Skip to content

Commit 96550c7

Browse files
authored
Merge pull request #2567 from trycompai/fix/remediation-preview-trigger-dev
fix(cloud-tests): move remediation preview to Trigger.dev
2 parents 44c5a78 + ef88b91 commit 96550c7

3 files changed

Lines changed: 205 additions & 52 deletions

File tree

apps/app/src/app/(app)/[orgId]/cloud-tests/actions/single-fix.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,50 @@ import { auth as triggerAuth, tasks } from '@trigger.dev/sdk';
44
import { auth } from '@/utils/auth';
55
import { headers } from 'next/headers';
66

7+
interface PreviewInput {
8+
connectionId: string;
9+
checkResultId: string;
10+
remediationKey: string;
11+
cachedPermissions?: string[];
12+
}
13+
14+
export async function startPreview(
15+
input: PreviewInput,
16+
): Promise<{ data?: { runId: string; accessToken: string }; error?: string }> {
17+
try {
18+
const session = await auth.api.getSession({
19+
headers: await headers(),
20+
});
21+
22+
if (!session?.user?.id) {
23+
return { error: 'Unauthorized' };
24+
}
25+
26+
const organizationId = session.session?.activeOrganizationId;
27+
if (!organizationId) {
28+
return { error: 'No active organization' };
29+
}
30+
31+
const handle = await tasks.trigger('remediate-preview', {
32+
connectionId: input.connectionId,
33+
organizationId,
34+
checkResultId: input.checkResultId,
35+
remediationKey: input.remediationKey,
36+
userId: session.user.id,
37+
cachedPermissions: input.cachedPermissions,
38+
});
39+
40+
const accessToken = await triggerAuth.createPublicToken({
41+
scopes: { read: { runs: [handle.id] } },
42+
});
43+
44+
return { data: { runId: handle.id, accessToken } };
45+
} catch (err) {
46+
console.error('Failed to start preview:', err);
47+
return { error: err instanceof Error ? err.message : 'Failed to load preview' };
48+
}
49+
}
50+
751
interface SingleFixInput {
852
connectionId: string;
953
checkResultId: string;

apps/app/src/app/(app)/[orgId]/cloud-tests/components/RemediationDialog.tsx

Lines changed: 79 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
'use client';
22

3-
import { useApi } from '@/hooks/use-api';
43
import { Badge } from '@trycompai/ui/badge';
54
import { Button } from '@trycompai/ui/button';
65
import {
@@ -14,10 +13,16 @@ import { useRealtimeRun } from '@trigger.dev/react-hooks';
1413
import { AlertTriangle, ListOrdered, Loader2, RotateCcw } from 'lucide-react';
1514
import { useCallback, useEffect, useRef, useState } from 'react';
1615
import { toast } from 'sonner';
17-
import { startSingleFix } from '../actions/single-fix';
16+
import { startPreview, startSingleFix } from '../actions/single-fix';
1817
import { AcknowledgmentPanel } from './AcknowledgmentPanel';
1918
import { PermissionErrorPanel } from './PermissionErrorPanel';
2019

20+
interface PreviewProgress {
21+
phase: 'analyzing' | 'complete' | 'failed';
22+
error?: string;
23+
preview?: PreviewData;
24+
}
25+
2126
interface SingleFixProgress {
2227
phase: 'executing' | 'success' | 'failed' | 'needs_permissions';
2328
error?: string;
@@ -267,7 +272,6 @@ export function RemediationDialog({
267272
description,
268273
onComplete,
269274
}: RemediationDialogProps) {
270-
const api = useApi();
271275
const [preview, setPreview] = useState<PreviewData | null>(null);
272276
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
273277
const [isExecuting, setIsExecuting] = useState(false);
@@ -277,18 +281,53 @@ export function RemediationDialog({
277281
const [permissionError, setPermissionError] = useState<{ missingActions: string[]; fixScript?: string } | null>(null);
278282
const [acknowledgment, setAcknowledgment] = useState<string | null>(null);
279283

280-
// Trigger.dev state for async execution
281-
const [runId, setRunId] = useState<string | null>(null);
282-
const [triggerAccessToken, setTriggerAccessToken] = useState<string | null>(null);
284+
// Trigger.dev state for preview (async)
285+
const [previewRunId, setPreviewRunId] = useState<string | null>(null);
286+
const [previewAccessToken, setPreviewAccessToken] = useState<string | null>(null);
283287

284-
const { run } = useRealtimeRun(runId ?? '', {
285-
accessToken: triggerAccessToken ?? undefined,
286-
enabled: Boolean(runId && triggerAccessToken),
288+
// Trigger.dev state for execute (async)
289+
const [executeRunId, setExecuteRunId] = useState<string | null>(null);
290+
const [executeAccessToken, setExecuteAccessToken] = useState<string | null>(null);
291+
292+
const { run: previewRun } = useRealtimeRun(previewRunId ?? '', {
293+
accessToken: previewAccessToken ?? undefined,
294+
enabled: Boolean(previewRunId && previewAccessToken),
287295
});
288296

289-
// Watch task progress and update dialog state
297+
const { run: executeRun } = useRealtimeRun(executeRunId ?? '', {
298+
accessToken: executeAccessToken ?? undefined,
299+
enabled: Boolean(executeRunId && executeAccessToken),
300+
});
301+
302+
// Ref to store permissions across rechecks (avoids stale closure in useCallback)
303+
const permissionsRef = useRef<string[] | undefined>(undefined);
304+
305+
// Watch preview task progress
290306
useEffect(() => {
291-
const progress = (run?.metadata as { progress?: SingleFixProgress } | undefined)?.progress;
307+
const progress = (previewRun?.metadata as { progress?: PreviewProgress } | undefined)?.progress;
308+
if (!progress || progress.phase === 'analyzing') return;
309+
310+
if (progress.phase === 'complete' && progress.preview) {
311+
const previewData = progress.preview as unknown as PreviewData;
312+
setPreview(previewData);
313+
if (previewData.allRequiredPermissions) {
314+
permissionsRef.current = previewData.allRequiredPermissions;
315+
}
316+
setIsLoadingPreview(false);
317+
setPreviewRunId(null);
318+
setPreviewAccessToken(null);
319+
} else if (progress.phase === 'failed') {
320+
setError(progress.error || 'Failed to load preview');
321+
setIsLoadingPreview(false);
322+
setPreviewRunId(null);
323+
setPreviewAccessToken(null);
324+
}
325+
// eslint-disable-next-line react-hooks/exhaustive-deps
326+
}, [previewRun?.metadata]);
327+
328+
// Watch execute task progress
329+
useEffect(() => {
330+
const progress = (executeRun?.metadata as { progress?: SingleFixProgress } | undefined)?.progress;
292331
if (!progress || progress.phase === 'executing') return;
293332

294333
if (progress.phase === 'success') {
@@ -301,73 +340,61 @@ export function RemediationDialog({
301340
setTimeout(() => {
302341
onOpenChange(false);
303342
setSucceeded(false);
304-
setRunId(null);
305-
setTriggerAccessToken(null);
343+
setExecuteRunId(null);
344+
setExecuteAccessToken(null);
306345
}, 4000);
307346
} else if (progress.phase === 'failed') {
308347
setIsExecuting(false);
309348
setError(progress.error || 'Remediation failed');
310-
setRunId(null);
311-
setTriggerAccessToken(null);
349+
setExecuteRunId(null);
350+
setExecuteAccessToken(null);
312351
} else if (progress.phase === 'needs_permissions') {
313352
setIsExecuting(false);
314353
setError(progress.error || 'Missing permissions');
315354
if (progress.permissionError) {
316355
setPermissionError(progress.permissionError);
317356
}
318-
setRunId(null);
319-
setTriggerAccessToken(null);
357+
setExecuteRunId(null);
358+
setExecuteAccessToken(null);
320359
}
321360
// eslint-disable-next-line react-hooks/exhaustive-deps
322-
}, [run?.metadata]);
323-
324-
// Ref to store permissions across rechecks (avoids stale closure in useCallback)
325-
const permissionsRef = useRef<string[] | undefined>(undefined);
361+
}, [executeRun?.metadata]);
326362

327363
const loadPreview = useCallback(async (recheck = false) => {
328364
setIsLoadingPreview(true);
329365
setError(null);
330366
try {
331-
const response = await api.post(
332-
'/v1/cloud-security/remediation/preview',
333-
{
334-
connectionId,
335-
checkResultId,
336-
remediationKey,
337-
// On recheck, send the cached permissions so backend doesn't re-run AI
338-
...(recheck && permissionsRef.current && {
339-
cachedPermissions: permissionsRef.current,
340-
}),
341-
},
342-
);
343-
if (response.error) {
344-
setError(
345-
typeof response.error === 'string'
346-
? response.error
347-
: 'Failed to load preview',
348-
);
367+
const result = await startPreview({
368+
connectionId,
369+
checkResultId,
370+
remediationKey,
371+
...(recheck && permissionsRef.current && {
372+
cachedPermissions: permissionsRef.current,
373+
}),
374+
});
375+
if (result.error || !result.data) {
376+
setError(result.error || 'Failed to load preview');
377+
setIsLoadingPreview(false);
349378
return;
350379
}
351-
const previewData = response.data as PreviewData;
352-
setPreview(previewData);
353-
// Store permissions in ref so Recheck can access them without stale closure
354-
if (previewData.allRequiredPermissions) {
355-
permissionsRef.current = previewData.allRequiredPermissions;
356-
}
380+
// Task started — preview effect handles the rest
381+
setPreviewRunId(result.data.runId);
382+
setPreviewAccessToken(result.data.accessToken);
357383
} catch {
358384
setError('Failed to load preview');
359-
} finally {
360385
setIsLoadingPreview(false);
361386
}
362-
}, [api, connectionId, checkResultId, remediationKey]);
387+
}, [connectionId, checkResultId, remediationKey]);
363388

364389
useEffect(() => {
365390
if (!open) return;
366391
setError(null);
367392
setPermissionError(null);
368393
setAcknowledgment(null);
369-
setRunId(null);
370-
setTriggerAccessToken(null);
394+
setPreviewRunId(null);
395+
setPreviewAccessToken(null);
396+
setExecuteRunId(null);
397+
setExecuteAccessToken(null);
371398
setSucceeded(false);
372399

373400
// Guided-only: skip API call, use local data
@@ -406,9 +433,9 @@ export function RemediationDialog({
406433
setIsExecuting(false);
407434
return;
408435
}
409-
// Task started — useRealtimeRun effect handles the rest
410-
setRunId(result.data.runId);
411-
setTriggerAccessToken(result.data.accessToken);
436+
// Task started — execute effect handles the rest
437+
setExecuteRunId(result.data.runId);
438+
setExecuteAccessToken(result.data.accessToken);
412439
} catch {
413440
setError('Failed to start fix');
414441
setIsExecuting(false);
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { logger, metadata, task } from '@trigger.dev/sdk';
2+
3+
interface PreviewProgress {
4+
phase: 'analyzing' | 'complete' | 'failed';
5+
error?: string;
6+
preview?: Record<string, unknown>;
7+
}
8+
9+
const getApiBaseUrl = () =>
10+
process.env.NEXT_PUBLIC_API_URL || process.env.API_BASE_URL || 'http://localhost:3333';
11+
12+
function makeHeaders(organizationId: string, userId?: string): Record<string, string> {
13+
return {
14+
'Content-Type': 'application/json',
15+
'x-service-token': process.env.SERVICE_TOKEN_TRIGGER!,
16+
'x-organization-id': organizationId,
17+
...(userId && { 'x-user-id': userId }),
18+
};
19+
}
20+
21+
function sync(progress: PreviewProgress) {
22+
metadata.set('progress', JSON.parse(JSON.stringify(progress)));
23+
}
24+
25+
export const remediatePreview = task({
26+
id: 'remediate-preview',
27+
maxDuration: 60 * 3, // 3 minutes
28+
retry: { maxAttempts: 1 },
29+
run: async (payload: {
30+
connectionId: string;
31+
organizationId: string;
32+
checkResultId: string;
33+
remediationKey: string;
34+
userId: string;
35+
cachedPermissions?: string[];
36+
}) => {
37+
const { connectionId, organizationId, checkResultId, remediationKey, userId, cachedPermissions } = payload;
38+
39+
logger.info(`Preview: ${remediationKey} on ${checkResultId} (user: ${userId})`);
40+
41+
const progress: PreviewProgress = { phase: 'analyzing' };
42+
sync(progress);
43+
44+
try {
45+
const url = `${getApiBaseUrl()}/v1/cloud-security/remediation/preview`;
46+
const resp = await fetch(url, {
47+
method: 'POST',
48+
headers: makeHeaders(organizationId, userId),
49+
body: JSON.stringify({
50+
connectionId,
51+
checkResultId,
52+
remediationKey,
53+
...(cachedPermissions && { cachedPermissions }),
54+
}),
55+
});
56+
57+
const json = await resp.json();
58+
59+
if (!resp.ok) {
60+
const errorMsg = (json as { message?: string }).message ?? `HTTP ${resp.status}`;
61+
progress.phase = 'failed';
62+
progress.error = errorMsg;
63+
sync(progress);
64+
logger.error(`Preview failed: ${errorMsg}`);
65+
return { success: false, error: errorMsg };
66+
}
67+
68+
progress.phase = 'complete';
69+
progress.preview = json as Record<string, unknown>;
70+
sync(progress);
71+
logger.info(`Preview complete for ${remediationKey}`);
72+
return { success: true, preview: json };
73+
} catch (err) {
74+
const errorMsg = err instanceof Error ? err.message : String(err);
75+
progress.phase = 'failed';
76+
progress.error = errorMsg;
77+
sync(progress);
78+
logger.error(`Preview exception: ${errorMsg}`);
79+
return { success: false, error: errorMsg };
80+
}
81+
},
82+
});

0 commit comments

Comments
 (0)