Add job cancellation functionality, including UI updates for canceling jobs in the TestBlueprintCard and jobs route. Enhance job management with cancellation handling in the API and database schema, ensuring proper status updates and error messages for canceled jobs.
Browse files
apps/web/src/components/generate/TestBlueprintCard.tsx
CHANGED
|
@@ -18,6 +18,8 @@ interface TestBlueprintCardProps {
|
|
| 18 |
jobProgressMessage?: string | null;
|
| 19 |
error: string | null;
|
| 20 |
onGenerate: () => void;
|
|
|
|
|
|
|
| 21 |
}
|
| 22 |
|
| 23 |
export function TestBlueprintCard({
|
|
@@ -34,6 +36,8 @@ export function TestBlueprintCard({
|
|
| 34 |
jobProgressMessage,
|
| 35 |
error,
|
| 36 |
onGenerate,
|
|
|
|
|
|
|
| 37 |
}: TestBlueprintCardProps) {
|
| 38 |
return (
|
| 39 |
<div className="lg:col-span-4 sticky top-8">
|
|
@@ -147,7 +151,7 @@ export function TestBlueprintCard({
|
|
| 147 |
<div className="space-y-2">
|
| 148 |
<div className="flex items-center gap-3 p-3 rounded-[var(--radius-md)] bg-[var(--matcha-300)]/20 border-2 border-[var(--matcha-300)]">
|
| 149 |
<MaterialIcon name="psychology" className="text-[var(--matcha-600)] animate-pulse" />
|
| 150 |
-
<div>
|
| 151 |
<p className="text-sm font-semibold text-[var(--matcha-800)]">
|
| 152 |
{jobProgressMessage ?? "Sedang berjalan..."}
|
| 153 |
</p>
|
|
@@ -164,6 +168,19 @@ export function TestBlueprintCard({
|
|
| 164 |
style={{ width: `${jobProgress}%` }}
|
| 165 |
/>
|
| 166 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
<p className="text-xs text-[var(--warm-charcoal)] text-center">
|
| 168 |
Bisa ditinggal — hasil akan muncul otomatis
|
| 169 |
</p>
|
|
|
|
| 18 |
jobProgressMessage?: string | null;
|
| 19 |
error: string | null;
|
| 20 |
onGenerate: () => void;
|
| 21 |
+
onCancelJob?: () => void;
|
| 22 |
+
cancelJobPending?: boolean;
|
| 23 |
}
|
| 24 |
|
| 25 |
export function TestBlueprintCard({
|
|
|
|
| 36 |
jobProgressMessage,
|
| 37 |
error,
|
| 38 |
onGenerate,
|
| 39 |
+
onCancelJob,
|
| 40 |
+
cancelJobPending,
|
| 41 |
}: TestBlueprintCardProps) {
|
| 42 |
return (
|
| 43 |
<div className="lg:col-span-4 sticky top-8">
|
|
|
|
| 151 |
<div className="space-y-2">
|
| 152 |
<div className="flex items-center gap-3 p-3 rounded-[var(--radius-md)] bg-[var(--matcha-300)]/20 border-2 border-[var(--matcha-300)]">
|
| 153 |
<MaterialIcon name="psychology" className="text-[var(--matcha-600)] animate-pulse" />
|
| 154 |
+
<div className="min-w-0 flex-1">
|
| 155 |
<p className="text-sm font-semibold text-[var(--matcha-800)]">
|
| 156 |
{jobProgressMessage ?? "Sedang berjalan..."}
|
| 157 |
</p>
|
|
|
|
| 168 |
style={{ width: `${jobProgress}%` }}
|
| 169 |
/>
|
| 170 |
</div>
|
| 171 |
+
{onCancelJob && (
|
| 172 |
+
<Button
|
| 173 |
+
type="button"
|
| 174 |
+
variant="outline"
|
| 175 |
+
size="sm"
|
| 176 |
+
className="w-full rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] text-[var(--warm-charcoal)]"
|
| 177 |
+
disabled={cancelJobPending}
|
| 178 |
+
onClick={onCancelJob}
|
| 179 |
+
>
|
| 180 |
+
<MaterialIcon name="cancel" className="text-sm mr-1" />
|
| 181 |
+
Batalkan job
|
| 182 |
+
</Button>
|
| 183 |
+
)}
|
| 184 |
<p className="text-xs text-[var(--warm-charcoal)] text-center">
|
| 185 |
Bisa ditinggal — hasil akan muncul otomatis
|
| 186 |
</p>
|
apps/web/src/hooks/use-generation-job.ts
CHANGED
|
@@ -44,14 +44,18 @@ export function useGenerationJob() {
|
|
| 44 |
refetchInterval: (query) => {
|
| 45 |
const data = query.state.data;
|
| 46 |
if (!data) return 1000;
|
| 47 |
-
if (data.status === "completed" || data.status === "failed"
|
|
|
|
| 48 |
return 1000;
|
| 49 |
},
|
| 50 |
});
|
| 51 |
|
| 52 |
const isGenerating =
|
| 53 |
jobId !== null &&
|
| 54 |
-
(!jobQuery.data ||
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
// Handle completion / failure
|
| 57 |
useEffect(() => {
|
|
@@ -65,6 +69,10 @@ export function useGenerationJob() {
|
|
| 65 |
setError(jobQuery.data.errorMessage ?? "Generation failed");
|
| 66 |
setJobId(null);
|
| 67 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
}, [jobQuery.data, setJobId]);
|
| 69 |
|
| 70 |
const reset = useCallback(() => {
|
|
|
|
| 44 |
refetchInterval: (query) => {
|
| 45 |
const data = query.state.data;
|
| 46 |
if (!data) return 1000;
|
| 47 |
+
if (data.status === "completed" || data.status === "failed" || data.status === "cancelled")
|
| 48 |
+
return false;
|
| 49 |
return 1000;
|
| 50 |
},
|
| 51 |
});
|
| 52 |
|
| 53 |
const isGenerating =
|
| 54 |
jobId !== null &&
|
| 55 |
+
(!jobQuery.data ||
|
| 56 |
+
(jobQuery.data.status !== "completed" &&
|
| 57 |
+
jobQuery.data.status !== "failed" &&
|
| 58 |
+
jobQuery.data.status !== "cancelled"));
|
| 59 |
|
| 60 |
// Handle completion / failure
|
| 61 |
useEffect(() => {
|
|
|
|
| 69 |
setError(jobQuery.data.errorMessage ?? "Generation failed");
|
| 70 |
setJobId(null);
|
| 71 |
}
|
| 72 |
+
if (jobQuery.data?.status === "cancelled") {
|
| 73 |
+
setError(jobQuery.data.errorMessage ?? "Generasi dibatalkan");
|
| 74 |
+
setJobId(null);
|
| 75 |
+
}
|
| 76 |
}, [jobQuery.data, setJobId]);
|
| 77 |
|
| 78 |
const reset = useCallback(() => {
|
apps/web/src/routes/generate.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import { useState } from "react";
|
|
| 2 |
import { useMutation } from "@tanstack/react-query";
|
| 3 |
import { createFileRoute, redirect, Link } from "@tanstack/react-router";
|
| 4 |
import { authClient } from "@/lib/auth-client";
|
| 5 |
-
import { trpc } from "@/utils/trpc";
|
| 6 |
import { useApiKey } from "@/hooks/use-api-key";
|
| 7 |
import { useGenerationJob } from "@/hooks/use-generation-job";
|
| 8 |
import { Input } from "@labas/ui/components/input";
|
|
@@ -36,6 +36,7 @@ function RouteComponent() {
|
|
| 36 |
result,
|
| 37 |
error,
|
| 38 |
generatedPackageId,
|
|
|
|
| 39 |
isGenerating,
|
| 40 |
jobQuery,
|
| 41 |
setError,
|
|
@@ -57,7 +58,6 @@ function RouteComponent() {
|
|
| 57 |
onSuccess: (data) => {
|
| 58 |
setJobId(data.jobId);
|
| 59 |
setError(null);
|
| 60 |
-
reset();
|
| 61 |
},
|
| 62 |
onError: (err) => {
|
| 63 |
setError(err.message);
|
|
@@ -65,6 +65,18 @@ function RouteComponent() {
|
|
| 65 |
},
|
| 66 |
});
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
const toggleFormat = (id: string) => {
|
| 69 |
setSelectedFormats((prev) =>
|
| 70 |
prev.includes(id) ? prev.filter((f) => f !== id) : [...prev, id],
|
|
@@ -303,6 +315,14 @@ function RouteComponent() {
|
|
| 303 |
jobProgressMessage={jobQuery.data?.progressMessage}
|
| 304 |
error={error}
|
| 305 |
onGenerate={handleGenerate}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
/>
|
| 307 |
</div>
|
| 308 |
|
|
|
|
| 2 |
import { useMutation } from "@tanstack/react-query";
|
| 3 |
import { createFileRoute, redirect, Link } from "@tanstack/react-router";
|
| 4 |
import { authClient } from "@/lib/auth-client";
|
| 5 |
+
import { queryClient, trpc } from "@/utils/trpc";
|
| 6 |
import { useApiKey } from "@/hooks/use-api-key";
|
| 7 |
import { useGenerationJob } from "@/hooks/use-generation-job";
|
| 8 |
import { Input } from "@labas/ui/components/input";
|
|
|
|
| 36 |
result,
|
| 37 |
error,
|
| 38 |
generatedPackageId,
|
| 39 |
+
jobId,
|
| 40 |
isGenerating,
|
| 41 |
jobQuery,
|
| 42 |
setError,
|
|
|
|
| 58 |
onSuccess: (data) => {
|
| 59 |
setJobId(data.jobId);
|
| 60 |
setError(null);
|
|
|
|
| 61 |
},
|
| 62 |
onError: (err) => {
|
| 63 |
setError(err.message);
|
|
|
|
| 65 |
},
|
| 66 |
});
|
| 67 |
|
| 68 |
+
const cancelJob = useMutation({
|
| 69 |
+
...trpc.ai.cancelJob.mutationOptions(),
|
| 70 |
+
onSuccess: async () => {
|
| 71 |
+
setJobId(null);
|
| 72 |
+
setError(null);
|
| 73 |
+
await queryClient.invalidateQueries({ queryKey: trpc.ai.myJobs.queryKey() });
|
| 74 |
+
},
|
| 75 |
+
onError: (err) => {
|
| 76 |
+
setError(err.message);
|
| 77 |
+
},
|
| 78 |
+
});
|
| 79 |
+
|
| 80 |
const toggleFormat = (id: string) => {
|
| 81 |
setSelectedFormats((prev) =>
|
| 82 |
prev.includes(id) ? prev.filter((f) => f !== id) : [...prev, id],
|
|
|
|
| 315 |
jobProgressMessage={jobQuery.data?.progressMessage}
|
| 316 |
error={error}
|
| 317 |
onGenerate={handleGenerate}
|
| 318 |
+
onCancelJob={
|
| 319 |
+
jobId
|
| 320 |
+
? () => {
|
| 321 |
+
cancelJob.mutate({ jobId });
|
| 322 |
+
}
|
| 323 |
+
: undefined
|
| 324 |
+
}
|
| 325 |
+
cancelJobPending={cancelJob.isPending}
|
| 326 |
/>
|
| 327 |
</div>
|
| 328 |
|
apps/web/src/routes/jobs.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import { useState } from "react";
|
| 2 |
-
import { useQuery } from "@tanstack/react-query";
|
| 3 |
import { createFileRoute, redirect, Link } from "@tanstack/react-router";
|
| 4 |
import { authClient } from "@/lib/auth-client";
|
| 5 |
import { trpc } from "@/utils/trpc";
|
|
@@ -24,6 +24,7 @@ const STATUS_COLORS: Record<string, string> = {
|
|
| 24 |
running: "bg-[var(--matcha-300)] text-[var(--matcha-800)]",
|
| 25 |
completed: "bg-[var(--lemon-300)] text-[var(--lemon-800)]",
|
| 26 |
failed: "bg-[var(--pomegranate-400)]/20 text-[var(--pomegranate-400)]",
|
|
|
|
| 27 |
};
|
| 28 |
|
| 29 |
const STATUS_ICONS: Record<string, string> = {
|
|
@@ -31,6 +32,7 @@ const STATUS_ICONS: Record<string, string> = {
|
|
| 31 |
running: "sync",
|
| 32 |
completed: "check_circle",
|
| 33 |
failed: "error",
|
|
|
|
| 34 |
};
|
| 35 |
|
| 36 |
function formatDate(d: string | Date | null) {
|
|
@@ -46,9 +48,18 @@ function formatDate(d: string | Date | null) {
|
|
| 46 |
|
| 47 |
function RouteComponent() {
|
| 48 |
const [expandedJobId, setExpandedJobId] = useState<string | null>(null);
|
|
|
|
| 49 |
|
| 50 |
const jobsQuery = useQuery(trpc.ai.myJobs.queryOptions({ limit: 50, offset: 0 }));
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
const toggleExpand = (id: string) => {
|
| 53 |
setExpandedJobId((prev) => (prev === id ? null : id));
|
| 54 |
};
|
|
@@ -116,8 +127,23 @@ function RouteComponent() {
|
|
| 116 |
{job.mode === "agentic" ? "Agentic" : "Quick"}
|
| 117 |
</div>
|
| 118 |
</div>
|
| 119 |
-
<div className="
|
| 120 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
</div>
|
| 122 |
</div>
|
| 123 |
|
|
@@ -212,6 +238,15 @@ function RouteComponent() {
|
|
| 212 |
</div>
|
| 213 |
</CardContent>
|
| 214 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
</Card>
|
| 216 |
);
|
| 217 |
})}
|
|
|
|
| 1 |
import { useState } from "react";
|
| 2 |
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
| 3 |
import { createFileRoute, redirect, Link } from "@tanstack/react-router";
|
| 4 |
import { authClient } from "@/lib/auth-client";
|
| 5 |
import { trpc } from "@/utils/trpc";
|
|
|
|
| 24 |
running: "bg-[var(--matcha-300)] text-[var(--matcha-800)]",
|
| 25 |
completed: "bg-[var(--lemon-300)] text-[var(--lemon-800)]",
|
| 26 |
failed: "bg-[var(--pomegranate-400)]/20 text-[var(--pomegranate-400)]",
|
| 27 |
+
cancelled: "bg-[var(--warm-silver)] text-[var(--warm-charcoal)]",
|
| 28 |
};
|
| 29 |
|
| 30 |
const STATUS_ICONS: Record<string, string> = {
|
|
|
|
| 32 |
running: "sync",
|
| 33 |
completed: "check_circle",
|
| 34 |
failed: "error",
|
| 35 |
+
cancelled: "block",
|
| 36 |
};
|
| 37 |
|
| 38 |
function formatDate(d: string | Date | null) {
|
|
|
|
| 48 |
|
| 49 |
function RouteComponent() {
|
| 50 |
const [expandedJobId, setExpandedJobId] = useState<string | null>(null);
|
| 51 |
+
const queryClient = useQueryClient();
|
| 52 |
|
| 53 |
const jobsQuery = useQuery(trpc.ai.myJobs.queryOptions({ limit: 50, offset: 0 }));
|
| 54 |
|
| 55 |
+
const cancelJob = useMutation({
|
| 56 |
+
...trpc.ai.cancelJob.mutationOptions(),
|
| 57 |
+
onSuccess: async () => {
|
| 58 |
+
await queryClient.invalidateQueries({ queryKey: trpc.ai.myJobs.queryKey() });
|
| 59 |
+
await queryClient.invalidateQueries({ queryKey: trpc.ai.getJobStatus.queryKey() });
|
| 60 |
+
},
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
const toggleExpand = (id: string) => {
|
| 64 |
setExpandedJobId((prev) => (prev === id ? null : id));
|
| 65 |
};
|
|
|
|
| 127 |
{job.mode === "agentic" ? "Agentic" : "Quick"}
|
| 128 |
</div>
|
| 129 |
</div>
|
| 130 |
+
<div className="flex items-center gap-2">
|
| 131 |
+
{(job.status === "pending" || job.status === "running") && (
|
| 132 |
+
<Button
|
| 133 |
+
type="button"
|
| 134 |
+
variant="outline"
|
| 135 |
+
size="sm"
|
| 136 |
+
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] text-xs shrink-0"
|
| 137 |
+
disabled={cancelJob.isPending}
|
| 138 |
+
onClick={() => cancelJob.mutate({ jobId: job.id })}
|
| 139 |
+
>
|
| 140 |
+
<MaterialIcon name="cancel" className="text-sm mr-1" />
|
| 141 |
+
Batalkan
|
| 142 |
+
</Button>
|
| 143 |
+
)}
|
| 144 |
+
<div className="text-xs text-[var(--warm-charcoal)]">
|
| 145 |
+
{formatDate(job.createdAt)}
|
| 146 |
+
</div>
|
| 147 |
</div>
|
| 148 |
</div>
|
| 149 |
|
|
|
|
| 238 |
</div>
|
| 239 |
</CardContent>
|
| 240 |
)}
|
| 241 |
+
|
| 242 |
+
{job.status === "cancelled" && (
|
| 243 |
+
<CardContent className="pt-0">
|
| 244 |
+
<div className="p-3 rounded-[var(--radius-md)] bg-[var(--warm-silver)]/40 text-[var(--warm-charcoal)] text-sm border-2 border-[var(--oat-border)]">
|
| 245 |
+
<MaterialIcon name="block" className="text-sm mr-1" />
|
| 246 |
+
{job.errorMessage ?? "Job dibatalkan"}
|
| 247 |
+
</div>
|
| 248 |
+
</CardContent>
|
| 249 |
+
)}
|
| 250 |
</Card>
|
| 251 |
);
|
| 252 |
})}
|
packages/api/src/queue.ts
CHANGED
|
@@ -4,10 +4,94 @@ import { env } from "@labas/env/server";
|
|
| 4 |
import { generateQuestionsQuick, generateQuestionsAgentic, type GenerationInput } from "@labas/ai";
|
| 5 |
import { db } from "@labas/db";
|
| 6 |
import { generationJob, question, testPackage, packageSection, sectionQuestion } from "@labas/db";
|
| 7 |
-
import { eq } from "drizzle-orm";
|
| 8 |
|
| 9 |
const redisConnection = new IORedis(env.REDIS_URL, { maxRetriesPerRequest: null });
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
export const generationQueue = new Queue("generation", {
|
| 12 |
connection: redisConnection,
|
| 13 |
});
|
|
@@ -18,7 +102,32 @@ export const generationWorker = new Worker(
|
|
| 18 |
const { input, jobId } = job.data;
|
| 19 |
const start = Date.now();
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
const updateProgress = async (progress: number, message: string) => {
|
|
|
|
| 22 |
await job.updateProgress(progress);
|
| 23 |
await db
|
| 24 |
.update(generationJob)
|
|
@@ -46,11 +155,6 @@ export const generationWorker = new Worker(
|
|
| 46 |
};
|
| 47 |
|
| 48 |
try {
|
| 49 |
-
await db
|
| 50 |
-
.update(generationJob)
|
| 51 |
-
.set({ status: "running" })
|
| 52 |
-
.where(eq(generationJob.id, jobId));
|
| 53 |
-
|
| 54 |
if (input.mode === "agentic") {
|
| 55 |
await updateProgress(10, "Generating passage...");
|
| 56 |
}
|
|
@@ -65,6 +169,7 @@ export const generationWorker = new Worker(
|
|
| 65 |
const result =
|
| 66 |
input.mode === "agentic"
|
| 67 |
? await generateQuestionsAgentic(input, async (p) => {
|
|
|
|
| 68 |
const stepProgress = Math.min(
|
| 69 |
10 + Math.round((p.currentStep / p.steps.length) * 80),
|
| 70 |
90,
|
|
@@ -74,6 +179,7 @@ export const generationWorker = new Worker(
|
|
| 74 |
})
|
| 75 |
: await generateQuestionsQuick(input, {
|
| 76 |
onToken: (token) => {
|
|
|
|
| 77 |
countToken(token);
|
| 78 |
// Update progress message with token count every ~500 chars
|
| 79 |
if (approxTokens % 20 === 0) {
|
|
@@ -87,7 +193,7 @@ export const generationWorker = new Worker(
|
|
| 87 |
},
|
| 88 |
});
|
| 89 |
|
| 90 |
-
|
| 91 |
await updateProgress(95, "Saving to bank...");
|
| 92 |
|
| 93 |
// Idempotent auto-save: check if already saved
|
|
@@ -192,6 +298,7 @@ export const generationWorker = new Worker(
|
|
| 192 |
|
| 193 |
await updateProgress(100, "Completed");
|
| 194 |
|
|
|
|
| 195 |
await db
|
| 196 |
.update(generationJob)
|
| 197 |
.set({
|
|
@@ -203,7 +310,18 @@ export const generationWorker = new Worker(
|
|
| 203 |
})
|
| 204 |
.where(eq(generationJob.id, jobId));
|
| 205 |
} catch (err: any) {
|
| 206 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
await db
|
| 208 |
.update(generationJob)
|
| 209 |
.set({
|
|
@@ -214,6 +332,9 @@ export const generationWorker = new Worker(
|
|
| 214 |
})
|
| 215 |
.where(eq(generationJob.id, jobId));
|
| 216 |
throw err;
|
|
|
|
|
|
|
|
|
|
| 217 |
}
|
| 218 |
},
|
| 219 |
{
|
|
|
|
| 4 |
import { generateQuestionsQuick, generateQuestionsAgentic, type GenerationInput } from "@labas/ai";
|
| 5 |
import { db } from "@labas/db";
|
| 6 |
import { generationJob, question, testPackage, packageSection, sectionQuestion } from "@labas/db";
|
| 7 |
+
import { and, eq, notInArray } from "drizzle-orm";
|
| 8 |
|
| 9 |
const redisConnection = new IORedis(env.REDIS_URL, { maxRetriesPerRequest: null });
|
| 10 |
|
| 11 |
+
/** Thrown when the job was cancelled (DB status or cooperative poll). */
|
| 12 |
+
export class GenerationJobCancelledError extends Error {
|
| 13 |
+
constructor() {
|
| 14 |
+
super("JOB_CANCELLED");
|
| 15 |
+
this.name = "GenerationJobCancelledError";
|
| 16 |
+
}
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
const CANCEL_POLL_MS = 500;
|
| 20 |
+
|
| 21 |
+
function createCancellationPoller(jobId: string) {
|
| 22 |
+
let cancelled = false;
|
| 23 |
+
const interval = setInterval(() => {
|
| 24 |
+
db
|
| 25 |
+
.select({ status: generationJob.status })
|
| 26 |
+
.from(generationJob)
|
| 27 |
+
.where(eq(generationJob.id, jobId))
|
| 28 |
+
.limit(1)
|
| 29 |
+
.then(([row]) => {
|
| 30 |
+
if (row?.status === "cancelled") cancelled = true;
|
| 31 |
+
})
|
| 32 |
+
.catch(() => {});
|
| 33 |
+
}, CANCEL_POLL_MS);
|
| 34 |
+
return {
|
| 35 |
+
stop: () => clearInterval(interval),
|
| 36 |
+
check: () => {
|
| 37 |
+
if (cancelled) throw new GenerationJobCancelledError();
|
| 38 |
+
},
|
| 39 |
+
};
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export type CancelGenerationJobResult =
|
| 43 |
+
| { ok: true }
|
| 44 |
+
| { ok: false; reason: "not_found" | "forbidden" | "not_cancellable" };
|
| 45 |
+
|
| 46 |
+
/**
|
| 47 |
+
* Marks the job cancelled in Postgres, then removes the BullMQ job if it is still waiting/delayed.
|
| 48 |
+
* Active jobs are stopped cooperatively by the worker (cancellation poller).
|
| 49 |
+
*/
|
| 50 |
+
export async function cancelGenerationJob(
|
| 51 |
+
userId: string,
|
| 52 |
+
jobId: string,
|
| 53 |
+
): Promise<CancelGenerationJobResult> {
|
| 54 |
+
const [row] = await db
|
| 55 |
+
.select({
|
| 56 |
+
id: generationJob.id,
|
| 57 |
+
userId: generationJob.userId,
|
| 58 |
+
status: generationJob.status,
|
| 59 |
+
})
|
| 60 |
+
.from(generationJob)
|
| 61 |
+
.where(eq(generationJob.id, jobId))
|
| 62 |
+
.limit(1);
|
| 63 |
+
|
| 64 |
+
if (!row) return { ok: false, reason: "not_found" };
|
| 65 |
+
if (row.userId !== userId) return { ok: false, reason: "forbidden" };
|
| 66 |
+
if (row.status === "completed" || row.status === "failed" || row.status === "cancelled") {
|
| 67 |
+
return { ok: false, reason: "not_cancellable" };
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
await db
|
| 71 |
+
.update(generationJob)
|
| 72 |
+
.set({
|
| 73 |
+
status: "cancelled",
|
| 74 |
+
errorMessage: "Dibatalkan pengguna",
|
| 75 |
+
completedAt: new Date(),
|
| 76 |
+
})
|
| 77 |
+
.where(eq(generationJob.id, jobId));
|
| 78 |
+
|
| 79 |
+
try {
|
| 80 |
+
const bullJob = await generationQueue.getJob(jobId);
|
| 81 |
+
if (bullJob) {
|
| 82 |
+
try {
|
| 83 |
+
await bullJob.remove();
|
| 84 |
+
} catch {
|
| 85 |
+
// Job is likely active (or already gone) — worker exits via DB `cancelled` + poller.
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
} catch {
|
| 89 |
+
// Redis/BullMQ hiccup — DB already cancelled; worker will exit cooperatively if active.
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
return { ok: true };
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
export const generationQueue = new Queue("generation", {
|
| 96 |
connection: redisConnection,
|
| 97 |
});
|
|
|
|
| 102 |
const { input, jobId } = job.data;
|
| 103 |
const start = Date.now();
|
| 104 |
|
| 105 |
+
const [initialRow] = await db
|
| 106 |
+
.select({ status: generationJob.status })
|
| 107 |
+
.from(generationJob)
|
| 108 |
+
.where(eq(generationJob.id, jobId))
|
| 109 |
+
.limit(1);
|
| 110 |
+
|
| 111 |
+
if (!initialRow || initialRow.status === "cancelled" || initialRow.status === "completed") {
|
| 112 |
+
return;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
const claimed = await db
|
| 116 |
+
.update(generationJob)
|
| 117 |
+
.set({ status: "running" })
|
| 118 |
+
.where(
|
| 119 |
+
and(eq(generationJob.id, jobId), notInArray(generationJob.status, ["cancelled", "completed"])),
|
| 120 |
+
)
|
| 121 |
+
.returning({ id: generationJob.id });
|
| 122 |
+
|
| 123 |
+
if (!claimed.length) {
|
| 124 |
+
return;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
const cancelPoll = createCancellationPoller(jobId);
|
| 128 |
+
|
| 129 |
const updateProgress = async (progress: number, message: string) => {
|
| 130 |
+
cancelPoll.check();
|
| 131 |
await job.updateProgress(progress);
|
| 132 |
await db
|
| 133 |
.update(generationJob)
|
|
|
|
| 155 |
};
|
| 156 |
|
| 157 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
if (input.mode === "agentic") {
|
| 159 |
await updateProgress(10, "Generating passage...");
|
| 160 |
}
|
|
|
|
| 169 |
const result =
|
| 170 |
input.mode === "agentic"
|
| 171 |
? await generateQuestionsAgentic(input, async (p) => {
|
| 172 |
+
cancelPoll.check();
|
| 173 |
const stepProgress = Math.min(
|
| 174 |
10 + Math.round((p.currentStep / p.steps.length) * 80),
|
| 175 |
90,
|
|
|
|
| 179 |
})
|
| 180 |
: await generateQuestionsQuick(input, {
|
| 181 |
onToken: (token) => {
|
| 182 |
+
cancelPoll.check();
|
| 183 |
countToken(token);
|
| 184 |
// Update progress message with token count every ~500 chars
|
| 185 |
if (approxTokens % 20 === 0) {
|
|
|
|
| 193 |
},
|
| 194 |
});
|
| 195 |
|
| 196 |
+
cancelPoll.check();
|
| 197 |
await updateProgress(95, "Saving to bank...");
|
| 198 |
|
| 199 |
// Idempotent auto-save: check if already saved
|
|
|
|
| 298 |
|
| 299 |
await updateProgress(100, "Completed");
|
| 300 |
|
| 301 |
+
cancelPoll.check();
|
| 302 |
await db
|
| 303 |
.update(generationJob)
|
| 304 |
.set({
|
|
|
|
| 310 |
})
|
| 311 |
.where(eq(generationJob.id, jobId));
|
| 312 |
} catch (err: any) {
|
| 313 |
+
if (err instanceof GenerationJobCancelledError || err?.name === "GenerationJobCancelledError") {
|
| 314 |
+
await db
|
| 315 |
+
.update(generationJob)
|
| 316 |
+
.set({
|
| 317 |
+
status: "cancelled",
|
| 318 |
+
errorMessage: "Dibatalkan pengguna",
|
| 319 |
+
durationMs: Date.now() - start,
|
| 320 |
+
completedAt: new Date(),
|
| 321 |
+
})
|
| 322 |
+
.where(eq(generationJob.id, jobId));
|
| 323 |
+
return;
|
| 324 |
+
}
|
| 325 |
await db
|
| 326 |
.update(generationJob)
|
| 327 |
.set({
|
|
|
|
| 332 |
})
|
| 333 |
.where(eq(generationJob.id, jobId));
|
| 334 |
throw err;
|
| 335 |
+
} finally {
|
| 336 |
+
stopHeartbeat();
|
| 337 |
+
cancelPoll.stop();
|
| 338 |
}
|
| 339 |
},
|
| 340 |
{
|
packages/api/src/routers/ai.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { z } from "zod";
|
|
| 2 |
import { eq, desc } from "drizzle-orm";
|
| 3 |
import { router, protectedProcedure } from "../index";
|
| 4 |
import { generationInputSchema } from "@labas/ai/schemas";
|
| 5 |
-
import { enqueueGeneration } from "../queue";
|
| 6 |
import { db } from "@labas/db";
|
| 7 |
import { question, generationJob } from "@labas/db";
|
| 8 |
|
|
@@ -29,6 +29,22 @@ export const aiRouter = router({
|
|
| 29 |
return job;
|
| 30 |
}),
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
myJobs: protectedProcedure
|
| 33 |
.input(
|
| 34 |
z.object({
|
|
|
|
| 2 |
import { eq, desc } from "drizzle-orm";
|
| 3 |
import { router, protectedProcedure } from "../index";
|
| 4 |
import { generationInputSchema } from "@labas/ai/schemas";
|
| 5 |
+
import { cancelGenerationJob, enqueueGeneration } from "../queue";
|
| 6 |
import { db } from "@labas/db";
|
| 7 |
import { question, generationJob } from "@labas/db";
|
| 8 |
|
|
|
|
| 29 |
return job;
|
| 30 |
}),
|
| 31 |
|
| 32 |
+
cancelJob: protectedProcedure
|
| 33 |
+
.input(z.object({ jobId: z.string().uuid() }))
|
| 34 |
+
.mutation(async ({ ctx, input }) => {
|
| 35 |
+
const result = await cancelGenerationJob(ctx.session.user.id, input.jobId);
|
| 36 |
+
if (!result.ok) {
|
| 37 |
+
const msg =
|
| 38 |
+
result.reason === "not_found"
|
| 39 |
+
? "Job tidak ditemukan"
|
| 40 |
+
: result.reason === "forbidden"
|
| 41 |
+
? "Tidak diizinkan"
|
| 42 |
+
: "Job sudah selesai atau tidak bisa dibatalkan";
|
| 43 |
+
throw new Error(msg);
|
| 44 |
+
}
|
| 45 |
+
return { ok: true as const };
|
| 46 |
+
}),
|
| 47 |
+
|
| 48 |
myJobs: protectedProcedure
|
| 49 |
.input(
|
| 50 |
z.object({
|
packages/db/src/schema/app.ts
CHANGED
|
@@ -437,7 +437,7 @@ export const generationJob = pgTable(
|
|
| 437 |
userId: text("user_id")
|
| 438 |
.notNull()
|
| 439 |
.references(() => user.id, { onDelete: "cascade" }),
|
| 440 |
-
status: text("status").notNull().default("pending"), // "pending" | "running" | "completed" | "failed"
|
| 441 |
mode: text("mode").notNull().default("quick"), // "quick" | "agentic"
|
| 442 |
examTypeId: text("exam_type_id").notNull(),
|
| 443 |
sectionTypeId: text("section_type_id").notNull(),
|
|
|
|
| 437 |
userId: text("user_id")
|
| 438 |
.notNull()
|
| 439 |
.references(() => user.id, { onDelete: "cascade" }),
|
| 440 |
+
status: text("status").notNull().default("pending"), // "pending" | "running" | "completed" | "failed" | "cancelled"
|
| 441 |
mode: text("mode").notNull().default("quick"), // "quick" | "agentic"
|
| 442 |
examTypeId: text("exam_type_id").notNull(),
|
| 443 |
sectionTypeId: text("section_type_id").notNull(),
|