From 93ff7ee2107dad4d1d101bd43ba914323a26357f Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Thu, 11 Jun 2026 22:27:16 +0200 Subject: [PATCH] feat(cloud-agent-next): add guarded PR staging deployments --- .../deploy-cloud-agent-next-staging.yml | 450 ++++++++++++++++++ .../src/callbacks/queue-config.test.ts | 9 +- .../src/kilo/devcontainer.test.ts | 28 +- .../cloud-agent-next/src/persistence/types.ts | 4 +- services/cloud-agent-next/src/server.test.ts | 13 + .../src/staging-config.test.ts | 32 ++ .../src/telemetry/report-consumer.ts | 1 + services/cloud-agent-next/src/types.ts | 4 +- .../worker-configuration.d.ts | 48 +- services/cloud-agent-next/wrangler.jsonc | 140 ++++++ 10 files changed, 714 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/deploy-cloud-agent-next-staging.yml create mode 100644 services/cloud-agent-next/src/staging-config.test.ts diff --git a/.github/workflows/deploy-cloud-agent-next-staging.yml b/.github/workflows/deploy-cloud-agent-next-staging.yml new file mode 100644 index 0000000000..cc822de8f4 --- /dev/null +++ b/.github/workflows/deploy-cloud-agent-next-staging.yml @@ -0,0 +1,450 @@ +name: Deploy Cloud Agent Next staging + +on: + workflow_dispatch: + inputs: + pr_number: + description: 'Same-repository pull request to deploy' + required: true + type: number + +permissions: + contents: read + pull-requests: read + +concurrency: + group: deploy-cloud-agent-next-staging + cancel-in-progress: false + +jobs: + authorize-and-test: + runs-on: ${{ vars.RUNNER_LARGE_LABEL || 'ubuntu-24.04-8core' }} + timeout-minutes: 30 + outputs: + merge_sha: ${{ steps.authorize.outputs.merge_sha }} + pr_url: ${{ steps.authorize.outputs.pr_url }} + steps: + - name: Verify trusted workflow ref + run: | + set -euo pipefail + if [ "$GITHUB_REF" != "refs/heads/main" ]; then + echo "::error::This workflow must be dispatched from main. Got: $GITHUB_REF" + exit 1 + fi + + - name: Authorize pull request merge commit + id: authorize + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ inputs.pr_number }} + run: | + set -euo pipefail + if [[ ! "$PR_NUMBER" =~ ^[0-9]+$ ]]; then + echo "::error::Invalid pull request number" + exit 1 + fi + + PR_META=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \ + --jq '{state: .state, head_repo: (.head.repo.full_name // ""), base_repo: (.base.repo.full_name // ""), html_url: .html_url}') + if [ "$(jq -r '.state' <<< "$PR_META")" != "open" ]; then + echo "::error::Pull request #${PR_NUMBER} is not open" + exit 1 + fi + if [ "$(jq -r '.head_repo' <<< "$PR_META")" != "$GITHUB_REPOSITORY" ] || \ + [ "$(jq -r '.base_repo' <<< "$PR_META")" != "$GITHUB_REPOSITORY" ]; then + echo "::error::Pull request #${PR_NUMBER} must originate from this repository" + exit 1 + fi + + MERGE_REFS=$(gh api "repos/${GITHUB_REPOSITORY}/git/matching-refs/pull/${PR_NUMBER}/merge") + MERGE_SHA=$(jq -r --arg ref "refs/pull/${PR_NUMBER}/merge" \ + 'map(select(.ref == $ref)) | .[0].object.sha // empty' <<< "$MERGE_REFS") + if [[ ! "$MERGE_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Pull request #${PR_NUMBER} does not have a current merge commit" + exit 1 + fi + + echo "merge_sha=$MERGE_SHA" >> "$GITHUB_OUTPUT" + echo "pr_url=$(jq -r '.html_url' <<< "$PR_META")" >> "$GITHUB_OUTPUT" + echo "Authorized pull request #${PR_NUMBER} merge commit $MERGE_SHA" + + - name: Checkout authorized merge commit + uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1 + with: + ref: ${{ steps.authorize.outputs.merge_sha }} + lfs: true + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + with: + version: 11.1.2 + + - name: Setup Node + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: 24.14.1 + cache: 'pnpm' + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.14 + + - name: Install candidate dependencies + run: pnpm install --frozen-lockfile + + - name: Build wrapper bundle + working-directory: services/cloud-agent-next/wrapper + run: bun run build.ts + + - name: Typecheck Cloud Agent Next + run: pnpm --filter cloud-agent-next typecheck + + - name: Test Cloud Agent Next + run: pnpm --filter cloud-agent-next test:all + + deploy: + needs: authorize-and-test + runs-on: ${{ vars.RUNNER_LARGE_LABEL || 'ubuntu-24.04-8core' }} + timeout-minutes: 30 + steps: + - name: Checkout authorized merge commit + uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1 + with: + ref: ${{ needs.authorize-and-test.outputs.merge_sha }} + lfs: true + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + with: + version: 11.1.2 + + - name: Setup Node + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: 24.14.1 + + - name: Install candidate dependencies without lifecycle scripts + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Install trusted deployment tools + env: + TRUSTED_TOOLS_DIR: ${{ runner.temp }}/cloud-agent-next-staging-tools + npm_config_userconfig: /dev/null + run: | + set -euo pipefail + mkdir -p "$TRUSTED_TOOLS_DIR" + printf '{"private":true}\n' > "$TRUSTED_TOOLS_DIR/package.json" + pnpm --dir "$TRUSTED_TOOLS_DIR" add --ignore-workspace --save-exact \ + jsonc-parser@3.3.1 wrangler@4.90.1 + + - name: Validate exact staging deployment configuration + env: + TRUSTED_TOOLS_DIR: ${{ runner.temp }}/cloud-agent-next-staging-tools + run: | + set -euo pipefail + node <<'NODE' + const fs = require('node:fs'); + const path = require('node:path'); + const { isDeepStrictEqual } = require('node:util'); + const { parse, printParseErrorCode } = require( + path.join(process.env.TRUSTED_TOOLS_DIR, 'node_modules/jsonc-parser') + ); + + const configPath = path.join( + process.env.GITHUB_WORKSPACE, + 'services/cloud-agent-next/wrangler.jsonc' + ); + const parseErrors = []; + const config = parse(fs.readFileSync(configPath, 'utf8'), parseErrors, { + allowTrailingComma: true, + }); + if (parseErrors.length > 0) { + const descriptions = parseErrors.map(error => + `${printParseErrorCode(error.error)} at offset ${error.offset}` + ); + throw new Error(`Invalid Wrangler JSONC: ${descriptions.join(', ')}`); + } + + const expectedTopLevelKeys = [ + '$schema', + 'account_id', + 'compatibility_date', + 'compatibility_flags', + 'containers', + 'dev', + 'durable_objects', + 'env', + 'hyperdrive', + 'logpush', + 'main', + 'migrations', + 'name', + 'observability', + 'placement', + 'preview_urls', + 'queues', + 'r2_buckets', + 'routes', + 'rules', + 'secrets_store_secrets', + 'services', + 'triggers', + 'vars', + 'workers_dev', + ]; + const actualTopLevelKeys = Object.keys(config).sort(); + if (!isDeepStrictEqual(actualTopLevelKeys, expectedTopLevelKeys)) { + throw new Error( + `Wrangler top-level keys differ from the allowlist: ${JSON.stringify(actualTopLevelKeys)}` + ); + } + + const expectedInheritedConfig = { + name: 'cloud-agent-next', + account_id: 'e115e769bcdd4c3d66af59d3332cb394', + main: 'src/index.ts', + compatibility_date: '2025-09-15', + compatibility_flags: ['nodejs_compat'], + preview_urls: false, + workers_dev: true, + rules: [{ type: 'Text', globs: ['**/*.sql'], fallthrough: true }], + observability: { + enabled: false, + head_sampling_rate: 1, + logs: { + enabled: true, + head_sampling_rate: 1, + persist: true, + invocation_logs: true, + }, + traces: { enabled: true, persist: true, head_sampling_rate: 1 }, + }, + logpush: true, + placement: { mode: 'smart' }, + secrets_store_secrets: [ + { + binding: 'INTERNAL_API_SECRET_PROD', + store_id: '342a86d9e3a94da698e82d0c6e2a36f0', + secret_name: 'INTERNAL_API_SECRET_PROD', + }, + ], + migrations: [ + { new_sqlite_classes: ['Sandbox'], tag: 'v1' }, + { new_sqlite_classes: ['CloudAgentSession'], tag: 'v2' }, + { new_sqlite_classes: ['SandboxSmall'], tag: 'v3' }, + { new_sqlite_classes: ['SandboxDIND'], tag: 'v4' }, + { new_sqlite_classes: ['UserKiloFacade'], tag: 'v5' }, + ], + }; + const actualInheritedConfig = Object.fromEntries( + Object.keys(expectedInheritedConfig).map(key => [key, config[key]]) + ); + if (!isDeepStrictEqual(actualInheritedConfig, expectedInheritedConfig)) { + throw new Error('Inherited Wrangler configuration differs from the staging allowlist'); + } + + const expectedStaging = { + name: 'cloud-agent-next-staging', + workers_dev: false, + preview_urls: false, + triggers: { crons: [] }, + routes: [ + { + pattern: 'cloud-agent-next-staging.kilosessions.ai', + custom_domain: true, + }, + ], + vars: { + KILOCODE_BACKEND_BASE_URL: 'https://api.kilo.ai', + KILO_OPENROUTER_BASE: 'https://api.kilo.ai/api', + GITHUB_APP_SLUG: 'kiloconnect', + GITHUB_APP_BOT_USER_ID: '240665456', + GITHUB_LITE_APP_SLUG: 'kiloconnect-lite', + GITHUB_LITE_APP_BOT_USER_ID: '257753004', + WORKER_URL: 'https://cloud-agent-next-staging.kilosessions.ai', + CLI_TIMEOUT_SECONDS: '900', + REAPER_INTERVAL_MS: '300000', + R2_ATTACHMENTS_BUCKET: 'cloud-agent-attachments', + R2_ENDPOINT: + 'https://e115e769bcdd4c3d66af59d3332cb394.r2.cloudflarestorage.com', + WS_ALLOWED_ORIGINS: + 'https://app.kilo.ai,https://api.kilo.ai,https://cloud-agent-next-staging.kilosessions.ai', + KILO_SESSION_INGEST_URL: 'https://ingest.kilosessions.ai', + PER_SESSION_SANDBOX_ORG_IDS: '*', + }, + placement: { mode: 'smart' }, + services: [ + { + binding: 'SESSION_INGEST', + service: 'session-ingest', + entrypoint: 'SessionIngestRPC', + }, + { + binding: 'GIT_TOKEN_SERVICE', + service: 'git-token-service', + entrypoint: 'GitTokenRPCEntrypoint', + }, + { + binding: 'NOTIFICATIONS', + service: 'notifications', + entrypoint: 'NotificationsService', + }, + ], + r2_buckets: [ + { binding: 'R2_BUCKET', bucket_name: 'kilocode-sessions-staging' }, + ], + containers: [ + { + class_name: 'Sandbox', + image: './Dockerfile', + instance_type: 'standard-4', + image_vars: { KILOCODE_CLI_VERSION: '7.3.21' }, + max_instances: 10, + rollout_active_grace_period: 60, + }, + { + class_name: 'SandboxSmall', + image: './Dockerfile', + instance_type: 'standard-3', + image_vars: { KILOCODE_CLI_VERSION: '7.3.21' }, + max_instances: 2, + rollout_active_grace_period: 60, + }, + { + class_name: 'SandboxDIND', + image: './Dockerfile.dind', + instance_type: 'standard-3', + image_vars: { KILOCODE_CLI_VERSION: '7.3.21' }, + max_instances: 2, + rollout_active_grace_period: 60, + }, + ], + durable_objects: { + bindings: [ + { class_name: 'Sandbox', name: 'Sandbox' }, + { class_name: 'SandboxSmall', name: 'SandboxSmall' }, + { class_name: 'SandboxDIND', name: 'SandboxDIND' }, + { class_name: 'CloudAgentSession', name: 'CLOUD_AGENT_SESSION' }, + { class_name: 'UserKiloFacade', name: 'USER_KILO_FACADE' }, + ], + }, + hyperdrive: [ + { + binding: 'HYPERDRIVE', + id: '624ec80650dd414199349f4e217ddb10', + }, + ], + queues: { + producers: [ + { + binding: 'CALLBACK_QUEUE', + queue: 'cloud-agent-next-callback-queue-staging', + }, + { + binding: 'CLOUD_AGENT_REPORT_QUEUE', + queue: 'cloud-agent-next-report-queue-staging', + }, + ], + consumers: [ + { + queue: 'cloud-agent-next-callback-queue-staging', + max_batch_size: 5, + max_retries: 4, + }, + { + queue: 'cloud-agent-next-report-queue-staging', + max_retries: 3, + dead_letter_queue: 'cloud-agent-next-report-queue-dlq-staging', + }, + ], + }, + }; + if (!isDeepStrictEqual(config.env?.staging, expectedStaging)) { + throw new Error('env.staging differs from the exact deployment allowlist'); + } + + console.log('Staging Wrangler configuration matches the exact allowlist'); + NODE + + - name: Revalidate authorized merge commit + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ inputs.pr_number }} + AUTHORIZED_SHA: ${{ needs.authorize-and-test.outputs.merge_sha }} + run: | + set -euo pipefail + PR_META=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \ + --jq '{state: .state, head_repo: (.head.repo.full_name // ""), base_repo: (.base.repo.full_name // "")}') + if [ "$(jq -r '.state' <<< "$PR_META")" != "open" ] || \ + [ "$(jq -r '.head_repo' <<< "$PR_META")" != "$GITHUB_REPOSITORY" ] || \ + [ "$(jq -r '.base_repo' <<< "$PR_META")" != "$GITHUB_REPOSITORY" ]; then + echo "::error::Pull request authorization changed after testing" + exit 1 + fi + + MERGE_REFS=$(gh api "repos/${GITHUB_REPOSITORY}/git/matching-refs/pull/${PR_NUMBER}/merge") + CURRENT_SHA=$(jq -r --arg ref "refs/pull/${PR_NUMBER}/merge" \ + 'map(select(.ref == $ref)) | .[0].object.sha // empty' <<< "$MERGE_REFS") + if [ "$CURRENT_SHA" != "$AUTHORIZED_SHA" ]; then + echo "::error::Pull request merge commit changed after testing" + exit 1 + fi + + - name: Deploy authorized candidate to staging + id: deploy + working-directory: services/cloud-agent-next + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: e115e769bcdd4c3d66af59d3332cb394 + TRUSTED_TOOLS_DIR: ${{ runner.temp }}/cloud-agent-next-staging-tools + WRANGLER_SEND_METRICS: false + run: | + set -euo pipefail + "$TRUSTED_TOOLS_DIR/node_modules/.bin/wrangler" deploy --env staging + + - name: Verify authenticated staging smoke path + id: health + env: + STAGING_SMOKE_TOKEN: ${{ secrets.CLOUD_AGENT_STAGING_SMOKE_TOKEN }} + run: | + set -euo pipefail + if [ -z "$STAGING_SMOKE_TOKEN" ]; then + echo "::error::CLOUD_AGENT_STAGING_SMOKE_TOKEN is not configured" + exit 1 + fi + + for attempt in $(seq 1 12); do + if body=$(curl --fail-with-body --silent --show-error \ + --connect-timeout 10 --max-time 30 \ + --header "Authorization: Bearer ${STAGING_SMOKE_TOKEN}" \ + "https://cloud-agent-next-staging.kilosessions.ai/kilo/session?limit=1"); then + if jq -e 'type == "array"' >/dev/null <<< "$body"; then + echo "Authenticated staging smoke check passed" + exit 0 + fi + fi + echo "Smoke check attempt ${attempt} failed; retrying" + sleep 10 + done + echo "::error::Authenticated staging smoke check did not pass" + exit 1 + + - name: Write deployment summary + if: always() + env: + PR_NUMBER: ${{ inputs.pr_number }} + PR_URL: ${{ needs.authorize-and-test.outputs.pr_url }} + MERGE_SHA: ${{ needs.authorize-and-test.outputs.merge_sha }} + RESULT: ${{ job.status }} + run: | + { + echo "## Cloud Agent Next staging" + echo "" + echo "- **Pull request:** [#${PR_NUMBER}](${PR_URL})" + echo "- **Merge commit:** \`${MERGE_SHA}\`" + echo "- **Staging URL:** https://cloud-agent-next-staging.kilosessions.ai" + echo "- **Result:** ${RESULT}" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/services/cloud-agent-next/src/callbacks/queue-config.test.ts b/services/cloud-agent-next/src/callbacks/queue-config.test.ts index 132c2654ea..0c9a767ca6 100644 --- a/services/cloud-agent-next/src/callbacks/queue-config.test.ts +++ b/services/cloud-agent-next/src/callbacks/queue-config.test.ts @@ -5,9 +5,10 @@ import { describe, expect, it } from 'vitest'; import { CALLBACK_DELIVERY_MAX_ATTEMPTS } from './delivery.js'; type QueueConsumer = { queue?: string; max_retries?: number }; +type EnvironmentConfig = { queues?: { consumers?: QueueConsumer[] } }; type WranglerConfig = { queues?: { consumers?: QueueConsumer[] }; - env?: { dev?: { queues?: { consumers?: QueueConsumer[] } } }; + env?: { dev?: EnvironmentConfig; staging?: EnvironmentConfig }; }; const CONFIGURED_REDELIVERIES = CALLBACK_DELIVERY_MAX_ATTEMPTS - 1; @@ -18,7 +19,7 @@ function readWranglerConfig(): WranglerConfig { } describe('callback queue retry configuration', () => { - it('allows the application callback retry budget in default and dev consumers', () => { + it('allows the application callback retry budget in every environment', () => { const config = readWranglerConfig(); const production = config.queues?.consumers?.find( consumer => consumer.queue === 'cloud-agent-next-callback-queue' @@ -26,8 +27,12 @@ describe('callback queue retry configuration', () => { const dev = config.env?.dev?.queues?.consumers?.find( consumer => consumer.queue === 'cloud-agent-next-callback-queue-dev' ); + const staging = config.env?.staging?.queues?.consumers?.find( + consumer => consumer.queue === 'cloud-agent-next-callback-queue-staging' + ); expect(production?.max_retries).toBe(CONFIGURED_REDELIVERIES); expect(dev?.max_retries).toBe(CONFIGURED_REDELIVERIES); + expect(staging?.max_retries).toBe(CONFIGURED_REDELIVERIES); }); }); diff --git a/services/cloud-agent-next/src/kilo/devcontainer.test.ts b/services/cloud-agent-next/src/kilo/devcontainer.test.ts index ce08fa00b4..c53f18098e 100644 --- a/services/cloud-agent-next/src/kilo/devcontainer.test.ts +++ b/services/cloud-agent-next/src/kilo/devcontainer.test.ts @@ -11,6 +11,7 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; +import { parse } from 'jsonc-parser'; import { describe, expect, it, vi } from 'vitest'; import { bringUpDevContainer, @@ -77,17 +78,32 @@ describe('sandbox image versions', () => { fileURLToPath(new URL('../../Dockerfile.dind', import.meta.url).href), 'utf8' ); - const wranglerConfig = readFileSync( - fileURLToPath(new URL('../../wrangler.jsonc', import.meta.url).href), - 'utf8' - ); - const imageVar = `"KILOCODE_CLI_VERSION": "${KILO_CLI_VERSION}"`; + const wranglerConfig = parse( + readFileSync(fileURLToPath(new URL('../../wrangler.jsonc', import.meta.url).href), 'utf8') + ) as { + containers?: Array<{ image_vars?: { KILOCODE_CLI_VERSION?: string } }>; + env?: { + dev?: { containers?: Array<{ image_vars?: { KILOCODE_CLI_VERSION?: string } }> }; + staging?: { containers?: Array<{ image_vars?: { KILOCODE_CLI_VERSION?: string } }> }; + }; + }; expect(wrapperPackageJson.dependencies['@kilocode/sdk']).toBe(KILO_CLI_VERSION); expect(dockerfile).toContain(`ARG KILOCODE_CLI_VERSION="${KILO_CLI_VERSION}"`); expect(devDockerfile).toContain(`ARG KILOCODE_CLI_VERSION="${KILO_CLI_VERSION}"`); expect(dindDockerfile).toContain(`ARG KILOCODE_CLI_VERSION="${KILO_CLI_VERSION}"`); - expect(wranglerConfig.split(imageVar)).toHaveLength(7); + for (const containers of [ + wranglerConfig.containers, + wranglerConfig.env?.dev?.containers, + wranglerConfig.env?.staging?.containers, + ]) { + expect(containers).toHaveLength(3); + expect(containers?.map(container => container.image_vars?.KILOCODE_CLI_VERSION)).toEqual([ + KILO_CLI_VERSION, + KILO_CLI_VERSION, + KILO_CLI_VERSION, + ]); + } expect(DEFAULT_SLASH_COMMANDS_SOURCE).toBe(`kilo@${KILO_CLI_VERSION}`); }); }); diff --git a/services/cloud-agent-next/src/persistence/types.ts b/services/cloud-agent-next/src/persistence/types.ts index f89e7196ec..c69e4de0ce 100644 --- a/services/cloud-agent-next/src/persistence/types.ts +++ b/services/cloud-agent-next/src/persistence/types.ts @@ -150,8 +150,8 @@ export type PersistenceEnv = { /** Worker base URL for building wrapper ingest WebSocket endpoints */ WORKER_URL?: string; - /** Shared secret for internal service-to-service authentication */ - INTERNAL_API_SECRET_PROD: SecretsStoreSecret; + /** Optional production Secrets Store binding; staging uses the direct Worker secret. */ + INTERNAL_API_SECRET_PROD?: SecretsStoreSecret; R2_ENDPOINT?: string; R2_ATTACHMENTS_READONLY_ACCESS_KEY_ID?: string; diff --git a/services/cloud-agent-next/src/server.test.ts b/services/cloud-agent-next/src/server.test.ts index 779cd41f6a..2039aab0ba 100644 --- a/services/cloud-agent-next/src/server.test.ts +++ b/services/cloud-agent-next/src/server.test.ts @@ -59,6 +59,7 @@ vi.mock('./telemetry/report-consumer.js', () => ({ CLOUD_AGENT_REPORT_QUEUE_NAMES: new Set([ 'cloud-agent-next-report-queue', 'cloud-agent-next-report-queue-dev', + 'cloud-agent-next-report-queue-staging', 'cloud-agent-next-report-queue-test', ]), consumeCloudAgentReportBatch: consumeCloudAgentReportBatchMock, @@ -198,6 +199,18 @@ describe('server background reporting', () => { expect(consumeCloudAgentReportBatchMock).toHaveBeenCalledWith(batch, env); }); + it('routes isolated staging report queue batches to the Cloud Agent report consumer', async () => { + const env = createEnv(); + const batch = { + queue: 'cloud-agent-next-report-queue-staging', + messages: [], + } as unknown as MessageBatch; + + await worker.queue(batch, env as unknown as Env); + + expect(consumeCloudAgentReportBatchMock).toHaveBeenCalledWith(batch, env); + }); + it('runs reporting retention cleanup from the scheduled handler', async () => { const env = createEnv(); diff --git a/services/cloud-agent-next/src/staging-config.test.ts b/services/cloud-agent-next/src/staging-config.test.ts new file mode 100644 index 0000000000..ed8b443bcf --- /dev/null +++ b/services/cloud-agent-next/src/staging-config.test.ts @@ -0,0 +1,32 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { parse } from 'jsonc-parser'; +import { describe, expect, it } from 'vitest'; + +type WranglerConfig = { + env?: { + staging?: { + triggers?: { crons?: string[] }; + secrets_store_secrets?: unknown[]; + }; + }; +}; + +function readWranglerConfig(): WranglerConfig { + const configPath = fileURLToPath(new URL('../wrangler.jsonc', import.meta.url).href); + return parse(readFileSync(configPath, 'utf8')) as WranglerConfig; +} + +describe('staging deployment configuration', () => { + it('does not schedule production report retention cleanup', () => { + const config = readWranglerConfig(); + + expect(config.env?.staging?.triggers?.crons ?? []).toEqual([]); + }); + + it('uses direct Worker secrets instead of Secrets Store bindings', () => { + const config = readWranglerConfig(); + + expect(config.env?.staging?.secrets_store_secrets).toBeUndefined(); + }); +}); diff --git a/services/cloud-agent-next/src/telemetry/report-consumer.ts b/services/cloud-agent-next/src/telemetry/report-consumer.ts index 677223630b..aad2d446f2 100644 --- a/services/cloud-agent-next/src/telemetry/report-consumer.ts +++ b/services/cloud-agent-next/src/telemetry/report-consumer.ts @@ -7,6 +7,7 @@ import { createCloudAgentReportStore } from './report-store.js'; export const CLOUD_AGENT_REPORT_QUEUE_NAMES = new Set([ 'cloud-agent-next-report-queue', 'cloud-agent-next-report-queue-dev', + 'cloud-agent-next-report-queue-staging', 'cloud-agent-next-report-queue-test', ]); diff --git a/services/cloud-agent-next/src/types.ts b/services/cloud-agent-next/src/types.ts index ca6125f5f8..53c522e73d 100644 --- a/services/cloud-agent-next/src/types.ts +++ b/services/cloud-agent-next/src/types.ts @@ -197,8 +197,8 @@ export type Env = { USER_KILO_FACADE: DurableObjectNamespace; /** Service binding for the session ingest worker */ SESSION_INGEST: SessionIngestBinding; - /** Shared secret for internal service-to-service authentication */ - INTERNAL_API_SECRET_PROD: SecretsStoreSecret; + /** Optional production Secrets Store binding; staging uses the direct Worker secret. */ + INTERNAL_API_SECRET_PROD?: SecretsStoreSecret; /** R2 bucket for storing session logs */ R2_BUCKET: R2Bucket; /** Queue for callback messages (optional - supports incremental rollout) */ diff --git a/services/cloud-agent-next/worker-configuration.d.ts b/services/cloud-agent-next/worker-configuration.d.ts index 16777b1b29..339c90041a 100644 --- a/services/cloud-agent-next/worker-configuration.d.ts +++ b/services/cloud-agent-next/worker-configuration.d.ts @@ -1,10 +1,10 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 0264ea9ef0ff7fd80bb9ebde4d3d6652) +// Generated by Wrangler by running `wrangler types` (hash: 58d62ff0a69f81d997b2a46b6819e5c8) // Runtime types generated with workerd@1.20260508.1 2025-09-15 nodejs_compat declare namespace Cloudflare { interface GlobalProps { mainModule: typeof import("./src/index"); - durableNamespaces: "Sandbox" | "CloudAgentSession" | "SandboxSmall" | "SandboxDIND"; + durableNamespaces: "Sandbox" | "CloudAgentSession" | "SandboxSmall" | "SandboxDIND" | "UserKiloFacade"; } interface DevEnv { R2_BUCKET: R2Bucket; @@ -42,16 +42,57 @@ declare namespace Cloudflare { SandboxSmall: DurableObjectNamespace; SandboxDIND: DurableObjectNamespace; CLOUD_AGENT_SESSION: DurableObjectNamespace; + USER_KILO_FACADE: DurableObjectNamespace; SESSION_INGEST: Service /* entrypoint SessionIngestRPC from session-ingest */; GIT_TOKEN_SERVICE: Service /* entrypoint GitTokenRPCEntrypoint from git-token-service-dev */; NOTIFICATIONS: Service /* entrypoint NotificationsService from notifications */; } + interface StagingEnv { + R2_BUCKET: R2Bucket; + HYPERDRIVE: Hyperdrive; + CALLBACK_QUEUE: Queue; + CLOUD_AGENT_REPORT_QUEUE: Queue; + GITHUB_LITE_APP_SLUG: "kiloconnect-lite"; + GITHUB_LITE_APP_BOT_USER_ID: "257753004"; + R2_ATTACHMENTS_BUCKET: "cloud-agent-attachments"; + PER_SESSION_SANDBOX_ORG_IDS: "*"; + NEXTAUTH_SECRET: string; + KILO_SESSION_INGEST_URL: string; + WORKER_URL: string; + WS_ALLOWED_ORIGINS: string; + KILOCODE_BACKEND_BASE_URL: string; + KILO_OPENROUTER_BASE: string; + INTERNAL_API_SECRET: string; + R2_ENDPOINT: string; + R2_ATTACHMENTS_READONLY_ACCESS_KEY_ID: string; + R2_ATTACHMENTS_READONLY_SECRET_ACCESS_KEY: string; + AGENT_ENV_VARS_PRIVATE_KEY: string; + GITHUB_APP_ID: string; + GITHUB_APP_PRIVATE_KEY: string; + CLI_TIMEOUT_SECONDS: string; + REAPER_INTERVAL_MS: string; + STALE_THRESHOLD_MS: string; + PENDING_START_TIMEOUT_MS: string; + GITHUB_APP_SLUG: string; + GITHUB_APP_BOT_USER_ID: string; + GITHUB_LITE_APP_ID: string; + GITHUB_LITE_APP_PRIVATE_KEY: string; + KILOCODE_SANDBOX_BACKEND_BASE_URL: string; + Sandbox: DurableObjectNamespace; + SandboxSmall: DurableObjectNamespace; + SandboxDIND: DurableObjectNamespace; + CLOUD_AGENT_SESSION: DurableObjectNamespace; + USER_KILO_FACADE: DurableObjectNamespace; + SESSION_INGEST: Service /* entrypoint SessionIngestRPC from session-ingest */; + GIT_TOKEN_SERVICE: Service /* entrypoint GitTokenRPCEntrypoint from git-token-service */; + NOTIFICATIONS: Service /* entrypoint NotificationsService from notifications */; + } interface Env { R2_BUCKET: R2Bucket; HYPERDRIVE: Hyperdrive; CALLBACK_QUEUE: Queue; CLOUD_AGENT_REPORT_QUEUE: Queue; - INTERNAL_API_SECRET_PROD: SecretsStoreSecret; + INTERNAL_API_SECRET_PROD?: SecretsStoreSecret; GITHUB_LITE_APP_SLUG: "" | "kiloconnect-lite"; GITHUB_LITE_APP_BOT_USER_ID: "" | "257753004"; R2_ATTACHMENTS_BUCKET: "cloud-agent-attachments-dev" | "cloud-agent-attachments"; @@ -82,6 +123,7 @@ declare namespace Cloudflare { SandboxSmall: DurableObjectNamespace; SandboxDIND: DurableObjectNamespace; CLOUD_AGENT_SESSION: DurableObjectNamespace; + USER_KILO_FACADE: DurableObjectNamespace; SESSION_INGEST: Service /* entrypoint SessionIngestRPC from session-ingest */; GIT_TOKEN_SERVICE: Service /* entrypoint GitTokenRPCEntrypoint from git-token-service-dev */ | Service /* entrypoint GitTokenRPCEntrypoint from git-token-service */; NOTIFICATIONS: Service /* entrypoint NotificationsService from notifications */; diff --git a/services/cloud-agent-next/wrangler.jsonc b/services/cloud-agent-next/wrangler.jsonc index bc63b5a5d8..672fceecd0 100644 --- a/services/cloud-agent-next/wrangler.jsonc +++ b/services/cloud-agent-next/wrangler.jsonc @@ -403,5 +403,145 @@ ], }, }, + "staging": { + "name": "cloud-agent-next-staging", + "workers_dev": false, + "preview_urls": false, + "triggers": { + "crons": [], + }, + "routes": [ + { + "pattern": "cloud-agent-next-staging.kilosessions.ai", + "custom_domain": true, + }, + ], + "vars": { + "KILOCODE_BACKEND_BASE_URL": "https://api.kilo.ai", + "KILO_OPENROUTER_BASE": "https://api.kilo.ai/api", + "GITHUB_APP_SLUG": "kiloconnect", + "GITHUB_APP_BOT_USER_ID": "240665456", + "GITHUB_LITE_APP_SLUG": "kiloconnect-lite", + "GITHUB_LITE_APP_BOT_USER_ID": "257753004", + "WORKER_URL": "https://cloud-agent-next-staging.kilosessions.ai", + "CLI_TIMEOUT_SECONDS": "900", + "REAPER_INTERVAL_MS": "300000", + "R2_ATTACHMENTS_BUCKET": "cloud-agent-attachments", + "R2_ENDPOINT": "https://e115e769bcdd4c3d66af59d3332cb394.r2.cloudflarestorage.com", + "WS_ALLOWED_ORIGINS": "https://app.kilo.ai,https://api.kilo.ai,https://cloud-agent-next-staging.kilosessions.ai", + "KILO_SESSION_INGEST_URL": "https://ingest.kilosessions.ai", + "PER_SESSION_SANDBOX_ORG_IDS": "*", + }, + "placement": { "mode": "smart" }, + "services": [ + { + "binding": "SESSION_INGEST", + "service": "session-ingest", + "entrypoint": "SessionIngestRPC", + }, + { + "binding": "GIT_TOKEN_SERVICE", + "service": "git-token-service", + "entrypoint": "GitTokenRPCEntrypoint", + }, + { + "binding": "NOTIFICATIONS", + "service": "notifications", + "entrypoint": "NotificationsService", + }, + ], + "r2_buckets": [ + { + "binding": "R2_BUCKET", + "bucket_name": "kilocode-sessions-staging", + }, + ], + "containers": [ + { + "class_name": "Sandbox", + "image": "./Dockerfile", + "instance_type": "standard-4", + "image_vars": { + "KILOCODE_CLI_VERSION": "7.3.21", + }, + "max_instances": 10, + "rollout_active_grace_period": 60, + }, + { + "class_name": "SandboxSmall", + "image": "./Dockerfile", + "instance_type": "standard-3", + "image_vars": { + "KILOCODE_CLI_VERSION": "7.3.21", + }, + "max_instances": 2, + "rollout_active_grace_period": 60, + }, + { + "class_name": "SandboxDIND", + "image": "./Dockerfile.dind", + "instance_type": "standard-3", + "image_vars": { + "KILOCODE_CLI_VERSION": "7.3.21", + }, + "max_instances": 2, + "rollout_active_grace_period": 60, + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "Sandbox", + "name": "Sandbox", + }, + { + "class_name": "SandboxSmall", + "name": "SandboxSmall", + }, + { + "class_name": "SandboxDIND", + "name": "SandboxDIND", + }, + { + "class_name": "CloudAgentSession", + "name": "CLOUD_AGENT_SESSION", + }, + { + "class_name": "UserKiloFacade", + "name": "USER_KILO_FACADE", + }, + ], + }, + "hyperdrive": [ + { + "binding": "HYPERDRIVE", + "id": "624ec80650dd414199349f4e217ddb10", + }, + ], + "queues": { + "producers": [ + { + "binding": "CALLBACK_QUEUE", + "queue": "cloud-agent-next-callback-queue-staging", + }, + { + "binding": "CLOUD_AGENT_REPORT_QUEUE", + "queue": "cloud-agent-next-report-queue-staging", + }, + ], + "consumers": [ + { + "queue": "cloud-agent-next-callback-queue-staging", + "max_batch_size": 5, + "max_retries": 4, + }, + { + "queue": "cloud-agent-next-report-queue-staging", + "max_retries": 3, + "dead_letter_queue": "cloud-agent-next-report-queue-dlq-staging", + }, + ], + }, + }, }, }