diff --git a/.env.local.example b/.env.local.example index 7ead2aeba4..a53e31541d 100644 --- a/.env.local.example +++ b/.env.local.example @@ -101,7 +101,6 @@ INTERNAL_API_SECRET=changeme # Git token service persisted-authorization disconnect GIT_TOKEN_SERVICE_API_URL=http://localhost:8802 # Worker URLs (defaults shown, workers are optional) -CLOUD_AGENT_API_URL=http://localhost:8788 WEBHOOK_AGENT_URL=http://localhost:8793 MODEL_EVAL_INGEST_URL=http://localhost:8798 SESSION_INGEST_WORKER_URL= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e92a6626fe..1d9fd47916 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,6 @@ jobs: timeout-minutes: 5 outputs: kilocode_backend: ${{ steps.filter.outputs.kilocode_backend }} - cloud_agent: ${{ steps.filter.outputs.cloud_agent }} cloud_agent_next: ${{ steps.filter.outputs.cloud_agent_next }} workspace_matrix: ${{ steps.workspaces.outputs.matrix }} steps: @@ -63,15 +62,13 @@ jobs: - 'packages/worker-utils/**' - 'package.json' - 'pnpm-lock.yaml' - cloud_agent: - - 'services/cloud-agent/**' cloud_agent_next: - 'services/cloud-agent-next/**' - name: Detect changed workspaces with tests id: workspaces run: | - matrix=$(scripts/changed-workspaces.sh --exclude services/cloud-agent --exclude services/cloud-agent-next --exclude apps/web) + matrix=$(scripts/changed-workspaces.sh --exclude services/cloud-agent-next --exclude apps/web) echo "matrix=$matrix" >> "$GITHUB_OUTPUT" typecheck: @@ -301,40 +298,6 @@ jobs: NODE_OPTIONS: '--max-old-space-size=8192' run: pnpm run build - cloud-agent: - needs: [changes, typecheck, lint, format-check, drizzle-check] - if: needs.changes.outputs.cloud_agent == 'true' - runs-on: ${{ vars.RUNNER_LARGE_LABEL || 'ubuntu-24.04-8core' }} - timeout-minutes: 15 - steps: - - uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1 - with: - lfs: true - - - name: Setup pnpm - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - - - name: Setup Node - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 - with: - node-version-file: '.nvmrc' - cache: 'pnpm' - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version: latest - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build wrapper bundle - working-directory: services/cloud-agent/wrapper - run: bun run build.ts - - - name: Run cloud-agent tests - run: pnpm --filter cloud-agent test:all - cloud-agent-next: needs: [changes, typecheck, lint, format-check, drizzle-check] if: needs.changes.outputs.cloud_agent_next == 'true' @@ -403,17 +366,7 @@ jobs: notify-main-failure: if: ${{ always() && github.ref == 'refs/heads/main' && contains(join(needs.*.result, ','), 'failure') }} needs: - [ - typecheck, - lint, - format-check, - drizzle-check, - test, - build, - cloud-agent, - cloud-agent-next, - workspace-tests, - ] + [typecheck, lint, format-check, drizzle-check, test, build, cloud-agent-next, workspace-tests] runs-on: ${{ vars.RUNNER_DEFAULT_LABEL || 'ubuntu-latest' }} timeout-minutes: 5 steps: diff --git a/.oxlintrc.json b/.oxlintrc.json index 5316a68505..9d78b380c2 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -180,7 +180,6 @@ }, { "files": [ - "services/cloud-agent/src/session/queries/*.ts", "services/cloud-agent-next/src/session/queries/*.ts", "services/gastown/**/*.ts", "services/wasteland/**/*.ts", diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 9dff2cebce..0d6b339dfb 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -350,7 +350,7 @@ AI inference works locally without any extra services. The Next.js app includes ### Running workers locally -Each worker in the workspace can be started individually with `wrangler dev` (or `pnpm dev`) from its directory. Workers communicate with Next.js over HTTP using env vars like `CLOUD_AGENT_API_URL`, `CODE_REVIEW_WORKER_URL`, etc. Dev ports are defined in each worker's `wrangler.jsonc`. +Each worker in the workspace can be started individually with `wrangler dev` (or `pnpm dev`) from its directory. Workers communicate with Next.js over HTTP using env vars like `CLOUD_AGENT_NEXT_API_URL`, `CODE_REVIEW_WORKER_URL`, etc. Dev ports are defined in each worker's `wrangler.jsonc`. The easiest way to run workers is with `pnpm dev:start` (see [Common Development Commands](#common-development-commands)), which starts groups of related services in a tmux dashboard. @@ -435,7 +435,7 @@ If `CF_AE_TOKEN` is missing, Grafana will still boot — only dashboard queries - **Service bindings** resolve locally for Workers launched together by `pnpm dev:start` when the bound target is running. Bindings to optional services remain unavailable unless their owning group is started (for example, session-ingest -> o11y requires the `observability` group). - **Webhook → KiloClaw Chat** triggers require the KiloClaw worker running on port 8795. The webhook worker calls it via `KILOCLAW_API_URL` (HTTP, not a service binding) to deliver messages to Stream Chat. Stream Chat credentials (`STREAM_CHAT_API_KEY`, `STREAM_CHAT_API_SECRET`) must be in `kiloclaw/.dev.vars`. -- **Cloudflare Containers** (used by cloud-agent, cloud-agent-next, app-builder) always run on Cloudflare's remote infrastructure, even in dev mode. Purely local execution is not possible. +- **Cloudflare Containers** (used by Cloud Agent Next and App Builder) always run on Cloudflare's remote infrastructure, even in dev mode. Purely local execution is not possible. - **Analytics Engine writes** are no-ops in `wrangler dev` — there is no local AE simulator. Reads against the real prod datasets still work via the local Grafana above. **Pipelines** and **dispatch namespaces** don't work locally. ### What works without running any workers diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index e076870339..f08562ba8e 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -160,7 +160,7 @@ This document lists all environment variables used in the Kilo Code cloud monore - `POSTGRES_MAX_QUERY_TIME` - Max allowed query time in ms. [SERVER] - `USE_PRODUCTION_DB` - Forces use of the production DB URL in non-production contexts; used by `packages/db/src/database-url.ts`. [SERVER] - `DATABASE_CA` - CA certificate content (PEM) for TLS connections to Postgres; used by `packages/db/src/database-url.ts` in tests and scripts. [SERVER] -- `DATABASE_URL` - Generic/alternate Postgres URL used by E2E tests and some services (`cloud-agent`, `kiloclaw`). `[SECRET]` +- `DATABASE_URL` - Generic/alternate Postgres URL used by E2E tests and some services (`cloud-agent-next`, `kiloclaw`). `[SECRET]` ### Redis & Queue @@ -193,10 +193,8 @@ This document lists all environment variables used in the Kilo Code cloud monore - `USER_DEPLOYMENTS_DISPATCHER_URL` - URL for the deployments dispatcher (local dev). [SERVER] - `USER_DEPLOYMENTS_ENV_VARS_PUBLIC_KEY` - Public key for encrypting user deployment env vars. [SERVER] - `USER_DEPLOYMENTS_ENV_VARS_PRIVATE_KEY` - Private key counterpart for decrypting user deployment env vars. `[SECRET]` -- `CLOUD_AGENT_API_URL` - URL for Cloud Agent Next API; used by App Builder chat and other clients. [SERVER] -- `CLOUD_AGENT_NEXT_API_URL` - Alias for `CLOUD_AGENT_API_URL` in local dev overrides. [SERVER] -- `NEXT_PUBLIC_CLOUD_AGENT_WS_URL` - WebSocket URL for Cloud Agent (legacy) from browser. [PUBLIC] -- `NEXT_PUBLIC_CLOUD_AGENT_NEXT_WS_URL` - WebSocket URL for Cloud Agent Next from browser. [PUBLIC] +- `CLOUD_AGENT_NEXT_API_URL` - URL for the Cloud Agent Next API; used by App Builder chat and other clients. [SERVER] +- `NEXT_PUBLIC_CLOUD_AGENT_NEXT_WS_URL` - WebSocket URL for Cloud Agent Next from the browser. [PUBLIC] - `CLOUD_AGENT_R2_ATTACHMENTS_BUCKET_NAME` - R2 bucket for cloud agent file attachments. [SERVER] - `GASTOWN_SERVICE_URL` - URL for the Gastown service. [SERVER] - `NEXT_PUBLIC_GASTOWN_URL` - Client-side base URL for Gastown. [PUBLIC] @@ -304,16 +302,12 @@ This document lists all environment variables used in the Kilo Code cloud monore ### Cloud Agent Services -- `KILOCODE_TOKEN` - Auth token for KiloCode/Session service identity; used by `cloud-agent-next` wrapper and Gastown containers. `[SECRET]` +- `KILOCODE_TOKEN` - Auth token for KiloCode/Session service identity; used by the Cloud Agent Next wrapper and Gastown containers. `[SECRET]` - `KILOCODE_TOKEN_FILE` - Path to a file containing the KiloCode token (alternative to the env var). [SERVER] -- `KILO_SESSION_ID` - Legacy/session-wrapper session identifier. [SERVER] -- `KILO_SESSION_INGEST_URL` - URL for ingesting session data from the cloud agent wrapper. [SERVER] -- `INGEST_URL` - URL for telemetry/event ingest from `cloud-agent/wrapper`. [SERVER] -- `KILO_PLATFORM` - Target platform identifier for the cloud-agent wrapper (`darwin`, `linux`, etc.). [SERVER] -- `CLI_LOG_PATH` - File path for the cloud-agent wrapper's local CLI log output. [SERVER] -- `WRAPPER_LOG_PATH` - File path for the cloud-agent-next wrapper's log output. [SERVER] +- `KILO_SESSION_INGEST_URL` - URL used by the Cloud Agent Next wrapper to ingest session data. [SERVER] +- `KILO_PLATFORM` - Target platform identifier used by the Cloud Agent Next wrapper (`darwin`, `linux`, etc.). [SERVER] +- `WRAPPER_LOG_PATH` - File path for the Cloud Agent Next wrapper's log output. [SERVER] - `KILO_BIN_PATH` - Path or name of the `kilo` CLI binary; used by `services/cloud-agent-next/scripts/update-default-slash-commands.mjs`. [SERVER] -- `UPSTREAM_BRANCH` - Default upstream branch name for the cloud-agent wrapper workspace. [SERVER] - `WORKSPACE_PATH` - Filesystem path of the agent workspace. [SERVER] - `SESSION_ID` - Reserved session identifier for the `cloud-agent-next` runtime; reserved in `RESERVED_ENV_VARS`. [SERVER] - `HOME` - Reserved in `RESERVED_ENV_VARS` for cloud-agent-next session home management. [SYSTEM] @@ -349,7 +343,7 @@ This document lists all environment variables used in the Kilo Code cloud monore - `API_BASE_URL` - Base HTTPS URL for the mobile app's API (e.g. `https://api.kilo.ai`). Bundled into the binary. [PUBLIC] - `WEB_BASE_URL` - Base HTTPS URL for the mobile in-app web views (e.g. `https://app.kilo.ai`). Bundled into the binary. [PUBLIC] -- `CLOUD_AGENT_WS_URL` - WebSocket URL for cloud-agent streaming in the mobile app. Bundled into the binary. [PUBLIC] +- `CLOUD_AGENT_WS_URL` - WebSocket URL for Cloud Agent Next streaming in the mobile app. Bundled into the binary. [PUBLIC] - `SESSION_INGEST_WS_URL` - WebSocket URL for session ingest from the mobile app. Bundled into the binary. [PUBLIC] - `APPSFLYER_DEV_KEY` - AppsFlyer development key for mobile attribution. Bundled into the binary (not secret — it's a device-level SDK key). [PUBLIC] - `APPSFLYER_APP_ID` - AppsFlyer app ID for mobile attribution tracking. [PUBLIC] diff --git a/apps/web/.env.development.local.example b/apps/web/.env.development.local.example index 914be54de0..6452a3be3e 100644 --- a/apps/web/.env.development.local.example +++ b/apps/web/.env.development.local.example @@ -1,6 +1,3 @@ -# @url cloud-agent-next -CLOUD_AGENT_API_URL=http://localhost:8794 - # @url cloud-agent-next CLOUD_AGENT_NEXT_API_URL=http://localhost:8794 @@ -53,9 +50,6 @@ O11Y_SERVICE_URL=http://localhost:8801 # @from O11Y_KILO_GATEWAY_CLIENT_SECRET O11Y_KILO_GATEWAY_CLIENT_SECRET= -# @url cloud-agent-next -NEXT_PUBLIC_CLOUD_AGENT_WS_URL=ws://localhost:8794 - # @url cloud-agent-next NEXT_PUBLIC_CLOUD_AGENT_NEXT_WS_URL=ws://localhost:8794 diff --git a/apps/web/.env.test b/apps/web/.env.test index 01e8eb0668..b32dbf8663 100644 --- a/apps/web/.env.test +++ b/apps/web/.env.test @@ -31,7 +31,6 @@ XAI_API_KEY= GIGAPOTATO_API_KEY= GIGAPOTATO_API_URL= GENLABS_API_KEY= -CLOUD_AGENT_API_URL= NEXT_PUBLIC_TURNSTILE_SITE_KEY=invalid-turnstile-site TURNSTILE_SECRET_KEY=invalid-turnstile-test-key WORKOS_API_KEY=invalid-mock-workos-key @@ -52,7 +51,6 @@ R2_CLI_SESSIONS_BUCKET_NAME=test-bucket GENLABS_API_KEY='' GIGAPOTATO_API_KEY='' GIGAPOTATO_API_URL='' -CLOUD_AGENT_API_URL='' CODE_REVIEW_WORKER_URL=mock-url'' CODE_REVIEW_WORKER_AUTH_TOKEN='mock-key' AUTO_TRIAGE_URL='mock-url' diff --git a/apps/web/jest.config.ts b/apps/web/jest.config.ts index 8d6dc93d1b..ecdac91d98 100644 --- a/apps/web/jest.config.ts +++ b/apps/web/jest.config.ts @@ -37,7 +37,6 @@ const config: Config = { testMatch: ['**/src/**/*.test.ts', '/../../packages/db/src/**/*.test.ts'], testPathIgnorePatterns: [ '/../../.kilocode/', - '/../../services/cloud-agent/', '/../../services/cloud-agent-next/', '/../../services/app-builder/', '/../../services/webhook-agent-ingest/', diff --git a/apps/web/knip.ts b/apps/web/knip.ts index 27fa22bfc1..8ec1edb830 100644 --- a/apps/web/knip.ts +++ b/apps/web/knip.ts @@ -76,10 +76,6 @@ const config: KnipConfig = { '@chromatic-com/storybook', ], }, - '../../services/cloud-agent': { - entry: ['src/index.ts', 'test/**/*.test.ts'], - ignoreDependencies: ['cloudflare', '@vitest/coverage-v8'], - }, '../../services/ai-attribution': { entry: ['src/ai-attribution.worker.ts'], ignoreDependencies: ['cloudflare'], diff --git a/apps/web/src/app/(app)/cloud/webhooks/[triggerId]/EditWebhookTriggerContent.tsx b/apps/web/src/app/(app)/cloud/webhooks/[triggerId]/EditWebhookTriggerContent.tsx index 629002f2bf..0695f0b9f9 100644 --- a/apps/web/src/app/(app)/cloud/webhooks/[triggerId]/EditWebhookTriggerContent.tsx +++ b/apps/web/src/app/(app)/cloud/webhooks/[triggerId]/EditWebhookTriggerContent.tsx @@ -61,11 +61,11 @@ export function EditWebhookTriggerContent({ error: repoError, } = useQuery( organizationId - ? trpc.organizations.cloudAgent.listGitHubRepositories.queryOptions({ + ? trpc.organizations.cloudAgentNext.listGitHubRepositories.queryOptions({ organizationId, forceRefresh: false, }) - : trpc.cloudAgent.listGitHubRepositories.queryOptions({ + : trpc.cloudAgentNext.listGitHubRepositories.queryOptions({ forceRefresh: false, }) ); diff --git a/apps/web/src/app/(app)/cloud/webhooks/new/CreateWebhookTriggerContent.tsx b/apps/web/src/app/(app)/cloud/webhooks/new/CreateWebhookTriggerContent.tsx index bbc2ea5a4a..2560631da1 100644 --- a/apps/web/src/app/(app)/cloud/webhooks/new/CreateWebhookTriggerContent.tsx +++ b/apps/web/src/app/(app)/cloud/webhooks/new/CreateWebhookTriggerContent.tsx @@ -43,11 +43,11 @@ export function CreateWebhookTriggerContent({ organizationId }: CreateWebhookTri error: repoError, } = useQuery( organizationId - ? trpc.organizations.cloudAgent.listGitHubRepositories.queryOptions({ + ? trpc.organizations.cloudAgentNext.listGitHubRepositories.queryOptions({ organizationId, forceRefresh: false, }) - : trpc.cloudAgent.listGitHubRepositories.queryOptions({ + : trpc.cloudAgentNext.listGitHubRepositories.queryOptions({ forceRefresh: false, }) ); diff --git a/apps/web/src/app/(app)/gastown/onboarding/OnboardingStepRepo.tsx b/apps/web/src/app/(app)/gastown/onboarding/OnboardingStepRepo.tsx index ce92d0866a..0483f8de5f 100644 --- a/apps/web/src/app/(app)/gastown/onboarding/OnboardingStepRepo.tsx +++ b/apps/web/src/app/(app)/gastown/onboarding/OnboardingStepRepo.tsx @@ -28,7 +28,7 @@ export function OnboardingStepRepo() { organizationId: orgId, forceRefresh: false, }) - : mainTrpc.cloudAgent.listGitHubRepositories.queryOptions({ forceRefresh: false })), + : mainTrpc.cloudAgentNext.listGitHubRepositories.queryOptions({ forceRefresh: false })), }); const gitlabReposQuery = useQuery({ @@ -37,7 +37,7 @@ export function OnboardingStepRepo() { organizationId: orgId, forceRefresh: false, }) - : mainTrpc.cloudAgent.listGitLabRepositories.queryOptions({ forceRefresh: false })), + : mainTrpc.cloudAgentNext.listGitLabRepositories.queryOptions({ forceRefresh: false })), }); const unifiedRepositories = useMemo(() => { diff --git a/apps/web/src/app/api/cloud-agent/sessions/prepare/route.test.ts b/apps/web/src/app/api/cloud-agent/sessions/prepare/route.test.ts deleted file mode 100644 index ea49a4db7a..0000000000 --- a/apps/web/src/app/api/cloud-agent/sessions/prepare/route.test.ts +++ /dev/null @@ -1,618 +0,0 @@ -import { describe, test, expect, beforeEach } from '@jest/globals'; -import { NextResponse } from 'next/server'; -import { POST } from './route'; -import { failureResult } from '@/lib/maybe-result'; -import { getUserFromAuth } from '@/lib/user/server'; -import { ensureOrganizationAccess } from '@/routers/organizations/utils'; -import { - validateGitHubRepoAccessForUser, - validateGitHubRepoAccessForOrganization, - getGitHubInstallationIdForUser, - getGitHubInstallationIdForOrganization, -} from '@/lib/cloud-agent/github-integration-helpers'; -import { createCloudAgentClient } from '@/lib/cloud-agent/cloud-agent-client'; -import { signStreamTicket } from '@/lib/cloud-agent/stream-ticket'; -import type { User } from '@kilocode/db/schema'; - -jest.mock('@/lib/user/server'); -jest.mock('@/routers/organizations/utils'); -jest.mock('@/lib/cloud-agent/github-integration-helpers'); -jest.mock('@/lib/cloud-agent/cloud-agent-client'); -jest.mock('@/lib/cloud-agent/stream-ticket'); - -const mockedGetUserFromAuth = jest.mocked(getUserFromAuth); -const mockedEnsureOrganizationAccess = jest.mocked(ensureOrganizationAccess); -const mockedGetGitHubInstallationIdForUser = jest.mocked(getGitHubInstallationIdForUser); -const mockedGetGitHubInstallationIdForOrganization = jest.mocked( - getGitHubInstallationIdForOrganization -); -const mockedValidateGitHubRepoAccessForUser = jest.mocked(validateGitHubRepoAccessForUser); -const mockedValidateGitHubRepoAccessForOrganization = jest.mocked( - validateGitHubRepoAccessForOrganization -); -const mockedCreateCloudAgentClient = jest.mocked(createCloudAgentClient); -const mockedSignStreamTicket = jest.mocked(signStreamTicket); - -function makeRequest(body: unknown) { - return new Request('http://localhost:3000/api/cloud-agent/sessions/prepare', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); -} - -function makeInvalidJsonRequest() { - return new Request('http://localhost:3000/api/cloud-agent/sessions/prepare', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: 'not valid json', - }); -} - -const validInput = { - prompt: 'Add a hello world function', - mode: 'code', - model: 'anthropic/claude-3-5-sonnet', - githubRepo: 'owner/repo', -}; - -function createMockUser(overrides: Partial = {}): User { - return { - id: crypto.randomUUID(), - google_user_email: `test-${Date.now()}@example.com`, - google_user_name: 'Test User', - google_user_image_url: 'https://example.com/avatar.png', - hosted_domain: 'example.com', - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - is_admin: false, - blocked_reason: null, - blocked_at: null, - blocked_by_kilo_user_id: null, - api_token_pepper: 'test-pepper', - web_session_pepper: null, - auto_top_up_enabled: false, - kiloclaw_early_access: false, - stripe_customer_id: 'cus_test123', - app_store_account_token: crypto.randomUUID(), - microdollars_used: 0, - kilo_pass_threshold: null, - total_microdollars_acquired: 0, - next_credit_expiration_at: null, - has_validation_stytch: null, - has_validation_novel_card_with_hold: false, - default_model: null, - is_bot: false, - cohorts: {}, - completed_welcome_form: false, - linkedin_url: null, - github_url: null, - discord_server_membership_verified_at: null, - openrouter_upstream_safety_identifier: null, - vercel_downstream_safety_identifier: null, - customer_source: null, - signup_ip: null, - account_deletion_requested_at: null, - normalized_email: null, - email_domain: null, - ...overrides, - }; -} - -function createMockCloudAgentClient( - prepareSession: jest.Mock = jest.fn() -): ReturnType { - return { prepareSession } as unknown as ReturnType; -} - -function setUserAuth(overrides: Partial = {}) { - const user = createMockUser(overrides); - mockedGetUserFromAuth.mockResolvedValue({ - user, - authFailedResponse: null, - }); - return user; -} - -describe('POST /api/cloud-agent/sessions/prepare', () => { - beforeEach(() => { - jest.resetAllMocks(); - mockedValidateGitHubRepoAccessForUser.mockResolvedValue(true); - mockedValidateGitHubRepoAccessForOrganization.mockResolvedValue(true); - mockedSignStreamTicket.mockReturnValue({ ticket: 'test-ticket', expiresAt: 1234567890 }); - }); - - describe('authentication', () => { - test('returns authFailedResponse when auth fails', async () => { - const authFailedResponse = NextResponse.json(failureResult('Unauthorized'), { status: 401 }); - - mockedGetUserFromAuth.mockResolvedValue({ - user: null, - authFailedResponse, - }); - - const response = await POST(makeRequest(validInput)); - - expect(response).toBe(authFailedResponse); - }); - }); - - describe('request validation', () => { - test('returns 400 for invalid JSON', async () => { - setUserAuth(); - - const response = await POST(makeInvalidJsonRequest()); - - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.error).toBe('Invalid JSON in request body'); - }); - - test('returns 400 when prompt is missing', async () => { - setUserAuth(); - - const response = await POST( - makeRequest({ - mode: 'code', - model: 'anthropic/claude-3-5-sonnet', - githubRepo: 'owner/repo', - }) - ); - - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.error).toBe('Invalid request'); - expect(body.details).toContainEqual(expect.objectContaining({ path: 'prompt' })); - }); - - test('returns 400 when mode is not a valid slug', async () => { - setUserAuth(); - - const response = await POST( - makeRequest({ - ...validInput, - // Uppercase + space — not a valid slug shape. - mode: 'Invalid Mode!', - }) - ); - - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.error).toBe('Invalid request'); - expect(body.details).toContainEqual(expect.objectContaining({ path: 'mode' })); - }); - - test('returns 400 when githubRepo format is invalid', async () => { - setUserAuth(); - - const response = await POST( - makeRequest({ - ...validInput, - githubRepo: 'invalid-repo-format', - }) - ); - - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.error).toBe('Invalid request'); - expect(body.details).toContainEqual( - expect.objectContaining({ - path: 'githubRepo', - message: expect.stringContaining('owner/repo'), - }) - ); - }); - - test('returns 400 when organizationId is not a valid UUID', async () => { - setUserAuth(); - - const response = await POST( - makeRequest({ - ...validInput, - organizationId: 'not-a-uuid', - }) - ); - - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.error).toBe('Invalid request'); - expect(body.details).toContainEqual(expect.objectContaining({ path: 'organizationId' })); - }); - - test('returns 400 when envVars exceeds limit', async () => { - setUserAuth(); - - const tooManyEnvVars: Record = {}; - for (let i = 0; i < 51; i++) { - tooManyEnvVars[`VAR_${i}`] = 'value'; - } - - const response = await POST( - makeRequest({ - ...validInput, - envVars: tooManyEnvVars, - }) - ); - - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.error).toBe('Invalid request'); - expect(body.details).toContainEqual( - expect.objectContaining({ - message: expect.stringContaining('50'), - }) - ); - }); - }); - - describe('repository validation', () => { - test('returns 404 when user does not have access to the repository', async () => { - setUserAuth(); - mockedGetGitHubInstallationIdForUser.mockResolvedValue('12345'); - mockedValidateGitHubRepoAccessForUser.mockResolvedValue(false); - - const response = await POST( - makeRequest({ - ...validInput, - githubRepo: 'nonexistent/repo', - }) - ); - - expect(response.status).toBe(404); - const body = await response.json(); - expect(body.error).toBe('Repository not found or not accessible'); - expect(body.details).toContainEqual( - expect.objectContaining({ - path: 'githubRepo', - message: expect.stringContaining('nonexistent/repo'), - }) - ); - }); - - test('returns 404 when organization does not have access to the repository', async () => { - setUserAuth(); - const orgId = '123e4567-e89b-12d3-a456-426614174001'; - mockedEnsureOrganizationAccess.mockResolvedValue('member'); - mockedGetGitHubInstallationIdForOrganization.mockResolvedValue('67890'); - mockedValidateGitHubRepoAccessForOrganization.mockResolvedValue(false); - - const response = await POST( - makeRequest({ - ...validInput, - organizationId: orgId, - githubRepo: 'nonexistent/repo', - }) - ); - - expect(response.status).toBe(404); - const body = await response.json(); - expect(body.error).toBe('Repository not found or not accessible'); - }); - - test('returns 500 when repo validation fails with error', async () => { - setUserAuth(); - mockedGetGitHubInstallationIdForUser.mockResolvedValue('12345'); - const { TRPCError } = await import('@trpc/server'); - mockedValidateGitHubRepoAccessForUser.mockRejectedValue( - new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Failed to validate repository access', - }) - ); - - const response = await POST(makeRequest(validInput)); - - expect(response.status).toBe(500); - const body = await response.json(); - expect(body.error).toBe('Failed to validate repository access'); - }); - - test('validates repo using user context when no organizationId', async () => { - const user = setUserAuth(); - mockedGetGitHubInstallationIdForUser.mockResolvedValue('12345'); - mockedValidateGitHubRepoAccessForUser.mockResolvedValue(true); - mockedCreateCloudAgentClient.mockReturnValue( - createMockCloudAgentClient( - jest.fn().mockResolvedValue({ - kiloSessionId: '123', - cloudAgentSessionId: 'cloud-123', - }) - ) - ); - - await POST(makeRequest(validInput)); - - expect(mockedValidateGitHubRepoAccessForUser).toHaveBeenCalledWith(user.id, 'owner/repo'); - expect(mockedValidateGitHubRepoAccessForOrganization).not.toHaveBeenCalled(); - }); - - test('validates repo using organization context when organizationId provided', async () => { - setUserAuth(); - const orgId = '123e4567-e89b-12d3-a456-426614174001'; - mockedEnsureOrganizationAccess.mockResolvedValue('member'); - mockedGetGitHubInstallationIdForOrganization.mockResolvedValue('67890'); - mockedValidateGitHubRepoAccessForOrganization.mockResolvedValue(true); - mockedCreateCloudAgentClient.mockReturnValue( - createMockCloudAgentClient( - jest.fn().mockResolvedValue({ - kiloSessionId: '123', - cloudAgentSessionId: 'cloud-123', - }) - ) - ); - - await POST( - makeRequest({ - ...validInput, - organizationId: orgId, - }) - ); - - expect(mockedValidateGitHubRepoAccessForOrganization).toHaveBeenCalledWith( - orgId, - 'owner/repo' - ); - expect(mockedValidateGitHubRepoAccessForUser).not.toHaveBeenCalled(); - }); - }); - - describe('personal context (no organizationId)', () => { - test('successfully prepares session with personal GitHub installation', async () => { - const user = setUserAuth(); - const mockInstallationId = '12345'; - const mockResult = { - kiloSessionId: '123e4567-e89b-12d3-a456-426614174000', - cloudAgentSessionId: 'cloud-session-123', - }; - - mockedGetGitHubInstallationIdForUser.mockResolvedValue(mockInstallationId); - mockedCreateCloudAgentClient.mockReturnValue( - createMockCloudAgentClient(jest.fn().mockResolvedValue(mockResult)) - ); - - const response = await POST(makeRequest(validInput)); - - expect(response.status).toBe(200); - const body = await response.json(); - expect(body).toEqual({ - ...mockResult, - ticket: 'test-ticket', - expiresAt: 1234567890, - }); - - expect(mockedGetGitHubInstallationIdForUser).toHaveBeenCalledWith(user.id); - expect(mockedGetGitHubInstallationIdForOrganization).not.toHaveBeenCalled(); - }); - - test('works without GitHub installation (public repos)', async () => { - setUserAuth(); - const mockResult = { - kiloSessionId: '123e4567-e89b-12d3-a456-426614174000', - cloudAgentSessionId: 'cloud-session-123', - }; - - mockedGetGitHubInstallationIdForUser.mockResolvedValue(undefined); - mockedCreateCloudAgentClient.mockReturnValue( - createMockCloudAgentClient(jest.fn().mockResolvedValue(mockResult)) - ); - - const response = await POST(makeRequest(validInput)); - - expect(response.status).toBe(200); - }); - }); - - describe('organization context', () => { - test('successfully prepares session with organization GitHub installation', async () => { - const user = setUserAuth(); - const orgId = '123e4567-e89b-12d3-a456-426614174001'; - const mockInstallationId = '67890'; - const mockResult = { - kiloSessionId: '123e4567-e89b-12d3-a456-426614174000', - cloudAgentSessionId: 'cloud-session-123', - }; - - mockedEnsureOrganizationAccess.mockResolvedValue('member'); - mockedGetGitHubInstallationIdForOrganization.mockResolvedValue(mockInstallationId); - mockedCreateCloudAgentClient.mockReturnValue( - createMockCloudAgentClient(jest.fn().mockResolvedValue(mockResult)) - ); - - const response = await POST( - makeRequest({ - ...validInput, - organizationId: orgId, - }) - ); - - expect(response.status).toBe(200); - const body = await response.json(); - expect(body).toEqual({ - ...mockResult, - ticket: 'test-ticket', - expiresAt: 1234567890, - }); - - expect(mockedEnsureOrganizationAccess).toHaveBeenCalledWith({ user }, orgId); - expect(mockedGetGitHubInstallationIdForOrganization).toHaveBeenCalledWith(orgId); - expect(mockedGetGitHubInstallationIdForUser).not.toHaveBeenCalled(); - }); - - test('returns 403 when user is not a member of the organization', async () => { - setUserAuth(); - const orgId = '123e4567-e89b-12d3-a456-426614174001'; - - const { TRPCError } = await import('@trpc/server'); - mockedEnsureOrganizationAccess.mockRejectedValue( - new TRPCError({ - code: 'UNAUTHORIZED', - message: 'You do not have access to this organization', - }) - ); - - const response = await POST( - makeRequest({ - ...validInput, - organizationId: orgId, - }) - ); - - expect(response.status).toBe(403); - const body = await response.json(); - expect(body.error).toBe('You do not have access to this organization'); - }); - }); - - describe('cloud-agent errors', () => { - test('returns 402 for insufficient credits error', async () => { - setUserAuth(); - mockedGetGitHubInstallationIdForUser.mockResolvedValue('12345'); - mockedCreateCloudAgentClient.mockReturnValue( - createMockCloudAgentClient( - jest.fn().mockRejectedValue(new Error('Insufficient credits: $1 minimum required')) - ) - ); - - const response = await POST(makeRequest(validInput)); - - expect(response.status).toBe(402); - const body = await response.json(); - expect(body.error).toContain('Insufficient credits'); - }); - - test('returns 500 with generic message for other cloud-agent errors', async () => { - setUserAuth(); - mockedGetGitHubInstallationIdForUser.mockResolvedValue('12345'); - mockedCreateCloudAgentClient.mockReturnValue( - createMockCloudAgentClient( - jest.fn().mockRejectedValue(new Error('Internal cloud-agent error')) - ) - ); - - const response = await POST(makeRequest(validInput)); - - expect(response.status).toBe(500); - const body = await response.json(); - // Generic message to avoid leaking implementation details - expect(body.error).toBe('Failed to prepare session'); - }); - - test('returns 500 with generic message for network errors like "fetch failed"', async () => { - setUserAuth(); - mockedGetGitHubInstallationIdForUser.mockResolvedValue('12345'); - mockedCreateCloudAgentClient.mockReturnValue( - createMockCloudAgentClient(jest.fn().mockRejectedValue(new Error('fetch failed'))) - ); - - const response = await POST(makeRequest(validInput)); - - expect(response.status).toBe(500); - const body = await response.json(); - // Should NOT leak "fetch failed" which exposes the proxy implementation - expect(body.error).toBe('Failed to prepare session'); - }); - }); - - describe('optional fields', () => { - test('passes through envVars, setupCommands, mcpServers, and autoCommit', async () => { - setUserAuth(); - const mockPrepareSession = jest.fn().mockResolvedValue({ - kiloSessionId: '123e4567-e89b-12d3-a456-426614174000', - cloudAgentSessionId: 'cloud-session-123', - }); - - mockedGetGitHubInstallationIdForUser.mockResolvedValue('12345'); - mockedCreateCloudAgentClient.mockReturnValue(createMockCloudAgentClient(mockPrepareSession)); - - const inputWithOptionals = { - ...validInput, - envVars: { MY_VAR: 'my-value' }, - setupCommands: ['npm install'], - mcpServers: { - myServer: { - command: 'node', - args: ['server.js'], - }, - }, - autoCommit: true, - }; - - await POST(makeRequest(inputWithOptionals)); - - expect(mockPrepareSession).toHaveBeenCalledWith( - expect.objectContaining({ - envVars: { MY_VAR: 'my-value' }, - setupCommands: ['npm install'], - mcpServers: { - myServer: { - command: 'node', - args: ['server.js'], - }, - }, - autoCommit: true, - }) - ); - }); - }); - - describe('profile forwarding', () => { - const profileId = 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa'; - - test('forwards profileId and inline overrides to cloud-agent-next unchanged', async () => { - setUserAuth(); - mockedGetGitHubInstallationIdForUser.mockResolvedValue('12345'); - const mockPrepareSession = jest.fn().mockResolvedValue({ - kiloSessionId: '123e4567-e89b-12d3-a456-426614174000', - cloudAgentSessionId: 'cloud-session-123', - }); - mockedCreateCloudAgentClient.mockReturnValue(createMockCloudAgentClient(mockPrepareSession)); - - await POST( - makeRequest({ - ...validInput, - profileId, - envVars: { INLINE: 'value' }, - setupCommands: ['echo inline'], - }) - ); - - expect(mockPrepareSession).toHaveBeenCalledWith( - expect.objectContaining({ - profileId, - envVars: { INLINE: 'value' }, - setupCommands: ['echo inline'], - }) - ); - }); - - test('returns 404 when cloud-agent reports the profile is not found', async () => { - setUserAuth(); - mockedGetGitHubInstallationIdForUser.mockResolvedValue('12345'); - mockedCreateCloudAgentClient.mockReturnValue( - createMockCloudAgentClient( - jest.fn().mockRejectedValue(new Error(`Profile '${profileId}' not found`)) - ) - ); - - const response = await POST( - makeRequest({ - ...validInput, - profileId, - }) - ); - - expect(response.status).toBe(404); - const body = await response.json(); - expect(body.error).toBe('Profile not found'); - expect(body.details).toContainEqual( - expect.objectContaining({ - path: 'profileId', - }) - ); - }); - }); -}); diff --git a/apps/web/src/app/api/cloud-agent/sessions/prepare/route.ts b/apps/web/src/app/api/cloud-agent/sessions/prepare/route.ts deleted file mode 100644 index 2c49636df2..0000000000 --- a/apps/web/src/app/api/cloud-agent/sessions/prepare/route.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { NextResponse } from 'next/server'; -import { getUserFromAuth } from '@/lib/user/server'; -import { ensureOrganizationAccess } from '@/routers/organizations/utils'; -import { - validateGitHubRepoAccessForUser, - validateGitHubRepoAccessForOrganization, - getGitHubInstallationIdForOrganization, - getGitHubInstallationIdForUser, -} from '@/lib/cloud-agent/github-integration-helpers'; -import { - getGitLabTokenForUser, - getGitLabTokenForOrganization, - validateGitLabRepoAccessForUser, - validateGitLabRepoAccessForOrganization, - buildGitLabCloneUrl, - getGitLabInstanceUrlForUser, - getGitLabInstanceUrlForOrganization, -} from '@/lib/cloud-agent/gitlab-integration-helpers'; -import { createCloudAgentClient } from '@/lib/cloud-agent/cloud-agent-client'; -import { generateApiToken } from '@/lib/tokens'; -import { publicPrepareSessionSchema } from './schema'; -import { captureException } from '@sentry/nextjs'; -import { TRPCError } from '@trpc/server'; -import { signStreamTicket } from '@/lib/cloud-agent/stream-ticket'; -import { PLATFORM } from '@/lib/integrations/core/constants'; - -function handleTRPCError(error: unknown): NextResponse { - if (error instanceof TRPCError) { - const statusCode = error.code === 'UNAUTHORIZED' ? 403 : 500; - return NextResponse.json({ error: error.message }, { status: statusCode }); - } - - captureException(error, { - tags: { source: 'cloud-agent-prepare-session' }, - }); - return NextResponse.json({ error: 'An unexpected error occurred' }, { status: 500 }); -} - -export async function POST(request: Request) { - try { - const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false }); - - if (authFailedResponse) { - return authFailedResponse; - } - - let body: unknown; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); - } - - const validation = publicPrepareSessionSchema.safeParse(body); - if (!validation.success) { - return NextResponse.json( - { - error: 'Invalid request', - details: validation.error.issues.map(issue => ({ - path: issue.path.join('.'), - message: issue.message, - })), - }, - { status: 400 } - ); - } - - const input = validation.data; - - let githubInstallationId: string | undefined; - let kilocodeOrganizationId: string | undefined; - - // Validate organization access if provided - if (input.organizationId) { - // Verify org membership before proceeding - await ensureOrganizationAccess({ user }, input.organizationId); - githubInstallationId = await getGitHubInstallationIdForOrganization(input.organizationId); - kilocodeOrganizationId = input.organizationId; - } else { - githubInstallationId = await getGitHubInstallationIdForUser(user.id); - } - - // Determine which platform we're using and get the appropriate token/validation - let gitUrl: string | undefined; - let gitToken: string | undefined; - let repoIdentifier: string; // For error messages - - if (input.githubRepo) { - // GitHub flow - we pass githubInstallationId to cloud-agent which generates tokens - repoIdentifier = input.githubRepo; - - const hasRepoAccess = input.organizationId - ? await validateGitHubRepoAccessForOrganization(input.organizationId, input.githubRepo) - : await validateGitHubRepoAccessForUser(user.id, input.githubRepo); - - if (!hasRepoAccess) { - return NextResponse.json( - { - error: 'Repository not found or not accessible', - details: [ - { - path: 'githubRepo', - message: `You do not have access to the repository '${input.githubRepo}'. Please ensure the GitHub integration has access to this repository.`, - }, - ], - }, - { status: 404 } - ); - } - } else if (input.gitlabProject) { - // GitLab flow - repoIdentifier = input.gitlabProject; - - gitToken = input.organizationId - ? await getGitLabTokenForOrganization(input.organizationId) - : await getGitLabTokenForUser(user.id); - - if (!gitToken) { - return NextResponse.json( - { - error: 'GitLab integration not configured', - details: [ - { - path: 'gitlabProject', - message: 'No GitLab integration found. Please connect your GitLab account first.', - }, - ], - }, - { status: 400 } - ); - } - - const hasRepoAccess = input.organizationId - ? await validateGitLabRepoAccessForOrganization(input.organizationId, input.gitlabProject) - : await validateGitLabRepoAccessForUser(user.id, input.gitlabProject); - - if (!hasRepoAccess) { - return NextResponse.json( - { - error: 'Project not found or not accessible', - details: [ - { - path: 'gitlabProject', - message: `You do not have access to the project '${input.gitlabProject}'. Please ensure the GitLab integration has access to this project.`, - }, - ], - }, - { status: 404 } - ); - } - - // Build the GitLab clone URL - const instanceUrl = input.organizationId - ? await getGitLabInstanceUrlForOrganization(input.organizationId) - : await getGitLabInstanceUrlForUser(user.id); - - gitUrl = buildGitLabCloneUrl(input.gitlabProject, instanceUrl); - } else { - // This shouldn't happen due to schema validation, but handle it gracefully - return NextResponse.json( - { - error: 'Invalid request', - details: [ - { - path: 'githubRepo', - message: 'Must provide either githubRepo or gitlabProject', - }, - ], - }, - { status: 400 } - ); - } - - try { - const authToken = generateApiToken(user); - const client = createCloudAgentClient(authToken); - - const result = await client.prepareSession({ - prompt: input.prompt, - mode: input.mode, - model: input.model, - // GitHub-specific params (only set for GitHub repos) - githubRepo: input.githubRepo, - githubInstallationId, - // GitLab-specific params (only set for GitLab projects) - gitUrl, - gitToken, - // Platform detection: explicit instead of URL-based - platform: input.gitlabProject ? PLATFORM.GITLAB : PLATFORM.GITHUB, - // Common params - kilocodeOrganizationId, - // Profile resolution happens in cloud-agent-next — forward profileId - // and any inline overrides. cloud agent merges profile-derived values with - // the inline fields using the same precedence the web used to apply. - profileId: input.profileId, - envVars: input.envVars, - setupCommands: input.setupCommands, - mcpServers: input.mcpServers, - autoCommit: input.autoCommit, - upstreamBranch: input.upstreamBranch, - callbackTarget: input.callbackTarget, - }); - - const ticketResult = signStreamTicket({ - userId: user.id, - kiloSessionId: result.kiloSessionId, - cloudAgentSessionId: result.cloudAgentSessionId, - organizationId: input.organizationId, - }); - - return NextResponse.json({ - ...result, - ...ticketResult, - }); - } catch (error) { - // Profile resolution failures are surfaced by cloud agent as 404s. Forward them - // through without mapping to a generic "Failed to prepare session" - // response so the caller sees the same shape we used before this - // refactor. - if (error instanceof Error && /Profile '.+' not found/i.test(error.message)) { - return NextResponse.json( - { - error: 'Profile not found', - details: [ - { - path: 'profileId', - message: error.message, - }, - ], - }, - { status: 404 } - ); - } - - captureException(error, { - tags: { source: 'cloud-agent-prepare-session', step: 'forward-to-cloud-agent' }, - extra: { - userId: user.id, - organizationId: input.organizationId, - repo: repoIdentifier, - }, - }); - - // Allowlist approach: only pass through known safe error messages to avoid leaking - // implementation details (e.g., "fetch failed", internal worker errors) - if (error instanceof Error && error.message.includes('Insufficient credits')) { - return NextResponse.json( - { error: 'Insufficient credits. Please add funds to your account.' }, - { status: 402 } - ); - } - - // All other errors get a generic message - return NextResponse.json({ error: 'Failed to prepare session' }, { status: 500 }); - } - } catch (error) { - return handleTRPCError(error); - } -} diff --git a/apps/web/src/app/api/cloud-agent/sessions/prepare/schema.ts b/apps/web/src/app/api/cloud-agent/sessions/prepare/schema.ts deleted file mode 100644 index f2daff5369..0000000000 --- a/apps/web/src/app/api/cloud-agent/sessions/prepare/schema.ts +++ /dev/null @@ -1,108 +0,0 @@ -import * as z from 'zod'; -import { agentModeSchema, mcpServerConfigSchema } from '@/routers/cloud-agent-schemas'; - -/** - * Schema for callback target configuration. - * Allows users to specify a webhook URL to receive execution completion events. - */ -export const callbackTargetSchema = z.object({ - url: z.string().url('Callback URL must be a valid URL'), - headers: z.record(z.string(), z.string()).optional(), -}); - -/** - * Public API schema for preparing a cloud agent session. - * - * This is a subset of the internal prepareSession schema - users cannot - * supply their own GitHub/GitLab tokens (those are enriched by the backend). - * - * Must provide either `githubRepo` OR `gitlabProject`, but not both. - */ -export const publicPrepareSessionSchema = z - .object({ - // Required fields - prompt: z - .string() - .min(1, 'Prompt is required') - .max(100_000, 'Prompt must be at most 100,000 characters'), - mode: agentModeSchema, - model: z.string().min(1, 'Model is required'), - - // Repository source (mutually exclusive - must provide exactly one) - githubRepo: z - .string() - .regex( - /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/, - 'Invalid repository format. Expected: owner/repo' - ) - .optional(), - gitlabProject: z - .string() - .regex( - /^[a-zA-Z0-9_.-]+(?:\/[a-zA-Z0-9_.-]+)+$/, - 'Invalid project path format. Expected: group/project or group/subgroup/project' - ) - .optional() - .describe('GitLab project path (e.g., group/project or group/subgroup/project)'), - - // Optional organization context - organizationId: z.string().uuid('Invalid organization ID format').optional(), - - // Optional environment profile - // If provided, envVars/setupCommands/MCP servers/skills/secrets from the - // profile are merged with inline values (inline takes precedence). - // When omitted no profile is applied — pass the desired profile's UUID - // explicitly to opt into profile-based configuration. - profileId: z.string().uuid('Invalid profile ID format').optional(), - - // Optional configuration - envVars: z - .record( - z.string().max(256, 'Environment variable key must be at most 256 characters'), - z.string().max(256, 'Environment variable value must be at most 256 characters') - ) - .refine(obj => Object.keys(obj).length <= 50, { - message: 'Maximum 50 environment variables allowed', - }) - .optional(), - setupCommands: z - .array(z.string().max(500, 'Setup command must be at most 500 characters')) - .max(20, 'Maximum 20 setup commands allowed') - .optional(), - mcpServers: z - .record( - z.string().max(100, 'MCP server name must be at most 100 characters'), - mcpServerConfigSchema - ) - .refine(obj => Object.keys(obj).length <= 10, { - message: 'Maximum 10 MCP servers allowed', - }) - .optional(), - autoCommit: z.boolean().optional(), - upstreamBranch: z - .string() - .max(256, 'Upstream branch must be at most 256 characters') - .optional(), - callbackTarget: callbackTargetSchema.optional(), - }) - .refine( - data => (data.githubRepo || data.gitlabProject) && !(data.githubRepo && data.gitlabProject), - { - message: 'Must provide either githubRepo or gitlabProject, but not both', - path: ['githubRepo'], - } - ); - -export type PublicPrepareSessionInput = z.infer; - -/** - * Response schema for the public prepareSession endpoint. - */ -export const publicPrepareSessionResponseSchema = z.object({ - kiloSessionId: z.string().uuid(), - cloudAgentSessionId: z.string(), - ticket: z.string(), - expiresAt: z.number(), -}); - -export type PublicPrepareSessionResponse = z.infer; diff --git a/apps/web/src/components/cloud-agent-next/CloudAgentProvider.tsx b/apps/web/src/components/cloud-agent-next/CloudAgentProvider.tsx index ebb99502f1..d7d910a77a 100644 --- a/apps/web/src/components/cloud-agent-next/CloudAgentProvider.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudAgentProvider.tsx @@ -227,12 +227,20 @@ export function CloudAgentProvider({ children, organizationId }: CloudAgentProvi }, respondToPermission: async payload => { - const trpc = organizationId - ? trpcClient.organizations.cloudAgentNext - : trpcClient.cloudAgentNext; - await trpc.answerPermission.mutate( + if (organizationId) { + await trpcClient.organizations.cloudAgentNext.answerPermission.mutate( + { + organizationId, + sessionId: payload.sessionId, + permissionId: payload.requestId, + response: payload.response, + }, + { context: { skipBatch: true } } + ); + return; + } + await trpcClient.cloudAgentNext.answerPermission.mutate( { - ...(organizationId ? { organizationId } : {}), sessionId: payload.sessionId, permissionId: payload.requestId, response: payload.response, diff --git a/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx b/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx index 619ec2876d..1e756f9714 100644 --- a/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx +++ b/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx @@ -140,11 +140,11 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New // Eligibility // --------------------------------------------------------------------------- const personalEligibilityQuery = useQuery({ - ...trpc.cloudAgent.checkEligibility.queryOptions(), + ...trpc.cloudAgentNext.checkEligibility.queryOptions(), enabled: !organizationId, }); const orgEligibilityQuery = useQuery({ - ...trpc.organizations.cloudAgent.checkEligibility.queryOptions({ + ...trpc.organizations.cloudAgentNext.checkEligibility.queryOptions({ organizationId: organizationId || '', }), enabled: !!organizationId, diff --git a/apps/web/src/components/cloud-agent/CloudSessionsPage.tsx b/apps/web/src/components/cloud-agent/CloudSessionsPage.tsx deleted file mode 100644 index 5396393257..0000000000 --- a/apps/web/src/components/cloud-agent/CloudSessionsPage.tsx +++ /dev/null @@ -1,967 +0,0 @@ -'use client'; - -import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { useRefreshRepositories } from '@/hooks/useRefreshRepositories'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import { Button, LinkButton } from '@/components/Button'; -import { Button as UIButton } from '@/components/ui/button'; -import { Label } from '@/components/ui/label'; -import { Textarea } from '@/components/ui/textarea'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { useTRPC, useRawTRPCClient } from '@/lib/trpc/utils'; -import { useRouter } from 'next/navigation'; -import { AlertCircle, ExternalLink, Loader2, RefreshCw, Rocket, Sparkles } from 'lucide-react'; -import { toast } from 'sonner'; -import { formatDistanceToNow } from 'date-fns'; -import { PageLayout } from '@/components/PageLayout'; -import { SetPageTitle } from '@/components/SetPageTitle'; -import { DemoSessionCTA } from './DemoSessionCTA'; -import { DemoSessionModal } from './DemoSessionModal'; -import { - DEMO_CONFIGS, - DEMO_SOURCE_REPO, - DEMO_SOURCE_REPO_NAME, - templatePrompt, - type DemoConfig, -} from './demo-config'; -import type { AgentMode } from './types'; -import { useProfiles, useCombinedProfiles } from '@/hooks/useCloudAgentProfiles'; -import { useAtom, useSetAtom } from 'jotai'; -import { selectedProfileIdAtom, resetSessionFormAtom } from './store/session-form-atoms'; -import { useOrganizationDefaults } from '@/app/api/organizations/hooks'; -import { useModelSelectorList } from '@/app/api/openrouter/hooks'; -import { - RepositoryCombobox, - type RepositoryOption, - type RepositoryPlatform, -} from '@/components/shared/RepositoryCombobox'; -import { ModelCombobox, type ModelOption } from '@/components/shared/ModelCombobox'; -import { ProfilePickerPopover } from '@/components/cloud-agent/ProfilePickerPopover'; -import { cn } from '@/lib/utils'; -import { CLOUD_AGENT_PROMPT_MAX_LENGTH } from '@/lib/cloud-agent/constants'; -import { MODES } from './ResumeConfigModal'; - -type CloudSessionsPageProps = { - organizationId?: string; -}; - -type Repository = { - id: number; - name: string; - fullName: string; - private: boolean; -}; - -export function CloudSessionsPage({ organizationId }: CloudSessionsPageProps) { - const router = useRouter(); - const trpc = useTRPC(); - const trpcClient = useRawTRPCClient(); - const queryClient = useQueryClient(); - - const hasInsufficientBalance = false; - - // Fetch organization configuration and models - const { data: modelsData } = useModelSelectorList(organizationId); - const { data: defaultsData } = useOrganizationDefaults(organizationId); - - const allModels = modelsData?.data || []; - - // Format models for the combobox (ModelOption format: id, name) - const modelOptions = useMemo( - () => - allModels.map(model => ({ - id: model.id, - name: model.name, - isFree: model.isFree, - variants: model.opencode?.variants ? Object.keys(model.opencode.variants) : undefined, - })), - [allModels] - ); - - // Form state (non-profile related) - const [prompt, setPrompt] = useState(''); - const [selectedRepo, setSelectedRepo] = useState(''); - const [selectedPlatform, setSelectedPlatform] = useState('github'); - const [mode, setMode] = useState('code'); - const [model, setModel] = useState(''); - const [isModelUserSelected, setIsModelUserSelected] = useState(false); - const [isPreparing, setIsPreparing] = useState(false); - - // Demo session state - const [isDemoMode, setIsDemoMode] = useState(false); - const [showDemoModal, setShowDemoModal] = useState(false); - const [selectedDemo, setSelectedDemo] = useState(null); - const [isDemoActionLoading, setIsDemoActionLoading] = useState(false); - const [highlightedDemoId, setHighlightedDemoId] = useState(null); - - // Profile override selection (base profile resolved server-side from repo binding / default) - const [selectedProfileId, setSelectedProfileId] = useAtom(selectedProfileIdAtom); - const resetSessionForm = useSetAtom(resetSessionFormAtom); - - // Clear any lingering manual overrides whenever the page loads - useEffect(() => { - resetSessionForm(); - }, [resetSessionForm]); - - // Parse URL hash to highlight demo if present - useEffect(() => { - if (typeof window !== 'undefined') { - const hash = window.location.hash; - if (hash.startsWith('#demo-')) { - const demoId = hash.slice(6); // Remove '#demo-' prefix - setHighlightedDemoId(demoId); - } - } - }, []); - - // Set or reset model when defaults change (organization switch or initial load) - useEffect(() => { - // If no models are available, clear the selection to prevent invalid submissions - if (modelOptions.length === 0) { - if (model) { - setModel(''); - setIsModelUserSelected(false); - } - return; - } - - // If current model is not in the available models list, or if we don't have a model yet, - // reset to an allowed model - const isCurrentModelAvailable = modelOptions.some(m => m.id === model); - if (!isCurrentModelAvailable || !model || !isModelUserSelected) { - // Prefer the default model if it is available under org policy, otherwise use the first available. - const defaultModel = defaultsData?.defaultModel; - const isDefaultAllowed = defaultModel && modelOptions.some(m => m.id === defaultModel); - const newModel = isDefaultAllowed ? defaultModel : modelOptions[0]?.id; - - if (newModel && newModel !== model) { - setModel(newModel); - setIsModelUserSelected(false); // Auto-selected, not user-selected - } - } - }, [defaultsData?.defaultModel, modelOptions, model, isModelUserSelected]); - - // Fetch profiles list to find default profile - // In org context, use combined profiles to get both org and personal profiles - const { data: combinedProfilesData } = useCombinedProfiles({ - organizationId: organizationId ?? '', - enabled: !!organizationId, - }); - const { data: personalProfiles } = useProfiles({ - organizationId: undefined, - enabled: !organizationId, - }); - - // Get all profiles and effective default based on context - const allProfiles = organizationId - ? [ - ...(combinedProfilesData?.orgProfiles ?? []), - ...(combinedProfilesData?.personalProfiles ?? []), - ] - : (personalProfiles ?? []); - - // If override profile was deleted, clear the selection - useEffect(() => { - if (!selectedProfileId || allProfiles.length === 0) return; - const stillPresent = allProfiles.some(p => p.id === selectedProfileId); - if (!stillPresent) setSelectedProfileId(null); - }, [allProfiles, selectedProfileId, setSelectedProfileId]); - - // Fetch GitHub repositories - const { - data: githubRepoData, - isLoading: isLoadingGitHubRepos, - error: githubRepoError, - } = useQuery( - organizationId - ? trpc.organizations.cloudAgent.listGitHubRepositories.queryOptions({ - organizationId, - forceRefresh: false, - }) - : trpc.cloudAgent.listGitHubRepositories.queryOptions({ - forceRefresh: false, - }) - ); - - // Fetch GitLab repositories - const { - data: gitlabRepoData, - isLoading: isLoadingGitLabRepos, - error: gitlabRepoError, - } = useQuery( - organizationId - ? trpc.organizations.cloudAgent.listGitLabRepositories.queryOptions({ - organizationId, - forceRefresh: false, - }) - : trpc.cloudAgent.listGitLabRepositories.queryOptions({ - forceRefresh: false, - }) - ); - - // Combined loading state - only show loading if both are loading - const isLoadingRepos = isLoadingGitHubRepos && isLoadingGitLabRepos; - - // Refresh repositories hook (refreshes both GitHub and GitLab) - const { refresh: refreshGitHubRepositories, isRefreshing: isRefreshingGitHubRepos } = - useRefreshRepositories({ - silent: true, - getRefreshQueryOptions: useCallback( - () => - organizationId - ? trpc.organizations.cloudAgent.listGitHubRepositories.queryOptions({ - organizationId, - forceRefresh: true, - }) - : trpc.cloudAgent.listGitHubRepositories.queryOptions({ - forceRefresh: true, - }), - [organizationId, trpc] - ), - getCacheQueryKey: useCallback( - () => - organizationId - ? trpc.organizations.cloudAgent.listGitHubRepositories.queryKey({ - organizationId, - forceRefresh: false, - }) - : trpc.cloudAgent.listGitHubRepositories.queryKey({ - forceRefresh: false, - }), - [organizationId, trpc] - ), - }); - - const { refresh: refreshGitLabRepositories, isRefreshing: isRefreshingGitLabRepos } = - useRefreshRepositories({ - silent: true, - getRefreshQueryOptions: useCallback( - () => - organizationId - ? trpc.organizations.cloudAgent.listGitLabRepositories.queryOptions({ - organizationId, - forceRefresh: true, - }) - : trpc.cloudAgent.listGitLabRepositories.queryOptions({ - forceRefresh: true, - }), - [organizationId, trpc] - ), - getCacheQueryKey: useCallback( - () => - organizationId - ? trpc.organizations.cloudAgent.listGitLabRepositories.queryKey({ - organizationId, - forceRefresh: false, - }) - : trpc.cloudAgent.listGitLabRepositories.queryKey({ - forceRefresh: false, - }), - [organizationId, trpc] - ), - }); - - // Combined refresh function — single toast for both platforms - const refreshRepositories = useCallback(async () => { - try { - await Promise.all([refreshGitHubRepositories(), refreshGitLabRepositories()]); - toast.success('Repositories refreshed'); - } catch (error) { - toast.error('Failed to refresh repositories', { - description: error instanceof Error ? error.message : 'Unknown error', - }); - } - }, [refreshGitHubRepositories, refreshGitLabRepositories]); - - const isRefreshingRepos = isRefreshingGitHubRepos || isRefreshingGitLabRepos; - - // Get repositories from both platforms - const githubRepositories = (githubRepoData?.repositories || []) as Repository[]; - const gitlabRepositories = (gitlabRepoData?.repositories || []) as Repository[]; - - // Combine repositories with platform tags - const unifiedRepositories = useMemo(() => { - const github = githubRepositories.map(repo => ({ - id: repo.id, - fullName: repo.fullName, - private: repo.private, - platform: 'github' as const, - })); - const gitlab = gitlabRepositories.map(repo => ({ - id: repo.id, - fullName: repo.fullName, - private: repo.private, - platform: 'gitlab' as const, - })); - return [...github, ...gitlab]; - }, [githubRepositories, gitlabRepositories]); - - // Determine if grouping is needed (both platforms have repositories) - const hasMultiplePlatforms = githubRepositories.length > 0 && gitlabRepositories.length > 0; - - // Handle repository selection - track platform based on selected repo - const handleRepoSelect = useCallback( - (repoFullName: string) => { - setSelectedRepo(repoFullName); - const repo = unifiedRepositories.find(r => r.fullName === repoFullName); - if (repo?.platform) { - setSelectedPlatform(repo.platform); - } - }, - [unifiedRepositories] - ); - - // Get the most recent sync time from either platform - const syncedAt = githubRepoData?.syncedAt || gitlabRepoData?.syncedAt; - - // Combine errors - show first error if any - const repoError = githubRepoError || gitlabRepoError; - - // Check if demo repo fork exists in the repositories list (GitHub only for demo) - const demoForkInRepos = useMemo(() => { - if (organizationId) return null; // Demo only for personal users - - // Look for a repo that ends with the demo repo name (e.g., username/KiloMan) - const forkRepo = githubRepositories.find(repo => - repo.fullName.endsWith(`/${DEMO_SOURCE_REPO_NAME}`) - ); - - return forkRepo - ? { exists: true, forkedRepo: forkRepo.fullName } - : { exists: false, forkedRepo: null }; - }, [githubRepositories, organizationId]); - - const handleStartSession = useCallback(async () => { - if (!prompt.trim() || !selectedRepo) { - return; - } - - setIsPreparing(true); - - try { - // Call prepareSession to create DB entry and cloud-agent DO. - // profileId is unambiguous across org/personal. - const baseInput = { - prompt: prompt.trim(), - mode, - model, - profileId: selectedProfileId ?? undefined, - autoCommit: true, - }; - - let result: { kiloSessionId: string; cloudAgentSessionId: string }; - - if (organizationId) { - // Organization context - use org-scoped endpoint - // Use the correct field based on selected platform - if (selectedPlatform === 'gitlab') { - result = await trpcClient.organizations.cloudAgent.prepareSession.mutate({ - ...baseInput, - gitlabProject: selectedRepo, - organizationId, - }); - } else { - result = await trpcClient.organizations.cloudAgent.prepareSession.mutate({ - ...baseInput, - githubRepo: selectedRepo, - organizationId, - }); - } - } else { - // Personal context - // Use the correct field based on selected platform - if (selectedPlatform === 'gitlab') { - result = await trpcClient.cloudAgent.prepareSession.mutate({ - ...baseInput, - gitlabProject: selectedRepo, - }); - } else { - result = await trpcClient.cloudAgent.prepareSession.mutate({ - ...baseInput, - githubRepo: selectedRepo, - }); - } - } - - // Invalidate the sessions list cache so the sidebar shows the new session. - // This legacy page goes through cloudAgent.prepareSession which writes to - // cli_sessions (v1), so the sidebar/list data it produces still comes from - // the unified router (which UNIONs v1 and v2). Invalidating cliSessionsV2.list - // would miss the newly-created v1 row. - void queryClient.invalidateQueries({ - queryKey: trpc.unifiedSessions.list.queryKey({ - limit: 3, - createdOnPlatform: ['cloud-agent', 'cloud-agent-web'], - orderBy: 'updated_at', - organizationId: organizationId ?? null, - }), - }); - - // Navigate to chat page with sessionId - const basePath = organizationId ? `/organizations/${organizationId}/cloud` : '/cloud'; - router.push(`${basePath}/chat?sessionId=${result.kiloSessionId}`); - } catch (error) { - console.error('Failed to prepare session:', error); - toast.error('Failed to create session. Please try again.'); - } finally { - setIsPreparing(false); - } - }, [ - model, - mode, - organizationId, - prompt, - queryClient, - router, - selectedPlatform, - selectedRepo, - selectedProfileId, - trpc.unifiedSessions.list, - trpcClient, - ]); - - // Handle demo card click - either show modal or populate form - const handleDemoClick = useCallback( - async (demo: DemoConfig) => { - if (organizationId) return; // Demo is only for personal users - - setSelectedDemo(demo); - - if (demoForkInRepos?.exists && demoForkInRepos.forkedRepo) { - const repoOwner = demoForkInRepos.forkedRepo.split('/')[0]; - const templatedPrompt = repoOwner ? templatePrompt(demo.prompt, repoOwner) : demo.prompt; - - setPrompt(templatedPrompt); - setSelectedRepo(demoForkInRepos.forkedRepo); - setModel(demo.model); - setIsModelUserSelected(true); - setMode(demo.mode); - setIsDemoMode(true); - - // Scroll to the form - window.scrollTo({ top: 0, behavior: 'smooth' }); - } else { - // Not forked - open GitHub fork page and show modal - window.open(`https://github.com/${DEMO_SOURCE_REPO}/fork`, '_blank'); - setShowDemoModal(true); - } - }, - [organizationId, demoForkInRepos] - ); - - // Handle "Done. Let's Go!" button in modal - const handleModalComplete = useCallback(async () => { - if (!selectedDemo) return; - - setIsDemoActionLoading(true); - - try { - const maxAttempts = 10; - const delayMs = 1000; - - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - - const pollForDemoFork = async () => { - let forkCheck: { - exists: boolean; - forkedRepo: string | null; - githubUsername: string | null; - } | null = null; - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - forkCheck = await trpcClient.cloudAgent.checkDemoRepositoryFork.query(); - - if (forkCheck.exists && forkCheck.forkedRepo) { - return forkCheck; - } - - if (attempt < maxAttempts - 1) { - await sleep(delayMs); - } - } - - return forkCheck; - }; - - const forkCheck = await pollForDemoFork(); - - if (!forkCheck?.exists || !forkCheck.forkedRepo) { - toast.error( - 'Fork not detected. Please make sure you forked the repository on GitHub and try again.' - ); - return; - } - - // Refresh repositories to include the fork in the dropdown - await refreshRepositories(); - - // Close modal - setShowDemoModal(false); - - // Template the prompt with GitHub username if available - const templatedPrompt = forkCheck.githubUsername - ? templatePrompt(selectedDemo.prompt, forkCheck.githubUsername) - : selectedDemo.prompt; - - // Populate the form with demo data - setPrompt(templatedPrompt); - setSelectedRepo(forkCheck.forkedRepo); - setModel(selectedDemo.model); - setIsModelUserSelected(true); - setMode(selectedDemo.mode); - setIsDemoMode(true); - - // Scroll to the form - window.scrollTo({ top: 0, behavior: 'smooth' }); - } catch (error) { - console.error('Failed to check for fork:', error); - toast.error('Failed to check for fork. Please try again.'); - } finally { - setIsDemoActionLoading(false); - } - }, [selectedDemo, refreshRepositories, trpcClient]); - - const isFormValid = - prompt.trim().length > 0 && - prompt.length <= CLOUD_AGENT_PROMPT_MAX_LENGTH && - selectedRepo.length > 0 && - model.length > 0 && - !isPreparing && - !hasInsufficientBalance; - - const titleContent = ( - - new - - ); - - const subtitleContent = ( - <> -

