feat: Public Tasks API with API key auth and webhook callbacks#1
feat: Public Tasks API with API key auth and webhook callbacks#1
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds an API-key–protected public Tasks API at /v1/tasks, a webhook delivery utility with exponential backoff, session-completion detection that triggers asynchronous webhook calls for API-originated sessions, and a new API Keys management UI in Settings. Changes
🚥 Pre-merge checks | ✅ 1✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Fix all issues with AI agents
In `@src/routes/chat.ts`:
- Around line 102-119: The code can process duplicate session.idle events and
trigger multiple updates/webhooks; first, read the current row (chatSessions)
via the existing select and check current.status !== 'completed' (or !== 'idle'
depending on your lifecycle) before proceeding, then perform the UPDATE with an
additional WHERE that includes the expected previous status (e.g.,
.where(eq(chatSessions.id, sessionId)).and(eq(chatSessions.status, 'idle'))) so
only the first concurrent updater wins; rely on the returned "updated" value
from the update to decide whether to trigger the webhook (only trigger when
updated is truthy) and avoid multiple webhook calls; use parseMetadata,
sessionId, chatSessions, and the existing update/returning flow to implement
this guard.
In `@src/routes/tasks.ts`:
- Around line 45-64: The getOrCreateApiWorkspace function can race when two
requests create the same workspace; modify the insert path to use an upsert that
ignores conflicts (e.g., use db.insert(...).values(...).onConflictDoNothing())
and then re-query the workspaces table to fetch the existing row if the insert
did nothing; keep the initial select fallback but after the insert use a second
select (or returning when supported) to ensure you return the
created-or-existing workspace and avoid unique constraint errors on
workspaces.name + workspaces.organizationId.
- Around line 333-415: Export the existing handleSessionCompletionEvent from
chat.ts and import it into tasks.ts (or alternatively copy its
completion-detection logic into tasks.ts), then invoke it inside the SSE event
loop in the tasks endpoint right after parsing each JSON event (the block that
currently computes eventSessionId and checks against session.opencodeSessionId);
specifically, call handleSessionCompletionEvent(event, session) (or the
equivalent logic) when the parsed event indicates session completion so
configured webhooks fire; ensure you export the symbol from chat.ts and add the
import to the file that defines the '/:id/events' SSE handler which uses
getOwnedTask and session.opencodeSessionId.
🧹 Nitpick comments (3)
src/routes/chat.ts (1)
348-351: Inconsistent error handling compared to line 137.The error handler here silently swallows errors with
.catch(() => {}), while the equivalent call in line 137 logs the error. Consider consistent logging:♻️ Proposed fix for consistent logging
- handleSessionCompletionEvent(event, session.id, session.opencodeSessionId!).catch( - () => {}, - ); + handleSessionCompletionEvent(event, session.id, session.opencodeSessionId!).catch( + (err) => console.error(`[webhook] Completion event handling failed:`, err), + );src/routes/tasks.ts (2)
168-176: Consider guarding against null session.While unlikely, if both the insert (due to conflict) and the subsequent select fail (e.g., deleted between operations),
sessionwould be undefined, causing line 261 to fail. The current code assumes this won't happen, which is reasonable but could be made defensive:🛡️ Proposed defensive check
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); + }
427-438: Consider handlingdestroySandboxfailure gracefully.If
destroySandboxthrows (e.g., sandbox already gone, network error), the task record remains in the database. Depending on desired behavior, you may want to proceed with deletion even if sandbox cleanup fails:♻️ Proposed fix for resilient cleanup
if (session.sandboxId) { const sandboxCtx: SandboxContext = { sandbox: c.var.sandbox, logger: c.var.logger, }; - await destroySandbox(sandboxCtx, session.sandboxId); + 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); }
📜 Review details
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (4)
src/api/index.tssrc/lib/webhook.tssrc/routes/chat.tssrc/routes/tasks.ts
🧰 Additional context used
🧬 Code graph analysis (3)
src/routes/chat.ts (4)
src/db/index.ts (1)
db(11-11)src/db/schema.ts (1)
chatSessions(25-40)src/lib/parse-metadata.ts (1)
parseMetadata(11-31)src/lib/webhook.ts (2)
WebhookPayload(8-17)deliverWebhook(32-80)
src/api/index.ts (1)
src/auth.ts (1)
apiKeyMiddleware(43-43)
src/routes/tasks.ts (4)
src/db/index.ts (1)
db(11-11)src/db/schema.ts (5)
workspaces(15-23)chatSessions(25-40)skills(42-53)sources(55-64)userSettings(66-72)src/lib/encryption.ts (1)
decrypt(18-31)src/lib/parse-metadata.ts (1)
parseMetadata(11-31)
🔇 Additional comments (7)
src/lib/webhook.ts (1)
1-80: LGTM! Well-structured webhook delivery with proper retry logic.The implementation correctly handles:
- Exponential backoff (1s → 2s → 4s)
- Non-retryable 4xx responses (except 429)
- 10s timeout per attempt
- Clear logging for debugging
src/routes/chat.ts (1)
14-16: LGTM!Clean imports for the new webhook functionality.
src/api/index.ts (1)
37-40: LGTM!Correct middleware registration order ensures API key authentication is applied before the catch-all
authMiddleware. Both/v1/tasksand/v1/tasks/*patterns are needed to cover the root endpoint and nested routes.src/routes/tasks.ts (4)
183-257: LGTM!The background provisioning pattern is well-implemented:
- Context captured before async to avoid stale references
- Retry logic for OpenCode session creation (5 attempts, 2s delay)
- Error handling updates status to 'error' for visibility
- Initial prompt sent after session is established
272-293: LGTM!Proper ownership validation and safe metadata extraction.
298-328: LGTM!Clean implementation matching the pattern in chat.ts, with appropriate simplification (no attachments) for the public API.
1-31: LGTM!Well-documented module header with clear endpoint descriptions. Imports are properly organized.
✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@src/web/components/settings/ApiKeySettings.tsx`:
- Around line 209-220: The empty-state card in ApiKeySettings currently renders
whenever keys.length === 0 even if an error exists; change the conditional so
the "No API keys yet" block only renders when keys.length === 0 AND error is
falsy (i.e., only show the empty state when there is no load error). Locate the
render that checks keys.length === 0 in ApiKeySettings (related symbols: keys,
error, fetchKeys, setError, setKeys, setShowCreate) and update it to
short-circuit on error so the error banner ("Failed to load API keys") and the
empty state are not shown at the same time.
- Around line 110-117: The code assumes data.key always exists: after const key
= data?.key; add a guard so if key is falsy you do not clear the form or close
the dialog; instead set a user-facing error state (create/use something like
setCreateError or showError) and return so the dialog stays open and the user
sees that creation returned no key; if key exists continue to call
setNewlyCreatedKey(key), clear setNewKeyName(''), setShowCreate(false) and await
fetchKeys(); also optionally log/res.json for debugging.
🧹 Nitpick comments (1)
src/web/components/settings/ApiKeySettings.tsx (1)
149-157: Empty catch block doesn't implement the advertised fallback.The comment says "Fallback: select text" but no fallback is actually implemented. If clipboard access fails (e.g., due to permissions or non-HTTPS context), the user gets no feedback.
🔧 Proposed fix to provide fallback or feedback
const handleCopy = async (text: string) => { try { await navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { - // Fallback: select text + // Clipboard API failed - show brief error or rely on select-all styling + // The code element has select-all class so users can manually copy + console.warn('Clipboard access denied, user can manually select and copy'); } };
📜 Review details
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
src/web/components/pages/SettingsPage.tsxsrc/web/components/settings/ApiKeySettings.tsx
🧰 Additional context used
🧬 Code graph analysis (1)
src/web/components/pages/SettingsPage.tsx (1)
src/web/components/settings/ApiKeySettings.tsx (1)
ApiKeySettings(49-391)
🔇 Additional comments (6)
src/web/components/settings/ApiKeySettings.tsx (4)
1-47: LGTM!The imports, interfaces, and helper functions are well-structured. The
formatRelativefunction correctly handles edge cases and provides appropriate fallback toformatDatefor older entries.
49-91: LGTM!State management is well-organized with clear separation of concerns. The
useCallbackforfetchKeysand proper dependency array inuseEffectfollow React best practices.
244-323: LGTM!The key list implementation with two-step delete confirmation provides good UX. The conditional rendering for confirm/cancel states and disabled states during async operations is handled correctly.
333-389: LGTM!The create dialog has proper accessibility with labeled input and
DialogDescription. The Enter-to-submit handler and disabled state logic are correctly implemented.src/web/components/pages/SettingsPage.tsx (2)
6-6: LGTM!Import path is correct relative to the file location.
331-338: LGTM!The API Keys section follows the same Card pattern as the GitHub section above, maintaining UI consistency. The descriptive text clearly explains the purpose of the feature.
✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.
…hook in tasks SSE
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@src/routes/tasks.ts`:
- Around line 221-248: The session creation loop (getOpencodeClient /
client.session.create) can finish without opencodeSessionId and leave the chat
session status as "creating"; change the post-loop handling so that if
opencodeSessionId is still null you set status to "error" and persist the
failure reason: capture the last thrown error (from the catch) or a final
message and include it in the DB update (add and set an error column like
opencodeError or sessionError alongside sandboxId, sandboxUrl,
opencodeSessionId, status, updatedAt) so the final
db.update(chatSessions).set(...) writes status: 'error' and the error metadata
to avoid clients polling forever.
🧹 Nitpick comments (1)
src/web/components/settings/ApiKeySettings.tsx (1)
151-161: Consider cleaning up the timeout on unmount.The
setTimeoutat line 155 isn't cleaned up if the component unmounts within 2 seconds of copying, which could trigger a React state update warning on an unmounted component.♻️ Proposed fix using useRef for timeout cleanup
+import { useCallback, useEffect, useState, useRef } from 'react';In the component:
+ const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); + + useEffect(() => { + return () => { + if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current); + }; + }, []); const handleCopy = async (text: string) => { try { await navigator.clipboard.writeText(text); setCopied(true); - setTimeout(() => setCopied(false), 2000); + copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000); } catch {
📜 Review details
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
src/routes/chat.tssrc/routes/tasks.tssrc/web/components/settings/ApiKeySettings.tsx
🧰 Additional context used
🧬 Code graph analysis (1)
src/web/components/settings/ApiKeySettings.tsx (3)
src/web/components/ui/button.tsx (1)
Button(52-52)src/web/components/ui/badge.tsx (1)
Badge(26-26)src/web/components/ui/dialog.tsx (6)
Dialog(95-95)DialogContent(100-100)DialogHeader(101-101)DialogTitle(103-103)DialogDescription(104-104)DialogFooter(102-102)
🔇 Additional comments (18)
src/web/components/settings/ApiKeySettings.tsx (8)
1-26: LGTM!Clean imports and well-defined interface. The
ApiKeyinterface covers all necessary fields for the key management UI.
28-47: LGTM!Well-implemented date formatting helpers with appropriate null handling and human-friendly relative time display.
49-91: LGTM!State management is well-organized. The
fetchKeysimplementation correctly handles errors and uses defensive coding withArray.isArray(data)check.
93-125: LGTM!The previous review comment about handling missing key in response has been addressed. The error handling at lines 114-116 now properly notifies the user when the key was created but couldn't be retrieved. The subsequent form clear and
fetchKeys()call is appropriate since the key exists server-side.
127-149: LGTM!Solid delete implementation with proper error handling and per-key loading state. Keeping the confirmation buttons visible on failure allows users to retry.
163-210: LGTM!Well-designed new key banner with clear messaging about the one-time visibility. The
select-allstyling on the code element provides a good fallback for manual copying.
212-329: LGTM!The previous review comment about empty state showing alongside error message has been addressed. The conditional at line 213 now correctly checks
keys.length === 0 && !errorto prevent both UI states from appearing simultaneously. The key list UI with per-item confirmation flow and togglable prefix visibility is well-implemented.
331-394: LGTM!Well-structured error display, accessible create dialog with proper label association (
htmlFor/id), Enter key support, and clear guidance text for API usage.src/routes/chat.ts (3)
10-16: Imports align with completion + webhook logic.
No issues with the added dependencies for metadata parsing and webhook delivery.
82-140: Completion handling is well-guarded and non-blocking.
The status guard plus conditional update prevents duplicate webhooks, and the async delivery won’t stall SSE.
335-357: SSE loop integration looks solid.
Filtering, completion detection, and malformed-event handling are all in good shape.src/routes/tasks.ts (7)
1-32: Public Tasks API scaffold and imports look good.
No concerns with the module setup or dependencies.
39-74: Conflict-safe workspace creation is solid.
The upsert + re-fetch path cleanly handles concurrent inserts.
76-89: Ownership check helper is clear and safe.
Returning 404 on mismatched ownership keeps the surface tight.
285-305: Task detail response shape is clean.
The metadata extraction is consistent and client-friendly.
311-340: Message forwarding flow looks correct.
Validation + prompt dispatch are straightforward and safe.
346-433: Verify completion/webhook handling isn’t dependent on SSE consumers.
Completion detection is currently executed inside the SSE loop. If no client connects to/events, session completion might never be processed and webhooks won’t fire. Please confirm there’s another background listener or move completion handling off the request-bound SSE path.
438-463: Cleanup flow is robust.
Sandbox destruction with logging + client removal + DB delete looks good.
✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@src/routes/tasks.ts`:
- Around line 372-380: The fetch to the sandbox event stream
(fetch(`${session.sandboxUrl}/event`)) has no timeout and can hang; update the
request to use an AbortSignal.timeout (or create an AbortController with a
setTimeout) so the connection attempt fails after a short initial timeout, then
handle the abort error by writing the same SSE error via stream.writeSSE and
calling stream.close(); ensure you reference the fetch call and the
eventResponse handling (and keep existing stream.writeSSE / stream.close
behavior) so long-lived SSE reads remain unaffected.
In `@src/web/components/settings/ApiKeySettings.tsx`:
- Around line 78-95: In fetchKeys (the useCallback function) clear any previous
error on successful retrieval by calling setError(null) when the response is ok
and after parsing data (before setKeys or immediately after), and also clear
error in the fallback branch where you set keys to an empty array; ensure
setLoading(false) remains in finally so loading state is handled as before. This
change touches fetchKeys, setError, setKeys, and setLoading.
🧹 Nitpick comments (1)
src/routes/tasks.ts (1)
333-340: Consider validating the model format.If a user passes
model: "anthropic"(without the/), the split producesproviderID = "anthropic"andmodelID = undefined, silently ignoring the model override. This may confuse API consumers who expect an error when providing an invalid format.💡 Suggested validation
try { - const [providerID, modelID] = body.model ? body.model.split('/') : []; + let providerID: string | undefined; + let modelID: string | undefined; + if (body.model) { + if (!body.model.includes('/')) { + return c.json({ error: 'model must be in format "provider/model"' }, 400); + } + [providerID, modelID] = body.model.split('/'); + } await client.session.promptAsync({
📜 Review details
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
src/routes/tasks.tssrc/web/components/settings/ApiKeySettings.tsx
🧰 Additional context used
🧬 Code graph analysis (2)
src/routes/tasks.ts (5)
src/db/index.ts (1)
db(11-11)src/db/schema.ts (5)
workspaces(15-23)chatSessions(25-40)skills(42-53)sources(55-64)userSettings(66-72)src/lib/encryption.ts (1)
decrypt(18-31)src/lib/parse-metadata.ts (1)
parseMetadata(11-31)src/routes/chat.ts (1)
handleSessionCompletionEvent(86-140)
src/web/components/settings/ApiKeySettings.tsx (3)
src/web/components/ui/button.tsx (1)
Button(52-52)src/web/components/ui/badge.tsx (1)
Badge(26-26)src/web/components/ui/dialog.tsx (6)
Dialog(95-95)DialogContent(100-100)DialogHeader(101-101)DialogTitle(103-103)DialogDescription(104-104)DialogFooter(102-102)
🔇 Additional comments (21)
src/web/components/settings/ApiKeySettings.tsx (13)
1-16: LGTM!Imports are well-organized and the
API_BASEconstant provides good centralization for API endpoint management.
17-47: LGTM!The
ApiKeyinterface properly models the API response, and the date formatting utilities handle null values gracefully. The relative time formatting provides good UX for recent activity.
49-76: LGTM!State management is well-organized by concern, and the timeout cleanup in
useEffectproperly prevents memory leaks and state updates on unmounted components.
101-133: LGTM!The create handler properly validates input, handles both success and error responses, and addresses the edge case where the API returns success without the key value (as noted in past review).
135-157: LGTM!The delete handler implements a good two-step confirmation pattern with proper loading and error states.
159-169: LGTM!Good graceful degradation when clipboard API is unavailable, with proper timeout management via the ref.
171-180: LGTM!Clean dismiss handler and appropriate loading state display.
185-218: LGTM!Excellent UX for the newly created key banner with clear one-time visibility warning, copy functionality with visual feedback, and proper text wrapping for long keys.
221-232: LGTM!The empty state properly hides when there's an error (addressing past review feedback), providing clear onboarding guidance for new users.
256-336: LGTM!Well-structured key list with inline delete confirmation, proper accessibility (button always in DOM), and good use of Tailwind group hover patterns for the delete action reveal.
339-343: LGTM!Clear error banner styling that's appropriately positioned below the key list.
346-389: LGTM!Well-structured dialog with proper accessibility (label/input association), keyboard support (Enter to create), and appropriate disabled states during creation.
391-401: LGTM!Clear contextual help text explaining API key usage with proper formatting for technical details.
src/routes/tasks.ts (8)
15-34: LGTM!Imports are well-organized and appropriate for the functionality. Good use of
randomUUIDfromnode:cryptofor generating session IDs.
46-74: LGTM!The race condition handling with
onConflictDoNothingfollowed by a re-fetch is a solid pattern for concurrent workspace creation.
76-89: LGTM!Good security practice returning the same "Task not found" error for both non-existent and unauthorized tasks, preventing task ID enumeration attacks.
94-189: LGTM!The task creation flow is well-structured:
- Input validation catches missing prompt and invalid webhook URLs
- Session creation handles race conditions with
onConflictDoNothingand re-fetch- Conflict handling pattern is consistent with the workspace creation helper
196-274: LGTM!The background async processing is well-implemented:
- Captures context variables before the async block to avoid closure issues
- Retry logic with 5 attempts and 2-second delays for session creation
- Properly transitions to
errorstatus with descriptive metadata on failure- Fire-and-forget pattern is appropriate here since the response already instructs clients to poll or use SSE
289-310: LGTM!Clean implementation with proper ownership verification and safe metadata parsing.
413-416: LGTM!Good integration of
handleSessionCompletionEventfor webhook delivery on session completion. The fire-and-forget pattern with error logging is appropriate here to avoid blocking the SSE stream.
442-467: LGTM!Good resilience pattern: logs the sandbox destruction failure but proceeds with task deletion to avoid leaving orphaned records. The
removeOpencodeClientcleanup ensures no stale client references remain.
✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@src/routes/tasks.ts`:
- Around line 335-340: The model parsing currently uses body.model.split('/')
and destructures into providerID and modelID, which drops any additional path
segments (e.g., "openai/gpt-4/turbo"); update the parsing in the tasks route so
providerID is the substring before the first '/' and modelID is the entire
remainder after the first '/', preserving variants. Concretely, in the block
that reads body.model (referencing body.model, providerID, modelID), replace the
split/destructure with logic that finds the first '/' (e.g., indexOf or split
with limit) and assigns providerID = body.model.slice(0, idx) and modelID =
body.model.slice(idx+1), keeping the existing validation that a slash must
exist.
- Around line 112-122: The current webhook URL check only validates scheme but
allows localhost/private addresses; update the validation around the
body.webhookUrl/new URL(...) block to reject internal addresses by: 1)
immediately reject obvious hostnames like "localhost" and IP literals like
"127.0.0.1" or "::1"; 2) resolve the hostname (use dns.promises.lookup or
equivalent in your environment) when the host is not an IP literal, get all
A/AAAA addresses, and check each IP against private/loopback/link-local ranges
(RFC1918: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, ::1,
fc00::/7, fe80::/10, etc.); and 3) if any resolved IP is
private/loopback/link-local, return c.json({ error: 'webhookUrl resolves to a
private or loopback address' }, 400). Ensure DNS resolution errors are handled
and treated as rejection or cause a clear 400 response.
🧹 Nitpick comments (1)
src/routes/tasks.ts (1)
438-442: Avoid exposing internal error details to API clients.
String(error)may include stack traces or internal paths, leaking implementation details. Use a generic error message for the SSE response.🛡️ Suggested fix
} catch (error) { + console.error('[tasks/events] SSE stream error:', error); await stream.writeSSE({ - data: JSON.stringify({ type: 'error', message: String(error) }), + data: JSON.stringify({ type: 'error', message: 'Event stream connection failed' }), }); }
📜 Review details
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
src/routes/tasks.tssrc/web/components/settings/ApiKeySettings.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
- src/web/components/settings/ApiKeySettings.tsx
🧰 Additional context used
🧬 Code graph analysis (1)
src/routes/tasks.ts (5)
src/db/index.ts (1)
db(11-11)src/db/schema.ts (5)
workspaces(15-23)chatSessions(25-40)skills(42-53)sources(55-64)userSettings(66-72)src/lib/encryption.ts (1)
decrypt(18-31)src/lib/parse-metadata.ts (1)
parseMetadata(11-31)src/routes/chat.ts (1)
handleSessionCompletionEvent(86-140)
🔇 Additional comments (7)
src/routes/tasks.ts (7)
15-33: LGTM!The imports are well-organized, and the
handleSessionCompletionEventimport from./chatproperly addresses the previous review feedback about webhook integration.
46-74: LGTM!The race condition fix is correctly implemented using
onConflictDoNothing()with a re-fetch fallback. This pattern safely handles concurrent workspace creation attempts.
79-89: LGTM!Good security practice returning the same 404 error for both "not found" and "not owned" cases, preventing enumeration of other users' tasks.
196-274: LGTM!The background task properly handles failures by setting the task status to
'error'with error metadata, addressing the previous review concern about tasks stuck in'creating'state. The retry logic with 5 attempts and 2-second delays is reasonable.
289-310: LGTM!Clean implementation that properly validates ownership and returns relevant task details.
422-426: LGTM!Proper integration of
handleSessionCompletionEventfor webhook delivery, addressing the previous review feedback. The fire-and-forget pattern with error logging is appropriate for non-blocking webhook delivery.
451-476: LGTM!Robust cleanup implementation that:
- Handles sandbox destruction failures gracefully (logs and continues)
- Cleans up the OpenCode client
- Deletes the DB record last to avoid orphaned external resources
✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.
…serve model path segments
…has API key management
Summary
Adds a public REST API (
/api/v1/tasks) that enables external services to programmatically create coding sessions, interact with them, and receive webhook notifications when work completes. Includes a settings UI for users to create and manage their API keys.This is the foundation for integrations like CI bots, Slack bots, or any external service that needs to trigger "go to this repo, fix something, and open a PR" workflows.
Architecture
sequenceDiagram participant External as External Service participant API as /api/v1/tasks participant Auth as API Key Auth participant DB as PostgreSQL participant Sandbox as Agentuity Sandbox participant OC as OpenCode (AI) participant GH as GitHub participant WH as Webhook URL External->>API: POST /api/v1/tasks Note right of External: {repoUrl, prompt, webhookUrl} API->>Auth: Validate Bearer token Auth-->>API: User context API->>DB: INSERT session (status: creating) API-->>External: 201 {taskId, status: creating} Note over API,Sandbox: Background (async) API->>Sandbox: Create sandbox Sandbox->>GH: Clone repo API->>OC: Create session + send prompt API->>DB: UPDATE status → active Note over OC,GH: AI works... OC->>OC: Analyze, edit files, commit OC->>GH: Push + open PR OC-->>API: session.idle event (via SSE) API->>DB: UPDATE status → completed API->>WH: POST {taskId, status: completed, prUrl} WH-->>External: 200 OK Note over External,API: Optional: continue conversation External->>API: POST /tasks/:id/messages Note right of External: {text: "Also add tests"} API->>OC: Forward prompt OC->>OC: Continue working... OC-->>API: session.idle API->>WH: POST {taskId, status: completed} External->>API: DELETE /tasks/:id API->>Sandbox: Destroy API->>DB: DELETE sessionWhat's New
1. Public Tasks API — 5 endpoints
All authenticated via API key (
Authorization: Bearer <key>), using Better Auth's existingapikeytable and thecreateApiKeyMiddlewarethat was already exported but never wired up.POST/api/v1/tasksrepoUrl,branch,prompt,webhookUrl)GET/api/v1/tasks/:idPOST/api/v1/tasks/:id/messagesGET/api/v1/tasks/:id/eventsDELETE/api/v1/tasks/:id2. Webhook Callbacks
When a task completes (OpenCode emits
session.idle), the system POSTs to the caller'swebhookUrl:{ "taskId": "uuid", "status": "completed", "repoUrl": "https://github.com/org/repo", "prUrl": "https://github.com/org/repo/pull/42", "completedAt": "2026-02-11T..." }3. Session Completion Detection
The SSE proxy in
chat.tsnow detectssession.idleevents and transitions task status fromactive→completed. This only applies to API-created tasks (wheremetadata.source === 'api'). Web UI sessions are completely untouched.4. API Key Management UI
New settings section where users can create, view, and delete API keys:
Files Changed
src/routes/tasks.tssrc/lib/webhook.tssrc/web/components/settings/ApiKeySettings.tsxsrc/api/index.ts/api/v1/tasksroutes withapiKeyMiddlewarebefore the session auth catch-allsrc/routes/chat.tssrc/web/components/pages/SettingsPage.tsxDesign Decisions
source: 'api'flag stored in existingmetadatajsonb columnmetadata.source === 'api'fetch()to Better Auth endpoints (/api/auth/api-key/*)Example Usage
Future Work (not in scope)
Summary by CodeRabbit