diff --git a/app/api/tasks/[taskId]/deployment/route.ts b/app/api/tasks/[taskId]/deployment/route.ts new file mode 100644 index 0000000..6d8ecd8 --- /dev/null +++ b/app/api/tasks/[taskId]/deployment/route.ts @@ -0,0 +1,315 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from '@/lib/session/get-server-session' +import { db } from '@/lib/db/client' +import { tasks } from '@/lib/db/schema' +import { eq, and, isNull } from 'drizzle-orm' +import { getOctokit } from '@/lib/github/client' + +export async function GET(request: NextRequest, { params }: { params: Promise<{ taskId: string }> }) { + try { + const session = await getServerSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { taskId } = await params + + // Get task from database + const taskResult = await db + .select() + .from(tasks) + .where(and(eq(tasks.id, taskId), eq(tasks.userId, session.user.id), isNull(tasks.deletedAt))) + .limit(1) + + const task = taskResult[0] + + if (!task) { + return NextResponse.json({ error: 'Task not found' }, { status: 404 }) + } + + // Return cached preview URL if available + if (task.previewUrl) { + return NextResponse.json({ + success: true, + data: { + hasDeployment: true, + previewUrl: task.previewUrl, + cached: true, + }, + }) + } + + // Return early if no branch or repo + if (!task.branchName || !task.repoUrl) { + return NextResponse.json({ + success: true, + data: { + hasDeployment: false, + message: 'Task does not have branch or repository information', + }, + }) + } + + // Parse GitHub repository URL to get owner and repo + const githubMatch = task.repoUrl.match(/github\.com\/([^\/]+)\/([^\/\.]+)/) + if (!githubMatch) { + return NextResponse.json({ + success: true, + data: { + hasDeployment: false, + message: 'Invalid GitHub repository URL', + }, + }) + } + + const [, owner, repo] = githubMatch + + try { + const octokit = await getOctokit() + + // Check if user has GitHub connected + if (!octokit.auth) { + return NextResponse.json({ + success: true, + data: { + hasDeployment: false, + message: 'GitHub account not connected', + }, + }) + } + + // First, get the latest commit on the branch to check for deployment checks + let latestCommitSha: string | null = null + try { + const { data: branch } = await octokit.rest.repos.getBranch({ + owner, + repo, + branch: task.branchName, + }) + latestCommitSha = branch.commit.sha + } catch (branchError) { + if (branchError && typeof branchError === 'object' && 'status' in branchError && branchError.status === 404) { + return NextResponse.json({ + success: true, + data: { + hasDeployment: false, + message: 'Branch not found', + }, + }) + } + throw branchError + } + + // Check for Vercel deployment via GitHub Checks API (most common) + if (latestCommitSha) { + try { + const { data: checkRuns } = await octokit.rest.checks.listForRef({ + owner, + repo, + ref: latestCommitSha, + per_page: 100, + }) + + // Helper function to extract preview URL from check output + const extractPreviewUrl = (check: { + output?: { summary?: string | null; text?: string | null } | null + }): string | null => { + // Check output summary for deployment URL + if (check.output?.summary) { + const summary = check.output.summary + // Look for URLs in the format https://[deployment]-[hash].vercel.app or other Vercel domains + const urlMatch = summary.match(/https?:\/\/[^\s\)\]<]+\.vercel\.app/i) + if (urlMatch) { + return urlMatch[0] + } + } + + // Check output text as well + if (check.output?.text) { + const text = check.output.text + const urlMatch = text.match(/https?:\/\/[^\s\)\]<]+\.vercel\.app/i) + if (urlMatch) { + return urlMatch[0] + } + } + + return null + } + + // Look for Vercel check runs - try Preview Comments first as it's more likely to have the URL + const vercelPreviewCheck = checkRuns.check_runs.find( + (check) => + check.app?.slug === 'vercel' && check.name === 'Vercel Preview Comments' && check.status === 'completed', + ) + + const vercelDeploymentCheck = checkRuns.check_runs.find( + (check) => + check.app?.slug === 'vercel' && + check.name === 'Vercel' && + check.conclusion === 'success' && + check.status === 'completed', + ) + + // Try to get preview URL from either check + let previewUrl: string | null = null + + if (vercelPreviewCheck) { + previewUrl = extractPreviewUrl(vercelPreviewCheck) + } + + if (!previewUrl && vercelDeploymentCheck) { + previewUrl = extractPreviewUrl(vercelDeploymentCheck) + } + + // Fallback to details_url if no preview URL found + if (!previewUrl && vercelDeploymentCheck?.details_url) { + previewUrl = vercelDeploymentCheck.details_url + } + + if (previewUrl) { + // Store the preview URL in the database + await db.update(tasks).set({ previewUrl }).where(eq(tasks.id, taskId)) + + return NextResponse.json({ + success: true, + data: { + hasDeployment: true, + previewUrl, + checkId: vercelDeploymentCheck?.id || vercelPreviewCheck?.id, + createdAt: vercelDeploymentCheck?.completed_at || vercelPreviewCheck?.completed_at, + }, + }) + } + } catch (checksError) { + console.error('Error checking GitHub Checks:', checksError) + // Continue to try other methods + } + } + + // Fallback: Check GitHub Deployments API + try { + const { data: deployments } = await octokit.rest.repos.listDeployments({ + owner, + repo, + ref: task.branchName, + per_page: 10, + }) + + if (deployments && deployments.length > 0) { + // Find the most recent Vercel deployment + for (const deployment of deployments) { + // Check if this is a Vercel deployment + if ( + deployment.environment === 'Preview' || + deployment.environment === 'preview' || + deployment.description?.toLowerCase().includes('vercel') + ) { + // Get deployment status + const { data: statuses } = await octokit.rest.repos.listDeploymentStatuses({ + owner, + repo, + deployment_id: deployment.id, + per_page: 1, + }) + + if (statuses && statuses.length > 0) { + const status = statuses[0] + if (status.state === 'success') { + const previewUrl = status.environment_url || status.target_url + if (previewUrl) { + // Store the preview URL in the database + await db.update(tasks).set({ previewUrl }).where(eq(tasks.id, taskId)) + + return NextResponse.json({ + success: true, + data: { + hasDeployment: true, + previewUrl, + deploymentId: deployment.id, + createdAt: deployment.created_at, + }, + }) + } + } + } + } + } + } + } catch (deploymentsError) { + console.error('Error checking GitHub Deployments:', deploymentsError) + // Continue to final fallback + } + + // Final fallback: Check commit statuses + if (latestCommitSha) { + try { + const { data: statuses } = await octokit.rest.repos.listCommitStatusesForRef({ + owner, + repo, + ref: latestCommitSha, + per_page: 100, + }) + + const vercelStatus = statuses.find( + (status) => + status.context?.toLowerCase().includes('vercel') && status.state === 'success' && status.target_url, + ) + + if (vercelStatus && vercelStatus.target_url) { + // Store the preview URL in the database + await db.update(tasks).set({ previewUrl: vercelStatus.target_url }).where(eq(tasks.id, taskId)) + + return NextResponse.json({ + success: true, + data: { + hasDeployment: true, + previewUrl: vercelStatus.target_url, + createdAt: vercelStatus.created_at, + }, + }) + } + } catch (statusError) { + console.error('Error checking commit statuses:', statusError) + } + } + + // No deployment found + return NextResponse.json({ + success: true, + data: { + hasDeployment: false, + message: 'No successful Vercel deployment found', + }, + }) + } catch (error) { + console.error('Error fetching deployment status:', error) + + // Return graceful response for common errors + if (error && typeof error === 'object' && 'status' in error && error.status === 404) { + return NextResponse.json({ + success: true, + data: { + hasDeployment: false, + message: 'Branch or repository not found', + }, + }) + } + + return NextResponse.json({ + success: true, + data: { + hasDeployment: false, + message: 'Failed to fetch deployment status', + }, + }) + } + } catch (error) { + console.error('Error in deployment API:', error) + return NextResponse.json( + { + error: error instanceof Error ? error.message : 'Internal server error', + }, + { status: 500 }, + ) + } +} diff --git a/components/app-layout.tsx b/components/app-layout.tsx index 117feff..e09a167 100644 --- a/components/app-layout.tsx +++ b/components/app-layout.tsx @@ -227,6 +227,7 @@ export function AppLayout({ children, initialSidebarWidth, initialSidebarOpen, i error: null, branchName: null, sandboxUrl: null, + previewUrl: null, mcpServerIds: null, createdAt: new Date(), updatedAt: new Date(), diff --git a/components/icons/vercel-icon.tsx b/components/icons/vercel-icon.tsx new file mode 100644 index 0000000..527cf50 --- /dev/null +++ b/components/icons/vercel-icon.tsx @@ -0,0 +1,7 @@ +export default function VercelIcon({ className = 'h-6 w-6' }: { className?: string }) { + return ( + + + + ) +} diff --git a/components/task-details.tsx b/components/task-details.tsx index b3c259e..0aa4e28 100644 --- a/components/task-details.tsx +++ b/components/task-details.tsx @@ -44,6 +44,7 @@ import LinearIcon from '@/components/icons/linear-icon' import NotionIcon from '@/components/icons/notion-icon' import PlaywrightIcon from '@/components/icons/playwright-icon' import SupabaseIcon from '@/components/icons/supabase-icon' +import VercelIcon from '@/components/icons/vercel-icon' interface TaskDetailsProps { task: Task @@ -124,6 +125,8 @@ export function TaskDetails({ task }: TaskDetailsProps) { const [isTryingAgain, setIsTryingAgain] = useState(false) const [selectedAgent, setSelectedAgent] = useState(task.selectedAgent || 'claude') const [selectedModel, setSelectedModel] = useState(task.selectedModel || DEFAULT_MODELS.claude) + const [deploymentUrl, setDeploymentUrl] = useState(task.previewUrl || null) + const [loadingDeployment, setLoadingDeployment] = useState(false) const { refreshTasks } = useTasks() const router = useRouter() @@ -285,6 +288,41 @@ export function TaskDetails({ task }: TaskDetailsProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify(task.mcpServerIds)]) + // Fetch deployment info when task is completed and has a branch (only if not already cached) + useEffect(() => { + async function fetchDeployment() { + // Skip if we already have a preview URL or task isn't ready + if (deploymentUrl || currentStatus !== 'completed' || !task.branchName) { + return + } + + setLoadingDeployment(true) + + try { + const response = await fetch(`/api/tasks/${task.id}/deployment`) + if (response.ok) { + const result = await response.json() + if (result.success && result.data.hasDeployment && result.data.previewUrl) { + setDeploymentUrl(result.data.previewUrl) + } + } + } catch (error) { + console.error('Failed to fetch deployment info:', error) + } finally { + setLoadingDeployment(false) + } + } + + fetchDeployment() + }, [task.id, task.branchName, currentStatus, deploymentUrl]) + + // Update deploymentUrl when task.previewUrl changes + useEffect(() => { + if (task.previewUrl && task.previewUrl !== deploymentUrl) { + setDeploymentUrl(task.previewUrl) + } + }, [task.previewUrl, deploymentUrl]) + // Fetch all diffs when files list changes const fetchAllDiffs = async (filesList: string[]) => { if (!filesList.length || loadingDiffsRef.current) return @@ -631,6 +669,21 @@ export function TaskDetails({ task }: TaskDetailsProps) { )} + + {/* Preview Deployment */} + {!loadingDeployment && deploymentUrl && ( +
+ + + Preview + +
+ )} diff --git a/lib/db/migrations/0012_burly_captain_britain.sql b/lib/db/migrations/0012_burly_captain_britain.sql new file mode 100644 index 0000000..e8b87f9 --- /dev/null +++ b/lib/db/migrations/0012_burly_captain_britain.sql @@ -0,0 +1 @@ +ALTER TABLE "tasks" ADD COLUMN "preview_url" text; \ No newline at end of file diff --git a/lib/db/migrations/meta/0012_snapshot.json b/lib/db/migrations/meta/0012_snapshot.json new file mode 100644 index 0000000..6d10d81 --- /dev/null +++ b/lib/db/migrations/meta/0012_snapshot.json @@ -0,0 +1,603 @@ +{ + "id": "b133eb97-a7a9-4ac9-a467-21632a8cf5a1", + "prevId": "9df31f87-de44-4c99-b222-900db0ab4d0a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "external_user_id": { + "name": "external_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_user_id_provider_idx": { + "name": "accounts_user_id_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connectors": { + "name": "connectors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'remote'" + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'disconnected'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "connectors_user_id_users_id_fk": { + "name": "connectors_user_id_users_id_fk", + "tableFrom": "connectors", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "keys_user_id_provider_idx": { + "name": "keys_user_id_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "keys_user_id_users_id_fk": { + "name": "keys_user_id_users_id_fk", + "tableFrom": "keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "selected_agent": { + "name": "selected_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'claude'" + }, + "selected_model": { + "name": "selected_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "install_dependencies": { + "name": "install_dependencies", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "max_duration": { + "name": "max_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "logs": { + "name": "logs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_url": { + "name": "sandbox_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_url": { + "name": "preview_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_server_ids": { + "name": "mcp_server_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "tasks_user_id_users_id_fk": { + "name": "tasks_user_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_provider_external_id_idx": { + "name": "users_provider_external_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/lib/db/migrations/meta/_journal.json b/lib/db/migrations/meta/_journal.json index 51decca..c18c687 100644 --- a/lib/db/migrations/meta/_journal.json +++ b/lib/db/migrations/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1760171942092, "tag": "0011_outstanding_punisher", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1760236167307, + "tag": "0012_burly_captain_britain", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 92e3175..a3c4016 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -94,6 +94,7 @@ export const tasks = pgTable('tasks', { error: text('error'), branchName: text('branch_name'), sandboxUrl: text('sandbox_url'), + previewUrl: text('preview_url'), mcpServerIds: jsonb('mcp_server_ids').$type(), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), @@ -117,6 +118,7 @@ export const insertTaskSchema = z.object({ error: z.string().optional(), branchName: z.string().optional(), sandboxUrl: z.string().optional(), + previewUrl: z.string().optional(), mcpServerIds: z.array(z.string()).optional(), createdAt: z.date().optional(), updatedAt: z.date().optional(), @@ -139,6 +141,7 @@ export const selectTaskSchema = z.object({ error: z.string().nullable(), branchName: z.string().nullable(), sandboxUrl: z.string().nullable(), + previewUrl: z.string().nullable(), mcpServerIds: z.array(z.string()).nullable(), createdAt: z.date(), updatedAt: z.date(),