From 39cb80d80f1cc3f6eea375532d0621092a5fc55c Mon Sep 17 00:00:00 2001
From: Christopher Tso
Date: Thu, 25 Jun 2026 02:49:40 +0200
Subject: [PATCH] feat(dashboard): pending-merge card + OK-to-resync for
no-force-push sync
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Phase 3 of conflict-free results sync (av-raf.4). When a results push hits a
genuine conflict, Layer 2 now pushes local work to a temp branch and returns a
`pending_merge` block. The Dashboard surfaces this as a "Pending merge" card with
an "Open merge on GitHub" link (the PR is the conflict surface — no merge UI in
AgentV) and an "I merged it — resync" button that calls the confirm-merge
endpoint to resume canonical sync.
- types.ts: add snake_case `pending_merge` block to RemoteStatusResponse
- project-sync-status.ts: translate to camelCase PendingMergeView; tailor the
needs_human_merge view (warn tone, "Pending merge", GitHub merge guidance)
- api.ts: confirmRemoteResultsMergeApi client (scoped + unscoped)
- RunSourceToolbar.tsx: pending-merge card with GitHub link + resync button
- routes: wire confirm-merge handlers in both index and project views
- tests: pending-merge view (GitHub + non-GitHub remotes)
Deferred: "Merged remote (auto)" toast needs a precise core wire signal; the
pull+push heuristic is ambiguous, so it is left out of this PR.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../src/components/RunSourceToolbar.tsx | 50 +++++++++++++++++++
apps/dashboard/src/lib/api.ts | 24 +++++++++
.../src/lib/project-sync-status.test.ts | 47 +++++++++++++++++
apps/dashboard/src/lib/project-sync-status.ts | 46 +++++++++++++++++
apps/dashboard/src/lib/types.ts | 16 ++++++
apps/dashboard/src/routes/index.tsx | 34 +++++++++++++
.../src/routes/projects/$projectId.tsx | 28 +++++++++++
7 files changed, 245 insertions(+)
diff --git a/apps/dashboard/src/components/RunSourceToolbar.tsx b/apps/dashboard/src/components/RunSourceToolbar.tsx
index 6ac220700..2be600630 100644
--- a/apps/dashboard/src/components/RunSourceToolbar.tsx
+++ b/apps/dashboard/src/components/RunSourceToolbar.tsx
@@ -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({
@@ -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',
@@ -112,6 +118,50 @@ export function RunSourceToolbar({
)}
+ {pendingMerge ? (
+
+
Pending merge on GitHub
+
+ Local results were pushed to{' '}
+
+ {pendingMerge.tempBranch}
+ {' '}
+ instead of force-pushing{' '}
+
+ {pendingMerge.targetBranch}
+
+ {typeof pendingMerge.contributedRunCount === 'number'
+ ? ` (${pendingMerge.contributedRunCount} run${
+ pendingMerge.contributedRunCount === 1 ? '' : 's'
+ })`
+ : ''}
+ . Merge it on GitHub, then resync.
+
+
+ {pendingMerge.compareUrl ? (
+
+ Open merge on GitHub ↗
+
+ ) : null}
+ {onConfirmMerge ? (
+
+ ) : null}
+
+
+ ) : null}
+
{syncFeedback ? (
;
}
+/**
+ * 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
{
+ 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;
+}
+
export class CombineRunsApiError extends Error {
constructor(
message: string,
diff --git a/apps/dashboard/src/lib/project-sync-status.test.ts b/apps/dashboard/src/lib/project-sync-status.test.ts
index 98c0edeff..eb1f34069 100644
--- a/apps/dashboard/src/lib/project-sync-status.test.ts
+++ b/apps/dashboard/src/lib/project-sync-status.test.ts
@@ -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');
});
});
diff --git a/apps/dashboard/src/lib/project-sync-status.ts b/apps/dashboard/src/lib/project-sync-status.ts
index 4e3444b98..544191cf1 100644
--- a/apps/dashboard/src/lib/project-sync-status.ts
+++ b/apps/dashboard/src/lib/project-sync-status.ts
@@ -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;
@@ -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 {
@@ -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',
diff --git a/apps/dashboard/src/lib/types.ts b/apps/dashboard/src/lib/types.ts
index 09c7f54cb..12b3aa34a 100644
--- a/apps/dashboard/src/lib/types.ts
+++ b/apps/dashboard/src/lib/types.ts
@@ -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;
@@ -493,6 +508,7 @@ export interface RemoteStatusResponse {
previous_remote_commit?: string;
force_pushed_commit?: string;
lease_commit?: string;
+ pending_merge?: RemotePendingMerge;
}
// ── Project types ──────────────────────────────────────────────────────
diff --git a/apps/dashboard/src/routes/index.tsx b/apps/dashboard/src/routes/index.tsx
index 008d90ca6..f790752f1 100644
--- a/apps/dashboard/src/routes/index.tsx
+++ b/apps/dashboard/src/routes/index.tsx
@@ -19,6 +19,7 @@ import { RunList } from '~/components/RunList';
import { RunSourceToolbar } from '~/components/RunSourceToolbar';
import { TargetsTab } from '~/components/TargetsTab';
import {
+ confirmRemoteResultsMergeApi,
remoteStatusOptions,
removeProjectApi,
syncRemoteResultsApi,
@@ -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;
@@ -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 (
@@ -329,6 +355,8 @@ function SingleProjectHome() {
syncInFlight={syncInFlight}
syncFeedback={syncFeedback}
onSyncRemote={handleSyncRemote}
+ onConfirmMerge={handleConfirmMerge}
+ confirmMergeInFlight={confirmMergeInFlight}
projectName={config?.project_name}
onRemoteSummary={onRemoteSummary}
hasNextPage={hasNextPage}
@@ -367,6 +395,8 @@ function RunsTabContent({
syncInFlight,
syncFeedback,
onSyncRemote,
+ onConfirmMerge,
+ confirmMergeInFlight,
projectName,
onRemoteSummary,
hasNextPage,
@@ -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;
@@ -410,6 +442,8 @@ function RunsTabContent({
projectName={projectName}
syncFeedback={syncFeedback}
onRemoteSummary={onRemoteSummary}
+ onConfirmMerge={onConfirmMerge}
+ confirmMergeInFlight={confirmMergeInFlight}
/>
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;
@@ -241,6 +267,8 @@ function ProjectRunsTab({
projectName={projectName}
syncFeedback={syncFeedback}
onRemoteSummary={onRemoteSummary}
+ onConfirmMerge={handleConfirmMerge}
+ confirmMergeInFlight={confirmMergeInFlight}
/>