Checkpoint restore feature (#607)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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:{' '}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user