diff --git a/.impeccable.md b/.impeccable.md new file mode 100644 index 0000000..4318ac0 --- /dev/null +++ b/.impeccable.md @@ -0,0 +1,41 @@ +## Design Context + +### Users +Existing developer users who already understand what deployment means and come to Sealos to launch a GitHub repository quickly. + +Their most common contexts are: +- quickly trying a deployment path to see if it works +- formally deploying a real project with confidence + +They do not need a long explanation of the product. They need a calm surface that makes the next action obvious and fast. + +### Brand Personality +Reliable, convenient, calm. + +The interface should feel trustworthy and efficient, with very little theatricality. It should communicate capability through clarity and reduction, not through decoration. + +### Aesthetic Direction +Extreme minimalist product UI with a calm, restrained tone. + +Reference direction from the product owner: closer to the refinement bar of Vercel, but not a copy. The page should feel precise, sparse, and intentional, with only one obvious primary action at each step. + +Theme requirements: +- support both light and dark mode +- default to the same restrained, premium feel in both themes +- avoid decorative color usage unless it serves hierarchy + +Anti-references from the product owner: +- demo-like UI +- backend-style form pages +- AI landing page aesthetics +- overly explained layouts +- decorative backgrounds or visual effects +- redundant controls in both header and hero +- multiple competing action buttons + +### Design Principles +1. Optimize for users who already know they want to deploy. +2. Show only the current step and make the primary action unmistakable. +3. Prefer structural clarity over instructional copy. +4. Remove visual noise before adding any new element. +5. Keep the page feeling reliable and premium in both light and dark themes. diff --git a/AGENTS.md b/AGENTS.md index bfb03c3..e624118 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ This document contains critical rules and guidelines for AI agents working on th **By default, all log statements MUST use static strings only. Dynamic values are only allowed through the task-flow logging utility in `lib/utils/task-flow-logs.ts`.** #### Bad Examples (DO NOT DO THIS): + ```typescript // BAD - Contains dynamic values await logger.info(`Task created: ${taskId}`) @@ -18,6 +19,7 @@ console.error(`Error for ${provider}:`, error) ``` #### Good Examples (DO THIS): + ```typescript // GOOD - Static strings only await logger.info('Task created') @@ -35,6 +37,7 @@ Key task flow logs may include selected dynamic values, but only when all of the 3. The values are operational identifiers only, not secrets or user content Examples of allowed dynamic values: + - `sessionId` - `threadId` - `runtimeName` @@ -48,17 +51,20 @@ Examples of allowed dynamic values: - `installedSkill` Examples that remain forbidden: + - tokens, secrets, API keys, passwords - repository URLs and file paths - raw user prompts or assistant content - branch names, commit messages, personal identifiers #### Rationale: + - **Prevents data leakage**: Dynamic values in logs can expose sensitive information (user IDs, file paths, credentials, etc.) to end users - **Security by default**: Logs are displayed directly in the UI and returned in API responses - Outside the approved task-flow utility path, this applies to ALL log levels (info, error, success, command, console.log, console.error, console.warn, etc.) #### Sensitive Data That Must NEVER Appear in Logs: + - Devbox runtime credentials (DEVBOX_TOKEN, DEVBOX_JWT_SIGNING_KEY) - User IDs and personal information - File paths and repository URLs @@ -71,8 +77,9 @@ Examples that remain forbidden: The `redactSensitiveInfo()` function in `lib/utils/logging.ts` automatically redacts known sensitive patterns, but this is a **backup measure only**. The primary defense is to never log dynamic values in the first place. #### Current Redaction Patterns: + - API keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.) -- GitHub tokens (ghp_, gho_, ghu_, ghs_, ghr_) +- GitHub tokens (ghp*, gho*, ghu*, ghs*, ghr\_) - Devbox runtime credentials (DEVBOX_TOKEN, DEVBOX_JWT_SIGNING_KEY) - Bearer tokens - JSON fields (teamId, projectId) @@ -93,6 +100,7 @@ pnpm lint ``` **If any errors are found:** + 1. **Type errors**: Fix TypeScript type errors by correcting type annotations, adding missing imports, or fixing type mismatches 2. **Lint errors**: Fix ESLint errors by following the suggested fixes or adjusting the code to meet the linting rules 3. **Do not skip or ignore errors** - all errors must be resolved before considering the task complete @@ -114,12 +122,14 @@ Existing components are in `components/ui/`. See [shadcn/ui docs](https://ui.sha **DO NOT run development servers (e.g., `npm run dev`, `pnpm dev`, `next dev`) as they will conflict with other running instances.** #### Why This Rule Exists: + - Dev servers run indefinitely and block the terminal session - Multiple instances on the same port cause conflicts - The application may already be running in the user's environment - Long-running processes make the conversation hang for the user #### Commands to AVOID: + ```bash # DO NOT RUN THESE: npm run dev @@ -133,6 +143,7 @@ nodemon ``` #### What to Do Instead: + 1. **Type checking**: Use `pnpm type-check` to verify types 2. **Linting**: Use `pnpm lint` to check code quality 3. **Formatting**: Use `pnpm format` when editing TypeScript/TSX files @@ -141,11 +152,13 @@ nodemon 6. **If the user needs to test**: Let the user run the dev server themselves #### Exception: + If the user explicitly asks you to start a dev server, politely explain why you cannot do this and suggest they run it themselves instead. ### Logging Best Practices 1. **Use descriptive static messages** + ```typescript // Instead of logging the value, log the action await logger.info('Sandbox created successfully') @@ -154,6 +167,7 @@ If the user explicitly asks you to start a dev server, politely explain why you ``` 2. **Server-side logging for debugging** + ```typescript // Use console.error for server-side debugging (not shown to users) // But still avoid sensitive data @@ -170,6 +184,7 @@ If the user explicitly asks you to start a dev server, politely explain why you ### Error Handling 1. **Generic error messages to users** + ```typescript await logger.error('Operation failed') // NOT: await logger.error(`Operation failed: ${error.message}`) @@ -186,10 +201,11 @@ If the user explicitly asks you to start a dev server, politely explain why you When making changes that involve logging: 1. **Search for dynamic values** + ```bash # Check for logger statements with template literals grep -r "logger\.(info|error|success|command)\(\`.*\$\{" . - + # Check for console statements with template literals grep -r "console\.(log|error|warn|info)\(\`.*\$\{" . ``` @@ -204,9 +220,13 @@ When making changes that involve logging: ### Environment Variables Never expose these in logs or to the client: + +- `POSTGRES_URL` - Database connection string that may contain credentials +- `SEALOS_HOST` - Sealos cluster host configuration - `DEVBOX_TOKEN` - Devbox runtime API token - `DEVBOX_JWT_SIGNING_KEY` - Devbox JWT signing secret -- `DEVBOX_GATEWAY_URL_TEMPLATE` - Devbox gateway URL template + +- `AI_GATEWAY_API_KEY` - AI Gateway API key - `ANTHROPIC_API_KEY` - Anthropic/Claude API key - `OPENAI_API_KEY` - OpenAI API key - `GEMINI_API_KEY` - Google Gemini API key @@ -219,8 +239,19 @@ Never expose these in logs or to the client: ### Client-Safe Variables Only these variables should be exposed to the client (via `NEXT_PUBLIC_` prefix): + - `NEXT_PUBLIC_AUTH_PROVIDERS` - Available auth providers -- `NEXT_PUBLIC_GITHUB_CLIENT_ID` - GitHub OAuth client ID (public) + +### Runtime Variables Used By Current App Code + +The current codebase expects these environment variables: + +- Required core runtime: `POSTGRES_URL`, `SEALOS_HOST`, `DEVBOX_TOKEN`, `JWE_SECRET`, `ENCRYPTION_KEY`, `NEXT_PUBLIC_AUTH_PROVIDERS`, `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` +- Required for Devbox JWT auth when `DEVBOX_TOKEN` is not set: `DEVBOX_JWT_SIGNING_KEY` +- Required for AI-generated branch names, titles, and commit messages: `AI_GATEWAY_API_KEY` +- Optional callback override for self-hosted deployments: `APP_BASE_URL` + +Do not rename these without updating the corresponding code paths. In particular, GitHub OAuth currently uses `GITHUB_CLIENT_ID` on the server, not `NEXT_PUBLIC_GITHUB_CLIENT_ID`. ## Architecture Guidelines @@ -229,6 +260,7 @@ Only these variables should be exposed to the client (via `NEXT_PUBLIC_` prefix) The repository page uses a nested routing structure with separate pages for each tab: #### Route Structure + ``` app/repos/[owner]/[repo]/ ├── layout.tsx # Shared layout with navigation tabs @@ -242,12 +274,14 @@ app/repos/[owner]/[repo]/ ``` #### Components + - `components/repo-layout.tsx` - Shared layout component with tab navigation - `components/repo-commits.tsx` - Commits list component - `components/repo-issues.tsx` - Issues list component - `components/repo-pull-requests.tsx` - Pull requests list component #### API Routes + ``` app/api/repos/[owner]/[repo]/ ├── commits/route.ts # GET - Fetch commits @@ -256,6 +290,7 @@ app/api/repos/[owner]/[repo]/ ``` #### Key Features + 1. **Tab Navigation**: Uses Next.js Link components for client-side navigation between tabs 2. **Separate Pages**: Each tab renders on its own route (commits, issues, pull-requests) 3. **Default Route**: Visiting `/repos/[owner]/[repo]` redirects to `/repos/[owner]/[repo]/commits` @@ -263,6 +298,7 @@ app/api/repos/[owner]/[repo]/ 5. **GitHub Integration**: All data is fetched from GitHub API using Octokit client #### Adding New Tabs + To add a new tab to the repository page: 1. Create a new directory under `app/repos/[owner]/[repo]/[tab-name]/` @@ -291,6 +327,7 @@ Before submitting changes, verify: ## Questions? If you need to log information for debugging purposes: + 1. Use server-side console logs (not shown to users) 2. Still avoid logging sensitive credentials 3. Consider adding better error handling instead of logging details @@ -300,7 +337,6 @@ If you need to log information for debugging purposes: **Remember: When in doubt, use a static string. No exceptions.** - ## Source Code Reference @@ -323,7 +359,9 @@ npx opensrc / # GitHub repo (e.g., npx opensrc vercel/ai) ``` + 4. **Use task-flow logs for approved runtime identifiers** + ```typescript import { formatKeyTaskLogMessage, TASK_FLOW_LOGS } from '@/lib/utils/task-flow-logs' diff --git a/README.md b/README.md index 166fc11..e24fb07 100644 --- a/README.md +++ b/README.md @@ -4,24 +4,13 @@ A template for building AI-powered coding agents that supports Claude Code, Open ![Coding Agent Template Screenshot](screenshot.png) -## Deploy Your Own - -You can deploy your own version of the coding agent template to Vercel with one click: - -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fcoding-agent-template&env=DEVBOX_TOKEN,DEVBOX_GATEWAY_URL_TEMPLATE,JWE_SECRET,ENCRYPTION_KEY&envDescription=Required+environment+variables+for+the+coding+agent+template.+You+must+also+configure+at+least+one+OAuth+provider+(GitHub+or+Vercel)+after+deployment.+Optional+API+keys+can+be+added+later.&stores=%5B%7B%22type%22%3A%22postgres%22%7D%5D&project-name=coding-agent-template&repository-name=coding-agent-template) - -**What happens during deployment:** -- **Automatic Database Setup**: A Neon Postgres database is automatically created and connected to your project -- **Environment Configuration**: You'll be prompted to provide required environment variables (Devbox runtime credentials and encryption keys) -- **OAuth Setup**: After deployment, you'll need to configure at least one OAuth provider (GitHub or Vercel) in your project settings for user authentication - ## Features - **Multi-Agent Support**: Choose from Claude Code, OpenAI Codex CLI, GitHub Copilot CLI, Cursor CLI, Google Gemini CLI, or opencode to execute coding tasks -- **User Authentication**: Secure sign-in with GitHub or Vercel OAuth +- **User Authentication**: Secure sign-in with GitHub OAuth - **Multi-User Support**: Each user has their own tasks, API keys, and GitHub connection - **Devbox Runtime**: Runs code in isolated, resumable runtimes -- **AI Gateway Integration**: Built for seamless integration with [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) for model routing and observability +- **AI Gateway Integration**: Built for model routing and observability - **AI-Generated Branch Names**: Automatically generates descriptive Git branch names using AI SDK 5 + AI Gateway - **Task Management**: Track task progress with real-time updates - **Persistent Storage**: Tasks stored in Neon Postgres database @@ -34,13 +23,13 @@ You can deploy your own version of the coding agent template to Vercel with one For detailed setup instructions, see the [Local Development Setup](#local-development-setup) section below. **TL;DR:** -1. Click the "Deploy with Vercel" button above (automatic database setup!) -2. Configure OAuth (GitHub or Vercel) in your project settings -3. Users sign in and start creating tasks +1. Clone the repository and install dependencies +2. Configure GitHub OAuth and required environment variables +3. Initialize the database and start creating tasks Or run locally: ```bash -git clone https://github.com/vercel-labs/coding-agent-template.git +git clone cd coding-agent-template pnpm install # Set up .env.local with required variables @@ -50,7 +39,7 @@ pnpm dev ## Usage -1. **Sign In**: Authenticate with GitHub or Vercel +1. **Sign In**: Authenticate with GitHub 2. **Create a Task**: Enter a repository URL and describe what you want the AI to do 3. **Monitor Progress**: Watch real-time logs as the agent works 4. **Review Results**: See the changes made and the branch created @@ -60,7 +49,7 @@ pnpm dev ### Maximum Duration -The maximum duration setting controls how long the Vercel sandbox will stay alive from the moment it's created. You can select timeouts ranging from 5 minutes to 5 hours. +The maximum duration setting controls how long the runtime sandbox will stay alive from the moment it's created. You can select timeouts ranging from 5 minutes to 5 hours. - The sandbox is created at the start of the task - The timeout begins when the sandbox is created @@ -123,14 +112,14 @@ When Keep Alive is enabled, the sandbox stays alive after task completion for th 1. **Task Creation**: When you submit a task, it's stored in the database 2. **AI Branch Name Generation**: AI SDK 5 + AI Gateway automatically generates a descriptive branch name based on your task (non-blocking using Next.js 15's `after()`) -3. **Sandbox Setup**: A Vercel sandbox is created with your repository +3. **Sandbox Setup**: A Devbox runtime is created with your repository 4. **Agent Execution**: Your chosen coding agent (Claude Code, Codex CLI, GitHub Copilot CLI, Cursor CLI, Gemini CLI, or opencode) analyzes your prompt and makes changes 5. **Git Operations**: Changes are committed and pushed to the AI-generated branch 6. **Cleanup**: The sandbox is shut down to free resources ## AI Branch Name Generation -The system automatically generates descriptive Git branch names using AI SDK 5 and Vercel AI Gateway. This feature: +The system automatically generates descriptive Git branch names using AI SDK 5 and AI Gateway. This feature: - **Non-blocking**: Uses Next.js 15's `after()` function to generate names without delaying task creation - **Descriptive**: Creates meaningful branch names like `feature/user-authentication-A1b2C3` or `fix/memory-leak-parser-X9y8Z7` @@ -150,10 +139,10 @@ The system automatically generates descriptive Git branch names using AI SDK 5 a - **Frontend**: Next.js 15, React 19, Tailwind CSS - **UI Components**: shadcn/ui - **Database**: PostgreSQL with Drizzle ORM -- **AI SDK**: AI SDK 5 with Vercel AI Gateway integration +- **AI SDK**: AI SDK 5 - **AI Agents**: Claude Code, OpenAI Codex CLI, GitHub Copilot CLI, Cursor CLI, Google Gemini CLI, opencode - **Runtime**: Devbox-based execution environment -- **Authentication**: Next Auth (OAuth with GitHub/Vercel) +- **Authentication**: OAuth with GitHub - **Git**: Automated branching and commits with AI-generated branch names ## MCP Server Support @@ -173,7 +162,7 @@ Connect MCP Servers to extend Claude Code with additional tools and integrations ### 1. Clone the repository ```bash -git clone https://github.com/vercel-labs/coding-agent-template.git +git clone cd coding-agent-template ``` @@ -191,51 +180,32 @@ Create a `.env.local` file with your values: These are set once by you (the app developer) and are used for core infrastructure: -- `POSTGRES_URL`: Your PostgreSQL connection string (automatically provided when deploying to Vercel via the Neon integration, or set manually for local development) +- `POSTGRES_URL`: Your PostgreSQL connection string - `DEVBOX_TOKEN`: Token used to provision and access the Devbox runtime API -- `DEVBOX_GATEWAY_URL_TEMPLATE`: URL template used to derive runtime gateway and preview URLs, for example `http://127.0.0.1:{port}` +- `SEALOS_HOST`: Sealos host entrypoint, for example `staging-usw-1.sealos.io` - `JWE_SECRET`: Base64-encoded secret for session encryption (generate with: `openssl rand -base64 32`) - `ENCRYPTION_KEY`: 32-byte hex string for encrypting user API keys and tokens (generate with: `openssl rand -hex 32`) -> **Note**: When deploying to Vercel using the "Deploy with Vercel" button, the database is automatically provisioned via Neon and `POSTGRES_URL` is set for you. For local development, you'll need to provide your own database connection string. - -#### User Authentication (Required) - -**You must configure at least one authentication method** (Vercel or GitHub): +The app derives the rest from `SEALOS_HOST`: -##### Configure Enabled Providers +- `region`: `staging-usw-1` +- `region_url`: `https://staging-usw-1.sealos.io` +- `template_api`: `https://template.staging-usw-1.sealos.io/api/v2alpha/templates/raw` +- `devbox_base_url`: `https://devbox-server.staging-usw-1.sealos.io` -- `NEXT_PUBLIC_AUTH_PROVIDERS`: Comma-separated list of enabled auth providers - - `"github"` - GitHub only (default) - - `"vercel"` - Vercel only - - `"github,vercel"` - Both providers enabled +#### User Authentication (Required) -**Examples:** +Configure GitHub OAuth for user authentication: ```bash -# GitHub authentication only (default) NEXT_PUBLIC_AUTH_PROVIDERS=github - -# Vercel authentication only -NEXT_PUBLIC_AUTH_PROVIDERS=vercel - -# Both GitHub and Vercel authentication -NEXT_PUBLIC_AUTH_PROVIDERS=github,vercel ``` -##### Provider Configuration - -**Option 1: Sign in with Vercel** (if `vercel` is in `NEXT_PUBLIC_AUTH_PROVIDERS`) -- `NEXT_PUBLIC_VERCEL_CLIENT_ID`: Your Vercel OAuth app client ID (exposed to client) -- `VERCEL_CLIENT_SECRET`: Your Vercel OAuth app client secret - -**Option 2: Sign in with GitHub** (if `github` is in `NEXT_PUBLIC_AUTH_PROVIDERS`) +Required GitHub OAuth variables: - `GITHUB_CLIENT_ID`: Your GitHub OAuth app client ID - `GITHUB_CLIENT_SECRET`: Your GitHub OAuth app client secret - `APP_BASE_URL`: Optional explicit public app URL for OAuth callbacks in self-hosted deployments -> **Note**: Only the providers listed in `NEXT_PUBLIC_AUTH_PROVIDERS` will appear in the sign-in dialog. You must provide the OAuth credentials for each enabled provider. - #### API Keys (Optional - Can be per-user) These API keys can be set globally (fallback for all users) or left unset to require users to provide their own: @@ -252,12 +222,9 @@ These API keys can be set globally (fallback for all users) or left unset to req - ~~`GITHUB_TOKEN`~~: **No longer needed!** Users authenticate with their own GitHub accounts. - Users who sign in with GitHub automatically get repository access via their OAuth token - - Users who sign in with Vercel can connect their GitHub account from their profile to access repositories **How Authentication Works:** - **Sign in with GitHub**: Users get immediate repository access via their GitHub OAuth token -- **Sign in with Vercel**: Users must connect a GitHub account from their profile to work with repositories -- **Identity Merging**: If a user signs in with Vercel, connects GitHub, then later signs in directly with GitHub, they'll be recognized as the same user (no duplicate accounts) #### Optional Environment Variables @@ -283,15 +250,6 @@ Based on your `NEXT_PUBLIC_AUTH_PROVIDERS` configuration, you'll need to create **Required Scopes**: The app will request `repo`, `read:user`, `user:email`, `read:packages`, and `write:packages` scopes to access repositories and push container images to GHCR. -#### Vercel OAuth App (if using Vercel authentication) - -1. Go to your [Vercel Dashboard](https://vercel.com/dashboard) -2. Navigate to Settings → Integrations → Create -3. Configure the integration: - - **Redirect URL**: `http://localhost:3000/api/auth/callback/vercel` -4. Copy the **Client ID** → use for `NEXT_PUBLIC_VERCEL_CLIENT_ID` -5. Copy the **Client Secret** → use for `VERCEL_CLIENT_SECRET` - > **Production Deployment**: Remember to add production callback URLs when deploying (e.g., `https://yourdomain.com/api/auth/github/callback`) ### 5. Set up the database @@ -355,205 +313,3 @@ pnpm start - **Devbox Runtime**: Runtimes are isolated but ensure you're not exposing sensitive data in logs or outputs. - **User Authentication**: Each user uses their own GitHub token for repository access - no shared credentials - **Encryption**: All sensitive data (tokens, API keys) is encrypted at rest using per-user encryption - -## Changelog - -### Version 2.0.0 - Major Update: User Authentication & Security - -This release introduces **user authentication** and **major security improvements**, but contains **breaking changes** that require migration for existing deployments. - -#### New Features - -- **User Authentication System** - - Sign in with Vercel - - Sign in with GitHub - - Session management with encrypted tokens - - User profile management - -- **Multi-User Support** - - Each user has their own tasks and connectors - - Users can manage their own API keys (Anthropic, OpenAI, Cursor, Gemini, AI Gateway) - - GitHub account connection for repository access - -- **Security Enhancements** - - Per-user GitHub authentication - each user uses their own GitHub token instead of shared credentials - - All sensitive data (tokens, API keys, env vars) encrypted at rest - - Session-based authentication with JWT encryption - - User-scoped authorization - users can only access their own resources - -- **Database Enhancements** - - New `users` table for user profiles and OAuth accounts - - New `accounts` table for linked accounts (e.g., Vercel users connecting GitHub) - - New `keys` table for user-provided API keys - - Foreign key relationships ensure data integrity - - Soft delete support for tasks - -#### Breaking Changes - -**These changes require action if upgrading from v1.x:** - -1. **Database Schema Changes** - - `tasks` table now requires `userId` (foreign key to `users.id`) - - `connectors` table now requires `userId` (foreign key to `users.id`) - - `connectors.env` changed from `jsonb` to encrypted `text` - - Added `tasks.deletedAt` for soft deletes - -2. **API Changes** - - All API endpoints now require authentication - - Task creation requires `userId` in request body - - Tasks are now filtered by user ownership - - GitHub API access uses user's own GitHub token (no shared token fallback) - -3. **Environment Variables** - - **New Required Variables:** - - `JWE_SECRET`: Base64-encoded secret for session encryption (generate: `openssl rand -base64 32`) - - `ENCRYPTION_KEY`: 32-byte hex string for encrypting sensitive data (generate: `openssl rand -hex 32`) - - `NEXT_PUBLIC_AUTH_PROVIDERS`: Configure which auth providers to enable (`github`, `vercel`, or both) - - - **New OAuth Configuration (at least one required):** - - GitHub: `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` - - Vercel: `NEXT_PUBLIC_VERCEL_CLIENT_ID`, `VERCEL_CLIENT_SECRET` - - - **Changed Authentication:** - - `GITHUB_TOKEN` no longer used as fallback in API routes - - Users must connect their own GitHub account for repository access - - Each user's GitHub token is used for their requests - -4. **Authentication Required** - - All routes now require user authentication - - No anonymous access to tasks or API endpoints - - Users must sign in with GitHub or Vercel before creating tasks - -#### Migration Guide for Existing Deployments - -If you're upgrading from v1.x to v2.0.0, follow these steps: - -##### Step 1: Backup Your Database - -```bash -# Create a backup of your existing database -pg_dump $POSTGRES_URL > backup-before-v2-migration.sql -``` - -##### Step 2: Add Required Environment Variables - -Add these new variables to your `.env.local` or Vercel project settings: - -```bash -# Session encryption (REQUIRED) -JWE_SECRET=$(openssl rand -base64 32) -ENCRYPTION_KEY=$(openssl rand -hex 32) - -# Configure auth providers (REQUIRED - choose at least one) -NEXT_PUBLIC_AUTH_PROVIDERS=github # or "vercel" or "github,vercel" - -# GitHub OAuth (if using GitHub authentication) -GITHUB_CLIENT_ID=your_github_client_id -GITHUB_CLIENT_SECRET=your_github_client_secret - -# Optional explicit public URL for OAuth callbacks in self-hosted deployments -APP_BASE_URL=http://localhost:3000 - -# Vercel OAuth (if using Vercel authentication) -NEXT_PUBLIC_VERCEL_CLIENT_ID=your_vercel_client_id -VERCEL_CLIENT_SECRET=your_vercel_client_secret -``` - -##### Step 3: Set Up OAuth Applications - -Create OAuth applications for your chosen authentication provider(s). See the [Local Development Setup](#local-development-setup) section for detailed instructions. - -##### Step 4: Prepare Database Migration - -Before running migrations, you need to handle existing data: - -**Option A: Fresh Start (Recommended for Development)** - -If you don't have production data to preserve: - -```bash -# Drop existing tables and start fresh -pnpm db:push --force - -# This will create all new tables with proper structure -``` - -**Option B: Preserve Existing Data (Production)** - -If you have existing tasks/connectors to preserve: - -1. **Create a system user first:** - -```sql --- Connect to your database and run: -INSERT INTO users (id, provider, external_id, access_token, username, email, created_at, updated_at, last_login_at) -VALUES ( - 'system-user-migration', - 'github', - 'system-migration', - 'encrypted-placeholder-token', -- You'll need to encrypt a placeholder - 'System Migration User', - NULL, - NOW(), - NOW(), - NOW() -); -``` - -2. **Update existing records:** - -```sql --- Add userId to existing tasks -ALTER TABLE tasks ADD COLUMN user_id TEXT; -UPDATE tasks SET user_id = 'system-user-migration' WHERE user_id IS NULL; -ALTER TABLE tasks ALTER COLUMN user_id SET NOT NULL; -ALTER TABLE tasks ADD CONSTRAINT tasks_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; - --- Add userId to existing connectors -ALTER TABLE connectors ADD COLUMN user_id TEXT; -UPDATE connectors SET user_id = 'system-user-migration' WHERE user_id IS NULL; -ALTER TABLE connectors ALTER COLUMN user_id SET NOT NULL; -ALTER TABLE connectors ADD CONSTRAINT connectors_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; - --- Convert connector env from jsonb to encrypted text (requires app-level encryption) --- Note: You'll need to manually encrypt existing env values using your ENCRYPTION_KEY -``` - -3. **Run the standard migrations:** - -```bash -pnpm db:generate -pnpm db:push -``` - -##### Step 5: Update Your Code - -Pull the latest changes: - -```bash -git pull origin main -pnpm install -``` - -##### Step 6: Test Authentication - -1. Start the development server: `pnpm dev` -2. Navigate to `http://localhost:3000` -3. Sign in with your configured OAuth provider -4. Verify you can create and view tasks - -##### Step 7: Verify Security Fix - -Confirm that: -- Users can only see their own tasks -- File diff/files endpoints require GitHub connection -- Users without GitHub connection see "GitHub authentication required" errors -- No `GITHUB_TOKEN` fallback is being used in API routes - -#### Important Notes - -- **All users will need to sign in** after this upgrade - no anonymous access -- **Existing tasks** will be owned by the system user if using Option B migration -- **Users must connect GitHub** (if they signed in with Vercel) to access repositories -- **API keys** can now be per-user - users can override global API keys in their profile -- **Breaking API changes**: If you have external integrations calling your API, they'll need to be updated to include authentication diff --git a/app/api/tasks/[taskId]/chat/interrupt/route.ts b/app/api/tasks/[taskId]/chat/interrupt/route.ts new file mode 100644 index 0000000..5f24454 --- /dev/null +++ b/app/api/tasks/[taskId]/chat/interrupt/route.ts @@ -0,0 +1,75 @@ +import { and, eq, isNull } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import { CodexGatewayApiError, interruptCodexGatewayTurn } from '@/lib/codex-gateway/client' +import { hasActiveTurnCheckpoint } from '@/lib/codex-gateway/completion' +import { getTaskGatewayContext } from '@/lib/codex-gateway/task' +import { db } from '@/lib/db/client' +import { tasks } from '@/lib/db/schema' +import { getServerSession } from '@/lib/session/get-server-session' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 60 + +interface RouteParams { + params: Promise<{ + taskId: string + }> +} + +export async function POST(_request: Request, { params }: RouteParams) { + try { + const session = await getServerSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { taskId } = await params + const [task] = await db + .select() + .from(tasks) + .where(and(eq(tasks.id, taskId), eq(tasks.userId, session.user.id), isNull(tasks.deletedAt))) + .limit(1) + + if (!task) { + return NextResponse.json({ error: 'Task not found' }, { status: 404 }) + } + + if (task.selectedAgent !== 'codex') { + return NextResponse.json({ error: 'Unsupported agent' }, { status: 400 }) + } + + if (!hasActiveTurnCheckpoint(task) || !task.activeTurnSessionId) { + return NextResponse.json({ error: 'Task does not have an active turn' }, { status: 409 }) + } + + const { gatewayUrl, gatewayAuthToken } = await getTaskGatewayContext(taskId, session.user.id) + if (!gatewayUrl) { + return NextResponse.json({ error: 'Gateway URL is not configured' }, { status: 400 }) + } + + const result = await interruptCodexGatewayTurn(gatewayUrl, task.activeTurnSessionId, gatewayAuthToken) + + return NextResponse.json({ + success: true, + data: { + sessionId: result.sessionId, + state: result.state, + }, + }) + } catch (error) { + if (error instanceof CodexGatewayApiError) { + return NextResponse.json( + { + error: 'Failed to interrupt active turn', + statusCode: error.status, + message: error.message, + }, + { status: error.status >= 400 && error.status < 500 ? error.status : 502 }, + ) + } + + console.error('Failed to interrupt chat turn:', error) + return NextResponse.json({ error: 'Failed to interrupt active turn' }, { status: 500 }) + } +} diff --git a/app/api/tasks/[taskId]/chat/turn/route.ts b/app/api/tasks/[taskId]/chat/turn/route.ts index 4370553..53f814d 100644 --- a/app/api/tasks/[taskId]/chat/turn/route.ts +++ b/app/api/tasks/[taskId]/chat/turn/route.ts @@ -2,16 +2,11 @@ import { after, NextRequest, NextResponse } from 'next/server' import { and, eq, isNull } from 'drizzle-orm' import { z } from 'zod' import { CodexGatewayApiError } from '@/lib/codex-gateway/client' -import { startCodexGatewayTaskTurn, waitForCodexGatewayTurnCompletion } from '@/lib/codex-gateway/runner' -import { createTaskChatStreamDescriptor } from '@/lib/codex-gateway/stream-ticket' +import { finalizeTaskChatV2Turn, startTaskChatV2Turn } from '@/lib/codex-gateway/chat-v2-service' import { db } from '@/lib/db/client' import { tasks } from '@/lib/db/schema' -import { ensureTaskDevboxRuntime } from '@/lib/devbox/runtime' import { getServerSession } from '@/lib/session/get-server-session' -import { appendTaskMessage } from '@/lib/task-messages' import { checkRateLimit } from '@/lib/utils/rate-limit' -import { createTaskLogger } from '@/lib/utils/task-logger' -import { formatKeyTaskLogMessage, TASK_FLOW_LOGS } from '@/lib/utils/task-flow-logs' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -80,58 +75,15 @@ export async function POST(request: NextRequest, { params }: RouteParams) { } const prompt = parsed.data.prompt || parsed.data.message || '' - const logger = createTaskLogger(taskId) - let userMessagePersisted = false - const userInputReceivedLog = formatKeyTaskLogMessage(TASK_FLOW_LOGS.USER_INPUT_RECEIVED, { - promptChars: prompt.length, + const result = await startTaskChatV2Turn({ + task, + prompt, source: 'chat-turn', }) - await logger.info(userInputReceivedLog) - console.info(userInputReceivedLog) - - try { - await appendTaskMessage({ - taskId: resolvedTaskId, - role: 'user', - content: prompt, - }) - userMessagePersisted = true - const userInputSavedLog = formatKeyTaskLogMessage(TASK_FLOW_LOGS.USER_INPUT_SAVED, { - promptChars: prompt.length, - source: 'chat-turn', - }) - await logger.info(userInputSavedLog) - console.info(userInputSavedLog) - } catch { - console.error('Failed to persist chat turn user message') - } - - await db - .update(tasks) - .set({ - status: 'processing', - progress: 0, - error: null, - completedAt: null, - updatedAt: new Date(), - }) - .where(eq(tasks.id, resolvedTaskId)) - - await ensureTaskDevboxRuntime(task, { logger }) - - const startedTurn = await startCodexGatewayTaskTurn(resolvedTaskId, prompt, { - appendUserMessage: !userMessagePersisted, - model: task.selectedModel, - }) - const stream = await createTaskChatStreamDescriptor({ - taskId: resolvedTaskId, - userId: session.user.id, - sessionId: startedTurn.sessionId, - }) after(async () => { try { - await waitForCodexGatewayTurnCompletion(startedTurn) + await finalizeTaskChatV2Turn(result.startedTurn) } catch (error) { console.error('Failed to finalize chat turn:', error) @@ -143,8 +95,6 @@ export async function POST(request: NextRequest, { params }: RouteParams) { updatedAt: new Date(), }) .where(eq(tasks.id, resolvedTaskId)) - - await logger.error('Failed to finalize chat turn') } }) @@ -152,14 +102,16 @@ export async function POST(request: NextRequest, { params }: RouteParams) { success: true, data: { session: { - sessionId: startedTurn.sessionId, + sessionId: result.startedTurn.sessionId, + threadId: result.startedTurn.threadId, + turnId: result.startedTurn.turnId, }, - stream, + stream: result.stream, turn: { - transcriptCursor: startedTurn.transcriptCursor, + transcriptCursor: result.startedTurn.transcriptCursor, turnAccepted: true, - turnStartedAt: startedTurn.startedAt.toISOString(), - streamUrl: stream.streamUrl, + turnStartedAt: result.startedTurn.startedAt.toISOString(), + streamUrl: result.stream.streamUrl, }, }, }) diff --git a/app/api/tasks/[taskId]/chat/v2/route.ts b/app/api/tasks/[taskId]/chat/v2/route.ts new file mode 100644 index 0000000..72d2f9e --- /dev/null +++ b/app/api/tasks/[taskId]/chat/v2/route.ts @@ -0,0 +1,228 @@ +import { after, NextRequest, NextResponse } from 'next/server' +import { and, asc, eq, isNull } from 'drizzle-orm' +import { z } from 'zod' +import { CodexGatewayApiError } from '@/lib/codex-gateway/client' +import { finalizeTaskChatV2Turn, startTaskChatV2Turn } from '@/lib/codex-gateway/chat-v2-service' +import { + hasActiveTurnCheckpoint, + reconcileIncompleteTurnSafely, + shouldAttemptTurnReconciliation, +} from '@/lib/codex-gateway/completion' +import { db } from '@/lib/db/client' +import { taskMessages, tasks } from '@/lib/db/schema' +import { reconcileProjectedTaskMessages } from '@/lib/task-event-projection' +import { + closeTaskChatV2StreamDescriptor, + ensureTaskChatV2StreamDescriptor, + getActiveTaskChatV2StreamDescriptor, +} from '@/lib/task-chat-v2' +import { listTaskEvents } from '@/lib/task-events' +import { getServerSession } from '@/lib/session/get-server-session' +import { checkRateLimit } from '@/lib/utils/rate-limit' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 300 + +const turnSchema = z.object({ + prompt: z.string().trim().min(1, 'Prompt is required'), +}) + +interface RouteParams { + params: Promise<{ + taskId: string + }> +} + +async function getOwnedTask(taskId: string, userId: string) { + const [task] = await db + .select() + .from(tasks) + .where(and(eq(tasks.id, taskId), eq(tasks.userId, userId), isNull(tasks.deletedAt))) + .limit(1) + + return task || null +} + +export async function GET(_request: NextRequest, { params }: RouteParams) { + try { + const session = await getServerSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { taskId } = await params + let task = await getOwnedTask(taskId, session.user.id) + + if (!task) { + return NextResponse.json({ error: 'Task not found' }, { status: 404 }) + } + + if ( + task.selectedAgent === 'codex' && + hasActiveTurnCheckpoint(task) && + shouldAttemptTurnReconciliation(task, 5_000) + ) { + try { + task = (await reconcileIncompleteTurnSafely(task.id, 2_500)) || task + } catch { + console.error('Failed to reconcile incomplete Codex turn') + } + } + + await reconcileProjectedTaskMessages(taskId) + + const refreshedTask = (await getOwnedTask(taskId, session.user.id)) || task + const messages = await db + .select() + .from(taskMessages) + .where(eq(taskMessages.taskId, taskId)) + .orderBy(asc(taskMessages.createdAt)) + + let stream = await getActiveTaskChatV2StreamDescriptor(taskId) + + if ( + refreshedTask.selectedAgent === 'codex' && + hasActiveTurnCheckpoint(refreshedTask) && + refreshedTask.activeTurnSessionId + ) { + stream = await ensureTaskChatV2StreamDescriptor({ + taskId, + sessionId: refreshedTask.activeTurnSessionId, + threadId: null, + turnId: null, + startedAt: refreshedTask.activeTurnStartedAt || undefined, + }) + } else if (stream) { + await closeTaskChatV2StreamDescriptor(taskId) + stream = null + } + + const events = await listTaskEvents(taskId, { limit: 200 }) + + return NextResponse.json({ + success: true, + data: { + task: refreshedTask, + messages, + events, + stream, + }, + }) + } catch (error) { + console.error('Failed to fetch chat v2 state:', error) + return NextResponse.json({ error: 'Failed to fetch chat state' }, { status: 500 }) + } +} + +export async function POST(request: NextRequest, { params }: RouteParams) { + let taskId: string | null = null + + try { + const session = await getServerSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const rateLimit = await checkRateLimit(session.user.id) + if (!rateLimit.allowed) { + return NextResponse.json( + { + error: 'Rate limit exceeded', + message: `You have reached the daily limit of ${rateLimit.total} messages (tasks + follow-ups). Your limit will reset at ${rateLimit.resetAt.toISOString()}`, + remaining: rateLimit.remaining, + total: rateLimit.total, + resetAt: rateLimit.resetAt.toISOString(), + }, + { status: 429 }, + ) + } + + ;({ taskId } = await params) + const resolvedTaskId = taskId + + const body = await request.json().catch(() => ({})) + const parsed = turnSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: 'Prompt is required' }, { status: 400 }) + } + + const task = await getOwnedTask(resolvedTaskId, session.user.id) + if (!task) { + return NextResponse.json({ error: 'Task not found' }, { status: 404 }) + } + + if (task.selectedAgent !== 'codex') { + return NextResponse.json({ error: 'Unsupported agent' }, { status: 400 }) + } + + const result = await startTaskChatV2Turn({ + task, + prompt: parsed.data.prompt, + source: 'chat-v2', + }) + + after(async () => { + try { + await finalizeTaskChatV2Turn(result.startedTurn) + } catch (error) { + console.error('Failed to finalize chat v2 turn:', error) + + await db + .update(tasks) + .set({ + status: 'error', + error: 'Failed to finalize chat turn', + updatedAt: new Date(), + }) + .where(eq(tasks.id, resolvedTaskId)) + } + }) + + return NextResponse.json({ + success: true, + data: { + session: { + sessionId: result.startedTurn.sessionId, + threadId: result.startedTurn.threadId, + turnId: result.startedTurn.turnId, + }, + stream: result.stream, + turn: { + transcriptCursor: result.startedTurn.transcriptCursor, + turnAccepted: true, + turnStartedAt: result.startedTurn.startedAt.toISOString(), + }, + }, + }) + } catch (error) { + if (taskId) { + try { + await db + .update(tasks) + .set({ + status: 'error', + error: 'Failed to start chat turn', + updatedAt: new Date(), + }) + .where(eq(tasks.id, taskId)) + } catch { + console.error('Failed to mark chat v2 turn as errored') + } + } + + if (error instanceof CodexGatewayApiError) { + return NextResponse.json( + { + error: 'Failed to start chat turn', + statusCode: error.status, + message: error.message, + }, + { status: error.status >= 400 && error.status < 500 ? error.status : 502 }, + ) + } + + console.error('Failed to start chat v2 turn:', error) + return NextResponse.json({ error: 'Failed to start chat turn' }, { status: 500 }) + } +} diff --git a/app/api/tasks/[taskId]/chat/v2/stream/route.ts b/app/api/tasks/[taskId]/chat/v2/stream/route.ts new file mode 100644 index 0000000..4c63bb6 --- /dev/null +++ b/app/api/tasks/[taskId]/chat/v2/stream/route.ts @@ -0,0 +1,295 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getCodexGatewayEventStreamUrl } from '@/lib/codex-gateway/client' +import { getTaskGatewayContext } from '@/lib/codex-gateway/task' +import type { CodexGatewayState } from '@/lib/codex-gateway/types' +import { closeTaskStream, getTaskStream, recordTaskEvent, touchTaskStream } from '@/lib/task-events' +import { getServerSession } from '@/lib/session/get-server-session' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 300 + +interface RouteParams { + params: Promise<{ + taskId: string + }> +} + +function mapGatewayEventKind(eventName: string) { + switch (eventName) { + case 'session': + return 'gateway.session.opened' as const + case 'state': + return 'gateway.state.snapshot' as const + case 'notification': + return 'gateway.notification' as const + case 'server-request': + return 'gateway.server_request' as const + case 'warning': + return 'gateway.warning' as const + case 'session-closed': + return 'gateway.session.closed' as const + default: + return null + } +} + +function parseSseBlock(block: string): { + dataText: string + eventName: string +} | null { + if (!block.trim()) { + return null + } + + let eventName = 'message' + const dataLines: string[] = [] + + for (const line of block.split(/\r?\n/)) { + if (line.startsWith('event:')) { + eventName = line.slice('event:'.length).trim() || 'message' + continue + } + + if (line.startsWith('data:')) { + dataLines.push(line.slice('data:'.length).trimStart()) + } + } + + return { + eventName, + dataText: dataLines.join('\n'), + } +} + +async function persistGatewayEvent(input: { + eventName: string + payload: Record | null + sessionId: string + streamId: string + taskId: string + transcriptCursor: number | null +}) { + const eventKind = mapGatewayEventKind(input.eventName) + + if (!eventKind) { + return + } + + if (eventKind === 'gateway.session.opened') { + const nextSessionId = + typeof input.payload?.id === 'string' && input.payload.id.trim() ? input.payload.id.trim() : input.sessionId + + await touchTaskStream(input.streamId, { sessionId: nextSessionId }) + await recordTaskEvent({ + taskId: input.taskId, + streamId: input.streamId, + kind: eventKind, + sessionId: nextSessionId, + payload: input.payload, + }) + return + } + + if (eventKind === 'gateway.state.snapshot') { + const state = (input.payload || {}) as CodexGatewayState & Record + + await touchTaskStream(input.streamId, { + threadId: state.threadId || null, + turnId: state.currentTurnId || null, + }) + + await recordTaskEvent({ + taskId: input.taskId, + streamId: input.streamId, + kind: eventKind, + sessionId: input.sessionId, + threadId: state.threadId || null, + turnId: state.currentTurnId || null, + payload: { + ...state, + transcriptCursor: input.transcriptCursor, + }, + }) + + if (!state.activeTurn && state.lastTurnStatus) { + await closeTaskStream(input.streamId, 'closed') + } + + return + } + + await recordTaskEvent({ + taskId: input.taskId, + streamId: input.streamId, + kind: eventKind, + sessionId: input.sessionId, + payload: input.payload, + }) + + if (eventKind === 'gateway.session.closed') { + await closeTaskStream(input.streamId, 'closed') + } +} + +export async function GET(request: NextRequest, { params }: RouteParams) { + const decoder = new TextDecoder() + let streamId: string | null = null + let taskId: string | null = null + + try { + const session = await getServerSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + ;({ taskId } = await params) + const resolvedTaskId = taskId + streamId = request.nextUrl.searchParams.get('streamId') + + if (!streamId) { + return NextResponse.json({ error: 'Missing stream id' }, { status: 400 }) + } + + const resolvedStreamId = streamId + + const stream = await getTaskStream(resolvedStreamId) + if (!stream || stream.taskId !== resolvedTaskId || stream.status !== 'active') { + return NextResponse.json({ error: 'Stream not found' }, { status: 404 }) + } + + const { task, gatewayUrl, gatewayAuthToken } = await getTaskGatewayContext(resolvedTaskId, session.user.id) + + if (!task) { + return NextResponse.json({ error: 'Task not found' }, { status: 404 }) + } + + if (!gatewayUrl) { + return NextResponse.json({ error: 'Gateway URL is not configured' }, { status: 400 }) + } + + if (task.activeTurnSessionId && task.activeTurnSessionId !== stream.sessionId) { + await closeTaskStream(resolvedStreamId, 'errored') + return NextResponse.json({ error: 'Stream session is no longer active' }, { status: 410 }) + } + + const upstream = await fetch(getCodexGatewayEventStreamUrl(gatewayUrl, stream.sessionId, gatewayAuthToken), { + headers: { + accept: 'text/event-stream', + }, + cache: 'no-store', + signal: AbortSignal.timeout(15_000), + }) + + if (!upstream.ok || !upstream.body) { + await closeTaskStream(resolvedStreamId, 'errored') + return NextResponse.json({ error: 'Failed to connect to Codex gateway events' }, { status: 502 }) + } + + const headers = new Headers() + headers.set('content-type', 'text/event-stream; charset=utf-8') + headers.set('cache-control', 'no-cache, no-transform') + headers.set('connection', 'keep-alive') + headers.set('x-accel-buffering', 'no') + + const transcriptCursor = + task.activeTurnSessionId === stream.sessionId && typeof task.activeTurnTranscriptCursor === 'number' + ? task.activeTurnTranscriptCursor + : null + + const reader = upstream.body.getReader() + let sseBuffer = '' + + const handleSseBlock = async (block: string) => { + const parsedBlock = parseSseBlock(block) + if (!parsedBlock || !parsedBlock.dataText) { + return + } + + try { + const payload = JSON.parse(parsedBlock.dataText) as Record + await persistGatewayEvent({ + taskId: resolvedTaskId, + streamId: resolvedStreamId, + sessionId: stream.sessionId, + eventName: parsedBlock.eventName, + payload, + transcriptCursor, + }) + } catch { + console.error('Failed to persist gateway stream event') + } + } + + const flushBufferedEvents = async (flushAll: boolean) => { + while (true) { + const separatorMatch = sseBuffer.match(/\r?\n\r?\n/) + if (!separatorMatch || separatorMatch.index === undefined) { + break + } + + const block = sseBuffer.slice(0, separatorMatch.index) + sseBuffer = sseBuffer.slice(separatorMatch.index + separatorMatch[0].length) + await handleSseBlock(block) + } + + if (flushAll && sseBuffer.trim()) { + const finalBlock = sseBuffer + sseBuffer = '' + await handleSseBlock(finalBlock) + } + } + + const streamResponse = new ReadableStream({ + async start(controller) { + try { + while (true) { + const { done, value } = await reader.read() + + if (done) { + break + } + + if (!value) { + continue + } + + controller.enqueue(value) + sseBuffer += decoder.decode(value, { stream: true }) + await flushBufferedEvents(false) + } + + sseBuffer += decoder.decode() + await flushBufferedEvents(true) + controller.close() + } catch (error) { + await closeTaskStream(resolvedStreamId, 'errored') + try { + controller.close() + } catch { + // Ignore close errors after upstream socket termination. + } + } finally { + reader.releaseLock() + } + }, + async cancel() { + await reader.cancel() + }, + }) + + return new Response(streamResponse, { + status: 200, + headers, + }) + } catch (error) { + if (streamId) { + await closeTaskStream(streamId, 'errored').catch(() => { + console.error('Failed to close chat v2 stream after proxy error') + }) + } + + console.error('Failed to proxy chat v2 stream:', error) + return NextResponse.json({ error: 'Failed to proxy chat stream' }, { status: 500 }) + } +} diff --git a/app/api/tasks/[taskId]/continue/route.ts b/app/api/tasks/[taskId]/continue/route.ts index 7c845fe..02b09f2 100644 --- a/app/api/tasks/[taskId]/continue/route.ts +++ b/app/api/tasks/[taskId]/continue/route.ts @@ -4,6 +4,7 @@ import { db } from '@/lib/db/client' import { tasks } from '@/lib/db/schema' import { startCodexGatewayTaskTurn, waitForCodexGatewayTurnCompletion } from '@/lib/codex-gateway/runner' import { ensureTaskDevboxRuntime } from '@/lib/devbox/runtime' +import { prependSealosDeployContext } from '@/lib/sealos-deploy-context' import { checkRateLimit } from '@/lib/utils/rate-limit' import { createTaskLogger } from '@/lib/utils/task-logger' import { getServerSession } from '@/lib/session/get-server-session' @@ -82,9 +83,10 @@ export async function POST(req: NextRequest, context: { params: Promise<{ taskId const logger = createTaskLogger(taskId) try { - await ensureTaskDevboxRuntime(task, { logger }) + const runtime = await ensureTaskDevboxRuntime(task, { logger }) + const gatewayPrompt = prependSealosDeployContext(trimmedMessage, runtime.namespace || task.runtimeNamespace) - const startedTurn = await startCodexGatewayTaskTurn(taskId, trimmedMessage, { + const startedTurn = await startCodexGatewayTaskTurn(taskId, gatewayPrompt, { appendUserMessage: !userMessagePersisted, model: task.selectedModel, }) diff --git a/app/api/tasks/[taskId]/events/route.ts b/app/api/tasks/[taskId]/events/route.ts new file mode 100644 index 0000000..71d4761 --- /dev/null +++ b/app/api/tasks/[taskId]/events/route.ts @@ -0,0 +1,49 @@ +import { and, eq, isNull } from 'drizzle-orm' +import { NextRequest, NextResponse } from 'next/server' +import { db } from '@/lib/db/client' +import { tasks } from '@/lib/db/schema' +import { listTaskEvents } from '@/lib/task-events' +import { getServerSession } from '@/lib/session/get-server-session' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 60 + +interface RouteParams { + params: Promise<{ + taskId: string + }> +} + +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const session = await getServerSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { taskId } = await params + const [task] = await db + .select({ id: tasks.id }) + .from(tasks) + .where(and(eq(tasks.id, taskId), eq(tasks.userId, session.user.id), isNull(tasks.deletedAt))) + .limit(1) + + if (!task) { + return NextResponse.json({ error: 'Task not found' }, { status: 404 }) + } + + const limitParam = Number.parseInt(request.nextUrl.searchParams.get('limit') || '', 10) + const events = await listTaskEvents(taskId, { + limit: Number.isFinite(limitParam) ? limitParam : 200, + }) + + return NextResponse.json({ + success: true, + events, + }) + } catch (error) { + console.error('Failed to fetch task events:', error) + return NextResponse.json({ error: 'Failed to fetch task events' }, { status: 500 }) + } +} diff --git a/app/api/tasks/[taskId]/gateway/session/route.ts b/app/api/tasks/[taskId]/gateway/session/route.ts index 46165d8..b5e8287 100644 --- a/app/api/tasks/[taskId]/gateway/session/route.ts +++ b/app/api/tasks/[taskId]/gateway/session/route.ts @@ -30,7 +30,7 @@ function createGatewayUnavailableResponse() { return NextResponse.json( { error: 'Gateway URL is not configured', - message: 'Set DEVBOX_GATEWAY_URL_TEMPLATE or persist gatewayUrl for this task runtime', + message: 'Gateway URL is not available from the Devbox runtime', }, { status: 400 }, ) diff --git a/app/api/tasks/[taskId]/gateway/turn/route.ts b/app/api/tasks/[taskId]/gateway/turn/route.ts index ba50c6e..c178b71 100644 --- a/app/api/tasks/[taskId]/gateway/turn/route.ts +++ b/app/api/tasks/[taskId]/gateway/turn/route.ts @@ -6,7 +6,9 @@ import { tasks } from '@/lib/db/schema' import { CodexGatewayApiError } from '@/lib/codex-gateway/client' import { startCodexGatewayTaskTurn, waitForCodexGatewayTurnCompletion } from '@/lib/codex-gateway/runner' import { getTaskGatewayContext } from '@/lib/codex-gateway/task' +import { prependSealosDeployContext } from '@/lib/sealos-deploy-context' import { getServerSession } from '@/lib/session/get-server-session' +import { appendTaskMessage } from '@/lib/task-messages' import { createTaskLogger } from '@/lib/utils/task-logger' const turnSchema = z.object({ @@ -45,10 +47,25 @@ export async function POST(request: NextRequest, { params }: RouteParams) { } const prompt = parsed.data.prompt - const startedTurn = await startCodexGatewayTaskTurn(taskId, prompt, { - appendUserMessage: true, - model: task.selectedModel, - }) + + try { + await appendTaskMessage({ + taskId, + role: 'user', + content: prompt, + }) + } catch { + console.error('Failed to persist gateway turn user message') + } + + const startedTurn = await startCodexGatewayTaskTurn( + taskId, + prependSealosDeployContext(prompt, task.runtimeNamespace), + { + appendUserMessage: false, + model: task.selectedModel, + }, + ) after(async () => { try { diff --git a/app/api/tasks/[taskId]/messages/route.ts b/app/api/tasks/[taskId]/messages/route.ts index a67f58c..7344724 100644 --- a/app/api/tasks/[taskId]/messages/route.ts +++ b/app/api/tasks/[taskId]/messages/route.ts @@ -3,7 +3,12 @@ import { getServerSession } from '@/lib/session/get-server-session' import { db } from '@/lib/db/client' import { taskMessages, tasks } from '@/lib/db/schema' import { eq, and, asc, isNull } from 'drizzle-orm' -import { hasActiveTurnCheckpoint, reconcileIncompleteTurn } from '@/lib/codex-gateway/completion' +import { + hasActiveTurnCheckpoint, + reconcileIncompleteTurnSafely, + shouldAttemptTurnReconciliation, +} from '@/lib/codex-gateway/completion' +import { reconcileProjectedTaskMessages } from '@/lib/task-event-projection' export async function GET(req: NextRequest, context: { params: Promise<{ taskId: string }> }) { try { @@ -26,14 +31,20 @@ export async function GET(req: NextRequest, context: { params: Promise<{ taskId: return NextResponse.json({ error: 'Task not found' }, { status: 404 }) } - if (task[0].selectedAgent === 'codex' && hasActiveTurnCheckpoint(task[0])) { + if ( + task[0].selectedAgent === 'codex' && + hasActiveTurnCheckpoint(task[0]) && + shouldAttemptTurnReconciliation(task[0], 5_000) + ) { try { - await reconcileIncompleteTurn(taskId) + await reconcileIncompleteTurnSafely(taskId, 2_500) } catch { console.error('Failed to reconcile incomplete Codex turn') } } + await reconcileProjectedTaskMessages(taskId) + // Fetch all messages for this task, ordered by creation time const messages = await db .select() diff --git a/app/api/tasks/[taskId]/route.ts b/app/api/tasks/[taskId]/route.ts index 602570d..03cc59c 100644 --- a/app/api/tasks/[taskId]/route.ts +++ b/app/api/tasks/[taskId]/route.ts @@ -6,8 +6,13 @@ import { createTaskLogger } from '@/lib/utils/task-logger' import { deleteDevbox, DevboxApiError } from '@/lib/devbox/client' import { getServerSession } from '@/lib/session/get-server-session' import { CodexGatewayApiError, deleteCodexGatewaySession } from '@/lib/codex-gateway/client' -import { hasActiveTurnCheckpoint, reconcileIncompleteTurn } from '@/lib/codex-gateway/completion' +import { + hasActiveTurnCheckpoint, + reconcileIncompleteTurnSafely, + shouldAttemptTurnReconciliation, +} from '@/lib/codex-gateway/completion' import { getTaskGatewayContext } from '@/lib/codex-gateway/task' +import { closeTaskChatV2StreamDescriptor } from '@/lib/task-chat-v2' interface RouteParams { params: Promise<{ @@ -35,9 +40,13 @@ export async function GET(request: NextRequest, { params }: RouteParams) { let currentTask = task[0] - if (currentTask.selectedAgent === 'codex' && hasActiveTurnCheckpoint(currentTask)) { + if ( + currentTask.selectedAgent === 'codex' && + hasActiveTurnCheckpoint(currentTask) && + shouldAttemptTurnReconciliation(currentTask) + ) { try { - currentTask = (await reconcileIncompleteTurn(currentTask.id)) || currentTask + currentTask = (await reconcileIncompleteTurnSafely(currentTask.id)) || currentTask } catch { console.error('Failed to reconcile incomplete Codex turn') } @@ -127,6 +136,10 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { .where(eq(tasks.id, taskId)) .returning() + await closeTaskChatV2StreamDescriptor(taskId).catch(() => { + console.error('Failed to close active chat stream during stop') + }) + if (existingTask.runtimeName) { try { await deleteDevbox(existingTask.runtimeName) diff --git a/app/api/tasks/route.ts b/app/api/tasks/route.ts index 1fbac32..cb8cb9b 100644 --- a/app/api/tasks/route.ts +++ b/app/api/tasks/route.ts @@ -1,17 +1,14 @@ import { NextRequest, NextResponse, after } from 'next/server' import { and, desc, eq, isNull, or } from 'drizzle-orm' -import { startCodexGatewayTaskTurn, waitForCodexGatewayTurnCompletion } from '@/lib/codex-gateway/runner' +import { finalizeTaskChatV2Turn, startTaskChatV2Turn } from '@/lib/codex-gateway/chat-v2-service' import { FORCED_CODEX_MODEL } from '@/lib/codex/defaults' import { db } from '@/lib/db/client' import { insertTaskSchema, tasks } from '@/lib/db/schema' -import { ensureTaskDevboxRuntime } from '@/lib/devbox/runtime' import { getServerSession } from '@/lib/session/get-server-session' -import { appendTaskMessage } from '@/lib/task-messages' import { generateBranchName, createFallbackBranchName } from '@/lib/utils/branch-name-generator' import { generateId } from '@/lib/utils/id' import { checkRateLimit } from '@/lib/utils/rate-limit' import { createTaskLogger } from '@/lib/utils/task-logger' -import { formatKeyTaskLogMessage, TASK_FLOW_LOGS } from '@/lib/utils/task-flow-logs' import { generateTaskTitle, createFallbackTitle } from '@/lib/utils/title-generator' export const runtime = 'nodejs' @@ -190,44 +187,16 @@ export async function POST(request: NextRequest) { } }) - const logger = createTaskLogger(taskId) - let userMessagePersisted = false - const userInputReceivedLog = formatKeyTaskLogMessage(TASK_FLOW_LOGS.USER_INPUT_RECEIVED, { - promptChars: validatedData.prompt.length, - source: 'task-create', - selectedModel, - }) - await logger.info(userInputReceivedLog) - console.info(userInputReceivedLog) - try { - await appendTaskMessage({ - taskId, - role: 'user', - content: validatedData.prompt, - }) - userMessagePersisted = true - const userInputSavedLog = formatKeyTaskLogMessage(TASK_FLOW_LOGS.USER_INPUT_SAVED, { - promptChars: validatedData.prompt.length, + const startedTurn = await startTaskChatV2Turn({ + task: newTask, + prompt: validatedData.prompt, source: 'task-create', }) - await logger.info(userInputSavedLog) - console.info(userInputSavedLog) - } catch { - console.error('Failed to persist task user message') - } - - try { - await ensureTaskDevboxRuntime(newTask, { logger }) - - const startedTurn = await startCodexGatewayTaskTurn(taskId, validatedData.prompt, { - appendUserMessage: !userMessagePersisted, - model: selectedModel, - }) after(async () => { try { - await waitForCodexGatewayTurnCompletion(startedTurn) + await finalizeTaskChatV2Turn(startedTurn.startedTurn) } catch { console.error('Failed to finalize Codex task') @@ -240,6 +209,7 @@ export async function POST(request: NextRequest) { }) .where(eq(tasks.id, taskId)) + const logger = createTaskLogger(taskId) await logger.error('Failed to finalize Codex task') } }) @@ -255,6 +225,7 @@ export async function POST(request: NextRequest) { }) .where(eq(tasks.id, taskId)) + const logger = createTaskLogger(taskId) await logger.error('Failed to start Codex gateway task') return NextResponse.json({ error: 'Failed to start Codex gateway task' }, { status: 500 }) } diff --git a/app/globals.css b/app/globals.css index 888964a..246fec2 100644 --- a/app/globals.css +++ b/app/globals.css @@ -8,6 +8,7 @@ @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); + --font-display: var(--font-commissioner); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); --color-sidebar-ring: var(--sidebar-ring); @@ -120,6 +121,86 @@ } body { @apply bg-background text-foreground; + font-kerning: normal; + text-rendering: optimizeLegibility; + } +} + +@layer utilities { + .sealos-eyebrow { + color: color-mix(in oklab, var(--foreground) 44%, var(--background)); + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.18em; + line-height: 1; + text-transform: uppercase; + } + + .sealos-hero-title { + font-family: var(--font-display), var(--font-sans), sans-serif; + font-size: clamp(2.5rem, 5vw, 3.8rem); + font-weight: 600; + letter-spacing: -0.062em; + line-height: 0.92; + text-wrap: balance; + } + + .sealos-section-title { + font-family: var(--font-display), var(--font-sans), sans-serif; + font-size: clamp(1.35rem, 2vw, 1.75rem); + font-weight: 600; + letter-spacing: -0.05em; + line-height: 1.02; + text-wrap: balance; + } + + .sealos-body { + max-width: 34rem; + color: var(--muted-foreground); + font-size: 0.95rem; + font-weight: 400; + letter-spacing: -0.012em; + line-height: 1.68; + text-wrap: pretty; + } + + .sealos-meta-label { + color: var(--muted-foreground); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.16em; + line-height: 1; + text-transform: uppercase; + } + + .sealos-meta-value { + color: var(--foreground); + font-size: 0.98rem; + font-weight: 600; + letter-spacing: -0.028em; + line-height: 1.2; + } + + .sealos-command-text { + color: var(--foreground); + font-size: 1.02rem; + font-weight: 400; + letter-spacing: -0.018em; + line-height: 1.72; + } + + .sealos-helper { + color: var(--muted-foreground); + font-size: 0.78rem; + font-weight: 400; + letter-spacing: -0.006em; + line-height: 1.45; + } + + .sealos-action-text { + font-size: 0.93rem; + font-weight: 600; + letter-spacing: -0.022em; } } diff --git a/app/layout.tsx b/app/layout.tsx index 4b71bc6..07e5831 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,5 @@ import type { Metadata } from 'next' -import { Geist, Geist_Mono } from 'next/font/google' +import { Commissioner, Geist, Geist_Mono } from 'next/font/google' import './globals.css' import { Toaster } from '@/components/ui/sonner' import { ThemeProvider } from '@/components/theme-provider' @@ -19,6 +19,12 @@ const geistMono = Geist_Mono({ subsets: ['latin'], }) +const commissioner = Commissioner({ + variable: '--font-commissioner', + subsets: ['latin'], + weight: ['500', '600'], +}) + export const metadata: Metadata = { title: 'Analyze and Ship to Sealos', description: @@ -32,7 +38,7 @@ export default function RootLayout({ }>) { return ( - + diff --git a/codex-gateway b/codex-gateway new file mode 160000 index 0000000..4b43a42 --- /dev/null +++ b/codex-gateway @@ -0,0 +1 @@ +Subproject commit 4b43a42503d18484d616ec565df979f121f3be51 diff --git a/components/home-page-content.tsx b/components/home-page-content.tsx index 4ac7a0c..05c14ef 100644 --- a/components/home-page-content.tsx +++ b/components/home-page-content.tsx @@ -29,7 +29,6 @@ import { multiRepoModeAtom, selectedReposAtom } from '@/lib/atoms/multi-repo' import { sessionAtom } from '@/lib/atoms/session' import { githubConnectionAtom, githubConnectionInitializedAtom } from '@/lib/atoms/github-connection' import { OpenRepoUrlDialog } from '@/components/open-repo-url-dialog' -import { MultiRepoDialog } from '@/components/multi-repo-dialog' interface HomePageContentProps { initialSelectedOwner?: string @@ -62,7 +61,6 @@ export function HomePageContent({ const [loadingGitHub, setLoadingGitHub] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false) const [showOpenRepoDialog, setShowOpenRepoDialog] = useState(false) - const [showMultiRepoDialog, setShowMultiRepoDialog] = useState(false) const router = useRouter() const searchParams = useSearchParams() const { refreshTasks, addTaskOptimistically } = useTasks() @@ -265,7 +263,6 @@ export function HomePageContent({ onOwnerChange={handleOwnerChange} onRepoChange={handleRepoChange} size="sm" - onMultiRepoClick={() => setShowMultiRepoDialog(true)} /> @@ -557,6 +554,7 @@ export function HomePageContent({ - {/* Sign In Dialog */} diff --git a/components/home-page-mobile-footer.tsx b/components/home-page-mobile-footer.tsx index c347e14..9441aac 100644 --- a/components/home-page-mobile-footer.tsx +++ b/components/home-page-mobile-footer.tsx @@ -2,7 +2,6 @@ import { Button } from '@/components/ui/button' import { GitHubIcon } from '@/components/icons/github-icon' -import { VERCEL_DEPLOY_URL } from '@/lib/constants' import { formatAbbreviatedNumber } from '@/lib/utils/format-number' const GITHUB_REPO_URL = 'https://github.com/vercel-labs/coding-agent-template' @@ -15,7 +14,6 @@ export function HomePageMobileFooter({ initialStars = 1200 }: HomePageMobileFoot return (
- {/* GitHub Stars Button */} - - {/* Deploy to Vercel Button */} - {/* */}
) diff --git a/components/repo-selector.tsx b/components/repo-selector.tsx index 83708d0..4cc66a6 100644 --- a/components/repo-selector.tsx +++ b/components/repo-selector.tsx @@ -3,11 +3,10 @@ import { useState, useEffect, useRef } from 'react' import { Input } from '@/components/ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { Lock, Loader2, Layers } from 'lucide-react' +import { Lock, Loader2 } from 'lucide-react' import { useAtomValue, useSetAtom, useAtom } from 'jotai' import { githubConnectionAtom } from '@/lib/atoms/github-connection' import { githubOwnersAtom, githubReposAtomFamily } from '@/lib/atoms/github-cache' -import { multiRepoModeAtom, selectedReposAtom } from '@/lib/atoms/multi-repo' interface GitHubOwner { login: string @@ -31,7 +30,6 @@ interface RepoSelectorProps { onRepoChange: (repo: string) => void disabled?: boolean size?: 'sm' | 'default' - onMultiRepoClick?: () => void } export function RepoSelector({ @@ -41,7 +39,6 @@ export function RepoSelector({ onRepoChange, disabled = false, size = 'default', - onMultiRepoClick, }: RepoSelectorProps) { const [repoFilter, setRepoFilter] = useState('') // Initialize with selected owner to prevent flash @@ -54,10 +51,6 @@ export function RepoSelector({ const [temporaryOwner, setTemporaryOwner] = useState(null) const [temporaryRepo, setTemporaryRepo] = useState(null) - // Multi-repo mode state - const [multiRepoMode, setMultiRepoMode] = useAtom(multiRepoModeAtom) - const selectedRepos = useAtomValue(selectedReposAtom) - // Ref for the filter input to focus it when dropdown opens const filterInputRef = useRef(null) @@ -413,16 +406,6 @@ export function RepoSelector({ } const handleOwnerChange = (value: string) => { - if (value === '__many__') { - // Enable multi-repo mode - setMultiRepoMode(true) - onMultiRepoClick?.() - return - } - - // Disable multi-repo mode when selecting a specific owner - setMultiRepoMode(false) - onOwnerChange(value) onRepoChange('') // Reset repo when owner changes setRepoFilter('') // Reset filter when owner changes @@ -482,22 +465,13 @@ export function RepoSelector({ return (
- {showOwnersLoading ? (
Loading...
- ) : multiRepoMode ? ( -
- - Multi-repo -
) : size === 'sm' && selectedOwnerData ? ( // Mobile: Show only avatar
@@ -515,14 +489,6 @@ export function RepoSelector({ )} - {/* Multi-repo option */} - -
- - Multi-repo -
-
-
{displayedOwners && displayedOwners.map((owner) => ( @@ -535,84 +501,74 @@ export function RepoSelector({ - {/* Show "X repo(s) selected" button in multi-repo mode, or regular repo dropdown otherwise */} - {multiRepoMode ? ( - - ) : ( - selectedOwner && ( - <> - / - - 50 - ? `Filter ${repos?.length || 0} repositories...` - : 'Filter repositories...' - } - value={repoFilter} - onChange={(e) => setRepoFilter(e.target.value)} - disabled={disabled} - className="text-base md:text-sm h-8" - onClick={(e) => e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - /> -
- )} - {filteredRepos.length === 0 && repoFilter ? ( -
- No repositories match "{repoFilter}" -
- ) : showReposLoading ? ( -
- - Loading repositories... -
- ) : ( - <> - {displayedRepos.map((repo) => ( - -
- {repo.name} - {repo.private && } -
-
- ))} - {hasMoreRepos && ( -
- Showing first 50 of {repos?.length || 0} repositories. Use filter to find more. + {selectedOwner && ( + <> + / + + 50 + ? `Filter ${repos?.length || 0} repositories...` + : 'Filter repositories...' + } + value={repoFilter} + onChange={(e) => setRepoFilter(e.target.value)} + disabled={disabled} + className="text-base md:text-sm h-8" + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + /> +
+ )} + {filteredRepos.length === 0 && repoFilter ? ( +
+ No repositories match "{repoFilter}" +
+ ) : showReposLoading ? ( +
+ + Loading repositories... +
+ ) : ( + <> + {displayedRepos.map((repo) => ( + +
+ {repo.name} + {repo.private && }
- )} - - )} -
- - - ) + + ))} + {hasMoreRepos && ( +
+ Showing first 50 of {repos?.length || 0} repositories. Use filter to find more. +
+ )} + + )} + + + )}
) diff --git a/components/sealos-home-page-content.tsx b/components/sealos-home-page-content.tsx index 9d1d5e8..f46145e 100644 --- a/components/sealos-home-page-content.tsx +++ b/components/sealos-home-page-content.tsx @@ -53,6 +53,13 @@ export function SealosHomePageContent({ const githubConnectionInitialized = useAtomValue(githubConnectionInitializedAtom) const isGitHubAuthUser = session.authProvider === 'github' const { github: hasGitHub, vercel: hasVercel } = getEnabledAuthProviders() + const isAuthenticated = Boolean(user) + const visibleSelectedOwner = isAuthenticated ? selectedOwner : '' + const visibleSelectedRepo = isAuthenticated ? selectedRepo : '' + const hasSelectedRepo = Boolean(visibleSelectedOwner && visibleSelectedRepo) + const canSelectRepository = + isAuthenticated && + (githubConnection.connected || isGitHubAuthUser || Boolean(selectedOwner) || Boolean(selectedRepo)) const handleOwnerChange = (owner: string) => { setSelectedOwnerState(owner) @@ -86,7 +93,7 @@ export function SealosHomePageContent({ if (!data.repoUrl) { toast.error('Please select a repository', { - description: 'Choose a GitHub repository from the header before starting the task.', + description: 'Choose a GitHub repository before starting the task.', }) return } @@ -137,23 +144,69 @@ export function SealosHomePageContent({ window.location.href = '/api/auth/github/signin' } - const headerLeftActions = ( -
- {!githubConnectionInitialized ? null : githubConnection.connected || - isGitHubAuthUser || - selectedOwner || - selectedRepo ? ( - - ) : user ? ( - + )} +
+
+ + {!isAuthenticated ? ( + ) : null} @@ -162,18 +215,33 @@ export function SealosHomePageContent({ return (
- +
- +
+
+
Deploy to Sealos
+

Deploy with one command

+

{pageDescription}

+
+ + +
diff --git a/components/shared-header.tsx b/components/shared-header.tsx index 504d50b..07cdccd 100644 --- a/components/shared-header.tsx +++ b/components/shared-header.tsx @@ -5,14 +5,13 @@ import { Menu } from 'lucide-react' import { useTasks } from '@/components/app-layout' import { User } from '@/components/auth/user' import { GitHubStarsButton } from '@/components/github-stars-button' -import { VERCEL_DEPLOY_URL } from '@/lib/constants' interface SharedHeaderProps { leftActions?: React.ReactNode extraActions?: React.ReactNode initialStars?: number hideStars?: boolean - hideDeployButton?: boolean + hideUserAction?: boolean } export function SharedHeader({ @@ -20,7 +19,7 @@ export function SharedHeader({ extraActions, initialStars = 1200, hideStars = true, - hideDeployButton = true, + hideUserAction = false, }: SharedHeaderProps) { const { toggleSidebar } = useTasks() @@ -39,30 +38,9 @@ export function SharedHeader({
{!hideStars && } - {!hideDeployButton && ( - - )} - {extraActions} - + {!hideUserAction ? : null}
diff --git a/components/task-agent-activity.tsx b/components/task-agent-activity.tsx new file mode 100644 index 0000000..3773c22 --- /dev/null +++ b/components/task-agent-activity.tsx @@ -0,0 +1,126 @@ +'use client' + +import { memo, useMemo } from 'react' +import { AlertCircle, CheckCircle2, Loader2, ShieldAlert } from 'lucide-react' +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' +import type { TaskAgentActivityItem } from '@/lib/task-agent-events' +import { cn } from '@/lib/utils' + +interface TaskAgentActivityProps { + isStreaming: boolean + items: TaskAgentActivityItem[] +} + +function getToneIcon(tone: TaskAgentActivityItem['tone']) { + if (tone === 'success') { + return + } + + if (tone === 'error') { + return + } + + if (tone === 'warning') { + return + } + + return +} + +export const TaskAgentActivity = memo(function TaskAgentActivity({ isStreaming, items }: TaskAgentActivityProps) { + const visibleItems = useMemo(() => { + const recentItems = [...items].toSorted((left, right) => left.occurredAt.localeCompare(right.occurredAt)).slice(-12) + const latestByGroup = new Map() + + for (const item of recentItems) { + latestByGroup.set(item.groupKey, item) + } + + return Array.from(latestByGroup.values()) + .toSorted((left, right) => right.occurredAt.localeCompare(left.occurredAt)) + .slice(0, 4) + }, [items]) + + const latestItem = visibleItems[0] + + if (!latestItem && !isStreaming) { + return null + } + + const summaryLabel = latestItem?.label || 'Preparing response' + const summaryDetail = latestItem?.detail || (isStreaming ? 'Agent is working on the next reply' : 'Recent activity') + const summaryTone = latestItem?.tone || 'default' + + if (visibleItems.length === 0) { + return ( +
+
+
{getToneIcon(summaryTone)}
+
+
{summaryLabel}
+
{summaryDetail}
+
+ {isStreaming ? ( +
+ + Live +
+ ) : null} +
+
+ ) + } + + return ( + + + +
+
{getToneIcon(summaryTone)}
+
+
{summaryLabel}
+
{summaryDetail}
+
+ {isStreaming ? ( +
+ + Live +
+ ) : null} +
+
+ +
+ {visibleItems.map((item) => ( +
+
{getToneIcon(item.tone)}
+
+
+ {item.label} + + {new Date(item.occurredAt).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + })} + +
+ {item.detail ? ( +
+ {item.detail} +
+ ) : null} +
+
+ ))} +
+
+
+
+ ) +}) diff --git a/components/task-chat-transcript.tsx b/components/task-chat-transcript.tsx index 46f089c..975323a 100644 --- a/components/task-chat-transcript.tsx +++ b/components/task-chat-transcript.tsx @@ -3,14 +3,18 @@ import { memo, useDeferredValue, useEffect, useMemo, useRef } from 'react' import { Check, Copy, Loader2, RotateCcw } from 'lucide-react' import type { LogEntry, Task } from '@/lib/db/schema' +import { TaskAgentActivity } from '@/components/task-agent-activity' import type { ChatTaskMessage } from '@/lib/task-chat' import { buildChatTurns, parseTaskAgentMessage } from '@/lib/task-chat' +import type { TaskAgentActivityItem } from '@/lib/task-agent-events' import { TaskChatMarkdown } from '@/components/task-chat-markdown' import { cn } from '@/lib/utils' interface TaskChatTranscriptProps { + activityItems: TaskAgentActivityItem[] copiedMessageId: string | null isGatewayTask: boolean + isStreaming: boolean logs: LogEntry[] messages: ChatTaskMessage[] onCopyMessage: (messageId: string, content: string) => void @@ -23,8 +27,10 @@ function isTaskProcessing(status: Task['status']): boolean { } export const TaskChatTranscript = memo(function TaskChatTranscript({ + activityItems, copiedMessageId, isGatewayTask, + isStreaming, logs, messages, onCopyMessage, @@ -35,7 +41,7 @@ export const TaskChatTranscript = memo(function TaskChatTranscript({ const wasAtBottomRef = useRef(true) const deferredMessages = useDeferredValue(messages) const turns = useMemo(() => buildChatTurns(deferredMessages), [deferredMessages]) - const isProcessing = isTaskProcessing(status) + const isProcessing = isStreaming || isTaskProcessing(status) const visibleLogs = useMemo(() => logs.filter((entry) => !entry.message.startsWith('[SERVER]')).slice(-6), [logs]) @@ -129,6 +135,10 @@ export const TaskChatTranscript = memo(function TaskChatTranscript({ ) : null} + {isGatewayTask && isLastTurn ? ( + + ) : null} + {turn.agentMessages.map((message) => { const content = parseTaskAgentMessage(message.content).trim() const isStreamingMessage = message.id.startsWith('gateway-stream-') diff --git a/components/task-chat.tsx b/components/task-chat.tsx index 218c093..45e9d9b 100644 --- a/components/task-chat.tsx +++ b/components/task-chat.tsx @@ -21,7 +21,7 @@ import { TaskChatComposer } from '@/components/task-chat-composer' import { TaskChatMarkdown } from '@/components/task-chat-markdown' import { TaskChatTranscript } from '@/components/task-chat-transcript' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' -import { useTaskChatMessages } from '@/lib/hooks/use-task-chat-messages' +import { useTaskAgentChatV2 } from '@/lib/hooks/use-task-agent-chat-v2' interface TaskChatProps { taskId: string @@ -57,10 +57,6 @@ interface DeploymentInfo { createdAt?: string } -function isTaskProcessing(status: Task['status']): boolean { - return status === 'processing' || status === 'pending' -} - export function TaskChat({ taskId, task, chatOnly = false }: TaskChatProps) { const [activeTab, setActiveTab] = useState<'chat' | 'comments' | 'actions' | 'deployments'>('chat') const [newMessage, setNewMessage] = useAtom(taskChatInputAtomFamily(taskId)) @@ -79,17 +75,19 @@ export function TaskChat({ taskId, task, chatOnly = false }: TaskChatProps) { const deploymentLoadedRef = useRef(false) const { + activityItems, error: chatError, isGatewayTask, isLoading, isSending, isStopping, + isStreaming, messages, refreshMessages, retryMessage, sendMessage, stopTask, - } = useTaskChatMessages(taskId, task) + } = useTaskAgentChatV2(taskId, task) const fetchPRComments = useCallback( async (showLoading = true) => { @@ -247,11 +245,11 @@ export function TaskChat({ taskId, task, chatOnly = false }: TaskChatProps) { const handleStopTask = useCallback(async () => { const result = await stopTask() if (result.success) { - toast.success('Task stopped successfully') + toast.success('Generation stopped') return } - toast.error(result.error || 'Failed to stop task') + toast.error(result.error || 'Failed to stop generation') }, [stopTask]) const handleCopyMessage = useCallback(async (messageId: string, content: string) => { @@ -584,8 +582,10 @@ export function TaskChat({ taskId, task, chatOnly = false }: TaskChatProps) { return ( void isSubmitting: boolean + isAuthenticated: boolean selectedOwner: string selectedRepo: string + commandHeader?: React.ReactNode + variant?: 'default' | 'command' + commandDisabled?: boolean + commandPlaceholder?: string + commandHelperText?: string + alwaysShowCommandHelper?: boolean initialInstallDependencies?: boolean initialMaxDuration?: number initialKeepAlive?: boolean @@ -45,13 +53,23 @@ const FIXED_TASK_MODEL = 'gpt-5.4' export function TaskForm({ onSubmit, isSubmitting, + isAuthenticated, selectedOwner, selectedRepo, + commandHeader, + variant = 'default', + commandDisabled = false, + commandPlaceholder, + commandHelperText, + alwaysShowCommandHelper = false, initialMaxDuration = 300, }: TaskFormProps) { const [prompt, setPrompt] = useAtom(taskPromptAtom) const [repos, setRepos] = useAtom(githubReposAtomFamily(selectedOwner)) const textareaRef = useRef(null) + const hasSelectedRepo = Boolean(selectedOwner && selectedRepo) + const isCommandVariant = variant === 'command' + const defaultCommand = 'Prepare this repo for Sealos with /sealos-deploy.' const handleTextareaKeyDown = (event: React.KeyboardEvent) => { if (event.key !== 'Enter') { @@ -62,15 +80,19 @@ export function TaskForm({ if (!isMobile && !event.shiftKey) { event.preventDefault() - if (prompt.trim()) { + if (!commandDisabled && (isCommandVariant || prompt.trim())) { event.currentTarget.closest('form')?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) } } } useEffect(() => { + if (commandDisabled || (typeof window !== 'undefined' && window.matchMedia('(max-width: 768px)').matches)) { + return + } + textareaRef.current?.focus() - }, []) + }, [commandDisabled]) useEffect(() => { if (!selectedOwner) { @@ -100,15 +122,21 @@ export function TaskForm({ const handleSubmit = (event: React.FormEvent) => { event.preventDefault() + if (commandDisabled) { + return + } + const trimmedPrompt = prompt.trim() - if (!trimmedPrompt) { + const effectivePrompt = trimmedPrompt || (isCommandVariant ? defaultCommand : '') + + if (!effectivePrompt) { return } const selectedRepoData = repos?.find((repo) => repo.name === selectedRepo) onSubmit({ - prompt: trimmedPrompt, + prompt: effectivePrompt, repoUrl: selectedRepoData?.clone_url || '', selectedAgent: FIXED_TASK_AGENT, selectedModel: FIXED_TASK_MODEL, @@ -119,44 +147,126 @@ export function TaskForm({ }) } + const title = !isAuthenticated + ? 'Sign In to Deploy on Sealos' + : hasSelectedRepo + ? 'Deploy Your Project to Sealos' + : 'Choose a Repository to Deploy' + + const description = !isAuthenticated + ? 'Sign in first, then choose a GitHub repository and tell Sealos how it should be analyzed, built, and deployed.' + : hasSelectedRepo + ? 'Tell Sealos what you want to do with this repository. A simple deployment request is enough.' + : 'Connect GitHub if needed, choose a repository, then describe how Sealos should analyze, build, and deploy it.' + + const repoBannerText = hasSelectedRepo + ? `${selectedOwner}/${selectedRepo}` + : !isAuthenticated + ? 'Sign in to choose a GitHub repository.' + : 'Choose a GitHub repository from the header to begin.' + + const placeholder = isCommandVariant + ? (commandPlaceholder ?? defaultCommand) + : !isAuthenticated + ? 'Sign in first. After that, choose a repository and describe the deployment task here.' + : hasSelectedRepo + ? 'For example: deploy this repository to Sealos.' + : 'Choose a repository first. Then describe how Sealos should build and deploy it.' + + const helperText = isCommandVariant + ? (commandHelperText ?? 'Enter to deploy. Shift+Enter for a new line.') + : !isAuthenticated + ? 'Start by signing in. After you pick a repository, you can describe the deployment task here.' + : hasSelectedRepo + ? '' + : 'After you pick a repository, describe what Sealos should build and deploy.' + const showCommandHelper = + isCommandVariant && Boolean(helperText) && (alwaysShowCommandHelper || prompt.trim().length > 0 || isSubmitting) + + if (isCommandVariant) { + return ( +
+ + +
+
{commandHeader ??
{repoBannerText}
}
+ +
+