profileShare

rasmusjy / roundtable

Read-only snapshot

No repository description.

main default branch 181 files Expires Sep 13, 2026, 9:06 AM

Commit

add server-side cancel debate endpoint and button

commit d0c3cd0

4 changed files with +81 and −4

Jump to a changed file
  1. src/app/api/debates/[id]/cancel/route.ts +22 −0
  2. src/components/debate/new-debate.tsx +18 −3
  3. src/lib/debate-registry.ts +30 −0
  4. src/lib/debate-runner.ts +11 −1
added src/app/api/debates/[id]/cancel/route.ts +22 −0
@@ -0,0 +1,22 @@
1 +import { NextResponse } from 'next/server';
2 +import { currentUserId } from '@/auth';
3 +import { getDebateOwnership } from '@/db/repositories';
4 +import { cancelDebate } from '@/lib/debate-registry';
5 +
6 +export const runtime = 'nodejs';
7 +
8 +type Params = { params: Promise<{ id: string }> };
9 +
10 +/** POST: abort a running debate server-side (distinct from just disconnecting). */
11 +export async function POST(_request: Request, { params }: Params) {
12 + const { id } = await params;
13 + const ownership = await getDebateOwnership(id);
14 + if (!ownership) return NextResponse.json({ error: 'Not found' }, { status: 404 });
15 +
16 + const userId = await currentUserId();
17 + const canCancel = ownership.userId === null || ownership.userId === userId;
18 + if (!canCancel) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
19 +
20 + const cancelled = cancelDebate(id);
21 + return NextResponse.json({ ok: cancelled });
22 +}
modified src/components/debate/new-debate.tsx +18 −3
@@ -128,6 +128,16 @@export function NewDebate() {
128 128 }
129 129 };
130 130
131 + const cancelDebateRun = async () => {
132 + if (!view.debateId) return;
133 + try {
134 + await fetch(`/api/debates/${view.debateId}/cancel`, { method: 'POST' });
135 + toast.message('Cancelling debate...');
136 + } catch {
137 + toast.error('Could not cancel');
138 + }
139 + };
140 +
131 141 const launch = () =>
132 142 start({
133 143 question: question.trim(),
@@ -295,9 +305,14 @@export function NewDebate() {
295 305 </div>
296 306 <div className="flex gap-2">
297 307 {running && (
298 - <Button variant="outline" size="sm" onClick={cancel}>
299 - <Square className="h-3.5 w-3.5" /> Stop watching
300 - </Button>
308 + <>
309 + <Button variant="outline" size="sm" onClick={cancel}>
310 + <Square className="h-3.5 w-3.5" /> Stop watching
311 + </Button>
312 + <Button variant="destructive" size="sm" onClick={cancelDebateRun}>
313 + <Square className="h-3.5 w-3.5" /> Cancel debate
314 + </Button>
315 + </>
301 316 )}
302 317 <Button variant="outline" size="sm" onClick={reset}>
303 318 <RotateCcw className="h-3.5 w-3.5" /> New debate
added src/lib/debate-registry.ts +30 −0
@@ -0,0 +1,30 @@
1 +/**
2 + * Registry of in-flight debates so an explicit "cancel" request can abort a run
3 + * server-side.
4 + *
5 + * Note this is intentionally separate from client disconnect: a browser going
6 + * away does NOT abort the debate (graceful resume), but hitting the cancel
7 + * endpoint does. Process-local, which is fine for the single-VPS deployment.
8 + */
9 +const running = new Map<string, AbortController>();
10 +
11 +export function registerDebate(id: string, controller: AbortController): void {
12 + running.set(id, controller);
13 +}
14 +
15 +export function unregisterDebate(id: string): void {
16 + running.delete(id);
17 +}
18 +
19 +export function isDebateRunning(id: string): boolean {
20 + return running.has(id);
21 +}
22 +
23 +/** Abort a running debate. Returns false if it wasn't running here. */
24 +export function cancelDebate(id: string): boolean {
25 + const controller = running.get(id);
26 + if (!controller) return false;
27 + controller.abort(new Error('Debate cancelled by user'));
28 + running.delete(id);
29 + return true;
30 +}
modified src/lib/debate-runner.ts +11 −1
@@ -16,6 +16,7 @@import { PROMPT_VERSION } from '@/core/prompts';
16 16 import type { DebateConfig } from '@/core/types';
17 17 import { createDebate, finalizeDebate, persistEvent } from '@/db/repositories';
18 18 import { resolveApiKey } from './byok';
19 +import { registerDebate, unregisterDebate } from './debate-registry';
19 20 import { env } from './env';
20 21 import { createOpenRouterClient } from './openrouter';
21 22 import { getPricing } from './model-cache';
@@ -52,6 +53,10 @@export async function startDebateStream(
52 53 ? new MockLlmClient()
53 54 : createOpenRouterClient({ apiKey: key.apiKey, pricing: await getPricing() });
54 55
56 + // Abort controller for explicit server-side cancellation (not client disconnect).
57 + const abort = new AbortController();
58 + registerDebate(debateId, abort);
59 +
55 60 let closed = false;
56 61 let heartbeat: ReturnType<typeof setInterval> | undefined;
57 62
@@ -80,12 +85,17 @@export async function startDebateStream(
80 85
81 86 void (async () => {
82 87 try {
83 - const result = await runDebate(args.config, { llm, emit, logger: serverLogger }, { debateId });
88 + const result = await runDebate(
89 + args.config,
90 + { llm, emit, logger: serverLogger },
91 + { debateId, signal: abort.signal },
92 + );
84 93 await finalizeDebate(debateId, result);
85 94 } catch (err) {
86 95 serverLogger.error('run_failed', { debateId, err: String(err) });
87 96 safeEnqueue(encodeNamed('error', { message: err instanceof Error ? err.message : 'Debate failed' }));
88 97 } finally {
98 + unregisterDebate(debateId);
89 99 if (heartbeat) clearInterval(heartbeat);
90 100 if (!closed) {
91 101 try {