From f2d917a6de42d80b1ed79b325ebbf5b1c7f68535 Mon Sep 17 00:00:00 2001 From: Christopher Date: Thu, 21 May 2026 16:34:15 +1000 Subject: [PATCH] feat(results): append-only run index for fast Studio list view (#1259 P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds index/runs.jsonl to the results repo so Studio's /api/runs reads ONE file instead of readdir+statSync+loadResultFile per run (O(N) → O(1)). Changes: - RunIndexEntry interface (snake_case wire format): run_id, timestamp, experiment, target, test_count, passed, pass_rate, avg_score, size_bytes, tags, sha - appendToRunIndex / readRunIndex helpers in packages/core - directPushResults: writes index entry on each push; backfills commit sha via --amend after the initial commit - reindexResultsRepo: rebuilds index from scratch for migration - listMergedResultFiles: reads index/runs.jsonl first; falls back to directory walk for older repos without an index - agentv results reindex: CLI command to backfill existing repos (--dry-run) Co-Authored-By: Claude Sonnet 4.6 --- apps/cli/src/commands/inspect/utils.ts | 2 +- apps/cli/src/commands/results/index.ts | 2 + apps/cli/src/commands/results/reindex.ts | 123 ++++++++++++++++ apps/cli/src/commands/results/remote.ts | 95 ++++++++++-- apps/cli/test/commands/results/remote.test.ts | 72 +++++++++ packages/core/src/evaluation/results-repo.ts | 137 +++++++++++++++++- packages/core/src/index.ts | 4 + .../core/test/evaluation/run-index.test.ts | 118 +++++++++++++++ 8 files changed, 542 insertions(+), 11 deletions(-) create mode 100644 apps/cli/src/commands/results/reindex.ts create mode 100644 apps/cli/test/commands/results/remote.test.ts create mode 100644 packages/core/test/evaluation/run-index.test.ts diff --git a/apps/cli/src/commands/inspect/utils.ts b/apps/cli/src/commands/inspect/utils.ts index 3592d34e8..6b60e8a25 100644 --- a/apps/cli/src/commands/inspect/utils.ts +++ b/apps/cli/src/commands/inspect/utils.ts @@ -531,7 +531,7 @@ export interface ResultFileMeta { sizeBytes: number; } -function buildRunId(relativeRunPath: string): string { +export function buildRunId(relativeRunPath: string): string { const normalized = relativeRunPath.split(path.sep).join('/'); const segments = normalized.split('/').filter(Boolean); if (segments.length >= 2) { diff --git a/apps/cli/src/commands/results/index.ts b/apps/cli/src/commands/results/index.ts index 5dc90626a..0eb22f300 100644 --- a/apps/cli/src/commands/results/index.ts +++ b/apps/cli/src/commands/results/index.ts @@ -2,6 +2,7 @@ import { subcommands } from 'cmd-ts'; import { resultsExportCommand } from './export.js'; import { resultsFailuresCommand } from './failures.js'; +import { resultsReindexCommand } from './reindex.js'; import { resultsReportCommand } from './report.js'; import { resultsShowCommand } from './show.js'; import { resultsSummaryCommand } from './summary.js'; @@ -17,5 +18,6 @@ export const resultsCommand = subcommands({ failures: resultsFailuresCommand, show: resultsShowCommand, validate: resultsValidateCommand, + reindex: resultsReindexCommand, }, }); diff --git a/apps/cli/src/commands/results/reindex.ts b/apps/cli/src/commands/results/reindex.ts new file mode 100644 index 000000000..28ca4b5d3 --- /dev/null +++ b/apps/cli/src/commands/results/reindex.ts @@ -0,0 +1,123 @@ +/** + * `agentv results reindex` — rebuild index/runs.jsonl from the existing run tree. + * + * Use this once to backfill the index after upgrading an existing results repo. + * After the first push following the upgrade, new runs are appended automatically. + * + * How it works: + * 1. Fetch/pull the latest state of the results repo. + * 2. Walk all run directories via listResultFilesFromRunsDir. + * 3. Read each run's first JSONL result to extract target/experiment. + * 4. Write a complete index/runs.jsonl and commit+push it. + */ + +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +import { command, flag, option, optional, string } from 'cmd-ts'; + +import { + type ResultsConfig, + type RunIndexEntry, + loadConfig, + normalizeResultsConfig, + reindexResultsRepo, + resolveResultsRepoRunsDir, +} from '@agentv/core'; + +import { findRepoRoot } from '../eval/shared.js'; +import { listResultFilesFromRunsDir } from '../inspect/utils.js'; + +async function loadNormalizedResultsConfig( + cwd: string, +): Promise | undefined> { + const repoRoot = (await findRepoRoot(cwd)) ?? cwd; + const config = await loadConfig(path.join(cwd, '_'), repoRoot); + if (!config?.results) return undefined; + return normalizeResultsConfig(config.results); +} + +export const resultsReindexCommand = command({ + name: 'reindex', + description: + 'Backfill index/runs.jsonl in the results repo from the existing run tree (migration helper)', + args: { + dir: option({ + type: optional(string), + long: 'dir', + short: 'd', + description: 'Working directory (default: current directory)', + }), + dryRun: flag({ + long: 'dry-run', + description: 'Print the entries that would be written without committing', + }), + }, + handler: async ({ dir, dryRun }) => { + const cwd = dir ?? process.cwd(); + const config = await loadNormalizedResultsConfig(cwd); + if (!config) { + console.error( + 'Error: No results repo configured. Add a results section to .agentv/config.yaml', + ); + process.exit(1); + } + + const runsDir = resolveResultsRepoRunsDir(config); + console.log(`Scanning runs from ${runsDir}…`); + const metas = listResultFilesFromRunsDir(runsDir); + + const entries: RunIndexEntry[] = []; + + for (const meta of metas) { + let target = ''; + const sepIdx = meta.filename.indexOf('::'); + let experiment = sepIdx === -1 ? 'default' : meta.filename.slice(0, sepIdx); + + try { + const content = readFileSync(meta.path, 'utf8'); + const firstLine = content.split('\n').find((l) => l.trim()); + if (firstLine) { + const first = JSON.parse(firstLine) as { + target?: string; + experiment?: string; + }; + if (first.target) target = first.target; + if (first.experiment) experiment = first.experiment; + } + } catch { + // skip unreadable manifests + } + + const passed = Math.round(meta.passRate * meta.testCount); + + entries.push({ + run_id: meta.filename, + timestamp: meta.timestamp, + experiment, + target, + test_count: meta.testCount, + passed, + pass_rate: meta.passRate, + avg_score: meta.avgScore, + size_bytes: meta.sizeBytes, + tags: [], + }); + } + + if (dryRun) { + console.log(`Would write ${entries.length} entries to index/runs.jsonl:`); + for (const e of entries) { + console.log(` ${e.run_id} (${e.test_count} tests, pass_rate=${e.pass_rate.toFixed(2)})`); + } + return; + } + + const written = await reindexResultsRepo({ config, entries }); + if (written === 0) { + console.log('Index is already up to date — no changes committed.'); + } else { + console.log(`Reindexed ${written} runs and pushed index/runs.jsonl to ${config.repo}.`); + } + }, +}); diff --git a/apps/cli/src/commands/results/remote.ts b/apps/cli/src/commands/results/remote.ts index 2fcc4a7e4..0a2116110 100644 --- a/apps/cli/src/commands/results/remote.ts +++ b/apps/cli/src/commands/results/remote.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs'; import path from 'node:path'; import { @@ -5,17 +6,22 @@ import { type EvaluationResult, type ResultsConfig, type ResultsRepoStatus, + type RunIndexEntry, directPushResults, directorySizeBytes, + getResultsRepoCachePaths, getResultsRepoStatus, loadConfig, + readRunIndex, resolveResultsRepoRunsDir, syncResultsRepo, } from '@agentv/core'; +import { RESULT_INDEX_FILENAME } from '../eval/result-layout.js'; import { findRepoRoot } from '../eval/shared.js'; import { type ResultFileMeta, + buildRunId, listResultFiles, listResultFilesFromRunsDir, } from '../inspect/utils.js'; @@ -128,6 +134,49 @@ export function decodeRemoteRunId(filename: string): string { return filename.replace(REMOTE_RUN_PREFIX, ''); } +/** + * Reconstruct the filesystem manifest path from a run_id and the runs directory. + * Inverse of buildRunId: "experiment::timestamp" → runsDir/experiment/timestamp/index.jsonl + * Default experiment: "timestamp" → runsDir/default/timestamp/index.jsonl + */ +function runIdToManifestPath(runId: string, runsDir: string): string { + const sepIdx = runId.indexOf('::'); + const relPath = + sepIdx === -1 + ? path.join('default', runId) + : path.join(runId.slice(0, sepIdx), runId.slice(sepIdx + 2)); + return path.join(runsDir, relPath, RESULT_INDEX_FILENAME); +} + +/** + * Read remote runs from the index file. Returns null if index doesn't exist (triggers fallback). + */ +function listRemoteRunsFromIndex( + repoDir: string, + config: Required, +): SourcedResultFileMeta[] | null { + const indexFile = path.join(repoDir, 'index', 'runs.jsonl'); + if (!existsSync(indexFile)) return null; + + const runsDir = resolveResultsRepoRunsDir(config); + const entries = readRunIndex(indexFile); + + return entries.map((entry) => ({ + path: runIdToManifestPath(entry.run_id, runsDir), + filename: encodeRemoteRunId(entry.run_id), + raw_filename: entry.run_id, + displayName: entry.run_id.includes('::') + ? (entry.run_id.split('::').at(-1) ?? entry.run_id) + : entry.run_id, + timestamp: entry.timestamp, + testCount: entry.test_count, + passRate: entry.pass_rate, + avgScore: entry.avg_score, + sizeBytes: entry.size_bytes, + source: 'remote' as const, + })); +} + export async function getRemoteResultsStatus(cwd: string): Promise { const config = await loadNormalizedResultsConfig(cwd); const status = getResultsRepoStatus(config); @@ -185,15 +234,20 @@ export async function listMergedResultFiles( }; } - const remoteRuns = listResultFilesFromRunsDir(resolveResultsRepoRunsDir(config)).map( - (meta) => - ({ - ...meta, - filename: encodeRemoteRunId(meta.filename), - raw_filename: meta.filename, - source: 'remote' as const, - }) satisfies SourcedResultFileMeta, - ); + const repoDir = getResultsRepoCachePaths(config.repo).repoDir; + + // Prefer index for O(1) listing; fall back to directory walk for repos without an index. + const remoteRuns = + listRemoteRunsFromIndex(repoDir, config) ?? + listResultFilesFromRunsDir(resolveResultsRepoRunsDir(config)).map( + (meta) => + ({ + ...meta, + filename: encodeRemoteRunId(meta.filename), + raw_filename: meta.filename, + source: 'remote' as const, + }) satisfies SourcedResultFileMeta, + ); const merged = [...localRuns, ...remoteRuns].sort((a, b) => b.timestamp.localeCompare(a.timestamp), @@ -223,12 +277,35 @@ export async function maybeAutoExportRunArtifacts(payload: RemoteExportPayload): const relativeRunPath = getRelativeRunPath(payload.cwd, payload.run_dir); const commitTitle = buildCommitTitle(payload); + const runId = buildRunId(relativeRunPath); + const results = payload.results; + const passed = results.filter((r) => r.score >= DEFAULT_THRESHOLD).length; + const testCount = results.length; + const avgScore = testCount > 0 ? results.reduce((sum, r) => sum + r.score, 0) / testCount : 0; + const passRate = testCount > 0 ? passed / testCount : 0; + const experiment = payload.experiment ?? 'default'; + const target = results[0]?.target ?? ''; + const sizeBytes = await directorySizeBytes(payload.run_dir); + + const indexEntry: Omit = { + run_id: runId, + timestamp: results[0]?.timestamp ?? new Date().toISOString(), + experiment, + target, + test_count: testCount, + passed, + pass_rate: passRate, + avg_score: avgScore, + size_bytes: sizeBytes, + tags: [], + }; const pushed = await directPushResults({ config, sourceDir: payload.run_dir, destinationPath: relativeRunPath, commitMessage: commitTitle, + indexEntry, }); if (!pushed) { diff --git a/apps/cli/test/commands/results/remote.test.ts b/apps/cli/test/commands/results/remote.test.ts new file mode 100644 index 000000000..04010633a --- /dev/null +++ b/apps/cli/test/commands/results/remote.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { type RunIndexEntry, appendToRunIndex, readRunIndex } from '@agentv/core'; + +// We test the pure helper that maps index entries to SourcedResultFileMeta. +// Import the module under test, then poke at its internals via the public API. + +import { + decodeRemoteRunId, + encodeRemoteRunId, + isRemoteRunId, +} from '../../../src/commands/results/remote.js'; + +describe('encodeRemoteRunId / decodeRemoteRunId / isRemoteRunId', () => { + it('encodes a plain run id', () => { + expect(encodeRemoteRunId('2026-05-21T10-00-00-000Z')).toBe('remote::2026-05-21T10-00-00-000Z'); + }); + + it('decodes a remote-prefixed run id', () => { + expect(decodeRemoteRunId('remote::2026-05-21T10-00-00-000Z')).toBe('2026-05-21T10-00-00-000Z'); + }); + + it('identifies remote run ids', () => { + expect(isRemoteRunId('remote::2026-05-21T10-00-00-000Z')).toBe(true); + expect(isRemoteRunId('2026-05-21T10-00-00-000Z')).toBe(false); + }); +}); + +// ── Index fallback behaviour ───────────────────────────────────────────── + +describe('listRemoteRunsFromIndex fallback (via file system)', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'agentv-remote-')); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('index/runs.jsonl is absent → no crash (confirmed by readRunIndex returning [])', () => { + const indexFile = path.join(tmpDir, 'index', 'runs.jsonl'); + expect(readRunIndex(indexFile)).toEqual([]); + }); + + it('index/runs.jsonl present → entries parse correctly', () => { + const indexFile = path.join(tmpDir, 'index', 'runs.jsonl'); + const entry: RunIndexEntry = { + run_id: '2026-05-21T10-00-00-000Z', + timestamp: '2026-05-21T10:00:01.000Z', + experiment: 'default', + target: 'gpt-4o', + test_count: 5, + passed: 4, + pass_rate: 0.8, + avg_score: 0.85, + size_bytes: 12345, + tags: [], + }; + appendToRunIndex(indexFile, entry); + + const entries = readRunIndex(indexFile); + expect(entries).toHaveLength(1); + expect(entries[0]?.run_id).toBe('2026-05-21T10-00-00-000Z'); + expect(entries[0]?.target).toBe('gpt-4o'); + expect(entries[0]?.pass_rate).toBe(0.8); + }); +}); diff --git a/packages/core/src/evaluation/results-repo.ts b/packages/core/src/evaluation/results-repo.ts index 04419785f..8447a29a2 100644 --- a/packages/core/src/evaluation/results-repo.ts +++ b/packages/core/src/evaluation/results-repo.ts @@ -1,5 +1,12 @@ import { execFile } from 'node:child_process'; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { + appendFileSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; import { cp, mkdtemp, readdir, rm, stat } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -318,6 +325,64 @@ export function resolveResultsRepoRunsDir(config: ResultsConfig): string { ); } +// ── Run index ───────────────────────────────────────────────────────────── + +/** + * Wire format for a single run index entry. + * Stored as one JSON line in index/runs.jsonl inside the results repo. + * snake_case (wire format convention: everything crossing a process boundary uses snake_case). + */ +export interface RunIndexEntry { + run_id: string; + timestamp: string; + experiment: string; + target: string; + test_count: number; + passed: number; + pass_rate: number; + avg_score: number; + size_bytes: number; + tags: string[]; + sha?: string; +} + +/** Append one entry to the run index. Creates the file and parent dirs if absent. */ +export function appendToRunIndex(indexFile: string, entry: RunIndexEntry): void { + mkdirSync(path.dirname(indexFile), { recursive: true }); + appendFileSync(indexFile, `${JSON.stringify(entry)}\n`, 'utf8'); +} + +/** Read all entries from a run index file. Returns [] if the file doesn't exist. */ +export function readRunIndex(indexFile: string): RunIndexEntry[] { + if (!existsSync(indexFile)) return []; + const content = readFileSync(indexFile, 'utf8'); + const entries: RunIndexEntry[] = []; + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + entries.push(JSON.parse(trimmed) as RunIndexEntry); + } catch { + // skip malformed lines + } + } + return entries; +} + +function updateLastRunIndexEntrySha(indexFile: string, sha: string): void { + const content = readFileSync(indexFile, 'utf8'); + const lines = content.trimEnd().split('\n'); + const lastLine = lines.at(-1); + if (!lastLine) return; + try { + const entry = JSON.parse(lastLine) as RunIndexEntry; + lines[lines.length - 1] = JSON.stringify({ ...entry, sha }); + writeFileSync(indexFile, `${lines.join('\n')}\n`, 'utf8'); + } catch { + // skip on parse error + } +} + export async function directorySizeBytes(targetPath: string): Promise { const entry = await stat(targetPath); if (entry.isFile()) { @@ -400,6 +465,8 @@ const DIRECT_PUSH_MAX_RETRIES = 3; /** * Push results directly to the base branch of the results repo. * Handles non-fast-forward conflicts by pulling with rebase and retrying. + * If indexEntry is provided, appends a row to index/runs.jsonl and backfills + * the commit sha via an amend after the initial commit. * Returns true if artifacts were pushed, false if no changes were detected. */ export async function directPushResults(params: { @@ -407,6 +474,7 @@ export async function directPushResults(params: { readonly sourceDir: string; readonly destinationPath: string; readonly commitMessage: string; + readonly indexEntry?: Omit; }): Promise { const normalized = normalizeResultsConfig(params.config); const repoDir = await ensureResultsRepoClone(normalized); @@ -420,6 +488,11 @@ export async function directPushResults(params: { destinationDir, }); + if (params.indexEntry) { + const indexFile = path.join(repoDir, 'index', 'runs.jsonl'); + appendToRunIndex(indexFile, params.indexEntry); + } + await runGit(['add', '--all'], { cwd: repoDir }); const { stdout: status } = await runGit(['status', '--porcelain'], { cwd: repoDir, @@ -431,6 +504,14 @@ export async function directPushResults(params: { await runGit(['commit', '-m', params.commitMessage], { cwd: repoDir }); + if (params.indexEntry) { + const { stdout: sha } = await runGit(['rev-parse', 'HEAD'], { cwd: repoDir }); + const indexFile = path.join(repoDir, 'index', 'runs.jsonl'); + updateLastRunIndexEntrySha(indexFile, sha.trim()); + await runGit(['add', 'index/runs.jsonl'], { cwd: repoDir }); + await runGit(['commit', '--amend', '--no-edit'], { cwd: repoDir }); + } + for (let attempt = 1; attempt <= DIRECT_PUSH_MAX_RETRIES; attempt++) { try { await runGit(['push', 'origin', baseBranch], { cwd: repoDir }); @@ -451,3 +532,57 @@ export async function directPushResults(params: { return false; } + +/** + * Rebuild index/runs.jsonl from the provided entries and push to the results repo. + * Used by `agentv results reindex` to backfill the index for existing repos. + * Returns the number of entries written, or 0 if the index was already up to date. + */ +export async function reindexResultsRepo(params: { + readonly config: ResultsConfig; + readonly entries: readonly RunIndexEntry[]; +}): Promise { + const normalized = normalizeResultsConfig(params.config); + const repoDir = await ensureResultsRepoClone(normalized); + const baseBranch = await resolveDefaultBranch(repoDir); + await updateCacheRepo(repoDir, baseBranch); + + const indexFile = path.join(repoDir, 'index', 'runs.jsonl'); + mkdirSync(path.dirname(indexFile), { recursive: true }); + const content = + params.entries.length > 0 ? `${params.entries.map((e) => JSON.stringify(e)).join('\n')}\n` : ''; + writeFileSync(indexFile, content, 'utf8'); + + await runGit(['add', 'index/runs.jsonl'], { cwd: repoDir }); + const { stdout: statusOut } = await runGit(['status', '--porcelain'], { + cwd: repoDir, + check: false, + }); + if (statusOut.trim().length === 0) { + return 0; + } + + await runGit(['commit', '-m', `chore(index): reindex ${params.entries.length} runs`], { + cwd: repoDir, + }); + + for (let attempt = 1; attempt <= DIRECT_PUSH_MAX_RETRIES; attempt++) { + try { + await runGit(['push', 'origin', baseBranch], { cwd: repoDir }); + updateStatusFile(normalized, { + last_synced_at: new Date().toISOString(), + last_error: undefined, + }); + return params.entries.length; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (attempt < DIRECT_PUSH_MAX_RETRIES && message.includes('non-fast-forward')) { + await runGit(['pull', '--rebase', 'origin', baseBranch], { cwd: repoDir }); + } else { + throw error; + } + } + } + + return params.entries.length; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index aab188c8e..f8f96ede3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -74,10 +74,14 @@ export { pushResultsRepoBranch, createDraftResultsPr, directPushResults, + appendToRunIndex, + readRunIndex, + reindexResultsRepo, type CheckedOutResultsRepoBranch, type PreparedResultsRepoBranch, type ResultsRepoCachePaths, type ResultsRepoStatus, + type RunIndexEntry, } from './evaluation/results-repo.js'; export { getAgentvConfigDir, diff --git a/packages/core/test/evaluation/run-index.test.ts b/packages/core/test/evaluation/run-index.test.ts new file mode 100644 index 000000000..1c9b168f0 --- /dev/null +++ b/packages/core/test/evaluation/run-index.test.ts @@ -0,0 +1,118 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { appendToRunIndex, readRunIndex } from '../../src/evaluation/results-repo.js'; +import type { RunIndexEntry } from '../../src/evaluation/results-repo.js'; + +const ENTRY_A: RunIndexEntry = { + run_id: '2026-05-21T10-00-00-000Z', + timestamp: '2026-05-21T10:00:01.000Z', + experiment: 'default', + target: 'gpt-4o', + test_count: 5, + passed: 4, + pass_rate: 0.8, + avg_score: 0.85, + size_bytes: 12345, + tags: [], +}; + +const ENTRY_B: RunIndexEntry = { + run_id: 'myexp::2026-05-22T11-00-00-000Z', + timestamp: '2026-05-22T11:00:01.000Z', + experiment: 'myexp', + target: 'claude-3-5-sonnet-20241022', + test_count: 3, + passed: 3, + pass_rate: 1.0, + avg_score: 0.95, + size_bytes: 8000, + tags: ['regression'], + sha: 'abc123', +}; + +describe('appendToRunIndex', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'agentv-run-index-')); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('creates the file and parent dirs if absent', () => { + const indexFile = path.join(tmpDir, 'deep', 'index', 'runs.jsonl'); + appendToRunIndex(indexFile, ENTRY_A); + expect(existsSync(indexFile)).toBe(true); + }); + + it('writes a valid JSON line per entry', () => { + const indexFile = path.join(tmpDir, 'runs.jsonl'); + appendToRunIndex(indexFile, ENTRY_A); + const entries = readRunIndex(indexFile); + expect(entries).toHaveLength(1); + expect(entries[0]).toEqual(ENTRY_A); + }); + + it('appends successive entries without overwriting', () => { + const indexFile = path.join(tmpDir, 'runs.jsonl'); + appendToRunIndex(indexFile, ENTRY_A); + appendToRunIndex(indexFile, ENTRY_B); + const entries = readRunIndex(indexFile); + expect(entries).toHaveLength(2); + expect(entries[0]).toEqual(ENTRY_A); + expect(entries[1]).toEqual(ENTRY_B); + }); + + it('preserves optional sha field when present', () => { + const indexFile = path.join(tmpDir, 'runs.jsonl'); + appendToRunIndex(indexFile, ENTRY_B); + const entries = readRunIndex(indexFile); + expect(entries[0]?.sha).toBe('abc123'); + }); + + it('omits sha from JSON when not provided', () => { + const indexFile = path.join(tmpDir, 'runs.jsonl'); + appendToRunIndex(indexFile, ENTRY_A); // no sha + const entries = readRunIndex(indexFile); + expect(entries[0]?.sha).toBeUndefined(); + }); +}); + +describe('readRunIndex', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'agentv-run-index-')); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('returns empty array for missing file', () => { + const indexFile = path.join(tmpDir, 'nonexistent.jsonl'); + expect(readRunIndex(indexFile)).toEqual([]); + }); + + it('skips blank lines', () => { + const indexFile = path.join(tmpDir, 'runs.jsonl'); + appendToRunIndex(indexFile, ENTRY_A); + appendToRunIndex(indexFile, ENTRY_B); + const entries = readRunIndex(indexFile); + expect(entries).toHaveLength(2); + }); + + it('skips malformed lines without throwing', () => { + const indexFile = path.join(tmpDir, 'runs.jsonl'); + writeFileSync(indexFile, `${JSON.stringify(ENTRY_A)}\nnot-json\n${JSON.stringify(ENTRY_B)}\n`); + const entries = readRunIndex(indexFile); + expect(entries).toHaveLength(2); + expect(entries[0]).toEqual(ENTRY_A); + expect(entries[1]).toEqual(ENTRY_B); + }); +});