rogasper commited on
Commit
36b41bf
·
1 Parent(s): dbeb485

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") return false;
 
48
  return 1000;
49
  },
50
  });
51
 
52
  const isGenerating =
53
  jobId !== null &&
54
- (!jobQuery.data || (jobQuery.data.status !== "completed" && jobQuery.data.status !== "failed"));
 
 
 
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="text-xs text-[var(--warm-charcoal)]">
120
- {formatDate(job.createdAt)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- stopHeartbeat();
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
- stopHeartbeat();
 
 
 
 
 
 
 
 
 
 
 
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(),