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} />