diff --git a/apps/cli/src/commands/results/remote.ts b/apps/cli/src/commands/results/remote.ts index 756f5d2c4..9bc65aefb 100644 --- a/apps/cli/src/commands/results/remote.ts +++ b/apps/cli/src/commands/results/remote.ts @@ -6,9 +6,11 @@ import { type EvaluationResult, type GitListedRun, type ResultsConfig, + ResultsRepoRunExistsError, type ResultsRepoStatus, directPushResults, directorySizeBytes, + findResultsRepoRun, getProject, getProjectForPath, getResultsRepoSyncStatus, @@ -27,6 +29,7 @@ import { listResultFiles, listResultFilesFromRunsDir, } from '../inspect/utils.js'; +import { loadManifestResults } from './manifest.js'; import { type RemoteRunTagState, assertWritableResultsRepo, @@ -97,6 +100,33 @@ export interface RemoteResultsStatus extends ResultsRepoStatus { readonly run_count: number; } +export interface LocalRunPublishPreview { + readonly sourceRunId: string; + readonly targetRepo: string; + readonly targetPath: string; + readonly targetRunId: string; + readonly remoteExists: boolean; + readonly replaceRequired: boolean; + readonly canPublish: boolean; + readonly blockReason?: string; + readonly remoteStatus: RemoteResultsStatus; +} + +export interface LocalRunPublishResult extends LocalRunPublishPreview { + readonly published: boolean; + readonly replaced: boolean; +} + +export class LocalRunPublishError extends Error { + readonly status: 400 | 409; + + constructor(message: string, status: 400 | 409 = 400) { + super(message); + this.name = 'LocalRunPublishError'; + this.status = status; + } +} + const REMOTE_RUN_PREFIX = 'remote::'; const SIZE_WARNING_BYTES = 10 * 1024 * 1024; @@ -122,6 +152,136 @@ function getRelativeRunPath(cwd: string, runDir: string): string { return experiment && experiment !== runName ? path.join(experiment, runName) : runName; } +function toPosixPath(p: string): string { + return p.split(path.sep).join('/'); +} + +function getPublishStatusBlockReason(status: RemoteResultsStatus): string | undefined { + if (!status.configured) { + return 'Remote results repo is not configured for this project.'; + } + + if (status.sync_status === 'syncing') { + return 'Project sync is already in progress. Wait for it to finish before publishing a run.'; + } + + if (status.sync_status && status.sync_status !== 'clean') { + return 'Sync Project before publishing a selected run so pending result metadata is handled first.'; + } + + if (status.blocked) { + return status.block_reason ?? 'Remote results repo sync is blocked.'; + } + + return undefined; +} + +function getExperimentFromRunId(runId: string): string { + const separatorIndex = runId.lastIndexOf('::'); + return separatorIndex === -1 ? 'default' : runId.slice(0, separatorIndex); +} + +async function buildLocalRunPublishPreview(params: { + readonly cwd: string; + readonly config: Required; + readonly meta: Pick; + readonly projectId?: string; +}): Promise { + if (params.meta.source !== 'local') { + throw new LocalRunPublishError('Selected run publish is only available for local runs'); + } + + const runDir = path.dirname(params.meta.path); + const relativeRunPath = toPosixPath(getRelativeRunPath(params.cwd, runDir)); + const targetPath = path.posix.join('.agentv/results/runs', relativeRunPath); + const existingRun = await findResultsRepoRun(params.config, params.meta.raw_filename); + const remoteStatus = await getRemoteResultsStatus(params.cwd, params.projectId); + const blockReason = getPublishStatusBlockReason(remoteStatus); + const remoteExists = existingRun !== undefined; + + return { + sourceRunId: params.meta.raw_filename, + targetRepo: params.config.repo, + targetPath, + targetRunId: params.meta.raw_filename, + remoteExists, + replaceRequired: remoteExists, + canPublish: !blockReason && !remoteExists, + ...(blockReason && { blockReason }), + remoteStatus, + relativeRunPath, + }; +} + +export async function previewLocalRunPublish( + cwd: string, + meta: Pick, + projectId?: string, +): Promise { + const config = await loadNormalizedResultsConfig(cwd, projectId); + if (!config) { + throw new LocalRunPublishError('Remote results repo is not configured for this project', 409); + } + + const { relativeRunPath: _relativeRunPath, ...preview } = await buildLocalRunPublishPreview({ + cwd, + config, + meta, + projectId, + }); + return preview; +} + +export async function publishLocalRun(params: { + readonly cwd: string; + readonly meta: Pick; + readonly projectId?: string; + readonly replace?: boolean; +}): Promise { + const config = await loadNormalizedResultsConfig(params.cwd, params.projectId); + if (!config) { + throw new LocalRunPublishError('Remote results repo is not configured for this project', 409); + } + + const preview = await buildLocalRunPublishPreview({ + cwd: params.cwd, + config, + meta: params.meta, + projectId: params.projectId, + }); + if (preview.blockReason) { + throw new LocalRunPublishError(preview.blockReason, 409); + } + if (preview.remoteExists && params.replace !== true) { + throw new ResultsRepoRunExistsError(preview.targetRunId, preview.relativeRunPath); + } + + await maybeWarnLargeArtifact(path.dirname(params.meta.path)); + const results = loadManifestResults(params.meta.path); + const pushed = await directPushResults({ + config, + sourceDir: path.dirname(params.meta.path), + destinationPath: preview.relativeRunPath, + commitMessage: buildCommitTitle({ + cwd: params.cwd, + run_dir: path.dirname(params.meta.path), + test_files: [], + results, + eval_summaries: [], + experiment: getExperimentFromRunId(preview.targetRunId), + }), + replaceExisting: params.replace === true, + }); + + invalidateGitRunsCache(config.path); + const refreshed = await previewLocalRunPublish(params.cwd, params.meta, params.projectId); + return { + ...refreshed, + published: pushed, + replaced: preview.remoteExists && params.replace === true, + }; +} + function buildCommitTitle(payload: RemoteExportPayload): string { const passed = payload.results.filter((result) => result.score >= DEFAULT_THRESHOLD).length; const avgScore = diff --git a/apps/cli/src/commands/results/serve.ts b/apps/cli/src/commands/results/serve.ts index 7cbc61b89..2aff716ea 100644 --- a/apps/cli/src/commands/results/serve.ts +++ b/apps/cli/src/commands/results/serve.ts @@ -40,6 +40,7 @@ import { command, flag, number, option, optional, positional, string } from 'cmd import { DEFAULT_CATEGORY, type EvaluationResult, + ResultsRepoRunExistsError, addProject, getProject, loadConfig, @@ -72,12 +73,16 @@ import { resolveResultSourcePath, } from './manifest.js'; import { + LocalRunPublishError, + type LocalRunPublishPreview, type SourcedResultFileMeta, clearRemoteRunTags, ensureRemoteRunAvailable, findRunById, getRemoteResultsStatus, listMergedResultFiles, + previewLocalRunPublish, + publishLocalRun, readRemoteRunTagState, setRemoteRunTags, syncRemoteResults, @@ -362,6 +367,34 @@ function remoteMetadataErrorStatus(error: unknown): 400 | 409 { return 400; } +function localRunPublishToWire( + preview: LocalRunPublishPreview & { published?: boolean; replaced?: boolean }, +) { + return { + source_run_id: preview.sourceRunId, + target_repo: preview.targetRepo, + target_path: preview.targetPath, + target_run_id: preview.targetRunId, + remote_exists: preview.remoteExists, + replace_required: preview.replaceRequired, + can_publish: preview.canPublish, + ...(preview.blockReason && { block_reason: preview.blockReason }), + remote_status: preview.remoteStatus, + ...(preview.published !== undefined && { published: preview.published }), + ...(preview.replaced !== undefined && { replaced: preview.replaced }), + }; +} + +function localRunPublishErrorStatus(error: unknown): 400 | 409 | 500 { + if (error instanceof LocalRunPublishError) { + return error.status; + } + if (error instanceof ResultsRepoRunExistsError) { + return 409; + } + return 500; +} + async function ensureRunReadable( searchDir: string, meta: SourcedResultFileMeta, @@ -1193,9 +1226,10 @@ function getLocalRunsRoot(searchDir: string): string { function validateLocalCompletedRun( searchDir: string, meta: SourcedResultFileMeta, + actionName = 'Run combine', ): { ok: true } | { error: string; status: 400 | 409 } { if (meta.source === 'remote') { - return { error: 'Run combine is only available for local runs', status: 400 }; + return { error: `${actionName} is only available for local runs`, status: 400 }; } if (getActiveRunStatus(meta.path) === 'starting' || getActiveRunStatus(meta.path) === 'running') { return { error: 'Run is still active', status: 409 }; @@ -1214,6 +1248,52 @@ function validateLocalCompletedRun( return { error: 'Run workspace is outside the local results directory', status: 400 }; } +async function handleRunPublishPreview(c: C, { searchDir, projectId }: DataContext) { + const filename = c.req.param('filename') ?? ''; + const meta = await findRunById(searchDir, filename, projectId); + if (!meta) return c.json({ error: 'Run not found' }, 404); + const safe = validateLocalCompletedRun(searchDir, meta, 'Selected run publish'); + if ('error' in safe) return c.json({ error: safe.error }, safe.status); + + try { + const preview = await previewLocalRunPublish(searchDir, meta, projectId); + return c.json(localRunPublishToWire(preview)); + } catch (err) { + return c.json({ error: (err as Error).message }, localRunPublishErrorStatus(err)); + } +} + +async function handleRunPublish(c: C, { searchDir, projectId }: DataContext) { + const filename = c.req.param('filename') ?? ''; + const meta = await findRunById(searchDir, filename, projectId); + if (!meta) return c.json({ error: 'Run not found' }, 404); + const safe = validateLocalCompletedRun(searchDir, meta, 'Selected run publish'); + if ('error' in safe) return c.json({ error: safe.error }, safe.status); + + let replace = false; + try { + const body = (await c.req.json()) as unknown; + if (body && typeof body === 'object' && !Array.isArray(body)) { + const value = (body as Record).replace; + if (value !== undefined && typeof value !== 'boolean') { + return c.json({ error: 'replace must be a boolean' }, 400); + } + replace = value === true; + } else if (body !== undefined) { + return c.json({ error: 'Invalid payload' }, 400); + } + } catch { + return c.json({ error: 'Invalid JSON' }, 400); + } + + try { + const result = await publishLocalRun({ cwd: searchDir, meta, projectId, replace }); + return c.json(localRunPublishToWire(result)); + } catch (err) { + return c.json({ error: (err as Error).message }, localRunPublishErrorStatus(err)); + } +} + async function handleRunsCombine(c: C, { searchDir, projectId }: DataContext) { let body: unknown; try { @@ -1538,6 +1618,13 @@ export function createApp( } return handleRunsCombine(c, defaultCtx); }); + app.get('/api/runs/:filename/publish', (c) => handleRunPublishPreview(c, defaultCtx)); + app.post('/api/runs/:filename/publish', (c) => { + if (readOnly) { + return c.json({ error: 'Dashboard is running in read-only mode' }, 403); + } + return handleRunPublish(c, defaultCtx); + }); app.put('/api/runs/:filename/tags', (c) => { if (readOnly) { return c.json({ error: 'Dashboard is running in read-only mode' }, 403); @@ -1679,6 +1766,15 @@ export function createApp( } return withProject(c, handleRunsCombine); }); + app.get('/api/projects/:projectId/runs/:filename/publish', (c) => + withProject(c, handleRunPublishPreview), + ); + app.post('/api/projects/:projectId/runs/:filename/publish', (c) => { + if (readOnly) { + return c.json({ error: 'Dashboard is running in read-only mode' }, 403); + } + return withProject(c, handleRunPublish); + }); app.put('/api/projects/:projectId/runs/:filename/tags', (c) => { if (readOnly) { return c.json({ error: 'Dashboard is running in read-only mode' }, 403); diff --git a/apps/cli/test/commands/results/serve.test.ts b/apps/cli/test/commands/results/serve.test.ts index 5c4f49ab2..f205ff164 100644 --- a/apps/cli/test/commands/results/serve.test.ts +++ b/apps/cli/test/commands/results/serve.test.ts @@ -197,6 +197,42 @@ function writeRemoteTagMetadataOverlay( return metadataPath; } +function writeLocalRunArtifact( + projectDir: string, + experiment: string, + timestamp: string, + resultRecord: object, +): string { + const isoTimestamp = timestamp.replace( + /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/, + '$1T$2:$3:$4.$5Z', + ); + const runDir = path.join(projectDir, '.agentv', 'results', 'runs', experiment, timestamp); + mkdirSync(runDir, { recursive: true }); + writeFileSync(path.join(runDir, 'index.jsonl'), toJsonl({ ...resultRecord, experiment })); + writeFileSync( + path.join(runDir, 'benchmark.json'), + JSON.stringify( + { + metadata: { + timestamp: isoTimestamp, + experiment, + targets: ['gpt-4o'], + tests_run: ['test-greeting'], + }, + run_summary: { + 'gpt-4o': { + pass_rate: { mean: 1 }, + }, + }, + }, + null, + 2, + ), + ); + return `${experiment}::${timestamp}`; +} + // ── resolveSourceFile ──────────────────────────────────────────────────── describe('resolveSourceFile', () => { @@ -1018,6 +1054,173 @@ describe('serve app', () => { expect(data.error).toContain('not a writable git checkout'); expect(existsSync(path.join(runDir, 'tags.json'))).toBe(false); }); + + it('previews and publishes a selected local run to the configured results repo', async () => { + const { remoteDir, cloneDir } = initializeRemoteRepo(tempDir); + const runId = writeLocalRunArtifact( + tempDir, + 'selected-publish', + '2026-03-26T14-00-00-000Z', + RESULT_A, + ); + + mkdirSync(path.join(tempDir, '.agentv'), { recursive: true }); + writeFileSync( + path.join(tempDir, '.agentv', 'config.yaml'), + `results: + mode: github + repo: file://${remoteDir} + path: ${cloneDir} +`, + ); + + const app = createApp([], tempDir, tempDir, undefined, { studioDir }); + const previewRes = await app.request(`/api/runs/${encodeURIComponent(runId)}/publish`); + + expect(previewRes.status).toBe(200); + const preview = (await previewRes.json()) as { + source_run_id: string; + target_repo: string; + target_path: string; + remote_exists: boolean; + can_publish: boolean; + }; + expect(preview).toMatchObject({ + source_run_id: runId, + target_repo: `file://${remoteDir}`, + target_path: '.agentv/results/runs/selected-publish/2026-03-26T14-00-00-000Z', + remote_exists: false, + can_publish: true, + }); + + const publishRes = await app.request(`/api/runs/${encodeURIComponent(runId)}/publish`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ replace: false }), + }); + + expect(publishRes.status).toBe(200); + const published = (await publishRes.json()) as { + published: boolean; + remote_exists: boolean; + can_publish: boolean; + }; + expect(published).toMatchObject({ + published: true, + remote_exists: true, + can_publish: false, + }); + expect(git(`git --git-dir "${remoteDir}" ls-tree -r --name-only main`, tempDir)).toContain( + '.agentv/results/runs/selected-publish/2026-03-26T14-00-00-000Z/index.jsonl', + ); + }, 20000); + + it('blocks selected local run overwrite unless replace is explicit', async () => { + const { remoteDir, cloneDir } = initializeRemoteRepo(tempDir); + const runId = writeLocalRunArtifact( + tempDir, + 'replace-publish', + '2026-03-26T15-00-00-000Z', + RESULT_A, + ); + writeRemoteRunArtifact(cloneDir, 'replace-publish', '2026-03-26T15-00-00-000Z', { + ...RESULT_A, + output: [{ role: 'assistant', content: 'old remote result' }], + }); + + mkdirSync(path.join(tempDir, '.agentv'), { recursive: true }); + writeFileSync( + path.join(tempDir, '.agentv', 'config.yaml'), + `results: + mode: github + repo: file://${remoteDir} + path: ${cloneDir} +`, + ); + + const app = createApp([], tempDir, tempDir, undefined, { studioDir }); + const previewRes = await app.request(`/api/runs/${encodeURIComponent(runId)}/publish`); + expect(previewRes.status).toBe(200); + await expect(previewRes.json()).resolves.toMatchObject({ + remote_exists: true, + replace_required: true, + can_publish: false, + }); + + const blockedRes = await app.request(`/api/runs/${encodeURIComponent(runId)}/publish`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ replace: false }), + }); + expect(blockedRes.status).toBe(409); + const blocked = (await blockedRes.json()) as { error: string }; + expect(blocked.error).toContain('already has run'); + + const replaceRes = await app.request(`/api/runs/${encodeURIComponent(runId)}/publish`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ replace: true }), + }); + expect(replaceRes.status).toBe(200); + await expect(replaceRes.json()).resolves.toMatchObject({ + published: true, + replaced: true, + remote_exists: true, + }); + }, 20000); + + it('blocks selected local run publish when project-level results sync is dirty', async () => { + const { remoteDir, cloneDir } = initializeRemoteRepo(tempDir); + const runId = writeLocalRunArtifact( + tempDir, + 'dirty-publish', + '2026-03-26T16-00-00-000Z', + RESULT_A, + ); + const dirtyPath = path.join( + cloneDir, + '.agentv', + 'results', + 'metadata', + 'runs', + 'dirty-publish', + 'tags.json', + ); + mkdirSync(path.dirname(dirtyPath), { recursive: true }); + writeFileSync(dirtyPath, '{"tags":["pending"]}\n'); + + mkdirSync(path.join(tempDir, '.agentv'), { recursive: true }); + writeFileSync( + path.join(tempDir, '.agentv', 'config.yaml'), + `results: + mode: github + repo: file://${remoteDir} + path: ${cloneDir} +`, + ); + + const app = createApp([], tempDir, tempDir, undefined, { studioDir }); + const previewRes = await app.request(`/api/runs/${encodeURIComponent(runId)}/publish`); + + expect(previewRes.status).toBe(200); + const preview = (await previewRes.json()) as { + can_publish: boolean; + block_reason?: string; + remote_status: { sync_status?: string }; + }; + expect(preview.can_publish).toBe(false); + expect(preview.remote_status.sync_status).toBe('dirty'); + expect(preview.block_reason).toContain('Sync Project before publishing'); + + const publishRes = await app.request(`/api/runs/${encodeURIComponent(runId)}/publish`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ replace: false }), + }); + expect(publishRes.status).toBe(409); + const data = (await publishRes.json()) as { error: string }; + expect(data.error).toContain('Sync Project before publishing'); + }, 20000); }); describe('GET /api/projects/all-runs', () => { diff --git a/apps/dashboard/src/components/PublishRunAction.tsx b/apps/dashboard/src/components/PublishRunAction.tsx new file mode 100644 index 000000000..b481dd8ee --- /dev/null +++ b/apps/dashboard/src/components/PublishRunAction.tsx @@ -0,0 +1,156 @@ +/** + * PublishRunAction — secondary local-run workflow for copying one completed + * run into the configured remote results repo. + * + * Project-level Sync Project remains the primary remote workflow. This + * component only appears for completed local runs, previews the destination + * repo/path before publishing, and requires a separate two-step replace action + * when a remote run with the same ID already exists. + */ + +import { useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; + +import { publishRunApi, useRunPublishPreview } from '~/lib/api'; + +type RunSource = 'local' | 'remote' | undefined; +type RunStatus = 'starting' | 'running' | 'finished' | 'failed' | undefined; + +export interface PublishRunActionProps { + runId: string; + projectId?: string; + source: RunSource; + status: RunStatus; + isReadOnly: boolean; +} + +export function PublishRunAction({ + runId, + projectId, + source, + status, + isReadOnly, +}: PublishRunActionProps) { + const queryClient = useQueryClient(); + const [busy, setBusy] = useState<'publish' | 'replace' | null>(null); + const [confirmReplace, setConfirmReplace] = useState(false); + const [feedback, setFeedback] = useState(null); + const [error, setError] = useState(null); + const isActiveRun = status === 'starting' || status === 'running'; + const enabled = !isReadOnly && source === 'local' && !isActiveRun; + const preview = useRunPublishPreview(runId, projectId, enabled); + + if (!enabled) return null; + + async function refreshQueries() { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['runs'] }), + queryClient.invalidateQueries({ queryKey: ['projects'] }), + queryClient.invalidateQueries({ queryKey: ['remote-status'] }), + queryClient.invalidateQueries({ queryKey: ['runs', runId, 'publish-preview'] }), + ]); + } + + async function publish(replace: boolean) { + setBusy(replace ? 'replace' : 'publish'); + setError(null); + setFeedback(null); + try { + const result = await publishRunApi(runId, { projectId, replace }); + setConfirmReplace(false); + setFeedback( + result.published + ? replace + ? 'Remote run replaced' + : 'Run published' + : 'Remote already matches this run', + ); + await refreshQueries(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to publish run'); + } finally { + setBusy(null); + } + } + + const data = preview.data; + const disabled = preview.isLoading || busy !== null || !!data?.block_reason; + const title = + data?.block_reason ?? + (data + ? `${data.target_repo}: ${data.target_path}` + : preview.error + ? (preview.error as Error).message + : 'Loading publish target'); + + return ( +
+
+ {data?.remote_exists ? ( + confirmReplace ? ( + <> + + + + ) : ( + + ) + ) : ( + + )} +
+ {data ? ( +

+ {data.target_repo} / {data.target_path} + {data.remote_exists ? ' - remote run exists' : ''} +

+ ) : preview.isLoading ? ( +

Checking publish target...

+ ) : null} + {data?.block_reason && ( +

{data.block_reason}

+ )} + {feedback &&

{feedback}

} + {(error || preview.error) && ( +

+ {error ?? (preview.error as Error).message} +

+ )} +
+ ); +} diff --git a/apps/dashboard/src/lib/api.ts b/apps/dashboard/src/lib/api.ts index 80f42394c..5ed3b702e 100644 --- a/apps/dashboard/src/lib/api.ts +++ b/apps/dashboard/src/lib/api.ts @@ -29,6 +29,7 @@ import type { FileContentResponse, FileTreeResponse, IndexResponse, + LocalRunPublishPreviewResponse, ProjectEntry, ProjectListResponse, RemoteStatusResponse, @@ -305,6 +306,41 @@ export function useRemoteStatus(projectId?: string) { return useQuery(remoteStatusOptions(projectId)); } +export function runPublishPreviewOptions(filename: string, projectId?: string, enabled = true) { + const url = projectId + ? `${projectApiBase(projectId)}/runs/${encodeURIComponent(filename)}/publish` + : `/api/runs/${encodeURIComponent(filename)}/publish`; + return queryOptions({ + queryKey: ['runs', filename, 'publish-preview', projectId ?? ''], + queryFn: () => fetchJson(url), + enabled: !!filename && enabled, + staleTime: 5_000, + }); +} + +export function useRunPublishPreview(filename: string, projectId?: string, enabled = true) { + return useQuery(runPublishPreviewOptions(filename, projectId, enabled)); +} + +export async function publishRunApi( + filename: string, + options?: { projectId?: string; replace?: boolean }, +): Promise { + const url = options?.projectId + ? `${projectApiBase(options.projectId)}/runs/${encodeURIComponent(filename)}/publish` + : `/api/runs/${encodeURIComponent(filename)}/publish`; + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ replace: options?.replace === true }), + }); + if (!res.ok) { + const err = (await res.json()) as { error?: string }; + throw new Error(err.error || `Failed to publish run: ${res.status}`); + } + return res.json() as Promise; +} + /** Default pass threshold matching @agentv/core DEFAULT_THRESHOLD */ export const DEFAULT_PASS_THRESHOLD = 0.8; export const DEFAULT_APP_NAME = 'agentv'; diff --git a/apps/dashboard/src/lib/types.ts b/apps/dashboard/src/lib/types.ts index 8a5d0f76a..ec5bb3c72 100644 --- a/apps/dashboard/src/lib/types.ts +++ b/apps/dashboard/src/lib/types.ts @@ -217,6 +217,20 @@ export interface RunTagsResponse { updated_at: string; } +export interface LocalRunPublishPreviewResponse { + source_run_id: string; + target_repo: string; + target_path: string; + target_run_id: string; + remote_exists: boolean; + replace_required: boolean; + can_publish: boolean; + block_reason?: string; + remote_status: RemoteStatusResponse; + published?: boolean; + replaced?: boolean; +} + export interface CombineDuplicateConflict { key: string; test_id: string; diff --git a/apps/dashboard/src/routes/projects/$projectId_/runs/$runId.tsx b/apps/dashboard/src/routes/projects/$projectId_/runs/$runId.tsx index a33dbc132..e4a43f242 100644 --- a/apps/dashboard/src/routes/projects/$projectId_/runs/$runId.tsx +++ b/apps/dashboard/src/routes/projects/$projectId_/runs/$runId.tsx @@ -5,6 +5,7 @@ import { createFileRoute } from '@tanstack/react-router'; import { useState } from 'react'; +import { PublishRunAction } from '~/components/PublishRunAction'; import { ResumeRunActions } from '~/components/ResumeRunActions'; import { RunDetail } from '~/components/RunDetail'; import { RunEvalModal } from '~/components/RunEvalModal'; @@ -104,6 +105,13 @@ function ProjectRunDetailPage() { runStatus={runStatus} /> )} + {runStatus && } {!isReadOnly && !isActiveRun && (