Skip to content

Commit dee28b5

Browse files
authored
Stop Build Jobs (#363)
* ✨ (build): add build cancellation feature with UI integration and database support * ♻️ (build.ts): simplify error handling by removing redundant abort checks * ♻️ (queue): remove redundant build job cancellation logic for efficiency * ♻️ (build): remove bullJobId from build jobs and improve cost handling during cancellations * squashed migrations
1 parent 58d6502 commit dee28b5

File tree

16 files changed

+1970
-73
lines changed

16 files changed

+1970
-73
lines changed

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

Lines changed: 91 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,24 @@
11
'use client';
22

3+
import {
4+
AlertDialog,
5+
AlertDialogAction,
6+
AlertDialogCancel,
7+
AlertDialogContent,
8+
AlertDialogDescription,
9+
AlertDialogFooter,
10+
AlertDialogHeader,
11+
AlertDialogTitle,
12+
AlertDialogTrigger,
13+
} from '@/components/ui/alert-dialog';
14+
import { Button } from '@/components/ui/button';
315
import { Skeleton } from '@/components/ui/skeleton';
4-
import type { BuildJobResponse, BuildTask } from '@/lib/types/chat';
16+
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
17+
import { useBuildStatus } from '@/hooks/use-build-status';
18+
import { useCancelBuild } from '@/hooks/use-cancel-build';
19+
import type { BuildTask } from '@/lib/types/chat';
520
import { cn } from '@/lib/utils';
6-
import { useQuery } from '@tanstack/react-query';
7-
import { Circle, CircleCheck, CircleX, Loader2 } from 'lucide-react';
21+
import { Circle, CircleCheck, CircleX, Loader2, Square, StopCircle } from 'lucide-react';
822

923
interface BuildMessageProps {
1024
buildJobId: string;
@@ -19,29 +33,10 @@ interface BuildMessageProps {
1933
*/
2034
export function BuildMessage({ buildJobId, projectId, sessionId, className }: BuildMessageProps) {
2135
// Fetch build status with polling while active
22-
const { data, isLoading, error } = useQuery({
23-
queryKey: ['build-job', buildJobId],
24-
queryFn: async (): Promise<BuildJobResponse> => {
25-
const response = await fetch(
26-
`/api/projects/${projectId}/chat-sessions/${sessionId}/build-status/${buildJobId}`
27-
);
28-
if (!response.ok) {
29-
throw new Error('Failed to fetch build status');
30-
}
31-
return response.json();
32-
},
33-
// Poll every 5 seconds while build is active
34-
refetchInterval: query => {
35-
const buildJob = query.state.data?.buildJob;
36-
// Stop polling only when we have data AND build is done
37-
if (buildJob?.status === 'completed' || buildJob?.status === 'failed') {
38-
return false;
39-
}
40-
// Keep polling: no data yet, error recovery, or build in progress
41-
return 5000;
42-
},
43-
staleTime: 1000,
44-
});
36+
const { data, isLoading, error } = useBuildStatus({ projectId, sessionId, buildJobId });
37+
38+
// Cancel build mutation
39+
const cancelMutation = useCancelBuild({ projectId, sessionId, buildJobId });
4540

4641
const buildJob = data?.buildJob;
4742
const progress = data?.progress;
@@ -104,6 +99,9 @@ export function BuildMessage({ buildJobId, projectId, sessionId, className }: Bu
10499
if (task.status === 'error') {
105100
return <CircleX className="h-4 w-4 text-red-500 fill-red-500/10 shrink-0" />;
106101
}
102+
if (task.status === 'cancelled') {
103+
return <StopCircle className="h-4 w-4 text-destructive shrink-0" />;
104+
}
107105
if (task.status === 'in_progress') {
108106
// Pulsing blue circle for running
109107
return (
@@ -117,8 +115,31 @@ export function BuildMessage({ buildJobId, projectId, sessionId, className }: Bu
117115
return <Circle className="h-4 w-4 text-muted-foreground/50 shrink-0" />;
118116
};
119117

120-
// Build status is already set to 'failed' when any tasks fail
118+
// Build status states
121119
const hasFailed = buildJob.status === 'failed';
120+
const isCancelled = buildJob.status === 'cancelled';
121+
122+
// Get status icon
123+
const getStatusIcon = () => {
124+
if (isActive) {
125+
return <Loader2 className="h-5 w-5 text-blue-500 animate-spin" />;
126+
}
127+
if (isCancelled) {
128+
return <StopCircle className="h-5 w-5 text-destructive" />;
129+
}
130+
if (hasFailed) {
131+
return <CircleX className="h-5 w-5 text-red-500 fill-red-500/10" />;
132+
}
133+
return <CircleCheck className="h-5 w-5 text-green-500 fill-green-500/10" />;
134+
};
135+
136+
// Get status text
137+
const getStatusText = () => {
138+
if (isActive) return 'In progress';
139+
if (isCancelled) return 'Cancelled';
140+
if (hasFailed) return 'Failed';
141+
return 'Completed';
142+
};
122143

123144
return (
124145
<div className={cn('w-full', className)}>
@@ -127,20 +148,50 @@ export function BuildMessage({ buildJobId, projectId, sessionId, className }: Bu
127148
{/* Header */}
128149
<div className="flex items-center justify-between">
129150
<div className="flex items-center gap-2">
130-
<div className="shrink-0">
131-
{isActive ? (
132-
<Loader2 className="h-5 w-5 text-blue-500 animate-spin" />
133-
) : hasFailed ? (
134-
<CircleX className="h-5 w-5 text-red-500 fill-red-500/10" />
135-
) : (
136-
<CircleCheck className="h-5 w-5 text-green-500 fill-green-500/10" />
137-
)}
138-
</div>
151+
<div className="shrink-0">{getStatusIcon()}</div>
139152
<span className="text-sm font-semibold text-foreground">Build</span>
153+
{isActive && (
154+
<AlertDialog>
155+
<Tooltip>
156+
<TooltipTrigger asChild>
157+
<AlertDialogTrigger asChild>
158+
<Button
159+
variant="ghost"
160+
size="icon"
161+
className="h-6 w-6 text-muted-foreground hover:text-destructive"
162+
disabled={cancelMutation.isPending}
163+
>
164+
{cancelMutation.isPending ? (
165+
<Loader2 className="h-4 w-4 animate-spin" />
166+
) : (
167+
<Square className="h-3.5 w-3.5 fill-current" />
168+
)}
169+
</Button>
170+
</AlertDialogTrigger>
171+
</TooltipTrigger>
172+
<TooltipContent>Stop build</TooltipContent>
173+
</Tooltip>
174+
<AlertDialogContent>
175+
<AlertDialogHeader>
176+
<AlertDialogTitle>Stop build?</AlertDialogTitle>
177+
<AlertDialogDescription>
178+
This will cancel the current build. Any progress will be lost.
179+
</AlertDialogDescription>
180+
</AlertDialogHeader>
181+
<AlertDialogFooter>
182+
<AlertDialogCancel>Continue</AlertDialogCancel>
183+
<AlertDialogAction
184+
onClick={() => cancelMutation.mutate()}
185+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
186+
>
187+
Stop
188+
</AlertDialogAction>
189+
</AlertDialogFooter>
190+
</AlertDialogContent>
191+
</AlertDialog>
192+
)}
140193
</div>
141-
<span className="text-xs font-medium text-muted-foreground">
142-
{isActive ? 'In progress' : hasFailed ? 'Failed' : 'Completed'}
143-
</span>
194+
<span className="text-xs font-medium text-muted-foreground">{getStatusText()}</span>
144195
</div>
145196

146197
{/* Progress bar and description - only while active */}
@@ -170,6 +221,7 @@ export function BuildMessage({ buildJobId, projectId, sessionId, className }: Bu
170221
'truncate',
171222
task.status === 'done' && 'text-muted-foreground line-through',
172223
task.status === 'error' && 'text-red-500',
224+
task.status === 'cancelled' && 'text-destructive',
173225
task.status === 'in_progress' && 'text-blue-500 font-medium'
174226
)}
175227
>

src/app/api/projects/[id]/chat-sessions/[sessionId]/build-status/[buildJobId]/route.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,6 @@ export async function GET(
7777
createdAt: buildJob.createdAt,
7878
startedAt: buildJob.startedAt,
7979
completedAt: buildJob.completedAt,
80-
bullJobId: buildJob.bullJobId,
8180
},
8281
progress: {
8382
totalTasks,
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
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 } from '@/lib/db/schema';
7+
import { getGitHubToken } from '@/lib/github/client';
8+
import { findChatSession, verifyProjectAccess } from '@/lib/projects';
9+
import { cancelBuild } from '@/lib/queue';
10+
import { getSandboxManager, SandboxClient } from '@/lib/sandbox';
11+
import { eq } from 'drizzle-orm';
12+
13+
/**
14+
* POST /api/projects/[id]/chat-sessions/[sessionId]/cancel-build/[buildJobId]
15+
* Cancel a running or pending build job
16+
*/
17+
export async function POST(
18+
request: NextRequest,
19+
{ params }: { params: Promise<{ id: string; sessionId: string; buildJobId: string }> }
20+
) {
21+
try {
22+
// Authenticate user
23+
const { userId } = await auth();
24+
if (!userId) {
25+
return ApiErrorHandler.unauthorized();
26+
}
27+
28+
const { id: projectId, sessionId, buildJobId } = await params;
29+
30+
// Verify user has access to project through organization membership
31+
const { hasAccess, project } = await verifyProjectAccess(userId, projectId);
32+
33+
if (!hasAccess || !project) {
34+
return ApiErrorHandler.projectNotFound();
35+
}
36+
37+
// Get session info
38+
const session = await findChatSession(projectId, sessionId);
39+
40+
if (!session) {
41+
return ApiErrorHandler.chatSessionNotFound();
42+
}
43+
44+
// Verify build job exists and belongs to this session
45+
const [buildJob] = await db
46+
.select()
47+
.from(buildJobs)
48+
.where(eq(buildJobs.id, buildJobId))
49+
.limit(1);
50+
51+
if (!buildJob) {
52+
return ApiErrorHandler.notFound('Build job not found');
53+
}
54+
55+
if (buildJob.chatSessionId !== session.id) {
56+
return ApiErrorHandler.forbidden('Build job does not belong to this session');
57+
}
58+
59+
// Check if build is already completed/failed/cancelled
60+
if (['completed', 'failed', 'cancelled'].includes(buildJob.status)) {
61+
return NextResponse.json({
62+
success: false,
63+
error: `Build is already ${buildJob.status}`,
64+
});
65+
}
66+
67+
console.log(`🛑 Cancelling build job ${buildJobId} for session ${session.branchName}`);
68+
69+
// Get GitHub token for git reset
70+
const githubToken = await getGitHubToken(project.isImported, userId);
71+
72+
// Get sandbox client if sandbox is running
73+
let sandboxClient: SandboxClient | undefined;
74+
const sandboxManager = getSandboxManager();
75+
const sandbox = await sandboxManager.getSandbox(session.id);
76+
77+
if (sandbox && sandbox.status === 'running' && githubToken) {
78+
sandboxClient = new SandboxClient(session.id);
79+
}
80+
81+
// Cancel the build
82+
const result = await cancelBuild({
83+
buildJobId,
84+
sandboxClient,
85+
githubToken: githubToken || undefined,
86+
});
87+
88+
console.log(
89+
`✅ Build cancelled: ${result.cancelled} job(s) removed${result.resetCommit ? `, reverted to ${result.resetCommit.substring(0, 8)}` : ''}`
90+
);
91+
92+
return NextResponse.json({
93+
success: true,
94+
data: {
95+
cancelled: result.cancelled,
96+
resetCommit: result.resetCommit,
97+
message: result.resetCommit
98+
? `Build cancelled and code reverted to ${result.resetCommit.slice(0, 7)}`
99+
: 'Build cancelled',
100+
},
101+
});
102+
} catch (error) {
103+
console.error('Error cancelling build:', error);
104+
return ApiErrorHandler.handle(error);
105+
}
106+
}

src/app/api/projects/[id]/chat-sessions/[sessionId]/route.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,22 @@ export async function DELETE(
301301
return ApiErrorHandler.badRequest('Cannot delete default chat session');
302302
}
303303

304+
// Step 0: Cancel any active builds for this session
305+
try {
306+
console.log(`Cancelling any active builds for session ${session.id}`);
307+
const { cancelBuild } = await import('@/lib/queue');
308+
const cancelResult = await cancelBuild({ chatSessionId: session.id });
309+
if (cancelResult.cancelled > 0) {
310+
console.log(
311+
`Cancelled ${cancelResult.cancelled} active build(s) for session ${session.id}`
312+
);
313+
}
314+
} catch (cancelError) {
315+
Sentry.captureException(cancelError);
316+
console.error(`Error cancelling builds for session ${session.id}:`, cancelError);
317+
// Continue with deletion even if cancellation fails
318+
}
319+
304320
// Step 1: Close the associated PR if one exists
305321
if (session.pullRequestNumber && project.githubOwner && project.githubRepoName) {
306322
try {

src/app/api/projects/[id]/route.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,19 @@ export async function DELETE(
134134
// No body provided; keep defaults
135135
}
136136

137+
// Step 0: Cancel all active builds for this project
138+
try {
139+
console.log(`Cancelling all active builds for project ${projectId}`);
140+
const { cancelBuild } = await import('@/lib/queue');
141+
const cancelResult = await cancelBuild({ projectId });
142+
if (cancelResult.cancelled > 0) {
143+
console.log(`Cancelled ${cancelResult.cancelled} active build(s) for project ${projectId}`);
144+
}
145+
} catch (cancelError) {
146+
console.error(`Error cancelling builds for project ${projectId}:`, cancelError);
147+
// Continue with deletion even if cancellation fails
148+
}
149+
137150
// Step 1: Destroy all sandbox containers for this project
138151
try {
139152
console.log(`Destroying all sandboxes for project ${projectId}`);

src/hooks/use-build-status.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { BuildJobResponse } from '@/lib/types/chat';
2+
import { useQuery } from '@tanstack/react-query';
3+
4+
interface UseBuildStatusOptions {
5+
projectId: string;
6+
sessionId: string;
7+
buildJobId: string;
8+
}
9+
10+
/**
11+
* Hook for fetching and polling build job status
12+
*/
13+
export function useBuildStatus({ projectId, sessionId, buildJobId }: UseBuildStatusOptions) {
14+
return useQuery({
15+
queryKey: ['build-job', buildJobId],
16+
queryFn: async (): Promise<BuildJobResponse> => {
17+
const response = await fetch(
18+
`/api/projects/${projectId}/chat-sessions/${sessionId}/build-status/${buildJobId}`
19+
);
20+
if (!response.ok) {
21+
throw new Error('Failed to fetch build status');
22+
}
23+
return response.json();
24+
},
25+
// Poll every 5 seconds while build is active
26+
refetchInterval: query => {
27+
const buildJob = query.state.data?.buildJob;
28+
// Stop polling only when we have data AND build is done
29+
if (
30+
buildJob?.status === 'completed' ||
31+
buildJob?.status === 'failed' ||
32+
buildJob?.status === 'cancelled'
33+
) {
34+
return false;
35+
}
36+
// Keep polling: no data yet, error recovery, or build in progress
37+
return 5000;
38+
},
39+
staleTime: 1000,
40+
});
41+
}

0 commit comments

Comments
 (0)