diff --git a/apps/cli/src/commands/results/eval-runner.ts b/apps/cli/src/commands/results/eval-runner.ts index 91dd8386c..790b10092 100644 --- a/apps/cli/src/commands/results/eval-runner.ts +++ b/apps/cli/src/commands/results/eval-runner.ts @@ -105,6 +105,31 @@ interface RunEvalRequest { threshold?: number; workers?: number; dry_run?: boolean; + /** Resume an interrupted run: skip already-completed tests and append results to `output`. */ + resume?: boolean; + /** Re-run failed/errored tests while keeping passing results. */ + rerun_failed?: boolean; + /** Path to a previous run dir or index.jsonl — re-run only execution_error cases. */ + retry_errors?: string; + /** Artifact directory for run output. Required when resume/rerun_failed are set without auto-detect. */ + output?: string; +} + +/** + * Validate mutually-exclusive resume modes. + * Returns an error message if invalid, or undefined if valid. + */ +function validateResumeOptions(req: RunEvalRequest): string | undefined { + const modes: string[] = []; + if (req.resume) modes.push('resume'); + if (req.rerun_failed) modes.push('rerun_failed'); + if (req.retry_errors?.trim()) { + modes.push('retry_errors'); + } + if (modes.length > 1) { + return `resume, rerun_failed, and retry_errors are mutually exclusive (got: ${modes.join(', ')})`; + } + return undefined; } function buildCliArgs(req: RunEvalRequest): string[] { @@ -148,6 +173,20 @@ function buildCliArgs(req: RunEvalRequest): string[] { args.push('--dry-run'); } + // Resume / rerun-failed / retry-errors / output + if (req.output?.trim()) { + args.push('--output', req.output.trim()); + } + if (req.resume) { + args.push('--resume'); + } + if (req.rerun_failed) { + args.push('--rerun-failed'); + } + if (req.retry_errors?.trim()) { + args.push('--retry-errors', req.retry_errors.trim()); + } + return args; } @@ -255,6 +294,11 @@ export function registerEvalRoutes( return c.json({ error: 'Provide suite_filter or test_ids' }, 400); } + const resumeError = validateResumeOptions(body); + if (resumeError) { + return c.json({ error: resumeError }, 400); + } + const cliPaths = resolveCliPath(cwd); if (!cliPaths) { return c.json({ error: 'Cannot locate agentv CLI entry point' }, 500); @@ -405,6 +449,9 @@ export function registerEvalRoutes( }); app.post('/api/benchmarks/:benchmarkId/eval/run', async (c) => { + if (readOnly) { + return c.json({ error: 'Studio is running in read-only mode' }, 403); + } const cwd = getCwd(c); let body: RunEvalRequest; @@ -418,6 +465,11 @@ export function registerEvalRoutes( return c.json({ error: 'Provide suite_filter or test_ids' }, 400); } + const resumeError = validateResumeOptions(body); + if (resumeError) { + return c.json({ error: resumeError }, 400); + } + const cliPaths = resolveCliPath(cwd); if (!cliPaths) { return c.json({ error: 'Cannot locate agentv CLI entry point' }, 500); diff --git a/apps/cli/src/commands/results/serve.ts b/apps/cli/src/commands/results/serve.ts index acea1e5af..00ebaf3db 100644 --- a/apps/cli/src/commands/results/serve.ts +++ b/apps/cli/src/commands/results/serve.ts @@ -319,16 +319,55 @@ async function handleRunDetail(c: C, { searchDir }: DataContext) { if (!meta) return c.json({ error: 'Run not found' }, 404); try { const loaded = loadManifestResults(meta.path); + // Surface run_dir + suite_filter for local runs so the UI can launch a + // Studio-side resume against this exact run. Remote runs live in the + // results-repo cache and cannot be resumed in place, so omit both fields. + const resumeMeta = meta.source === 'local' ? deriveResumeMeta(searchDir, meta.path) : {}; return c.json({ results: stripHeavyFields(loaded), source: meta.source, source_label: meta.displayName, + ...resumeMeta, }); } catch { return c.json({ error: 'Failed to load run' }, 500); } } +/** + * Compute `run_dir` (relative to cwd, snake_case) and `suite_filter` (the + * eval file path stored in benchmark.json metadata) for a local run manifest. + * Returns whatever fields could be resolved — both are best-effort and only + * needed by the Studio "Resume run" / "Rerun failed" actions. + */ +function deriveResumeMeta( + cwd: string, + manifestPath: string, +): { run_dir?: string; suite_filter?: string } { + const out: { run_dir?: string; suite_filter?: string } = {}; + const runDir = path.dirname(manifestPath); + const relative = path.relative(cwd, runDir); + // path.relative returns '..'-prefixed paths when runDir is outside cwd; keep + // those absolute so the CLI doesn't get confused. An empty string ('' = same + // dir as cwd) is unusual but valid — fall through to absolute in that case. + out.run_dir = relative !== '' && !relative.startsWith('..') ? relative : runDir; + try { + const benchmarkPath = path.join(runDir, 'benchmark.json'); + if (existsSync(benchmarkPath)) { + const parsed = JSON.parse(readFileSync(benchmarkPath, 'utf8')) as { + metadata?: { eval_file?: string }; + }; + const evalFile = parsed.metadata?.eval_file; + if (typeof evalFile === 'string' && evalFile.trim()) { + out.suite_filter = evalFile.trim(); + } + } + } catch { + // benchmark.json missing / unreadable / malformed — leave suite_filter unset. + } + return out; +} + async function handleRunSuites(c: C, { searchDir, agentvDir }: DataContext) { const filename = c.req.param('filename') ?? ''; const meta = await findRunById(searchDir, filename); diff --git a/apps/cli/test/commands/results/serve.test.ts b/apps/cli/test/commands/results/serve.test.ts index 5c4117188..397f73167 100644 --- a/apps/cli/test/commands/results/serve.test.ts +++ b/apps/cli/test/commands/results/serve.test.ts @@ -871,4 +871,261 @@ describe('serve app', () => { expect(data.error).toBe('Not found'); }); }); + + // ── POST /api/eval/run — resume / rerun-failed / retry-errors ───────── + // + // These tests assert the launch endpoint accepts the resume-family fields, + // translates them to CLI flags in the `command` preview returned to the + // client, validates mutual exclusivity, and respects the read-only guard. + // They do not depend on the spawned child process — once the request is + // accepted and the command is built, we have validated the contract. + + describe('POST /api/eval/run (resume API)', () => { + function makeAppForRun(opts?: { readOnly?: boolean }) { + return createApp([], tempDir, undefined, undefined, { + studioDir, + readOnly: opts?.readOnly === true, + }); + } + + it('builds --resume + --output flags from the request', async () => { + const app = makeAppForRun(); + const res = await app.request('/api/eval/run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + suite_filter: 'examples/demo.eval.yaml', + target: 'gpt-4o', + output: '.agentv/results/runs/2026-05-06T00-00-00-000Z', + resume: true, + }), + }); + // Either 202 (spawn succeeded) or 500 (no CLI on disk in test env). + expect([202, 500]).toContain(res.status); + const data = (await res.json()) as { command?: string; error?: string }; + if (res.status === 202) { + expect(data.command).toContain('--resume'); + expect(data.command).toContain('--output .agentv/results/runs/2026-05-06T00-00-00-000Z'); + } + }); + + it('builds --rerun-failed + --output flags from the request', async () => { + const app = makeAppForRun(); + const res = await app.request('/api/eval/run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + suite_filter: 'examples/demo.eval.yaml', + target: 'gpt-4o', + output: 'runs/r1', + rerun_failed: true, + }), + }); + expect([202, 500]).toContain(res.status); + if (res.status === 202) { + const data = (await res.json()) as { command: string }; + expect(data.command).toContain('--rerun-failed'); + expect(data.command).toContain('--output runs/r1'); + } + }); + + it('builds --retry-errors from the request', async () => { + const app = makeAppForRun(); + const res = await app.request('/api/eval/run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + suite_filter: 'examples/demo.eval.yaml', + retry_errors: 'runs/r0/index.jsonl', + }), + }); + expect([202, 500]).toContain(res.status); + if (res.status === 202) { + const data = (await res.json()) as { command: string }; + expect(data.command).toContain('--retry-errors runs/r0/index.jsonl'); + } + }); + + it('rejects resume + rerun_failed combo with 400', async () => { + const app = makeAppForRun(); + const res = await app.request('/api/eval/run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + suite_filter: 'examples/demo.eval.yaml', + output: 'runs/r1', + resume: true, + rerun_failed: true, + }), + }); + expect(res.status).toBe(400); + const data = (await res.json()) as { error: string }; + expect(data.error).toContain('mutually exclusive'); + }); + + it('rejects resume + retry_errors combo with 400', async () => { + const app = makeAppForRun(); + const res = await app.request('/api/eval/run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + suite_filter: 'examples/demo.eval.yaml', + output: 'runs/r1', + resume: true, + retry_errors: 'runs/r0/index.jsonl', + }), + }); + expect(res.status).toBe(400); + }); + + it('returns 403 in read-only mode for unscoped /api/eval/run', async () => { + const app = makeAppForRun({ readOnly: true }); + const res = await app.request('/api/eval/run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + suite_filter: 'examples/demo.eval.yaml', + resume: true, + output: 'runs/r1', + }), + }); + expect(res.status).toBe(403); + }); + + it('returns 403 in read-only mode for benchmark-scoped /api/benchmarks/:id/eval/run', async () => { + const app = makeAppForRun({ readOnly: true }); + const res = await app.request('/api/benchmarks/some-id/eval/run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + suite_filter: 'examples/demo.eval.yaml', + resume: true, + output: 'runs/r1', + }), + }); + expect(res.status).toBe(403); + }); + }); + + // ── POST /api/eval/preview — argument shaping for resume flags ───────── + // + // /api/eval/preview is a lightweight endpoint that returns the CLI + // command without spawning anything. Use it to assert the exact CLI + // surface produced by the new fields independent of test-host CLI state. + + describe('POST /api/eval/preview (resume API)', () => { + it('emits --resume and --output for resume:true requests', async () => { + const app = createApp([], tempDir, undefined, undefined, { studioDir }); + const res = await app.request('/api/eval/preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + suite_filter: 'examples/demo.eval.yaml', + target: 'gpt-4o', + output: 'runs/r1', + resume: true, + }), + }); + expect(res.status).toBe(200); + const data = (await res.json()) as { command: string }; + expect(data.command).toContain('--resume'); + expect(data.command).toContain('--output runs/r1'); + expect(data.command).not.toContain('--rerun-failed'); + }); + + it('emits --rerun-failed for rerun_failed:true requests', async () => { + const app = createApp([], tempDir, undefined, undefined, { studioDir }); + const res = await app.request('/api/eval/preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + suite_filter: 'examples/demo.eval.yaml', + output: 'runs/r1', + rerun_failed: true, + }), + }); + expect(res.status).toBe(200); + const data = (await res.json()) as { command: string }; + expect(data.command).toContain('--rerun-failed'); + expect(data.command).not.toContain('--resume'); + }); + + it('emits --retry-errors for retry_errors requests', async () => { + const app = createApp([], tempDir, undefined, undefined, { studioDir }); + const res = await app.request('/api/eval/preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + suite_filter: 'examples/demo.eval.yaml', + retry_errors: 'runs/r0/index.jsonl', + }), + }); + expect(res.status).toBe(200); + const data = (await res.json()) as { command: string }; + expect(data.command).toContain('--retry-errors runs/r0/index.jsonl'); + }); + }); + + // ── GET /api/runs/:filename — run_dir + suite_filter for resume UI ───── + // + // The Studio "Resume run" / "Rerun failed cases" buttons need the run dir + // and the original eval file path to issue a launch request that targets + // the same run workspace. handleRunDetail reads benchmark.json's + // metadata.eval_file and reports the run dir relative to cwd. + + describe('GET /api/runs/:filename (resume metadata)', () => { + it('includes run_dir and suite_filter for local runs with benchmark.json', async () => { + const runsDir = path.join(tempDir, '.agentv', 'results', 'runs'); + mkdirSync(runsDir, { recursive: true }); + const filename = '2026-05-06T00-00-00-000Z'; + const runDir = path.join(runsDir, filename); + mkdirSync(runDir, { recursive: true }); + writeFileSync(path.join(runDir, 'index.jsonl'), toJsonl(RESULT_A)); + writeFileSync( + path.join(runDir, 'benchmark.json'), + JSON.stringify( + { + metadata: { + eval_file: 'examples/demo.eval.yaml', + timestamp: '2026-05-06T00:00:00.000Z', + targets: ['gpt-4o'], + tests_run: ['test-greeting'], + }, + run_summary: {}, + notes: [], + }, + null, + 2, + ), + ); + + const app = createApp([], tempDir, tempDir, undefined, { studioDir }); + const res = await app.request(`/api/runs/${filename}`); + expect(res.status).toBe(200); + const data = (await res.json()) as { + run_dir?: string; + suite_filter?: string; + source: 'local' | 'remote'; + }; + expect(data.source).toBe('local'); + expect(data.run_dir).toBe(path.join('.agentv', 'results', 'runs', filename)); + expect(data.suite_filter).toBe('examples/demo.eval.yaml'); + }); + + it('omits suite_filter when benchmark.json is missing', async () => { + const runsDir = path.join(tempDir, '.agentv', 'results', 'runs'); + mkdirSync(runsDir, { recursive: true }); + const filename = '2026-05-06T00-00-01-000Z'; + const runDir = path.join(runsDir, filename); + mkdirSync(runDir, { recursive: true }); + writeFileSync(path.join(runDir, 'index.jsonl'), toJsonl(RESULT_A)); + + const app = createApp([], tempDir, tempDir, undefined, { studioDir }); + const res = await app.request(`/api/runs/${filename}`); + expect(res.status).toBe(200); + const data = (await res.json()) as { run_dir?: string; suite_filter?: string }; + expect(data.run_dir).toBeDefined(); + expect(data.suite_filter).toBeUndefined(); + }); + }); }); diff --git a/apps/studio/src/components/ResumeRunActions.tsx b/apps/studio/src/components/ResumeRunActions.tsx new file mode 100644 index 000000000..d161c9d22 --- /dev/null +++ b/apps/studio/src/components/ResumeRunActions.tsx @@ -0,0 +1,106 @@ +/** + * ResumeRunActions — header buttons for resuming an interrupted run. + * + * Surfaces the existing CLI resume mechanics (`--resume`, `--rerun-failed`) + * via the launch endpoint when the loaded run contains at least one result + * with `executionStatus === 'execution_error'`. Hidden in read-only mode + * (the server also returns 403, but UI-level hiding avoids dead controls). + * + * On click, POSTs to /api/eval/run with `{ resume | rerun_failed: true, + * output: , suite_filter, target }` and navigates to /jobs/:runId + * to surface progress. + * + * To extend with another resume verb (e.g. surfacing --retry-errors as a + * cross-run picker), add a third button calling `launch({ retryErrors: + * })`. The launch helper already passes whichever fields are set + * straight through to the server, which forwards them to the CLI. + */ + +import { useNavigate } from '@tanstack/react-router'; +import { useState } from 'react'; + +import { launchEvalRun } from '~/lib/api'; +import type { EvalResult } from '~/lib/types'; + +import { + type ResumeMode, + buildResumeRequestBody, + shouldShowResumeActions, +} from './resume-run-helpers'; + +export interface ResumeRunActionsProps { + results: EvalResult[]; + runDir?: string; + suiteFilter?: string; + target?: string; + benchmarkId?: string; + isReadOnly: boolean; +} + +export function ResumeRunActions({ + results, + runDir, + suiteFilter, + target, + benchmarkId, + isReadOnly, +}: ResumeRunActionsProps) { + const navigate = useNavigate(); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + + if (!shouldShowResumeActions(results, isReadOnly)) return null; + + // Both actions need the run dir + the original eval file. Without those + // we can't target the existing run workspace, so we render the buttons + // disabled with an explanatory title rather than hiding them — that way + // users can still see the affordance and understand why it's unavailable. + const ready = !!runDir && !!suiteFilter; + const disabledReason = !runDir + ? 'Run directory unavailable (remote run cannot be resumed in place)' + : !suiteFilter + ? 'Original eval file path missing from benchmark.json — cannot determine what to resume' + : ''; + + async function launch(mode: ResumeMode) { + if (!ready || !runDir || !suiteFilter) return; + setBusy(mode); + setError(null); + try { + const body = buildResumeRequestBody({ mode, runDir, suiteFilter, target }); + const response = await launchEvalRun(body, benchmarkId); + navigate({ to: '/jobs/$runId', params: { runId: response.id } }); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to launch resume'); + setBusy(null); + } + } + + return ( +
+
+ + +
+ {error &&

{error}

} +
+ ); +} diff --git a/apps/studio/src/components/resume-run-helpers.test.ts b/apps/studio/src/components/resume-run-helpers.test.ts new file mode 100644 index 000000000..39e2d807c --- /dev/null +++ b/apps/studio/src/components/resume-run-helpers.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'bun:test'; + +import type { EvalResult } from '~/lib/types'; + +import { buildResumeRequestBody, shouldShowResumeActions } from './resume-run-helpers'; + +const ok = (testId: string): EvalResult => ({ + testId, + score: 1, + executionStatus: 'ok', +}); + +const errored = (testId: string): EvalResult => ({ + testId, + score: 0, + executionStatus: 'execution_error', +}); + +describe('shouldShowResumeActions', () => { + it('hides when no row has executionStatus=execution_error', () => { + expect(shouldShowResumeActions([ok('a'), ok('b')], false)).toBe(false); + }); + + it('shows when at least one row has executionStatus=execution_error', () => { + expect(shouldShowResumeActions([ok('a'), errored('b')], false)).toBe(true); + }); + + it('hides in read-only mode even when execution errors are present', () => { + expect(shouldShowResumeActions([errored('a')], true)).toBe(false); + }); + + it('hides on empty results', () => { + expect(shouldShowResumeActions([], false)).toBe(false); + }); +}); + +describe('buildResumeRequestBody', () => { + it('builds a resume request with snake_case fields and resume:true', () => { + expect( + buildResumeRequestBody({ + mode: 'resume', + runDir: '.agentv/results/runs/2026-05-06T00-00-00-000Z', + suiteFilter: 'examples/demo.eval.yaml', + target: 'gpt-4o', + }), + ).toEqual({ + suite_filter: 'examples/demo.eval.yaml', + output: '.agentv/results/runs/2026-05-06T00-00-00-000Z', + target: 'gpt-4o', + resume: true, + }); + }); + + it('builds a rerun-failed request with rerun_failed:true (and no resume key)', () => { + const body = buildResumeRequestBody({ + mode: 'rerun', + runDir: 'runs/r1', + suiteFilter: 'examples/demo.eval.yaml', + target: 'gpt-4o', + }); + expect(body).toEqual({ + suite_filter: 'examples/demo.eval.yaml', + output: 'runs/r1', + target: 'gpt-4o', + rerun_failed: true, + }); + expect(body.resume).toBeUndefined(); + }); + + it('omits target when none is provided', () => { + expect( + buildResumeRequestBody({ + mode: 'resume', + runDir: 'runs/r1', + suiteFilter: 'examples/demo.eval.yaml', + }), + ).toEqual({ + suite_filter: 'examples/demo.eval.yaml', + output: 'runs/r1', + resume: true, + }); + }); +}); diff --git a/apps/studio/src/components/resume-run-helpers.ts b/apps/studio/src/components/resume-run-helpers.ts new file mode 100644 index 000000000..fcbf805a0 --- /dev/null +++ b/apps/studio/src/components/resume-run-helpers.ts @@ -0,0 +1,51 @@ +/** + * Pure helpers backing ResumeRunActions, isolated for unit testing. + * + * These are intentionally side-effect-free: visibility logic and request-body + * shaping live here so tests can pin the API contract without rendering React. + * + * To extend: add another `mode` to `ResumeMode` and handle it inside + * `buildResumeRequestBody` — the server already accepts the union of resume, + * rerun_failed, and retry_errors per the launch endpoint contract. + */ + +import type { EvalResult, RunEvalRequest } from '~/lib/types'; + +export type ResumeMode = 'resume' | 'rerun'; + +export interface BuildResumeRequestParams { + mode: ResumeMode; + runDir: string; + suiteFilter: string; + target?: string; +} + +/** + * Whether the resume actions should be visible. The button only makes sense + * when at least one row failed with an execution error and the user has + * write access (read-only mode hides the entire control rather than + * showing a disabled button — see issue acceptance criteria). + */ +export function shouldShowResumeActions(results: EvalResult[], isReadOnly: boolean): boolean { + if (isReadOnly) return false; + return results.some((r) => r.executionStatus === 'execution_error'); +} + +/** + * Build the POST /api/eval/run body for a resume / rerun-failed launch. + * Matches the wire-format contract: snake_case, with `output` pointing at + * the existing run dir so the CLI appends to that workspace. + */ +export function buildResumeRequestBody(params: BuildResumeRequestParams): RunEvalRequest { + const body: RunEvalRequest = { + suite_filter: params.suiteFilter, + output: params.runDir, + }; + if (params.target) body.target = params.target; + if (params.mode === 'resume') { + body.resume = true; + } else { + body.rerun_failed = true; + } + return body; +} diff --git a/apps/studio/src/lib/types.ts b/apps/studio/src/lib/types.ts index 6ca189340..6e2c1a6ff 100644 --- a/apps/studio/src/lib/types.ts +++ b/apps/studio/src/lib/types.ts @@ -76,6 +76,10 @@ export interface RunDetailResponse { results: EvalResult[]; source: 'local' | 'remote'; source_label?: string; + /** Path to the run workspace directory (relative to cwd when inside, otherwise absolute). Local runs only. */ + run_dir?: string; + /** Eval file path the run was launched against, if recorded in benchmark.json. Local runs only. */ + suite_filter?: string; } export interface SuiteSummary { @@ -301,6 +305,14 @@ export interface RunEvalRequest { threshold?: number; workers?: number; dry_run?: boolean; + /** Resume an interrupted run: skip already-completed tests and append to `output`. */ + resume?: boolean; + /** Re-run failed/errored tests while keeping passing results. */ + rerun_failed?: boolean; + /** Path to a previous run dir or index.jsonl — re-run only execution_error cases. */ + retry_errors?: string; + /** Artifact directory for run output — required to target an existing run dir. */ + output?: string; } export interface EvalRunResponse { diff --git a/apps/studio/src/routes/benchmarks/$benchmarkId_/runs/$runId.tsx b/apps/studio/src/routes/benchmarks/$benchmarkId_/runs/$runId.tsx index b584fe0e5..db0f28bd4 100644 --- a/apps/studio/src/routes/benchmarks/$benchmarkId_/runs/$runId.tsx +++ b/apps/studio/src/routes/benchmarks/$benchmarkId_/runs/$runId.tsx @@ -5,6 +5,7 @@ import { createFileRoute } from '@tanstack/react-router'; import { useState } from 'react'; +import { ResumeRunActions } from '~/components/ResumeRunActions'; import { RunDetail } from '~/components/RunDetail'; import { RunEvalModal } from '~/components/RunEvalModal'; import { useBenchmarkRunDetail, useStudioConfig } from '~/lib/api'; @@ -68,15 +69,25 @@ function BenchmarkRunDetailPage() {

{heading}

{meta}

- {!isReadOnly && ( - - )} +
+ + {!isReadOnly && ( + + )} +
{!isReadOnly && ( diff --git a/apps/studio/src/routes/runs/$runId.tsx b/apps/studio/src/routes/runs/$runId.tsx index d500cadef..da7311637 100644 --- a/apps/studio/src/routes/runs/$runId.tsx +++ b/apps/studio/src/routes/runs/$runId.tsx @@ -5,6 +5,7 @@ import { createFileRoute } from '@tanstack/react-router'; import { useState } from 'react'; +import { ResumeRunActions } from '~/components/ResumeRunActions'; import { RunDetail } from '~/components/RunDetail'; import { RunEvalModal } from '~/components/RunEvalModal'; import { useRunDetail, useStudioConfig } from '~/lib/api'; @@ -69,15 +70,24 @@ function RunDetailPage() {

{heading}

{meta}

- {!isReadOnly && ( - - )} +
+ + {!isReadOnly && ( + + )} +
{!isReadOnly && (