diff --git a/src/api/index.ts b/src/api/index.ts index 75bd385..e0c4cc3 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -3,8 +3,9 @@ * Mounts auth, workspace, session, chat, skills, and sources routes. */ import { createRouter } from '@agentuity/runtime'; -import { auth, authMiddleware, authRoutes } from '../auth'; +import { auth, authMiddleware, apiKeyMiddleware, authRoutes } from '../auth'; import workspaceRoutes from '../routes/workspaces'; +import taskRoutes from '../routes/tasks'; import sessionRoutes from '../routes/sessions'; import sessionDetailRoutes from '../routes/session-detail'; import chatRoutes from '../routes/chat'; @@ -36,6 +37,11 @@ api.get('/auth-methods', (c) => { // Shared session routes (public — no authentication required) api.route('/shared', sharedRoutes); +// Public Tasks API (API key authentication) +api.use('/v1/tasks/*', apiKeyMiddleware); +api.use('/v1/tasks', apiKeyMiddleware); +api.route('/v1/tasks', taskRoutes); + // All other routes require authentication api.use('/*', authMiddleware); diff --git a/src/lib/webhook.ts b/src/lib/webhook.ts new file mode 100644 index 0000000..1caef41 --- /dev/null +++ b/src/lib/webhook.ts @@ -0,0 +1,80 @@ +/** + * Webhook invocation utility with retry logic. + * + * Fires a POST to the caller-supplied webhook URL when a task + * reaches a terminal state (completed, error, terminated). + */ + +export interface WebhookPayload { + taskId: string; + status: 'completed' | 'error' | 'terminated'; + repoUrl?: string; + branch?: string; + summary?: string; + prUrl?: string; + error?: string; + completedAt: string; +} + +interface WebhookOptions { + /** Maximum number of delivery attempts (default: 3). */ + maxAttempts?: number; + /** Initial backoff in ms before the first retry (default: 1000). */ + initialBackoffMs?: number; +} + +/** + * Deliver a webhook payload via POST with exponential-backoff retry. + * + * Returns `true` if the webhook was delivered (2xx response), + * `false` if all attempts failed. + */ +export async function deliverWebhook( + url: string, + payload: WebhookPayload, + options: WebhookOptions = {}, +): Promise { + const maxAttempts = options.maxAttempts ?? 3; + const initialBackoffMs = options.initialBackoffMs ?? 1_000; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'Agentuity-Coder/1.0', + 'X-Webhook-Attempt': String(attempt), + }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(10_000), // 10s timeout per attempt + }); + + if (response.ok) { + return true; + } + + // Non-retryable client errors (4xx except 429) + if (response.status >= 400 && response.status < 500 && response.status !== 429) { + console.warn( + `[webhook] Non-retryable ${response.status} from ${url} (attempt ${attempt}/${maxAttempts})`, + ); + return false; + } + } catch (err) { + console.warn( + `[webhook] Delivery attempt ${attempt}/${maxAttempts} to ${url} failed:`, + err instanceof Error ? err.message : err, + ); + } + + // Exponential backoff before next retry + if (attempt < maxAttempts) { + const backoff = initialBackoffMs * Math.pow(2, attempt - 1); + await new Promise((r) => setTimeout(r, backoff)); + } + } + + console.error(`[webhook] All ${maxAttempts} attempts to ${url} failed`); + return false; +} diff --git a/src/routes/chat.ts b/src/routes/chat.ts index dd8c5a2..a2a5372 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -11,6 +11,9 @@ import { and, eq } from '@agentuity/drizzle'; import { getOpencodeClient, buildBasicAuthHeader } from '../opencode'; import { sandboxListFiles, sandboxReadFile, sandboxExecute, sandboxWriteFiles } from '@agentuity/server'; import { normalizeSandboxPath } from '../lib/path-utils'; +import { parseMetadata } from '../lib/parse-metadata'; +import { deliverWebhook } from '../lib/webhook'; +import type { WebhookPayload } from '../lib/webhook'; import { decrypt } from '../lib/encryption'; import { SpanStatusCode } from '@opentelemetry/api'; import { COMMAND_TO_AGENT, TEMPLATE_COMMANDS } from '../lib/agent-commands'; @@ -94,6 +97,70 @@ function isAllowedFilename(filename: string) { const api = createRouter(); +// --------------------------------------------------------------------------- +// Session completion detection & webhook delivery +// --------------------------------------------------------------------------- + +/** + * Detect whether an SSE event signals session completion (session.idle). + * When detected, update DB status to 'completed' and fire webhook if configured. + */ +export async function handleSessionCompletionEvent( + event: any, + sessionId: string, + opencodeSessionId: string, +): Promise { + // OpenCode emits "session.idle" when the AI finishes its work + const eventType = event?.type; + if (eventType !== 'session.idle') return; + + // Check if this event is for our session + const props = event?.properties; + const eventSessionId = + props?.sessionID || props?.info?.sessionID || props?.info?.id || props?.part?.sessionID; + if (eventSessionId && eventSessionId !== opencodeSessionId) return; + + // Only handle completion for API-created tasks — leave web UI sessions untouched + const [current] = await db + .select() + .from(chatSessions) + .where(eq(chatSessions.id, sessionId)) + .limit(1); + if (!current) return; + + const metadata = parseMetadata(current); + if (metadata.source !== 'api') return; + if (current.status === 'completed') return; // Already processed — prevent duplicate webhooks + + // Update session status to 'completed' (only if still active, to win the race) + const [updated] = await db + .update(chatSessions) + .set({ status: 'completed', updatedAt: new Date() }) + .where(and(eq(chatSessions.id, sessionId), eq(chatSessions.status, 'active'))) + .returning(); + + if (!updated) return; + + // Check for webhook URL in metadata + const webhookUrl = typeof metadata.webhookUrl === 'string' ? metadata.webhookUrl : null; + if (!webhookUrl) return; + + // Build webhook payload + const payload: WebhookPayload = { + taskId: sessionId, + status: 'completed', + repoUrl: typeof metadata.repoUrl === 'string' ? metadata.repoUrl : undefined, + branch: typeof metadata.branch === 'string' ? metadata.branch : undefined, + prUrl: (metadata.pullRequest as any)?.url ?? undefined, + completedAt: new Date().toISOString(), + }; + + // Fire-and-forget webhook delivery (don't block SSE) + deliverWebhook(webhookUrl, payload).catch((err) => { + console.error(`[webhook] Failed to deliver for session ${sessionId}:`, err); + }); +} + // --------------------------------------------------------------------------- // GET /api/sessions/:id/messages — fetch existing messages for page load // --------------------------------------------------------------------------- @@ -371,20 +438,25 @@ api.get( const jsonStr = line.slice(6).trim(); if (!jsonStr) continue; - try { - const event = JSON.parse(jsonStr); - // Filter by session - const props = (event as any)?.properties; - const eventSessionId = - props?.sessionID || - props?.info?.sessionID || - props?.info?.id || - props?.part?.sessionID; + try { + const event = JSON.parse(jsonStr); + // Filter by session + const props = (event as any)?.properties; + const eventSessionId = + props?.sessionID || + props?.info?.sessionID || + props?.info?.id || + props?.part?.sessionID; if (eventSessionId && eventSessionId !== session.opencodeSessionId) { continue; } + // Detect session completion and trigger webhook (fire-and-forget) + handleSessionCompletionEvent(event, session.id, session.opencodeSessionId!).catch( + (err) => console.error('[webhook] Completion event handling failed:', err), + ); + await safeWrite({ data: JSON.stringify(event) }); } catch { // Skip malformed events diff --git a/src/routes/tasks.ts b/src/routes/tasks.ts new file mode 100644 index 0000000..663639c --- /dev/null +++ b/src/routes/tasks.ts @@ -0,0 +1,563 @@ +/** + * Public Tasks API — programmatic access to coding sessions. + * + * All routes are authenticated via API key (Bearer token). + * This is the external-facing API for triggering coding sessions, + * sending follow-up messages, streaming events, and cleaning up. + * + * Endpoints: + * POST /api/v1/tasks — Create a new coding task + * GET /api/v1/tasks/:id — Get task status & details + * POST /api/v1/tasks/:id/messages — Send a follow-up message + * GET /api/v1/tasks/:id/events — SSE event stream + * DELETE /api/v1/tasks/:id — Delete task & destroy sandbox + */ +import { createRouter, sse } from '@agentuity/runtime'; +import { db } from '../db'; +import { chatSessions, workspaces, skills, sources, userSettings } from '../db/schema'; +import { eq, and } from 'drizzle-orm'; +import { randomUUID } from 'node:crypto'; +import { lookup } from 'node:dns/promises'; +import { + createSandbox, + generateOpenCodeConfig, + serializeOpenCodeConfig, + getOpencodeClient, + removeOpencodeClient, + destroySandbox, +} from '../opencode'; +import type { SandboxContext } from '../opencode'; +import { decrypt } from '../lib/encryption'; +import { parseMetadata } from '../lib/parse-metadata'; +import { handleSessionCompletionEvent } from './chat'; + +const api = createRouter(); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const API_WORKSPACE_NAME = 'API Tasks'; + +/** + * Check whether an IP address is private, loopback, or link-local. + * Covers RFC1918 (10/8, 172.16/12, 192.168/16), loopback (127/8, ::1), + * link-local (169.254/16, fe80::/10), and ULA (fc00::/7). + */ +function isPrivateIp(ip: string): boolean { + // IPv4 + const v4Parts = ip.split('.').map(Number); + if (v4Parts.length === 4 && v4Parts.every((n) => n >= 0 && n <= 255)) { + const a = v4Parts[0]!; + const b = v4Parts[1]!; + if (a === 10) return true; // 10.0.0.0/8 + if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 + if (a === 192 && b === 168) return true; // 192.168.0.0/16 + if (a === 169 && b === 254) return true; // 169.254.0.0/16 + if (a === 127) return true; // 127.0.0.0/8 + if (a === 0) return true; // 0.0.0.0/8 + return false; + } + + // IPv6 + const normalized = ip.toLowerCase().replace(/^\[|\]$/g, ''); + if (normalized === '::1') return true; // loopback + if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true; // fc00::/7 ULA + if (normalized.startsWith('fe80')) return true; // fe80::/10 link-local + if (normalized === '::') return true; // unspecified + + return false; +} + +/** + * Get or create the default "API Tasks" workspace for a user. + * API-created sessions are grouped under a single auto-managed workspace + * so external callers don't need to know about workspaces. + */ +async function getOrCreateApiWorkspace(userId: string) { + const existing = await db + .select() + .from(workspaces) + .where(and(eq(workspaces.organizationId, userId), eq(workspaces.name, API_WORKSPACE_NAME))) + .limit(1); + + if (existing[0]) return existing[0]; + + const [workspace] = await db + .insert(workspaces) + .values({ + organizationId: userId, + name: API_WORKSPACE_NAME, + description: 'Auto-created workspace for API-initiated tasks', + }) + .onConflictDoNothing() + .returning(); + + if (workspace) return workspace; + + // Re-fetch if conflict occurred (concurrent creation race) + const [created] = await db + .select() + .from(workspaces) + .where(and(eq(workspaces.organizationId, userId), eq(workspaces.name, API_WORKSPACE_NAME))) + .limit(1); + return created!; +} + +/** + * Look up a task (session) and verify the requesting user owns it. + */ +async function getOwnedTask(taskId: string, userId: string) { + const [session] = await db + .select() + .from(chatSessions) + .where(eq(chatSessions.id, taskId)); + + if (!session) return { error: 'Task not found', status: 404 as const }; + if (session.createdBy !== userId) return { error: 'Task not found', status: 404 as const }; + + return { session }; +} + +// --------------------------------------------------------------------------- +// POST /api/v1/tasks — create a new coding task +// --------------------------------------------------------------------------- +api.post('/', async (c) => { + const user = c.get('user')!; + + const body = await c.req + .json<{ + repoUrl?: string; + branch?: string; + prompt: string; + agent?: string; + model?: string; + webhookUrl?: string; + }>() + .catch(() => null); + + if (!body || !body.prompt?.trim()) { + return c.json({ error: 'A "prompt" field is required' }, 400); + } + + // Validate webhookUrl if provided (with SSRF protection) + if (body.webhookUrl) { + let parsedUrl: URL; + try { + parsedUrl = new URL(body.webhookUrl); + } catch { + return c.json({ error: 'webhookUrl is not a valid URL' }, 400); + } + + if (!['http:', 'https:'].includes(parsedUrl.protocol)) { + return c.json({ error: 'webhookUrl must use http or https' }, 400); + } + + // Block obvious private/loopback hostnames + const hostname = parsedUrl.hostname.toLowerCase(); + const blockedHosts = ['localhost', '127.0.0.1', '::1', '0.0.0.0', '[::1]']; + if (blockedHosts.includes(hostname)) { + return c.json({ error: 'webhookUrl must not point to a loopback address' }, 400); + } + + // Check if hostname is an IP literal and validate it directly + const isIpV4 = /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname); + const isIpV6 = hostname.startsWith('[') && hostname.endsWith(']'); + const ipToCheck = isIpV4 ? hostname : isIpV6 ? hostname.slice(1, -1) : null; + + if (ipToCheck) { + if (isPrivateIp(ipToCheck)) { + return c.json({ error: 'webhookUrl must not point to a private or loopback address' }, 400); + } + } else { + // Resolve hostname and check all resulting IPs + try { + const results = await lookup(hostname, { all: true }); + for (const result of results) { + if (isPrivateIp(result.address)) { + return c.json({ error: 'webhookUrl resolves to a private or loopback address' }, 400); + } + } + } catch { + return c.json({ error: 'webhookUrl hostname could not be resolved' }, 400); + } + } + } + + const workspace = await getOrCreateApiWorkspace(user.id); + const workspaceId = workspace.id; + + // Fetch workspace skills and sources for config + const [workspaceSkills, workspaceSources] = await Promise.all([ + db.select().from(skills).where(eq(skills.workspaceId, workspaceId)), + db.select().from(sources).where(eq(sources.workspaceId, workspaceId)), + ]); + + const opencodeConfig = generateOpenCodeConfig( + { model: body.model }, + workspaceSources.map((s) => ({ + name: s.name, + type: s.type, + config: (s.config || {}) as Record, + enabled: s.enabled ?? true, + })), + ); + + const enabledSkills = workspaceSkills.filter((s) => s.enabled ?? true); + const customSkills = enabledSkills + .filter((s) => s.type !== 'registry') + .map((s) => ({ name: s.name, content: s.content })); + const registrySkills = enabledSkills + .filter((s) => s.type === 'registry' && s.repo) + .map((s) => ({ repo: s.repo as string, skillName: s.name })); + + const title = body.prompt.length > 60 ? body.prompt.slice(0, 57) + '...' : body.prompt; + + const sessionId = randomUUID(); + const metadata: Record = { + repoUrl: body.repoUrl, + branch: body.branch, + source: 'api', + }; + if (body.webhookUrl) { + metadata.webhookUrl = body.webhookUrl; + } + + const insertedRows = await db + .insert(chatSessions) + .values({ + id: sessionId, + workspaceId, + createdBy: user.id, + status: 'creating', + title, + agent: body.agent ?? null, + model: body.model ?? null, + metadata, + }) + .onConflictDoNothing() + .returning(); + + let session = insertedRows[0]; + if (!session) { + const [existing] = await db + .select() + .from(chatSessions) + .where(eq(chatSessions.id, sessionId)) + .limit(1); + session = existing; + } + if (!session) { + return c.json({ error: 'Failed to create task' }, 500); + } + + // Capture context variables before async block + const sandbox = c.var.sandbox; + const logger = c.var.logger; + + // Background: create sandbox, establish OpenCode session, send prompt + (async () => { + try { + const sandboxCtx: SandboxContext = { sandbox, logger }; + let githubToken: string | undefined; + try { + const [settings] = await db + .select() + .from(userSettings) + .where(eq(userSettings.userId, user.id)); + if (settings?.githubPat) { + githubToken = decrypt(settings.githubPat); + } + } catch { + logger.warn('Failed to load GitHub token for sandbox', { userId: user.id }); + } + + const { sandboxId, sandboxUrl } = await createSandbox(sandboxCtx, { + repoUrl: body.repoUrl, + branch: body.branch, + opencodeConfigJson: serializeOpenCodeConfig(opencodeConfig), + customSkills, + registrySkills, + githubToken, + }); + + const client = getOpencodeClient(sandboxId, sandboxUrl); + let opencodeSessionId: string | null = null; + for (let attempt = 1; attempt <= 5; attempt++) { + try { + const opencodeSession = await client.session.create({ body: {} }); + opencodeSessionId = + (opencodeSession as any)?.data?.id || (opencodeSession as any)?.id || null; + if (opencodeSessionId) break; + logger.warn(`session.create attempt ${attempt}: no session ID returned`); + } catch (err) { + logger.warn(`session.create attempt ${attempt} failed`, { error: err }); + } + if (attempt < 5) await new Promise((r) => setTimeout(r, 2000)); + } + + const newStatus = opencodeSessionId ? 'active' : 'error'; + const nextMetadata = opencodeSessionId + ? metadata + : { ...metadata, error: 'Failed to create OpenCode session after 5 attempts' }; + + await db + .update(chatSessions) + .set({ + sandboxId, + sandboxUrl, + opencodeSessionId, + status: newStatus, + metadata: nextMetadata, + updatedAt: new Date(), + }) + .where(eq(chatSessions.id, session!.id)); + + // Send prompt + if (body.prompt && opencodeSessionId) { + try { + await client.session.promptAsync({ + path: { id: opencodeSessionId }, + body: { parts: [{ type: 'text', text: body.prompt }] }, + }); + } catch (err) { + logger.warn('Failed to send initial prompt', { error: err }); + } + } + } catch (error) { + await db + .update(chatSessions) + .set({ + status: 'error', + metadata: { ...metadata, error: String(error) }, + updatedAt: new Date(), + }) + .where(eq(chatSessions.id, session!.id)); + } + })(); + + return c.json( + { + taskId: session!.id, + status: 'creating', + message: 'Task created. Poll GET /api/v1/tasks/:id or listen to SSE at GET /api/v1/tasks/:id/events.', + }, + 201, + ); +}); + +// --------------------------------------------------------------------------- +// GET /api/v1/tasks/:id — get task status & details +// --------------------------------------------------------------------------- +api.get('/:id', async (c) => { + const user = c.get('user')!; + const result = await getOwnedTask(c.req.param('id')!, user.id); + if ('error' in result) return c.json({ error: result.error }, result.status); + + const { session } = result; + const metadata = parseMetadata(session); + + return c.json({ + taskId: session.id, + status: session.status, + title: session.title, + repoUrl: metadata.repoUrl ?? null, + branch: metadata.branch ?? null, + prUrl: (metadata.pullRequest as any)?.url ?? null, + webhookUrl: metadata.webhookUrl ?? null, + agent: session.agent, + model: session.model, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + }); +}); + +// --------------------------------------------------------------------------- +// POST /api/v1/tasks/:id/messages — send follow-up message +// --------------------------------------------------------------------------- +api.post('/:id/messages', async (c) => { + const user = c.get('user')!; + const result = await getOwnedTask(c.req.param('id')!, user.id); + if ('error' in result) return c.json({ error: result.error }, result.status); + + const { session } = result; + if (!session.sandboxId || !session.sandboxUrl || !session.opencodeSessionId) { + return c.json({ error: 'Task sandbox not ready' }, 503); + } + + const body = await c.req.json<{ text: string; model?: string }>().catch(() => null); + if (!body || !body.text?.trim()) { + return c.json({ error: 'A "text" field is required' }, 400); + } + + const client = getOpencodeClient(session.sandboxId, session.sandboxUrl); + + try { + let providerID: string | undefined; + let modelID: string | undefined; + if (body.model) { + const slashIdx = body.model.indexOf('/'); + if (slashIdx === -1) { + return c.json({ error: 'model must be in format "provider/model"' }, 400); + } + providerID = body.model.slice(0, slashIdx); + modelID = body.model.slice(slashIdx + 1); + } + await client.session.promptAsync({ + path: { id: session.opencodeSessionId }, + body: { + parts: [{ type: 'text' as const, text: body.text }], + ...(providerID && modelID ? { model: { providerID, modelID } } : {}), + }, + }); + return c.json({ success: true }); + } catch (error) { + return c.json({ error: 'Failed to send message', details: String(error) }, 500); + } +}); + +// --------------------------------------------------------------------------- +// GET /api/v1/tasks/:id/events — SSE event stream +// --------------------------------------------------------------------------- +api.get( + '/:id/events', + sse(async (c, stream) => { + const user = c.get('user')!; + const result = await getOwnedTask(c.req.param('id')!, user.id); + if ('error' in result) { + await stream.writeSSE({ + data: JSON.stringify({ type: 'error', message: result.error }), + }); + stream.close(); + return; + } + + const { session } = result; + if (!session.sandboxId || !session.sandboxUrl || !session.opencodeSessionId) { + await stream.writeSSE({ + data: JSON.stringify({ type: 'error', message: 'Task sandbox not ready' }), + }); + stream.close(); + return; + } + + try { + // Use an AbortController for the initial connection only — + // clear the timeout once connected so the long-lived SSE stream isn't killed. + const connectController = new AbortController(); + const connectTimeout = setTimeout(() => connectController.abort(), 30_000); + + let eventResponse: Response; + try { + eventResponse = await fetch(`${session.sandboxUrl}/event`, { + signal: connectController.signal, + }); + } catch (err) { + clearTimeout(connectTimeout); + const message = connectController.signal.aborted + ? 'Sandbox event stream connection timed out' + : String(err); + await stream.writeSSE({ + data: JSON.stringify({ type: 'error', message }), + }); + stream.close(); + return; + } + clearTimeout(connectTimeout); + + if (!eventResponse.ok || !eventResponse.body) { + await stream.writeSSE({ + data: JSON.stringify({ type: 'error', message: 'No event stream' }), + }); + stream.close(); + return; + } + + const reader = eventResponse.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + const jsonStr = line.slice(6).trim(); + if (!jsonStr) continue; + + try { + const event = JSON.parse(jsonStr); + const props = (event as any)?.properties; + const eventSessionId = + props?.sessionID || + props?.info?.sessionID || + props?.info?.id || + props?.part?.sessionID; + + if (eventSessionId && eventSessionId !== session.opencodeSessionId) { + continue; + } + + // Detect session completion and trigger webhook (fire-and-forget) + handleSessionCompletionEvent(event, session.id, session.opencodeSessionId!).catch( + (err) => console.error('[webhook] Completion event handling failed:', err), + ); + + await stream.writeSSE({ data: JSON.stringify(event) }); + } catch { + // Skip malformed events + } + } + } + } catch { + // Stream ended + } finally { + reader.releaseLock(); + } + } catch (error) { + await stream.writeSSE({ + data: JSON.stringify({ type: 'error', message: String(error) }), + }); + } + + stream.close(); + }), +); + +// --------------------------------------------------------------------------- +// DELETE /api/v1/tasks/:id — delete task & destroy sandbox +// --------------------------------------------------------------------------- +api.delete('/:id', async (c) => { + const user = c.get('user')!; + const result = await getOwnedTask(c.req.param('id')!, user.id); + if ('error' in result) return c.json({ error: result.error }, result.status); + + const { session } = result; + + if (session.sandboxId) { + const sandboxCtx: SandboxContext = { + sandbox: c.var.sandbox, + logger: c.var.logger, + }; + try { + await destroySandbox(sandboxCtx, session.sandboxId); + } catch (err) { + c.var.logger.warn('Failed to destroy sandbox, proceeding with task deletion', { + sandboxId: session.sandboxId, + error: err, + }); + } + removeOpencodeClient(session.sandboxId); + } + + await db.delete(chatSessions).where(eq(chatSessions.id, session.id)); + return c.json({ success: true, message: 'Task deleted and sandbox destroyed' }); +}); + +export default api; diff --git a/src/web/styles.css b/src/web/styles.css index ddf8e78..bf422ca 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -121,8 +121,8 @@ h1, h2, #logo { color-scheme: dark; } -/* Solid background for dialog content in dark mode */ -.dark [role="dialog"] { +/* Solid background for dialog content */ +[role="dialog"] { background-color: var(--card); color: var(--card-foreground); }