Skip to content

Commit f62ed83

Browse files
authored
Handle Task/Build failure and restart (#370)
* ✨ (build): add restart functionality for failed builds with UI and API integration * ♻️ (restart-build): refactor error handling with ApiErrorHandler and improve type safety in use-restart-build hook * ♻️ (build): handle cancelled build status across components and hooks
1 parent 8ecb4b6 commit f62ed83

File tree

9 files changed

+412
-10
lines changed

9 files changed

+412
-10
lines changed

src/app/(logged-in)/projects/components/delete-project-dialog.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,11 @@ export default function DeleteProjectDialog({
198198
placeholder={project.name}
199199
value={repoConfirmationText}
200200
onChange={e => setRepoConfirmationText(e.target.value)}
201+
onKeyDown={e => {
202+
if (e.key === 'Enter' && isRepoConfirmationValid) {
203+
handleDelete();
204+
}
205+
}}
201206
className="text-sm"
202207
autoFocus
203208
/>

src/app/(project-workspace)/projects/[id]/components/chat/build-message.tsx

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@ import { Skeleton } from '@/components/ui/skeleton';
1616
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
1717
import { useBuildStatus } from '@/hooks/use-build-status';
1818
import { useCancelBuild } from '@/hooks/use-cancel-build';
19+
import { useLatestBuild } from '@/hooks/use-latest-build';
20+
import { useRestartBuild } from '@/hooks/use-restart-build';
1921
import type { BuildTask } from '@/lib/types/chat';
2022
import { cn } from '@/lib/utils';
21-
import { Circle, CircleCheck, CircleX, Loader2, Square, StopCircle } from 'lucide-react';
23+
import { Circle, CircleCheck, CircleX, Loader2, RotateCcw, Square, StopCircle } from 'lucide-react';
2224

2325
interface BuildMessageProps {
2426
buildJobId: string;
@@ -38,6 +40,13 @@ export function BuildMessage({ buildJobId, projectId, sessionId, className }: Bu
3840
// Cancel build mutation
3941
const cancelMutation = useCancelBuild({ projectId, sessionId, buildJobId });
4042

43+
// Restart build mutation
44+
const restartMutation = useRestartBuild({ projectId, sessionId, buildJobId });
45+
46+
// Check if this is the latest build (only show restart for latest failed build)
47+
const { data: latestBuildData } = useLatestBuild(projectId, sessionId);
48+
const isLatestBuild = latestBuildData?.buildJobId === buildJobId;
49+
4150
const buildJob = data?.buildJob;
4251
const progress = data?.progress;
4352
const tasks = data?.tasks || [];
@@ -100,7 +109,7 @@ export function BuildMessage({ buildJobId, projectId, sessionId, className }: Bu
100109
return <CircleX className="h-4 w-4 text-red-500 fill-red-500/10 shrink-0" />;
101110
}
102111
if (task.status === 'cancelled') {
103-
return <StopCircle className="h-4 w-4 text-destructive shrink-0" />;
112+
return <StopCircle className="h-4 w-4 text-muted-foreground shrink-0" />;
104113
}
105114
if (task.status === 'in_progress') {
106115
// Pulsing blue circle for running
@@ -190,6 +199,43 @@ export function BuildMessage({ buildJobId, projectId, sessionId, className }: Bu
190199
</AlertDialogContent>
191200
</AlertDialog>
192201
)}
202+
{(hasFailed || isCancelled) && isLatestBuild && (
203+
<AlertDialog>
204+
<Tooltip>
205+
<TooltipTrigger asChild>
206+
<AlertDialogTrigger asChild>
207+
<Button
208+
variant="ghost"
209+
size="icon"
210+
className="h-6 w-6 text-muted-foreground hover:text-primary"
211+
disabled={restartMutation.isPending}
212+
>
213+
{restartMutation.isPending ? (
214+
<Loader2 className="h-4 w-4 animate-spin" />
215+
) : (
216+
<RotateCcw className="h-3.5 w-3.5" />
217+
)}
218+
</Button>
219+
</AlertDialogTrigger>
220+
</TooltipTrigger>
221+
<TooltipContent>Restart build</TooltipContent>
222+
</Tooltip>
223+
<AlertDialogContent>
224+
<AlertDialogHeader>
225+
<AlertDialogTitle>Restart build?</AlertDialogTitle>
226+
<AlertDialogDescription>
227+
This will revert all changes and restart the build from scratch.
228+
</AlertDialogDescription>
229+
</AlertDialogHeader>
230+
<AlertDialogFooter>
231+
<AlertDialogCancel>Cancel</AlertDialogCancel>
232+
<AlertDialogAction onClick={() => restartMutation.mutate()}>
233+
Restart
234+
</AlertDialogAction>
235+
</AlertDialogFooter>
236+
</AlertDialogContent>
237+
</AlertDialog>
238+
)}
193239
</div>
194240
<span className="text-xs font-medium text-muted-foreground">{getStatusText()}</span>
195241
</div>
@@ -221,7 +267,7 @@ export function BuildMessage({ buildJobId, projectId, sessionId, className }: Bu
221267
'truncate',
222268
task.status === 'done' && 'text-muted-foreground line-through',
223269
task.status === 'error' && 'text-red-500',
224-
task.status === 'cancelled' && 'text-destructive',
270+
task.status === 'cancelled' && 'text-muted-foreground',
225271
task.status === 'in_progress' && 'text-blue-500 font-medium'
226272
)}
227273
>

src/app/(project-workspace)/projects/[id]/components/chat/chat-interface.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export default function ChatInterface({
2727
sessionId,
2828
model,
2929
isBuildInProgress = false,
30+
isBuildFailed = false,
3031
}: ChatInterfaceProps) {
3132
// Refs
3233
const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -174,6 +175,12 @@ export default function ChatInterface({
174175
}
175176
}
176177

178+
// Always show avatar for build messages (they should appear as separate messages)
179+
const hasBuildJobId = message.metadata && 'buildJobId' in message.metadata;
180+
if (hasBuildJobId) {
181+
showAvatar = true;
182+
}
183+
177184
return {
178185
...message,
179186
showAvatar,
@@ -333,8 +340,14 @@ export default function ChatInterface({
333340
isLoading={isSending || isRegenerating}
334341
isStreaming={isStreaming}
335342
onStop={cancelStream}
336-
placeholder={isBuildInProgress ? 'Build in progress...' : 'Type your message...'}
337-
disabled={isBuildInProgress}
343+
placeholder={
344+
isBuildFailed
345+
? 'Build stopped. Use the restart button above to try again.'
346+
: isBuildInProgress
347+
? 'Build in progress...'
348+
: 'Type your message...'
349+
}
350+
disabled={isBuildInProgress || isBuildFailed}
338351
data-testid="chat-input"
339352
className="chat-input"
340353
/>

src/app/(project-workspace)/projects/[id]/page.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,8 @@ export default function ProjectPage({ params }: ProjectPageProps) {
141141
const canCreatePR = latestBuildData?.status === 'completed';
142142
const isBuildInProgress =
143143
latestBuildData?.status === 'pending' || latestBuildData?.status === 'running';
144+
const isBuildFailed =
145+
latestBuildData?.status === 'failed' || latestBuildData?.status === 'cancelled';
144146

145147
// UI state management
146148
const [isChatCollapsed, setIsChatCollapsed] = useState(false);
@@ -371,6 +373,7 @@ export default function ProjectPage({ params }: ProjectPageProps) {
371373
sessionId={sessionId}
372374
model={project?.model}
373375
isBuildInProgress={isBuildInProgress}
376+
isBuildFailed={isBuildFailed}
374377
/>
375378
</div>
376379
)}
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
3+
import { ApiErrorHandler } from '@/lib/api/errors';
4+
import { auth } from '@/lib/auth';
5+
import { db } from '@/lib/db/drizzle';
6+
import { buildJobs, chatMessages, tasks } from '@/lib/db/schema';
7+
import { getGitHubToken } from '@/lib/github/client';
8+
import { findChatSession, verifyProjectAccess } from '@/lib/projects';
9+
import { buildQueue } from '@/lib/queue/queues/build';
10+
import { getSandboxConfig, getSandboxManager, SandboxClient } from '@/lib/sandbox';
11+
import { getSandboxDatabaseUrl } from '@/lib/sandbox/database';
12+
import { eq } from 'drizzle-orm';
13+
14+
/**
15+
* POST /api/projects/[id]/chat-sessions/[sessionId]/restart-build/[buildJobId]
16+
* Restart a failed or cancelled build by reverting git, resetting tickets, and starting a new build
17+
*/
18+
export async function POST(
19+
request: NextRequest,
20+
{ params }: { params: Promise<{ id: string; sessionId: string; buildJobId: string }> }
21+
) {
22+
try {
23+
// Authenticate user
24+
const { userId } = await auth();
25+
if (!userId) {
26+
return ApiErrorHandler.unauthorized();
27+
}
28+
29+
const { id: projectId, sessionId, buildJobId } = await params;
30+
31+
// Verify user has access to project through organization membership
32+
const { hasAccess, project } = await verifyProjectAccess(userId, projectId);
33+
34+
if (!hasAccess || !project) {
35+
return ApiErrorHandler.projectNotFound();
36+
}
37+
38+
// Get session info
39+
const session = await findChatSession(projectId, sessionId);
40+
41+
if (!session) {
42+
return ApiErrorHandler.chatSessionNotFound();
43+
}
44+
45+
// Get the failed build job
46+
const [failedBuild] = await db
47+
.select()
48+
.from(buildJobs)
49+
.where(eq(buildJobs.id, buildJobId))
50+
.limit(1);
51+
52+
if (!failedBuild) {
53+
return ApiErrorHandler.notFound('Build job not found');
54+
}
55+
56+
if (failedBuild.chatSessionId !== session.id) {
57+
return ApiErrorHandler.forbidden('Build job does not belong to this session');
58+
}
59+
60+
// Only allow restarting failed or cancelled builds
61+
if (failedBuild.status !== 'failed' && failedBuild.status !== 'cancelled') {
62+
return ApiErrorHandler.badRequest(
63+
`Can only restart failed or cancelled builds. Current status: ${failedBuild.status}`
64+
);
65+
}
66+
67+
console.log(
68+
`🔄 Restarting ${failedBuild.status} build job ${buildJobId} for session ${session.branchName}`
69+
);
70+
71+
// Get GitHub token for git reset
72+
const githubToken = await getGitHubToken(project.isImported, userId);
73+
74+
if (!githubToken) {
75+
return ApiErrorHandler.unauthorized('GitHub token not available');
76+
}
77+
78+
// Get sandbox client
79+
const sandboxManager = getSandboxManager();
80+
const sandbox = await sandboxManager.getSandbox(session.id);
81+
82+
if (!sandbox || sandbox.status !== 'running') {
83+
return ApiErrorHandler.badRequest('Sandbox is not running');
84+
}
85+
86+
const sandboxClient = new SandboxClient(session.id);
87+
88+
// 1. Revert git to startCommit if available
89+
let resetCommit: string | null = null;
90+
if (failedBuild.startCommit) {
91+
try {
92+
console.log(`🔄 Reverting to commit ${failedBuild.startCommit.substring(0, 8)}`);
93+
const result = await sandboxClient.revert(failedBuild.startCommit, githubToken);
94+
if (result.success) {
95+
resetCommit = failedBuild.startCommit;
96+
console.log(`✅ Git reset and force push successful`);
97+
} else {
98+
console.warn(`⚠️ Git reset failed: ${result.error}`);
99+
return ApiErrorHandler.badRequest(`Git reset failed: ${result.error}`);
100+
}
101+
} catch (error) {
102+
console.error(`❌ Git reset error:`, error);
103+
return ApiErrorHandler.badRequest('Git reset failed');
104+
}
105+
}
106+
107+
// 2. Get original tasks for the failed build to recreate them
108+
const originalTasks = await db.select().from(tasks).where(eq(tasks.buildJobId, buildJobId));
109+
110+
if (originalTasks.length === 0) {
111+
return ApiErrorHandler.badRequest('No tasks found for this build');
112+
}
113+
114+
// 3. Generate timestamp-based tickets path (same format as plan command)
115+
// Format: tickets/YYYY-MM-DD-HH-mm-ss.tickets.json
116+
const now = new Date();
117+
const timestamp = now
118+
.toISOString()
119+
.replace(/[T:]/g, '-')
120+
.replace(/\.\d{3}Z$/, '');
121+
const ticketsFileName = `${timestamp}.tickets.json`;
122+
const ticketsRelativePath = `tickets/${ticketsFileName}`;
123+
const ticketsAbsolutePath = `/app/project/${ticketsRelativePath}`;
124+
125+
// 4. Recreate tickets file from database tasks
126+
// (After git revert, tickets file doesn't exist since it was created during plan phase)
127+
try {
128+
const ticketsData = {
129+
tickets: originalTasks
130+
.sort((a, b) => a.order - b.order)
131+
.map(task => ({
132+
id: task.externalId,
133+
title: task.title,
134+
description: task.description,
135+
type: task.type || 'feature',
136+
category: task.category || 'general',
137+
estimatedEffort: task.estimatedEffort || 1,
138+
status: 'Todo',
139+
})),
140+
};
141+
142+
await sandboxClient.writeFile(ticketsAbsolutePath, JSON.stringify(ticketsData, null, 2));
143+
console.log(`✅ Created ${ticketsRelativePath} with ${ticketsData.tickets.length} tickets`);
144+
} catch (error) {
145+
console.error(`❌ Failed to create tickets file:`, error);
146+
return ApiErrorHandler.badRequest('Failed to create tickets file');
147+
}
148+
149+
// 5. Create new build job
150+
const sandboxConfig = getSandboxConfig();
151+
const testUrl = `http://localhost:${sandboxConfig.bunPort}`;
152+
153+
const [newBuildJob] = await db
154+
.insert(buildJobs)
155+
.values({
156+
projectId,
157+
chatSessionId: session.id,
158+
claudeSessionId: failedBuild.claudeSessionId, // Keep audit trail
159+
status: 'pending',
160+
})
161+
.returning();
162+
163+
console.log(`✅ Created new build job ${newBuildJob.id}`);
164+
165+
// 6. Create new tasks for the new build job (copy from original)
166+
await db.insert(tasks).values(
167+
originalTasks.map((task, index) => ({
168+
buildJobId: newBuildJob.id,
169+
externalId: task.externalId,
170+
title: task.title,
171+
description: task.description,
172+
type: task.type,
173+
category: task.category,
174+
estimatedEffort: task.estimatedEffort,
175+
order: index,
176+
status: 'todo' as const,
177+
}))
178+
);
179+
180+
console.log(`✅ Created ${originalTasks.length} tasks for new build`);
181+
182+
// 7. Create assistant message for the restarted build (empty content, build component shows)
183+
await db.insert(chatMessages).values({
184+
projectId,
185+
chatSessionId: session.id,
186+
userId,
187+
role: 'assistant',
188+
content: '',
189+
metadata: { buildJobId: newBuildJob.id },
190+
});
191+
192+
console.log(`✅ Created chat message for build ${newBuildJob.id}`);
193+
194+
// 8. Enqueue the new build
195+
await buildQueue.add('build', {
196+
buildJobId: newBuildJob.id,
197+
chatSessionId: session.id,
198+
projectId,
199+
sessionId: session.id,
200+
ticketsPath: ticketsRelativePath,
201+
cwd: '/app/project',
202+
dbUrl: getSandboxDatabaseUrl(session.id),
203+
githubToken,
204+
baseBranch: project.defaultBranch || 'main',
205+
enableReview: true,
206+
enableTest: sandboxConfig.test,
207+
testUrl,
208+
});
209+
210+
console.log(`🚀 Enqueued new build job ${newBuildJob.id}`);
211+
212+
return NextResponse.json({
213+
success: true,
214+
data: {
215+
originalBuildJobId: buildJobId,
216+
newBuildJobId: newBuildJob.id,
217+
resetCommit,
218+
tasksCount: originalTasks.length,
219+
message: `Build restarted${resetCommit ? ` from commit ${resetCommit.slice(0, 7)}` : ''}`,
220+
},
221+
});
222+
} catch (error) {
223+
console.error('Error restarting build:', error);
224+
return ApiErrorHandler.handle(error);
225+
}
226+
}

src/hooks/use-latest-build.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query';
22

33
interface LatestBuildResponse {
44
hasBuild: boolean;
5-
status: 'pending' | 'running' | 'completed' | 'failed' | null;
5+
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | null;
66
buildJobId: string | null;
77
}
88

0 commit comments

Comments
 (0)