Start a new cloud agent session

- - Learn how to use it - - - - ); - - // Check if NEITHER platform has an integration installed - // Show the banner only if both platforms report integrationInstalled === false - const githubIntegrationMissing = - !isLoadingGitHubRepos && githubRepoData?.integrationInstalled === false; - const gitlabIntegrationMissing = - !isLoadingGitLabRepos && gitlabRepoData?.integrationInstalled === false; - const isIntegrationMissing = githubIntegrationMissing && gitlabIntegrationMissing; - - const content = ( - <> - {/* New Session Form */} - - - - - Start New Session - - - Configure and launch a cloud agent to work on your repository - - - - {/* Prompt */} - - - {/* Repository Selector */} - - - {/* Mode and Model Row */} - { - setModel(newModel); - setIsModelUserSelected(true); - }} - modelOptions={modelOptions} - isLoadingModels={!modelsData} - /> - - {/* Profile picker — sits below mode/model row */} -
- -
- - {/* Submit Button */} - -
-
- - {/* Demo CTAs - Only for personal users */} - {!organizationId && ( -
- {isLoadingRepos - ? // Show loading skeletons while loading repositories - DEMO_CONFIGS.map(demo => ( - - -
-
- -
-
-
-
-
-
-
- - - )) - : DEMO_CONFIGS.map(demo => ( - handleDemoClick(demo)} - isForked={demoForkInRepos?.exists ?? false} - isWaitingForFork={ - showDemoModal && selectedDemo?.id === demo.id && isDemoActionLoading - } - isHighlighted={highlightedDemoId === demo.id} - /> - ))} -
- )} - - {/* Demo Fork Instructions Modal */} - - - ); - - if (isIntegrationMissing) { - const integrationsPath = organizationId - ? `/organizations/${organizationId}/integrations` - : '/integrations'; - const integrationMessage = - githubRepoData?.errorMessage || - gitlabRepoData?.errorMessage || - 'Connect a GitHub or GitLab integration to select a repository for the cloud agent.'; - - const integrationContent = ( - - - - - Connect GitHub or GitLab to start a session - - {integrationMessage} - - -

- We need access to your repositories to launch cloud agent sessions. Install the GitHub - or GitLab integration to continue. -

-
- - Open integrations - - -
-
-
- ); - - // When in organization context, skip PageLayout (OrganizationTrialWrapper provides PageContainer) - if (organizationId) { - return ( - <> -
-
- {titleContent} - {subtitleContent} -
-
- {integrationContent} - - ); - } - - return ( - - {integrationContent} - - ); - } - - // When in organization context, skip PageLayout (OrganizationTrialWrapper provides PageContainer) - if (organizationId) { - return ( - <> -
-
- {titleContent} - {subtitleContent} -
-
- {content} - - ); - } - - return ( - - {content} - - ); -} - -type PromptFieldProps = { - value: string; - onChange: (value: string) => void; -}; - -const PromptField = memo(function PromptField({ value, onChange }: PromptFieldProps) { - const handleChange = useCallback( - (e: React.ChangeEvent) => { - onChange(e.target.value); - }, - [onChange] - ); - - const isOverLimit = value.length > CLOUD_AGENT_PROMPT_MAX_LENGTH; - const showCounter = value.length >= CLOUD_AGENT_PROMPT_MAX_LENGTH * 0.9; - - return ( -
- -