Checkpoint restore feature (#607)

This commit is contained in:
Solomon
2025-09-04 15:11:41 +01:00
committed by GitHub
parent 6c9d098216
commit 18a9ff770e
34 changed files with 1879 additions and 195 deletions

View File

@@ -11,6 +11,7 @@ import {
type Config,
type Environment,
type UserSystemInfo,
type BaseAgentCapability,
CheckTokenResponse,
} from 'shared/types';
import type { ExecutorConfig } from 'shared/types';
@@ -20,6 +21,7 @@ interface UserSystemState {
config: Config | null;
environment: Environment | null;
profiles: Record<string, ExecutorConfig> | null;
capabilities: Record<string, BaseAgentCapability[]> | null;
}
interface UserSystemContextType {
@@ -35,8 +37,10 @@ interface UserSystemContextType {
// System data access
environment: Environment | null;
profiles: Record<string, ExecutorConfig> | null;
capabilities: Record<string, BaseAgentCapability[]> | null;
setEnvironment: (env: Environment | null) => void;
setProfiles: (profiles: Record<string, ExecutorConfig> | null) => void;
setCapabilities: (caps: Record<string, BaseAgentCapability[]> | null) => void;
// Reload system data
reloadSystem: () => Promise<void>;
@@ -62,6 +66,10 @@ export function UserSystemProvider({ children }: UserSystemProviderProps) {
string,
ExecutorConfig
> | null>(null);
const [capabilities, setCapabilities] = useState<Record<
string,
BaseAgentCapability[]
> | null>(null);
const [loading, setLoading] = useState(true);
const [githubTokenInvalid, setGithubTokenInvalid] = useState(false);
@@ -74,6 +82,12 @@ export function UserSystemProvider({ children }: UserSystemProviderProps) {
setProfiles(
userSystemInfo.executors as Record<string, ExecutorConfig> | null
);
setCapabilities(
(userSystemInfo.capabilities || null) as Record<
string,
BaseAgentCapability[]
> | null
);
} catch (err) {
console.error('Error loading user system:', err);
} finally {
@@ -150,6 +164,12 @@ export function UserSystemProvider({ children }: UserSystemProviderProps) {
setProfiles(
userSystemInfo.executors as Record<string, ExecutorConfig> | null
);
setCapabilities(
(userSystemInfo.capabilities || null) as Record<
string,
BaseAgentCapability[]
> | null
);
} catch (err) {
console.error('Error reloading user system:', err);
} finally {
@@ -160,15 +180,17 @@ export function UserSystemProvider({ children }: UserSystemProviderProps) {
// Memoize context value to prevent unnecessary re-renders
const value = useMemo<UserSystemContextType>(
() => ({
system: { config, environment, profiles },
system: { config, environment, profiles, capabilities },
config,
environment,
profiles,
capabilities,
updateConfig,
saveConfig,
updateAndSaveConfig,
setEnvironment,
setProfiles,
setCapabilities,
reloadSystem,
loading,
githubTokenInvalid,
@@ -177,6 +199,7 @@ export function UserSystemProvider({ children }: UserSystemProviderProps) {
config,
environment,
profiles,
capabilities,
updateConfig,
saveConfig,
updateAndSaveConfig,

View File

@@ -13,6 +13,10 @@ interface LogEntryRowProps {
setRowHeight?: (index: number, height: number) => void;
isCollapsed?: boolean;
onToggleCollapse?: (processId: string) => void;
onRestore?: (processId: string) => void;
restoreProcessId?: string;
restoreDisabled?: boolean;
restoreDisabledReason?: string;
}
function LogEntryRow({
@@ -22,6 +26,10 @@ function LogEntryRow({
setRowHeight,
isCollapsed,
onToggleCollapse,
onRestore,
restoreProcessId,
restoreDisabled,
restoreDisabledReason,
}: LogEntryRowProps) {
const rowRef = useRef<HTMLDivElement>(null);
@@ -53,6 +61,10 @@ function LogEntryRow({
payload={entry.payload as ProcessStartPayload}
isCollapsed={isCollapsed || false}
onToggle={onToggleCollapse || (() => {})}
onRestore={onRestore}
restoreProcessId={restoreProcessId}
restoreDisabled={restoreDisabled}
restoreDisabledReason={restoreDisabledReason}
/>
);
default:

View File

@@ -1,4 +1,12 @@
import { Clock, Cog, Play, Terminal, Code, ChevronDown } from 'lucide-react';
import {
Clock,
Cog,
Play,
Terminal,
Code,
ChevronDown,
History,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import type { ProcessStartPayload } from '@/types/logs';
@@ -6,12 +14,20 @@ interface ProcessStartCardProps {
payload: ProcessStartPayload;
isCollapsed: boolean;
onToggle: (processId: string) => void;
onRestore?: (processId: string) => void;
restoreProcessId?: string; // explicit id if payload lacks it in future
restoreDisabled?: boolean;
restoreDisabledReason?: string;
}
function ProcessStartCard({
payload,
isCollapsed,
onToggle,
onRestore,
restoreProcessId,
restoreDisabled,
restoreDisabledReason,
}: ProcessStartCardProps) {
const getProcessIcon = (runReason: string) => {
switch (runReason) {
@@ -78,6 +94,33 @@ function ProcessStartCard({
<Clock className="h-3 w-3" />
<span>{formatTime(payload.startedAt)}</span>
</div>
{onRestore && payload.runReason === 'codingagent' && (
<button
className={cn(
'ml-2 group w-20 flex items-center gap-1 px-1.5 py-1 rounded transition-colors',
restoreDisabled
? 'cursor-not-allowed text-muted-foreground/60 bg-muted/40'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/60'
)}
onClick={(e) => {
e.stopPropagation();
if (restoreDisabled) return;
onRestore(restoreProcessId || payload.processId);
}}
title={
restoreDisabled
? restoreDisabledReason || 'Restore is currently unavailable.'
: 'Restore to this checkpoint (deletes later history)'
}
aria-label="Restore to this checkpoint"
disabled={!!restoreDisabled}
>
<History className="h-4 w-4" />
<span className="text-xs opacity-0 group-hover:opacity-100 transition-opacity">
Restore
</span>
</button>
)}
<div
className={`ml-auto text-xs px-2 py-1 rounded-full ${
payload.status === 'running'

View File

@@ -1,7 +1,15 @@
import { useRef, useCallback, useMemo, useEffect, useReducer } from 'react';
import {
useRef,
useCallback,
useMemo,
useEffect,
useReducer,
useState,
} from 'react';
import { Virtuoso } from 'react-virtuoso';
import { Cog } from 'lucide-react';
import { Cog, AlertTriangle, CheckCircle, GitCommit } from 'lucide-react';
import { useAttemptExecution } from '@/hooks/useAttemptExecution';
import { useBranchStatus } from '@/hooks/useBranchStatus';
import { useProcessesLogs } from '@/hooks/useProcessesLogs';
import LogEntryRow from '@/components/logs/LogEntryRow';
import {
@@ -11,8 +19,23 @@ import {
isCodingAgent,
getLatestCodingAgent,
PROCESS_STATUSES,
PROCESS_RUN_REASONS,
} from '@/constants/processes';
import type { ExecutionProcessStatus, TaskAttempt } from 'shared/types';
import type {
ExecutionProcessStatus,
TaskAttempt,
BaseAgentCapability,
} from 'shared/types';
import { useUserSystem } from '@/components/config-provider';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
// Helper functions
function addAll<T>(set: Set<T>, items: T[]): Set<T> {
@@ -114,21 +137,50 @@ type Props = {
};
function LogsTab({ selectedAttempt }: Props) {
const { attemptData } = useAttemptExecution(selectedAttempt?.id);
const { attemptData, refetch } = useAttemptExecution(selectedAttempt?.id);
const { data: branchStatus, refetch: refetchBranch } = useBranchStatus(
selectedAttempt?.id
);
const virtuosoRef = useRef<any>(null);
const [state, dispatch] = useReducer(reducer, initialState);
// Filter out dev server processes before passing to useProcessesLogs
const filteredProcesses = useMemo(
() =>
(attemptData.processes || []).filter((process) =>
shouldShowInLogs(process.run_reason)
),
[attemptData.processes?.map((p) => p.id).join(',')]
const filteredProcesses = useMemo(() => {
const processes = attemptData.processes || [];
return processes.filter(
(process) => shouldShowInLogs(process.run_reason) && !process.dropped
);
}, [
attemptData.processes
?.map((p) => `${p.id}:${p.status}:${p.dropped}`)
.join(','),
]);
const { capabilities } = useUserSystem();
const restoreSupported = useMemo(() => {
const exec = selectedAttempt?.executor;
if (!exec) return false;
const caps = capabilities?.[exec] || [];
return caps.includes('RESTORE_CHECKPOINT' as BaseAgentCapability);
}, [selectedAttempt?.executor, capabilities]);
// Detect if any process is running
const anyRunning = useMemo(
() => (attemptData.processes || []).some((p) => p.status === 'running'),
[attemptData.processes?.map((p) => p.status).join(',')]
);
const { entries } = useProcessesLogs(filteredProcesses, true);
const [confirmOpen, setConfirmOpen] = useState(false);
const [restorePid, setRestorePid] = useState<string | null>(null);
const [restoreBusy, setRestoreBusy] = useState(false);
const [targetSha, setTargetSha] = useState<string | null>(null);
const [targetSubject, setTargetSubject] = useState<string | null>(null);
const [commitsToReset, setCommitsToReset] = useState<number | null>(null);
const [isLinear, setIsLinear] = useState<boolean | null>(null);
const [worktreeResetOn, setWorktreeResetOn] = useState(true);
const [forceReset, setForceReset] = useState(false);
// Combined collapsed processes (auto + user)
const allCollapsedProcesses = useMemo(() => {
@@ -252,9 +304,132 @@ function LogsTab({ selectedAttempt }: Props) {
onToggleCollapse={
entry.channel === 'process_start' ? toggleProcessCollapse : undefined
}
// Pass restore handler via entry.meta for process_start
// The LogEntryRow/ProcessStartCard will ignore if not provided
{...(entry.channel === 'process_start' && restoreSupported
? (() => {
const proc = (attemptData.processes || []).find(
(p) => p.id === entry.payload.processId
);
// Consider only non-dropped processes that appear in logs for latest determination
const procs = (attemptData.processes || []).filter(
(p) => !p.dropped && shouldShowInLogs(p.run_reason)
);
const finished = procs.filter((p) => p.status !== 'running');
const latestFinished =
finished.length > 0 ? finished[finished.length - 1] : undefined;
const isLatest = latestFinished?.id === proc?.id;
const isRunningProc = proc?.status === 'running';
const headKnown = !!branchStatus?.head_oid;
const head = branchStatus?.head_oid || null;
const isDirty = !!branchStatus?.has_uncommitted_changes;
const needGitReset =
headKnown &&
!!(
proc?.after_head_commit &&
(proc.after_head_commit !== head || isDirty)
);
// Base visibility rules:
// - Never show for the currently running process
// - For earlier finished processes, show only if either:
// a) later history includes a coding agent run, or
// b) restoring would change the worktree (needGitReset)
// - For the latest finished process, only show if diverged (needGitReset)
let baseShouldShow = false;
if (!isRunningProc) {
baseShouldShow = !isLatest || needGitReset;
// If this is an earlier finished process and restoring would not
// change the worktree, hide when only non-coding processes would be deleted.
if (baseShouldShow && !isLatest && !needGitReset) {
const procs = (attemptData.processes || []).filter(
(p) => !p.dropped && shouldShowInLogs(p.run_reason)
);
const idx = procs.findIndex((p) => p.id === proc?.id);
const later = idx >= 0 ? procs.slice(idx + 1) : [];
const laterHasCoding = later.some((p) =>
isCodingAgent(p.run_reason)
);
baseShouldShow = laterHasCoding;
}
}
// If any process is running, also surface the latest finished button disabled
// so users see it immediately with a clear disabled reason.
const shouldShow =
baseShouldShow || (anyRunning && !isRunningProc && isLatest);
if (!shouldShow) return {};
let disabledReason: string | undefined;
let disabled = anyRunning || restoreBusy || confirmOpen;
if (anyRunning)
disabledReason = 'Cannot restore while a process is running.';
else if (restoreBusy) disabledReason = 'Restore in progress.';
else if (confirmOpen)
disabledReason = 'Confirm the current restore first.';
if (!proc?.after_head_commit) {
disabled = true;
disabledReason = 'No recorded commit for this process.';
}
return {
restoreProcessId: entry.payload.processId,
onRestore: async (pid: string) => {
setRestorePid(pid);
const p2 = (attemptData.processes || []).find(
(p) => p.id === pid
);
const after = p2?.after_head_commit || null;
setTargetSha(after);
setTargetSubject(null);
if (after && selectedAttempt?.id) {
try {
const { commitsApi } = await import('@/lib/api');
const info = await commitsApi.getInfo(
selectedAttempt.id,
after
);
setTargetSubject(info.subject);
const cmp = await commitsApi.compareToHead(
selectedAttempt.id,
after
);
setCommitsToReset(
cmp.is_linear ? cmp.ahead_from_head : null
);
setIsLinear(cmp.is_linear);
} catch {
/* empty */
}
}
// Initialize reset to disabled (white) when dirty, enabled otherwise
const head = branchStatus?.head_oid || null;
const isDirty = !!branchStatus?.has_uncommitted_changes;
const needGitReset = !!(after && (after !== head || isDirty));
const canGitReset = needGitReset && !isDirty;
setWorktreeResetOn(!!canGitReset);
setForceReset(false);
setConfirmOpen(true);
},
restoreDisabled: disabled,
restoreDisabledReason: disabledReason,
};
})()
: {})}
/>
),
[allCollapsedProcesses, toggleProcessCollapse]
[
allCollapsedProcesses,
toggleProcessCollapse,
restoreSupported,
anyRunning,
confirmOpen,
restoreBusy,
selectedAttempt?.id,
attemptData.processes,
branchStatus?.head_oid,
branchStatus?.has_uncommitted_changes,
]
);
if (!filteredProcesses || filteredProcesses.length === 0) {
@@ -270,6 +445,506 @@ function LogsTab({ selectedAttempt }: Props) {
return (
<div className="w-full h-full flex flex-col">
<Dialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
className="bg-white dark:bg-white"
>
<DialogContent
className="max-h-[92vh] sm:max-h-[88vh] overflow-y-auto overflow-x-hidden"
onKeyDownCapture={(e) => {
if (e.key === 'Escape') {
e.stopPropagation();
setConfirmOpen(false);
}
}}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 mb-3 md:mb-4">
<AlertTriangle className="h-4 w-4 text-destructive" /> Confirm
Restore
</DialogTitle>
<DialogDescription className="mt-6 break-words">
{(() => {
// Only consider non-dropped processes that appear in logs for counting "later" ones
const procs = (attemptData.processes || []).filter(
(p) => !p.dropped && shouldShowInLogs(p.run_reason)
);
const idx = procs.findIndex((p) => p.id === restorePid);
const laterCount = idx >= 0 ? procs.length - (idx + 1) : 0;
const hasLater = laterCount > 0;
const head = branchStatus?.head_oid || null;
const isDirty = !!branchStatus?.has_uncommitted_changes;
const needGitReset = !!(
targetSha &&
(targetSha !== head || isDirty)
);
const canGitReset = needGitReset && !isDirty;
const short = targetSha?.slice(0, 7);
const uncomm = branchStatus?.uncommitted_count ?? 0;
const untrk = branchStatus?.untracked_count ?? 0;
const hasRisk = uncomm > 0; // Only uncommitted tracked changes are risky; untracked alone is not
// Determine types of later processes for clearer messaging
const later = idx >= 0 ? procs.slice(idx + 1) : [];
const laterCoding = later.filter((p) =>
isCodingAgent(p.run_reason)
).length;
const laterSetup = later.filter(
(p) => p.run_reason === PROCESS_RUN_REASONS.SETUP_SCRIPT
).length;
const laterCleanup = later.filter(
(p) => p.run_reason === PROCESS_RUN_REASONS.CLEANUP_SCRIPT
).length;
return (
<div className="space-y-3">
{hasLater && (
<div className="flex items-start gap-3 rounded-md border border-destructive/30 bg-destructive/10 p-3">
<AlertTriangle className="h-4 w-4 text-destructive mt-0.5" />
<div className="text-sm min-w-0 w-full break-words">
<p className="font-medium text-destructive mb-2">
History change
</p>
{laterCount > 0 && (
<>
<p className="mt-0.5">
Will delete {laterCount} later process
{laterCount === 1 ? '' : 'es'} from history.
</p>
<ul className="mt-1 text-xs text-muted-foreground list-disc pl-5">
{laterCoding > 0 && (
<li>
{laterCoding} coding agent run
{laterCoding === 1 ? '' : 's'}
</li>
)}
{laterSetup + laterCleanup > 0 && (
<li>
{laterSetup + laterCleanup} script process
{laterSetup + laterCleanup === 1
? ''
: 'es'}
{laterSetup > 0 && laterCleanup > 0 && (
<>
{' '}
({laterSetup} setup, {laterCleanup}{' '}
cleanup)
</>
)}
</li>
)}
</ul>
</>
)}
<p className="mt-1 text-xs text-muted-foreground">
This permanently alters history and cannot be
undone.
</p>
</div>
</div>
)}
{needGitReset && canGitReset && (
<div
className={
!worktreeResetOn
? 'flex items-start gap-3 rounded-md border p-3'
: hasRisk
? 'flex items-start gap-3 rounded-md border border-destructive/30 bg-destructive/10 p-3'
: 'flex items-start gap-3 rounded-md border border-amber-300/60 bg-amber-50/70 p-3'
}
>
<AlertTriangle
className={
!worktreeResetOn
? 'h-4 w-4 text-muted-foreground mt-0.5'
: hasRisk
? 'h-4 w-4 text-destructive mt-0.5'
: 'h-4 w-4 text-amber-600 mt-0.5'
}
/>
<div className="text-sm min-w-0 w-full break-words">
<p
className={
(!worktreeResetOn
? 'font-medium text-muted-foreground'
: hasRisk
? 'font-medium text-destructive'
: 'font-medium text-amber-700') + ' mb-2'
}
>
Reset worktree
</p>
<div
className="mt-2 w-full flex items-center cursor-pointer select-none"
role="switch"
aria-checked={worktreeResetOn}
aria-label="Toggle worktree reset"
onClick={() => setWorktreeResetOn((v) => !v)}
>
<div className="text-xs text-muted-foreground">
{worktreeResetOn ? 'Enabled' : 'Disabled'}
</div>
<div className="ml-auto relative inline-flex h-5 w-9 items-center rounded-full">
<span
className={
(worktreeResetOn
? 'bg-emerald-500'
: 'bg-muted-foreground/30') +
' absolute inset-0 rounded-full transition-colors'
}
/>
<span
className={
(worktreeResetOn
? 'translate-x-5'
: 'translate-x-1') +
' pointer-events-none relative inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform'
}
/>
</div>
</div>
{worktreeResetOn && (
<>
<p className="mt-2 text-xs text-muted-foreground">
Your worktree will be restored to this commit.
</p>
<div
className="mt-1 flex items-center gap-2 min-w-0"
title={
targetSubject
? `${short}${targetSubject}`
: short || undefined
}
>
<GitCommit className="h-3.5 w-3.5 text-muted-foreground" />
{short && (
<span className="font-mono text-xs px-2 py-0.5 rounded bg-muted">
{short}
</span>
)}
{targetSubject && (
<span className="text-muted-foreground break-words whitespace-normal">
{targetSubject}
</span>
)}
</div>
{((isLinear &&
commitsToReset !== null &&
commitsToReset > 0) ||
uncomm > 0 ||
untrk > 0) && (
<ul className="mt-2 space-y-1 text-xs text-muted-foreground list-disc pl-5">
{isLinear &&
commitsToReset !== null &&
commitsToReset > 0 && (
<li>
Roll back {commitsToReset} commit
{commitsToReset === 1 ? '' : 's'} from
current HEAD.
</li>
)}
{uncomm > 0 && (
<li>
Discard {uncomm} uncommitted change
{uncomm === 1 ? '' : 's'}.
</li>
)}
{untrk > 0 && (
<li>
{untrk} untracked file
{untrk === 1 ? '' : 's'} present (not
affected by reset).
</li>
)}
</ul>
)}
</>
)}
</div>
</div>
)}
{needGitReset &&
!canGitReset &&
(() => {
const showDanger = forceReset && worktreeResetOn;
return (
<div
className={
showDanger
? 'flex items-start gap-3 rounded-md border border-destructive/30 bg-destructive/10 p-3'
: 'flex items-start gap-3 rounded-md border p-3'
}
>
<AlertTriangle
className={
showDanger
? 'h-4 w-4 text-destructive mt-0.5'
: 'h-4 w-4 text-muted-foreground mt-0.5'
}
/>
<div className="text-sm min-w-0 w-full break-words">
<p
className={
showDanger
? 'font-medium text-destructive'
: 'font-medium text-muted-foreground'
}
>
Reset worktree
</p>
<div
className={`mt-2 w-full flex items-center select-none ${forceReset ? 'cursor-pointer' : 'opacity-60 cursor-not-allowed'}`}
role="switch"
aria-checked={worktreeResetOn}
aria-label="Toggle worktree reset"
onClick={() => {
if (!forceReset) return;
setWorktreeResetOn((v) => !v);
}}
>
<div className="text-xs text-muted-foreground">
{forceReset
? worktreeResetOn
? 'Enabled'
: 'Disabled'
: 'Disabled (uncommitted changes detected)'}
</div>
<div className="ml-auto relative inline-flex h-5 w-9 items-center rounded-full">
<span
className={
(worktreeResetOn && forceReset
? 'bg-emerald-500'
: 'bg-muted-foreground/30') +
' absolute inset-0 rounded-full transition-colors'
}
/>
<span
className={
(worktreeResetOn && forceReset
? 'translate-x-5'
: 'translate-x-1') +
' pointer-events-none relative inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform'
}
/>
</div>
</div>
<div
className="mt-2 w-full flex items-center cursor-pointer select-none"
role="switch"
aria-checked={forceReset}
aria-label="Force reset (discard uncommitted changes)"
onClick={() => {
setForceReset((v) => {
const next = !v;
if (next) setWorktreeResetOn(true);
return next;
});
}}
>
<div className="text-xs font-medium text-destructive">
Force reset (discard uncommitted changes)
</div>
<div className="ml-auto relative inline-flex h-5 w-9 items-center rounded-full">
<span
className={
(forceReset
? 'bg-destructive'
: 'bg-muted-foreground/30') +
' absolute inset-0 rounded-full transition-colors'
}
/>
<span
className={
(forceReset
? 'translate-x-5'
: 'translate-x-1') +
' pointer-events-none relative inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform'
}
/>
</div>
</div>
<p className="mt-2 text-xs text-muted-foreground">
{forceReset
? 'Uncommitted changes will be discarded.'
: 'Uncommitted changes present. Turn on Force reset or commit/stash to proceed.'}
</p>
{((branchStatus?.uncommitted_count ?? 0) > 0 ||
(branchStatus?.untracked_count ?? 0) > 0) && (
<ul className="mt-2 space-y-1 text-xs text-muted-foreground list-disc pl-5">
{(branchStatus?.uncommitted_count ?? 0) >
0 && (
<li>
{
branchStatus?.uncommitted_count as number
}{' '}
uncommitted change
{(branchStatus?.uncommitted_count as number) ===
1
? ''
: 's'}{' '}
present.
</li>
)}
{(branchStatus?.untracked_count ?? 0) > 0 && (
<li>
{branchStatus?.untracked_count as number}{' '}
untracked file
{(branchStatus?.untracked_count as number) ===
1
? ''
: 's'}{' '}
present.
</li>
)}
</ul>
)}
{short && (
<>
<p className="mt-2 text-xs text-muted-foreground">
Your worktree will be restored to this
commit.
</p>
<div
className="mt-1 flex items-center gap-2 min-w-0"
title={
targetSubject
? `${short}${targetSubject}`
: short || undefined
}
>
<GitCommit className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-mono text-xs px-2 py-0.5 rounded bg-muted">
{short}
</span>
{targetSubject && (
<span className="text-muted-foreground break-words whitespace-normal">
{targetSubject}
</span>
)}
</div>
</>
)}
</div>
</div>
);
})()}
{!hasLater && !needGitReset && (
<div className="flex items-start gap-3 rounded-md border border-green-300/60 bg-green-50/70 p-3">
<CheckCircle className="h-4 w-4 text-green-600 mt-0.5" />
<div className="text-sm min-w-0 w-full break-words">
<p className="font-medium text-green-700 mb-2">
Nothing to change
</p>
<p className="mt-0.5">
You are already at this checkpoint.
</p>
</div>
</div>
)}
</div>
);
})()}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
Cancel
</Button>
<Button
variant="destructive"
disabled={(() => {
// Disable when there's nothing to change
const procs = (attemptData.processes || []).filter(
(p) => !p.dropped && shouldShowInLogs(p.run_reason)
);
const idx = procs.findIndex((p) => p.id === restorePid);
const laterCount = idx >= 0 ? procs.length - (idx + 1) : 0;
const hasLater = laterCount > 0;
const head = branchStatus?.head_oid || null;
const isDirty = !!branchStatus?.has_uncommitted_changes;
const needGitReset = !!(
targetSha &&
(targetSha !== head || isDirty)
);
const effectiveNeedGitReset =
needGitReset &&
worktreeResetOn &&
(!isDirty || (isDirty && forceReset));
return restoreBusy || (!hasLater && !effectiveNeedGitReset);
})()}
onClick={async () => {
if (!selectedAttempt?.id || !restorePid) return;
const { attemptsApi } = await import('@/lib/api');
try {
setRestoreBusy(true);
// Short-circuit when nothing to change
const procs = (attemptData.processes || []).filter(
(p) => !p.dropped && shouldShowInLogs(p.run_reason)
);
const idx = procs.findIndex((p) => p.id === restorePid);
const laterCount = idx >= 0 ? procs.length - (idx + 1) : 0;
const hasLater = laterCount > 0;
const head = branchStatus?.head_oid || null;
const isDirty = !!branchStatus?.has_uncommitted_changes;
const needGitReset = !!(
targetSha &&
(targetSha !== head || isDirty)
);
const effectiveNeedGitReset =
needGitReset &&
worktreeResetOn &&
(!isDirty || (isDirty && forceReset));
if (!hasLater && !effectiveNeedGitReset) {
// No-op: simply close and refresh state lightly
setRestoreBusy(false);
setConfirmOpen(false);
setRestorePid(null);
return;
}
await attemptsApi.restore(selectedAttempt.id!, restorePid, {
performGitReset: worktreeResetOn,
forceWhenDirty: forceReset,
});
// Immediately refresh processes so UI reflects dropped state without delay
await refetch();
await refetchBranch();
} finally {
setRestoreBusy(false);
}
setConfirmOpen(false);
setRestorePid(null);
}}
>
{(() => {
if (restoreBusy) return 'Restoring…';
const procs = (attemptData.processes || []).filter(
(p) => !p.dropped && shouldShowInLogs(p.run_reason)
);
const idx = procs.findIndex((p) => p.id === restorePid);
const laterCount = idx >= 0 ? procs.length - (idx + 1) : 0;
const hasLater = laterCount > 0;
const head = branchStatus?.head_oid || null;
const isDirty = !!branchStatus?.has_uncommitted_changes;
const needGitReset = !!(
targetSha &&
(targetSha !== head || isDirty)
);
const effectiveNeedGitReset =
needGitReset &&
worktreeResetOn &&
(!isDirty || (isDirty && forceReset));
return !hasLater && !effectiveNeedGitReset
? 'Nothing to change'
: 'Restore';
})()}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="flex-1">
<Virtuoso
ref={virtuosoRef}

View File

@@ -149,6 +149,14 @@ function ProcessesTab({ attemptId }: ProcessesTabProps) {
<p className="text-sm text-muted-foreground mt-1">
Process ID: {process.id}
</p>
{process.dropped && (
<span
className="inline-block mt-1 text-[10px] px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-700 border border-amber-200"
title="Deleted by restore: timeline was restored to a checkpoint and later executions were removed"
>
Deleted
</span>
)}
{
<p className="text-sm text-muted-foreground mt-1">
Profile:{' '}

View File

@@ -32,6 +32,24 @@ export const useEventSourceManager = ({
const eventSourcesRef = useRef<Map<string, EventSource>>(new Map());
const processDataRef = useRef<ProcessData>({});
const processedEntriesRef = useRef<Map<string, Set<number>>>(new Map());
const processesRef = useRef<ExecutionProcess[]>([]);
const enabledRef = useRef<boolean>(enabled);
const getEndpointRef = useRef(getEndpoint);
const retryCountsRef = useRef<Map<string, number>>(new Map());
const retryTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
new Map()
);
// Keep latest values in refs for retry handlers
useEffect(() => {
processesRef.current = processes;
}, [processes]);
useEffect(() => {
enabledRef.current = enabled;
}, [enabled]);
useEffect(() => {
getEndpointRef.current = getEndpoint;
}, [getEndpoint]);
useEffect(() => {
if (!enabled || !processes.length) {
@@ -58,52 +76,49 @@ export const useEventSourceManager = ({
}
});
// Add new connections
processes.forEach((process) => {
if (eventSourcesRef.current.has(process.id)) return;
// Helper to open an EventSource with auto-retry on transient failures (e.g., race before store is ready)
const openEventSource = (process: ExecutionProcess) => {
// If disabled or process no longer present, don't connect
if (!enabledRef.current) return;
if (!processesRef.current.find((p) => p.id === process.id)) return;
const endpoint = getEndpoint(process);
const endpoint = getEndpointRef.current(process);
// Initialize process data
if (!processDataRef.current[process.id]) {
processDataRef.current[process.id] = initialData
? structuredClone(initialData)
: { entries: [] };
// Reinitialize process data on each (re)connect to avoid duplicating history
processDataRef.current[process.id] = initialData
? structuredClone(initialData)
: { entries: [] };
processedEntriesRef.current.delete(process.id);
// Inject process start marker as the first entry
const processStartPayload: ProcessStartPayload = {
processId: process.id,
runReason: process.run_reason,
startedAt: process.started_at,
status: process.status,
};
const processStartEntry = {
type: 'PROCESS_START' as const,
content: processStartPayload,
};
processDataRef.current[process.id].entries.push(processStartEntry);
}
// Inject process start marker as the first entry (client-side only)
const processStartPayload: ProcessStartPayload = {
processId: process.id,
runReason: process.run_reason,
startedAt: process.started_at,
status: process.status,
};
const processStartEntry = {
type: 'PROCESS_START' as const,
content: processStartPayload,
};
processDataRef.current[process.id].entries.push(processStartEntry);
const eventSource = new EventSource(endpoint);
eventSource.onopen = () => {
setError(null);
setIsConnected(true);
retryCountsRef.current.set(process.id, 0);
};
eventSource.addEventListener('json_patch', (event) => {
try {
const patches = JSON.parse(event.data);
// Initialize tracking for this process if needed
if (!processedEntriesRef.current.has(process.id)) {
processedEntriesRef.current.set(process.id, new Set());
}
applyPatch(processDataRef.current[process.id], patches);
// Trigger re-render with updated data
setProcessData({ ...processDataRef.current });
} catch (err) {
console.error('Failed to apply JSON patch:', err);
@@ -114,6 +129,12 @@ export const useEventSourceManager = ({
eventSource.addEventListener('finished', () => {
eventSource.close();
eventSourcesRef.current.delete(process.id);
retryCountsRef.current.delete(process.id);
const t = retryTimersRef.current.get(process.id);
if (t) {
clearTimeout(t);
retryTimersRef.current.delete(process.id);
}
setIsConnected(eventSourcesRef.current.size > 0);
});
@@ -121,17 +142,43 @@ export const useEventSourceManager = ({
setError('Connection failed');
eventSource.close();
eventSourcesRef.current.delete(process.id);
setIsConnected(eventSourcesRef.current.size > 0);
const nextAttempt = (retryCountsRef.current.get(process.id) || 0) + 1;
retryCountsRef.current.set(process.id, nextAttempt);
const maxAttempts = 6;
if (
nextAttempt <= maxAttempts &&
enabledRef.current &&
processesRef.current.find((p) => p.id === process.id)
) {
const delay = Math.min(1500, 250 * 2 ** (nextAttempt - 1));
const timer = setTimeout(() => openEventSource(process), delay);
const prevTimer = retryTimersRef.current.get(process.id);
if (prevTimer) clearTimeout(prevTimer);
retryTimersRef.current.set(process.id, timer);
} else {
setIsConnected(eventSourcesRef.current.size > 0);
}
};
eventSourcesRef.current.set(process.id, eventSource);
};
// Add new connections
processes.forEach((process) => {
if (eventSourcesRef.current.has(process.id)) return;
openEventSource(process);
});
setIsConnected(eventSourcesRef.current.size > 0);
return () => {
// Cleanup all event sources and any pending retry timers
eventSourcesRef.current.forEach((es) => es.close());
eventSourcesRef.current.clear();
retryTimersRef.current.forEach((t) => clearTimeout(t));
retryTimersRef.current.clear();
};
}, [processes, enabled, getEndpoint, initialData]);

View File

@@ -12,6 +12,8 @@ export const useLogStream = (processId: string): UseLogStreamResult => {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [error, setError] = useState<string | null>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const retryCountRef = useRef<number>(0);
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!processId) {
@@ -22,53 +24,74 @@ export const useLogStream = (processId: string): UseLogStreamResult => {
setLogs([]);
setError(null);
const eventSource = new EventSource(
`/api/execution-processes/${processId}/raw-logs`
);
eventSourceRef.current = eventSource;
const open = () => {
const eventSource = new EventSource(
`/api/execution-processes/${processId}/raw-logs`
);
eventSourceRef.current = eventSource;
eventSource.onopen = () => {
setError(null);
eventSource.onopen = () => {
setError(null);
// Reset logs on new connection since server replays history
setLogs([]);
retryCountRef.current = 0;
};
const addLogEntry = (entry: LogEntry) => {
setLogs((prev) => [...prev, entry]);
};
// Handle json_patch events (new format from server)
eventSource.addEventListener('json_patch', (event) => {
try {
const patches = JSON.parse(event.data);
patches.forEach((patch: any) => {
const value = patch?.value;
if (!value || !value.type) return;
switch (value.type) {
case 'STDOUT':
case 'STDERR':
addLogEntry({ type: value.type, content: value.content });
break;
// Ignore other patch types (NORMALIZED_ENTRY, DIFF, etc.)
default:
break;
}
});
} catch (e) {
console.error('Failed to parse json_patch:', e);
}
});
eventSource.addEventListener('finished', () => {
eventSource.close();
});
eventSource.onerror = () => {
setError('Connection failed');
eventSource.close();
// Retry a few times with backoff in case of race before logs are ready
const next = retryCountRef.current + 1;
retryCountRef.current = next;
if (next <= 6) {
const delay = Math.min(1500, 250 * 2 ** (next - 1));
retryTimerRef.current = setTimeout(() => open(), delay);
}
};
};
const addLogEntry = (entry: LogEntry) => {
setLogs((prev) => [...prev, entry]);
};
// Handle json_patch events (new format from server)
eventSource.addEventListener('json_patch', (event) => {
try {
const patches = JSON.parse(event.data);
patches.forEach((patch: any) => {
const value = patch?.value;
if (!value || !value.type) return;
switch (value.type) {
case 'STDOUT':
case 'STDERR':
addLogEntry({ type: value.type, content: value.content });
break;
// Ignore other patch types (NORMALIZED_ENTRY, DIFF, etc.)
default:
break;
}
});
} catch (e) {
console.error('Failed to parse json_patch:', e);
}
});
eventSource.addEventListener('finished', () => {
eventSource.close();
});
eventSource.onerror = () => {
setError('Connection failed');
eventSource.close();
};
open();
return () => {
eventSource.close();
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
};
}, [processId]);

View File

@@ -5,6 +5,7 @@ import {
BranchStatus,
CheckTokenResponse,
Config,
CommitInfo,
CreateFollowUpAttempt,
CreateGitHubPrRequest,
CreateTask,
@@ -35,6 +36,8 @@ import {
UpdateMcpServersBody,
GetMcpServerResponse,
ImageResponse,
RestoreAttemptRequest,
RestoreAttemptResult,
} from 'shared/types';
// Re-export types for convenience
@@ -314,6 +317,26 @@ export const attemptsApi = {
return handleApiResponse<void>(response);
},
restore: async (
attemptId: string,
processId: string,
opts?: { forceWhenDirty?: boolean; performGitReset?: boolean }
): Promise<RestoreAttemptResult> => {
const body: RestoreAttemptRequest = {
process_id: processId,
force_when_dirty: opts?.forceWhenDirty ?? false,
perform_git_reset: opts?.performGitReset ?? true,
} as any;
const response = await makeRequest(
`/api/task-attempts/${attemptId}/restore`,
{
method: 'POST',
body: JSON.stringify(body),
}
);
return handleApiResponse<RestoreAttemptResult>(response);
},
followUp: async (
attemptId: string,
data: CreateFollowUpAttempt
@@ -424,6 +447,35 @@ export const attemptsApi = {
},
};
// Extra helpers
export const commitsApi = {
getInfo: async (attemptId: string, sha: string): Promise<CommitInfo> => {
const response = await makeRequest(
`/api/task-attempts/${attemptId}/commit-info?sha=${encodeURIComponent(
sha
)}`
);
return handleApiResponse<CommitInfo>(response);
},
compareToHead: async (
attemptId: string,
sha: string
): Promise<{
head_oid: string;
target_oid: string;
ahead_from_head: number;
behind_from_head: number;
is_linear: boolean;
}> => {
const response = await makeRequest(
`/api/task-attempts/${attemptId}/commit-compare?sha=${encodeURIComponent(
sha
)}`
);
return handleApiResponse(response);
},
};
// Execution Process APIs
export const executionProcessesApi = {
getExecutionProcesses: async (