@@ -40,6 +40,7 @@ import { command, flag, number, option, optional, positional, string } from 'cmd
4040import {
4141 DEFAULT_CATEGORY ,
4242 type EvaluationResult ,
43+ ResultsRepoRunExistsError ,
4344 addProject ,
4445 getProject ,
4546 loadConfig ,
@@ -72,12 +73,16 @@ import {
7273 resolveResultSourcePath ,
7374} from './manifest.js' ;
7475import {
76+ LocalRunPublishError ,
77+ type LocalRunPublishPreview ,
7578 type SourcedResultFileMeta ,
7679 clearRemoteRunTags ,
7780 ensureRemoteRunAvailable ,
7881 findRunById ,
7982 getRemoteResultsStatus ,
8083 listMergedResultFiles ,
84+ previewLocalRunPublish ,
85+ publishLocalRun ,
8186 readRemoteRunTagState ,
8287 setRemoteRunTags ,
8388 syncRemoteResults ,
@@ -362,6 +367,34 @@ function remoteMetadataErrorStatus(error: unknown): 400 | 409 {
362367 return 400 ;
363368}
364369
370+ function localRunPublishToWire (
371+ preview : LocalRunPublishPreview & { published ?: boolean ; replaced ?: boolean } ,
372+ ) {
373+ return {
374+ source_run_id : preview . sourceRunId ,
375+ target_repo : preview . targetRepo ,
376+ target_path : preview . targetPath ,
377+ target_run_id : preview . targetRunId ,
378+ remote_exists : preview . remoteExists ,
379+ replace_required : preview . replaceRequired ,
380+ can_publish : preview . canPublish ,
381+ ...( preview . blockReason && { block_reason : preview . blockReason } ) ,
382+ remote_status : preview . remoteStatus ,
383+ ...( preview . published !== undefined && { published : preview . published } ) ,
384+ ...( preview . replaced !== undefined && { replaced : preview . replaced } ) ,
385+ } ;
386+ }
387+
388+ function localRunPublishErrorStatus ( error : unknown ) : 400 | 409 | 500 {
389+ if ( error instanceof LocalRunPublishError ) {
390+ return error . status ;
391+ }
392+ if ( error instanceof ResultsRepoRunExistsError ) {
393+ return 409 ;
394+ }
395+ return 500 ;
396+ }
397+
365398async function ensureRunReadable (
366399 searchDir : string ,
367400 meta : SourcedResultFileMeta ,
@@ -1193,9 +1226,10 @@ function getLocalRunsRoot(searchDir: string): string {
11931226function validateLocalCompletedRun (
11941227 searchDir : string ,
11951228 meta : SourcedResultFileMeta ,
1229+ actionName = 'Run combine' ,
11961230) : { ok : true } | { error : string ; status : 400 | 409 } {
11971231 if ( meta . source === 'remote' ) {
1198- return { error : 'Run combine is only available for local runs' , status : 400 } ;
1232+ return { error : ` ${ actionName } is only available for local runs` , status : 400 } ;
11991233 }
12001234 if ( getActiveRunStatus ( meta . path ) === 'starting' || getActiveRunStatus ( meta . path ) === 'running' ) {
12011235 return { error : 'Run is still active' , status : 409 } ;
@@ -1214,6 +1248,52 @@ function validateLocalCompletedRun(
12141248 return { error : 'Run workspace is outside the local results directory' , status : 400 } ;
12151249}
12161250
1251+ async function handleRunPublishPreview ( c : C , { searchDir, projectId } : DataContext ) {
1252+ const filename = c . req . param ( 'filename' ) ?? '' ;
1253+ const meta = await findRunById ( searchDir , filename , projectId ) ;
1254+ if ( ! meta ) return c . json ( { error : 'Run not found' } , 404 ) ;
1255+ const safe = validateLocalCompletedRun ( searchDir , meta , 'Selected run publish' ) ;
1256+ if ( 'error' in safe ) return c . json ( { error : safe . error } , safe . status ) ;
1257+
1258+ try {
1259+ const preview = await previewLocalRunPublish ( searchDir , meta , projectId ) ;
1260+ return c . json ( localRunPublishToWire ( preview ) ) ;
1261+ } catch ( err ) {
1262+ return c . json ( { error : ( err as Error ) . message } , localRunPublishErrorStatus ( err ) ) ;
1263+ }
1264+ }
1265+
1266+ async function handleRunPublish ( c : C , { searchDir, projectId } : DataContext ) {
1267+ const filename = c . req . param ( 'filename' ) ?? '' ;
1268+ const meta = await findRunById ( searchDir , filename , projectId ) ;
1269+ if ( ! meta ) return c . json ( { error : 'Run not found' } , 404 ) ;
1270+ const safe = validateLocalCompletedRun ( searchDir , meta , 'Selected run publish' ) ;
1271+ if ( 'error' in safe ) return c . json ( { error : safe . error } , safe . status ) ;
1272+
1273+ let replace = false ;
1274+ try {
1275+ const body = ( await c . req . json ( ) ) as unknown ;
1276+ if ( body && typeof body === 'object' && ! Array . isArray ( body ) ) {
1277+ const value = ( body as Record < string , unknown > ) . replace ;
1278+ if ( value !== undefined && typeof value !== 'boolean' ) {
1279+ return c . json ( { error : 'replace must be a boolean' } , 400 ) ;
1280+ }
1281+ replace = value === true ;
1282+ } else if ( body !== undefined ) {
1283+ return c . json ( { error : 'Invalid payload' } , 400 ) ;
1284+ }
1285+ } catch {
1286+ return c . json ( { error : 'Invalid JSON' } , 400 ) ;
1287+ }
1288+
1289+ try {
1290+ const result = await publishLocalRun ( { cwd : searchDir , meta, projectId, replace } ) ;
1291+ return c . json ( localRunPublishToWire ( result ) ) ;
1292+ } catch ( err ) {
1293+ return c . json ( { error : ( err as Error ) . message } , localRunPublishErrorStatus ( err ) ) ;
1294+ }
1295+ }
1296+
12171297async function handleRunsCombine ( c : C , { searchDir, projectId } : DataContext ) {
12181298 let body : unknown ;
12191299 try {
@@ -1538,6 +1618,13 @@ export function createApp(
15381618 }
15391619 return handleRunsCombine ( c , defaultCtx ) ;
15401620 } ) ;
1621+ app . get ( '/api/runs/:filename/publish' , ( c ) => handleRunPublishPreview ( c , defaultCtx ) ) ;
1622+ app . post ( '/api/runs/:filename/publish' , ( c ) => {
1623+ if ( readOnly ) {
1624+ return c . json ( { error : 'Dashboard is running in read-only mode' } , 403 ) ;
1625+ }
1626+ return handleRunPublish ( c , defaultCtx ) ;
1627+ } ) ;
15411628 app . put ( '/api/runs/:filename/tags' , ( c ) => {
15421629 if ( readOnly ) {
15431630 return c . json ( { error : 'Dashboard is running in read-only mode' } , 403 ) ;
@@ -1679,6 +1766,15 @@ export function createApp(
16791766 }
16801767 return withProject ( c , handleRunsCombine ) ;
16811768 } ) ;
1769+ app . get ( '/api/projects/:projectId/runs/:filename/publish' , ( c ) =>
1770+ withProject ( c , handleRunPublishPreview ) ,
1771+ ) ;
1772+ app . post ( '/api/projects/:projectId/runs/:filename/publish' , ( c ) => {
1773+ if ( readOnly ) {
1774+ return c . json ( { error : 'Dashboard is running in read-only mode' } , 403 ) ;
1775+ }
1776+ return withProject ( c , handleRunPublish ) ;
1777+ } ) ;
16821778 app . put ( '/api/projects/:projectId/runs/:filename/tags' , ( c ) => {
16831779 if ( readOnly ) {
16841780 return c . json ( { error : 'Dashboard is running in read-only mode' } , 403 ) ;
0 commit comments