Skip to content

Commit 771b9a4

Browse files
committed
feat(results): publish selected local runs
1 parent adcb6cf commit 771b9a4

11 files changed

Lines changed: 752 additions & 11 deletions

File tree

apps/cli/src/commands/results/remote.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ import {
66
type EvaluationResult,
77
type GitListedRun,
88
type ResultsConfig,
9+
ResultsRepoRunExistsError,
910
type ResultsRepoStatus,
1011
directPushResults,
1112
directorySizeBytes,
13+
findResultsRepoRun,
1214
getProject,
1315
getProjectForPath,
1416
getResultsRepoSyncStatus,
@@ -27,6 +29,7 @@ import {
2729
listResultFiles,
2830
listResultFilesFromRunsDir,
2931
} from '../inspect/utils.js';
32+
import { loadManifestResults } from './manifest.js';
3033
import {
3134
type RemoteRunTagState,
3235
assertWritableResultsRepo,
@@ -97,6 +100,33 @@ export interface RemoteResultsStatus extends ResultsRepoStatus {
97100
readonly run_count: number;
98101
}
99102

103+
export interface LocalRunPublishPreview {
104+
readonly sourceRunId: string;
105+
readonly targetRepo: string;
106+
readonly targetPath: string;
107+
readonly targetRunId: string;
108+
readonly remoteExists: boolean;
109+
readonly replaceRequired: boolean;
110+
readonly canPublish: boolean;
111+
readonly blockReason?: string;
112+
readonly remoteStatus: RemoteResultsStatus;
113+
}
114+
115+
export interface LocalRunPublishResult extends LocalRunPublishPreview {
116+
readonly published: boolean;
117+
readonly replaced: boolean;
118+
}
119+
120+
export class LocalRunPublishError extends Error {
121+
readonly status: 400 | 409;
122+
123+
constructor(message: string, status: 400 | 409 = 400) {
124+
super(message);
125+
this.name = 'LocalRunPublishError';
126+
this.status = status;
127+
}
128+
}
129+
100130
const REMOTE_RUN_PREFIX = 'remote::';
101131
const SIZE_WARNING_BYTES = 10 * 1024 * 1024;
102132

@@ -122,6 +152,136 @@ function getRelativeRunPath(cwd: string, runDir: string): string {
122152
return experiment && experiment !== runName ? path.join(experiment, runName) : runName;
123153
}
124154

155+
function toPosixPath(p: string): string {
156+
return p.split(path.sep).join('/');
157+
}
158+
159+
function getPublishStatusBlockReason(status: RemoteResultsStatus): string | undefined {
160+
if (!status.configured) {
161+
return 'Remote results repo is not configured for this project.';
162+
}
163+
164+
if (status.sync_status === 'syncing') {
165+
return 'Project sync is already in progress. Wait for it to finish before publishing a run.';
166+
}
167+
168+
if (status.sync_status && status.sync_status !== 'clean') {
169+
return 'Sync Project before publishing a selected run so pending result metadata is handled first.';
170+
}
171+
172+
if (status.blocked) {
173+
return status.block_reason ?? 'Remote results repo sync is blocked.';
174+
}
175+
176+
return undefined;
177+
}
178+
179+
function getExperimentFromRunId(runId: string): string {
180+
const separatorIndex = runId.lastIndexOf('::');
181+
return separatorIndex === -1 ? 'default' : runId.slice(0, separatorIndex);
182+
}
183+
184+
async function buildLocalRunPublishPreview(params: {
185+
readonly cwd: string;
186+
readonly config: Required<ResultsConfig>;
187+
readonly meta: Pick<SourcedResultFileMeta, 'source' | 'path' | 'raw_filename'>;
188+
readonly projectId?: string;
189+
}): Promise<LocalRunPublishPreview & { readonly relativeRunPath: string }> {
190+
if (params.meta.source !== 'local') {
191+
throw new LocalRunPublishError('Selected run publish is only available for local runs');
192+
}
193+
194+
const runDir = path.dirname(params.meta.path);
195+
const relativeRunPath = toPosixPath(getRelativeRunPath(params.cwd, runDir));
196+
const targetPath = path.posix.join('.agentv/results/runs', relativeRunPath);
197+
const existingRun = await findResultsRepoRun(params.config, params.meta.raw_filename);
198+
const remoteStatus = await getRemoteResultsStatus(params.cwd, params.projectId);
199+
const blockReason = getPublishStatusBlockReason(remoteStatus);
200+
const remoteExists = existingRun !== undefined;
201+
202+
return {
203+
sourceRunId: params.meta.raw_filename,
204+
targetRepo: params.config.repo,
205+
targetPath,
206+
targetRunId: params.meta.raw_filename,
207+
remoteExists,
208+
replaceRequired: remoteExists,
209+
canPublish: !blockReason && !remoteExists,
210+
...(blockReason && { blockReason }),
211+
remoteStatus,
212+
relativeRunPath,
213+
};
214+
}
215+
216+
export async function previewLocalRunPublish(
217+
cwd: string,
218+
meta: Pick<SourcedResultFileMeta, 'source' | 'path' | 'raw_filename'>,
219+
projectId?: string,
220+
): Promise<LocalRunPublishPreview> {
221+
const config = await loadNormalizedResultsConfig(cwd, projectId);
222+
if (!config) {
223+
throw new LocalRunPublishError('Remote results repo is not configured for this project', 409);
224+
}
225+
226+
const { relativeRunPath: _relativeRunPath, ...preview } = await buildLocalRunPublishPreview({
227+
cwd,
228+
config,
229+
meta,
230+
projectId,
231+
});
232+
return preview;
233+
}
234+
235+
export async function publishLocalRun(params: {
236+
readonly cwd: string;
237+
readonly meta: Pick<SourcedResultFileMeta, 'source' | 'path' | 'raw_filename'>;
238+
readonly projectId?: string;
239+
readonly replace?: boolean;
240+
}): Promise<LocalRunPublishResult> {
241+
const config = await loadNormalizedResultsConfig(params.cwd, params.projectId);
242+
if (!config) {
243+
throw new LocalRunPublishError('Remote results repo is not configured for this project', 409);
244+
}
245+
246+
const preview = await buildLocalRunPublishPreview({
247+
cwd: params.cwd,
248+
config,
249+
meta: params.meta,
250+
projectId: params.projectId,
251+
});
252+
if (preview.blockReason) {
253+
throw new LocalRunPublishError(preview.blockReason, 409);
254+
}
255+
if (preview.remoteExists && params.replace !== true) {
256+
throw new ResultsRepoRunExistsError(preview.targetRunId, preview.relativeRunPath);
257+
}
258+
259+
await maybeWarnLargeArtifact(path.dirname(params.meta.path));
260+
const results = loadManifestResults(params.meta.path);
261+
const pushed = await directPushResults({
262+
config,
263+
sourceDir: path.dirname(params.meta.path),
264+
destinationPath: preview.relativeRunPath,
265+
commitMessage: buildCommitTitle({
266+
cwd: params.cwd,
267+
run_dir: path.dirname(params.meta.path),
268+
test_files: [],
269+
results,
270+
eval_summaries: [],
271+
experiment: getExperimentFromRunId(preview.targetRunId),
272+
}),
273+
replaceExisting: params.replace === true,
274+
});
275+
276+
invalidateGitRunsCache(config.path);
277+
const refreshed = await previewLocalRunPublish(params.cwd, params.meta, params.projectId);
278+
return {
279+
...refreshed,
280+
published: pushed,
281+
replaced: preview.remoteExists && params.replace === true,
282+
};
283+
}
284+
125285
function buildCommitTitle(payload: RemoteExportPayload): string {
126286
const passed = payload.results.filter((result) => result.score >= DEFAULT_THRESHOLD).length;
127287
const avgScore =

apps/cli/src/commands/results/serve.ts

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { command, flag, number, option, optional, positional, string } from 'cmd
4040
import {
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';
7475
import {
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+
365398
async function ensureRunReadable(
366399
searchDir: string,
367400
meta: SourcedResultFileMeta,
@@ -1182,9 +1215,10 @@ function getLocalRunsRoot(searchDir: string): string {
11821215
function validateLocalCompletedRun(
11831216
searchDir: string,
11841217
meta: SourcedResultFileMeta,
1218+
actionName = 'Run combine',
11851219
): { ok: true } | { error: string; status: 400 | 409 } {
11861220
if (meta.source === 'remote') {
1187-
return { error: 'Run combine is only available for local runs', status: 400 };
1221+
return { error: `${actionName} is only available for local runs`, status: 400 };
11881222
}
11891223
if (getActiveRunStatus(meta.path) === 'starting' || getActiveRunStatus(meta.path) === 'running') {
11901224
return { error: 'Run is still active', status: 409 };
@@ -1203,6 +1237,52 @@ function validateLocalCompletedRun(
12031237
return { error: 'Run workspace is outside the local results directory', status: 400 };
12041238
}
12051239

1240+
async function handleRunPublishPreview(c: C, { searchDir, projectId }: DataContext) {
1241+
const filename = c.req.param('filename') ?? '';
1242+
const meta = await findRunById(searchDir, filename, projectId);
1243+
if (!meta) return c.json({ error: 'Run not found' }, 404);
1244+
const safe = validateLocalCompletedRun(searchDir, meta, 'Selected run publish');
1245+
if ('error' in safe) return c.json({ error: safe.error }, safe.status);
1246+
1247+
try {
1248+
const preview = await previewLocalRunPublish(searchDir, meta, projectId);
1249+
return c.json(localRunPublishToWire(preview));
1250+
} catch (err) {
1251+
return c.json({ error: (err as Error).message }, localRunPublishErrorStatus(err));
1252+
}
1253+
}
1254+
1255+
async function handleRunPublish(c: C, { searchDir, projectId }: DataContext) {
1256+
const filename = c.req.param('filename') ?? '';
1257+
const meta = await findRunById(searchDir, filename, projectId);
1258+
if (!meta) return c.json({ error: 'Run not found' }, 404);
1259+
const safe = validateLocalCompletedRun(searchDir, meta, 'Selected run publish');
1260+
if ('error' in safe) return c.json({ error: safe.error }, safe.status);
1261+
1262+
let replace = false;
1263+
try {
1264+
const body = (await c.req.json()) as unknown;
1265+
if (body && typeof body === 'object' && !Array.isArray(body)) {
1266+
const value = (body as Record<string, unknown>).replace;
1267+
if (value !== undefined && typeof value !== 'boolean') {
1268+
return c.json({ error: 'replace must be a boolean' }, 400);
1269+
}
1270+
replace = value === true;
1271+
} else if (body !== undefined) {
1272+
return c.json({ error: 'Invalid payload' }, 400);
1273+
}
1274+
} catch {
1275+
return c.json({ error: 'Invalid JSON' }, 400);
1276+
}
1277+
1278+
try {
1279+
const result = await publishLocalRun({ cwd: searchDir, meta, projectId, replace });
1280+
return c.json(localRunPublishToWire(result));
1281+
} catch (err) {
1282+
return c.json({ error: (err as Error).message }, localRunPublishErrorStatus(err));
1283+
}
1284+
}
1285+
12061286
async function handleRunsCombine(c: C, { searchDir, projectId }: DataContext) {
12071287
let body: unknown;
12081288
try {
@@ -1527,6 +1607,13 @@ export function createApp(
15271607
}
15281608
return handleRunsCombine(c, defaultCtx);
15291609
});
1610+
app.get('/api/runs/:filename/publish', (c) => handleRunPublishPreview(c, defaultCtx));
1611+
app.post('/api/runs/:filename/publish', (c) => {
1612+
if (readOnly) {
1613+
return c.json({ error: 'Dashboard is running in read-only mode' }, 403);
1614+
}
1615+
return handleRunPublish(c, defaultCtx);
1616+
});
15301617
app.put('/api/runs/:filename/tags', (c) => {
15311618
if (readOnly) {
15321619
return c.json({ error: 'Dashboard is running in read-only mode' }, 403);
@@ -1668,6 +1755,15 @@ export function createApp(
16681755
}
16691756
return withProject(c, handleRunsCombine);
16701757
});
1758+
app.get('/api/projects/:projectId/runs/:filename/publish', (c) =>
1759+
withProject(c, handleRunPublishPreview),
1760+
);
1761+
app.post('/api/projects/:projectId/runs/:filename/publish', (c) => {
1762+
if (readOnly) {
1763+
return c.json({ error: 'Dashboard is running in read-only mode' }, 403);
1764+
}
1765+
return withProject(c, handleRunPublish);
1766+
});
16711767
app.put('/api/projects/:projectId/runs/:filename/tags', (c) => {
16721768
if (readOnly) {
16731769
return c.json({ error: 'Dashboard is running in read-only mode' }, 403);

0 commit comments

Comments
 (0)