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
50 changes: 50 additions & 0 deletions apps/dashboard/src/components/RunSourceToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ interface RunSourceToolbarProps {
syncFeedback?: { kind: 'success' | 'warning' | 'error'; message: string } | null;
/** Pre-formatted "N of M runs on remote (branch)" summary derived from the listed runs. */
onRemoteSummary?: string;
/** Layer 2 "I merged it — resync" handler; resumes canonical sync after a GitHub merge. */
onConfirmMerge?: () => void;
confirmMergeInFlight?: boolean;
}

export function RunSourceToolbar({
Expand All @@ -22,10 +25,13 @@ export function RunSourceToolbar({
projectName,
syncFeedback,
onRemoteSummary,
onConfirmMerge,
confirmMergeInFlight,
}: RunSourceToolbarProps) {
const remoteConfigured = remoteStatus?.configured === true;
const syncView = getProjectSyncView(remoteStatus, syncInFlight);
const syncDisabled = syncInFlight === true || !syncView.canSync;
const pendingMerge = syncView.pendingMerge;
const statusToneClass = {
neutral: 'border-gray-700 bg-gray-800/70 text-gray-300',
good: 'border-emerald-800/70 bg-emerald-950/30 text-emerald-300',
Expand Down Expand Up @@ -112,6 +118,50 @@ export function RunSourceToolbar({
</p>
)}

{pendingMerge ? (
<div className="space-y-2 rounded-md border border-yellow-900/60 bg-yellow-950/20 px-3 py-2.5 text-sm text-yellow-200">
<p className="font-medium text-yellow-300">Pending merge on GitHub</p>
<p className="text-yellow-200/90">
Local results were pushed to{' '}
<code className="rounded bg-yellow-950/60 px-1 font-mono text-xs text-yellow-200">
{pendingMerge.tempBranch}
</code>{' '}
instead of force-pushing{' '}
<code className="rounded bg-yellow-950/60 px-1 font-mono text-xs text-yellow-200">
{pendingMerge.targetBranch}
</code>
{typeof pendingMerge.contributedRunCount === 'number'
? ` (${pendingMerge.contributedRunCount} run${
pendingMerge.contributedRunCount === 1 ? '' : 's'
})`
: ''}
. Merge it on GitHub, then resync.
</p>
<div className="flex flex-wrap items-center gap-2 pt-0.5">
{pendingMerge.compareUrl ? (
<a
href={pendingMerge.compareUrl}
target="_blank"
rel="noreferrer noopener"
className="rounded-md border border-yellow-700 bg-yellow-950/40 px-3 py-1.5 text-sm font-medium text-yellow-200 transition-colors hover:bg-yellow-900/40"
>
Open merge on GitHub ↗
</a>
) : null}
{onConfirmMerge ? (
<button
type="button"
onClick={onConfirmMerge}
disabled={confirmMergeInFlight === true || syncInFlight === true}
className="rounded-md border border-cyan-800 bg-cyan-950/40 px-3 py-1.5 text-sm font-medium text-cyan-300 transition-colors hover:bg-cyan-900/50 disabled:cursor-not-allowed disabled:opacity-60"
>
{confirmMergeInFlight ? 'Resyncing...' : 'I merged it — resync'}
</button>
) : null}
</div>
</div>
) : null}

{syncFeedback ? (
<div
className={`rounded-md border px-3 py-2 text-sm ${feedbackClass}`}
Expand Down
24 changes: 24 additions & 0 deletions apps/dashboard/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,30 @@ export async function syncRemoteResultsApi(projectId?: string): Promise<RemoteSt
return res.json() as Promise<RemoteStatusResponse>;
}

/**
* Explicit "OK" action of the Layer 2 human-merge flow: the user merged the temp
* branch into the canonical target on GitHub, so the Dashboard tells the backend
* to pull the merged target (ancestor-guarded fast-forward) and resume canonical
* sync. A premature confirm is a safe no-op (no force push, no data loss).
*/
export async function confirmRemoteResultsMergeApi(
projectId?: string,
): Promise<RemoteStatusResponse> {
const url = projectId
? `${projectApiBase(projectId)}/remote/sync/confirm-merge`
: '/api/remote/sync/confirm-merge';
const res = await fetch(url, {
method: 'POST',
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(
(err as { error?: string }).error ?? `Failed to confirm results merge: ${res.status}`,
);
}
return res.json() as Promise<RemoteStatusResponse>;
}

export class CombineRunsApiError extends Error {
constructor(
message: string,
Expand Down
47 changes: 47 additions & 0 deletions apps/dashboard/src/lib/project-sync-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,53 @@ describe('getProjectSyncView', () => {
});
expect(view.nextAction).not.toMatch(/force/i);
expect(view.nextAction).toMatch(/pull request/i);
expect(view.pendingMerge).toBeUndefined();
});

it('surfaces a pending-merge card with the GitHub link when a temp branch exists', () => {
const view = getProjectSyncView({
configured: true,
available: true,
sync_status: 'needs_human_merge',
pending_merge: {
temp_branch: 'agentv/results-sync/20260625T0000Z-agentv-results-v1-ab12cd',
target_branch: 'agentv/results/v1',
compare_url:
'https://github.com/o/r/compare/agentv%2Fresults%2Fv1...agentv%2Fresults-sync%2F20260625T0000Z-agentv-results-v1-ab12cd?expand=1',
contributed_run_count: 3,
created_at: '2026-06-25T00:00:00.000Z',
},
});
expect(view).toMatchObject({
state: 'needs_human_merge',
label: 'Pending merge',
tone: 'warn',
canSync: false,
});
expect(view.nextAction).not.toMatch(/force/i);
expect(view.pendingMerge).toEqual({
tempBranch: 'agentv/results-sync/20260625T0000Z-agentv-results-v1-ab12cd',
targetBranch: 'agentv/results/v1',
compareUrl:
'https://github.com/o/r/compare/agentv%2Fresults%2Fv1...agentv%2Fresults-sync%2F20260625T0000Z-agentv-results-v1-ab12cd?expand=1',
contributedRunCount: 3,
createdAt: '2026-06-25T00:00:00.000Z',
});
});

it('omits the compare URL in the pending-merge view for non-GitHub remotes', () => {
const view = getProjectSyncView({
configured: true,
available: true,
sync_status: 'needs_human_merge',
pending_merge: {
temp_branch: 'agentv/results-sync/20260625T0000Z-main-ab12cd',
target_branch: 'main',
created_at: '2026-06-25T00:00:00.000Z',
},
});
expect(view.pendingMerge?.compareUrl).toBeUndefined();
expect(view.pendingMerge?.tempBranch).toBe('agentv/results-sync/20260625T0000Z-main-ab12cd');
});
});

Expand Down
46 changes: 46 additions & 0 deletions apps/dashboard/src/lib/project-sync-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ export type ProjectSyncState =

export type ProjectSyncTone = 'neutral' | 'good' | 'info' | 'warn' | 'danger';

/**
* camelCase view of the wire {@link RemoteStatusResponse.pending_merge} block.
* Surfaces the GitHub merge hand-off for the Layer 2 human-merge flow.
*/
export interface PendingMergeView {
tempBranch: string;
targetBranch: string;
compareUrl?: string;
contributedRunCount?: number;
createdAt: string;
}

export interface ProjectSyncView {
state: ProjectSyncState;
label: string;
Expand All @@ -22,6 +34,25 @@ export interface ProjectSyncView {
summary: string;
nextAction?: string;
canSync: boolean;
/** Present only when a Layer 2 conflict pushed local work to a temp branch. */
pendingMerge?: PendingMergeView;
}

/** Translates the snake_case wire pending-merge block into the camelCase view. */
export function toPendingMergeView(status: RemoteStatusResponse): PendingMergeView | undefined {
const pending = status.pending_merge;
if (!pending) {
return undefined;
}
return {
tempBranch: pending.temp_branch,
targetBranch: pending.target_branch,
...(pending.compare_url !== undefined && { compareUrl: pending.compare_url }),
...(pending.contributed_run_count !== undefined && {
contributedRunCount: pending.contributed_run_count,
}),
createdAt: pending.created_at,
};
}

function formatTimestamp(timestamp?: string): string | undefined {
Expand Down Expand Up @@ -124,6 +155,21 @@ export function getProjectSyncView(

const state = status.sync_status ?? 'clean';
if (state === 'needs_human_merge') {
const pendingMerge = toPendingMergeView(status);
if (pendingMerge) {
return {
state: 'needs_human_merge',
label: 'Pending merge',
actionLabel: 'Sync Project',
tone: 'warn',
summary:
status.block_reason ??
`Local results could not be auto-merged, so they were pushed to ${pendingMerge.tempBranch} for review.`,
nextAction: `No history was rewritten and the canonical branch was left untouched. Merge the branch into ${pendingMerge.targetBranch} on GitHub, then click "I merged it — resync".`,
canSync: false,
pendingMerge,
};
}
return {
state: 'needs_human_merge',
label: 'Needs human merge',
Expand Down
16 changes: 16 additions & 0 deletions apps/dashboard/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,21 @@ export interface StudioConfigResponse {
current_project_id?: string;
}

/**
* Layer 2 of the no-force-push results sync. When a genuine conflict cannot be
* auto-merged, the local work is pushed to a fresh timestamped temp branch
* (never the canonical branch, never force) and this block is surfaced so the
* user can merge it on GitHub and then click OK to resync. GitHub's pull request
* is the conflict surface — the Dashboard builds no merge/diff/accept UI.
*/
export interface RemotePendingMerge {
temp_branch: string;
target_branch: string;
compare_url?: string;
contributed_run_count?: number;
created_at: string;
}

export interface RemoteStatusResponse {
configured: boolean;
available: boolean;
Expand Down Expand Up @@ -493,6 +508,7 @@ export interface RemoteStatusResponse {
previous_remote_commit?: string;
force_pushed_commit?: string;
lease_commit?: string;
pending_merge?: RemotePendingMerge;
}

// ── Project types ──────────────────────────────────────────────────────
Expand Down
34 changes: 34 additions & 0 deletions apps/dashboard/src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { RunList } from '~/components/RunList';
import { RunSourceToolbar } from '~/components/RunSourceToolbar';
import { TargetsTab } from '~/components/TargetsTab';
import {
confirmRemoteResultsMergeApi,
remoteStatusOptions,
removeProjectApi,
syncRemoteResultsApi,
Expand Down Expand Up @@ -232,6 +233,7 @@ function SingleProjectHome() {
const { data: config } = useStudioConfig();
const [showRunEval, setShowRunEval] = useState(false);
const [syncInFlight, setSyncInFlight] = useState(false);
const [confirmMergeInFlight, setConfirmMergeInFlight] = useState(false);
const [syncFeedback, setSyncFeedback] = useState<{
kind: 'success' | 'warning' | 'error';
message: string;
Expand Down Expand Up @@ -279,6 +281,30 @@ function SingleProjectHome() {
return () => window.clearTimeout(timeout);
}, [syncFeedback]);

async function handleConfirmMerge() {
setConfirmMergeInFlight(true);
setSyncFeedback(null);
try {
const result = await confirmRemoteResultsMergeApi();
queryClient.setQueryData(remoteStatusOptions().queryKey, result);
setSyncFeedback(buildProjectSyncFeedback(result));
void Promise.all([
queryClient.invalidateQueries({ queryKey: ['runs'] }),
queryClient.invalidateQueries({ queryKey: ['experiments'] }),
queryClient.invalidateQueries({ queryKey: ['compare'] }),
queryClient.invalidateQueries({ queryKey: ['targets'] }),
queryClient.invalidateQueries({ queryKey: ['remote-status', ''] }),
]).catch(() => undefined);
} catch (err) {
setSyncFeedback(buildProjectSyncErrorFeedback(err, remoteStatus));
void queryClient
.invalidateQueries({ queryKey: ['remote-status', ''] })
.catch(() => undefined);
} finally {
setConfirmMergeInFlight(false);
}
}

return (
<div className="space-y-6">
<div className="flex items-center justify-between">
Expand Down Expand Up @@ -329,6 +355,8 @@ function SingleProjectHome() {
syncInFlight={syncInFlight}
syncFeedback={syncFeedback}
onSyncRemote={handleSyncRemote}
onConfirmMerge={handleConfirmMerge}
confirmMergeInFlight={confirmMergeInFlight}
projectName={config?.project_name}
onRemoteSummary={onRemoteSummary}
hasNextPage={hasNextPage}
Expand Down Expand Up @@ -367,6 +395,8 @@ function RunsTabContent({
syncInFlight,
syncFeedback,
onSyncRemote,
onConfirmMerge,
confirmMergeInFlight,
projectName,
onRemoteSummary,
hasNextPage,
Expand All @@ -381,6 +411,8 @@ function RunsTabContent({
syncInFlight: boolean;
syncFeedback: { kind: 'success' | 'warning' | 'error'; message: string } | null;
onSyncRemote: () => void;
onConfirmMerge: () => void;
confirmMergeInFlight: boolean;
projectName?: string;
onRemoteSummary?: string;
hasNextPage: boolean | undefined;
Expand Down Expand Up @@ -410,6 +442,8 @@ function RunsTabContent({
projectName={projectName}
syncFeedback={syncFeedback}
onRemoteSummary={onRemoteSummary}
onConfirmMerge={onConfirmMerge}
confirmMergeInFlight={confirmMergeInFlight}
/>
<RunList
runs={runs}
Expand Down
28 changes: 28 additions & 0 deletions apps/dashboard/src/routes/projects/$projectId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { RunList } from '~/components/RunList';
import { RunSourceToolbar } from '~/components/RunSourceToolbar';
import { TargetsTab } from '~/components/TargetsTab';
import {
confirmRemoteResultsMergeApi,
projectCompareOptions,
remoteStatusOptions,
syncRemoteResultsApi,
Expand Down Expand Up @@ -136,6 +137,7 @@ function ProjectRunsTab({
const { data: activeRunsData } = useEvalRuns(projectId);
const { data: remoteStatus } = useRemoteStatus(projectId);
const [syncInFlight, setSyncInFlight] = useState(false);
const [confirmMergeInFlight, setConfirmMergeInFlight] = useState(false);
const [syncFeedback, setSyncFeedback] = useState<{
kind: 'success' | 'warning' | 'error';
message: string;
Expand Down Expand Up @@ -176,6 +178,30 @@ function ProjectRunsTab({
}
}

async function handleConfirmMerge() {
setConfirmMergeInFlight(true);
setSyncFeedback(null);
try {
const result = await confirmRemoteResultsMergeApi(projectId);
queryClient.setQueryData(remoteStatusOptions(projectId).queryKey, result);
setSyncFeedback(buildProjectSyncFeedback(result));
void Promise.all([
queryClient.invalidateQueries({ queryKey: ['projects', projectId, 'runs'] }),
queryClient.invalidateQueries({ queryKey: ['projects', projectId, 'experiments'] }),
queryClient.invalidateQueries({ queryKey: ['projects', projectId, 'compare'] }),
queryClient.invalidateQueries({ queryKey: ['projects', projectId, 'targets'] }),
queryClient.invalidateQueries({ queryKey: ['remote-status', projectId] }),
]).catch(() => undefined);
} catch (err) {
setSyncFeedback(buildProjectSyncErrorFeedback(err, remoteStatus));
void queryClient
.invalidateQueries({ queryKey: ['remote-status', projectId] })
.catch(() => undefined);
} finally {
setConfirmMergeInFlight(false);
}
}

useEffect(() => {
if (syncFeedback?.kind !== 'success') {
return;
Expand Down Expand Up @@ -241,6 +267,8 @@ function ProjectRunsTab({
projectName={projectName}
syncFeedback={syncFeedback}
onRemoteSummary={onRemoteSummary}
onConfirmMerge={handleConfirmMerge}
confirmMergeInFlight={confirmMergeInFlight}
/>
<RunList
runs={runs}
Expand Down
Loading