Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions apps/cli/src/commands/results/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import {
type EvaluationResult,
type GitListedRun,
type ResultsConfig,
ResultsRepoRunExistsError,
type ResultsRepoStatus,
directPushResults,
directorySizeBytes,
findResultsRepoRun,
getProject,
getProjectForPath,
getResultsRepoSyncStatus,
Expand All @@ -27,6 +29,7 @@ import {
listResultFiles,
listResultFilesFromRunsDir,
} from '../inspect/utils.js';
import { loadManifestResults } from './manifest.js';
import {
type RemoteRunTagState,
assertWritableResultsRepo,
Expand Down Expand Up @@ -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;

Expand All @@ -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<ResultsConfig>;
readonly meta: Pick<SourcedResultFileMeta, 'source' | 'path' | 'raw_filename'>;
readonly projectId?: string;
}): Promise<LocalRunPublishPreview & { readonly relativeRunPath: string }> {
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<SourcedResultFileMeta, 'source' | 'path' | 'raw_filename'>,
projectId?: string,
): Promise<LocalRunPublishPreview> {
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<SourcedResultFileMeta, 'source' | 'path' | 'raw_filename'>;
readonly projectId?: string;
readonly replace?: boolean;
}): Promise<LocalRunPublishResult> {
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 =
Expand Down
98 changes: 97 additions & 1 deletion apps/cli/src/commands/results/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { command, flag, number, option, optional, positional, string } from 'cmd
import {
DEFAULT_CATEGORY,
type EvaluationResult,
ResultsRepoRunExistsError,
addProject,
getProject,
loadConfig,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 };
Expand All @@ -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<string, unknown>).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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading