From 439a1473e9b82189e48c04f135ebd59f7fd70136 Mon Sep 17 00:00:00 2001 From: pandemicsyn Date: Wed, 4 Feb 2026 08:22:03 -0600 Subject: [PATCH 1/3] port from kilocode-backend pr --- .github/workflows/ci.yml | 46 ++++++++++++++++++ jest.config.ts | 1 + pnpm-lock.yaml | 100 +++++++++++++++++++++++++++++++++++++-- pnpm-workspace.yaml | 2 + 4 files changed, 146 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b72aa2d539..39e14d3e83 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,7 @@ jobs: runs-on: ubuntu-latest outputs: cloud_agent: ${{ steps.filter.outputs.cloud_agent }} + cloud_agent_next: ${{ steps.filter.outputs.cloud_agent_next }} webhook_agent: ${{ steps.filter.outputs.webhook_agent }} steps: - uses: actions/checkout@v4 @@ -28,6 +29,8 @@ jobs: filters: | cloud_agent: - 'cloud-agent/**' + cloud_agent_next: + - 'cloud-agent-next/**' webhook_agent: - 'cloudflare-webhook-agent-ingest/**' @@ -216,6 +219,49 @@ jobs: - name: Run cloud-agent tests run: pnpm --filter cloud-agent test:all + cloud-agent-next: + needs: changes + if: needs.changes.outputs.cloud_agent_next == 'true' + runs-on: ubuntu-24.04-8core + steps: + - uses: actions/checkout@v4 + with: + lfs: true + ref: ${{ github.head_ref }} + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: latest + run_install: false + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build wrapper bundle + working-directory: cloud-agent-next/wrapper + run: bun run build.ts + + - name: Typecheck (cloud-agent-next) + run: pnpm --filter cloud-agent-next typecheck + + - name: Lint (cloud-agent-next) + run: pnpm --filter cloud-agent-next lint + + - name: Run cloud-agent-next tests + run: pnpm --filter cloud-agent-next test:all + webhook-agent: needs: changes if: needs.changes.outputs.webhook_agent == 'true' diff --git a/jest.config.ts b/jest.config.ts index 020a3f4772..eba410bb60 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -31,6 +31,7 @@ const config: Config = { testMatch: ['**/src/**/*.test.ts'], testPathIgnorePatterns: [ '/cloud-agent/', + '/cloud-agent-next/', '/cloudflare-webhook-agent-ingest/', '/cloudflare-session-ingest/', ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f77222ba6..c94e34ea87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -490,6 +490,91 @@ importers: specifier: ^4.51.0 version: 4.51.0(@cloudflare/workers-types@4.20260130.0) + cloud-agent-next: + dependencies: + '@cloudflare/sandbox': + specifier: 0.6.7 + version: 0.6.7 + '@octokit/auth-app': + specifier: ^8.1.2 + version: 8.1.2 + '@trpc/server': + specifier: ^11.0.0 + version: 11.9.0(typescript@5.9.3) + aws4fetch: + specifier: ^1.0.20 + version: 1.0.20 + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.3 + pg: + specifier: ^8.16.3 + version: 8.17.2(pg-native@3.5.2) + workers-tagged-logger: + specifier: ^0.13.7 + version: 0.13.7 + zod: + specifier: ^4.1.0 + version: 4.3.6 + devDependencies: + '@cloudflare/containers': + specifier: 0.0.30 + version: 0.0.30 + '@cloudflare/vitest-pool-workers': + specifier: ^0.11.1 + version: 0.11.1(@cloudflare/workers-types@4.20260130.0)(@vitest/runner@2.1.9)(@vitest/snapshot@2.1.9)(vitest@2.1.9) + '@eslint/eslintrc': + specifier: ^3.3.1 + version: 3.3.3 + '@eslint/js': + specifier: ^9.38.0 + version: 9.39.2 + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 + '@types/node': + specifier: ^22 + version: 22.19.1 + '@types/pg': + specifier: ^8.15.6 + version: 8.15.6 + '@typescript/native-preview': + specifier: 7.0.0-dev.20251019.1 + version: 7.0.0-dev.20251019.1 + '@vitest/ui': + specifier: ^2.1.8 + version: 2.1.9(vitest@2.1.9) + eslint: + specifier: ^9.38.0 + version: 9.39.2(jiti@2.6.1) + prettier: + specifier: ^3.6.2 + version: 3.7.4 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.46.2 + version: 8.46.3(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.19.1)(@vitest/ui@2.1.9)(lightningcss@1.30.2)(terser@5.44.0) + wrangler: + specifier: ^4.51.0 + version: 4.60.0(@cloudflare/workers-types@4.20260130.0) + + cloud-agent-next/wrapper: + devDependencies: + '@types/bun': + specifier: latest + version: 1.3.8 + '@types/node': + specifier: ^20.0.0 + version: 20.19.27 + typescript: + specifier: ^5.0.0 + version: 5.9.3 + cloud-agent/wrapper: devDependencies: '@types/bun': @@ -12774,6 +12859,9 @@ packages: engines: {node: '>=16'} hasBin: true + workers-tagged-logger@0.13.7: + resolution: {integrity: sha512-8tDedtBBHogR/4uOJ0nqUbXnSPBeuvS3LM2REwh5dEg1MgY0bzDMs7NmQ/QDo39VF16vuUpkI2YTs+bkfioeTA==} + workers-tagged-logger@1.0.0: resolution: {integrity: sha512-tp5PAs48hSpF2GIbH0S186MBXuMe8u9uuHZR0Jmw/I8+NIiQblor5EpYJ5lOaE8u9s84mVme6Iv+ueC1C/beog==} @@ -19129,10 +19217,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.46.3(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.46.3 '@typescript-eslint/type-utils': 8.46.3(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/utils': 8.46.3(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) @@ -27132,7 +27220,7 @@ snapshots: typescript-eslint@8.46.3(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': 8.46.3(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.46.3(typescript@5.9.3) '@typescript-eslint/utils': 8.46.3(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) @@ -27693,6 +27781,12 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20260120.0 '@cloudflare/workerd-windows-64': 1.20260120.0 + workers-tagged-logger@0.13.7: + dependencies: + zod: 4.3.6 + optionalDependencies: + hono: 4.11.7 + workers-tagged-logger@1.0.0: dependencies: zod: 4.3.6 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a004bc9875..f8a25c05ec 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,6 +7,8 @@ packages: - 'cloudflare-auto-fix-infra' - 'cloud-agent' - 'cloud-agent/wrapper' + - 'cloud-agent-next' + - 'cloud-agent-next/wrapper' - 'cloudflare-app-builder' - 'cloudflare-ai-attribution' - 'cloudflare-db-proxy' From 81e1c3d6183463194f9f424516ae9c110692fcfa Mon Sep 17 00:00:00 2001 From: pandemicsyn Date: Wed, 4 Feb 2026 08:27:55 -0600 Subject: [PATCH 2/3] Add cloud-agent-next worker --- .github/workflows/deploy-cloud-agent-next.yml | 35 + cloud-agent-next/.dev.vars.example | 72 + cloud-agent-next/.gitignore | 7 + cloud-agent-next/.prettierrc.json | 11 + cloud-agent-next/.vscode/settings.json | 5 + cloud-agent-next/AGENTS.md | 103 + cloud-agent-next/Dockerfile | 49 + cloud-agent-next/Dockerfile.dev | 49 + cloud-agent-next/README.md | 852 ++ cloud-agent-next/cloud-agent-build.sh | 45 + cloud-agent-next/docs/diagrams.md | 211 + cloud-agent-next/eslint.config.mjs | 70 + cloud-agent-next/package.json | 56 + cloud-agent-next/src/auth.ts | 113 + .../src/balance-validation.test.ts | 578 + cloud-agent-next/src/balance-validation.ts | 155 + .../src/callbacks/delivery.test.ts | 207 + cloud-agent-next/src/callbacks/delivery.ts | 82 + cloud-agent-next/src/callbacks/index.ts | 3 + .../src/callbacks/queue-consumer.ts | 36 + cloud-agent-next/src/callbacks/types.ts | 19 + cloud-agent-next/src/constants.ts | 5 + cloud-agent-next/src/core/execution.test.ts | 80 + cloud-agent-next/src/core/execution.ts | 129 + cloud-agent-next/src/core/lease.test.ts | 83 + cloud-agent-next/src/core/lease.ts | 58 + cloud-agent-next/src/db/SqlStore.ts | 22 + cloud-agent-next/src/db/database.ts | 90 + cloud-agent-next/src/db/node-postgres.ts | 68 + .../db/stores/PlatformIntegrationsStore.ts | 67 + cloud-agent-next/src/db/table.ts | 159 + cloud-agent-next/src/db/tables/README.md | 40 + .../src/db/tables/events.table.ts | 56 + .../src/db/tables/execution-leases.table.ts | 45 + cloud-agent-next/src/db/tables/index.ts | 27 + .../tables/organization-memberships.table.ts | 7 + .../db/tables/platform-integrations.table.ts | 27 + cloud-agent-next/src/execution/errors.ts | 136 + .../src/execution/orchestrator.ts | 430 + cloud-agent-next/src/execution/types.ts | 348 + cloud-agent-next/src/helpers.ts | 19 + cloud-agent-next/src/index.ts | 292 + cloud-agent-next/src/kilo/client.test.ts | 236 + cloud-agent-next/src/kilo/client.ts | 360 + cloud-agent-next/src/kilo/errors.ts | 69 + cloud-agent-next/src/kilo/index.ts | 7 + cloud-agent-next/src/kilo/server-manager.ts | 433 + cloud-agent-next/src/kilo/types.ts | 52 + cloud-agent-next/src/kilo/utils.test.ts | 22 + cloud-agent-next/src/kilo/utils.ts | 31 + .../src/kilo/wrapper-client.test.ts | 704 + cloud-agent-next/src/kilo/wrapper-client.ts | 424 + cloud-agent-next/src/kilo/wrapper-manager.ts | 199 + cloud-agent-next/src/lib/result.test.ts | 75 + cloud-agent-next/src/lib/result.ts | 75 + cloud-agent-next/src/logger.ts | 44 + .../src/persistence/CloudAgentSession.ts | 1788 +++ .../src/persistence/migrations.ts | 131 + .../src/persistence/model-utils.test.ts | 22 + .../src/persistence/model-utils.ts | 6 + .../src/persistence/schemas.test.ts | 765 + cloud-agent-next/src/persistence/schemas.ts | 191 + cloud-agent-next/src/persistence/types.ts | 205 + cloud-agent-next/src/router.test.ts | 834 ++ cloud-agent-next/src/router.ts | 18 + cloud-agent-next/src/router/auth.ts | 79 + .../src/router/handlers/session-execution.ts | 205 + .../src/router/handlers/session-management.ts | 556 + .../src/router/handlers/session-prepare.ts | 467 + cloud-agent-next/src/router/schemas.ts | 402 + cloud-agent-next/src/sandbox-id.test.ts | 120 + cloud-agent-next/src/sandbox-id.ts | 62 + cloud-agent-next/src/schema.ts | 86 + .../src/services/github-token-service.ts | 183 + .../services/installation-lookup-service.ts | 64 + cloud-agent-next/src/session-prepare.test.ts | 1351 ++ cloud-agent-next/src/session-service.test.ts | 3254 +++++ cloud-agent-next/src/session-service.ts | 1787 +++ .../session/ingest-handlers/branch-capture.ts | 19 + .../ingest-handlers/execution-lifecycle.ts | 28 + .../src/session/ingest-handlers/index.ts | 11 + .../ingest-handlers/kilo-session-capture.ts | 50 + .../src/session/queries/events.ts | 160 + .../src/session/queries/executions.ts | 236 + cloud-agent-next/src/session/queries/index.ts | 39 + .../src/session/queries/leases.ts | 282 + cloud-agent-next/src/session/types.ts | 80 + cloud-agent-next/src/shared/index.ts | 7 + cloud-agent-next/src/shared/kilo-types.ts | 392 + cloud-agent-next/src/shared/protocol.ts | 55 + .../src/streaming-helpers.test.ts | 59 + cloud-agent-next/src/streaming-helpers.ts | 65 + cloud-agent-next/src/streaming.test.ts | 1211 ++ cloud-agent-next/src/streaming.ts | 493 + cloud-agent-next/src/types.ts | 235 + cloud-agent-next/src/types/ids.ts | 56 + cloud-agent-next/src/types/index.ts | 4 + cloud-agent-next/src/utils/do-retry.test.ts | 317 + cloud-agent-next/src/utils/do-retry.ts | 129 + cloud-agent-next/src/utils/encryption.test.ts | 240 + cloud-agent-next/src/utils/encryption.ts | 89 + cloud-agent-next/src/utils/image-download.ts | 91 + cloud-agent-next/src/utils/r2-client.ts | 45 + cloud-agent-next/src/utils/sql-helpers.ts | 62 + cloud-agent-next/src/utils/table.ts | 107 + cloud-agent-next/src/utils/timeout.ts | 36 + .../src/websocket/filters.test.ts | 188 + cloud-agent-next/src/websocket/filters.ts | 136 + cloud-agent-next/src/websocket/index.ts | 26 + cloud-agent-next/src/websocket/ingest.ts | 481 + cloud-agent-next/src/websocket/stream.ts | 207 + cloud-agent-next/src/websocket/types.ts | 171 + cloud-agent-next/src/workspace.test.ts | 423 + cloud-agent-next/src/workspace.ts | 521 + cloud-agent-next/test/env.d.ts | 10 + .../test/integration/session/events.test.ts | 224 + .../test/integration/session/leases.test.ts | 139 + .../session/start-execution-v2.test.ts | 120 + cloud-agent-next/test/test-worker.ts | 48 + cloud-agent-next/test/tsconfig.json | 7 + .../test/unit/execution/orchestrator.test.ts | 440 + .../test/unit/wrapper/lifecycle.test.ts | 592 + .../test/unit/wrapper/state.test.ts | 705 + cloud-agent-next/tsconfig.json | 16 + cloud-agent-next/utils/get-session-logs.sh | 29 + cloud-agent-next/vitest.config.ts | 22 + cloud-agent-next/vitest.workers.config.ts | 38 + cloud-agent-next/worker-configuration.d.ts | 12106 ++++++++++++++++ cloud-agent-next/wrangler.jsonc | 268 + cloud-agent-next/wrangler.test.jsonc | 33 + cloud-agent-next/wrapper/build.ts | 10 + cloud-agent-next/wrapper/package.json | 14 + cloud-agent-next/wrapper/src/auto-commit.ts | 164 + .../wrapper/src/condense-on-complete.ts | 117 + cloud-agent-next/wrapper/src/connection.ts | 440 + cloud-agent-next/wrapper/src/event-parser.ts | 41 + cloud-agent-next/wrapper/src/kilo-client.ts | 206 + .../wrapper/src/kilocode-runner.ts | 225 + cloud-agent-next/wrapper/src/lifecycle.ts | 464 + cloud-agent-next/wrapper/src/main.ts | 287 + cloud-agent-next/wrapper/src/server.ts | 551 + cloud-agent-next/wrapper/src/sse-consumer.ts | 306 + cloud-agent-next/wrapper/src/state.ts | 364 + cloud-agent-next/wrapper/src/utils.ts | 39 + cloud-agent-next/wrapper/tsconfig.json | 17 + 145 files changed, 45046 insertions(+) create mode 100644 .github/workflows/deploy-cloud-agent-next.yml create mode 100644 cloud-agent-next/.dev.vars.example create mode 100644 cloud-agent-next/.gitignore create mode 100644 cloud-agent-next/.prettierrc.json create mode 100644 cloud-agent-next/.vscode/settings.json create mode 100644 cloud-agent-next/AGENTS.md create mode 100644 cloud-agent-next/Dockerfile create mode 100644 cloud-agent-next/Dockerfile.dev create mode 100644 cloud-agent-next/README.md create mode 100755 cloud-agent-next/cloud-agent-build.sh create mode 100644 cloud-agent-next/docs/diagrams.md create mode 100644 cloud-agent-next/eslint.config.mjs create mode 100644 cloud-agent-next/package.json create mode 100644 cloud-agent-next/src/auth.ts create mode 100644 cloud-agent-next/src/balance-validation.test.ts create mode 100644 cloud-agent-next/src/balance-validation.ts create mode 100644 cloud-agent-next/src/callbacks/delivery.test.ts create mode 100644 cloud-agent-next/src/callbacks/delivery.ts create mode 100644 cloud-agent-next/src/callbacks/index.ts create mode 100644 cloud-agent-next/src/callbacks/queue-consumer.ts create mode 100644 cloud-agent-next/src/callbacks/types.ts create mode 100644 cloud-agent-next/src/constants.ts create mode 100644 cloud-agent-next/src/core/execution.test.ts create mode 100644 cloud-agent-next/src/core/execution.ts create mode 100644 cloud-agent-next/src/core/lease.test.ts create mode 100644 cloud-agent-next/src/core/lease.ts create mode 100644 cloud-agent-next/src/db/SqlStore.ts create mode 100644 cloud-agent-next/src/db/database.ts create mode 100644 cloud-agent-next/src/db/node-postgres.ts create mode 100644 cloud-agent-next/src/db/stores/PlatformIntegrationsStore.ts create mode 100644 cloud-agent-next/src/db/table.ts create mode 100644 cloud-agent-next/src/db/tables/README.md create mode 100644 cloud-agent-next/src/db/tables/events.table.ts create mode 100644 cloud-agent-next/src/db/tables/execution-leases.table.ts create mode 100644 cloud-agent-next/src/db/tables/index.ts create mode 100644 cloud-agent-next/src/db/tables/organization-memberships.table.ts create mode 100644 cloud-agent-next/src/db/tables/platform-integrations.table.ts create mode 100644 cloud-agent-next/src/execution/errors.ts create mode 100644 cloud-agent-next/src/execution/orchestrator.ts create mode 100644 cloud-agent-next/src/execution/types.ts create mode 100644 cloud-agent-next/src/helpers.ts create mode 100644 cloud-agent-next/src/index.ts create mode 100644 cloud-agent-next/src/kilo/client.test.ts create mode 100644 cloud-agent-next/src/kilo/client.ts create mode 100644 cloud-agent-next/src/kilo/errors.ts create mode 100644 cloud-agent-next/src/kilo/index.ts create mode 100644 cloud-agent-next/src/kilo/server-manager.ts create mode 100644 cloud-agent-next/src/kilo/types.ts create mode 100644 cloud-agent-next/src/kilo/utils.test.ts create mode 100644 cloud-agent-next/src/kilo/utils.ts create mode 100644 cloud-agent-next/src/kilo/wrapper-client.test.ts create mode 100644 cloud-agent-next/src/kilo/wrapper-client.ts create mode 100644 cloud-agent-next/src/kilo/wrapper-manager.ts create mode 100644 cloud-agent-next/src/lib/result.test.ts create mode 100644 cloud-agent-next/src/lib/result.ts create mode 100644 cloud-agent-next/src/logger.ts create mode 100644 cloud-agent-next/src/persistence/CloudAgentSession.ts create mode 100644 cloud-agent-next/src/persistence/migrations.ts create mode 100644 cloud-agent-next/src/persistence/model-utils.test.ts create mode 100644 cloud-agent-next/src/persistence/model-utils.ts create mode 100644 cloud-agent-next/src/persistence/schemas.test.ts create mode 100644 cloud-agent-next/src/persistence/schemas.ts create mode 100644 cloud-agent-next/src/persistence/types.ts create mode 100644 cloud-agent-next/src/router.test.ts create mode 100644 cloud-agent-next/src/router.ts create mode 100644 cloud-agent-next/src/router/auth.ts create mode 100644 cloud-agent-next/src/router/handlers/session-execution.ts create mode 100644 cloud-agent-next/src/router/handlers/session-management.ts create mode 100644 cloud-agent-next/src/router/handlers/session-prepare.ts create mode 100644 cloud-agent-next/src/router/schemas.ts create mode 100644 cloud-agent-next/src/sandbox-id.test.ts create mode 100644 cloud-agent-next/src/sandbox-id.ts create mode 100644 cloud-agent-next/src/schema.ts create mode 100644 cloud-agent-next/src/services/github-token-service.ts create mode 100644 cloud-agent-next/src/services/installation-lookup-service.ts create mode 100644 cloud-agent-next/src/session-prepare.test.ts create mode 100644 cloud-agent-next/src/session-service.test.ts create mode 100644 cloud-agent-next/src/session-service.ts create mode 100644 cloud-agent-next/src/session/ingest-handlers/branch-capture.ts create mode 100644 cloud-agent-next/src/session/ingest-handlers/execution-lifecycle.ts create mode 100644 cloud-agent-next/src/session/ingest-handlers/index.ts create mode 100644 cloud-agent-next/src/session/ingest-handlers/kilo-session-capture.ts create mode 100644 cloud-agent-next/src/session/queries/events.ts create mode 100644 cloud-agent-next/src/session/queries/executions.ts create mode 100644 cloud-agent-next/src/session/queries/index.ts create mode 100644 cloud-agent-next/src/session/queries/leases.ts create mode 100644 cloud-agent-next/src/session/types.ts create mode 100644 cloud-agent-next/src/shared/index.ts create mode 100644 cloud-agent-next/src/shared/kilo-types.ts create mode 100644 cloud-agent-next/src/shared/protocol.ts create mode 100644 cloud-agent-next/src/streaming-helpers.test.ts create mode 100644 cloud-agent-next/src/streaming-helpers.ts create mode 100644 cloud-agent-next/src/streaming.test.ts create mode 100644 cloud-agent-next/src/streaming.ts create mode 100644 cloud-agent-next/src/types.ts create mode 100644 cloud-agent-next/src/types/ids.ts create mode 100644 cloud-agent-next/src/types/index.ts create mode 100644 cloud-agent-next/src/utils/do-retry.test.ts create mode 100644 cloud-agent-next/src/utils/do-retry.ts create mode 100644 cloud-agent-next/src/utils/encryption.test.ts create mode 100644 cloud-agent-next/src/utils/encryption.ts create mode 100644 cloud-agent-next/src/utils/image-download.ts create mode 100644 cloud-agent-next/src/utils/r2-client.ts create mode 100644 cloud-agent-next/src/utils/sql-helpers.ts create mode 100644 cloud-agent-next/src/utils/table.ts create mode 100644 cloud-agent-next/src/utils/timeout.ts create mode 100644 cloud-agent-next/src/websocket/filters.test.ts create mode 100644 cloud-agent-next/src/websocket/filters.ts create mode 100644 cloud-agent-next/src/websocket/index.ts create mode 100644 cloud-agent-next/src/websocket/ingest.ts create mode 100644 cloud-agent-next/src/websocket/stream.ts create mode 100644 cloud-agent-next/src/websocket/types.ts create mode 100644 cloud-agent-next/src/workspace.test.ts create mode 100644 cloud-agent-next/src/workspace.ts create mode 100644 cloud-agent-next/test/env.d.ts create mode 100644 cloud-agent-next/test/integration/session/events.test.ts create mode 100644 cloud-agent-next/test/integration/session/leases.test.ts create mode 100644 cloud-agent-next/test/integration/session/start-execution-v2.test.ts create mode 100644 cloud-agent-next/test/test-worker.ts create mode 100644 cloud-agent-next/test/tsconfig.json create mode 100644 cloud-agent-next/test/unit/execution/orchestrator.test.ts create mode 100644 cloud-agent-next/test/unit/wrapper/lifecycle.test.ts create mode 100644 cloud-agent-next/test/unit/wrapper/state.test.ts create mode 100644 cloud-agent-next/tsconfig.json create mode 100755 cloud-agent-next/utils/get-session-logs.sh create mode 100644 cloud-agent-next/vitest.config.ts create mode 100644 cloud-agent-next/vitest.workers.config.ts create mode 100644 cloud-agent-next/worker-configuration.d.ts create mode 100644 cloud-agent-next/wrangler.jsonc create mode 100644 cloud-agent-next/wrangler.test.jsonc create mode 100644 cloud-agent-next/wrapper/build.ts create mode 100644 cloud-agent-next/wrapper/package.json create mode 100644 cloud-agent-next/wrapper/src/auto-commit.ts create mode 100644 cloud-agent-next/wrapper/src/condense-on-complete.ts create mode 100644 cloud-agent-next/wrapper/src/connection.ts create mode 100644 cloud-agent-next/wrapper/src/event-parser.ts create mode 100644 cloud-agent-next/wrapper/src/kilo-client.ts create mode 100644 cloud-agent-next/wrapper/src/kilocode-runner.ts create mode 100644 cloud-agent-next/wrapper/src/lifecycle.ts create mode 100644 cloud-agent-next/wrapper/src/main.ts create mode 100644 cloud-agent-next/wrapper/src/server.ts create mode 100644 cloud-agent-next/wrapper/src/sse-consumer.ts create mode 100644 cloud-agent-next/wrapper/src/state.ts create mode 100644 cloud-agent-next/wrapper/src/utils.ts create mode 100644 cloud-agent-next/wrapper/tsconfig.json diff --git a/.github/workflows/deploy-cloud-agent-next.yml b/.github/workflows/deploy-cloud-agent-next.yml new file mode 100644 index 0000000000..a3deb0167b --- /dev/null +++ b/.github/workflows/deploy-cloud-agent-next.yml @@ -0,0 +1,35 @@ +name: Deploy Cloud Agent Next + +on: + workflow_dispatch: + workflow_call: + +jobs: + deploy: + runs-on: ubuntu-latest + name: Deploy Cloud Agent Next + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: latest + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install dependencies + working-directory: cloud-agent-next + run: pnpm install --frozen-lockfile + + - name: Deploy to Cloudflare Workers + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + workingDirectory: cloud-agent-next + command: deploy diff --git a/cloud-agent-next/.dev.vars.example b/cloud-agent-next/.dev.vars.example new file mode 100644 index 0000000000..8e8717d78f --- /dev/null +++ b/cloud-agent-next/.dev.vars.example @@ -0,0 +1,72 @@ +# Shared secret for JWT token validation (same as NextAuth.js secret) +NEXTAUTH_SECRET=your-nextauth-secret-here + +# Shared secret for internal API calls, same as .env.development. +INTERNAL_API_SECRET=your-internal-api-secret-here + +# Optional: Override Kilocode credentials in session environment variables used during kilocode invocation +# These do NOT affect authentication - authentication always uses the original token from the API request +# Use these to inject test/dev credentials while maintaining proper authentication with credentials sent +# Affects: +# - Session environment variables (KILOCODE_TOKEN and KILOCODE_ORG_ID) +# +# Note: With the introduction of server backed sessionsthese should probably not be used any more, +# you should set KILOCODE_BACKEND_BASE_URL instead and use local dev only. +#KILOCODE_TOKEN_OVERRIDE=your-override-token-here +#KILOCODE_ORG_ID_OVERRIDE=your-override-org-id-here + +# Kilocode backend base URL and KILO_OPENROUTER_BASE for API calls and session environment variables +# For local development, point to your local kilocode-backend +# Note: you wanna use your actual privatenet address here and not "localhost" +# pnpm run dev of kilocode-backend usually gives you both addrs on boot. +KILOCODE_BACKEND_BASE_URL=http://192.168.200.70:3000 +KILO_OPENROUTER_BASE=http://192.168.200.70:3000/api + +# Worker base URL used by the wrapper to connect to /ingest. +# Use a host-reachable IP (not localhost) so sandbox containers can connect back. +WORKER_URL=http://192.168.200.72:8794 + +# Timeout overrides (optional) +WRAPPER_IDLE_TIMEOUT_MS=120000 +CLI_TIMEOUT_SECONDS=700 +REAPER_INTERVAL_MS=300000 +STALE_THRESHOLD_MS=600000 +PENDING_START_TIMEOUT_MS=300000 + +# GitHub App credentials for git commit attribution +# These identify commits as coming from the Kilo Code GitHub App +# Format for git config: +# user.name: {GITHUB_APP_SLUG}[bot] +# user.email: {GITHUB_APP_BOT_USER_ID}+{GITHUB_APP_SLUG}[bot]@users.noreply.github.com +GITHUB_APP_SLUG=kiloconnect-development +GITHUB_APP_BOT_USER_ID=242397087 + +# GitHub App credentials for generating installation access tokens +# Used by GitHubTokenService to authenticate with GitHub API on behalf of app installations +# GITHUB_APP_ID: The numeric App ID from GitHub App settings +# GITHUB_APP_PRIVATE_KEY: The raw PKCS#8 private key (use \n for newlines in env var) +# Note that the nextjs app uses PKCS#1 but the worker uses WebCrypto and requires a PKCS#8 +GITHUB_APP_ID=2245043 +GITHUB_APP_PRIVATE_KEY= + +# GitHub Lite App credentials (for OSS organizations with read-only permissions) +# Same format as standard app credentials above +GITHUB_LITE_APP_ID= +GITHUB_LITE_APP_PRIVATE_KEY= + +# Agent Environment Profile Secrets Decryption +# Used to decrypt encrypted secrets from agent environment profiles at session execution time. +# This is a RSA private key that pairs with AGENT_ENV_VARS_PUBLIC_KEY in the backend. +# +# The development private key is available in 1pass as 'Agent Env Vars Profile Private key' +AGENT_ENV_VARS_PRIVATE_KEY="" + +# +# R2 Attachments Bucket Readonly Access +# 1pass (Cloudflare - R2 Cloud Agents Attachments Read Only) +R2_ENDPOINT="" +R2_ATTACHMENTS_READONLY_ACCESS_KEY_ID="" +R2_ATTACHMENTS_READONLY_SECRET_ACCESS_KEY="" + +# Local dev worker origins allowed to connect to /stream +WS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8794 diff --git a/cloud-agent-next/.gitignore b/cloud-agent-next/.gitignore new file mode 100644 index 0000000000..2ba65cb39c --- /dev/null +++ b/cloud-agent-next/.gitignore @@ -0,0 +1,7 @@ +.wrangler/ +.env* +wrapper/dist/ +!.env.example +.dev.vars* +!.dev.vars.example +kilocode-cli.tgz diff --git a/cloud-agent-next/.prettierrc.json b/cloud-agent-next/.prettierrc.json new file mode 100644 index 0000000000..4fed1289df --- /dev/null +++ b/cloud-agent-next/.prettierrc.json @@ -0,0 +1,11 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "useTabs": false, + "printWidth": 100, + "trailingComma": "es5", + "bracketSpacing": true, + "arrowParens": "avoid", + "endOfLine": "lf" +} diff --git a/cloud-agent-next/.vscode/settings.json b/cloud-agent-next/.vscode/settings.json new file mode 100644 index 0000000000..9466963fbe --- /dev/null +++ b/cloud-agent-next/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "files.associations": { + "wrangler.json": "jsonc" + } +} diff --git a/cloud-agent-next/AGENTS.md b/cloud-agent-next/AGENTS.md new file mode 100644 index 0000000000..ebf02f0d4c --- /dev/null +++ b/cloud-agent-next/AGENTS.md @@ -0,0 +1,103 @@ +# AGENTS.md + +This file provides guidance to AI coding agents working in this repository. + +## Project Overview + +Cloudflare Worker that powers Kilocode Cloud Agents. It exposes a tRPC API for session preparation and execution, streams output over WebSockets, and runs the Kilocode CLI inside Cloudflare Sandbox containers. Durable Objects track sessions; Hyperdrive is used for Postgres lookups (for example, GitHub App installation IDs). The wrapper in `wrapper/` is a core component that brokers Kilocode CLI events into the worker’s `/ingest` WebSocket and handles job lifecycle. + +## Development Commands + +### Package Management + +- Use pnpm (enforced by preinstall). Never use npm or yarn. +- `pnpm install` - Install dependencies + +### Wrapper Build + +- `pnpm run build:wrapper` - Build wrapper bundle (uses Bun in `wrapper/`) + +### Testing + +- `pnpm run test` - Unit tests (Vitest Node) +- `pnpm run test:integration` - Integration tests in Workers runtime (Miniflare) +- `pnpm run test:all` - Unit + integration + +### Code Quality + +- `pnpm run lint` - ESLint +- `pnpm run lint:fix` - ESLint with auto-fix +- `pnpm run format` - Prettier write (src only) +- `pnpm run format:check` - Prettier check (src only) +- `pnpm run typecheck` - TypeScript (tsgo) + wrapper typecheck + +### Deployment + +- DO NOT attempt to deploy directly. Always defer to the user. + +## Architecture Overview + +### Core Worker + +- `src/index.ts` - Entry point, request routing +- `src/router/` - tRPC router and handlers +- `src/session-service.ts` - Session lifecycle orchestration +- `src/workspace.ts` - Workspace setup and git operations +- `src/streaming.ts` - WebSocket streaming + +### Durable Objects + +- `src/persistence/CloudAgentSession.ts` - Session DO storage + lifecycle +- `src/db/` - SQLite table definitions and store helpers for DOs + +### Sandbox + Execution + +- `src/execution/` - Orchestrator and execution lifecycle +- `src/kilo/` - Kilocode CLI wrapper client and helpers +- `Dockerfile` - Production sandbox image +- `Dockerfile.dev` - Dev sandbox image (local Kilocode CLI) +- `cloud-agent-build.sh` - Builds local Kilocode CLI binary for `Dockerfile.dev` + +### Wrapper + +- `wrapper/` - Local wrapper bundled into the sandbox image +- `wrapper/src/main.ts` - Wrapper entrypoint +- `src/shared/kilo-types.ts` - Types are a subset copied from `~/kilo/packages/sdk/js/src/v2/gen/types.gen.ts` (kilo repo, generated SDK); keep in sync when wrapper/Kilo API changes + +### Configuration + +- `wrangler.jsonc` - Worker config, bindings, environments +- `.dev.vars.example` - Local dev env template +- `worker-configuration.d.ts` - Auto-generated types. Do not edit; regenerate with `pnpm run types`. + +## Development Guidelines + +### Code Style + +- Keep streaming payloads and schemas aligned with `src/shared/protocol.ts` + +### Runtime Guidelines + +- Durable Object calls should be retried using `withDoRetry` in `src/utils/do-retry.ts` +- Execute commands inside a session context (use `session.exec(...)`, not `sandbox.exec(...)`) + +### Testing Standards + +- Unit tests: `src/**/*.test.ts` (Vitest Node) +- Integration tests: `test/**/*.test.ts` (Workers runtime) +- Use `vitest.workers.config.ts` for Workers runtime tests + +### Git Workflow + +- Create feature branches; do not commit on main + +## Key Locations + +- `src/router/handlers/` - API endpoints (prepare, initiate, sendMessage, session management) +- `src/persistence/` - Durable Object schema + migrations +- `src/websocket/` - WebSocket ingest + filters +- `src/utils/` - Shared helpers (encryption, retries, SQL helpers) +- `wrangler.jsonc` - Bindings: R2, Hyperdrive, KV, queues, containers +- `vitest.config.ts` - Unit test config +- `vitest.workers.config.ts` - Integration test config +- `wrapper/` - Wrapper build shipped into the sandbox diff --git a/cloud-agent-next/Dockerfile b/cloud-agent-next/Dockerfile new file mode 100644 index 0000000000..d04b951661 --- /dev/null +++ b/cloud-agent-next/Dockerfile @@ -0,0 +1,49 @@ +FROM docker.io/cloudflare/sandbox:0.6.7 + +# Build arguments for metadata (all optional with defaults) +ARG BUILD_DATE="" +ARG VCS_REF="" +ARG KILOCODE_CLI_VERSION="latest" + +RUN mkdir -p -m 755 /etc/apt/keyrings \ + && out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + && cat $out | tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \ + && chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \ + && mkdir -p -m 755 /etc/apt/sources.list.d \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ + && apt update \ + && apt install gh -y + +# Install GitLab CLI (glab) - download official .deb from GitLab releases +RUN GLAB_VERSION="1.80.4" \ + && wget -nv -O /tmp/glab.deb "https://gitlab.com/gitlab-org/cli/-/releases/v${GLAB_VERSION}/downloads/glab_${GLAB_VERSION}_linux_amd64.deb" \ + && dpkg -i /tmp/glab.deb \ + && rm /tmp/glab.deb + +# Generate locales to suppress setlocale warnings +RUN apt-get update && apt-get install -y --no-install-recommends locales && \ + sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && \ + locale-gen && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* +ENV LC_ALL=en_US.UTF-8 +ENV LANG=en_US.UTF-8 + +# Install dependencies globally (accessible to all users) +RUN npm install -g pnpm @kilocode/cli@${KILOCODE_CLI_VERSION} + +# === Build wrapper bundle inside container === +# This ensures the wrapper is built with the same Bun version that will run it, +# avoiding any compatibility issues between build and runtime environments. +# The wrapper imports from ../../src/shared/protocol.js, so we maintain that structure. +COPY wrapper /tmp/wrapper-build/wrapper +COPY src/shared /tmp/wrapper-build/src/shared + +# Build the wrapper bundle +RUN cd /tmp/wrapper-build/wrapper && \ + bun build src/main.ts --outfile=/usr/local/bin/kilocode-wrapper.js --target=bun --minify && \ + rm -rf /tmp/wrapper-build + +# DO NOT override USER, WORKDIR, or ENTRYPOINT from the base image +# The Cloudflare sandbox base image has its own startup.sh script that must run +# Installing packages globally makes them accessible regardless of the user diff --git a/cloud-agent-next/Dockerfile.dev b/cloud-agent-next/Dockerfile.dev new file mode 100644 index 0000000000..2a827c7d9a --- /dev/null +++ b/cloud-agent-next/Dockerfile.dev @@ -0,0 +1,49 @@ +FROM docker.io/cloudflare/sandbox:0.6.7 + +# Build arguments for metadata (all optional with defaults) +ARG BUILD_DATE="" +ARG VCS_REF="" +ARG KILOCODE_CLI_VERSION="next" + +# Build the kilo binary: +# cd ~/projects/kilocode-backend/cloud-agent +# ./cloud-agent-build.sh +# +# This builds kilo-cli from source and copies the linux-x64 binary here. + +# Install ripgrep and Generate locales to suppress setlocale warnings +RUN apt-get update && apt-get install -y --no-install-recommends ripgrep locales && \ + sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && \ + locale-gen && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* +ENV LC_ALL=en_US.UTF-8 +ENV LANG=en_US.UTF-8 + + +# Install GitLab CLI (glab) - download official .deb from GitLab releases +RUN GLAB_VERSION="1.80.4" \ + && wget -nv -O /tmp/glab.deb "https://gitlab.com/gitlab-org/cli/-/releases/v${GLAB_VERSION}/downloads/glab_${GLAB_VERSION}_linux_amd64.deb" \ + && dpkg -i /tmp/glab.deb \ + && rm /tmp/glab.deb + +# Install pnpm and kilocode +RUN npm install -g pnpm @kilocode/cli@${KILOCODE_CLI_VERSION} + +# Copy to install pre-built kilo binary (built via ./cloud-agent-build.sh) +#COPY kilo /usr/local/bin/kilo +#RUN chmod +x /usr/local/bin/kilo + +# === Build wrapper bundle inside container === +# This ensures the wrapper is built with the same Bun version that will run it, +# avoiding any compatibility issues between build and runtime environments. +COPY wrapper /tmp/wrapper-build/wrapper +COPY src/shared /tmp/wrapper-build/src/shared + +RUN cd /tmp/wrapper-build/wrapper && \ + bun build src/main.ts --outfile=/usr/local/bin/kilocode-wrapper.js --target=bun --minify && \ + rm -rf /tmp/wrapper-build + +# DO NOT override USER, WORKDIR, or ENTRYPOINT from the base image +# The Cloudflare sandbox base image has its own startup.sh script that must run +# Installing packages globally makes them accessible regardless of the user diff --git a/cloud-agent-next/README.md b/cloud-agent-next/README.md new file mode 100644 index 0000000000..fea4abeb86 --- /dev/null +++ b/cloud-agent-next/README.md @@ -0,0 +1,852 @@ +# Kilocode Cloud Worker + +A Cloudflare Worker that provides a secure, scalable API for running [Kilocode](https://kilo.ai) AI coding tasks in isolated sandbox environments with GitHub integration. + +## Development Setup + +### Building the Development Docker Image + +The `Dockerfile.dev` is used for local development and testing. It requires a pre-built `kilo` binary from the [kilo-cli](https://github.com/kilocode/kilo-cli) repository. + +**Prerequisites:** + +- [Bun](https://bun.sh) 1.3+ +- Docker +- Clone of the kilo-cli repository + +**Build the kilo binary and Docker image:** + +```bash +# Set the path to your kilo-cli checkout (required) +export KILO_CLI_DIR=/path/to/kilo-cli + +# From the cloud-agent directory +./cloud-agent-build.sh + +# Use it +pnpm run dev +``` + +The `cloud-agent-build.sh` script: + +1. Builds kilo-cli from source (all targets including linux-x64) +2. Copies the `linux-x64` binary to `./kilo` + +By default, the script looks for kilo-cli at `$HOME/projects/kilo-cli`. Override with `KILO_CLI_DIR` environment variable. + +**What's in Dockerfile.dev:** + +- Base image: `cloudflare/sandbox:0.6.7` +- Pre-built `kilo` binary (from `cloud-agent-build.sh`) +- GitHub CLI (`gh`) and GitLab CLI (`glab`) +- Wrapper bundle built inside the container + +## API Documentation + +### Overview + +The recommended V2 flow is: + +1. **Prepare Session** - Pre-create session with all configuration (supports prompts up to 100KB) +2. **Initiate Prepared Session (V2)** - Start execution using stored configuration (ack + WebSocket) +3. **Send Messages (V2)** - Queue follow-up messages to the same session + +**Endpoints:** + +- `prepareSession` - Create a fully prepared session with workspace, git clone, and configuration +- `updateSession` - Update a prepared (not yet initiated) session +- `getSession` - Query session metadata (no secrets) +- `initiateFromKilocodeSessionV2` - Start execution on a prepared session +- `sendMessageV2` - Send follow-up messages (output via `/stream` WebSocket) +- `deleteSession` - Delete a session and clean up resources +- `interruptSession` - Interrupt running execution +- `getWrapperLogs` - Get wrapper logs for debugging +- `health` - Health check endpoint + +### Authentication + +All endpoints require a kilocode api token except `/stream` which uses short lived ws tickets. + +## Usage Examples + +### TypeScript Client (Recommended) + +```typescript +import { createTRPCClient, httpBatchLink } from '@trpc/client'; +import type { AppRouter } from './router'; + +const client = createTRPCClient({ + links: [ + httpBatchLink({ + url: 'https://your-worker.dev/trpc', + headers: { + Authorization: `Bearer ${KILOCODE_TOKEN}`, + }, + }), + ], +}); + +// 1) Prepare a session first (backend-to-backend) +const prepared = await client.prepareSession.mutate({ + githubRepo: 'facebook/react', + kilocodeOrganizationId: 'your-org-id-here', + prompt: 'Analyze the project structure', + mode: 'architect', + model: 'gpt-4o-mini', +}); + +// 2) Initiate the prepared session via V2 +const ack = await client.initiateFromKilocodeSessionV2.mutate({ + cloudAgentSessionId: prepared.cloudAgentSessionId, +}); + +// 3) Obtain a stream ticket (short-lived JWT for WebSocket auth) +// Option A: If using Next.js prepareSession API, ticket is included in response +// Option B: Call /stream-ticket endpoint with cloudAgentSessionId +const ticketResponse = await fetch( + 'https://your-backend.com/api/cloud-agent/sessions/stream-ticket', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${USER_SESSION_TOKEN}`, + }, + body: JSON.stringify({ cloudAgentSessionId: ack.cloudAgentSessionId }), + } +); +const { ticket } = await ticketResponse.json(); + +// 4) Connect to /stream with ticket query parameter +const ws = new WebSocket(`wss://your-worker.dev${ack.streamUrl}&ticket=${ticket}`); +ws.onmessage = event => { + const payload = JSON.parse(event.data); + console.log('Stream event:', payload.streamEventType, payload.data); +}; + +// 5) Send follow-up messages +await client.sendMessageV2.mutate({ + cloudAgentSessionId: ack.cloudAgentSessionId, + prompt: 'Implement the improvements', + mode: 'code', + model: 'gpt-4o-mini', +}); +``` + +### Advanced Configuration + +Customize the sandbox environment with environment variables, setup commands, and MCP servers. All configurations are set during `prepareSession` and persist across the session lifecycle. + +```typescript +const prepared = await client.prepareSession.mutate({ + githubRepo: 'pandemicsyn/velocillama.com', + kilocodeOrganizationId: '9d278969-5453-4ae3-a51f-a8d2274a7b56', // Optional: omit for personal accounts + prompt: 'Check if the repo has any open pull requests using the GitHub MCP', + mode: 'code', + model: 'anthropic/claude-sonnet-4.5', + + // Optional: Checkout a specific upstream branch instead of creating session/ + upstreamBranch: 'develop', + + // Environment variables - available to all commands and CLI executions + envVars: { + SPECIAL_KEY: 'my-name-is-jeff', + GITHUB_PAT: 'github_pat_11adfadfsomestuff', + }, + + // Setup commands - run during init and on cold starts (after reclone) + setupCommands: ['npm install', 'npm install -g some-tool'], + + // MCP servers - supports stdio (local), sse, and streamable-http transports + mcpServers: { + github: { + type: 'streamable-http', + url: 'https://api.githubcopilot.com/mcp/', + headers: { + Authorization: 'Bearer ${env:GITHUB_PAT}', + }, + timeout: 120, + }, + }, +}); + +await client.initiateFromKilocodeSessionV2.mutate({ + cloudAgentSessionId: prepared.cloudAgentSessionId, +}); +``` + +**Configuration Details:** + +- **upstreamBranch**: Specify an existing branch to work on (optional) + - Default: Creates and uses `session/` branch + - Upstream branch behavior: Must exist remotely, fetch + checkout only (no automatic pull between invocations) + - Session branch behavior: Tries remote first, creates fresh if not found, lenient pull + - Validation: Git-compatible names (alphanumeric, dots, dashes, underscores, slashes) + - Use cases: Working on `main`, `develop`, or existing feature branches + - Note: Cold starts trigger the initial fetch + checkout from a clean state +- **envVars**: Injected into session environment, persist across sandbox restarts + - Limits: Max 50 variables, keys/values max 256 chars +- **encryptedSecrets**: Encrypted environment variables for sensitive values (backend-to-backend only) + - Format: RSA+AES envelope encryption per secret (see below) + - Behavior: Decrypted just-in-time when injecting into CLI environment + - Security: Never stored unencrypted; requires `AGENT_ENV_VARS_PRIVATE_KEY` worker secret +- **setupCommands**: Run in workspace directory with access to env vars + - Behavior: Fail-fast on `initiate` (returns 422 with `sessionId`), lenient on `resume` + - Execution: Only re-run on cold starts (when repo is recloned) + - Limits: Max 20 commands (500 chars each), 2-minute timeout per command +- **mcpServers**: Written to `.kilocode/cli/global/settings/mcp_settings.json` + - Types: `stdio` (local process), `sse` (Server-Sent Events), `streamable-http` + - Limits: Max 20 servers + +**Lifecycle:** + +1. Session initiation → Config stored in Durable Object +2. Session resume → Config restored; setup/MCP only re-applied if repo was recloned +3. Environment variables → Always injected into every execution + +### Encrypted Secrets (Backend-to-Backend) + +For sensitive environment variables like API keys and tokens, the `encryptedSecrets` field provides end-to-end encryption. Secrets are encrypted by the backend before being sent to the cloud-agent worker, stored encrypted in the Durable Object, and only decrypted at the moment they're injected into the CLI process environment. + +**Envelope Format:** + +Each secret uses RSA+AES envelope encryption: + +```typescript +{ + encryptedData: string; // Base64-encoded: IV (16 bytes) + ciphertext + authTag (16 bytes) + encryptedDEK: string; // Base64-encoded RSA-OAEP encrypted data encryption key + algorithm: 'rsa-aes-256-gcm'; + version: 1; +} +``` + +**Encryption Flow:** + +1. **Backend encrypts** each secret value: + - Generate random 256-bit DEK (data encryption key) + - Encrypt secret with AES-256-GCM using the DEK + - Encrypt DEK with RSA-OAEP (SHA-256) using the worker's public key + - Package as envelope with `encryptedData` and `encryptedDEK` + +2. **Worker stores** encrypted envelopes in Durable Object metadata + +3. **At execution time**, worker decrypts: + - Decrypt DEK using `AGENT_ENV_VARS_PRIVATE_KEY` (RSA private key) + - Decrypt secret using DEK with AES-256-GCM + - Merge decrypted secrets with `envVars` into CLI environment + +**Worker Configuration:** + +The worker requires the `AGENT_ENV_VARS_PRIVATE_KEY` secret to decrypt secrets: + +```bash +# Generate RSA key pair (if not already done) +openssl genrsa -out private.pem 2048 +openssl rsa -in private.pem -pubout -out public.pem + +# Set the private key as a worker secret +wrangler secret put AGENT_ENV_VARS_PRIVATE_KEY < private.pem +``` + +**Security Properties:** + +- Secrets are **never stored unencrypted** in the Durable Object +- Decryption happens **just-in-time** when starting CLI process +- Each secret has its own DEK (no key reuse across secrets) +- AES-GCM provides authenticated encryption (integrity + confidentiality) + +### Session Preparation (Backend-to-Backend) + +The preparation endpoints enable pre-creating sessions before execution starts. This pattern is useful for: + +- Large prompts (up to 100KB, avoiding URL size limits) +- Showing "pending" sessions in UI before execution +- Updating session configuration between creation and execution + +**Authentication:** These endpoints require dual authentication: + +- `x-internal-api-key` header matching `INTERNAL_API_SECRET` env var (server-to-server trust) +- Standard customer token via `Authorization: Bearer ` (user identity) + +#### `prepareSession` + +Creates a session in "prepared" state with all configuration stored in the Durable Object. Note that **you probably don't want to call this directly** SERIOUSLY - and instead call kilocode-backends prepare-session endpoint which will call this endpoint filled out appropriately. + +**Example:** + +```typescript +const result = await client.prepareSession.mutate({ + prompt: 'Your task description here...', // Up to 100KB + mode: 'code', + model: 'anthropic/claude-sonnet-4.5', + githubRepo: 'facebook/react', + kilocodeOrganizationId: 'your-org-id', // Optional + envVars: { API_KEY: 'secret' }, // Optional + setupCommands: ['npm install'], // Optional + mcpServers: { + /* ... */ + }, // Optional + upstreamBranch: 'main', // Optional + autoCommit: true, // Optional + callbackTarget: { + type: 'http', + url: 'https://example.com/callbacks/cloud-agent', + }, // Optional +}); + +console.log('Session prepared:', result.cloudAgentSessionId); +// cloudAgentSessionId: 'agent_123e4567-e89b-12d3-a456-426614174000' +``` + +**Input:** + +- `prompt` (required): Task prompt (max 100KB) +- `mode` (required): Agent mode (`code`, `architect`, `ask`, `debug`, `orchestrator`) +- `model` (required): AI model identifier +- `githubRepo` or `gitUrl` (required): Repository to work on +- `githubToken` or `gitToken` (optional, deprecated): Pre-generated authentication token for private repos +- `kilocodeOrganizationId` (optional): Organization ID +- `envVars` (optional): Environment variables (max 50, keys/values max 256 chars) +- `encryptedSecrets` (optional): Encrypted environment variables for sensitive values (see below) +- `setupCommands` (optional): Setup commands (max 20, each max 500 chars) +- `mcpServers` (optional): MCP server configurations (max 20) +- `upstreamBranch` (optional): Existing branch to checkout before execution +- `autoCommit` (optional): Auto-commit and push changes after execution +- `callbackTarget` (optional): Callback configuration for execution completion + +**Output:** + +- `cloudAgentSessionId`: Generated session identifier for later initiation +- `kiloSessionId`: Generated Kilo CLI session ID + +#### `updateSession` + +Updates a prepared (but not yet initiated) session. + +**Example:** + +```typescript +await client.updateSession.mutate({ + cloudAgentSessionId: 'agent_123e4567-e89b-12d3-a456-426614174000', + mode: 'architect', // Update mode + envVars: { NEW_VAR: 'value' }, // Add/update env vars + // Or clear fields: + githubToken: null, // Clear token + setupCommands: [], // Clear all setup commands +}); +``` + +**Input:** + +- `cloudAgentSessionId` (required): Session ID from `prepareSession` +- `mode` (optional): Update mode (`null` to clear, `undefined` to skip) +- `model` (optional): Update model (`null` to clear, `undefined` to skip) +- `githubToken` (optional): Update GitHub token (`null` to clear) +- `gitToken` (optional): Update git token (`null` to clear) +- `autoCommit` (optional): Update auto-commit setting (`null` to clear) +- `envVars` (optional): Update environment variables (`{}` to clear all) +- `setupCommands` (optional): Update setup commands (`[]` to clear all) +- `mcpServers` (optional): Update MCP servers (`{}` to clear all) + +**Update semantics:** + +- `undefined`: Field is not changed +- `null`: Scalar field is cleared +- `{}` or `[]`: Collection is cleared +- Non-empty value: Field is updated + +**State machine:** + +- Only works on prepared sessions (after `prepareSession`, before initiation) +- Returns error if session hasn't been prepared or has already been initiated + +### V2 Endpoints + +V2 endpoints execute directly and return an immediate ack. Output is delivered via the +read-only `/stream` WebSocket for live updates and replay. + +**Ack shape (all V2 mutations):** + +```ts +{ + cloudAgentSessionId, + executionId, + status: 'started', + streamUrl: `/stream?cloudAgentSessionId=${cloudAgentSessionId}` +} +``` + +**Error responses:** + +- `409 Conflict`: Another execution is already in progress (includes `activeExecutionId`) +- `503 Service Unavailable`: Transient error (sandbox connect, workspace setup, etc.) - client should retry + +**Endpoints:** + +- `initiateFromKilocodeSessionV2`: Prepared-session only (expects `cloudAgentSessionId`). +- `sendMessageV2`: Follow-up messages (expects `cloudAgentSessionId`). + +**Streaming output:** + +- Obtain a stream ticket via `/stream-ticket` endpoint (or from Next.js `prepareSession` API response) +- Connect to `ws://.../stream?cloudAgentSessionId=...&ticket=` with the ticket as a query parameter +- Optional replay: `fromId=` to resume from the last seen event + +### Stream Event Types + +All streaming events use the `streamEventType` discriminator field for type-safe event handling. The API emits: + +**1. `streamEventType: 'kilocode'` - Kilocode CLI Events** + +Wraps JSON events from the Kilocode CLI, preserving all original fields: + +```typescript +{ + streamEventType: 'kilocode', + payload: { + type: 'tool_use', // CLI event type + tool: 'read_file', // Tool name + input: { path: 'test.ts' } // Tool arguments + }, + sessionId?: 'agent_123e4567-e89b-12d3-a456-426614174000' // Optional session binding +} +``` + +Common `payload.type` values: + +- `'tool_use'` - Tool execution (read_file, write_to_file, execute_command, etc.) +- `'progress'` - Step-by-step progress updates +- `'status'` - Status messages from Kilocode +- See [Kilocode CLI documentation](https://github.com/kilocode/kilocode) for complete event types + +**2. `streamEventType: 'status'` - System Status** + +Infrastructure status messages (initialization, branch operations, configuration): + +```typescript +{ + streamEventType: 'status', + message: 'Cloning repository facebook/react...', + timestamp: '2025-11-03T22:00:00.000Z', + sessionId?: 'agent_123e4567-e89b-12d3-a456-426614174000' +} +``` + +**3. `streamEventType: 'output'` - System Output** + +Non-JSON stdout/stderr from CLI execution (ANSI escape sequences automatically stripped): + +```typescript +{ + streamEventType: 'output', + content: 'Installing dependencies...', + source: 'stdout' | 'stderr', + timestamp: '2025-11-03T22:00:00.000Z', + sessionId?: 'agent_123e4567-e89b-12d3-a456-426614174000' +} +``` + +**4. `streamEventType: 'error'` - System Errors** + +System-level error events (exit codes, stream failures): + +```typescript +{ + streamEventType: 'error', + error: 'CLI exited with code 1', + details?: any, // Optional error context + timestamp: '2025-11-03T22:00:00.000Z', + sessionId?: 'agent_123e4567-e89b-12d3-a456-426614174000' +} +``` + +**5. `streamEventType: 'complete'` - Task Completion** + +Final event with execution results (no task history included in streaming): + +```typescript +{ + streamEventType: 'complete', + taskId: 'task_abc123', + sessionId: 'agent_123e4567-e89b-12d3-a456-426614174000', // Always present + exitCode: 0, + metadata: { + executionTimeMs: 45230, + workspace: '/workspace/org-id/user-id/sessions/agent_123e4567-e89b-12d3-a456-426614174000', + userId: 'user-id', + startedAt: '2025-11-03T22:00:00.000Z', + completedAt: '2025-11-03T22:00:45.230Z' + } +} +``` + +### Session Management + +Endpoints for querying and managing session state without streaming. + +#### `getSession` + +Retrieves session metadata without secrets. This query enables the frontend to check session state before initiating, supporting idempotent session execution where page refreshes don't restart already-initiated sessions. + +**Type:** Query (not mutation) + +**Authentication:** Standard customer token only (`protectedProcedure`) — no backend key required. + +**Example:** + +```typescript +// Check if session has already been initiated +const session = await client.getSession.query({ + cloudAgentSessionId: 'agent_123e4567-e89b-12d3-a456-426614174000', +}); + +if (session.initiatedAt) { + console.log('Session already initiated at:', new Date(session.initiatedAt)); + // Connect to existing session instead of re-initiating +} else if (session.preparedAt) { + console.log('Session prepared but not initiated, safe to start'); +} +``` + +**Input:** + +- `cloudAgentSessionId` (required): Session ID to query + +**Output (sanitized — no secrets returned):** + +```typescript +{ + // Session identifiers + sessionId: string; // Cloud-agent session ID + kiloSessionId?: string; // Linked Kilocode CLI session UUID + userId: string; // Owner user ID + orgId?: string; // Organization ID (if org account) + + // Repository info (no tokens) + githubRepo?: string; // e.g., 'facebook/react' + gitUrl?: string; // Raw git URL (without credentials) + + // Execution params + prompt?: string; // Task prompt + mode?: 'architect' | 'code' | 'ask' | 'debug' | 'orchestrator'; + model?: string; // AI model identifier + autoCommit?: boolean; // Auto-commit setting + upstreamBranch?: string; // Branch being worked on + + // Configuration metadata (counts only, no values) + envVarCount?: number; // Number of configured env vars + setupCommandCount?: number; // Number of setup commands + mcpServerCount?: number; // Number of MCP servers + + // Lifecycle timestamps (critical for idempotency) + preparedAt?: number; // Unix timestamp when prepared + initiatedAt?: number; // Unix timestamp when initiated + + // Versioning + timestamp: number; // Last update timestamp + version: number; // Optimistic concurrency version +} +``` + +**Error Cases:** + +- `NOT_FOUND`: Session not found (either doesn't exist or belongs to different user) + +**Security Notes:** + +- Returns metadata only — no secrets (tokens, env var values, etc.) +- Configuration counts (envVarCount, setupCommandCount, mcpServerCount) instead of actual values +- Enforces user ownership — cannot query other users' sessions + +**Use Cases:** + +- **Idempotent initiation**: Check `initiatedAt` before calling `initiateFromKilocodeSessionV2` +- **UI state sync**: Display session status after page refresh +- **Progress tracking**: Show preparation vs initiation state in UI + +#### `interruptSession` + +Kills all running Kilocode processes associated with a session. The session remains active and can be used for subsequent tasks. + +**Example:** + +```typescript +// Interrupt a running session +const result = await client.interruptSession.mutate({ + sessionId: 'agent_123e4567-e89b-12d3-a456-426614174000', +}); + +console.log('Interrupt result:', result); +// { +// success: true, +// killedProcessIds: ['proc_123', 'proc_456'], +// failedProcessIds: [], +// message: 'Interrupted execution: killed 2 process(es)' +// } +``` + +**Response:** + +```typescript +interface InterruptResult { + success: boolean; + killedProcessIds: string[]; // IDs of successfully killed processes + failedProcessIds: string[]; // IDs of processes that failed to kill + message: string; // Human-readable summary +} +``` + +**Notes:** + +- Idempotent: Safe to call multiple times; returns success even if no processes are running +- Non-destructive: Session remains active and can continue to be used +- Process identification: Only kills processes with `kilocode` in their command and matching workspace path +- Use case: Cancel stuck operations, stop unintended long-running tasks, or reset session state +- Streaming behavior: Interrupts emit `streamEventType: 'interrupted'` and short-circuit post-exec steps (auto-commit, task discovery). A `complete` event with `taskId` is not sent when an execution is interrupted. + +## Development + +### Prerequisites + +- Cloudflare Workers CLI (`wrangler`) + +### Setup + +1. Install dependencies: + +```bash +pnpm install +``` + +2. Configure environment variables: + +```bash +cp .dev.vars.example .dev.vars +# Edit .dev.vars with your credentials +# The worker will reuse the Authorization bearer token as the kilo code api token (because thats what it is). +``` + +3. Run locally: + +```bash +pnpm --filter cloud-agent +``` + +### Local Development with Dockerfile.dev + +For development with a local/custom Kilocode CLI build, use the `dev` environment which uses `Dockerfile.dev` to create a sandbox image with your CLI changes. + +#### Prerequisites + +1. Clone and build the Kilocode CLI from the [kilocode repository](https://github.com/Kilo-Org/kilocode): + +```bash +# In the kilocode repo directory +cd kilocode +pnpm install +pnpm cli:bundle +cd cli && npm pack ./dist +mv kilocode-cli-*.tgz ../kilocode-cli.tgz +``` + +2. Copy the CLI tarball to the cloud-agent directory: + +```bash +cp kilocode/kilocode-cli.tgz cloud-agent/ +``` + +#### The Dev Environment + +The `dev` named environment in [`wrangler.jsonc`](wrangler.jsonc:128) automatically: + +- Uses `./Dockerfile.dev` for the sandbox container image +- Sets `KILOCODE_BACKEND_BASE_URL=http://localhost:3000` you will almost certainly want to override this. + +#### Building the Dev Image + +When you run the dev environment, Cloudflare will build `Dockerfile.dev` automatically. + +#### Environment Variables + +Copy and configure `.dev.vars`: + +```bash +cp .dev.vars.example .dev.vars +``` + +#### Running + +```bash +# Start the worker in dev mode with the dev environment +pnpm --filter cloud-agent dev -- --env dev + +# Or directly with wrangler: +cd cloud-agent && wrangler dev --env dev +``` + +### GitHub Actions Deployment + +This project also ships with an on-demand GitHub Action (`Deploy Cloud Agent`) located at +`.github/workflows/deploy-cloud-agent.yml`. To trigger it: + +1. In GitHub, open the **Actions** tab and select **Deploy Cloud Agent**. +2. Click **Run workflow**, choose the target environment (`dev` or `prod`), and confirm. +3. The workflow checks out the repo, installs dependencies with pnpm, and runs + `wrangler deploy --env ` inside `cloud-agent/`. + +Required secrets: + +- `CLOUDFLARE_API_TOKEN` — must have permission to deploy the worker for the selected environment. + +### Testing + +This project uses a dual testing approach with separate configurations for unit and integration tests: + +#### Unit Tests (Node.js) + +Fast tests that run in Node.js with full mocking support. Use these for testing pure business logic, utilities, and functionality that doesn't require the Cloudflare Workers runtime. + +```bash +# Run unit tests (src/**/*.test.ts) +pnpm --filter cloud-agent test + +# Watch mode +pnpm --filter cloud-agent test:watch +``` + +**When to use:** Testing pure TypeScript logic, utility functions, type guards, data transformations, and any code that doesn't directly interact with Cloudflare Workers APIs (Durable Objects, queues, KV, etc.). + +#### Integration Tests (Cloudflare Workers Runtime) + +Tests that run in the actual Cloudflare Workers runtime via Miniflare using `@cloudflare/vitest-pool-workers`. These tests have access to real Durable Objects, queues, and other Workers APIs through the `cloudflare:test` module. + +```bash +# Run integration tests (test/**/*.test.ts) +pnpm --filter cloud-agent test:integration + +# Watch mode +pnpm --filter cloud-agent test:integration:watch + +# Run both unit and integration tests +pnpm --filter cloud-agent test:all +``` + +**When to use:** Testing Durable Object behavior, queue consumers, WebSocket handling, SQLite storage, alarms, and any functionality that depends on the Cloudflare Workers runtime. Integration tests use utilities like `runInDurableObject`, `createMessageBatch`, and `getQueueResult` from `cloudflare:test`. + +#### Other Quality Checks + +```bash +# Type checking +pnpm --filter cloud-agent typecheck + +# Linting +pnpm --filter cloud-agent lint +pnpm --filter cloud-agent lint:fix +``` + +## Architecture + +### Multi-Session Model + +The cloud agent uses a **one sandbox (container) per organization/user** architecture with **N sessions per sandbox**: + +- **Sandbox**: Isolated container with scope based on account type: + - **Organization accounts**: `${organizationId}__${userId}` (e.g., `org-123__user-456`) + - **Personal accounts**: `user:${userId}__${userId}` (e.g., `user:abc-123__abc-123`) + - **Bot/service isolation**: Optional `__bot:${botId}` suffix (e.g., `org-123__user-456__bot:reviewer`) + - One sandbox per unique org/user or user/bot combination + - Multiple users in same org get separate sandboxes + - Sandboxes persist across HTTP requests and share filesystem +- **Sessions**: Like bash shell execution contexts within a sandbox. Think of them like terminal tabs or panes in the same container. + - Multiple sessions can run concurrently in the same sandbox + - Each session has its own working directory, HOME, and git workspace + - Sessions maintain separate shell state (env vars, cwd) but share the container filesystem + +This architecture enables efficient resource utilization while maintaining strong isolation between organizations, users, bots, and sessions. + +### Session Management + +The `SessionService` orchestrates session lifecycle: + +- **Initiate (V2: `prepareSession` + `initiateFromKilocodeSessionV2`)** + - Creates a session-specific HOME (`/home/`) so the Kilocode CLI keeps config/logs/tasks per session + - Calls `setupWorkspace` to build `/workspace/${organizationId}/${userId}/sessions/${sessionId}` + - Clones the requested repository directly into the workspace path using the session service + - Ensures a git branch (either `session/` or the specified `upstreamBranch`) + - Runs setup commands and configures MCP servers if provided +- **Resume (V2: `sendMessageV2`)** + - Rehydrates the `SessionContext` for an existing workspace + - Refreshes runtime config for the requested model/token + - Reattaches/creates the Cloudflare session (branch ops only for prepared sessions) + - Re-runs setup commands only on cold starts (when repo was recloned) + +### Session Linking + +Cloud-agent sessions are bidirectionally linked to Kilocode CLI sessions: + +- **Cloud → Kilo**: `prepareSession` creates a CLI session and links IDs. +- **Kilo → Cloud**: The `cloud_agent_session_id` is stored in the backend's `cli_sessions` table for reverse lookup. + +This enables: + +- Resuming local Kilocode sessions in the cloud +- Finding which cloud-agent session corresponds to a Kilocode session +- Seamless transition between local and cloud development + +**Identifiers:** + +- **Cloud-Agent Session ID**: `agent_${uuid}` (e.g., `agent_123e4567-e89b-12d3-a456-426614174000`) +- **Kilocode CLI Session ID**: `${uuid}` (e.g., `601313d3-0dd7-4d4c-af24-5e0014398a86`) + +### Identifiers & Paths + +- **Session ID**: `agent_${uuid}` (e.g., `agent_123e4567-e89b-12d3-a456-426614174000`) +- **Sandbox ID**: + - Organization accounts: `${organizationId}__${userId}` + - Personal accounts: `user:${userId}__${userId}` + - With bot isolation: `${organizationId}__${userId}__bot:${botId}` +- **Workspace Path**: + - Organization accounts: `/workspace/${organizationId}/${userId}/sessions/${sessionId}` + - Personal accounts: `/workspace/${userId}/sessions/${sessionId}` +- **Session HOME**: `/home/${sessionId}` (exported as `HOME` for all CLI invocations) +- **Branch**: `session/${sessionId}` (default) or specified upstream branch + +### GitHub Integration + +- Repository cloning happens during `SessionService.initiate` using the session service clone helpers +- Branch handling: + - **Default**: Creates isolated `session/` branches for each session + - **Upstream branches**: Use `upstreamBranch` to work on existing branches (e.g., `main`, `develop`) + - Upstream branches must exist remotely; checkout only (no automatic pull) + - Rationale: Fetch-only approach for upstream branches provides consistent, predictable state + - Session branches support lenient pulls and can be created fresh if not found +- Works with both public and private repositories +- After cloning, git user/email defaults to `Kilo Code Cloud ` + +#### GitHub App Token Generation + +For V2 routes (`sendMessageV2`, `initiateFromKilocodeSessionV2`), the cloud-agent generates GitHub App installation tokens on-demand. + +**How it works:** + +1. **Automatic installation lookup**: The worker automatically looks up the GitHub App installation ID from the database via Hyperdrive. The lookup verifies the user has access to the repository's organization. +2. **On-demand token generation**: When execution starts, `GitHubTokenService` generates a fresh token using `@octokit/auth-app` +3. **KV caching**: Tokens are cached in Cloudflare KV with 30-minute TTL (tokens valid for 1 hour) +4. **Cache key format**: `github-token:installation:{installationId}` + +**Configuration:** + +The worker requires these environment variables: + +- `GITHUB_APP_ID`: GitHub App ID (configured in `wrangler.jsonc`) +- `GITHUB_APP_PRIVATE_KEY`: RSA private key for the GitHub App (set via `wrangler secret put`) +- `GITHUB_TOKEN_CACHE`: KV namespace binding for token caching +- `HYPERDRIVE`: Hyperdrive binding for database access (installation ID lookup) + +**Benefits:** + +- No need to pass `githubInstallationId` — automatically resolved from database +- Tokens generated closer to where they're used (reduced latency) +- Fresh tokens on-demand rather than at session start +- Rate limit protection via KV caching +- No token expiry issues during long sessions diff --git a/cloud-agent-next/cloud-agent-build.sh b/cloud-agent-next/cloud-agent-build.sh new file mode 100755 index 0000000000..07738ac75a --- /dev/null +++ b/cloud-agent-next/cloud-agent-build.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# cloud-agent-build.sh +# Builds kilo-cli from source and copies the linux-x64 binary for Docker +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +KILO_CLI_DIR="${KILO_CLI_DIR:-$HOME/projects/kilo-cli}" +CLOUD_AGENT_DIR="$SCRIPT_DIR" + +echo "==> Building kilo-cli from $KILO_CLI_DIR" + +# Verify kilo-cli directory exists +if [ ! -d "$KILO_CLI_DIR" ]; then + echo "Error: kilo-cli directory not found at $KILO_CLI_DIR" + echo "Set KILO_CLI_DIR environment variable to override" + exit 1 +fi + +# Install dependencies +echo "==> Installing dependencies..." +cd "$KILO_CLI_DIR" +bun install + +# Build all targets (includes linux-x64) +echo "==> Building kilo binaries..." +cd "$KILO_CLI_DIR/packages/opencode" +./script/build.ts + +# Copy linux-x64 binary to cloud-agent +BINARY_PATH="$KILO_CLI_DIR/packages/opencode/dist/@kilocode/cli-linux-x64/bin/kilo" +if [ ! -f "$BINARY_PATH" ]; then + echo "Error: Binary not found at $BINARY_PATH" + exit 1 +fi + +echo "==> Copying kilo binary to $CLOUD_AGENT_DIR" +cp "$BINARY_PATH" "$CLOUD_AGENT_DIR/kilo" +chmod +x "$CLOUD_AGENT_DIR/kilo" + +echo "" +echo "✓ kilo binary ready at $CLOUD_AGENT_DIR/kilo" +echo "" +echo "Next steps:" +echo " cd $CLOUD_AGENT_DIR" +echo " pnpm run dev" diff --git a/cloud-agent-next/docs/diagrams.md b/cloud-agent-next/docs/diagrams.md new file mode 100644 index 0000000000..e5a279af5e --- /dev/null +++ b/cloud-agent-next/docs/diagrams.md @@ -0,0 +1,211 @@ +# Cloud-Agent WebSockets: Core Diagrams + +These diagrams capture the core loops/patterns for the direct execution model, +DO ingestion + replay, and client reconnect. + +--- + +## 1) System overview (data flow) + +```mermaid +flowchart LR + clientA[Client A] -->|HTTP tRPC V2| worker[Worker] + clientB[Client B] -->|HTTP tRPC V2| worker + clientA -->|WS stream upgrade| worker + clientB -->|WS stream upgrade| worker + worker -->|RPC startExecutionV2| do[CloudAgentSession DO] + worker -->|proxy stream WS| do + do -->|metadata + SQLite| storage[(DO storage)] + do -->|ExecutionOrchestrator| sandbox[Sandbox] + sandbox -->|wrapper connects /ingest WS| do + do -->|broadcast stream| clientA + do -->|broadcast stream| clientB +``` + +--- + +## 2) Direct execution handoff + +```mermaid +sequenceDiagram + participant C as Client + participant W as Worker (tRPC V2) + participant DO as CloudAgentSession DO + participant SB as Sandbox + + C->>W: initiate/sendMessage V2 + W->>DO: startExecutionV2(...) + + DO->>DO: check for active execution + + alt no active execution + DO->>DO: set activeExecutionId + DO->>DO: ExecutionOrchestrator.execute() + DO->>SB: prepare workspace + start wrapper + SB->>DO: wrapper /ingest WS events + DO-->>W: status=started + else active exists + DO-->>W: 409 Conflict (EXECUTION_IN_PROGRESS) + end + + W-->>C: ack {cloudAgentSessionId, executionId, status, streamUrl} +``` + +--- + +## 3) Execution lifecycle (start/resume) + +```mermaid +sequenceDiagram + participant DO as CloudAgentSession DO + participant Orch as ExecutionOrchestrator + participant SB as Sandbox + participant Wrap as Wrapper + + DO->>Orch: execute(plan) + + alt first run (shouldPrepare=true) + Orch->>SB: SessionService.initiate(...) + Orch->>SB: ensureKiloServer() + else resume (shouldPrepare=false) + Orch->>SB: SessionService.resume(...) + end + + Orch->>Wrap: WrapperClient.ensureRunning() + Orch->>Wrap: WrapperClient.startJob() + Orch->>Wrap: WrapperClient.prompt() + + Wrap->>DO: /ingest WS connect + loop stream events + Wrap->>DO: kilocode/output/error events + end + Wrap->>DO: message.updated (completed) + DO->>DO: clear activeExecutionId +``` + +--- + +## 4) DO ingest + stream handling + +```mermaid +flowchart LR + subgraph DO["CloudAgentSession DO"] + ingest["/ingest WS"] --> normalize["normalize event"] + normalize --> insert["insert into SQLite (RETURNING id)"] + insert --> broadcast["broadcast to /stream clients"] + + stream["/stream WS"] --> replay["query SQLite with filters"] + replay --> live["live broadcast"] + end +``` + +--- + +## 5) Wrapper lifecycle + +```mermaid +sequenceDiagram + participant DO as DO (WrapperClient) + participant Wrap as Wrapper HTTP Server + participant Kilo as Kilo Server (SSE) + + DO->>Wrap: POST /job/start + Wrap->>Kilo: create/resume session + Wrap-->>DO: {kiloSessionId} + + DO->>Wrap: POST /job/prompt + Wrap->>Wrap: open connections (ingest WS + SSE) + Wrap->>Kilo: POST /session/:id/prompt_async + Wrap-->>DO: {messageId} + + loop SSE events + Kilo->>Wrap: event stream + Wrap->>DO: forward via ingest WS + end + + Note over Wrap: on message.updated (completed) + Wrap->>Wrap: run post-completion tasks + Wrap->>Wrap: drain period (250ms) + Wrap->>Wrap: close connections +``` + +--- + +## 6) Client reconnect + replay + +```mermaid +sequenceDiagram + participant C as Client + participant W as Worker + participant DO as CloudAgentSession DO + + C->>W: GET /stream?sessionId=...&fromId=lastSeen + W->>DO: stub.fetch upgrade + DO->>DO: SELECT events WHERE id > fromId + DO-->>C: replay events + DO-->>C: live events +``` + +--- + +## 7) Execution state machine (high-level) + +```mermaid +stateDiagram-v2 + [*] --> pending + pending --> running + pending --> failed + running --> completed + running --> failed + running --> interrupted + completed --> [*] + failed --> [*] + interrupted --> [*] +``` + +--- + +## 8) Prepared session lifecycle (prepare → initiate → follow-up) + +```mermaid +sequenceDiagram + participant B as Backend + participant W as Worker (tRPC) + participant DO as CloudAgentSession DO + participant SB as Sandbox + + B->>W: prepareSession (internal) + W->>DO: prepare(metadata) + DO-->>W: success + stored preparedAt + + B->>W: initiateFromKilocodeSessionV2 + W->>DO: startExecutionV2(kind=initiatePrepared) + DO->>DO: tryInitiate() sets initiatedAt + DO->>SB: ExecutionOrchestrator.execute() + DO-->>W: status=started + + SB->>DO: /ingest WS (streaming) + + B->>W: sendMessageV2 (follow-up) + W->>DO: startExecutionV2(kind=followup) + DO-->>W: status=started (or 409 if busy) +``` + +--- + +## 9) Error handling and retries + +```mermaid +flowchart TD + A[Client Request] --> B{DO startExecutionV2} + B -->|Active execution| C[409 Conflict] + B -->|No active| D[ExecutionOrchestrator] + D -->|Sandbox connect fail| E[503 SANDBOX_CONNECT_FAILED] + D -->|Workspace setup fail| F[503 WORKSPACE_SETUP_FAILED] + D -->|Kilo server fail| G[503 KILO_SERVER_FAILED] + D -->|Wrapper start fail| H[503 WRAPPER_START_FAILED] + D -->|Success| I[200 Started] + + E & F & G & H -->|Client retries| A + C -->|Client waits/polls| A +``` diff --git a/cloud-agent-next/eslint.config.mjs b/cloud-agent-next/eslint.config.mjs new file mode 100644 index 0000000000..b64f34df36 --- /dev/null +++ b/cloud-agent-next/eslint.config.mjs @@ -0,0 +1,70 @@ +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; +import tseslint from 'typescript-eslint'; +import eslint from '@eslint/js'; +import { defineConfig } from 'eslint/config'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +export default defineConfig([ + { + ignores: ['node_modules/**', 'dist/**', '.wrangler/**'], + }, + { + files: ['**/*.ts'], + extends: [eslint.configs.recommended, tseslint.configs.recommendedTypeChecked], + languageOptions: { + parser: tseslint.parser, + parserOptions: { + projectService: { + allowDefaultProject: ['*.js', '*.mjs'], + }, + tsconfigRootDir: __dirname, + }, + }, + rules: { + // Core TypeScript rules from main repo + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/no-misused-promises': [ + 'error', + { + checksVoidReturn: false, + }, + ], + '@typescript-eslint/require-await': 'off', + '@typescript-eslint/consistent-type-imports': 'error', + '@typescript-eslint/no-var-requires': 'error', + + // Disabled rules (same as main repo) + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-base-to-string': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-empty-object-type': 'off', + }, + }, + { + files: ['**/*.ts'], + ignores: ['**/*.test.ts'], + rules: { + '@typescript-eslint/no-non-null-assertion': 'error', + }, + }, + // Allow table interpolators (objects with toString()) in template literals for SQL query files + { + files: ['src/session/queries/*.ts'], + rules: { + '@typescript-eslint/restrict-template-expressions': 'off', + }, + }, +]); diff --git a/cloud-agent-next/package.json b/cloud-agent-next/package.json new file mode 100644 index 0000000000..66634a49b8 --- /dev/null +++ b/cloud-agent-next/package.json @@ -0,0 +1,56 @@ +{ + "name": "cloud-agent-next", + "version": "1.0.0", + "type": "module", + "private": true, + "description": "A Cloudflare Workers project for Kilocode Cloud Agents", + "scripts": { + "preinstall": "npx only-allow pnpm", + "deploy": "wrangler deploy", + "predev": "pnpm run build:wrapper", + "dev": "wrangler dev --env 'dev'", + "prestart": "pnpm run build:wrapper", + "start": "wrangler dev", + "types": "wrangler types", + "lint": "eslint --config eslint.config.mjs --cache 'src/**/*.ts' 'wrapper/src/**/*.ts'", + "lint:fix": "eslint --config eslint.config.mjs --cache --fix 'src/**/*.ts' 'wrapper/src/**/*.ts'", + "format": "prettier --write 'src/**/*.ts'", + "format:check": "prettier --check 'src/**/*.ts'", + "build:wrapper": "bun run --cwd wrapper build", + "test": "vitest run", + "test:watch": "vitest", + "test:integration": "vitest run --config vitest.workers.config.ts", + "test:integration:watch": "vitest --config vitest.workers.config.ts", + "test:all": "vitest run && vitest run --config vitest.workers.config.ts", + "typecheck": "tsgo --noEmit --incremental false && pnpm -C wrapper run typecheck" + }, + "dependencies": { + "@cloudflare/sandbox": "0.6.7", + "@octokit/auth-app": "^8.1.2", + "@trpc/server": "^11.0.0", + "aws4fetch": "^1.0.20", + "jsonwebtoken": "^9.0.2", + "pg": "^8.16.3", + "workers-tagged-logger": "^0.13.7", + "zod": "^4.1.0" + }, + "devDependencies": { + "@cloudflare/containers": "0.0.30", + "@cloudflare/vitest-pool-workers": "^0.11.1", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "^9.38.0", + "@types/jsonwebtoken": "^9.0.10", + "@types/node": "^22", + "@types/pg": "^8.15.6", + "@typescript/native-preview": "7.0.0-dev.20251019.1", + "@vitest/ui": "^2.1.8", + "eslint": "^9.38.0", + "prettier": "^3.6.2", + "typescript": "^5.8.3", + "typescript-eslint": "^8.46.2", + "vitest": "^2.1.8", + "wrangler": "^4.51.0" + }, + "author": "", + "license": "MIT" +} diff --git a/cloud-agent-next/src/auth.ts b/cloud-agent-next/src/auth.ts new file mode 100644 index 0000000000..028ed900a7 --- /dev/null +++ b/cloud-agent-next/src/auth.ts @@ -0,0 +1,113 @@ +import { TRPCError } from '@trpc/server'; +import jwt from 'jsonwebtoken'; +import type { Env, TokenPayload } from './types.js'; + +type StreamTicketPayload = { + type: 'stream_ticket'; + userId?: string; + kiloSessionId?: string; + cloudAgentSessionId?: string; + sessionId?: string; + organizationId?: string; + nonce?: string; +}; + +export function validateKiloToken( + authHeader: string | null, + secret: string +): + | { success: true; userId: string; token: string; botId?: string } + | { success: false; error: string } { + // Check header exists and has Bearer format + if (!authHeader) { + return { success: false, error: 'Missing Authorization header' }; + } + + if (!authHeader.toLowerCase().startsWith('bearer ')) { + return { success: false, error: 'Invalid Authorization header format' }; + } + + const token = authHeader.substring(7).trim(); + + try { + // Verify JWT signature and decode + const payload = jwt.verify(token, secret, { + algorithms: ['HS256'], + }) as TokenPayload; + + // Validate token version + if (payload.version !== 3) { + return { + success: false, + error: `Invalid token version: ${payload.version}, expected 3`, + }; + } + + // Token is valid + return { + success: true, + userId: payload.kiloUserId, + token, + botId: payload.botId, + }; + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + return { success: false, error: 'Token expired' }; + } + if (error instanceof jwt.JsonWebTokenError) { + return { success: false, error: 'Invalid token signature' }; + } + return { success: false, error: 'Token validation failed' }; + } +} + +export function validateStreamTicket( + ticket: string | null, + secret: string +): { success: true; payload: StreamTicketPayload } | { success: false; error: string } { + if (!ticket) { + return { success: false, error: 'Missing stream ticket' }; + } + + try { + const payload = jwt.verify(ticket, secret, { + algorithms: ['HS256'], + }) as StreamTicketPayload; + + if (payload.type !== 'stream_ticket') { + return { success: false, error: 'Invalid ticket type' }; + } + + return { success: true, payload }; + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + return { success: false, error: 'Ticket expired' }; + } + if (error instanceof jwt.JsonWebTokenError) { + return { success: false, error: 'Invalid ticket signature' }; + } + return { success: false, error: 'Ticket validation failed' }; + } +} + +/** + * Validates JWT token and extracts user ID for tRPC context + * @throws {TRPCError} If authentication fails + */ +export function authenticate( + request: Request, + env: Env +): { userId: string; token: string; botId?: string } { + const authHeader = request.headers.get('authorization'); + + const result = validateKiloToken(authHeader, env.NEXTAUTH_SECRET); + + if (!result.success) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: result.error, + }); + } + + return { userId: result.userId, token: result.token, botId: result.botId }; +} diff --git a/cloud-agent-next/src/balance-validation.test.ts b/cloud-agent-next/src/balance-validation.test.ts new file mode 100644 index 0000000000..742217d2ab --- /dev/null +++ b/cloud-agent-next/src/balance-validation.test.ts @@ -0,0 +1,578 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + validateAuthAndBalance, + extractProcedureName, + extractOrgIdFromUrl, + fetchOrgIdForSession, + BALANCE_REQUIRED_MUTATIONS, +} from './balance-validation.js'; +import type { PersistenceEnv } from './persistence/types.js'; +import type { Env } from './types.js'; + +// Mock the auth module +vi.mock('./auth.js', () => ({ + validateKiloToken: vi.fn(), +})); + +// Mock the session-service module +vi.mock('./session-service.js', () => ({ + fetchSessionMetadata: vi.fn(), +})); + +vi.mock('./logger.js', () => ({ + logger: { + withFields: () => ({ error: vi.fn(), warn: vi.fn() }), + error: vi.fn(), + warn: vi.fn(), + }, +})); + +import { validateKiloToken } from './auth.js'; +import { fetchSessionMetadata } from './session-service.js'; + +describe('balance-validation', () => { + const originalFetch = global.fetch; + let fetchMock: ReturnType; + + const mockEnv = { + NEXTAUTH_SECRET: 'test-secret', + KILOCODE_BACKEND_BASE_URL: 'https://app.kilo.ai', + Sandbox: {}, + CLOUD_AGENT_SESSION: {}, + } as unknown as Env; + + beforeEach(() => { + vi.clearAllMocks(); + fetchMock = vi.fn(); + global.fetch = fetchMock as unknown as typeof fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + describe('validateAuthAndBalance', () => { + describe('authentication failures', () => { + it('returns 401 when JWT is invalid', async () => { + vi.mocked(validateKiloToken).mockReturnValue({ + success: false, + error: 'Invalid token', + }); + + const result = await validateAuthAndBalance('Bearer invalid-token', undefined, mockEnv); + + expect(result).toEqual({ + success: false, + status: 401, + message: 'Invalid token', + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns 401 when no auth header provided', async () => { + vi.mocked(validateKiloToken).mockReturnValue({ + success: false, + error: 'No authorization header', + }); + + const result = await validateAuthAndBalance(null, undefined, mockEnv); + + expect(result).toEqual({ + success: false, + status: 401, + message: 'No authorization header', + }); + }); + + it('returns 401 when balance API returns 401', async () => { + vi.mocked(validateKiloToken).mockReturnValue({ + success: true, + userId: 'user-123', + token: 'valid-token', + }); + fetchMock.mockResolvedValue({ + ok: false, + status: 401, + } as Response); + + const result = await validateAuthAndBalance('Bearer valid-token', undefined, mockEnv); + + expect(result).toEqual({ + success: false, + status: 401, + message: 'Authentication failed', + }); + }); + }); + + describe('balance validation', () => { + it('returns 402 when balance is depleted', async () => { + vi.mocked(validateKiloToken).mockReturnValue({ + success: true, + userId: 'user-123', + token: 'valid-token', + }); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ balance: 0.5, isDepleted: true }), + } as Response); + + const result = await validateAuthAndBalance('Bearer valid-token', undefined, mockEnv); + + expect(result).toEqual({ + success: false, + status: 402, + message: 'Insufficient credits: $1 minimum required', + }); + }); + + it('returns 402 when balance is below $1', async () => { + vi.mocked(validateKiloToken).mockReturnValue({ + success: true, + userId: 'user-123', + token: 'valid-token', + }); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ balance: 0.99, isDepleted: false }), // Just under $1 + } as Response); + + const result = await validateAuthAndBalance('Bearer valid-token', undefined, mockEnv); + + expect(result).toEqual({ + success: false, + status: 402, + message: 'Insufficient credits: $1 minimum required', + }); + }); + + it('returns 402 when balance is zero', async () => { + vi.mocked(validateKiloToken).mockReturnValue({ + success: true, + userId: 'user-123', + token: 'valid-token', + }); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ balance: 0, isDepleted: false }), + } as Response); + + const result = await validateAuthAndBalance('Bearer valid-token', undefined, mockEnv); + + expect(result).toEqual({ + success: false, + status: 402, + message: 'Insufficient credits: $1 minimum required', + }); + }); + + it('returns 402 when balance is negative', async () => { + vi.mocked(validateKiloToken).mockReturnValue({ + success: true, + userId: 'user-123', + token: 'valid-token', + }); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ balance: -5, isDepleted: true }), + } as Response); + + const result = await validateAuthAndBalance('Bearer valid-token', undefined, mockEnv); + + expect(result).toEqual({ + success: false, + status: 402, + message: 'Insufficient credits: $1 minimum required', + }); + }); + }); + + describe('error handling', () => { + it('returns 500 when balance API returns non-401 error', async () => { + vi.mocked(validateKiloToken).mockReturnValue({ + success: true, + userId: 'user-123', + token: 'valid-token', + }); + fetchMock.mockResolvedValue({ + ok: false, + status: 500, + } as Response); + + const result = await validateAuthAndBalance('Bearer valid-token', undefined, mockEnv); + + expect(result).toEqual({ + success: false, + status: 500, + message: 'Failed to verify balance', + }); + }); + + it('returns 500 when fetch throws', async () => { + vi.mocked(validateKiloToken).mockReturnValue({ + success: true, + userId: 'user-123', + token: 'valid-token', + }); + fetchMock.mockRejectedValue(new Error('Network error')); + + const result = await validateAuthAndBalance('Bearer valid-token', undefined, mockEnv); + + expect(result).toEqual({ + success: false, + status: 500, + message: 'Failed to verify balance', + }); + }); + + it('returns 500 when response JSON is invalid', async () => { + vi.mocked(validateKiloToken).mockReturnValue({ + success: true, + userId: 'user-123', + token: 'valid-token', + }); + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => { + throw new Error('Invalid JSON'); + }, + } as unknown as Response); + + const result = await validateAuthAndBalance('Bearer valid-token', undefined, mockEnv); + + expect(result).toEqual({ + success: false, + status: 500, + message: 'Invalid balance response', + }); + }); + + it('returns 500 when balance is not a number', async () => { + vi.mocked(validateKiloToken).mockReturnValue({ + success: true, + userId: 'user-123', + token: 'valid-token', + }); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ balance: 'not-a-number', isDepleted: false }), + } as Response); + + const result = await validateAuthAndBalance('Bearer valid-token', undefined, mockEnv); + + expect(result).toEqual({ + success: false, + status: 402, + message: 'Insufficient credits: $1 minimum required', + }); + }); + }); + + describe('successful validation', () => { + it('returns success when balance is sufficient', async () => { + vi.mocked(validateKiloToken).mockReturnValue({ + success: true, + userId: 'user-123', + token: 'valid-token', + }); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ balance: 5, isDepleted: false }), // $5 + } as Response); + + const result = await validateAuthAndBalance('Bearer valid-token', undefined, mockEnv); + + expect(result).toEqual({ + success: true, + userId: 'user-123', + token: 'valid-token', + botId: undefined, + }); + }); + + it('returns success with exactly $1 balance', async () => { + vi.mocked(validateKiloToken).mockReturnValue({ + success: true, + userId: 'user-123', + token: 'valid-token', + }); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ balance: 1, isDepleted: false }), // Exactly $1 + } as Response); + + const result = await validateAuthAndBalance('Bearer valid-token', undefined, mockEnv); + + expect(result).toEqual({ + success: true, + userId: 'user-123', + token: 'valid-token', + botId: undefined, + }); + }); + + it('returns success with botId when present', async () => { + vi.mocked(validateKiloToken).mockReturnValue({ + success: true, + userId: 'user-123', + token: 'valid-token', + botId: 'reviewer', + }); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ balance: 5, isDepleted: false }), + } as Response); + + const result = await validateAuthAndBalance('Bearer valid-token', undefined, mockEnv); + + expect(result).toEqual({ + success: true, + userId: 'user-123', + token: 'valid-token', + botId: 'reviewer', + }); + }); + + it('uses default API URL when KILOCODE_BACKEND_BASE_URL not configured', async () => { + vi.mocked(validateKiloToken).mockReturnValue({ + success: true, + userId: 'user-123', + token: 'valid-token', + botId: 'reviewer', + }); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ balance: 5, isDepleted: false }), + } as Response); + const envWithoutBackendUrl = { ...mockEnv, KILOCODE_BACKEND_BASE_URL: undefined }; + + const result = await validateAuthAndBalance( + 'Bearer valid-token', + undefined, + envWithoutBackendUrl as Env + ); + + expect(result).toEqual({ + success: true, + userId: 'user-123', + token: 'valid-token', + botId: 'reviewer', + }); + // Should use default URL https://api.kilo.ai + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.kilo.ai/api/profile/balance', + expect.objectContaining({ + method: 'GET', + }) + ); + }); + }); + + describe('organization header', () => { + it('includes X-KiloCode-OrganizationId header when orgId provided', async () => { + vi.mocked(validateKiloToken).mockReturnValue({ + success: true, + userId: 'user-123', + token: 'valid-token', + }); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ balance: 5, isDepleted: false }), + } as Response); + + const orgId = '11111111-2222-3333-4444-555555555555'; + await validateAuthAndBalance('Bearer valid-token', orgId, mockEnv); + + expect(fetchMock).toHaveBeenCalledWith( + `${mockEnv.KILOCODE_BACKEND_BASE_URL}/api/profile/balance`, + expect.objectContaining({ + method: 'GET', + headers: expect.any(Headers), + }) + ); + + const [, init] = fetchMock.mock.calls[0]; + const headers = init.headers as Headers; + expect(headers.get('Authorization')).toBe('Bearer valid-token'); + expect(headers.get('X-KiloCode-OrganizationId')).toBe(orgId); + }); + + it('does not include X-KiloCode-OrganizationId header when orgId not provided', async () => { + vi.mocked(validateKiloToken).mockReturnValue({ + success: true, + userId: 'user-123', + token: 'valid-token', + }); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ balance: 5, isDepleted: false }), + } as Response); + + await validateAuthAndBalance('Bearer valid-token', undefined, mockEnv); + + const [, init] = fetchMock.mock.calls[0]; + const headers = init.headers as Headers; + expect(headers.get('Authorization')).toBe('Bearer valid-token'); + expect(headers.get('X-KiloCode-OrganizationId')).toBeNull(); + }); + }); + }); + + describe('extractProcedureName', () => { + it('extracts procedure name from valid tRPC path', () => { + expect(extractProcedureName('/trpc/initiateSessionStream')).toBe('initiateSessionStream'); + expect(extractProcedureName('/trpc/sendMessageStream')).toBe('sendMessageStream'); + expect(extractProcedureName('/trpc/deleteSession')).toBe('deleteSession'); + }); + + it('handles paths with query strings', () => { + expect(extractProcedureName('/trpc/initiateSessionStream?batch=1')).toBe( + 'initiateSessionStream' + ); + }); + + it('returns null for non-tRPC paths', () => { + expect(extractProcedureName('/api/health')).toBeNull(); + expect(extractProcedureName('/health')).toBeNull(); + expect(extractProcedureName('/')).toBeNull(); + }); + + it('returns null for malformed tRPC paths', () => { + expect(extractProcedureName('/trpc')).toBeNull(); + expect(extractProcedureName('/trpc/')).toBeNull(); + }); + }); + + describe('extractOrgIdFromUrl', () => { + it('extracts orgId from basic URL with simple string value', () => { + const input = { kilocodeOrganizationId: 'org-123' }; + const url = new URL( + `https://example.com/trpc/test?input=${encodeURIComponent(JSON.stringify(input))}` + ); + expect(extractOrgIdFromUrl(url)).toBe('org-123'); + }); + + it('handles URL-encoded values without double-decoding (regression test)', () => { + // This canary string was used to detect the original double-decoding bug. + // When URL-encoded: + // - `%` in `95%` becomes `%25` + // - `+` becomes `%2B` + // If double-decoding occurred, `%25` would incorrectly become `%` + const canaryString = 'decode test +95% and 75%'; + const input = { kilocodeOrganizationId: canaryString }; + const url = new URL( + `https://example.com/trpc/test?input=${encodeURIComponent(JSON.stringify(input))}` + ); + + // url.searchParams.get() decodes once, and JSON.parse handles the rest + // The function should NOT double-decode + expect(extractOrgIdFromUrl(url)).toBe(canaryString); + }); + + it('returns undefined when input parameter is missing', () => { + const url = new URL('https://example.com/trpc/test'); + expect(extractOrgIdFromUrl(url)).toBeUndefined(); + }); + + it('throws an error when input parameter is invalid JSON', () => { + const url = new URL('https://example.com/trpc/test?input=not-valid-json'); + expect(() => extractOrgIdFromUrl(url)).toThrow('Failed to parse tRPC input'); + }); + + it('returns undefined when kilocodeOrganizationId field is missing from input', () => { + const input = { sessionId: 'session-456' }; + const url = new URL( + `https://example.com/trpc/test?input=${encodeURIComponent(JSON.stringify(input))}` + ); + expect(extractOrgIdFromUrl(url)).toBeUndefined(); + }); + + it('returns undefined when kilocodeOrganizationId is not a string', () => { + const input = { kilocodeOrganizationId: 12345 }; + const url = new URL( + `https://example.com/trpc/test?input=${encodeURIComponent(JSON.stringify(input))}` + ); + expect(extractOrgIdFromUrl(url)).toBeUndefined(); + }); + + it('returns undefined when input is null', () => { + const url = new URL(`https://example.com/trpc/test?input=${encodeURIComponent('null')}`); + expect(extractOrgIdFromUrl(url)).toBeUndefined(); + }); + }); + + describe('fetchOrgIdForSession', () => { + const mockPersistenceEnv = { + CLOUD_AGENT_SESSION: { + idFromName: vi.fn(), + get: vi.fn(), + }, + } as unknown as PersistenceEnv; + + beforeEach(() => { + vi.mocked(fetchSessionMetadata).mockReset(); + }); + + it('returns orgId when session metadata exists', async () => { + vi.mocked(fetchSessionMetadata).mockResolvedValue({ + version: 1, + sessionId: 'agent_123', + orgId: 'org-456', + userId: 'user-789', + timestamp: Date.now(), + }); + + const result = await fetchOrgIdForSession(mockPersistenceEnv, 'user-789', 'agent_123'); + + expect(result).toBe('org-456'); + expect(fetchSessionMetadata).toHaveBeenCalledWith( + mockPersistenceEnv, + 'user-789', + 'agent_123' + ); + }); + + it('returns undefined when session metadata does not exist', async () => { + vi.mocked(fetchSessionMetadata).mockResolvedValue(null); + + const result = await fetchOrgIdForSession(mockPersistenceEnv, 'user-789', 'agent_123'); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when session metadata has no orgId (personal account)', async () => { + vi.mocked(fetchSessionMetadata).mockResolvedValue({ + version: 1, + sessionId: 'agent_123', + userId: 'user-789', + timestamp: Date.now(), + }); + + const result = await fetchOrgIdForSession(mockPersistenceEnv, 'user-789', 'agent_123'); + + expect(result).toBeUndefined(); + }); + + it('returns undefined and logs warning when fetchSessionMetadata throws', async () => { + vi.mocked(fetchSessionMetadata).mockRejectedValue(new Error('DO unavailable')); + + const result = await fetchOrgIdForSession(mockPersistenceEnv, 'user-789', 'agent_123'); + + expect(result).toBeUndefined(); + }); + }); + + describe('BALANCE_REQUIRED_MUTATIONS', () => { + it('contains expected V2 mutation procedures', () => { + expect(BALANCE_REQUIRED_MUTATIONS.has('initiateFromKilocodeSessionV2')).toBe(true); + expect(BALANCE_REQUIRED_MUTATIONS.has('sendMessageV2')).toBe(true); + }); + + it('does not contain non-balance-required procedures', () => { + expect(BALANCE_REQUIRED_MUTATIONS.has('deleteSession')).toBe(false); + expect(BALANCE_REQUIRED_MUTATIONS.has('getSessionLogs')).toBe(false); + expect(BALANCE_REQUIRED_MUTATIONS.has('prepareSession')).toBe(false); + }); + }); +}); diff --git a/cloud-agent-next/src/balance-validation.ts b/cloud-agent-next/src/balance-validation.ts new file mode 100644 index 0000000000..732c3d9d6c --- /dev/null +++ b/cloud-agent-next/src/balance-validation.ts @@ -0,0 +1,155 @@ +import { validateKiloToken } from './auth.js'; +import { DEFAULT_BACKEND_URL } from './constants.js'; +import { logger } from './logger.js'; +import type { Env } from './types.js'; +import type { PersistenceEnv } from './persistence/types.js'; +import { fetchSessionMetadata } from './session-service.js'; + +/** + * Result of balance validation - either success or failure with HTTP status + */ +export type BalanceValidationResult = + | { success: true; userId: string; token: string; botId?: string } + | { success: false; status: 401 | 402 | 500; message: string }; + +const MIN_BALANCE_DOLLARS = 1; + +/** + * Validates authentication and balance for subscription endpoints. + * Returns proper HTTP status codes that can be used before opening SSE streams. + * + * @param authHeader - Authorization header from the request + * @param orgId - Optional organization ID for org-specific balance check + * @param env - Worker environment with secrets and bindings + */ +export async function validateAuthAndBalance( + authHeader: string | null, + orgId: string | undefined, + env: Env +): Promise { + // Validate JWT first + const authResult = validateKiloToken(authHeader, env.NEXTAUTH_SECRET); + if (!authResult.success) { + return { success: false, status: 401, message: authResult.error }; + } + + // Use configured backend URL or fall back to production API + const backendUrl = env.KILOCODE_BACKEND_BASE_URL || DEFAULT_BACKEND_URL; + + // Call balance endpoint + const headers = new Headers({ + Authorization: `Bearer ${authResult.token}`, + }); + if (orgId) { + headers.set('X-KiloCode-OrganizationId', orgId); + } + + let response: Response; + try { + response = await fetch(`${backendUrl}/api/profile/balance`, { + method: 'GET', + headers, + }); + } catch (error) { + logger + .withFields({ error: error instanceof Error ? error.message : String(error) }) + .error('Failed to fetch balance'); + return { success: false, status: 500, message: 'Failed to verify balance' }; + } + + if (response.status === 401) { + return { success: false, status: 401, message: 'Authentication failed' }; + } + + if (!response.ok) { + logger + .withFields({ status: response.status, statusText: response.statusText }) + .error('Balance API returned error'); + return { success: false, status: 500, message: 'Failed to verify balance' }; + } + + let data: { balance: number; isDepleted: boolean }; + try { + data = await response.json(); + } catch { + return { success: false, status: 500, message: 'Invalid balance response' }; + } + + if (data.isDepleted || typeof data.balance !== 'number' || data.balance < MIN_BALANCE_DOLLARS) { + return { success: false, status: 402, message: 'Insufficient credits: $1 minimum required' }; + } + + return { + success: true, + userId: authResult.userId, + token: authResult.token, + botId: authResult.botId, + }; +} + +/** + * Extracts the tRPC procedure name from a URL pathname. + * @example "/trpc/initiateSessionStream" -> "initiateSessionStream" + */ +export function extractProcedureName(pathname: string): string | null { + const match = pathname.match(/^\/trpc\/([^?/]+)/); + return match ? match[1] : null; +} + +/** + * Extracts organization ID from tRPC input in URL query params. + * For GET requests (subscriptions), input is JSON-encoded in the 'input' query param. + */ +export function extractOrgIdFromUrl(url: URL): string | undefined { + const inputParam = url.searchParams.get('input'); + if (!inputParam) return undefined; + + try { + const input = JSON.parse(inputParam); + if (input && typeof input === 'object' && 'kilocodeOrganizationId' in input) { + const value = input.kilocodeOrganizationId; + if (typeof value === 'string') { + return value; + } + } + } catch (error) { + throw new Error( + `Failed to parse tRPC input: ${error instanceof Error ? error.message : String(error)}` + ); + } + + return undefined; +} + +/** + * Fetches the orgId for a session from the Durable Object metadata. + * Used by sendMessageV2 to get the org context for balance validation. + * + * @param env - Worker environment with DO bindings + * @param userId - User ID from auth + * @param sessionId - Session ID from URL input + * @returns The orgId if found, undefined otherwise + */ +export async function fetchOrgIdForSession( + env: PersistenceEnv, + userId: string, + sessionId: string +): Promise { + try { + const metadata = await fetchSessionMetadata(env, userId, sessionId); + return metadata?.orgId; + } catch (error) { + logger + .withFields({ error: error instanceof Error ? error.message : String(error), sessionId }) + .warn('Failed to fetch session metadata for balance validation'); + return undefined; + } +} + +/** + * Set of V2 mutation procedure names that require balance validation + */ +export const BALANCE_REQUIRED_MUTATIONS = new Set([ + 'initiateFromKilocodeSessionV2', + 'sendMessageV2', +]); diff --git a/cloud-agent-next/src/callbacks/delivery.test.ts b/cloud-agent-next/src/callbacks/delivery.test.ts new file mode 100644 index 0000000000..527e45b8e2 --- /dev/null +++ b/cloud-agent-next/src/callbacks/delivery.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { deliverCallbackJob } from './delivery.js'; +import type { CallbackTarget, ExecutionCallbackPayload } from './types.js'; + +const mockPayload: ExecutionCallbackPayload = { + sessionId: 'test-session', + cloudAgentSessionId: 'test-session', + executionId: 'test-execution', + status: 'completed', +}; + +describe('deliverCallbackJob', () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + globalThis.fetch = originalFetch; + }); + + describe('retry behavior based on attempts', () => { + it('should retry on first attempt (attempts=1) with 500 error', async () => { + globalThis.fetch = vi.fn().mockResolvedValue(new Response('', { status: 500 })); + const target: CallbackTarget = { url: 'https://example.com/callback' }; + + const result = await deliverCallbackJob(target, mockPayload, 1); + + expect(result.type).toBe('retry'); + if (result.type === 'retry') { + expect(result.delaySeconds).toBe(60); // base * 2^(attempts-1) + } + }); + + it('should retry on second attempt (attempts=2) with 500 error', async () => { + globalThis.fetch = vi.fn().mockResolvedValue(new Response('', { status: 500 })); + const target: CallbackTarget = { url: 'https://example.com/callback' }; + + const result = await deliverCallbackJob(target, mockPayload, 2); + + expect(result.type).toBe('retry'); + if (result.type === 'retry') { + expect(result.delaySeconds).toBe(120); // base * 2^(attempts-1) + } + }); + + it('should retry on third attempt (attempts=3) with 500 error', async () => { + globalThis.fetch = vi.fn().mockResolvedValue(new Response('', { status: 500 })); + const target: CallbackTarget = { url: 'https://example.com/callback' }; + + const result = await deliverCallbackJob(target, mockPayload, 3); + + expect(result.type).toBe('retry'); + if (result.type === 'retry') { + expect(result.delaySeconds).toBe(240); // base * 2^(attempts-1) + } + }); + + it('should fail on fifth attempt (attempts=5) with 500 error', async () => { + globalThis.fetch = vi.fn().mockResolvedValue(new Response('', { status: 500 })); + const target: CallbackTarget = { url: 'https://example.com/callback' }; + + const result = await deliverCallbackJob(target, mockPayload, 5); + + expect(result.type).toBe('failed'); + if (result.type === 'failed') { + expect(result.error).toContain('5 attempts'); + } + }); + + it('should retry on 429 (rate limited)', async () => { + globalThis.fetch = vi.fn().mockResolvedValue(new Response('', { status: 429 })); + const target: CallbackTarget = { url: 'https://example.com/callback' }; + + const result = await deliverCallbackJob(target, mockPayload, 1); + + expect(result.type).toBe('retry'); + }); + + it('should NOT retry on 4xx errors (except 429)', async () => { + globalThis.fetch = vi.fn().mockResolvedValue(new Response('', { status: 400 })); + const target: CallbackTarget = { url: 'https://example.com/callback' }; + + const result = await deliverCallbackJob(target, mockPayload, 1); + + expect(result.type).toBe('failed'); + }); + + it('should NOT retry on 404 errors', async () => { + globalThis.fetch = vi.fn().mockResolvedValue(new Response('', { status: 404 })); + const target: CallbackTarget = { url: 'https://example.com/callback' }; + + const result = await deliverCallbackJob(target, mockPayload, 1); + + expect(result.type).toBe('failed'); + }); + + it('should retry on 502 (bad gateway)', async () => { + globalThis.fetch = vi.fn().mockResolvedValue(new Response('', { status: 502 })); + const target: CallbackTarget = { url: 'https://example.com/callback' }; + + const result = await deliverCallbackJob(target, mockPayload, 1); + + expect(result.type).toBe('retry'); + }); + + it('should retry on 503 (service unavailable)', async () => { + globalThis.fetch = vi.fn().mockResolvedValue(new Response('', { status: 503 })); + const target: CallbackTarget = { url: 'https://example.com/callback' }; + + const result = await deliverCallbackJob(target, mockPayload, 1); + + expect(result.type).toBe('retry'); + }); + }); + + describe('successful delivery', () => { + it('should succeed on 200 response', async () => { + globalThis.fetch = vi.fn().mockResolvedValue(new Response('', { status: 200 })); + const target: CallbackTarget = { url: 'https://example.com/callback' }; + + const result = await deliverCallbackJob(target, mockPayload, 1); + + expect(result.type).toBe('success'); + }); + + it('should succeed on 201 response', async () => { + globalThis.fetch = vi.fn().mockResolvedValue(new Response('', { status: 201 })); + const target: CallbackTarget = { url: 'https://example.com/callback' }; + + const result = await deliverCallbackJob(target, mockPayload, 1); + + expect(result.type).toBe('success'); + }); + + it('should succeed on 204 response', async () => { + globalThis.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + const target: CallbackTarget = { url: 'https://example.com/callback' }; + + const result = await deliverCallbackJob(target, mockPayload, 1); + + expect(result.type).toBe('success'); + }); + + it('should send correct payload to fetch', async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response('', { status: 200 })); + globalThis.fetch = mockFetch; + const target: CallbackTarget = { url: 'https://example.com/callback' }; + + await deliverCallbackJob(target, mockPayload, 1); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.com/callback', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify(mockPayload), + }) + ); + }); + + it('should include custom headers in request', async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response('', { status: 200 })); + globalThis.fetch = mockFetch; + const target: CallbackTarget = { + url: 'https://example.com/callback', + headers: { Authorization: 'Bearer token123' }, + }; + + await deliverCallbackJob(target, mockPayload, 1); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.com/callback', + expect.objectContaining({ + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + Authorization: 'Bearer token123', + }), + }) + ); + }); + }); + + describe('network errors', () => { + it('should retry on network failure', async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network error')); + const target: CallbackTarget = { url: 'https://example.com/callback' }; + + const result = await deliverCallbackJob(target, mockPayload, 1); + + expect(result.type).toBe('retry'); + }); + + it('should fail after max attempts on persistent network error', async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network error')); + const target: CallbackTarget = { url: 'https://example.com/callback' }; + + const result = await deliverCallbackJob(target, mockPayload, 5); + + expect(result.type).toBe('failed'); + if (result.type === 'failed') { + expect(result.error).toContain('Network error'); + } + }); + }); +}); diff --git a/cloud-agent-next/src/callbacks/delivery.ts b/cloud-agent-next/src/callbacks/delivery.ts new file mode 100644 index 0000000000..2e7b7c1944 --- /dev/null +++ b/cloud-agent-next/src/callbacks/delivery.ts @@ -0,0 +1,82 @@ +import type { CallbackTarget, ExecutionCallbackPayload } from './types.js'; +import { logger } from '../logger.js'; + +const MAX_ATTEMPTS = 5; +const BASE_BACKOFF_SECONDS = 60; +const DELIVERY_TIMEOUT_MS = 10_000; + +function shouldRetry(status?: number): boolean { + if (!status) return true; + if (status === 429) return true; + return status >= 500; +} + +export type DeliveryResult = + | { type: 'success' } + | { type: 'retry'; delaySeconds: number } + | { type: 'failed'; error: string }; + +async function deliverToTarget( + target: CallbackTarget, + payload: ExecutionCallbackPayload +): Promise<{ ok: boolean; status?: number; error?: string }> { + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...target.headers, + }; + + try { + const response = await fetch(target.url, { + method: 'POST', + headers, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MS), + }); + + logger + .withFields({ + cloudAgentSessionId: payload.cloudAgentSessionId, + kiloSessionId: payload.kiloSessionId, + executionId: payload.executionId, + status: response.status, + }) + .info('Callback delivered'); + + return response.ok + ? { ok: true, status: response.status } + : { ok: false, status: response.status }; + } catch (err) { + logger + .withFields({ + cloudAgentSessionId: payload.cloudAgentSessionId, + kiloSessionId: payload.kiloSessionId, + executionId: payload.executionId, + error: err instanceof Error ? err.message : 'Unknown error', + }) + .error('Callback delivery failed'); + return { ok: false, error: err instanceof Error ? err.message : 'Unknown error' }; + } +} + +export async function deliverCallbackJob( + target: CallbackTarget, + payload: ExecutionCallbackPayload, + attempts: number +): Promise { + const result = await deliverToTarget(target, payload); + + if (result.ok) { + return { type: 'success' }; + } + + if (attempts < MAX_ATTEMPTS && shouldRetry(result.status)) { + const delaySeconds = BASE_BACKOFF_SECONDS * 2 ** (attempts - 1); + return { type: 'retry', delaySeconds }; + } + + const errorMsg = result.error ?? `HTTP ${result.status}`; + return { + type: 'failed', + error: `Callback delivery failed after ${attempts} attempts: ${errorMsg}`, + }; +} diff --git a/cloud-agent-next/src/callbacks/index.ts b/cloud-agent-next/src/callbacks/index.ts new file mode 100644 index 0000000000..23f512d068 --- /dev/null +++ b/cloud-agent-next/src/callbacks/index.ts @@ -0,0 +1,3 @@ +export type { CallbackTarget, CallbackJob, ExecutionCallbackPayload } from './types.js'; +export { deliverCallbackJob, type DeliveryResult } from './delivery.js'; +export { createCallbackQueueConsumer } from './queue-consumer.js'; diff --git a/cloud-agent-next/src/callbacks/queue-consumer.ts b/cloud-agent-next/src/callbacks/queue-consumer.ts new file mode 100644 index 0000000000..f91fc4bee8 --- /dev/null +++ b/cloud-agent-next/src/callbacks/queue-consumer.ts @@ -0,0 +1,36 @@ +import type { CallbackJob } from './types.js'; +import { deliverCallbackJob } from './delivery.js'; + +export function createCallbackQueueConsumer() { + return async function callbackQueueConsumer(batch: MessageBatch): Promise { + for (const message of batch.messages) { + await processMessage(message); + } + }; +} + +async function processMessage(message: Message): Promise { + const job = message.body; + + const result = await deliverCallbackJob(job.target, job.payload, message.attempts); + + switch (result.type) { + case 'success': + message.ack(); + break; + + case 'retry': + message.retry({ delaySeconds: result.delaySeconds }); + break; + + case 'failed': + // TODO: Send to DLQ when implemented + console.error(`Callback delivery failed: ${result.error}`, { + sessionId: job.payload.sessionId, + executionId: job.payload.executionId, + attempts: message.attempts, + }); + message.ack(); + break; + } +} diff --git a/cloud-agent-next/src/callbacks/types.ts b/cloud-agent-next/src/callbacks/types.ts new file mode 100644 index 0000000000..1d0daba8e7 --- /dev/null +++ b/cloud-agent-next/src/callbacks/types.ts @@ -0,0 +1,19 @@ +export type CallbackTarget = { + url: string; + headers?: Record; +}; + +export type ExecutionCallbackPayload = { + sessionId: string; + cloudAgentSessionId: string; + executionId: string; + status: 'completed' | 'failed' | 'interrupted'; + errorMessage?: string; + lastSeenBranch?: string; + kiloSessionId?: string; +}; + +export type CallbackJob = { + target: CallbackTarget; + payload: ExecutionCallbackPayload; +}; diff --git a/cloud-agent-next/src/constants.ts b/cloud-agent-next/src/constants.ts new file mode 100644 index 0000000000..aa2e6a55f8 --- /dev/null +++ b/cloud-agent-next/src/constants.ts @@ -0,0 +1,5 @@ +/** + * Default backend URL for Kilocode API calls when not configured via environment. + * Used for balance checks, session linking, and other backend API calls. + */ +export const DEFAULT_BACKEND_URL = 'https://api.kilo.ai'; diff --git a/cloud-agent-next/src/core/execution.test.ts b/cloud-agent-next/src/core/execution.test.ts new file mode 100644 index 0000000000..03df298bbf --- /dev/null +++ b/cloud-agent-next/src/core/execution.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import { canTransition, isTerminal, getAllowedTransitions } from './execution.js'; + +describe('Execution State Machine', () => { + describe('canTransition', () => { + it('should allow pending -> running', () => { + expect(canTransition('pending', 'running')).toBe(true); + }); + + it('should allow pending -> failed (for expired queue entries)', () => { + expect(canTransition('pending', 'failed')).toBe(true); + }); + + it('should allow running -> completed', () => { + expect(canTransition('running', 'completed')).toBe(true); + }); + + it('should allow running -> failed', () => { + expect(canTransition('running', 'failed')).toBe(true); + }); + + it('should allow running -> interrupted', () => { + expect(canTransition('running', 'interrupted')).toBe(true); + }); + + it('should not allow pending -> completed (must go through running)', () => { + expect(canTransition('pending', 'completed')).toBe(false); + }); + + it('should not allow completed -> running (terminal state)', () => { + expect(canTransition('completed', 'running')).toBe(false); + }); + + it('should not allow failed -> running (terminal state)', () => { + expect(canTransition('failed', 'running')).toBe(false); + }); + + it('should not allow interrupted -> running (terminal state)', () => { + expect(canTransition('interrupted', 'running')).toBe(false); + }); + }); + + describe('isTerminal', () => { + it('should return false for pending', () => { + expect(isTerminal('pending')).toBe(false); + }); + + it('should return false for running', () => { + expect(isTerminal('running')).toBe(false); + }); + + it('should return true for completed', () => { + expect(isTerminal('completed')).toBe(true); + }); + + it('should return true for failed', () => { + expect(isTerminal('failed')).toBe(true); + }); + + it('should return true for interrupted', () => { + expect(isTerminal('interrupted')).toBe(true); + }); + }); + + describe('getAllowedTransitions', () => { + it('should return [running, failed] for pending', () => { + expect(getAllowedTransitions('pending')).toEqual(['running', 'failed']); + }); + + it('should return [completed, failed, interrupted] for running', () => { + expect(getAllowedTransitions('running')).toEqual(['completed', 'failed', 'interrupted']); + }); + + it('should return empty array for terminal states', () => { + expect(getAllowedTransitions('completed')).toEqual([]); + expect(getAllowedTransitions('failed')).toEqual([]); + expect(getAllowedTransitions('interrupted')).toEqual([]); + }); + }); +}); diff --git a/cloud-agent-next/src/core/execution.ts b/cloud-agent-next/src/core/execution.ts new file mode 100644 index 0000000000..d1fb0cd4db --- /dev/null +++ b/cloud-agent-next/src/core/execution.ts @@ -0,0 +1,129 @@ +/** + * Execution state machine for the cloud-agent system. + * + * This module contains pure business logic for managing execution states. + * No side effects or dependencies - just state transition validation. + */ + +import { STALE_THRESHOLD_MS } from './lease.js'; + +// --------------------------------------------------------------------------- +// Execution Status +// --------------------------------------------------------------------------- + +/** Possible states of an execution */ +export type ExecutionStatus = 'pending' | 'running' | 'completed' | 'failed' | 'interrupted'; + +/** Health status for active executions */ +export type ExecutionHealth = 'healthy' | 'stale' | 'unknown'; + +// --------------------------------------------------------------------------- +// State Machine +// --------------------------------------------------------------------------- + +/** + * Valid state transitions for executions. + * Maps each status to the list of statuses it can transition to. + */ +const VALID_TRANSITIONS: Record = { + pending: ['running', 'failed'], // Allow failed for expired queue entries + running: ['completed', 'failed', 'interrupted'], + completed: [], + failed: [], + interrupted: [], +}; + +/** + * Check if a state transition is valid. + * + * @param from - Current execution status + * @param to - Target execution status + * @returns true if the transition is allowed + */ +export function canTransition(from: ExecutionStatus, to: ExecutionStatus): boolean { + return VALID_TRANSITIONS[from].includes(to); +} + +/** + * Check if a status is terminal (no more transitions possible). + * Terminal statuses: completed, failed, interrupted + * + * @param status - Status to check + * @returns true if no transitions are possible from this status + */ +export function isTerminal(status: ExecutionStatus): boolean { + return VALID_TRANSITIONS[status].length === 0; +} + +/** + * Get the list of statuses that can be transitioned to from the given status. + * + * @param status - Current status + * @returns Array of valid target statuses + */ +export function getAllowedTransitions(status: ExecutionStatus): ExecutionStatus[] { + return VALID_TRANSITIONS[status]; +} + +// --------------------------------------------------------------------------- +// Execution Health +// --------------------------------------------------------------------------- + +/** Startup grace period before marking execution as unknown (2 minutes) */ +const STARTUP_GRACE_MS = 2 * 60 * 1000; + +/** Threshold for healthy heartbeat (1 minute) */ +const HEALTHY_HEARTBEAT_MS = 60_000; + +/** + * Compute the health status of an execution based on heartbeat recency. + * + * Health statuses: + * - 'healthy': Heartbeat received within last minute, or still in startup grace period + * - 'unknown': No heartbeat but within stale threshold (1-10 minutes) + * - 'stale': No heartbeat for longer than stale threshold (10+ minutes) + * + * @param status - Current execution status + * @param startedAt - Timestamp when execution started + * @param lastHeartbeat - Timestamp of last heartbeat (undefined if never received) + * @param now - Current timestamp (defaults to Date.now()) + * @returns Health status, or null if execution is not running + */ +export function computeExecutionHealth( + status: ExecutionStatus, + startedAt: number, + lastHeartbeat: number | undefined, + now: number = Date.now() +): ExecutionHealth | null { + // Only compute health for running executions + if (status !== 'running') { + return null; + } + + // If we have a heartbeat, check its recency + if (lastHeartbeat !== undefined) { + const timeSinceHeartbeat = now - lastHeartbeat; + + if (timeSinceHeartbeat < HEALTHY_HEARTBEAT_MS) { + return 'healthy'; + } + if (timeSinceHeartbeat < STALE_THRESHOLD_MS) { + return 'unknown'; + } + return 'stale'; + } + + // No heartbeat received yet - check if still in startup grace period + const timeSinceStart = now - startedAt; + + if (timeSinceStart < STARTUP_GRACE_MS) { + // Still starting up - give it time + return 'healthy'; + } + if (timeSinceStart < STALE_THRESHOLD_MS) { + // Past startup grace but within stale threshold + return 'unknown'; + } + // Never received heartbeat and past stale threshold + return 'stale'; +} diff --git a/cloud-agent-next/src/core/lease.test.ts b/cloud-agent-next/src/core/lease.test.ts new file mode 100644 index 0000000000..5542f2b321 --- /dev/null +++ b/cloud-agent-next/src/core/lease.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest'; +import { + LEASE_TTL_MS, + HEARTBEAT_INTERVAL_MS, + STALE_THRESHOLD_MS, + calculateExpiry, + isExpired, + isStale, +} from './lease.js'; + +describe('Lease Logic', () => { + describe('constants', () => { + it('should have LEASE_TTL_MS set to 90 seconds', () => { + expect(LEASE_TTL_MS).toBe(90_000); + }); + + it('should have HEARTBEAT_INTERVAL_MS set to 30 seconds', () => { + expect(HEARTBEAT_INTERVAL_MS).toBe(30_000); + }); + + it('should have STALE_THRESHOLD_MS set to 10 minutes', () => { + expect(STALE_THRESHOLD_MS).toBe(10 * 60 * 1000); + }); + }); + + describe('calculateExpiry', () => { + it('should return now + LEASE_TTL_MS', () => { + const now = 1000000; + expect(calculateExpiry(now)).toBe(now + LEASE_TTL_MS); + }); + + it('should use Date.now() when no argument provided', () => { + const before = Date.now(); + const expiry = calculateExpiry(); + const after = Date.now(); + + expect(expiry).toBeGreaterThanOrEqual(before + LEASE_TTL_MS); + expect(expiry).toBeLessThanOrEqual(after + LEASE_TTL_MS); + }); + }); + + describe('isExpired', () => { + it('should return true when now >= expiresAt', () => { + expect(isExpired(1000, 1000)).toBe(true); + expect(isExpired(1000, 1001)).toBe(true); + }); + + it('should return false when now < expiresAt', () => { + expect(isExpired(1000, 999)).toBe(false); + }); + + it('should handle boundary correctly', () => { + const expiresAt = 1000; + expect(isExpired(expiresAt, expiresAt - 1)).toBe(false); + expect(isExpired(expiresAt, expiresAt)).toBe(true); + expect(isExpired(expiresAt, expiresAt + 1)).toBe(true); + }); + }); + + describe('isStale', () => { + it('should return true when lastHeartbeat is undefined', () => { + expect(isStale(undefined)).toBe(true); + }); + + it('should return true when time since lastHeartbeat > STALE_THRESHOLD_MS', () => { + const now = 1000000; + const lastHeartbeat = now - STALE_THRESHOLD_MS - 1; + expect(isStale(lastHeartbeat, now)).toBe(true); + }); + + it('should return false when time since lastHeartbeat <= STALE_THRESHOLD_MS', () => { + const now = 1000000; + const lastHeartbeat = now - STALE_THRESHOLD_MS; + expect(isStale(lastHeartbeat, now)).toBe(false); + }); + + it('should return false for recent heartbeat', () => { + const now = 1000000; + const lastHeartbeat = now - 1000; // 1 second ago + expect(isStale(lastHeartbeat, now)).toBe(false); + }); + }); +}); diff --git a/cloud-agent-next/src/core/lease.ts b/cloud-agent-next/src/core/lease.ts new file mode 100644 index 0000000000..29ffff549e --- /dev/null +++ b/cloud-agent-next/src/core/lease.ts @@ -0,0 +1,58 @@ +/** + * Lease timing constants and helpers for the cloud-agent system. + * + * Leases are used to track ownership of execution resources. + * A consumer must periodically renew its lease via heartbeats + * to maintain exclusive access to an execution. + */ + +// --------------------------------------------------------------------------- +// Timing Constants +// --------------------------------------------------------------------------- + +/** Duration of a lease in milliseconds (90 seconds) */ +export const LEASE_TTL_MS = 90_000; + +/** Interval for heartbeat messages in milliseconds (30 seconds) */ +export const HEARTBEAT_INTERVAL_MS = 30_000; + +/** Threshold for considering an execution stale (10 minutes) - used by reaper */ +export const STALE_THRESHOLD_MS = 10 * 60 * 1000; + +// --------------------------------------------------------------------------- +// Lease Helpers +// --------------------------------------------------------------------------- + +/** + * Calculate when a lease should expire. + * + * @param now - Current timestamp (defaults to Date.now()) + * @returns Unix timestamp when the lease expires + */ +export function calculateExpiry(now: number = Date.now()): number { + return now + LEASE_TTL_MS; +} + +/** + * Check if a lease has expired. + * + * @param expiresAt - Lease expiry timestamp + * @param now - Current timestamp (defaults to Date.now()) + * @returns true if the lease has expired + */ +export function isExpired(expiresAt: number, now: number = Date.now()): boolean { + return now >= expiresAt; +} + +/** + * Check if an execution is stale and should be cleaned up by the reaper. + * An execution is stale if it hasn't received a heartbeat in STALE_THRESHOLD_MS. + * + * @param lastHeartbeat - Timestamp of last heartbeat (undefined if never received) + * @param now - Current timestamp (defaults to Date.now()) + * @returns true if the execution is stale + */ +export function isStale(lastHeartbeat: number | undefined, now: number = Date.now()): boolean { + if (!lastHeartbeat) return true; + return now - lastHeartbeat > STALE_THRESHOLD_MS; +} diff --git a/cloud-agent-next/src/db/SqlStore.ts b/cloud-agent-next/src/db/SqlStore.ts new file mode 100644 index 0000000000..3ba52c982b --- /dev/null +++ b/cloud-agent-next/src/db/SqlStore.ts @@ -0,0 +1,22 @@ +import type { Database, QueryParams, Transaction } from './database.js'; + +export class SqlStore { + constructor(public db: Database | Transaction) {} + + async begin(transaction: (tx: Transaction) => Promise): Promise { + if (this.db.__kind === 'Database') { + return this.db.begin(tx => transaction(tx)); + } + + return transaction(this.db); + } + + async query(query: Query, params: QueryParams): Promise { + try { + return await this.db.query(query, params); + } catch (e) { + console.log('error executing query: ', query, e); + throw e; + } + } +} diff --git a/cloud-agent-next/src/db/database.ts b/cloud-agent-next/src/db/database.ts new file mode 100644 index 0000000000..9860e118fe --- /dev/null +++ b/cloud-agent-next/src/db/database.ts @@ -0,0 +1,90 @@ +/** + * This module serves as a [facade](https://en.wikipedia.org/wiki/Facade_pattern) + * to underlying postgres drivers (node-postgres, postgres.js). We are currently + * using node-postgres but we previously used postgres.js. In order to aide in the + * migration to node-postgres, we created this common interface module for creating + * generic "Database" connections which wrap either node-postgres or postgres.js. + * While we currently only use node-postgres, this same pattern could be applied + * to a range of databases, including d1 or sqlite in DO's + */ + +import { createNodePostgresConnection } from './node-postgres.js'; + +/** + * The primary interface into a database + */ +export type Database = { + __kind: 'Database'; + + /** + * Query the database connection + */ + query: (text: Query, values: QueryParams) => Promise; + + /** + * Begin a transaction. All code executed inside of transactionFn are performed + * within the context of the transaction + */ + begin: (transactionFn: (tx: Transaction) => Promise) => Promise; + + /** + * End a database connection. This function is for compatibility with postgres.js + * and is typically not used with node-postgres + */ + end: () => Promise; +}; + +export type Transaction = Pick & { + __kind: 'Transaction'; + rollback: () => Promise; +}; + +// Query helper types +type Separator = '\n' | ' '; + +type Trim = T extends `${infer Char}${infer Rest}` + ? Char extends Separator + ? Trim + : Trim + : T extends '' + ? Acc + : never; + +/** + * Recursively extracts numbered parameters (e.g. $1, $2...) from a + * sql query string and collects them into a tuple (e.g. ['1', '2']) + */ +export type QueryStringParams< + T extends string, + ExistingParams extends string[] = [], +> = T extends `${string}$${number}${number}${string}` + ? T extends `${string}$${infer NextParamDigit1}${infer NextParamDigit2}${infer Rest extends string}` + ? Rest extends string + ? QueryStringParams< + Rest, + [...ExistingParams, `${Trim}${Trim}`] + > + : [...ExistingParams, `${Trim}${Trim}`] + : ExistingParams + : T extends `${string}$${number}${string}` + ? T extends `${string}$${infer NextParam}${infer Rest extends string}` + ? Rest extends string + ? QueryStringParams + : [...ExistingParams, NextParam] + : ExistingParams + : ExistingParams; + +export type QueryParams = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [K in QueryStringParams[number]]: any; +}; + +export type CreateDatabaseConnection = (connectionString: string) => Database; + +/** + * The canoical createDatabaseConnection. Currently is pointing to the node-postgres.connection. + * If we decided we want to change out drivers across all of banda-infra, we'd update this + * function to point to the other driver. + */ +export const createDatabaseConnection: CreateDatabaseConnection = connectionString => + createNodePostgresConnection(connectionString); diff --git a/cloud-agent-next/src/db/node-postgres.ts b/cloud-agent-next/src/db/node-postgres.ts new file mode 100644 index 0000000000..fe6f0b97a4 --- /dev/null +++ b/cloud-agent-next/src/db/node-postgres.ts @@ -0,0 +1,68 @@ +import { Pool, types } from 'pg'; + +import type { CreateDatabaseConnection, Database } from './database.js'; + +// Default postgres behavior is to use strings for big ints. This parses them +// as regular numbers +types.setTypeParser(types.builtins.INT8, val => parseInt(val, 10)); + +// Ensure timestamptz values are properly handled as Date objects +// types.setTypeParser(types.builtins.TIMESTAMPTZ, (val) => new Date(val)) +// types.setTypeParser(types.builtins.TIMESTAMP, (val) => new Date(val)) + +export const createNodePostgresConnection: CreateDatabaseConnection = connectionString => { + const pool = new Pool({ + connectionString, + max: 100, + statement_timeout: 10 * 1000, + }); + + pool.on('error', error => console.error('Pool:error - Unexpected error on idle client', error)); + + return { + __kind: 'Database', + begin: async transactionFn => { + // Pull an available client from pg-pool + const client = await pool.connect(); + + try { + // Start the transaction + await client.query('begin'); + + // Call user provided transaction function + const result = await transactionFn({ + __kind: 'Transaction', + query: async (text, values = {}) => { + const { rows } = await client.query(text, Object.values(values)); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return rows ?? []; + }, + rollback: async () => { + await client.query('rollback'); + }, + }); + + // Commit the results + await client.query('commit'); + + return result; + } catch (e) { + // Rollback if there were any errors + await client.query('rollback'); + throw e; + } finally { + // Always release the client back to the pool! + client.release(); + } + }, + end: async () => { + // no-op with node-postgres since we just query from the pool + }, + query: async (text, values = {}) => { + const result = await pool.query(text, Object.values(values)); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return result.rows ?? []; + }, + // casting as Database here so we don't have to manually fill in function args + } satisfies Database; +}; diff --git a/cloud-agent-next/src/db/stores/PlatformIntegrationsStore.ts b/cloud-agent-next/src/db/stores/PlatformIntegrationsStore.ts new file mode 100644 index 0000000000..bfdf99dcd6 --- /dev/null +++ b/cloud-agent-next/src/db/stores/PlatformIntegrationsStore.ts @@ -0,0 +1,67 @@ +/* eslint-disable @typescript-eslint/restrict-template-expressions */ +import { SqlStore } from '../SqlStore.js'; +import { + platform_integrations, + PlatformIntegrationLookupSchema, + type PlatformIntegrationLookup, +} from '../tables/platform-integrations.table.js'; +import { organization_memberships } from '../tables/organization-memberships.table.js'; + +type FindInstallationParams = { + repoOwner: string; + userId: string; + orgId?: string; +}; + +export class PlatformIntegrationsStore extends SqlStore { + /** + * Find a GitHub App installation ID for a given repo owner and user/org context. + * + * SECURITY: When looking up org installations, we JOIN with organization_memberships + * to verify the user is actually a member of the organization. This prevents users + * from accessing installations for orgs they don't belong to. + * + * Prioritizes org installations over user installations. + */ + async findGitHubInstallation( + params: FindInstallationParams + ): Promise { + const rows = await this.query( + /* sql */ ` + SELECT + ${platform_integrations.platform_installation_id}, + ${platform_integrations.platform_account_login}, + ${platform_integrations.github_app_type} + FROM ${platform_integrations} + -- For org installations, verify user is a member of the org + LEFT JOIN ${organization_memberships} + ON ${platform_integrations.owned_by_organization_id} = ${organization_memberships.organization_id} + AND ${organization_memberships.kilo_user_id} = $3 + WHERE ${platform_integrations.platform} = 'github' + AND ${platform_integrations.integration_type} = 'app' + AND ${platform_integrations.integration_status} = 'active' + AND ${platform_integrations.platform_account_login} = $1 + AND ( + -- Org installation: must match org ID AND user must be a member + (${platform_integrations.owned_by_organization_id} IS NOT NULL + AND ${platform_integrations.owned_by_organization_id} = $2::uuid + AND ${organization_memberships.id} IS NOT NULL) + OR + -- User installation: must match user ID directly + (${platform_integrations.owned_by_user_id} IS NOT NULL + AND ${platform_integrations.owned_by_user_id} = $3) + ) + ORDER BY + CASE WHEN ${platform_integrations.owned_by_organization_id} IS NOT NULL THEN 0 ELSE 1 END + LIMIT 1 + `, + { 1: params.repoOwner, 2: params.orgId ?? null, 3: params.userId } + ); + + if (rows.length === 0) { + return null; + } + + return PlatformIntegrationLookupSchema.parse(rows[0]); + } +} diff --git a/cloud-agent-next/src/db/table.ts b/cloud-agent-next/src/db/table.ts new file mode 100644 index 0000000000..413fc50bd4 --- /dev/null +++ b/cloud-agent-next/src/db/table.ts @@ -0,0 +1,159 @@ +/* eslint-disable @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unsafe-return */ +import type { z } from 'zod'; + +export type TableInput = { + name: string; + columns: readonly string[]; +}; + +export type TableQueryInterpolator = { + // The name of the table. Prefixed with underscore to avoid + // conflicting with a column named "name" + _name: T['name']; + // Holds the un-prefixed version of column names e.g. "id" + columns: { + [K in T['columns'][number]]: K; + }; + // The valueOf and toString functions ensure that when using + // this object as a regular value, it gets turned into the + // the name of the table + valueOf: () => T['name']; + toString: () => T['name']; +} & { + // Mix-in prefixed versions of columns e.g. "users.id" + [K in T['columns'][number]]: `${T['name']}.${K}`; +}; + +/** + * Get a convenient object for interpolating a sql table name and columns + * into a template string. + * @example + * const users = getTable('users', ['id', 'email']) + * const query = `select ${users.email} from ${users} where ${users.id} = $1` + * @param table Table description + */ +export function getTable(table: T): TableQueryInterpolator { + const columns: { + [K in T['columns'][number]]: K; + // Need any type here because we populate this object in the loop below + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } = {} as any; + + const columnsWithTable: { + [K in T['columns'][number]]: `${T['name']}.${K}`; + // Need any type here because we populate this object in the loop below + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } = {} as any; + + for (const key of table.columns) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (columns as any)[key] = key; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (columnsWithTable as any)[key] = [table.name, key].join('.'); + } + + const result: TableQueryInterpolator = { + _name: table.name, + valueOf() { + return table.name; + }, + toString() { + return table.name; + }, + columns, + ...columnsWithTable, + }; + + return result; +} + +/** + * Get a convenient object for interpolating a sql table name and columns + * into a template string from a Zod Object schema. + * @example + * const UserRecord = z.object({ id: z.string(), email: z.string() }) + * const users = getTableFromZodSchema('users', UserRecord) + * const query = `select ${users.email} from ${users} where ${users.id} = $1` + * @param name The name of your table + * @param schema The Zod object schema + */ +export function getTableFromZodSchema< + Name extends string, + Schema extends z.ZodObject, +>( + name: Name, + schema: Schema +): TableQueryInterpolator<{ + name: Name; + columns: Array, string>>; +}> { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return getTable({ name, columns: Object.keys(schema.shape) }) as any; +} + +export type BaseTableQueryInterpolator = TableQueryInterpolator<{ + name: string; + columns: []; +}>; + +export type TablePostgresTypeMap = { + [K in keyof T['columns']]: string; +}; + +/** + * Given a table and a mapping of column names to postgres types, + * return the create table and alter table statements to safely + * migrate this entity. + * @param table The table description + * @param columnTypeMap A mapping of table columns to postgres types + */ +export function getCreateTableQueryFromTable( + table: T, + columnTypeMap: TablePostgresTypeMap +): string { + const alterTableStatements = objectKeys(table.columns) + .map( + k => /* sql */ `alter table "${table}" add column if not exists "${k}" ${columnTypeMap[k]};` + ) + .join('\n'); + + return /* sql */ ` +create table if not exists "${table}"(); +${alterTableStatements} + `.trim(); +} + +function objectKeys(obj: T): Array { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return Object.keys(obj as any) as any; +} + +export function getCreateTypeFromZodEnum /* maybe one day uncomment this code > */( + name: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + zodEnum: any /* ZEnum */ +): string { + return /* sql */ ` +DO $$ +begin + perform add_type('${name}', 'enum ()'); +end$$; + +-- Types need to be committed before they're used +start transaction; +${Object.keys(zodEnum.enum) + .map(k => /* sql */ `alter type ${name} add value if not exists '${k}';`) + .join('\n')} +commit; + `.trim(); +} + +export function getCreateModifiedOnTriggerForTable< + T extends TableQueryInterpolator<{ name: string; columns: ['modified_on'] }>, +>(table: T): string { + return /* sql */ ` +create or replace trigger update_${table}_modified_on +before update on ${table} +for each row execute procedure update_modified_on(); + `.trim(); +} diff --git a/cloud-agent-next/src/db/tables/README.md b/cloud-agent-next/src/db/tables/README.md new file mode 100644 index 0000000000..426794ac9b --- /dev/null +++ b/cloud-agent-next/src/db/tables/README.md @@ -0,0 +1,40 @@ +# Table Schemas + +This directory contains Zod schemas that mirror the SQLite table definitions in [../persistence/migrations.ts](../../persistence/migrations.ts). + +## Purpose + +These schemas provide: + +1. **Type-safe SQL query building** via `getTableFromZodSchema()` - table and column names are interpolated into SQL strings with compile-time safety +2. **Runtime validation** - `ZodSchema.parse(row)` validates query results and provides typed objects +3. **Partial schemas** for queries returning fewer columns (e.g., `RETURNING id`, `COUNT(*)`) + +## Usage + +```typescript +import { events, EventRecord } from '../db/tables/index.js'; + +const { columns: cols } = events; + +// INSERT - use cols.* for unqualified column names +sql.exec( + `INSERT INTO ${events} (${cols.execution_id}, ${cols.stream_event_type}) VALUES (?, ?)`, + executionId, + eventType +); + +// SELECT/WHERE - use events.* for qualified column names +const result = sql.exec( + `SELECT ${events.id} FROM ${events} WHERE ${events.execution_id} = ?`, + executionId +); + +// Parse and validate the result +const row = [...result][0]; +const parsed = EventRecord.pick({ id: true }).parse(row); +``` + +## ⚠️ Important: Keep in Sync with Migrations + +When modifying table schemas in [../../persistence/migrations.ts](../../persistence/migrations.ts), you **must** update the corresponding Zod schema in this directory to keep them in sync. `ZodSchema.parse(row)` validates at runtime, so mismatches between the actual DB schema and the Zod schema will throw errors when queries are executed. diff --git a/cloud-agent-next/src/db/tables/events.table.ts b/cloud-agent-next/src/db/tables/events.table.ts new file mode 100644 index 0000000000..fed7f7f60d --- /dev/null +++ b/cloud-agent-next/src/db/tables/events.table.ts @@ -0,0 +1,56 @@ +/** + * Events table schema for CloudAgentSession Durable Object. + */ + +import { z } from 'zod'; +import { getTableFromZodSchema } from '../../utils/table.js'; + +/** + * Full event record schema. + * Use for full-row queries like findByFilters(). + */ +export const EventRecord = z.object({ + id: z.number(), + execution_id: z.string(), + session_id: z.string(), + stream_event_type: z.string(), + payload: z.string(), + timestamp: z.number(), +}); + +export type EventRecord = z.infer; + +/** + * Partial schemas for queries returning fewer columns. + */ +export const EventIdOnly = EventRecord.pick({ id: true }); +export type EventIdOnly = z.infer; + +/** + * Schema for getLatestEventId() which returns MAX(id). + */ +export const MaxIdResult = z.object({ + max_id: z.number().nullable(), +}); +export type MaxIdResult = z.infer; + +/** + * Schema for countByExecutionId() which returns COUNT(*). + */ +export const CountResult = z.object({ + count: z.number(), +}); +export type CountResult = z.infer; + +/** + * Table interpolator for type-safe SQL queries. + * + * @example + * // Use events.columns.* for INSERT column lists (unqualified) + * const { columns: cols } = events; + * sql.exec(`INSERT INTO ${events} (${cols.execution_id}, ...) VALUES (?, ...)`, ...); + * + * // Use events.* for SELECT/WHERE/ORDER (qualified) + * sql.exec(`SELECT ${events.id} FROM ${events} WHERE ${events.execution_id} = ?`, ...); + */ +export const events = getTableFromZodSchema('events', EventRecord); diff --git a/cloud-agent-next/src/db/tables/execution-leases.table.ts b/cloud-agent-next/src/db/tables/execution-leases.table.ts new file mode 100644 index 0000000000..59be0b78b3 --- /dev/null +++ b/cloud-agent-next/src/db/tables/execution-leases.table.ts @@ -0,0 +1,45 @@ +/** + * Execution leases table schema for CloudAgentSession Durable Object. + */ + +import { z } from 'zod'; +import { getTableFromZodSchema } from '../../utils/table.js'; + +/** + * Full execution lease record schema. + * Use for full-row queries like get() and findExpired(). + */ +export const ExecutionLeaseRecord = z.object({ + execution_id: z.string(), + lease_id: z.string(), + lease_expires_at: z.number(), + updated_at: z.number(), + message_id: z.string().nullable(), +}); + +export type ExecutionLeaseRecord = z.infer; + +/** + * Partial schemas for queries returning fewer columns. + */ +export const LeaseIdAndExpiry = ExecutionLeaseRecord.pick({ + lease_id: true, + lease_expires_at: true, +}); +export type LeaseIdAndExpiry = z.infer; + +export const LeaseIdOnly = ExecutionLeaseRecord.pick({ lease_id: true }); +export type LeaseIdOnly = z.infer; + +/** + * Table interpolator for type-safe SQL queries. + * + * @example + * // Use execution_leases.columns.* for INSERT column lists (unqualified) + * const { columns: cols } = execution_leases; + * sql.exec(`INSERT INTO ${execution_leases} (${cols.execution_id}, ...) VALUES (?, ...)`, ...); + * + * // Use execution_leases.* for SELECT/WHERE/ORDER (qualified) + * sql.exec(`SELECT ${execution_leases.lease_id} FROM ${execution_leases} WHERE ${execution_leases.execution_id} = ?`, ...); + */ +export const execution_leases = getTableFromZodSchema('execution_leases', ExecutionLeaseRecord); diff --git a/cloud-agent-next/src/db/tables/index.ts b/cloud-agent-next/src/db/tables/index.ts new file mode 100644 index 0000000000..95302b7866 --- /dev/null +++ b/cloud-agent-next/src/db/tables/index.ts @@ -0,0 +1,27 @@ +/** + * Table definitions barrel export for CloudAgentSession Durable Object. + * + * Re-exports all table schemas and interpolators for use in query modules. + */ + +export { + events, + EventRecord, + EventIdOnly, + MaxIdResult, + CountResult, + type EventRecord as EventRecordType, + type EventIdOnly as EventIdOnlyType, + type MaxIdResult as MaxIdResultType, + type CountResult as CountResultType, +} from './events.table.js'; + +export { + execution_leases, + ExecutionLeaseRecord, + LeaseIdAndExpiry, + LeaseIdOnly, + type ExecutionLeaseRecord as ExecutionLeaseRecordType, + type LeaseIdAndExpiry as LeaseIdAndExpiryType, + type LeaseIdOnly as LeaseIdOnlyType, +} from './execution-leases.table.js'; diff --git a/cloud-agent-next/src/db/tables/organization-memberships.table.ts b/cloud-agent-next/src/db/tables/organization-memberships.table.ts new file mode 100644 index 0000000000..ef7070bc5c --- /dev/null +++ b/cloud-agent-next/src/db/tables/organization-memberships.table.ts @@ -0,0 +1,7 @@ +import { getTable } from '../table.js'; + +// Table query interpolator for organization membership verification +export const organization_memberships = getTable({ + name: 'organization_memberships', + columns: ['id', 'organization_id', 'kilo_user_id', 'role'] as const, +}); diff --git a/cloud-agent-next/src/db/tables/platform-integrations.table.ts b/cloud-agent-next/src/db/tables/platform-integrations.table.ts new file mode 100644 index 0000000000..1998101cec --- /dev/null +++ b/cloud-agent-next/src/db/tables/platform-integrations.table.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; +import { getTable } from '../table.js'; + +// Table query interpolator for platform_integrations +export const platform_integrations = getTable({ + name: 'platform_integrations', + columns: [ + 'id', + 'owned_by_user_id', + 'owned_by_organization_id', + 'platform', + 'integration_type', + 'platform_installation_id', + 'platform_account_login', + 'integration_status', + 'github_app_type', + ] as const, +}); + +// Zod schema for the lookup result (only the fields we need) +export const PlatformIntegrationLookupSchema = z.object({ + platform_installation_id: z.string(), + platform_account_login: z.string(), + github_app_type: z.enum(['standard', 'lite']).nullable().optional(), +}); + +export type PlatformIntegrationLookup = z.infer; diff --git a/cloud-agent-next/src/execution/errors.ts b/cloud-agent-next/src/execution/errors.ts new file mode 100644 index 0000000000..43fc762ea1 --- /dev/null +++ b/cloud-agent-next/src/execution/errors.ts @@ -0,0 +1,136 @@ +/** + * Execution error types and error codes. + * + * These errors are surfaced to clients via HTTP status codes: + * - 409 Conflict: EXECUTION_IN_PROGRESS + * - 503 Service Unavailable: Retryable errors (sandbox, workspace, server, wrapper) + * - 4xx/5xx: Non-retryable errors + */ + +/** + * Error codes for transient/retryable failures (503). + * Client should retry with backoff. + */ +export type RetryableErrorCode = + | 'SANDBOX_CONNECT_FAILED' // Sandbox may be waking up or network issue + | 'WORKSPACE_SETUP_FAILED' // Git clone/network transient failure + | 'KILO_SERVER_FAILED' // Kilo server starting up + | 'WRAPPER_START_FAILED'; // Wrapper process starting + +/** + * Error codes for conflict errors (409). + */ +export type ConflictErrorCode = 'EXECUTION_IN_PROGRESS'; + +/** + * Error codes for non-retryable failures (4xx/5xx). + */ +export type PermanentErrorCode = + | 'INVALID_REQUEST' // Bad input (missing fields, invalid format) + | 'SESSION_NOT_FOUND' // Session doesn't exist + | 'WRAPPER_JOB_CONFLICT'; // Wrapper busy (internal error - shouldn't happen) + +/** + * All possible execution error codes. + */ +export type ExecutionErrorCode = RetryableErrorCode | ConflictErrorCode | PermanentErrorCode; + +/** + * Options for creating an ExecutionError. + */ +export type ExecutionErrorOptions = { + /** Whether the error is retryable (affects HTTP status code mapping) */ + retryable: boolean; + /** For EXECUTION_IN_PROGRESS, the ID of the active execution */ + activeExecutionId?: string; + /** Original error that caused this (for logging/debugging) */ + cause?: unknown; +}; + +/** + * Structured error for execution failures. + * Maps to appropriate HTTP status codes in tRPC handlers. + */ +export class ExecutionError extends Error { + readonly code: ExecutionErrorCode; + readonly retryable: boolean; + readonly activeExecutionId?: string; + + constructor(code: ExecutionErrorCode, message: string, options: ExecutionErrorOptions) { + super(message, { cause: options.cause }); + this.name = 'ExecutionError'; + this.code = code; + this.retryable = options.retryable; + this.activeExecutionId = options.activeExecutionId; + } + + /** + * Create a retryable error for sandbox connection failures. + */ + static sandboxConnectFailed(message: string, cause?: unknown): ExecutionError { + return new ExecutionError('SANDBOX_CONNECT_FAILED', message, { retryable: true, cause }); + } + + /** + * Create a retryable error for workspace setup failures. + */ + static workspaceSetupFailed(message: string, cause?: unknown): ExecutionError { + return new ExecutionError('WORKSPACE_SETUP_FAILED', message, { retryable: true, cause }); + } + + /** + * Create a retryable error for kilo server failures. + */ + static kiloServerFailed(message: string, cause?: unknown): ExecutionError { + return new ExecutionError('KILO_SERVER_FAILED', message, { retryable: true, cause }); + } + + /** + * Create a retryable error for wrapper start failures. + */ + static wrapperStartFailed(message: string, cause?: unknown): ExecutionError { + return new ExecutionError('WRAPPER_START_FAILED', message, { retryable: true, cause }); + } + + /** + * Create a conflict error when execution is already in progress. + */ + static executionInProgress(activeExecutionId: string): ExecutionError { + return new ExecutionError( + 'EXECUTION_IN_PROGRESS', + `Execution ${activeExecutionId} is in progress`, + { retryable: false, activeExecutionId } + ); + } + + /** + * Create a non-retryable error for invalid requests. + */ + static invalidRequest(message: string): ExecutionError { + return new ExecutionError('INVALID_REQUEST', message, { retryable: false }); + } + + /** + * Create a non-retryable error when session is not found. + */ + static sessionNotFound(sessionId: string): ExecutionError { + return new ExecutionError('SESSION_NOT_FOUND', `Session ${sessionId} not found`, { + retryable: false, + }); + } + + /** + * Create a non-retryable error for wrapper job conflicts. + */ + static wrapperJobConflict(message: string): ExecutionError { + return new ExecutionError('WRAPPER_JOB_CONFLICT', message, { retryable: false }); + } +} + +/** + * Type guard to check if an error is an ExecutionError. + * Use error.retryable and error.code directly for condition checks. + */ +export function isExecutionError(error: unknown): error is ExecutionError { + return error instanceof ExecutionError; +} diff --git a/cloud-agent-next/src/execution/orchestrator.ts b/cloud-agent-next/src/execution/orchestrator.ts new file mode 100644 index 0000000000..3746ee504d --- /dev/null +++ b/cloud-agent-next/src/execution/orchestrator.ts @@ -0,0 +1,430 @@ +/** + * ExecutionOrchestrator - Handles prompt execution. + * + * This module handles workspace preparation and execution, called directly + * from the DO when a client sends a prompt. + * + * The key insight is that executionId === messageId for correlation. + */ + +import type { + Env, + SandboxInstance, + SandboxId as ServiceSandboxId, + SessionId as ServiceSessionId, + SessionContext, +} from '../types.js'; +import type { CloudAgentSession } from '../persistence/CloudAgentSession.js'; +import type { ExecutionPlan, ExecutionResult } from './types.js'; +import { ExecutionError } from './errors.js'; +import { SessionService, type PreparedSession } from '../session-service.js'; +import { logger } from '../logger.js'; +import { updateGitRemoteToken } from '../workspace.js'; +import { ensureKiloServer } from '../kilo/server-manager.js'; +import { WrapperClient } from '../kilo/wrapper-client.js'; +import { withDORetry } from '../utils/do-retry.js'; +import { normalizeAgentMode } from '../schema.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Dependencies for the orchestrator (for testability via dependency injection). + */ +export type OrchestratorDeps = { + /** Get a sandbox instance by ID */ + getSandbox: (sandboxId: string) => Promise; + /** Get a Durable Object stub for a session */ + getSessionStub: (userId: string, sessionId: string) => DurableObjectStub; + /** Get the ingest URL for the session */ + getIngestUrl: (sessionId: string, userId: string) => string; + /** Environment bindings */ + env: Env; +}; + +// --------------------------------------------------------------------------- +// ExecutionOrchestrator +// --------------------------------------------------------------------------- + +export class ExecutionOrchestrator { + private readonly deps: OrchestratorDeps; + private readonly sessionService: SessionService; + + constructor(deps: OrchestratorDeps) { + this.deps = deps; + this.sessionService = new SessionService(); + } + + /** + * Execute a prompt. Handles all setup and returns immediately after prompt is sent. + * Events stream asynchronously via wrapper -> ingest WS. + * + * @throws ExecutionError with appropriate code on failure (no internal retry) + */ + async execute(plan: ExecutionPlan): Promise { + const { executionId, sessionId, userId, orgId, prompt, mode, workspace, wrapper } = plan; + + logger.setTags({ + executionId, + sessionId, + userId, + orgId: orgId ?? '(personal)', + mode, + isInitialize: workspace.shouldPrepare, + }); + + logger.info('ExecutionOrchestrator starting execution'); + + // 1. Get sandbox (may throw SANDBOX_CONNECT_FAILED) + const sandboxId = workspace.sandboxId; + if (!sandboxId) { + throw ExecutionError.invalidRequest('Missing sandboxId in workspace plan'); + } + + let sandbox: SandboxInstance; + try { + sandbox = await this.deps.getSandbox(sandboxId); + } catch (error) { + throw ExecutionError.sandboxConnectFailed( + `Failed to connect to sandbox: ${error instanceof Error ? error.message : String(error)}`, + error + ); + } + + // 2. Workspace preparation (may throw WORKSPACE_SETUP_FAILED) + const prepared = await this.prepareWorkspace(sandbox, plan); + + // 3. Update git remote token if needed (resume path with token overrides) + if (!workspace.shouldPrepare) { + const resumeContext = workspace.resumeContext; + if (resumeContext.githubToken || resumeContext.gitToken) { + await this.updateTokenOverrides(prepared, workspace); + } + } + + // 4. Ensure kilo server is running (may throw KILO_SERVER_FAILED) + let kiloServerPort: number; + try { + kiloServerPort = await ensureKiloServer( + sandbox, + prepared.session, + sessionId, + prepared.context.workspacePath + ); + } catch (error) { + throw ExecutionError.kiloServerFailed( + `Failed to start kilo server: ${error instanceof Error ? error.message : String(error)}`, + error + ); + } + + // Record kilo server activity for idle timeout tracking + try { + await withDORetry( + () => this.deps.getSessionStub(userId, sessionId), + stub => stub.recordKiloServerActivity(), + 'recordKiloServerActivity' + ); + } catch { + // Non-fatal - log but continue + logger.warn('Failed to record kilo server activity'); + } + + // 5. Ensure wrapper is running (may throw WRAPPER_START_FAILED) + let wrapperClient: WrapperClient; + try { + wrapperClient = await WrapperClient.ensureWrapper( + sandbox, + prepared.session, + sessionId, + kiloServerPort, + prepared.context.workspacePath + ); + } catch (error) { + throw ExecutionError.wrapperStartFailed( + `Failed to start wrapper: ${error instanceof Error ? error.message : String(error)}`, + error + ); + } + + // 6. Start job (create/resume kilo session) + const ingestUrl = this.deps.getIngestUrl(sessionId, userId); + // Ingest token must match executionId for /ingest auth validation + const ingestToken = executionId; + + // Get kilocode token from plan + const kilocodeToken = this.getKilocodeToken(plan); + + let kiloSessionId: string; + try { + const result = await wrapperClient.startJob({ + executionId, + ingestUrl, + ingestToken, + sessionId, + userId, + kilocodeToken, + kiloSessionId: wrapper.kiloSessionId, + kiloSessionTitle: wrapper.kiloSessionTitle, + }); + kiloSessionId = result.kiloSessionId; + logger.withFields({ kiloSessionId }).info('Wrapper job started'); + } catch (error) { + throw ExecutionError.wrapperStartFailed( + `Failed to start wrapper job: ${error instanceof Error ? error.message : String(error)}`, + error + ); + } + + // 7. Send prompt (async - returns messageId immediately) + // Pass executionId as messageId to satisfy "executionId === messageId" invariant + // Normalize mode to internal mode (e.g., 'architect' -> 'plan', 'orchestrator' -> 'code') + const normalizedMode = normalizeAgentMode(mode); + let messageId: string; + try { + const result = await wrapperClient.prompt({ + prompt, + model: wrapper.model, + agent: normalizedMode, + messageId: executionId, + }); + messageId = result.messageId; + logger.withFields({ messageId }).info('Prompt sent to wrapper'); + } catch (error) { + throw ExecutionError.wrapperStartFailed( + `Failed to send prompt: ${error instanceof Error ? error.message : String(error)}`, + error + ); + } + + logger.info('ExecutionOrchestrator execution started successfully'); + return { messageId, kiloSessionId }; + } + + // --------------------------------------------------------------------------- + // Private Helpers + // --------------------------------------------------------------------------- + + /** + * Prepare workspace based on the workspace plan. + * Handles three paths: resume, fast path (fully prepared), and full init. + */ + private async prepareWorkspace( + sandbox: SandboxInstance, + plan: ExecutionPlan + ): Promise { + const { workspace, sessionId, userId, orgId } = plan; + + try { + if (!workspace.shouldPrepare) { + // Resume path - workspace already set up + const resumeContext = workspace.resumeContext; + + if (!resumeContext.kilocodeToken) { + throw new Error('Missing kilocodeToken in resume context'); + } + + return await this.sessionService.resume({ + sandbox, + sandboxId: workspace.sandboxId as ServiceSandboxId, + orgId, + userId, + sessionId: sessionId as ServiceSessionId, + kilocodeToken: resumeContext.kilocodeToken, + kilocodeModel: 'default', + env: this.deps.env, + githubToken: resumeContext.githubToken, + gitToken: resumeContext.gitToken, + }); + } + + const initContext = workspace.initContext; + if (!initContext) { + throw new Error('Missing initContext in workspace plan'); + } + + const existingMetadata = workspace.existingMetadata; + + // Fast path: fully prepared via prepareSession + // Fast path: fully prepared session with all required metadata + if ( + initContext.isPreparedSession && + existingMetadata?.workspacePath && + existingMetadata?.sandboxId && + existingMetadata?.sessionHome && + existingMetadata?.branchName + ) { + logger.info('Using fast path for fully prepared session'); + + const context: SessionContext = { + sandboxId: existingMetadata.sandboxId as ServiceSandboxId, + sessionId: sessionId as ServiceSessionId, + sessionHome: existingMetadata.sessionHome, + workspacePath: existingMetadata.workspacePath, + branchName: existingMetadata.branchName, + upstreamBranch: existingMetadata.upstreamBranch, + orgId, + userId, + botId: initContext.botId, + githubRepo: initContext.githubRepo, + githubToken: initContext.githubToken, + envVars: initContext.envVars, + }; + + const session = await this.sessionService.getOrCreateSession( + sandbox, + context, + this.deps.env, + initContext.kilocodeToken, + initContext.kilocodeModel ?? 'default', + orgId, + initContext.encryptedSecrets, + undefined, + existingMetadata.appendSystemPrompt + ); + + return { + context, + session, + // eslint-disable-next-line require-yield + streamKilocodeExec: async function* (): AsyncGenerator { + throw new Error('streamKilocodeExec not available for fast path'); + }, + }; + } + + // Legacy prepared session path + if (initContext.isPreparedSession && initContext.kiloSessionId) { + logger.info('Using legacy prepared session path'); + + const gitSource = initContext.githubRepo + ? { githubRepo: initContext.githubRepo, githubToken: initContext.githubToken } + : initContext.gitUrl + ? { gitUrl: initContext.gitUrl, gitToken: initContext.gitToken } + : null; + + if (!gitSource) { + throw new Error('Prepared session is missing git source'); + } + + if (!initContext.kiloSessionId) { + throw new Error('Prepared session is missing kiloSessionId'); + } + + return await this.sessionService.initiateFromKiloSessionWithRetry({ + getSandbox: () => this.deps.getSandbox(workspace.sandboxId ?? ''), + sandboxId: (workspace.sandboxId ?? '') as ServiceSandboxId, + orgId, + userId, + sessionId: sessionId as ServiceSessionId, + kilocodeToken: initContext.kilocodeToken, + kilocodeModel: initContext.kilocodeModel ?? 'default', + kiloSessionId: initContext.kiloSessionId, + env: this.deps.env, + envVars: initContext.envVars, + encryptedSecrets: initContext.encryptedSecrets, + setupCommands: initContext.setupCommands, + mcpServers: initContext.mcpServers, + botId: initContext.botId, + skipLinking: true, + githubAppType: initContext.githubAppType, + // Note: existingMetadata requires CloudAgentSessionState, not our simplified type + ...gitSource, + }); + } + + // Brand new session + logger.info('Initializing new session'); + return await this.sessionService.initiateWithRetry({ + getSandbox: () => this.deps.getSandbox(workspace.sandboxId ?? ''), + sandboxId: (workspace.sandboxId ?? '') as ServiceSandboxId, + orgId, + userId, + sessionId: sessionId as ServiceSessionId, + kilocodeToken: initContext.kilocodeToken, + kilocodeModel: initContext.kilocodeModel ?? 'default', + githubRepo: initContext.githubRepo, + githubToken: initContext.githubToken, + gitUrl: initContext.gitUrl, + gitToken: initContext.gitToken, + env: this.deps.env, + envVars: initContext.envVars, + encryptedSecrets: initContext.encryptedSecrets, + setupCommands: initContext.setupCommands, + mcpServers: initContext.mcpServers, + upstreamBranch: initContext.upstreamBranch, + botId: initContext.botId, + githubAppType: initContext.githubAppType, + }); + } catch (error) { + if (error instanceof ExecutionError) throw error; + throw ExecutionError.workspaceSetupFailed( + `Failed to prepare workspace: ${error instanceof Error ? error.message : String(error)}`, + error + ); + } + } + + /** + * Update git remote token for resume path with token overrides. + */ + private async updateTokenOverrides( + prepared: PreparedSession, + workspace: ExecutionPlan['workspace'] + ): Promise { + if (workspace.shouldPrepare) return; + + const resumeContext = workspace.resumeContext; + const existingMetadata = workspace.existingMetadata; + + if (!existingMetadata) { + logger.warn('Missing metadata for token override update'); + return; + } + + try { + if (resumeContext.githubToken && existingMetadata.githubRepo) { + const gitUrl = `https://github.com/${existingMetadata.githubRepo}.git`; + await updateGitRemoteToken( + prepared.session, + prepared.context.workspacePath, + gitUrl, + resumeContext.githubToken + ); + } + + if (resumeContext.gitToken && existingMetadata.gitUrl) { + await updateGitRemoteToken( + prepared.session, + prepared.context.workspacePath, + existingMetadata.gitUrl, + resumeContext.gitToken + ); + } + } catch (error) { + throw ExecutionError.workspaceSetupFailed( + `Failed to update git remote token: ${error instanceof Error ? error.message : String(error)}`, + error + ); + } + } + + /** + * Get the kilocode token from the plan. + */ + private getKilocodeToken(plan: ExecutionPlan): string { + const { workspace } = plan; + + if (!workspace.shouldPrepare) { + return workspace.resumeContext.kilocodeToken; + } + + const initContext = workspace.initContext; + if (initContext?.kilocodeToken) { + return initContext.kilocodeToken; + } + + throw ExecutionError.invalidRequest('Missing kilocodeToken in execution plan'); + } +} diff --git a/cloud-agent-next/src/execution/types.ts b/cloud-agent-next/src/execution/types.ts new file mode 100644 index 0000000000..74be160d0e --- /dev/null +++ b/cloud-agent-next/src/execution/types.ts @@ -0,0 +1,348 @@ +/** + * Types for the cloud-agent execution system. + * + * This module defines the core types for direct execution without queuing. + * The key insight is that executionId === messageId for correlation. + * + * NOTE: Queue-specific types (ExecutionMessage, WrapperLaunchPlan) have been removed + * as part of the migration to direct execution. + */ + +import type { ExecutionId, SessionId, UserId } from '../types/ids.js'; +import type { AgentMode } from '../schema.js'; +import type { Images, EncryptedSecrets as SchemaEncryptedSecrets } from '../router/schemas.js'; +import type { EncryptedSecrets } from '../utils/encryption.js'; +import type { MCPServerConfig } from '../persistence/types.js'; + +// --------------------------------------------------------------------------- +// Execution Modes +// --------------------------------------------------------------------------- + +/** Mode of execution - passed directly to kilocode CLI */ +export type ExecutionMode = AgentMode; + +/** How the client receives streaming output */ +export type StreamingMode = 'sse' | 'websocket'; + +// --------------------------------------------------------------------------- +// Token Resume Context (for DO token management) +// --------------------------------------------------------------------------- + +/** + * Resume context for follow-up executions (token management). + * Used by CloudAgentSession DO for managing authentication tokens. + */ +export type TokenResumeContext = { + kilocodeToken: string; + kilocodeModel: string; + githubToken?: string; + gitToken?: string; +}; + +// --------------------------------------------------------------------------- +// Initialize Context (for session initialization) +// --------------------------------------------------------------------------- + +/** + * Context for initializing a new session on first execution. + * Contains all parameters needed to set up workspace, clone repos, etc. + * Used by CloudAgentSession DO for the initiate flow. + */ +export type InitializeContext = { + /** Kilocode authentication token */ + kilocodeToken: string; + /** Model to use for Kilocode CLI */ + kilocodeModel?: string; + /** GitHub repository to clone (e.g., "owner/repo") */ + githubRepo?: string; + /** GitHub Personal Access Token for private repos */ + githubToken?: string; + /** Generic Git URL to clone */ + gitUrl?: string; + /** Git token for authentication */ + gitToken?: string; + /** Environment variables to set in the session (plaintext) */ + envVars?: Record; + /** + * Encrypted secret env vars from agent environment profiles. + * Stored encrypted, decrypted only at session execution time. + */ + encryptedSecrets?: SchemaEncryptedSecrets; + /** Setup commands to run after clone (e.g., npm install) */ + setupCommands?: string[]; + /** MCP server configurations */ + mcpServers?: Record; + /** Branch to checkout (if not session-specific) */ + upstreamBranch?: string; + /** Bot ID for sandbox isolation */ + botId?: string; + /** + * Existing Kilo session ID (for prepared sessions). + * When set, the CLI will resume this session instead of creating a new one. + */ + kiloSessionId?: string; + /** + * Flag indicating this is a prepared session (via prepareSession flow). + * When true, use initiateFromKiloSession instead of initiate, + * and skip linking (backend already linked during prepareSession). + */ + isPreparedSession?: boolean; + /** GitHub App type for selecting correct credentials and slug */ + githubAppType?: 'standard' | 'lite'; +}; + +// --------------------------------------------------------------------------- +// V2 Request/Response Types (for DO methods and tRPC handlers) +// --------------------------------------------------------------------------- + +/** + * Common fields shared by all execution request types. + */ +type BaseExecutionRequest = { + userId: UserId; + botId?: string; +}; + +/** + * Request for initiating a new session (full initialization). + */ +type InitiateExecutionRequest = BaseExecutionRequest & { + kind: 'initiate'; + authToken: string; + prompt: string; + mode: ExecutionMode; + model: string; + githubRepo?: string; + githubToken?: string; + gitUrl?: string; + gitToken?: string; + envVars?: Record; + encryptedSecrets?: SchemaEncryptedSecrets; + setupCommands?: string[]; + mcpServers?: Record; + autoCommit?: boolean; + condenseOnComplete?: boolean; + upstreamBranch?: string; + orgId?: string; +}; + +/** + * Request for initiating a prepared session. + */ +type InitiatePreparedRequest = BaseExecutionRequest & { + kind: 'initiatePrepared'; + authToken?: string; +}; + +/** + * Request for follow-up message on existing session. + */ +type FollowupExecutionRequest = BaseExecutionRequest & { + kind: 'followup'; + prompt: string; + mode?: ExecutionMode; + model?: string; + autoCommit?: boolean; + condenseOnComplete?: boolean; + tokenOverrides?: { + githubToken?: string; + gitToken?: string; + }; +}; + +/** + * Request payload for starting a V2 execution. + */ +export type StartExecutionV2Request = + | InitiateExecutionRequest + | InitiatePreparedRequest + | FollowupExecutionRequest; + +/** + * Retryable error codes that map to 503 Service Unavailable. + * These match the TransientErrorResponse schema. + */ +export type RetryableResultCode = + | 'SANDBOX_CONNECT_FAILED' + | 'WORKSPACE_SETUP_FAILED' + | 'KILO_SERVER_FAILED' + | 'WRAPPER_START_FAILED'; + +/** + * Result of starting a V2 execution. + * Returns 409 Conflict if an execution is already in progress. + * + * Error codes: + * - EXECUTION_IN_PROGRESS: 409 Conflict (another execution is running) + * - SANDBOX_CONNECT_FAILED, WORKSPACE_SETUP_FAILED, KILO_SERVER_FAILED, WRAPPER_START_FAILED: 503 Service Unavailable + * - NOT_FOUND: 404 Not Found + * - BAD_REQUEST: 400 Bad Request + * - INTERNAL: 500 Internal Server Error + */ +export type StartExecutionV2Result = + | { + success: true; + executionId: ExecutionId; + status: 'started'; + } + | { + success: false; + code: + | 'NOT_FOUND' + | 'BAD_REQUEST' + | 'INTERNAL' + | 'EXECUTION_IN_PROGRESS' + | RetryableResultCode; + error: string; + /** For EXECUTION_IN_PROGRESS, the currently active execution ID */ + activeExecutionId?: ExecutionId; + }; + +// --------------------------------------------------------------------------- +// Workspace Plan +// --------------------------------------------------------------------------- + +/** + * Context needed to resume an existing workspace (no clone needed). + */ +export type ResumeContext = { + kiloSessionId: string; + workspacePath: string; + kilocodeToken: string; + kilocodeModel?: string; + branchName: string; + /** GitHub token for token refresh (optional) */ + githubToken?: string; + /** Git token for non-GitHub repos (optional) */ + gitToken?: string; +}; + +/** + * Context for initializing a new workspace. + */ +export type InitContext = { + githubRepo?: string; + gitUrl?: string; + githubToken?: string; + gitToken?: string; + envVars?: Record; + setupCommands?: string[]; + upstreamBranch?: string; + kiloSessionId?: string; + isPreparedSession?: boolean; + /** Kilocode API token */ + kilocodeToken: string; + /** Kilocode model to use */ + kilocodeModel?: string; + /** Encrypted secrets for agent environment profiles */ + encryptedSecrets?: EncryptedSecrets; + /** MCP server configurations */ + mcpServers?: Record; + /** Bot ID for bot-specific sessions */ + botId?: string; + /** GitHub app type for determining which app to use */ + githubAppType?: 'lite' | 'standard'; +}; + +/** + * Existing metadata for prepared sessions. + */ +export type ExistingSessionMetadata = { + workspacePath: string; + kiloSessionId: string; + branchName: string; + sandboxId?: string; + sessionHome?: string; + upstreamBranch?: string; + appendSystemPrompt?: string; + /** GitHub repo (for token updates) */ + githubRepo?: string; + /** Git URL (for token updates) */ + gitUrl?: string; +}; + +/** + * Plan for workspace preparation. + * Determines whether to resume existing workspace or set up new one. + */ +export type WorkspacePlan = + | { + shouldPrepare: false; + sandboxId: string; + resumeContext: ResumeContext; + existingMetadata?: ExistingSessionMetadata; + } + | { + shouldPrepare: true; + sandboxId?: string; + initContext: InitContext; + existingMetadata?: ExistingSessionMetadata; + }; + +// --------------------------------------------------------------------------- +// Wrapper Plan +// --------------------------------------------------------------------------- + +/** + * Model configuration for the AI model to use. + */ +export type ModelConfig = { + providerID?: string; + modelID: string; +}; + +/** + * Plan for wrapper execution. + */ +export type WrapperPlan = { + kiloSessionId?: string; + kiloSessionTitle?: string; + model?: ModelConfig; + autoCommit?: boolean; + condenseOnComplete?: boolean; +}; + +// --------------------------------------------------------------------------- +// Execution Plan +// --------------------------------------------------------------------------- + +/** + * Complete plan for executing a prompt. + * Contains all information needed to set up and execute. + */ +export type ExecutionPlan = { + /** Unique execution ID (msg_ format, same as messageId) */ + executionId: ExecutionId; + /** Cloud-agent session ID */ + sessionId: SessionId; + /** User who owns this execution */ + userId: UserId; + /** Organization ID (optional) */ + orgId?: string; + /** The prompt to execute */ + prompt: string; + /** Execution mode */ + mode: AgentMode; + /** Workspace preparation plan */ + workspace: WorkspacePlan; + /** Wrapper configuration plan */ + wrapper: WrapperPlan; + /** Optional image attachments */ + images?: Images; +}; + +// --------------------------------------------------------------------------- +// Execution Result +// --------------------------------------------------------------------------- + +/** + * Result of starting an execution. + * Note: This is returned immediately after the prompt is sent. + * Actual completion is tracked via SSE events. + */ +export type ExecutionResult = { + /** Message ID from wrapper.prompt() - same as executionId */ + messageId: string; + /** Kilo session ID (created or resumed) */ + kiloSessionId: string; +}; diff --git a/cloud-agent-next/src/helpers.ts b/cloud-agent-next/src/helpers.ts new file mode 100644 index 0000000000..078468f082 --- /dev/null +++ b/cloud-agent-next/src/helpers.ts @@ -0,0 +1,19 @@ +import { getSandbox } from '@cloudflare/sandbox'; +import type { TRPCContext } from './types.js'; + +/** + * Sets up a sandbox instance and invokes a callback. + * Simplifies sandbox initialization by encapsulating the setup logic. + * @param ctx The TRPC context containing environment + * @param sandboxId The sandbox identifier for sandbox isolation + * @param fn Async callback that receives the configured sandbox instance + * @returns The result of the callback function + */ +export async function withSandbox( + ctx: TRPCContext, + sandboxId: string, + fn: (sandbox: ReturnType) => Promise +): Promise { + const sandbox = getSandbox(ctx.env.Sandbox, sandboxId); + return await fn(sandbox); +} diff --git a/cloud-agent-next/src/index.ts b/cloud-agent-next/src/index.ts new file mode 100644 index 0000000000..05eb5415ea --- /dev/null +++ b/cloud-agent-next/src/index.ts @@ -0,0 +1,292 @@ +import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; +import { TRPC_ERROR_CODES_BY_KEY } from '@trpc/server/rpc'; +import { WorkerEntrypoint } from 'cloudflare:workers'; +import { appRouter } from './router.js'; +import { authenticate, validateStreamTicket } from './auth.js'; +import type { Env } from './types.js'; +import { logger, withLogTags } from './logger.js'; +import { + validateAuthAndBalance, + extractProcedureName, + fetchOrgIdForSession, + BALANCE_REQUIRED_MUTATIONS, +} from './balance-validation.js'; +import { validateKiloToken } from './auth.js'; +import { createCallbackQueueConsumer } from './callbacks/index.js'; +import type { CallbackJob } from './callbacks/index.js'; + +/** Auth context for creating tRPC context */ +type AuthContext = { + userId: string; + token: string; + botId?: string; +}; + +export default class KilocodeWorker extends WorkerEntrypoint { + /** + * Handles tRPC requests with the given auth context. + * Centralizes fetchRequestHandler configuration to avoid duplication. + */ + private handleTrpcRequest(request: Request, auth: AuthContext): Promise { + return fetchRequestHandler({ + endpoint: '/trpc', + req: request, + router: appRouter, + createContext: async () => ({ + env: this.env, + userId: auth.userId, + authToken: auth.token, + botId: auth.botId, + request, + }), + onError: ({ error, path }) => { + logger.setTags({ path }); + logger + .withFields({ + error: error.message, + stack: error.stack, + }) + .error('tRPC error'); + }, + }); + } + + private buildTrpcErrorResponse(status: number, message: string, path?: string): Response { + const code = (() => { + switch (status) { + case 400: + return 'BAD_REQUEST'; + case 401: + return 'UNAUTHORIZED'; + case 402: + return 'PAYMENT_REQUIRED'; + case 403: + return 'FORBIDDEN'; + case 404: + return 'NOT_FOUND'; + default: + return 'INTERNAL_SERVER_ERROR'; + } + })(); + + return new Response( + JSON.stringify({ + error: { + message, + code: TRPC_ERROR_CODES_BY_KEY[code], + data: { + code, + httpStatus: status, + path, + }, + }, + }), + { + status, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + async fetch(request: Request): Promise { + return withLogTags({ source: 'worker-entry' }, async () => { + const url = new URL(request.url); + logger.setTags({ + method: request.method, + path: url.pathname, // Only log the path, not query params + }); + logger.info('Handling request'); + + // Handle /stream WebSocket endpoint (before tRPC handling) + if (url.pathname === '/stream') { + // 1. Check WebSocket upgrade header + const upgradeHeader = request.headers.get('Upgrade'); + if (upgradeHeader !== 'websocket') { + return new Response('Expected WebSocket upgrade', { status: 426 }); + } + + // 2. Extract cloudAgentSessionId from query params + const cloudAgentSessionId = url.searchParams.get('cloudAgentSessionId'); + if (!cloudAgentSessionId) { + logger.warn('/stream: Missing cloudAgentSessionId parameter'); + return new Response('Missing cloudAgentSessionId parameter', { status: 400 }); + } + + // 3. Validate ticket from URL (browser WebSocket can't send Authorization headers) + const ticket = url.searchParams.get('ticket'); + if (!ticket) { + logger.withFields({ cloudAgentSessionId }).warn('/stream: Missing ticket'); + return new Response('Missing ticket', { status: 401 }); + } + + const ticketResult = validateStreamTicket(ticket, this.env.NEXTAUTH_SECRET); + if (!ticketResult.success) { + logger + .withFields({ cloudAgentSessionId, error: ticketResult.error }) + .warn('/stream: Ticket validation failed'); + return new Response(ticketResult.error, { status: 401 }); + } + + const userId = ticketResult.payload.userId; + if (!userId) { + logger + .withFields({ cloudAgentSessionId }) + .warn('/stream: Invalid ticket - missing userId'); + return new Response('Invalid ticket: missing userId', { status: 401 }); + } + + // 4. Verify ticket cloudAgentSessionId matches URL cloudAgentSessionId + const ticketCloudAgentSessionId = + ticketResult.payload.cloudAgentSessionId || ticketResult.payload.sessionId; + if (ticketCloudAgentSessionId !== cloudAgentSessionId) { + logger + .withFields({ cloudAgentSessionId, ticketCloudAgentSessionId }) + .warn('/stream: Session mismatch between URL and ticket'); + return new Response('Session mismatch', { status: 403 }); + } + + logger + .withFields({ cloudAgentSessionId, userId }) + .info('/stream: WebSocket upgrade authorized'); + + // 5. Get DO stub and proxy the WebSocket upgrade (preserving all query params for filters) + const doId = this.env.CLOUD_AGENT_SESSION.idFromName(`${userId}:${cloudAgentSessionId}`); + const stub = this.env.CLOUD_AGENT_SESSION.get(doId); + + return stub.fetch(request); + } + + const ingestMatch = url.pathname.match(/^\/sessions\/([^/]+)\/([^/]+)\/ingest$/); + if (ingestMatch) { + // Decode userId to handle OAuth IDs like "oauth/google:123" that were URL-encoded + let userId: string; + try { + userId = decodeURIComponent(ingestMatch[1]); + } catch { + return new Response('Invalid userId encoding', { status: 400 }); + } + const sessionId = ingestMatch[2]; + const authHeader = request.headers.get('Authorization'); + const authResult = validateKiloToken(authHeader, this.env.NEXTAUTH_SECRET); + if (!authResult.success) { + return new Response(authResult.error, { status: 401 }); + } + if (authResult.userId !== userId) { + return new Response('Token does not match session user', { status: 403 }); + } + const doId = this.env.CLOUD_AGENT_SESSION.idFromName(`${userId}:${sessionId}`); + const stub = this.env.CLOUD_AGENT_SESSION.get(doId); + const doUrl = new URL(request.url); + doUrl.pathname = '/ingest'; + const doRequest = new Request(doUrl.toString(), request); + return stub.fetch(doRequest); + } + + // Extract procedure name for pre-flight validation + const procedureName = extractProcedureName(url.pathname); + + // Pre-flight validation for V2 mutation endpoints that require balance + // These are POST requests with JSON body containing the input + if (procedureName && BALANCE_REQUIRED_MUTATIONS.has(procedureName)) { + // Check for balance skip header (used by App Builder which handles its own billing) + const skipBalanceCheck = request.headers.get('x-skip-balance-check') === 'true'; + + const authHeader = request.headers.get('authorization'); + + // If skipping balance check, only validate auth (not balance) + if (skipBalanceCheck) { + logger.withFields({ procedure: procedureName }).info('Skipping balance check per header'); + + const authResult = validateKiloToken(authHeader, this.env.NEXTAUTH_SECRET); + if (!authResult.success) { + return this.buildTrpcErrorResponse(401, authResult.error, procedureName); + } + + return this.handleTrpcRequest(request, authResult); + } + + // Clone request to read body without consuming it + const clonedRequest = request.clone(); + let orgId: string | undefined; + let sessionId: string | undefined; + + try { + const body = await clonedRequest.json(); + // tRPC mutations have input at the root level + if (body && typeof body === 'object') { + if ( + 'kilocodeOrganizationId' in body && + typeof body.kilocodeOrganizationId === 'string' + ) { + orgId = body.kilocodeOrganizationId; + } + if ('cloudAgentSessionId' in body && typeof body.cloudAgentSessionId === 'string') { + sessionId = body.cloudAgentSessionId; + } + } + } catch { + return this.buildTrpcErrorResponse(400, 'Invalid request body', procedureName); + } + + // For sendMessageV2, we need to fetch orgId from session metadata if not in input + if (procedureName === 'sendMessageV2' && !orgId && sessionId) { + const authResult = validateKiloToken(authHeader, this.env.NEXTAUTH_SECRET); + if (!authResult.success) { + return this.buildTrpcErrorResponse(401, authResult.error, procedureName); + } + orgId = await fetchOrgIdForSession(this.env, authResult.userId, sessionId); + } + + // For initiateFromKilocodeSessionV2, fetch from session metadata + if (procedureName === 'initiateFromKilocodeSessionV2' && !orgId && sessionId) { + const authResult = validateKiloToken(authHeader, this.env.NEXTAUTH_SECRET); + if (!authResult.success) { + return this.buildTrpcErrorResponse(401, authResult.error, procedureName); + } + orgId = await fetchOrgIdForSession(this.env, authResult.userId, sessionId); + } + + const validationResult = await validateAuthAndBalance(authHeader, orgId, this.env); + + if (!validationResult.success) { + logger + .withFields({ + status: validationResult.status, + procedure: procedureName, + }) + .warn('Pre-flight validation failed for V2 mutation'); + + return this.buildTrpcErrorResponse( + validationResult.status, + validationResult.message, + procedureName + ); + } + + return this.handleTrpcRequest(request, validationResult); + } + + // For non-balance-required endpoints, use standard tRPC handling + const authResult = authenticate(request, this.env); + return this.handleTrpcRequest(request, authResult); + }); + } + + /** + * Handles queue messages for callback processing. + * Execution queue has been removed in favor of direct execution. + */ + async queue(batch: MessageBatch): Promise { + // Only callback queue is supported now (execution queue removed) + if (batch.queue.startsWith('cloud-agent-next-callback-queue')) { + const consumer = createCallbackQueueConsumer(); + return consumer(batch as MessageBatch); + } + + // Log unexpected queue messages (execution queue should not be active) + logger.warn(`Received message from unexpected queue: ${batch.queue}`); + } +} + +export { Sandbox } from '@cloudflare/sandbox'; +export { CloudAgentSession } from './persistence/CloudAgentSession'; diff --git a/cloud-agent-next/src/kilo/client.test.ts b/cloud-agent-next/src/kilo/client.test.ts new file mode 100644 index 0000000000..e3ac534155 --- /dev/null +++ b/cloud-agent-next/src/kilo/client.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { KiloClient } from './client.js'; +import { + KiloApiError, + KiloClientError, + KiloServerNotReadyError, + KiloSessionNotFoundError, + KiloSessionNotSetError, + KiloTimeoutError, +} from './errors.js'; +import type { ExecutionSession } from '../types.js'; +import type { Session, SessionCommandResponse } from './types.js'; + +type KiloClientPrivates = { + parseResponse: (stdout: string) => { responseBody: string; httpStatus: number }; + parseExecError: ( + result: { exitCode: number; stdout: string; stderr: string }, + method: string, + path: string, + timeoutSeconds: number, + httpStatus: number, + responseBody: string + ) => KiloClientError; + requireSession: () => string; +}; + +const getPrivates = (client: KiloClient): KiloClientPrivates => + client as unknown as KiloClientPrivates; + +const createExecSession = (): ExecutionSession => + ({ + exec: vi.fn(), + writeFile: vi.fn(), + deleteFile: vi.fn(), + }) as unknown as ExecutionSession; + +const buildSession = (id: string): Session => ({ + id, + projectID: 'proj_1', + directory: '/tmp', + title: 'Test Session', + version: '1', + time: { + created: 1, + updated: 2, + }, +}); + +describe('KiloClient', () => { + describe('parseResponse', () => { + it('extracts status code and body', () => { + const client = new KiloClient({ session: createExecSession(), port: 1234 }); + const result = getPrivates(client).parseResponse('ok\n200'); + expect(result).toEqual({ responseBody: 'ok', httpStatus: 200 }); + }); + + it('handles empty response with status', () => { + const client = new KiloClient({ session: createExecSession(), port: 1234 }); + const result = getPrivates(client).parseResponse('\n204'); + expect(result).toEqual({ responseBody: '', httpStatus: 204 }); + }); + + it('handles missing newline', () => { + const client = new KiloClient({ session: createExecSession(), port: 1234 }); + const result = getPrivates(client).parseResponse('just-body'); + expect(result).toEqual({ responseBody: 'just-body', httpStatus: 0 }); + }); + + it('handles malformed status code', () => { + const client = new KiloClient({ session: createExecSession(), port: 1234 }); + const result = getPrivates(client).parseResponse('ok\nabc'); + expect(result).toEqual({ responseBody: 'ok', httpStatus: 0 }); + }); + }); + + describe('parseExecError', () => { + it('classifies HTTP errors with JSON body', () => { + const client = new KiloClient({ session: createExecSession(), port: 1234 }); + const error = getPrivates(client).parseExecError( + { exitCode: 22, stdout: '', stderr: '' }, + 'GET', + '/path', + 10, + 400, + JSON.stringify({ message: 'bad request' }) + ); + expect(error).toBeInstanceOf(KiloApiError); + expect(error.message).toContain('bad request'); + }); + + it('classifies HTTP errors with plain text body', () => { + const client = new KiloClient({ session: createExecSession(), port: 1234 }); + const error = getPrivates(client).parseExecError( + { exitCode: 22, stdout: '', stderr: '' }, + 'GET', + '/path', + 10, + 500, + 'oops' + ); + expect(error).toBeInstanceOf(KiloApiError); + expect(error.message).toContain('oops'); + }); + + it('classifies timeout errors', () => { + const client = new KiloClient({ session: createExecSession(), port: 1234 }); + const error = getPrivates(client).parseExecError( + { exitCode: 28, stdout: '', stderr: '' }, + 'GET', + '/path', + 3, + 0, + '' + ); + expect(error).toBeInstanceOf(KiloTimeoutError); + expect((error as KiloTimeoutError).timeoutMs).toBe(3000); + }); + + it('classifies connection refused errors', () => { + const client = new KiloClient({ session: createExecSession(), port: 4321 }); + const error = getPrivates(client).parseExecError( + { exitCode: 7, stdout: '', stderr: '' }, + 'GET', + '/path', + 10, + 0, + '' + ); + expect(error).toBeInstanceOf(KiloServerNotReadyError); + expect(error.message).toContain('4321'); + }); + + it('classifies unknown errors', () => { + const client = new KiloClient({ session: createExecSession(), port: 1234 }); + const error = getPrivates(client).parseExecError( + { exitCode: 9, stdout: '', stderr: 'bad' }, + 'GET', + '/path', + 10, + 0, + '' + ); + expect(error).toBeInstanceOf(KiloClientError); + expect(error.message).toContain('exit 9'); + }); + }); + + describe('waitForReady', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2020-01-01T00:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('respects overall timeout', async () => { + const client = new KiloClient({ session: createExecSession(), port: 1234 }); + const callSpy = vi.spyOn(client, 'call').mockRejectedValue(new Error('nope')); + + const promise = client.waitForReady(1200); + const expectation = expect(promise).rejects.toBeInstanceOf(KiloServerNotReadyError); + await vi.advanceTimersByTimeAsync(2000); + + await expectation; + expect(callSpy).toHaveBeenCalled(); + }); + }); + + describe('session state management', () => { + it('requireSession throws when no session set', () => { + const client = new KiloClient({ session: createExecSession(), port: 1234 }); + expect(() => getPrivates(client).requireSession()).toThrow(KiloSessionNotSetError); + }); + + it('createSession stores session ID', async () => { + const client = new KiloClient({ session: createExecSession(), port: 1234 }); + vi.spyOn(client, 'call').mockResolvedValue(buildSession('ses_1')); + await client.createSession({ title: 'Title' }); + expect(client.currentSessionId).toBe('ses_1'); + }); + + it('resumeSession stores session ID', async () => { + const client = new KiloClient({ session: createExecSession(), port: 1234 }); + vi.spyOn(client, 'call').mockResolvedValue(buildSession('ses_2')); + await client.resumeSession('ses_2'); + expect(client.currentSessionId).toBe('ses_2'); + }); + + it('resumeSession converts 404 to KiloSessionNotFoundError', async () => { + const client = new KiloClient({ session: createExecSession(), port: 1234 }); + vi.spyOn(client, 'call').mockRejectedValue(new KiloApiError('not found', 404)); + await expect(client.resumeSession('missing')).rejects.toBeInstanceOf( + KiloSessionNotFoundError + ); + }); + }); + + describe('model defaults and passthrough', () => { + it('defaults summarize providerID to kilo', async () => { + const client = new KiloClient({ session: createExecSession(), port: 1234 }); + const callSpy = vi + .spyOn(client, 'call') + .mockResolvedValueOnce(buildSession('ses_1')) + .mockResolvedValueOnce(true); + + await client.resumeSession('ses_1'); + await client.summarize({ modelID: 'anthropic/claude-sonnet-4-20250514' }); + + expect(callSpy.mock.calls[1]?.[2]).toEqual({ + providerID: 'kilo', + modelID: 'anthropic/claude-sonnet-4-20250514', + }); + }); + + it('passes command model string as-is', async () => { + const client = new KiloClient({ session: createExecSession(), port: 1234 }); + const callSpy = vi + .spyOn(client, 'call') + .mockResolvedValueOnce(buildSession('ses_1')) + .mockResolvedValueOnce({} as unknown as SessionCommandResponse); + + await client.resumeSession('ses_1'); + await client.command('help', '', { model: 'anthropic/claude-sonnet-4-20250514' }); + + expect(callSpy.mock.calls[1]?.[2]).toEqual({ + command: 'help', + arguments: '', + messageID: undefined, + agent: undefined, + model: 'anthropic/claude-sonnet-4-20250514', + }); + }); + }); +}); diff --git a/cloud-agent-next/src/kilo/client.ts b/cloud-agent-next/src/kilo/client.ts new file mode 100644 index 0000000000..510e113e25 --- /dev/null +++ b/cloud-agent-next/src/kilo/client.ts @@ -0,0 +1,360 @@ +import type { ExecutionSession } from '../types.js'; +import { logger } from '../logger.js'; +import { + KiloClientError, + KiloApiError, + KiloTimeoutError, + KiloSessionNotSetError, + KiloSessionNotFoundError, + KiloServerNotReadyError, +} from './errors.js'; +import type { + Session, + SessionCommandResponse, + TextPartInput, + FilePartInput, + KiloClientOptions, + HealthResponse, + CreateSessionOptions, + PromptOptions, + CommandOptions, + SummarizeOptions, + PermissionResponse, +} from './types.js'; + +type PromptPart = TextPartInput | FilePartInput; + +/** Timeout for health checks during waitForReady polling (seconds). */ +const HEALTH_POLL_TIMEOUT_SECONDS = 2; + +export class KiloClient { + private readonly execSession: ExecutionSession; + private readonly port: number; + private readonly timeoutSeconds: number; + private kiloSessionId: string | null = null; + + constructor(options: KiloClientOptions) { + this.execSession = options.session; + this.port = options.port; + this.timeoutSeconds = options.timeoutSeconds ?? 10; + } + + get currentSessionId(): string | null { + return this.kiloSessionId; + } + + get baseUrl(): string { + return `http://127.0.0.1:${this.port}`; + } + + async health(): Promise { + return this.call('GET', '/global/health'); + } + + async waitForReady(timeoutMs: number = 30_000): Promise { + const startTime = Date.now(); + const pollInterval = 500; + + while (Date.now() - startTime < timeoutMs) { + try { + return await this.call( + 'GET', + '/global/health', + undefined, + HEALTH_POLL_TIMEOUT_SECONDS + ); + } catch { + const elapsed = Date.now() - startTime; + if (elapsed + pollInterval >= timeoutMs) { + break; + } + await this.sleep(pollInterval); + } + } + + throw new KiloServerNotReadyError(`Server not ready after ${timeoutMs}ms on port ${this.port}`); + } + + async createSession(options?: CreateSessionOptions): Promise { + const session = await this.call('POST', '/session', { + parentID: options?.parentId, + title: options?.title, + }); + this.kiloSessionId = session.id; + logger.withFields({ kiloSessionId: session.id }).info('Created kilo session'); + return session; + } + + async resumeSession(sessionId: string): Promise { + try { + const session = await this.call('GET', `/session/${sessionId}`); + this.kiloSessionId = session.id; + logger.withFields({ kiloSessionId: session.id }).info('Resumed kilo session'); + return session; + } catch (error) { + if (error instanceof KiloApiError && error.statusCode === 404) { + throw new KiloSessionNotFoundError(sessionId); + } + throw error; + } + } + + async importSession(shareUrl: string): Promise { + const urlMatch = shareUrl.match(/https?:\/\/kilosessions\.ai\/share\/([a-zA-Z0-9_-]+)/); + if (!urlMatch) { + throw new KiloClientError( + 'Invalid share URL format. Expected: https://kilosessions.ai/share/' + ); + } + + logger.withFields({ shareUrl }).info('Importing kilo session from URL'); + + const result = await this.execSession.exec(`kilo import "${shareUrl}"`, { + timeout: this.timeoutSeconds * 1000, + }); + + if (result.exitCode !== 0) { + throw new KiloClientError(`Failed to import session: ${result.stderr || result.stdout}`); + } + + const match = result.stdout.match(/Imported session:\s*(\S+)/); + if (!match || !match[1]) { + throw new KiloClientError(`Failed to parse session ID from import output: ${result.stdout}`); + } + + const sessionId = match[1]; + logger.withFields({ kiloSessionId: sessionId, shareUrl }).info('Imported kilo session'); + return this.resumeSession(sessionId); + } + + async promptAsync(parts: PromptPart[], options?: PromptOptions): Promise; + async promptAsync(text: string, options?: PromptOptions): Promise; + async promptAsync(partsOrText: PromptPart[] | string, options?: PromptOptions): Promise { + const sessionId = this.requireSession(); + if (options?.messageId) { + this.validateMessageId(options.messageId); + } + + const parts: PromptPart[] = + typeof partsOrText === 'string' ? [{ type: 'text', text: partsOrText }] : partsOrText; + + await this.call('POST', `/session/${sessionId}/prompt_async`, { + parts, + messageID: options?.messageId, + model: options?.model + ? { + providerID: options.model.providerID ?? 'kilo', + modelID: options.model.modelID, + } + : undefined, + agent: options?.agent, + noReply: options?.noReply, + system: options?.system, + tools: options?.tools, + }); + } + + async command( + command: string, + args?: string, + options?: CommandOptions + ): Promise { + const sessionId = this.requireSession(); + if (options?.messageId) { + this.validateMessageId(options.messageId); + } + + return this.call('POST', `/session/${sessionId}/command`, { + command, + arguments: args ?? '', + messageID: options?.messageId, + agent: options?.agent, + model: options?.model, + }); + } + + /** + * Respond to a permission request. + * Uses the new /permission/:requestID/reply endpoint (not the deprecated session endpoint). + */ + async respondToPermission( + permissionId: string, + response: PermissionResponse, + message?: string + ): Promise { + return this.call('POST', `/permission/${permissionId}/reply`, { + reply: response, + message, + }); + } + + /** + * Answer a question request from the AI assistant. + * @param questionId - The question request ID + * @param answers - Array of answers, one per question. Each answer is an array of selected option labels. + */ + async answerQuestion(questionId: string, answers: string[][]): Promise { + return this.call('POST', `/question/${questionId}/reply`, { + answers, + }); + } + + /** + * Reject/dismiss a question request from the AI assistant. + * @param questionId - The question request ID + */ + async rejectQuestion(questionId: string): Promise { + return this.call('POST', `/question/${questionId}/reject`); + } + + async summarize(options: SummarizeOptions): Promise { + const sessionId = this.requireSession(); + + return this.call('POST', `/session/${sessionId}/summarize`, { + providerID: options.providerID ?? 'kilo', + modelID: options.modelID, + }); + } + + async abort(): Promise { + const sessionId = this.requireSession(); + return this.call('POST', `/session/${sessionId}/abort`); + } + + async call(method: string, path: string, body?: unknown, timeoutSeconds?: number): Promise { + const timeout = timeoutSeconds ?? this.timeoutSeconds; + const url = `${this.baseUrl}${path}`; + + const curlArgs = [ + 'curl', + '-s', + '-S', + '--fail-with-body', + '--max-time', + String(timeout), + '-w', + '"\\n%{http_code}"', + '-X', + method, + ]; + + let tempFile: string | null = null; + + if (body !== undefined) { + const json = JSON.stringify(body); + tempFile = `/tmp/kilo-payload-${Date.now()}-${Math.random().toString(36).slice(2)}.json`; + + await this.execSession.writeFile(tempFile, json); + + curlArgs.push('-H', 'Content-Type: application/json'); + curlArgs.push('--data-binary', `@${tempFile}`); + } + + const quotedUrl = `'${url.replace(/'/g, "'\\''")}'`; + curlArgs.push(quotedUrl); + + const command = curlArgs.join(' '); + + logger.withFields({ method, path }).debug('KiloClient request'); + + try { + const result = await this.execSession.exec(command, { + timeout: (timeout + 1) * 1000, + }); + + const { responseBody, httpStatus } = this.parseResponse(result.stdout); + + if (result.exitCode !== 0) { + throw this.parseExecError(result, method, path, timeout, httpStatus, responseBody); + } + + if (!responseBody.trim()) { + return undefined as T; + } + + try { + return JSON.parse(responseBody) as T; + } catch { + throw new KiloClientError(`Failed to parse response JSON: ${responseBody.slice(0, 200)}`); + } + } finally { + if (tempFile) { + await this.execSession.deleteFile(tempFile).catch(() => {}); + } + } + } + + private parseResponse(stdout: string): { responseBody: string; httpStatus: number } { + const lastNewline = stdout.lastIndexOf('\n'); + if (lastNewline === -1) { + return { responseBody: stdout, httpStatus: 0 }; + } + + const statusStr = stdout.slice(lastNewline + 1).trim(); + const httpStatus = parseInt(statusStr, 10) || 0; + const responseBody = stdout.slice(0, lastNewline); + + return { responseBody, httpStatus }; + } + + private requireSession(): string { + if (!this.kiloSessionId) { + throw new KiloSessionNotSetError(); + } + return this.kiloSessionId; + } + + private parseExecError( + result: { exitCode: number; stdout: string; stderr: string }, + method: string, + path: string, + timeoutSeconds: number, + httpStatus: number, + responseBody: string + ): KiloClientError { + const { exitCode, stderr } = result; + + if (exitCode === 28) { + return new KiloTimeoutError(`Request timed out: ${method} ${path}`, timeoutSeconds * 1000); + } + + if (exitCode === 22) { + let errorMessage = `HTTP ${httpStatus}: ${method} ${path}`; + + try { + const errorBody = JSON.parse(responseBody); + if (errorBody.message) { + errorMessage = `${errorMessage} - ${errorBody.message}`; + } + } catch { + if (responseBody && responseBody.length < 200) { + errorMessage = `${errorMessage} - ${responseBody}`; + } + } + + return new KiloApiError(errorMessage, httpStatus, responseBody); + } + + if (exitCode === 7) { + return new KiloServerNotReadyError( + `Connection refused: ${method} ${path} - is the server running on port ${this.port}?` + ); + } + + return new KiloClientError( + `curl failed (exit ${exitCode}): ${method} ${path} - ${stderr || responseBody}` + ); + } + + private validateMessageId(messageId: string): void { + if (!messageId.startsWith('msg')) { + throw new KiloClientError( + `Invalid messageId: "${messageId}". Must start with "msg" prefix (e.g., "msg_my-custom-id")` + ); + } + } + + private sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} diff --git a/cloud-agent-next/src/kilo/errors.ts b/cloud-agent-next/src/kilo/errors.ts new file mode 100644 index 0000000000..3a358a7246 --- /dev/null +++ b/cloud-agent-next/src/kilo/errors.ts @@ -0,0 +1,69 @@ +/** + * Base error for all KiloClient errors. + */ +export class KiloClientError extends Error { + constructor( + message: string, + public readonly cause?: unknown + ) { + super(message); + this.name = 'KiloClientError'; + } +} + +/** + * HTTP error from kilo server (4xx/5xx). + */ +export class KiloApiError extends KiloClientError { + constructor( + message: string, + public readonly statusCode: number, + public readonly response?: string + ) { + super(message); + this.name = 'KiloApiError'; + } +} + +/** + * Request timed out. + */ +export class KiloTimeoutError extends KiloClientError { + constructor( + message: string, + public readonly timeoutMs: number + ) { + super(message); + this.name = 'KiloTimeoutError'; + } +} + +/** + * Method called without active session. + */ +export class KiloSessionNotSetError extends KiloClientError { + constructor() { + super('No session set. Call createSession() or resumeSession() first.'); + this.name = 'KiloSessionNotSetError'; + } +} + +/** + * Session not found (404). + */ +export class KiloSessionNotFoundError extends KiloClientError { + constructor(sessionId: string) { + super(`Session not found: ${sessionId}`); + this.name = 'KiloSessionNotFoundError'; + } +} + +/** + * Server not ready/healthy. + */ +export class KiloServerNotReadyError extends KiloClientError { + constructor(message: string) { + super(message); + this.name = 'KiloServerNotReadyError'; + } +} diff --git a/cloud-agent-next/src/kilo/index.ts b/cloud-agent-next/src/kilo/index.ts new file mode 100644 index 0000000000..86bcedebeb --- /dev/null +++ b/cloud-agent-next/src/kilo/index.ts @@ -0,0 +1,7 @@ +export * from './server-manager.js'; +export * from './client.js'; +export * from './errors.js'; +export * from './types.js'; +export * from './utils.js'; +export * from './wrapper-client.js'; +export * from './wrapper-manager.js'; diff --git a/cloud-agent-next/src/kilo/server-manager.ts b/cloud-agent-next/src/kilo/server-manager.ts new file mode 100644 index 0000000000..ddcae93ac9 --- /dev/null +++ b/cloud-agent-next/src/kilo/server-manager.ts @@ -0,0 +1,433 @@ +/** + * Kilo Server Manager + * + * Manages the lifecycle of kilo serve instances within sandboxes. + * Each cloud-agent session gets its own kilo server, identified by a + * command marker (KILO_CLOUD_SESSION={sessionId}) embedded in the process command. + * + * This allows us to: + * 1. Find existing servers by scanning listProcesses() + * 2. Avoid port collisions between sessions + * 3. Reuse servers across multiple executions in the same session + */ + +import type { SandboxInstance, ExecutionSession } from '../types.js'; +import { logger } from '../logger.js'; + +// Re-export Process type from sandbox for consumers +type Process = Awaited>[number]; + +/** Starting port for kilo servers */ +const KILO_SERVER_START_PORT = 4096; + +/** Port range size for session-based port allocation */ +const KILO_SERVER_PORT_RANGE = 1000; + +/** Timeout for waiting for server to become healthy */ +const KILO_SERVER_STARTUP_TIMEOUT_MS = 60_000; + +/** Timeout for creating a CLI session via curl (30 seconds) */ +const KILO_CLI_SESSION_CREATE_TIMEOUT_SECONDS = 30; + +/** Environment variable marker to identify which session owns a server */ +const KILO_CLOUD_SESSION_MARKER = 'KILO_CLOUD_SESSION'; + +/** Maximum retry attempts when port bind fails due to race condition */ +const MAX_PORT_RETRY_ATTEMPTS = 3; + +/** + * Information about a running kilo server. + */ +export type KiloServerInfo = { + port: number; + process: Process; +}; + +/** + * Extract port number from a kilo serve command string. + * Parses "--port XXXX" from the command. + * + * @param command - The full command string + * @returns The port number, or null if not found + */ +export function extractPortFromCommand(command: string): number | null { + // Match --port followed by whitespace and digits + const match = command.match(/--port\s+(\d+)/); + if (match && match[1]) { + const port = parseInt(match[1], 10); + if (!isNaN(port) && port > 0 && port < 65536) { + return port; + } + } + return null; +} + +/** + * Extract session ID from a kilo serve command string. + * Parses "KILO_CLOUD_SESSION=XXX" from the command. + * + * @param command - The full command string + * @returns The session ID, or null if not found + */ +export function extractSessionIdFromCommand(command: string): string | null { + const marker = `${KILO_CLOUD_SESSION_MARKER}=`; + const idx = command.indexOf(marker); + if (idx === -1) { + return null; + } + + // Extract everything after the marker until whitespace + const startIdx = idx + marker.length; + const endIdx = command.indexOf(' ', startIdx); + if (endIdx === -1) { + return command.slice(startIdx); + } + return command.slice(startIdx, endIdx); +} + +/** + * Find an existing kilo server for the given session. + * Scans listProcesses() for a command containing KILO_CLOUD_SESSION={sessionId}. + * + * @param sandbox - The sandbox instance to search in + * @param sessionId - The cloud-agent session ID to find + * @returns Server info if found, null otherwise + */ +export async function findKiloServerForSession( + sandbox: SandboxInstance, + sessionId: string +): Promise { + const processes = await sandbox.listProcesses(); + const marker = `${KILO_CLOUD_SESSION_MARKER}=${sessionId}`; + + for (const proc of processes) { + if (proc.command.includes(marker) && proc.command.includes('kilo serve')) { + const status = proc.status; + if (status === 'running' || status === 'starting') { + const port = extractPortFromCommand(proc.command); + if (port !== null) { + logger + .withFields({ sessionId, port, processId: proc.id, status }) + .debug('Found existing kilo server for session'); + return { port, process: proc }; + } + } + } + } + + return null; +} + +/** + * Find all ports currently in use by kilo serve processes. + * Used to avoid port collisions when starting a new server. + * + * @param sandbox - The sandbox instance to search in + * @returns Set of ports currently in use + */ +export async function findUsedKiloPorts(sandbox: SandboxInstance): Promise> { + const processes = await sandbox.listProcesses(); + const usedPorts = new Set(); + + for (const proc of processes) { + if (proc.command.includes('kilo serve')) { + const status = proc.status; + if (status === 'running' || status === 'starting') { + const port = extractPortFromCommand(proc.command); + if (port !== null) { + usedPorts.add(port); + } + } + } + } + + return usedPorts; +} + +/** + * Derive a preferred port from a sessionId using a simple hash. + * This ensures the same session consistently tries the same port first, + * improving cache locality and making debugging easier. + * + * @param sessionId - The cloud-agent session ID + * @returns A port number in the range [KILO_SERVER_START_PORT, KILO_SERVER_START_PORT + KILO_SERVER_PORT_RANGE) + */ +export function derivePortFromSessionId(sessionId: string): number { + // Simple hash: sum of char codes modulo port range + let hash = 0; + for (let i = 0; i < sessionId.length; i++) { + hash = (hash * 31 + sessionId.charCodeAt(i)) >>> 0; // unsigned 32-bit + } + return KILO_SERVER_START_PORT + (hash % KILO_SERVER_PORT_RANGE); +} + +/** + * Find an available port, starting from the session-derived preferred port. + * Falls back to scanning if the preferred port is in use. + * + * @param sandbox - The sandbox instance to check + * @param sessionId - The session ID to derive the preferred port from + * @returns First available port + */ +export async function findAvailablePort( + sandbox: SandboxInstance, + sessionId: string +): Promise { + const usedPorts = await findUsedKiloPorts(sandbox); + const preferredPort = derivePortFromSessionId(sessionId); + + // Try preferred port first + if (!usedPorts.has(preferredPort)) { + return preferredPort; + } + + // Fall back to scanning from preferred port + for (let offset = 1; offset < KILO_SERVER_PORT_RANGE; offset++) { + const port = + KILO_SERVER_START_PORT + + ((preferredPort - KILO_SERVER_START_PORT + offset) % KILO_SERVER_PORT_RANGE); + if (!usedPorts.has(port)) { + return port; + } + } + + // If all ports in range are used, scan beyond + for (let port = KILO_SERVER_START_PORT + KILO_SERVER_PORT_RANGE; port < 65535; port++) { + if (!usedPorts.has(port)) { + return port; + } + } + + // Extremely unlikely to reach here + throw new Error('No available ports for kilo server'); +} + +/** + * Build the kilo serve command with session marker. + * + * @param sessionId - The cloud-agent session ID + * @param port - The port to listen on + * @returns The full command string + */ +export function buildKiloServeCommand(sessionId: string, port: number): string { + // Using env var prefix to mark the session, then kilo serve command + // The cwd is set via startProcess options, so no cd needed here + return `${KILO_CLOUD_SESSION_MARKER}=${sessionId} kilo serve --port ${port} --hostname 127.0.0.1`; +} + +/** + * Ensure a kilo server is running for the given session. + * + * This function: + * 1. Checks if a server already exists for this session (sandbox-wide search) + * 2. If found and running, returns its port + * 3. If found and starting, waits for it to become healthy + * 4. If not found, starts a new server within the execution session and waits for healthy + * 5. Handles race conditions by retrying with a different port if bind fails + * + * @param sandbox - The sandbox instance (for listing processes across all sessions) + * @param session - The execution session (for starting processes within session context) + * @param sessionId - The cloud-agent session ID + * @param workspacePath - The workspace directory for the session + * @returns The port the server is listening on + */ +export async function ensureKiloServer( + sandbox: SandboxInstance, + session: ExecutionSession, + sessionId: string, + workspacePath: string +): Promise { + logger.withFields({ sessionId, workspacePath }).info('Ensuring kilo server is running'); + + // 1. Check for existing server (sandbox-wide search) + const existing = await findKiloServerForSession(sandbox, sessionId); + + if (existing) { + const { process: proc, port } = existing; + + if (proc.status === 'running') { + logger.withFields({ sessionId, port }).info('Reusing existing kilo server'); + return port; + } + + if (proc.status === 'starting') { + logger.withFields({ sessionId, port }).info('Found starting kilo server, waiting for ready'); + try { + await proc.waitForPort(port, { + mode: 'http', + path: '/global/health', + timeout: KILO_SERVER_STARTUP_TIMEOUT_MS, + }); + logger.withFields({ sessionId, port }).info('Kilo server is now ready'); + return port; + } catch (error) { + // Server failed to start, will try to start a new one + logger + .withFields({ + sessionId, + port, + error: error instanceof Error ? error.message : String(error), + }) + .warn('Existing kilo server failed to become ready'); + } + } + } + + // 2. Start a new server within the execution session + let lastError: Error | undefined; + + for (let attempt = 0; attempt < MAX_PORT_RETRY_ATTEMPTS; attempt++) { + const port = await findAvailablePort(sandbox, sessionId); + const command = buildKiloServeCommand(sessionId, port); + + logger + .withFields({ sessionId, port, attempt: attempt + 1, command }) + .info('Starting new kilo server'); + + try { + const proc = await session.startProcess(command, { + cwd: workspacePath, + }); + + // Wait for server to become healthy + await proc.waitForPort(port, { + mode: 'http', + path: '/global/health', + timeout: KILO_SERVER_STARTUP_TIMEOUT_MS, + }); + + logger + .withFields({ sessionId, port, processId: proc.id }) + .info('Kilo server started successfully'); + return port; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + // Check if this might be a port collision (race condition) + // Another session may have grabbed the port between our check and bind + const errorMessage = lastError.message.toLowerCase(); + const isPortConflict = + errorMessage.includes('address already in use') || + errorMessage.includes('eaddrinuse') || + errorMessage.includes('bind'); + + if (isPortConflict && attempt + 1 < MAX_PORT_RETRY_ATTEMPTS) { + logger + .withFields({ sessionId, port, attempt: attempt + 1, error: lastError.message }) + .warn('Port conflict, retrying with different port'); + continue; + } + + // Not a port conflict or max retries reached + logger + .withFields({ + sessionId, + port, + attempt: attempt + 1, + error: lastError.message, + }) + .error('Failed to start kilo server'); + } + } + + throw lastError ?? new Error('Failed to start kilo server after retries'); +} + +/** + * Stop the kilo server for a session (if running). + * Called during cleanup or intentional shutdown. + * + * @param sandbox - The sandbox instance + * @param sessionId - The cloud-agent session ID + */ +/** + * Response from creating a kilo CLI session. + */ +export type KiloCliSession = { + id: string; + title?: string; +}; + +/** + * Create a new kilo CLI session via the server API. + * This creates a session in the running kilo server that can be used + * for sending prompts and tracking conversation history. + * + * NOTE: This must be called from within the execution session since the kilo server + * runs on 127.0.0.1 inside the sandbox container. + * + * @param session - The execution session to execute the request from + * @param port - The kilo server port + * @returns The created session with its ID + */ +export async function createKiloCliSession( + session: ExecutionSession, + port: number +): Promise { + const url = `http://127.0.0.1:${port}/session`; + + logger.withFields({ port }).debug('Creating kilo CLI session'); + + // Execute curl from within the session since the server runs on localhost inside the container + // -f: Fail silently on HTTP errors (exit code 22 for 4xx/5xx) + // -s: Silent mode (no progress) + // -S: Show errors when -s is used + // --max-time: Timeout for the entire operation + const result = await session.exec( + `curl -f -s -S --max-time ${KILO_CLI_SESSION_CREATE_TIMEOUT_SECONDS} -X POST -H "Content-Type: application/json" -d "{}" "${url}"` + ); + + if (result.exitCode !== 0) { + // Exit code 22 = HTTP error (4xx/5xx), 28 = timeout + const exitCodeInfo = + result.exitCode === 22 + ? 'HTTP error from server' + : result.exitCode === 28 + ? 'Request timed out' + : `exit code ${result.exitCode}`; + throw new Error( + `Failed to create kilo CLI session: ${exitCodeInfo} - ${result.stderr || result.stdout}` + ); + } + + let kiloSession: KiloCliSession; + try { + kiloSession = JSON.parse(result.stdout) as KiloCliSession; + } catch { + throw new Error(`Failed to parse kilo CLI session response: ${result.stdout}`); + } + + if (!kiloSession.id) { + throw new Error(`Invalid kilo CLI session response - missing id: ${result.stdout}`); + } + + logger.withFields({ port, kiloSessionId: kiloSession.id }).info('Created kilo CLI session'); + + return kiloSession; +} + +export async function stopKiloServer(sandbox: SandboxInstance, sessionId: string): Promise { + const existing = await findKiloServerForSession(sandbox, sessionId); + + if (!existing) { + logger.withFields({ sessionId }).debug('No kilo server found to stop'); + return; + } + + const { process: proc, port } = existing; + + logger.withFields({ sessionId, port, processId: proc.id }).info('Stopping kilo server'); + + try { + await proc.kill('SIGTERM'); + logger.withFields({ sessionId, port }).info('Kilo server stopped'); + } catch (error) { + logger + .withFields({ + sessionId, + port, + error: error instanceof Error ? error.message : String(error), + }) + .warn('Error stopping kilo server'); + } +} diff --git a/cloud-agent-next/src/kilo/types.ts b/cloud-agent-next/src/kilo/types.ts new file mode 100644 index 0000000000..80de8e4ed2 --- /dev/null +++ b/cloud-agent-next/src/kilo/types.ts @@ -0,0 +1,52 @@ +import type { ExecutionSession } from '../types.js'; + +export type { + Session, + AssistantMessage, + Part, + TextPartInput, + FilePartInput, + SessionCommandResponse, +} from '../shared/kilo-types.js'; + +export interface KiloClientOptions { + session: ExecutionSession; + port: number; + /** Request timeout in seconds (default: 10) */ + timeoutSeconds?: number; +} + +export interface HealthResponse { + healthy: boolean; + version: string; +} + +export interface CreateSessionOptions { + parentId?: string; + title?: string; +} + +export interface PromptOptions { + messageId?: string; + model?: { providerID?: string; modelID: string }; + agent?: string; + noReply?: boolean; + /** Custom system prompt override */ + system?: string; + /** Enable/disable specific tools (e.g., { "read": true, "write": false }) */ + tools?: Record; +} + +export interface CommandOptions { + messageId?: string; + agent?: string; + /** Model ID string (e.g., "anthropic/claude-sonnet-4-20250514") */ + model?: string; +} + +export interface SummarizeOptions { + providerID?: string; + modelID: string; +} + +export type PermissionResponse = 'once' | 'always' | 'reject'; diff --git a/cloud-agent-next/src/kilo/utils.test.ts b/cloud-agent-next/src/kilo/utils.test.ts new file mode 100644 index 0000000000..d8fb07c2cc --- /dev/null +++ b/cloud-agent-next/src/kilo/utils.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { executionIdToMessageId, extractUuid } from './utils.js'; +import { KiloClientError } from './errors.js'; + +describe('executionIdToMessageId', () => { + it('returns msg_* id unchanged (identity function)', () => { + expect(executionIdToMessageId('msg_123')).toBe('msg_123'); + expect(executionIdToMessageId('msg_abc-def-ghi')).toBe('msg_abc-def-ghi'); + }); + + it('throws for invalid execution id (non-msg_ prefix)', () => { + expect(() => executionIdToMessageId('bad_123')).toThrow(KiloClientError); + expect(() => executionIdToMessageId('exec_123')).toThrow(KiloClientError); + }); +}); + +describe('extractUuid', () => { + it('extracts uuid portion from msg_ id', () => { + expect(extractUuid('msg_123-456-789')).toBe('123-456-789'); + expect(extractUuid('msg_abc')).toBe('abc'); + }); +}); diff --git a/cloud-agent-next/src/kilo/utils.ts b/cloud-agent-next/src/kilo/utils.ts new file mode 100644 index 0000000000..f9a0085255 --- /dev/null +++ b/cloud-agent-next/src/kilo/utils.ts @@ -0,0 +1,31 @@ +import { isExecutionId, type ExecutionId } from '../types/ids.js'; +import { KiloClientError } from './errors.js'; + +/** + * Convert an execution ID to a message ID for kilo server. + * + * Since executionId is now in msg_ format, this is an identity function. + * It validates the input and returns it unchanged. + * + * @param executionId - The execution ID to convert + * @returns The message ID (same as input for msg_ format) + */ +export const executionIdToMessageId = (executionId: string): string => { + if (!isExecutionId(executionId)) { + throw new KiloClientError(`Invalid executionId: "${executionId}". Expected prefix "msg_".`); + } + // executionId === messageId (identity function) + return executionId; +}; + +/** + * @deprecated Use executionIdToMessageId instead. This alias exists for migration clarity. + */ +export const asMessageId = executionIdToMessageId; + +/** + * Type-safe extraction of the UUID portion from an execution/message ID. + */ +export const extractUuid = (id: ExecutionId): string => { + return id.replace(/^msg_/, ''); +}; diff --git a/cloud-agent-next/src/kilo/wrapper-client.test.ts b/cloud-agent-next/src/kilo/wrapper-client.test.ts new file mode 100644 index 0000000000..9995361453 --- /dev/null +++ b/cloud-agent-next/src/kilo/wrapper-client.test.ts @@ -0,0 +1,704 @@ +/** + * Unit tests for WrapperClient. + * + * Tests HTTP call formatting, error handling, and response parsing. + * The WrapperClient uses session.exec to run curl inside the container. + */ + +/* eslint-disable @typescript-eslint/unbound-method */ + +import { describe, expect, it, vi } from 'vitest'; +import { + WrapperClient, + WrapperError, + WrapperNotReadyError, + WrapperNoJobError, + WrapperJobConflictError, + type StartJobOptions, + type WrapperPromptOptions, + type WrapperHealthResponse, + type JobStatus, +} from './wrapper-client.js'; +import type { ExecutionSession } from '../types.js'; + +// --------------------------------------------------------------------------- +// Test Helpers +// --------------------------------------------------------------------------- + +type MockExecResult = { + exitCode: number; + stdout?: string; + stderr?: string; +}; + +const createMockSession = ( + execResult: MockExecResult | ((cmd: string) => MockExecResult) +): ExecutionSession => { + const execFn = + typeof execResult === 'function' + ? vi.fn().mockImplementation((cmd: string) => Promise.resolve(execResult(cmd))) + : vi.fn().mockResolvedValue(execResult); + + // Mock startProcess that returns a process with waitForPort + const startProcessFn = vi.fn().mockImplementation(() => + Promise.resolve({ + id: 'mock-process-id', + waitForPort: vi.fn().mockResolvedValue(undefined), + }) + ); + + return { + exec: execFn, + writeFile: vi.fn(), + deleteFile: vi.fn(), + startProcess: startProcessFn, + } as unknown as ExecutionSession; +}; + +const createSuccessResponse = (data: T): MockExecResult => ({ + exitCode: 0, + stdout: JSON.stringify(data), +}); + +const createErrorResponse = (error: string, message?: string): MockExecResult => ({ + exitCode: 0, + stdout: JSON.stringify({ error, message: message ?? error }), +}); + +const createCurlError = (exitCode: number, stderr = ''): MockExecResult => ({ + exitCode, + stderr, +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('WrapperClient', () => { + const defaultPort = 5000; + + // ------------------------------------------------------------------------- + // Constructor + // ------------------------------------------------------------------------- + + describe('constructor', () => { + it('creates client with session and port', () => { + const session = createMockSession({ exitCode: 0, stdout: '{}' }); + const client = new WrapperClient({ session, port: defaultPort }); + + expect(client).toBeDefined(); + }); + }); + + // ------------------------------------------------------------------------- + // Health Check + // ------------------------------------------------------------------------- + + describe('health', () => { + it('returns health status on success', async () => { + const healthResponse: WrapperHealthResponse = { + healthy: true, + state: 'idle', + inflightCount: 0, + version: '1.0.0', + }; + + const session = createMockSession(createSuccessResponse(healthResponse)); + const client = new WrapperClient({ session, port: defaultPort }); + + const result = await client.health(); + + expect(result).toEqual(healthResponse); + expect(session.exec).toHaveBeenCalledWith( + expect.stringContaining("curl -s -X GET -H 'Content-Type: application/json'") + ); + expect(session.exec).toHaveBeenCalledWith(expect.stringContaining('/health')); + }); + + it('throws WrapperError on curl failure', async () => { + const session = createMockSession(createCurlError(7, 'Connection refused')); + const client = new WrapperClient({ session, port: defaultPort }); + + await expect(client.health()).rejects.toThrow(WrapperError); + }); + }); + + // ------------------------------------------------------------------------- + // Job Status + // ------------------------------------------------------------------------- + + describe('status', () => { + it('returns job status', async () => { + const statusResponse: JobStatus = { + state: 'active', + executionId: 'exec_123', + kiloSessionId: 'kilo_456', + inflight: ['msg_1', 'msg_2'], + inflightCount: 2, + }; + + const session = createMockSession(createSuccessResponse(statusResponse)); + const client = new WrapperClient({ session, port: defaultPort }); + + const result = await client.status(); + + expect(result).toEqual(statusResponse); + expect(session.exec).toHaveBeenCalledWith(expect.stringContaining('/job/status')); + }); + + it('returns idle status with lastError', async () => { + const statusResponse: JobStatus = { + state: 'idle', + inflight: [], + inflightCount: 0, + lastError: { + code: 'INFLIGHT_TIMEOUT', + messageId: 'msg_123', + message: 'Timeout after 600s', + timestamp: Date.now(), + }, + }; + + const session = createMockSession(createSuccessResponse(statusResponse)); + const client = new WrapperClient({ session, port: defaultPort }); + + const result = await client.status(); + + expect(result.lastError).toBeDefined(); + expect(result.lastError?.code).toBe('INFLIGHT_TIMEOUT'); + }); + }); + + // ------------------------------------------------------------------------- + // Start Job + // ------------------------------------------------------------------------- + + describe('startJob', () => { + const startJobOptions: StartJobOptions = { + executionId: 'exec_123', + ingestUrl: 'wss://ingest.example.com', + ingestToken: 'token_secret', + sessionId: 'session_abc', + userId: 'user_xyz', + kilocodeToken: 'kilo_token', + }; + + it('returns kiloSessionId on success', async () => { + const session = createMockSession( + createSuccessResponse({ status: 'started', kiloSessionId: 'kilo_sess_new' }) + ); + const client = new WrapperClient({ session, port: defaultPort }); + + const result = await client.startJob(startJobOptions); + + expect(result.kiloSessionId).toBe('kilo_sess_new'); + }); + + it('sends all options in request body', async () => { + const session = createMockSession( + createSuccessResponse({ status: 'started', kiloSessionId: 'kilo_sess_new' }) + ); + const client = new WrapperClient({ session, port: defaultPort }); + + await client.startJob({ + ...startJobOptions, + kiloSessionId: 'existing_kilo_sess', + kiloSessionTitle: 'My Session', + }); + + const execCall = (session.exec as ReturnType).mock.calls[0][0] as string; + expect(execCall).toContain('/job/start'); + expect(execCall).toContain('-d'); + // Body should contain the JSON data + expect(execCall).toContain('executionId'); + }); + + it('throws WrapperJobConflictError on conflict', async () => { + const session = createMockSession(createErrorResponse('JOB_CONFLICT', 'Job already running')); + const client = new WrapperClient({ session, port: defaultPort }); + + await expect(client.startJob(startJobOptions)).rejects.toThrow(WrapperJobConflictError); + }); + }); + + // ------------------------------------------------------------------------- + // Prompt + // ------------------------------------------------------------------------- + + describe('prompt', () => { + it('returns messageId on success', async () => { + const session = createMockSession( + createSuccessResponse({ status: 'sent', messageId: 'msg_generated_1' }) + ); + const client = new WrapperClient({ session, port: defaultPort }); + + const result = await client.prompt({ prompt: 'Hello, world!' }); + + expect(result.messageId).toBe('msg_generated_1'); + }); + + it('sends prompt text', async () => { + const session = createMockSession( + createSuccessResponse({ status: 'sent', messageId: 'msg_1' }) + ); + const client = new WrapperClient({ session, port: defaultPort }); + + await client.prompt({ prompt: 'Test prompt' }); + + const execCall = (session.exec as ReturnType).mock.calls[0][0] as string; + expect(execCall).toContain('/job/prompt'); + expect(execCall).toContain('Test prompt'); + }); + + it('sends all options', async () => { + const session = createMockSession( + createSuccessResponse({ status: 'sent', messageId: 'msg_custom' }) + ); + const client = new WrapperClient({ session, port: defaultPort }); + + const options: WrapperPromptOptions = { + prompt: 'Complex prompt', + model: { providerID: 'kilo', modelID: 'anthropic/claude-sonnet-4-20250514' }, + agent: 'build', + messageId: 'msg_custom', + system: 'You are a helpful assistant', + tools: { read_file: true, write_file: false }, + }; + + await client.prompt(options); + + const execCall = (session.exec as ReturnType).mock.calls[0][0] as string; + expect(execCall).toContain('/job/prompt'); + }); + + it('throws WrapperNoJobError when no job started', async () => { + const session = createMockSession(createErrorResponse('NO_JOB', 'Call /job/start first')); + const client = new WrapperClient({ session, port: defaultPort }); + + await expect(client.prompt({ prompt: 'test' })).rejects.toThrow(WrapperNoJobError); + }); + }); + + // ------------------------------------------------------------------------- + // Command + // ------------------------------------------------------------------------- + + describe('command', () => { + it('returns command result', async () => { + const commandResult = { messages: ['Cleared 5 messages'] }; + const session = createMockSession( + createSuccessResponse({ status: 'sent', result: commandResult }) + ); + const client = new WrapperClient({ session, port: defaultPort }); + + const result = await client.command('clear'); + + expect(result).toEqual(commandResult); + }); + + it('sends command and args', async () => { + const session = createMockSession(createSuccessResponse({ status: 'sent', result: {} })); + const client = new WrapperClient({ session, port: defaultPort }); + + await client.command('compact', '--aggressive'); + + const execCall = (session.exec as ReturnType).mock.calls[0][0] as string; + expect(execCall).toContain('/job/command'); + expect(execCall).toContain('compact'); + expect(execCall).toContain('--aggressive'); + }); + }); + + // ------------------------------------------------------------------------- + // Answer Permission + // ------------------------------------------------------------------------- + + describe('answerPermission', () => { + it('returns success on valid response', async () => { + const session = createMockSession( + createSuccessResponse({ status: 'answered', success: true }) + ); + const client = new WrapperClient({ session, port: defaultPort }); + + const result = await client.answerPermission('perm_123', 'once'); + + expect(result.success).toBe(true); + }); + + it('sends permission response', async () => { + const session = createMockSession( + createSuccessResponse({ status: 'answered', success: true }) + ); + const client = new WrapperClient({ session, port: defaultPort }); + + await client.answerPermission('perm_456', 'always'); + + const execCall = (session.exec as ReturnType).mock.calls[0][0] as string; + expect(execCall).toContain('/job/answer-permission'); + expect(execCall).toContain('perm_456'); + expect(execCall).toContain('always'); + }); + + it('handles reject response', async () => { + const session = createMockSession( + createSuccessResponse({ status: 'answered', success: true }) + ); + const client = new WrapperClient({ session, port: defaultPort }); + + await client.answerPermission('perm_789', 'reject'); + + const execCall = (session.exec as ReturnType).mock.calls[0][0] as string; + expect(execCall).toContain('reject'); + }); + }); + + // ------------------------------------------------------------------------- + // Answer Question + // ------------------------------------------------------------------------- + + describe('answerQuestion', () => { + it('returns success', async () => { + const session = createMockSession( + createSuccessResponse({ status: 'answered', success: true }) + ); + const client = new WrapperClient({ session, port: defaultPort }); + + const result = await client.answerQuestion('q_123', ['Yes']); + + expect(result.success).toBe(true); + }); + + it('sends answers array', async () => { + const session = createMockSession( + createSuccessResponse({ status: 'answered', success: true }) + ); + const client = new WrapperClient({ session, port: defaultPort }); + + await client.answerQuestion('q_456', ['Option A', 'Option B']); + + const execCall = (session.exec as ReturnType).mock.calls[0][0] as string; + expect(execCall).toContain('/job/answer-question'); + expect(execCall).toContain('q_456'); + }); + }); + + // ------------------------------------------------------------------------- + // Reject Question + // ------------------------------------------------------------------------- + + describe('rejectQuestion', () => { + it('returns success', async () => { + const session = createMockSession( + createSuccessResponse({ status: 'rejected', success: true }) + ); + const client = new WrapperClient({ session, port: defaultPort }); + + const result = await client.rejectQuestion('q_789'); + + expect(result.success).toBe(true); + }); + + it('calls correct endpoint', async () => { + const session = createMockSession( + createSuccessResponse({ status: 'rejected', success: true }) + ); + const client = new WrapperClient({ session, port: defaultPort }); + + await client.rejectQuestion('q_abc'); + + const execCall = (session.exec as ReturnType).mock.calls[0][0] as string; + expect(execCall).toContain('/job/reject-question'); + expect(execCall).toContain('q_abc'); + }); + }); + + // ------------------------------------------------------------------------- + // Abort + // ------------------------------------------------------------------------- + + describe('abort', () => { + it('completes without error', async () => { + const session = createMockSession(createSuccessResponse({ status: 'aborted' })); + const client = new WrapperClient({ session, port: defaultPort }); + + await expect(client.abort()).resolves.not.toThrow(); + }); + + it('calls abort endpoint', async () => { + const session = createMockSession(createSuccessResponse({ status: 'aborted' })); + const client = new WrapperClient({ session, port: defaultPort }); + + await client.abort(); + + const execCall = (session.exec as ReturnType).mock.calls[0][0] as string; + expect(execCall).toContain('/job/abort'); + }); + }); + + // ------------------------------------------------------------------------- + // Ensure Running + // ------------------------------------------------------------------------- + + describe('ensureRunning', () => { + it('returns immediately if wrapper already healthy', async () => { + const healthResponse: WrapperHealthResponse = { + healthy: true, + state: 'idle', + inflightCount: 0, + version: '1.0.0', + }; + + const session = createMockSession(createSuccessResponse(healthResponse)); + const client = new WrapperClient({ session, port: defaultPort }); + + await client.ensureRunning({ + sessionId: 'test-session', + kiloServerPort: 4600, + workspacePath: '/workspace/test', + }); + + // Should only call health once (already running) + expect(session.exec).toHaveBeenCalledTimes(1); + }); + + it('starts wrapper and waits for port via startProcess', async () => { + // Health check fails (not running) + const session = createMockSession(createCurlError(7, 'Connection refused')); + + // Track that waitForPort was called + const waitForPortMock = vi.fn().mockResolvedValue(undefined); + (session.startProcess as ReturnType).mockResolvedValue({ + id: 'mock-process-id', + waitForPort: waitForPortMock, + }); + + const client = new WrapperClient({ session, port: defaultPort }); + + await client.ensureRunning({ + sessionId: 'test-session', + pollIntervalMs: 10, + maxWaitMs: 5000, + kiloServerPort: 4600, + workspacePath: '/workspace/test', + }); + + // Should have called startProcess and waitForPort + expect(session.startProcess).toHaveBeenCalledTimes(1); + expect(waitForPortMock).toHaveBeenCalledWith(defaultPort, { + mode: 'http', + path: '/health', + timeout: 5000, + }); + }); + + it('throws WrapperNotReadyError on timeout', async () => { + // Health check fails (not running) + const session = createMockSession(createCurlError(7, 'Connection refused')); + + // Make startProcess return a process where waitForPort times out + (session.startProcess as ReturnType).mockResolvedValue({ + id: 'mock-process-id', + waitForPort: vi.fn().mockRejectedValue(new Error('Port not ready within timeout')), + }); + + const client = new WrapperClient({ session, port: defaultPort }); + + await expect( + client.ensureRunning({ + sessionId: 'test-session', + maxWaitMs: 100, + pollIntervalMs: 10, + kiloServerPort: 4600, + workspacePath: '/workspace/test', + }) + ).rejects.toThrow(WrapperNotReadyError); + }); + + it('uses default wrapper path and calls startProcess', async () => { + // Health check fails first (not running), then we start + let healthCheckCount = 0; + const session = createMockSession((cmd: string) => { + if (cmd.includes('/health')) { + healthCheckCount++; + if (healthCheckCount === 1) { + return createCurlError(7); // First check: not running + } + } + return createSuccessResponse({ + healthy: true, + state: 'idle', + inflightCount: 0, + version: '1.0.0', + }); + }); + + const client = new WrapperClient({ session, port: defaultPort }); + + await client.ensureRunning({ + sessionId: 'test-session', + pollIntervalMs: 10, + maxWaitMs: 1000, + kiloServerPort: 4600, + workspacePath: '/workspace/test', + }); + + // Verify startProcess was called with the wrapper command + expect(session.startProcess).toHaveBeenCalledTimes(1); + const startProcessCall = (session.startProcess as ReturnType).mock.calls[0]; + expect(startProcessCall[0]).toContain('kilocode-wrapper'); + expect(startProcessCall[0]).toContain('WRAPPER_PORT=5000'); + expect(startProcessCall[0]).toContain('KILO_SERVER_PORT=4600'); + }); + }); + + // ------------------------------------------------------------------------- + // Error Handling + // ------------------------------------------------------------------------- + + describe('error handling', () => { + it('parses JSON error response', async () => { + const session = createMockSession( + createErrorResponse('CUSTOM_ERROR', 'Custom error message') + ); + const client = new WrapperClient({ session, port: defaultPort }); + + try { + await client.health(); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(WrapperError); + expect((error as WrapperError).message).toContain('Custom error message'); + } + }); + + it('handles empty response body', async () => { + const session = createMockSession({ exitCode: 0, stdout: '' }); + const client = new WrapperClient({ session, port: defaultPort }); + + // Empty response should return empty object + const result = await client.health(); + expect(result).toEqual({}); + }); + + it('handles malformed JSON response', async () => { + const session = createMockSession({ exitCode: 0, stdout: 'not json' }); + const client = new WrapperClient({ session, port: defaultPort }); + + await expect(client.health()).rejects.toThrow(WrapperError); + }); + + it('handles curl exit codes', async () => { + const session = createMockSession(createCurlError(28, 'Operation timed out')); + const client = new WrapperClient({ session, port: defaultPort }); + + try { + await client.health(); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(WrapperError); + expect((error as WrapperError).code).toBe('REQUEST_FAILED'); + } + }); + }); + + // ------------------------------------------------------------------------- + // Request Formatting + // ------------------------------------------------------------------------- + + describe('request formatting', () => { + it('escapes single quotes in JSON body', async () => { + const session = createMockSession( + createSuccessResponse({ status: 'sent', messageId: 'msg_1' }) + ); + const client = new WrapperClient({ session, port: defaultPort }); + + await client.prompt({ prompt: "It's a test with 'quotes'" }); + + const execCall = (session.exec as ReturnType).mock.calls[0][0] as string; + // Single quotes should be escaped for shell + expect(execCall).toContain("'\\''"); + }); + + it('uses correct HTTP method for GET requests', async () => { + const session = createMockSession( + createSuccessResponse({ healthy: true, state: 'idle', inflightCount: 0, version: '1.0.0' }) + ); + const client = new WrapperClient({ session, port: defaultPort }); + + await client.health(); + + const execCall = (session.exec as ReturnType).mock.calls[0][0] as string; + expect(execCall).toContain('-X GET'); + }); + + it('uses correct HTTP method for POST requests', async () => { + const session = createMockSession( + createSuccessResponse({ status: 'sent', messageId: 'msg_1' }) + ); + const client = new WrapperClient({ session, port: defaultPort }); + + await client.prompt({ prompt: 'test' }); + + const execCall = (session.exec as ReturnType).mock.calls[0][0] as string; + expect(execCall).toContain('-X POST'); + }); + + it('constructs correct URL with port', async () => { + const customPort = 5123; + const session = createMockSession( + createSuccessResponse({ healthy: true, state: 'idle', inflightCount: 0, version: '1.0.0' }) + ); + const client = new WrapperClient({ session, port: customPort }); + + await client.health(); + + const execCall = (session.exec as ReturnType).mock.calls[0][0] as string; + expect(execCall).toContain(`http://127.0.0.1:${customPort}`); + }); + }); + + // ------------------------------------------------------------------------- + // Error Classes + // ------------------------------------------------------------------------- + + describe('error classes', () => { + it('WrapperError has correct properties', () => { + const error = new WrapperError('Test message', 'TEST_CODE', 500); + + expect(error.message).toBe('Test message'); + expect(error.code).toBe('TEST_CODE'); + expect(error.statusCode).toBe(500); + expect(error.name).toBe('WrapperError'); + }); + + it('WrapperNotReadyError has correct properties', () => { + const error = new WrapperNotReadyError('Not ready'); + + expect(error.code).toBe('NOT_READY'); + expect(error.statusCode).toBe(503); + expect(error.name).toBe('WrapperNotReadyError'); + }); + + it('WrapperNoJobError has correct properties', () => { + const error = new WrapperNoJobError('No job'); + + expect(error.code).toBe('NO_JOB'); + expect(error.statusCode).toBe(400); + expect(error.name).toBe('WrapperNoJobError'); + }); + + it('WrapperJobConflictError has correct properties', () => { + const error = new WrapperJobConflictError('Conflict'); + + expect(error.code).toBe('JOB_CONFLICT'); + expect(error.statusCode).toBe(409); + expect(error.name).toBe('WrapperJobConflictError'); + }); + + it('error classes extend WrapperError', () => { + expect(new WrapperNotReadyError('test')).toBeInstanceOf(WrapperError); + expect(new WrapperNoJobError('test')).toBeInstanceOf(WrapperError); + expect(new WrapperJobConflictError('test')).toBeInstanceOf(WrapperError); + }); + }); +}); diff --git a/cloud-agent-next/src/kilo/wrapper-client.ts b/cloud-agent-next/src/kilo/wrapper-client.ts new file mode 100644 index 0000000000..2c2a1c1262 --- /dev/null +++ b/cloud-agent-next/src/kilo/wrapper-client.ts @@ -0,0 +1,424 @@ +/** + * WrapperClient - Client for interacting with the long-running wrapper. + * + * This client is used by the Worker/DO to communicate with the wrapper + * running inside the sandbox container via HTTP. + */ + +import type { ExecutionSession, SandboxInstance } from '../types.js'; +import { logger } from '../logger.js'; +import { + findWrapperForSession, + findAvailableWrapperPort, + getWrapperSessionMarker, +} from './wrapper-manager.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type WrapperClientOptions = { + /** Sandbox session for exec/writeFile operations */ + session: ExecutionSession; + /** Wrapper HTTP port (typically 5xxx) */ + port: number; +}; + +export type StartJobOptions = { + executionId: string; + ingestUrl: string; + ingestToken: string; + sessionId: string; + userId: string; + kilocodeToken: string; + kiloSessionId?: string; + kiloSessionTitle?: string; +}; + +export type WrapperPromptOptions = { + prompt?: string; + parts?: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + model?: { providerID?: string; modelID: string }; + agent?: string; + messageId?: string; + system?: string; + tools?: Record; +}; + +export type WrapperPermissionResponse = 'always' | 'once' | 'reject'; + +export type WrapperHealthResponse = { + healthy: boolean; + state: 'idle' | 'active'; + inflightCount: number; + version: string; +}; + +export type JobStatus = { + state: 'idle' | 'active'; + executionId?: string; + kiloSessionId?: string; + inflight: string[]; + inflightCount: number; + lastError?: { + code: string; + messageId?: string; + message: string; + timestamp: number; + }; +}; + +export type WrapperSessionCommandResponse = unknown; + +// --------------------------------------------------------------------------- +// Error Classes +// --------------------------------------------------------------------------- + +export class WrapperError extends Error { + constructor( + message: string, + public readonly code: string, + public readonly statusCode: number + ) { + super(message); + this.name = 'WrapperError'; + } +} + +export class WrapperNotReadyError extends WrapperError { + constructor(message: string) { + super(message, 'NOT_READY', 503); + this.name = 'WrapperNotReadyError'; + } +} + +export class WrapperNoJobError extends WrapperError { + constructor(message: string) { + super(message, 'NO_JOB', 400); + this.name = 'WrapperNoJobError'; + } +} + +export class WrapperJobConflictError extends WrapperError { + constructor(message: string) { + super(message, 'JOB_CONFLICT', 409); + this.name = 'WrapperJobConflictError'; + } +} + +/** Map wrapper error codes to HTTP status codes */ +const ERROR_STATUS_CODES: Record = { + NO_JOB: 400, + JOB_CONFLICT: 409, + NOT_FOUND: 404, +}; + +// --------------------------------------------------------------------------- +// WrapperClient Implementation +// --------------------------------------------------------------------------- + +export class WrapperClient { + private readonly session: ExecutionSession; + private readonly port: number; + private readonly baseUrl: string; + + constructor(options: WrapperClientOptions) { + this.session = options.session; + this.port = options.port; + this.baseUrl = `http://127.0.0.1:${this.port}`; + } + + /** + * Make an HTTP request to the wrapper. + * Uses session.exec to run curl inside the container. + */ + private async request(method: 'GET' | 'POST', path: string, body?: unknown): Promise { + const url = `${this.baseUrl}${path}`; + + // Build curl command as a single string + let command = `curl -s -X ${method} -H 'Content-Type: application/json'`; + + if (body) { + // Escape single quotes in JSON + const json = JSON.stringify(body).replace(/'/g, "'\\''"); + command += ` -d '${json}'`; + } + + command += ` '${url}'`; + + // Execute curl in the container + const result = await this.session.exec(command); + + if (result.exitCode !== 0) { + const stderr = result.stderr?.trim() ?? ''; + throw new WrapperError(`Request failed: ${stderr || 'curl error'}`, 'REQUEST_FAILED', 500); + } + + const stdout = result.stdout?.trim() ?? ''; + if (!stdout) { + // Some endpoints return empty body + return {} as T; + } + + try { + const response = JSON.parse(stdout) as T & { error?: string; message?: string }; + + // Check for error response + if (response.error) { + const statusCode = ERROR_STATUS_CODES[response.error] ?? 500; + + if (response.error === 'NO_JOB') { + throw new WrapperNoJobError(response.message ?? 'No job started'); + } + if (response.error === 'JOB_CONFLICT') { + throw new WrapperJobConflictError(response.message ?? 'Job conflict'); + } + + throw new WrapperError(response.message ?? response.error, response.error, statusCode); + } + + return response; + } catch (e) { + if (e instanceof WrapperError) throw e; + throw new WrapperError(`Failed to parse response: ${stdout}`, 'PARSE_ERROR', 500); + } + } + + // --------------------------------------------------------------------------- + // Lifecycle Methods + // --------------------------------------------------------------------------- + + /** + * Ensure the wrapper is running and healthy. + * Starts the wrapper if needed and waits for it to be ready. + * + * NOTE: This method assumes the WrapperClient was created with the correct port + * (either found via findWrapperForSession or allocated via findAvailableWrapperPort). + * Use the static ensureWrapper() method for the full flow. + */ + async ensureRunning(options: { + sessionId: string; + wrapperPath?: string; + maxWaitMs?: number; + pollIntervalMs?: number; + /** Kilo server port (required for wrapper to connect) */ + kiloServerPort: number; + /** Workspace path (required by wrapper) */ + workspacePath: string; + }): Promise { + const { + sessionId, + wrapperPath = '/usr/local/bin/kilocode-wrapper.js', + maxWaitMs = 30_000, + kiloServerPort, + workspacePath, + } = options; + + // First, try to check health + try { + await this.health(); + logger.debug('WrapperClient: wrapper already running'); + return; // Already running + } catch { + // Not running, need to start + logger.debug('WrapperClient: wrapper not running, starting...'); + } + + // Start the wrapper process using startProcess so it's trackable via listProcesses() + // The command includes a session marker so we can find this wrapper later + const sessionMarker = getWrapperSessionMarker(sessionId); + const command = `${sessionMarker} WRAPPER_PORT=${this.port} KILO_SERVER_PORT=${kiloServerPort} WORKSPACE_PATH=${workspacePath} bun run ${wrapperPath}`; + + logger.debug('WrapperClient: starting wrapper process', { command, port: this.port }); + + try { + const proc = await this.session.startProcess(command, { + cwd: workspacePath, + }); + + // Wait for wrapper to become healthy via port check + await proc.waitForPort(this.port, { + mode: 'http', + path: '/health', + timeout: maxWaitMs, + }); + + logger.debug('WrapperClient: wrapper is ready', { port: this.port, processId: proc.id }); + } catch (error) { + throw new WrapperNotReadyError( + `Wrapper did not become ready within ${maxWaitMs}ms: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + /** + * Ensure a wrapper is running for the given session. + * + * This is the main entry point for wrapper lifecycle management: + * 1. Checks if a wrapper already exists for this session (sandbox-wide search) + * 2. If found and running, returns a client for it + * 3. If not found, allocates a port and starts a new wrapper + * + * @param sandbox - The sandbox instance (for listing processes across all sessions) + * @param session - The execution session (for starting processes within session context) + * @param sessionId - The cloud-agent session ID + * @param kiloServerPort - Port where kilo server is running + * @param workspacePath - Workspace path for the session + * @returns A WrapperClient connected to the running wrapper + */ + static async ensureWrapper( + sandbox: SandboxInstance, + session: ExecutionSession, + sessionId: string, + kiloServerPort: number, + workspacePath: string + ): Promise { + logger.withFields({ sessionId, workspacePath }).info('Ensuring wrapper is running'); + + // 1. Check for existing wrapper (sandbox-wide search) + const existing = await findWrapperForSession(sandbox, sessionId); + + if (existing) { + const { port } = existing; + logger.withFields({ sessionId, port }).info('Found existing wrapper'); + const client = new WrapperClient({ session, port }); + + // Verify it's healthy + try { + await client.health(); + return client; + } catch { + logger + .withFields({ sessionId, port }) + .warn('Existing wrapper not healthy, will start new one'); + } + } + + // 2. Find available port and start new wrapper + const port = await findAvailableWrapperPort(sandbox, sessionId); + logger.withFields({ sessionId, port }).info('Starting new wrapper'); + + const client = new WrapperClient({ session, port }); + await client.ensureRunning({ + sessionId, + kiloServerPort, + workspacePath, + }); + + return client; + } + + /** + * Start a new job (creates/resumes kilo session and stores context). + */ + async startJob(options: StartJobOptions): Promise<{ kiloSessionId: string }> { + const response = await this.request<{ + status: string; + kiloSessionId: string; + }>('POST', '/job/start', options); + + return { kiloSessionId: response.kiloSessionId }; + } + + // --------------------------------------------------------------------------- + // Action Methods (tracked in inflight) + // --------------------------------------------------------------------------- + + /** + * Send a prompt to the wrapper. + * Opens connection if idle, tracks in inflight. + */ + async prompt(options: WrapperPromptOptions): Promise<{ messageId: string }> { + const response = await this.request<{ + status: string; + messageId: string; + }>('POST', '/job/prompt', options); + + return { messageId: response.messageId }; + } + + // --------------------------------------------------------------------------- + // Action Methods (synchronous, no inflight tracking) + // --------------------------------------------------------------------------- + + /** + * Send a command (slash command) to the wrapper. + * Does NOT open connection or track inflight. + */ + async command(command: string, args?: string): Promise { + const response = await this.request<{ + status: string; + result: WrapperSessionCommandResponse; + }>('POST', '/job/command', { command, args }); + + return response.result; + } + + // --------------------------------------------------------------------------- + // Action Methods (fire-and-forget) + // --------------------------------------------------------------------------- + + /** + * Answer a permission request. + */ + async answerPermission( + permissionId: string, + response: WrapperPermissionResponse + ): Promise<{ success: boolean }> { + const result = await this.request<{ + status: string; + success: boolean; + }>('POST', '/job/answer-permission', { permissionId, response }); + + return { success: result.success }; + } + + /** + * Answer a question. + */ + async answerQuestion(questionId: string, answers: string[]): Promise<{ success: boolean }> { + const result = await this.request<{ + status: string; + success: boolean; + }>('POST', '/job/answer-question', { questionId, answers }); + + return { success: result.success }; + } + + /** + * Reject a question. + */ + async rejectQuestion(questionId: string): Promise<{ success: boolean }> { + const result = await this.request<{ + status: string; + success: boolean; + }>('POST', '/job/reject-question', { questionId }); + + return { success: result.success }; + } + + /** + * Abort the current job. + */ + async abort(): Promise { + await this.request<{ status: string }>('POST', '/job/abort', {}); + } + + // --------------------------------------------------------------------------- + // Status Methods + // --------------------------------------------------------------------------- + + /** + * Check wrapper health. + */ + async health(): Promise { + return this.request('GET', '/health'); + } + + /** + * Get current job status. + */ + async status(): Promise { + return this.request('GET', '/job/status'); + } +} diff --git a/cloud-agent-next/src/kilo/wrapper-manager.ts b/cloud-agent-next/src/kilo/wrapper-manager.ts new file mode 100644 index 0000000000..7aa809d621 --- /dev/null +++ b/cloud-agent-next/src/kilo/wrapper-manager.ts @@ -0,0 +1,199 @@ +/** + * Wrapper Manager + * + * Manages the lifecycle of wrapper instances within sandboxes. + * Each cloud-agent session gets its own wrapper, identified by a + * command marker (KILO_WRAPPER_SESSION={sessionId}) embedded in the process command. + * + * This is similar to server-manager.ts but for the wrapper process, + * using the 5xxx port range instead of 4xxx. + */ + +import type { SandboxInstance } from '../types.js'; +import { logger } from '../logger.js'; + +// Re-export Process type from sandbox for consumers +type Process = Awaited>[number]; + +/** Starting port for wrappers (5xxx range) */ +const WRAPPER_START_PORT = 5000; + +/** Port range size for session-based port allocation */ +const WRAPPER_PORT_RANGE = 1000; + +/** Environment variable marker to identify which session owns a wrapper */ +const KILO_WRAPPER_SESSION_MARKER = 'KILO_WRAPPER_SESSION'; + +/** + * Information about a running wrapper. + */ +export type WrapperInfo = { + port: number; + process: Process; +}; + +/** + * Extract port number from a wrapper command string. + * Parses "WRAPPER_PORT=XXXX" from the command. + * + * @param command - The full command string + * @returns The port number, or null if not found + */ +export function extractWrapperPortFromCommand(command: string): number | null { + // Match WRAPPER_PORT= followed by digits + const match = command.match(/WRAPPER_PORT=(\d+)/); + if (match && match[1]) { + const port = parseInt(match[1], 10); + if (!isNaN(port) && port > 0 && port < 65536) { + return port; + } + } + return null; +} + +/** + * Extract session ID from a wrapper command string. + * Parses "KILO_WRAPPER_SESSION=XXX" from the command. + * + * @param command - The full command string + * @returns The session ID, or null if not found + */ +export function extractWrapperSessionIdFromCommand(command: string): string | null { + const marker = `${KILO_WRAPPER_SESSION_MARKER}=`; + const idx = command.indexOf(marker); + if (idx === -1) { + return null; + } + + // Extract everything after the marker until whitespace + const startIdx = idx + marker.length; + const endIdx = command.indexOf(' ', startIdx); + if (endIdx === -1) { + return command.slice(startIdx); + } + return command.slice(startIdx, endIdx); +} + +/** + * Find an existing wrapper for the given session. + * Scans listProcesses() for a command containing KILO_WRAPPER_SESSION={sessionId}. + * + * @param sandbox - The sandbox instance to search in + * @param sessionId - The cloud-agent session ID to find + * @returns Wrapper info if found, null otherwise + */ +export async function findWrapperForSession( + sandbox: SandboxInstance, + sessionId: string +): Promise { + const processes = await sandbox.listProcesses(); + const marker = `${KILO_WRAPPER_SESSION_MARKER}=${sessionId}`; + + for (const proc of processes) { + if (proc.command.includes(marker) && proc.command.includes('kilocode-wrapper')) { + const status = proc.status; + if (status === 'running' || status === 'starting') { + const port = extractWrapperPortFromCommand(proc.command); + if (port !== null) { + logger + .withFields({ sessionId, port, processId: proc.id, status }) + .debug('Found existing wrapper for session'); + return { port, process: proc }; + } + } + } + } + + return null; +} + +/** + * Find all ports currently in use by wrapper processes. + * Used to avoid port collisions when starting a new wrapper. + * + * @param sandbox - The sandbox instance to search in + * @returns Set of ports currently in use + */ +export async function findUsedWrapperPorts(sandbox: SandboxInstance): Promise> { + const processes = await sandbox.listProcesses(); + const usedPorts = new Set(); + + for (const proc of processes) { + if (proc.command.includes('kilocode-wrapper')) { + const status = proc.status; + if (status === 'running' || status === 'starting') { + const port = extractWrapperPortFromCommand(proc.command); + if (port !== null) { + usedPorts.add(port); + } + } + } + } + + return usedPorts; +} + +/** + * Derive a preferred port from a sessionId using a simple hash. + * This ensures the same session consistently tries the same port first, + * improving cache locality and making debugging easier. + * + * @param sessionId - The cloud-agent session ID + * @returns A port number in the range [WRAPPER_START_PORT, WRAPPER_START_PORT + WRAPPER_PORT_RANGE) + */ +export function deriveWrapperPortFromSessionId(sessionId: string): number { + // Simple hash: sum of char codes modulo port range + // Use a different multiplier than kilo server to spread ports differently + let hash = 0; + for (let i = 0; i < sessionId.length; i++) { + hash = (hash * 37 + sessionId.charCodeAt(i)) >>> 0; // unsigned 32-bit + } + return WRAPPER_START_PORT + (hash % WRAPPER_PORT_RANGE); +} + +/** + * Find an available port for a wrapper, starting from the session-derived preferred port. + * Falls back to scanning if the preferred port is in use. + * + * @param sandbox - The sandbox instance to check + * @param sessionId - The session ID to derive the preferred port from + * @returns First available port + */ +export async function findAvailableWrapperPort( + sandbox: SandboxInstance, + sessionId: string +): Promise { + const usedPorts = await findUsedWrapperPorts(sandbox); + const preferredPort = deriveWrapperPortFromSessionId(sessionId); + + // Try preferred port first + if (!usedPorts.has(preferredPort)) { + return preferredPort; + } + + // Fall back to scanning from preferred port + for (let offset = 1; offset < WRAPPER_PORT_RANGE; offset++) { + const port = + WRAPPER_START_PORT + ((preferredPort - WRAPPER_START_PORT + offset) % WRAPPER_PORT_RANGE); + if (!usedPorts.has(port)) { + return port; + } + } + + // If all ports in range are used, scan beyond + for (let port = WRAPPER_START_PORT + WRAPPER_PORT_RANGE; port < 65535; port++) { + if (!usedPorts.has(port)) { + return port; + } + } + + // Extremely unlikely to reach here + throw new Error('No available ports for wrapper'); +} + +/** + * Get the session marker environment variable for a wrapper command. + */ +export function getWrapperSessionMarker(sessionId: string): string { + return `${KILO_WRAPPER_SESSION_MARKER}=${sessionId}`; +} diff --git a/cloud-agent-next/src/lib/result.test.ts b/cloud-agent-next/src/lib/result.test.ts new file mode 100644 index 0000000000..3f70144fb8 --- /dev/null +++ b/cloud-agent-next/src/lib/result.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; +import { Ok, Err, isOk, isErr, mapResult, unwrap, unwrapOr } from './result.js'; + +describe('Result Type', () => { + describe('Ok', () => { + it('should create a success result', () => { + const result = Ok(42); + expect(result).toEqual({ ok: true, value: 42 }); + }); + }); + + describe('Err', () => { + it('should create an error result', () => { + const result = Err('error message'); + expect(result).toEqual({ ok: false, error: 'error message' }); + }); + }); + + describe('isOk', () => { + it('should return true for Ok results', () => { + expect(isOk(Ok(42))).toBe(true); + }); + + it('should return false for Err results', () => { + expect(isOk(Err('error'))).toBe(false); + }); + }); + + describe('isErr', () => { + it('should return true for Err results', () => { + expect(isErr(Err('error'))).toBe(true); + }); + + it('should return false for Ok results', () => { + expect(isErr(Ok(42))).toBe(false); + }); + }); + + describe('mapResult', () => { + it('should apply function to Ok value', () => { + const result = mapResult(Ok(21), x => x * 2); + expect(result).toEqual(Ok(42)); + }); + + it('should pass through Err unchanged', () => { + const result = mapResult(Err('error'), (x: number) => x * 2); + expect(result).toEqual(Err('error')); + }); + }); + + describe('unwrap', () => { + it('should return value for Ok results', () => { + expect(unwrap(Ok(42))).toBe(42); + }); + + it('should throw for Err results with string error', () => { + expect(() => unwrap(Err('error'))).toThrow('error'); + }); + + it('should throw the original error for Err results with Error instance', () => { + const error = new Error('test error'); + expect(() => unwrap(Err(error))).toThrow(error); + }); + }); + + describe('unwrapOr', () => { + it('should return value for Ok results', () => { + expect(unwrapOr(Ok(42), 0)).toBe(42); + }); + + it('should return default for Err results', () => { + expect(unwrapOr(Err('error'), 0)).toBe(0); + }); + }); +}); diff --git a/cloud-agent-next/src/lib/result.ts b/cloud-agent-next/src/lib/result.ts new file mode 100644 index 0000000000..8dcd0f5d24 --- /dev/null +++ b/cloud-agent-next/src/lib/result.ts @@ -0,0 +1,75 @@ +/** + * Result type for explicit error handling without exceptions. + * + * This pattern makes error cases explicit in the type system, + * avoiding the need to rely on try/catch for control flow. + */ + +// --------------------------------------------------------------------------- +// Result Type +// --------------------------------------------------------------------------- + +/** + * A Result represents either success (Ok) or failure (Err). + * Use this instead of throwing exceptions for expected error cases. + */ +export type Result = { ok: true; value: T } | { ok: false; error: E }; + +// --------------------------------------------------------------------------- +// Constructors +// --------------------------------------------------------------------------- + +/** Create a successful Result containing a value */ +export const Ok = (value: T): Result => ({ ok: true, value }); + +/** Create a failed Result containing an error */ +export const Err = (error: E): Result => ({ ok: false, error }); + +// --------------------------------------------------------------------------- +// Type Guards +// --------------------------------------------------------------------------- + +/** Check if a Result is Ok (successful) */ +export const isOk = (r: Result): r is { ok: true; value: T } => r.ok; + +/** Check if a Result is Err (failed) */ +export const isErr = (r: Result): r is { ok: false; error: E } => !r.ok; + +// --------------------------------------------------------------------------- +// Combinators +// --------------------------------------------------------------------------- + +/** + * Transform the value inside a successful Result. + * If the Result is an error, it passes through unchanged. + * + * @param r - The Result to transform + * @param fn - Function to apply to the value + * @returns A new Result with the transformed value + */ +export const mapResult = (r: Result, fn: (v: T) => U): Result => + r.ok ? Ok(fn(r.value)) : r; + +/** + * Extract the value from a Result, throwing if it's an error. + * Use sparingly - prefer pattern matching or combinators. + * + * @param r - The Result to unwrap + * @returns The value if Ok + * @throws The error if Err (wrapped in Error if not already an Error instance) + */ +export const unwrap = (r: Result): T => { + if (r.ok) return r.value; + if (r.error instanceof Error) throw r.error; + throw new Error(String(r.error)); +}; + +/** + * Extract the value from a Result, returning a default if it's an error. + * + * @param r - The Result to unwrap + * @param defaultValue - Value to return if Result is an error + * @returns The value if Ok, otherwise the default + */ +export const unwrapOr = (r: Result, defaultValue: T): T => + r.ok ? r.value : defaultValue; diff --git a/cloud-agent-next/src/logger.ts b/cloud-agent-next/src/logger.ts new file mode 100644 index 0000000000..8a27e18c2a --- /dev/null +++ b/cloud-agent-next/src/logger.ts @@ -0,0 +1,44 @@ +import { WorkersLogger } from 'workers-tagged-logger'; + +/** + * Tag types for structured logging across cloud-agent + */ +export type CloudAgentTags = { + // Core identifiers + sessionId?: string; + userId?: string; + sandboxId?: string; + orgId?: string; + executionId?: string; + botId?: string; + + // Execution context + mode?: 'plan' | 'code' | 'build' | 'orchestrator' | 'architect' | 'ask' | 'custom'; + model?: string; + isResume?: boolean; + + // Repository context + githubRepo?: string; + branchName?: string; + workspacePath?: string; + + // Source tracking (auto-added by decorators) + source?: string; +}; + +/** + * Global logger instance for cloud-agent + * + * Use debug mode in development to catch missing context issues. + * In production, keep debug: false to reduce noise. + */ +export const logger = new WorkersLogger({ + // In production, only log warnings and errors by default + // Individual contexts can override with setLogLevel() + minimumLogLevel: 'debug', + + // Enable in development to catch missing withLogTags() contexts + debug: false, +}); + +export { withLogTags, WithLogTags } from 'workers-tagged-logger'; diff --git a/cloud-agent-next/src/persistence/CloudAgentSession.ts b/cloud-agent-next/src/persistence/CloudAgentSession.ts new file mode 100644 index 0000000000..364f4b5876 --- /dev/null +++ b/cloud-agent-next/src/persistence/CloudAgentSession.ts @@ -0,0 +1,1788 @@ +/** + * SQLite-backed Durable Object for cloud agent session metadata. + * Automatically cleans up after 90 days of inactivity. + * Uses RPC methods for type-safe communication. + */ + +import { DurableObject } from 'cloudflare:workers'; +import { TRPCError } from '@trpc/server'; +import type { CloudAgentSessionState, OperationResult, MCPServerConfig } from './types.js'; +import { MetadataSchema, type Images } from './schemas.js'; +import type { EncryptedSecrets } from '../router/schemas.js'; +import type { CallbackJob, CallbackTarget } from '../callbacks/index.js'; +import { logger } from '../logger.js'; +import { Limits } from '../schema.js'; +import { runMigrations } from './migrations.js'; +import { normalizeKilocodeModel } from './model-utils.js'; +import { + createExecutionQueries, + createEventQueries, + createLeaseQueries, + type ExecutionQueries, + type EventQueries, + type LeaseQueries, + type LeaseAcquireError, +} from '../session/queries/index.js'; +import { createExecutionId } from '../types/ids.js'; +import type { ExecutionId, SessionId, UserId } from '../types/ids.js'; +import type { + ExecutionMetadata, + AddExecutionParams, + UpdateExecutionStatusParams, +} from '../session/types.js'; +import type { ExecutionStatus } from '../core/execution.js'; +import type { Result } from '../lib/result.js'; +import type { + AddExecutionError, + UpdateStatusError, + SetActiveError, +} from '../session/queries/executions.js'; +import { createStreamHandler, type StreamHandler } from '../websocket/stream.js'; +import { + createIngestHandler, + type IngestHandler, + type IngestDOContext, +} from '../websocket/ingest.js'; +import type { StoredEvent } from '../websocket/types.js'; +import type { WrapperCommand } from '../shared/protocol.js'; +import { STALE_THRESHOLD_MS } from '../core/lease.js'; +import { ExecutionOrchestrator, type OrchestratorDeps } from '../execution/orchestrator.js'; +import type { + ExecutionMode, + ExecutionPlan, + StartExecutionV2Request, + StartExecutionV2Result, + InitializeContext, + TokenResumeContext, +} from '../execution/types.js'; +import { isExecutionError } from '../execution/errors.js'; +import type { Env as WorkerEnv } from '../types.js'; +import { generateSandboxId } from '../sandbox-id.js'; + +import { GitHubTokenService } from '../services/github-token-service.js'; +import { validateStreamTicket } from '../auth.js'; +import { getSandbox } from '@cloudflare/sandbox'; +import { stopKiloServer } from '../kilo/server-manager.js'; + +// --------------------------------------------------------------------------- +// Alarm Constants +// --------------------------------------------------------------------------- + +/** Reaper alarm interval: 5 minutes */ +const REAPER_INTERVAL_MS_DEFAULT = 5 * 60 * 1000; +const PENDING_START_TIMEOUT_MS_DEFAULT = 5 * 60 * 1000; + +/** Event retention period: 90 days (aligns with session TTL) */ +const EVENT_RETENTION_MS = Limits.SESSION_TTL_MS; + +/** Storage key for tracking last activity timestamp */ +const LAST_ACTIVITY_KEY = 'last_activity'; + +/** Kilo server idle timeout: 15 minutes */ +const KILO_SERVER_IDLE_TIMEOUT_MS_DEFAULT = 15 * 60 * 1000; + +export class CloudAgentSession extends DurableObject { + private executionQueries: ExecutionQueries; + private eventQueries: EventQueries; + private leaseQueries: LeaseQueries; + private streamHandler?: StreamHandler; + private ingestHandler?: IngestHandler; + private streamHandlerSessionId?: SessionId; + private ingestHandlerSessionId?: SessionId; + private sessionId?: SessionId; + private orchestrator?: ExecutionOrchestrator; + + private isTerminalStatus( + status: ExecutionStatus + ): status is 'completed' | 'failed' | 'interrupted' { + return status === 'completed' || status === 'failed' || status === 'interrupted'; + } + + private async enqueueCallbackNotification( + executionId: ExecutionId, + status: 'completed' | 'failed' | 'interrupted', + error?: string + ): Promise { + const metadata = await this.getMetadata(); + const callbackQueue = (this.env as unknown as WorkerEnv).CALLBACK_QUEUE; + + if (!metadata?.callbackTarget || !callbackQueue) { + return; + } + + logger.info('Enqueued callback job', { + cloudAgentSessionId: metadata.sessionId, + kiloSessionId: metadata.kiloSessionId, + executionId, + callbackUrl: metadata.callbackTarget.url, + }); + + const resolvedSessionId = await this.resolveSessionId(metadata.sessionId as SessionId); + const sessionId = resolvedSessionId ?? metadata.sessionId ?? ''; + + const callbackJob: CallbackJob = { + target: metadata.callbackTarget, + payload: { + sessionId, + cloudAgentSessionId: sessionId, + executionId, + status, + errorMessage: error, + lastSeenBranch: metadata.upstreamBranch, + kiloSessionId: metadata.kiloSessionId, + }, + }; + + // Fire-and-forget enqueue - don't block execution completion + callbackQueue.send(callbackJob).catch(err => { + logger + .withFields({ + sessionId, + executionId, + error: err instanceof Error ? err.message : String(err), + }) + .error('Failed to enqueue callback job'); + }); + } + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + + // Extract sessionId from DO name pattern: "userId:sessionId" + // The DO name is set by the worker when creating the stub + const doName = ctx.id.name; + const sessionIdPart = doName?.split(':')[1]; + this.sessionId = sessionIdPart ? (sessionIdPart as SessionId) : undefined; + + // Initialize query modules with storage + this.executionQueries = createExecutionQueries(ctx.storage); + this.eventQueries = createEventQueries(ctx.storage.sql); + this.leaseQueries = createLeaseQueries(ctx.storage.sql); + + // Run schema migrations on first access to this DO instance. + // blockConcurrencyWhile blocks all concurrent requests until completed, + // ensuring migrations complete before any handlers execute. + // Also ensures reaper alarm is scheduled. + void ctx.blockConcurrencyWhile(async () => { + await runMigrations(ctx); + await this.ensureAlarmScheduled(); + }); + } + + /** + * Resolve the canonical sessionId for this DO. + * Prefer metadata, then the expected sessionId, then existing value. + */ + private async resolveSessionId(expected?: SessionId): Promise { + if (this.sessionId?.startsWith('sess_')) { + this.sessionId = undefined; + } + + if (this.sessionId) { + if (expected && this.sessionId !== expected) { + throw new Error(`SessionId mismatch: ${expected} != ${this.sessionId}`); + } + return this.sessionId; + } + + const metadata = await this.ctx.storage.get('metadata'); + if (metadata?.sessionId) { + if (expected && metadata.sessionId !== expected) { + throw new Error(`SessionId mismatch: ${expected} != ${metadata.sessionId}`); + } + this.sessionId = metadata.sessionId as SessionId; + return this.sessionId; + } + + if (expected) { + this.sessionId = expected; + return expected; + } + + return null; + } + + private async requireSessionId(expected?: SessionId): Promise { + const sessionId = await this.resolveSessionId(expected); + if (!sessionId) { + throw new Error('SessionId is not available'); + } + return sessionId; + } + + private async getStreamHandler(expected?: SessionId): Promise { + const sessionId = await this.requireSessionId(expected); + if (!this.streamHandler || this.streamHandlerSessionId !== sessionId) { + this.streamHandler = createStreamHandler(this.ctx, this.eventQueries, sessionId); + this.streamHandlerSessionId = sessionId; + } + return this.streamHandler; + } + + private async getIngestHandler(): Promise { + const sessionId = await this.requireSessionId(); + if (!this.ingestHandler || this.ingestHandlerSessionId !== sessionId) { + // Create DO context for the ingest handler to call back into the DO + const doContext: IngestDOContext = { + updateKiloSessionId: (id: string) => this.updateKiloSessionId(id), + linkKiloSessionInBackend: (id: string) => this.linkKiloSessionInBackend(id), + updateUpstreamBranch: (branch: string) => this.updateUpstreamBranch(branch), + clearActiveExecution: () => this.clearActiveExecution(), + getExecution: async (executionId: string) => { + const execution = await this.executionQueries.get(executionId as ExecutionId); + if (!execution) return null; + return { + executionId: execution.executionId, + status: execution.status, + ingestToken: execution.ingestToken, + }; + }, + transitionToRunning: async (executionId: string) => { + const result = await this.executionQueries.updateStatus({ + executionId: executionId as ExecutionId, + status: 'running', + }); + return result.ok; + }, + updateHeartbeat: async (executionId: string, timestamp: number) => { + await this.executionQueries.updateHeartbeat(executionId as ExecutionId, timestamp); + }, + updateExecutionStatus: async ( + executionId: string, + status: 'completed' | 'failed' | 'interrupted', + error?: string + ) => { + await this.updateExecutionStatus({ + executionId: executionId as ExecutionId, + status, + error, + completedAt: Date.now(), + }); + }, + }; + + this.ingestHandler = createIngestHandler( + this.ctx, + this.eventQueries, + sessionId, + event => this.broadcastEvent(event), + doContext + ); + this.ingestHandlerSessionId = sessionId; + } + return this.ingestHandler; + } + + // --------------------------------------------------------------------------- + // HTTP/WebSocket Routing + // --------------------------------------------------------------------------- + + /** + * Handle incoming HTTP requests and WebSocket upgrades. + * Routes to appropriate handler based on URL pathname. + */ + async fetch(request: Request): Promise { + const url = new URL(request.url); + + // Route WebSocket upgrade requests + if (url.pathname === '/stream') { + const sessionIdParam = url.searchParams.get('cloudAgentSessionId') as SessionId | null; + const ticket = url.searchParams.get('ticket'); + const origin = request.headers.get('Origin'); + + const allowedOrigins = (this.env.WS_ALLOWED_ORIGINS || '') + .split(',') + .map(value => value.trim()) + .filter(Boolean); + + if (allowedOrigins.length > 0 && origin && !allowedOrigins.includes(origin)) { + logger + .withFields({ origin, allowedOrigins, sessionId: sessionIdParam }) + .warn('DO /stream: Origin not allowed'); + return new Response('Origin not allowed', { status: 403 }); + } + + if (!sessionIdParam) { + return new Response('Missing cloudAgentSessionId', { status: 400 }); + } + + const authResult = validateStreamTicket(ticket, this.env.NEXTAUTH_SECRET); + if (!authResult.success) { + return new Response(authResult.error, { status: 401 }); + } + + const ticketSessionId = + authResult.payload.cloudAgentSessionId || authResult.payload.sessionId; + if (!ticketSessionId || ticketSessionId !== sessionIdParam) { + return new Response('Invalid ticket session', { status: 401 }); + } + + const streamHandler = await this.getStreamHandler(sessionIdParam ?? undefined); + return streamHandler.handleStreamRequest(request); + } + + // Route ingest WebSocket (internal only - from queue consumer) + if (url.pathname === '/ingest') { + const ingestHandler = await this.getIngestHandler(); + return ingestHandler.handleIngestRequest(request); + } + + // No matching route + return new Response('Not Found', { status: 404 }); + } + + // --------------------------------------------------------------------------- + // WebSocket Lifecycle Methods (Hibernation API) + // --------------------------------------------------------------------------- + + /** + * Handle incoming messages from WebSocket clients. + * Distinguishes between /stream (server-push only) and /ingest connections. + */ + async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise { + const tags = this.ctx.getTags(ws); + + // Check if this is an ingest connection + if (tags.some(tag => tag.startsWith('ingest:'))) { + const ingestHandler = await this.getIngestHandler(); + void ingestHandler.handleIngestMessage(ws, message); + return; + } + + // Stream connections are server-push only, ignore client messages + // Future: could handle client commands like subscribe/unsubscribe + } + + /** + * Handle WebSocket close events. + * Cleans up ingest connections and logs the disconnection. + */ + async webSocketClose( + ws: WebSocket, + code: number, + reason: string, + wasClean: boolean + ): Promise { + const tags = this.ctx.getTags(ws); + + // Clean up ingest connection tracking + if (tags.some(tag => tag.startsWith('ingest:'))) { + const ingestHandler = await this.getIngestHandler(); + ingestHandler.handleIngestClose(ws); + } + + logger.debug(`WebSocket closed: code=${code}, reason=${reason}, wasClean=${wasClean}`); + } + + /** + * Handle WebSocket errors. + * Logs the error for debugging purposes. + */ + async webSocketError(_ws: WebSocket, error: unknown): Promise { + logger + .withFields({ + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }) + .error('WebSocket error'); + } + + // --------------------------------------------------------------------------- + // Event Broadcasting + // --------------------------------------------------------------------------- + + /** + * Broadcast a new event to all connected /stream clients. + * Called from the ingest handler when new events are stored. + * + * @param event - The stored event to broadcast + */ + broadcastEvent(event: StoredEvent): void { + if (this.streamHandler) { + this.streamHandler.broadcastEvent(event); + return; + } + + void this.getStreamHandler() + .then(handler => { + handler.broadcastEvent(event); + }) + .catch(error => { + logger + .withFields({ + error: error instanceof Error ? error.message : String(error), + }) + .warn('Failed to broadcast event - stream handler unavailable'); + }); + } + + /** + * Get count of connected stream clients. + * + * @returns Number of active WebSocket connections + */ + getConnectedClientCount(): number { + return this.streamHandler?.getConnectedClientCount() ?? 0; + } + + // --------------------------------------------------------------------------- + // Metadata RPC Methods + // --------------------------------------------------------------------------- + /** + * Get session metadata. + * Returns null if no metadata has been written yet (e.g., before first CLI execution). + */ + async getMetadata(): Promise { + const metadata = await this.ctx.storage.get('metadata'); + return metadata || null; + } + + /** + * Update session metadata with validation. + * Throws an error if validation fails. + */ + async updateMetadata(data: unknown): Promise { + const result = MetadataSchema.safeParse(data); + if (!result.success) { + throw new Error(`Invalid metadata structure: ${JSON.stringify(result.error.format())}`); + } + + const newMetadata: CloudAgentSessionState = result.data; + await this.ctx.storage.put('metadata', newMetadata); + + // Track activity for session TTL + await this.updateLastActivity(); + } + + /** + * Mark this session as interrupted. + * Used to signal streaming generators to stop when interruptSession is called. + */ + async markAsInterrupted(): Promise { + await this.ctx.storage.put('interrupted', true); + } + + /** + * Check if this session has been marked as interrupted. + */ + async isInterrupted(): Promise { + const interrupted = await this.ctx.storage.get('interrupted'); + return interrupted ?? false; + } + + /** + * Clear the interrupted flag. + * Should be called when starting a new execution after an interrupt. + */ + async clearInterrupted(): Promise { + await this.ctx.storage.delete('interrupted'); + } + + /** + * Update the Kilo CLI session ID for continuation. + * This ID is captured from the session_created event emitted by the CLI. + */ + async updateKiloSessionId(kiloSessionId: string): Promise { + const metadata = await this.getMetadata(); + if (!metadata) { + throw new Error('Cannot update kiloSessionId: session metadata not found'); + } + + const updated = { + ...metadata, + kiloSessionId, + version: Date.now(), // Bump version for cache invalidation + }; + + await this.updateMetadata(updated); + } + + /** + * Update the GitHub Personal Access Token for this session. + * This allows refreshing tokens without re-initializing the session. + */ + async updateGithubToken(githubToken: string): Promise { + const metadata = await this.getMetadata(); + if (!metadata) { + throw new Error('Cannot update githubToken: session metadata not found'); + } + + const updated = { + ...metadata, + githubToken, + version: Date.now(), // Bump version for cache invalidation + }; + + await this.updateMetadata(updated); + } + + /** + * Update the Git token for this session (for generic git repos). + * This allows refreshing tokens without re-initializing the session. + */ + async updateGitToken(gitToken: string): Promise { + const metadata = await this.getMetadata(); + if (!metadata) { + throw new Error('Cannot update gitToken: session metadata not found'); + } + + const updated = { + ...metadata, + gitToken, + version: Date.now(), // Bump version for cache invalidation + }; + + await this.updateMetadata(updated); + } + + /** + * Update the upstream branch for this session. + * This allows capturing the branch after kilo execution without a full metadata write. + */ + async updateUpstreamBranch(upstreamBranch: string): Promise { + const metadata = await this.getMetadata(); + if (!metadata) { + throw new Error('Cannot update upstreamBranch: session metadata not found'); + } + + const updated = { + ...metadata, + upstreamBranch, + version: Date.now(), // Bump version for cache invalidation + }; + + await this.updateMetadata(updated); + } + + /** + * Record kilo server activity for idle timeout tracking. + * Called by the queue consumer after each successful execution. + * Resets the idle timeout clock. + */ + async recordKiloServerActivity(): Promise { + const metadata = await this.getMetadata(); + if (!metadata) { + throw new Error('Cannot record kilo server activity: session metadata not found'); + } + + const updated = { + ...metadata, + kiloServerLastActivity: Date.now(), + version: Date.now(), + }; + + await this.updateMetadata(updated); + } + + /** + * Link the kiloSessionId to the backend for analytics/tracking. + * Called when a session_created event is received from the CLI. + * + * @param kiloSessionId - The kilo CLI session ID to link + */ + async linkKiloSessionInBackend(kiloSessionId: string): Promise { + const metadata = await this.getMetadata(); + if (!metadata?.kilocodeToken) { + throw new Error('Cannot link session: missing kilocodeToken'); + } + + const backendUrl = (this.env as unknown as WorkerEnv).KILOCODE_BACKEND_BASE_URL; + if (!backendUrl) { + throw new Error('Cannot link session: KILOCODE_BACKEND_BASE_URL not configured'); + } + + const response = await fetch(`${backendUrl}/api/cloud-sessions/linkSessions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${metadata.kilocodeToken}`, + }, + body: JSON.stringify({ + cloudSessionId: this.sessionId, + kiloSessionId: kiloSessionId, + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Backend link failed: ${response.status} ${text}`); + } + } + + // --------------------------------------------------------------------------- + // Wrapper Communication Methods + // --------------------------------------------------------------------------- + + /** + * Send a command to the wrapper via its ingest WebSocket connection. + * Used for bidirectional communication (kill, ping). + * + * @param executionId - The execution whose wrapper should receive the command + * @param command - The command to send (kill, ping) + */ + sendToWrapper(executionId: ExecutionId, command: WrapperCommand): void { + const wrappers = this.ctx.getWebSockets(`ingest:${executionId}`); + for (const ws of wrappers) { + ws.send(JSON.stringify(command)); + } + } + + /** + * Interrupt the currently active execution by sending a kill command to the wrapper. + * Returns success/failure status. + * + * @returns Result indicating if the interrupt was initiated + */ + async interruptExecution(): Promise<{ success: boolean; message?: string }> { + const activeExecutionId = await this.executionQueries.getActiveExecutionId(); + + if (!activeExecutionId) { + return { success: false, message: 'No active execution' }; + } + + // Send kill command directly to wrapper + this.sendToWrapper(activeExecutionId, { type: 'kill', signal: 'SIGTERM' }); + + return { success: true }; + } + + /** + * Delete session and all associated data. + */ + async deleteSession(): Promise { + logger.info('Explicit DELETE requested for Durable Object'); + + // Must delete alarm before deleteAll + await this.ctx.storage.deleteAlarm(); + await this.ctx.storage.deleteAll(); + } + + /** + * Atomically prepare a session - sets preparedAt timestamp. + * Fails if session was already prepared. + * Validates input against MetadataSchema before storing. + */ + async prepare(input: { + sessionId: string; + userId: string; + orgId?: string; + botId?: string; + kiloSessionId: string; + prompt: string; + mode: string; + model: string; + kilocodeToken?: string; + githubRepo?: string; + githubToken?: string; + githubInstallationId?: string; + githubAppType?: 'standard' | 'lite'; + gitUrl?: string; + gitToken?: string; + envVars?: Record; + encryptedSecrets?: EncryptedSecrets; + setupCommands?: string[]; + mcpServers?: Record; + autoCommit?: boolean; + condenseOnComplete?: boolean; + appendSystemPrompt?: string; + upstreamBranch?: string; + callbackTarget?: CallbackTarget; + images?: Images; + // Workspace metadata (set during prepareSession) + workspacePath?: string; + sessionHome?: string; + branchName?: string; + sandboxId?: string; + }): Promise { + await this.requireSessionId(input.sessionId as SessionId); + const existing = await this.ctx.storage.get('metadata'); + if (existing?.preparedAt) { + return { success: false, error: 'Session already prepared' }; + } + + const now = Date.now(); + + const metadata: CloudAgentSessionState = { + ...input, + version: now, + timestamp: now, + preparedAt: now, + }; + + // Validate against schema before storing + const parseResult = MetadataSchema.safeParse(metadata); + if (!parseResult.success) { + return { + success: false, + error: `Invalid metadata: ${JSON.stringify(parseResult.error.format())}`, + }; + } + + await this.ctx.storage.put('metadata', parseResult.data); + + // Track activity and ensure reaper alarm is scheduled + await this.updateLastActivity(); + await this.ensureAlarmScheduled(); + + return { success: true }; + } + + /** + * Atomically update a prepared session - only succeeds if prepared but not initiated. + * Single DO request ensures atomicity. + * Validates updated metadata against MetadataSchema before storing. + */ + async tryUpdate(updates: { + mode?: string | null; + model?: string | null; + githubToken?: string | null; + gitToken?: string | null; + autoCommit?: boolean | null; + condenseOnComplete?: boolean | null; + appendSystemPrompt?: string | null; + envVars?: Record; + encryptedSecrets?: EncryptedSecrets; + setupCommands?: string[]; + mcpServers?: Record; + callbackTarget?: CallbackTarget | null; + upstreamBranch?: string | null; + }): Promise { + const metadata = await this.ctx.storage.get('metadata'); + + if (!metadata?.preparedAt) { + return { success: false, error: 'Session has not been prepared' }; + } + if (metadata.initiatedAt) { + return { success: false, error: 'Session has already been initiated' }; + } + + // Apply updates (handle null for clearing) + const updated = { ...metadata }; + for (const [key, value] of Object.entries(updates)) { + if (value === null) { + delete (updated as Record)[key]; + } else if (value !== undefined) { + (updated as Record)[key] = value; + } + } + const now = Date.now(); + updated.version = now; + updated.timestamp = now; + + // Validate against schema before storing + const parseResult = MetadataSchema.safeParse(updated); + if (!parseResult.success) { + return { + success: false, + error: `Invalid metadata after update: ${JSON.stringify(parseResult.error.format())}`, + }; + } + + await this.ctx.storage.put('metadata', parseResult.data); + + // Track activity for session TTL + await this.updateLastActivity(); + + return { success: true }; + } + + /** + * Atomically initiate a prepared session - sets initiatedAt timestamp. + * Returns the full metadata on success for execution. + * Single DO request ensures no race between update and initiate. + */ + async tryInitiate(): Promise> { + const metadata = await this.ctx.storage.get('metadata'); + + if (!metadata?.preparedAt) { + return { success: false, error: 'Session has not been prepared' }; + } + if (metadata.initiatedAt) { + return { success: false, error: 'Session has already been initiated' }; + } + + const now = Date.now(); + + const updated: CloudAgentSessionState = { + ...metadata, + initiatedAt: now, + version: now, + timestamp: now, + }; + + await this.ctx.storage.put('metadata', updated); + + // Track activity for session TTL + await this.updateLastActivity(); + + return { success: true, data: updated }; + } + + // --------------------------------------------------------------------------- + // Alarm Reaper + // --------------------------------------------------------------------------- + + /** + * Alarm handler for periodic cleanup tasks. + * Runs every REAPER_INTERVAL_MS to: + * 1. Clean up stale executions (no heartbeat for STALE_THRESHOLD_MS) + * 2. Clean up old events (older than EVENT_RETENTION_MS) + * 3. Clean up expired leases + * 4. Check if session should be deleted due to inactivity + */ + async alarm(): Promise { + const now = Date.now(); + + try { + // Check if session should be deleted due to inactivity (90 days) + const lastActivity = await this.ctx.storage.get(LAST_ACTIVITY_KEY); + if (lastActivity && now - lastActivity > Limits.SESSION_TTL_MS) { + logger + .withFields({ sessionId: this.sessionId, lastActivity }) + .info('Deleting session due to inactivity'); + + await this.ctx.storage.deleteAlarm(); + await this.ctx.storage.deleteAll(); + return; + } + + // Run cleanup tasks + await this.cleanupStaleExecutions(now); + this.cleanupOldEvents(now); + this.cleanupExpiredLeases(now); + + // Check if kilo server should be stopped due to inactivity + await this.cleanupIdleKiloServer(now); + } catch (error) { + logger + .withFields({ + doId: this.ctx.id.toString(), + sessionId: this.sessionId, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }) + .error('Error during alarm reaper'); + } + + // Schedule next alarm run + await this.ctx.storage.setAlarm(now + this.getReaperIntervalMs()); + } + + /** + * Ensure the reaper alarm is scheduled. + * Called during initialization and when session is first created. + */ + private async ensureAlarmScheduled(): Promise { + const alarm = await this.ctx.storage.getAlarm(); + if (alarm === null) { + await this.ctx.storage.setAlarm(Date.now() + this.getReaperIntervalMs()); + } + } + + /** + * Update the last activity timestamp. + * Called when metadata is modified to track session activity. + */ + private async updateLastActivity(): Promise { + await this.ctx.storage.put(LAST_ACTIVITY_KEY, Date.now()); + } + + /** + * Clean up stale executions that have stopped heartbeating. + * Marks them as failed and clears the active execution. + */ + private async cleanupStaleExecutions(now: number): Promise { + const activeExecutionId = await this.executionQueries.getActiveExecutionId(); + + if (!activeExecutionId) return; + + // Get the execution metadata + const execution = await this.executionQueries.get(activeExecutionId); + + if (!execution) { + // Orphaned active execution ID - clear it + logger + .withFields({ sessionId: this.sessionId, executionId: activeExecutionId }) + .warn('Clearing orphaned active execution ID'); + await this.executionQueries.clearActiveExecution(); + return; + } + + // Check if execution is stale (no heartbeat for STALE_THRESHOLD_MS) + if (execution.status === 'running') { + const staleThresholdMs = this.getStaleThresholdMs(); + const isStale = !execution.lastHeartbeat || now - execution.lastHeartbeat > staleThresholdMs; + + if (isStale) { + logger + .withFields({ + sessionId: this.sessionId, + executionId: activeExecutionId, + lastHeartbeat: execution.lastHeartbeat, + staleDurationMs: execution.lastHeartbeat ? now - execution.lastHeartbeat : 'never', + staleThresholdMs, + }) + .info('Marking stale execution as failed'); + + // Mark as failed + await this.updateExecutionStatus({ + executionId: activeExecutionId, + status: 'failed', + error: 'Execution timeout - no heartbeat received', + completedAt: now, + }); + + // Clear active execution (updateStatus should do this, but ensure it) + await this.executionQueries.clearActiveExecution(); + + // Clear interrupt flag if set + await this.executionQueries.clearInterrupt(); + } + } + + if (execution.status === 'pending') { + const pendingTimeoutMs = this.getPendingStartTimeoutMs(); + const isPendingTooLong = now - execution.startedAt > pendingTimeoutMs; + + if (isPendingTooLong) { + logger + .withFields({ + sessionId: this.sessionId, + executionId: activeExecutionId, + startedAt: execution.startedAt, + pendingTimeoutMs, + }) + .info('Marking stuck pending execution as failed'); + + await this.updateExecutionStatus({ + executionId: activeExecutionId, + status: 'failed', + error: 'Execution timeout - wrapper never connected', + completedAt: now, + }); + + await this.executionQueries.clearActiveExecution(); + await this.executionQueries.clearInterrupt(); + } + } + } + + /** + * Clean up events older than the retention period. + */ + private cleanupOldEvents(now: number): void { + const retentionCutoff = now - EVENT_RETENTION_MS; + const deletedCount = this.eventQueries.deleteOlderThan(retentionCutoff); + + if (deletedCount > 0) { + logger.withFields({ sessionId: this.sessionId, deletedCount }).info('Cleaned up old events'); + } + } + + /** + * Clean up expired leases. + */ + private cleanupExpiredLeases(now: number): void { + const deletedCount = this.leaseQueries.deleteExpired(now); + + if (deletedCount > 0) { + logger + .withFields({ sessionId: this.sessionId, deletedCount }) + .info('Cleaned up expired leases'); + } + } + + private getReaperIntervalMs(): number { + const value = Number((this.env as unknown as WorkerEnv).REAPER_INTERVAL_MS); + return Number.isFinite(value) && value > 0 ? value : REAPER_INTERVAL_MS_DEFAULT; + } + + private getStaleThresholdMs(): number { + const value = Number((this.env as unknown as WorkerEnv).STALE_THRESHOLD_MS); + return Number.isFinite(value) && value > 0 ? value : STALE_THRESHOLD_MS; + } + + private getPendingStartTimeoutMs(): number { + const value = Number((this.env as unknown as WorkerEnv).PENDING_START_TIMEOUT_MS); + return Number.isFinite(value) && value > 0 ? value : PENDING_START_TIMEOUT_MS_DEFAULT; + } + + private getKiloServerIdleTimeoutMs(): number { + const value = Number((this.env as unknown as WorkerEnv).KILO_SERVER_IDLE_TIMEOUT_MS); + return Number.isFinite(value) && value > 0 ? value : KILO_SERVER_IDLE_TIMEOUT_MS_DEFAULT; + } + + /** + * Stop kilo server if it has been idle for too long. + * Called by the alarm handler to free up sandbox resources. + */ + private async cleanupIdleKiloServer(now: number): Promise { + const metadata = await this.getMetadata(); + if (!metadata) { + return; + } + + const lastActivity = metadata.kiloServerLastActivity; + if (!lastActivity) { + // No kilo server activity recorded, nothing to clean up + return; + } + + const idleMs = now - lastActivity; + const idleTimeoutMs = this.getKiloServerIdleTimeoutMs(); + + if (idleMs < idleTimeoutMs) { + // Server is still within idle threshold + return; + } + + // Check if there's an active execution - don't stop the server mid-run + const activeExecutionId = await this.executionQueries.getActiveExecutionId(); + if (activeExecutionId !== null) { + logger + .withFields({ + sessionId: this.sessionId, + executionId: activeExecutionId, + idleMs, + }) + .debug('Skipping idle kilo server cleanup - execution is active'); + return; + } + + // Server has been idle too long and no active execution, stop it + logger + .withFields({ + sessionId: this.sessionId, + idleMs, + idleTimeoutMs, + }) + .info('Stopping idle kilo server'); + + try { + const sandboxId = await generateSandboxId(metadata.orgId, metadata.userId, metadata.botId); + const sandbox = getSandbox((this.env as unknown as WorkerEnv).Sandbox, sandboxId); + + await stopKiloServer(sandbox, metadata.sessionId); + + // Clear the activity timestamp since server is stopped + // Must merge with existing metadata since updateMetadata validates the full schema + const updated = { + ...metadata, + kiloServerLastActivity: undefined, + version: Date.now(), + }; + await this.updateMetadata(updated); + + logger + .withFields({ sessionId: this.sessionId, sandboxId }) + .info('Idle kilo server stopped successfully'); + } catch (error) { + // Log but don't fail - server may already be stopped or sandbox recycled + logger + .withFields({ + sessionId: this.sessionId, + error: error instanceof Error ? error.message : String(error), + }) + .warn('Failed to stop idle kilo server (may already be stopped)'); + } + } + + // --------------------------------------------------------------------------- + // Execution Management RPC Methods + // --------------------------------------------------------------------------- + + /** + * Add a new execution with initial 'pending' status. + */ + async addExecution( + params: AddExecutionParams + ): Promise> { + return this.executionQueries.add(params); + } + + /** + * Update execution status with state machine validation. + */ + async updateExecutionStatus( + params: UpdateExecutionStatusParams + ): Promise> { + const result = await this.executionQueries.updateStatus(params); + + if (result.ok && this.isTerminalStatus(params.status)) { + await this.enqueueCallbackNotification(params.executionId, params.status, params.error); + } + + return result; + } + + /** + * Update execution heartbeat timestamp. + */ + async updateExecutionHeartbeat(executionId: ExecutionId, timestamp: number): Promise { + return this.executionQueries.updateHeartbeat(executionId, timestamp); + } + + /** + * Set the process ID for a long-running execution. + * Used for resume support in the queue consumer. + */ + async setProcessId(executionId: ExecutionId, processId: string): Promise { + return this.executionQueries.setProcessId(executionId, processId); + } + + /** + * Set the active execution for this session. + */ + async setActiveExecution(executionId: ExecutionId): Promise> { + return this.executionQueries.setActiveExecution(executionId); + } + + /** + * Clear the active execution. + */ + async clearActiveExecution(): Promise { + return this.executionQueries.clearActiveExecution(); + } + + /** + * Get a specific execution by ID. + */ + async getExecution(executionId: ExecutionId): Promise { + return this.executionQueries.get(executionId); + } + + /** + * Get all executions for this session. + */ + async getExecutions(): Promise { + return this.executionQueries.getAll(); + } + + /** + * Get the currently active execution ID. + */ + async getActiveExecutionId(): Promise { + return this.executionQueries.getActiveExecutionId(); + } + + /** + * Check if interrupt was requested for the current execution. + * Note: This is different from the legacy isInterrupted() method which uses 'interrupted' key. + */ + async isInterruptRequested(): Promise { + return this.executionQueries.isInterruptRequested(); + } + + /** + * Request interrupt for the current execution. + */ + async requestInterrupt(): Promise { + return this.executionQueries.requestInterrupt(); + } + + /** + * Clear the interrupt flag. + * Note: This is different from the legacy clearInterrupted() method. + */ + async clearInterruptRequest(): Promise { + return this.executionQueries.clearInterrupt(); + } + + // --------------------------------------------------------------------------- + // Lease Management RPC Methods + // --------------------------------------------------------------------------- + + /** + * Try to acquire a lease for an execution. + * Used by queue consumers for idempotent processing. + * + * @param executionId - ID of the execution to acquire lease for + * @param messageId - Queue message ID for tracking + * @param leaseId - Unique ID for this lease attempt + * @returns Result with expiry time on success, or error if lease is held + */ + acquireLease( + executionId: ExecutionId, + messageId: string, + leaseId: string + ): Result<{ acquired: true; expiresAt: number }, LeaseAcquireError> { + return this.leaseQueries.tryAcquire(executionId, leaseId, messageId); + } + + /** + * Extend an existing lease (heartbeat). + * Returns true if the lease was extended, false if the lease is not held. + * + * @param executionId - ID of the execution + * @param leaseId - Lease ID that must match the current holder + * @returns true if lease was extended + */ + extendLease(executionId: ExecutionId, leaseId: string): boolean { + const result = this.leaseQueries.extend(executionId, leaseId); + return result.ok; + } + + /** + * Release a lease on completion. + * + * @param executionId - ID of the execution + * @param leaseId - Lease ID that must match the current holder + * @returns true if lease was released + */ + releaseLease(executionId: ExecutionId, leaseId: string): boolean { + return this.leaseQueries.release(executionId, leaseId); + } + + // --------------------------------------------------------------------------- + // Direct Execution Methods + // --------------------------------------------------------------------------- + + /** + * Build an execution plan for the orchestrator. + */ + private buildExecutionPlan(params: { + executionId: ExecutionId; + sandboxId: string; + sessionId: SessionId; + userId: UserId; + orgId?: string; + mode: ExecutionMode; + prompt: string; + model?: string; + autoCommit?: boolean; + condenseOnComplete?: boolean; + initContext?: InitializeContext; + resumeContext?: TokenResumeContext; + existingMetadata?: CloudAgentSessionState; + kiloSessionId?: string; + }): ExecutionPlan { + const workspace = params.initContext + ? { + shouldPrepare: true as const, + sandboxId: params.sandboxId, + initContext: params.initContext, + existingMetadata: params.existingMetadata + ? { + workspacePath: params.existingMetadata.workspacePath ?? '', + kiloSessionId: params.existingMetadata.kiloSessionId ?? '', + branchName: params.existingMetadata.branchName ?? '', + sandboxId: params.existingMetadata.sandboxId, + sessionHome: params.existingMetadata.sessionHome, + upstreamBranch: params.existingMetadata.upstreamBranch, + appendSystemPrompt: params.existingMetadata.appendSystemPrompt, + githubRepo: params.existingMetadata.githubRepo, + gitUrl: params.existingMetadata.gitUrl, + } + : undefined, + } + : { + shouldPrepare: false as const, + sandboxId: params.sandboxId, + resumeContext: { + kiloSessionId: params.kiloSessionId ?? '', + workspacePath: params.existingMetadata?.workspacePath ?? '', + kilocodeToken: params.resumeContext?.kilocodeToken ?? '', + kilocodeModel: params.resumeContext?.kilocodeModel, + branchName: params.existingMetadata?.branchName ?? '', + githubToken: params.resumeContext?.githubToken, + gitToken: params.resumeContext?.gitToken, + }, + existingMetadata: params.existingMetadata + ? { + workspacePath: params.existingMetadata.workspacePath ?? '', + kiloSessionId: params.existingMetadata.kiloSessionId ?? '', + branchName: params.existingMetadata.branchName ?? '', + sandboxId: params.existingMetadata.sandboxId, + sessionHome: params.existingMetadata.sessionHome, + upstreamBranch: params.existingMetadata.upstreamBranch, + appendSystemPrompt: params.existingMetadata.appendSystemPrompt, + githubRepo: params.existingMetadata.githubRepo, + gitUrl: params.existingMetadata.gitUrl, + } + : undefined, + }; + + return { + executionId: params.executionId, + sessionId: params.sessionId, + userId: params.userId, + orgId: params.orgId, + prompt: params.prompt, + mode: params.mode, + workspace, + wrapper: { + kiloSessionId: params.kiloSessionId, + model: params.model ? { modelID: params.model } : undefined, + autoCommit: params.autoCommit, + condenseOnComplete: params.condenseOnComplete, + }, + }; + } + + /** + * Get or create the execution orchestrator. + */ + private getOrCreateOrchestrator(): ExecutionOrchestrator { + if (!this.orchestrator) { + const deps: OrchestratorDeps = { + getSandbox: async (sandboxId: string) => + getSandbox((this.env as unknown as WorkerEnv).Sandbox, sandboxId, { sleepAfter: 900 }), + getSessionStub: (userId, sessionId) => { + const doKey = `${userId}:${sessionId}`; + const id = (this.env as unknown as WorkerEnv).CLOUD_AGENT_SESSION.idFromName(doKey); + return (this.env as unknown as WorkerEnv).CLOUD_AGENT_SESSION.get(id); + }, + getIngestUrl: (sessionId, userId) => { + const workerUrl = + (this.env as unknown as WorkerEnv).WORKER_URL || 'http://localhost:8788'; + // Encode userId to handle OAuth IDs like "oauth/google:123" that contain slashes + return `${workerUrl}/sessions/${encodeURIComponent(userId)}/${sessionId}/ingest`; + }, + env: this.env as unknown as WorkerEnv, + }; + this.orchestrator = new ExecutionOrchestrator(deps); + } + return this.orchestrator; + } + + private buildStartResult(executionId: ExecutionId): StartExecutionV2Result { + return { + success: true, + executionId, + status: 'started', + }; + } + + private buildStartError( + code: Extract['code'], + error: string, + activeExecutionId?: ExecutionId + ): StartExecutionV2Result { + return { + success: false, + code, + error, + activeExecutionId, + }; + } + + private getGitHubTokenService(): GitHubTokenService { + return new GitHubTokenService({ + GITHUB_TOKEN_CACHE: Reflect.get(this.env, 'GITHUB_TOKEN_CACHE'), + GITHUB_APP_ID: Reflect.get(this.env, 'GITHUB_APP_ID'), + GITHUB_APP_PRIVATE_KEY: Reflect.get(this.env, 'GITHUB_APP_PRIVATE_KEY'), + GITHUB_LITE_APP_ID: Reflect.get(this.env, 'GITHUB_LITE_APP_ID'), + GITHUB_LITE_APP_PRIVATE_KEY: Reflect.get(this.env, 'GITHUB_LITE_APP_PRIVATE_KEY'), + }); + } + + /** + * Start a V2 execution using direct execution (no queue). + * This method performs validation, checks for active execution, and executes directly. + * + * Returns 409 Conflict (EXECUTION_IN_PROGRESS) if an execution is already active. + */ + async startExecutionV2(request: StartExecutionV2Request): Promise { + const sessionId = await this.requireSessionId(); + const executionId = createExecutionId(); + + // Maps TRPCError codes to StartExecutionV2Result error codes. + const mapTRPCCodeToResultCode = ( + trpcCode: string + ): Extract['code'] => { + switch (trpcCode) { + case 'BAD_REQUEST': + return 'BAD_REQUEST'; + case 'NOT_FOUND': + return 'NOT_FOUND'; + default: + return 'INTERNAL'; + } + }; + + try { + // Check if there's already an active execution - return 409 if so + const activeExecutionId = await this.executionQueries.getActiveExecutionId(); + if (activeExecutionId) { + return this.buildStartError( + 'EXECUTION_IN_PROGRESS', + `Execution ${activeExecutionId} is in progress`, + activeExecutionId + ); + } + + if (request.kind === 'initiate') { + // Validate githubRepo requires authentication + if (request.githubRepo && !request.githubToken) { + return this.buildStartError( + 'BAD_REQUEST', + 'GitHub authentication required for this repository' + ); + } + + const kiloSessionId = crypto.randomUUID(); + const normalizedModel = normalizeKilocodeModel(request.model); + if (!normalizedModel) { + return this.buildStartError('BAD_REQUEST', 'No model specified'); + } + + const prepareResult = await this.prepare({ + sessionId, + userId: request.userId, + orgId: request.orgId, + kiloSessionId, + prompt: request.prompt, + mode: request.mode, + model: normalizedModel, + kilocodeToken: request.authToken, + githubRepo: request.githubRepo, + githubToken: request.githubToken, + gitUrl: request.gitUrl, + gitToken: request.gitToken, + envVars: request.envVars, + encryptedSecrets: request.encryptedSecrets, + setupCommands: request.setupCommands, + mcpServers: request.mcpServers, + autoCommit: request.autoCommit, + upstreamBranch: request.upstreamBranch, + }); + + if (!prepareResult.success) { + return this.buildStartError( + 'INTERNAL', + prepareResult.error ?? 'Failed to prepare session' + ); + } + + // Transition to initiated state + const initiateResult = await this.tryInitiate(); + if ( + !initiateResult.success && + initiateResult.error !== 'Session has already been initiated' + ) { + return this.buildStartError( + 'INTERNAL', + initiateResult.error ?? 'Failed to initiate session' + ); + } + + const sandboxId = await generateSandboxId(request.orgId, request.userId, request.botId); + const initContext: InitializeContext = { + kilocodeToken: request.authToken, + kilocodeModel: request.model, + githubRepo: request.githubRepo, + githubToken: request.githubToken, + gitUrl: request.gitUrl, + gitToken: request.gitToken, + envVars: request.envVars, + encryptedSecrets: request.encryptedSecrets, + setupCommands: request.setupCommands, + mcpServers: request.mcpServers, + upstreamBranch: request.upstreamBranch, + botId: request.botId, + }; + + const plan = this.buildExecutionPlan({ + executionId, + sandboxId, + sessionId, + userId: request.userId, + orgId: request.orgId, + mode: request.mode, + prompt: request.prompt, + model: normalizedModel, + autoCommit: request.autoCommit, + condenseOnComplete: request.condenseOnComplete, + initContext, + kiloSessionId, + }); + + return await this.executeDirectly(plan); + } + + if (request.kind === 'initiatePrepared') { + const metadata = await this.getMetadata(); + if (!metadata) { + return this.buildStartError('NOT_FOUND', 'Session not found'); + } + if (!metadata.preparedAt) { + return this.buildStartError('BAD_REQUEST', 'Session has not been prepared'); + } + if (metadata.initiatedAt) { + return this.buildStartError('BAD_REQUEST', 'Session has already been initiated'); + } + if (!metadata.prompt || !metadata.mode || !metadata.model) { + return this.buildStartError( + 'BAD_REQUEST', + 'Session is missing required fields (prompt, mode, model)' + ); + } + + // Transition to initiated state + const initiateResult = await this.tryInitiate(); + if ( + !initiateResult.success && + initiateResult.error !== 'Session has already been initiated' + ) { + return this.buildStartError( + 'INTERNAL', + initiateResult.error ?? 'Failed to initiate session' + ); + } + + const token = request.authToken || metadata.kilocodeToken || ''; + let githubToken = metadata.githubToken; + if (metadata.githubInstallationId) { + const appType = metadata.githubAppType || 'standard'; + githubToken = await this.getGitHubTokenService().getToken( + metadata.githubInstallationId, + appType + ); + } + if (metadata.githubRepo && !githubToken) { + return this.buildStartError( + 'BAD_REQUEST', + 'GitHub authentication required for this repository' + ); + } + + const sandboxId = await generateSandboxId(metadata.orgId, metadata.userId, request.botId); + const initContext: InitializeContext = { + kilocodeToken: token, + kilocodeModel: metadata.model, + githubRepo: metadata.githubRepo, + githubToken, + gitUrl: metadata.gitUrl, + gitToken: metadata.gitToken, + envVars: metadata.envVars, + encryptedSecrets: metadata.encryptedSecrets, + setupCommands: metadata.setupCommands, + mcpServers: metadata.mcpServers, + upstreamBranch: metadata.upstreamBranch, + botId: request.botId, + kiloSessionId: metadata.kiloSessionId, + isPreparedSession: true, + githubAppType: metadata.githubAppType, + }; + + const plan = this.buildExecutionPlan({ + executionId, + sandboxId, + sessionId, + userId: metadata.userId as UserId, + orgId: metadata.orgId, + mode: metadata.mode as ExecutionMode, + prompt: metadata.prompt, + model: metadata.model, + autoCommit: metadata.autoCommit, + condenseOnComplete: metadata.condenseOnComplete, + initContext, + existingMetadata: metadata, + kiloSessionId: metadata.kiloSessionId, + }); + + return await this.executeDirectly(plan); + } + + // Follow-up message (kind === 'followup') + const metadata = await this.getMetadata(); + if (!metadata) { + return this.buildStartError('NOT_FOUND', 'Session not found'); + } + if (!metadata.initiatedAt) { + return this.buildStartError('BAD_REQUEST', 'Session has not been initiated yet'); + } + + if (request.tokenOverrides?.githubToken && metadata.githubRepo) { + await this.updateGithubToken(request.tokenOverrides.githubToken); + metadata.githubToken = request.tokenOverrides.githubToken; + } + if (request.tokenOverrides?.gitToken && metadata.gitUrl) { + await this.updateGitToken(request.tokenOverrides.gitToken); + metadata.gitToken = request.tokenOverrides.gitToken; + } + + const mode = (request.mode ?? metadata.mode ?? 'build') as ExecutionMode; + const model = normalizeKilocodeModel(request.model ?? metadata.model); + if (!model) { + return this.buildStartError( + 'BAD_REQUEST', + 'No model specified and session has no default model' + ); + } + + // Token overrides win: only generate from installation ID if no override provided + let githubToken = request.tokenOverrides?.githubToken ?? metadata.githubToken; + if (!request.tokenOverrides?.githubToken && metadata.githubInstallationId) { + const appType = metadata.githubAppType || 'standard'; + githubToken = await this.getGitHubTokenService().getToken( + metadata.githubInstallationId, + appType + ); + } + if (metadata.githubRepo && !githubToken) { + return this.buildStartError( + 'BAD_REQUEST', + 'GitHub authentication required for this repository' + ); + } + + const sandboxId = await generateSandboxId(metadata.orgId, metadata.userId, request.botId); + const resumeContext: TokenResumeContext = { + kilocodeToken: metadata.kilocodeToken ?? '', + kilocodeModel: model, + githubToken, + gitToken: request.tokenOverrides?.gitToken, + }; + + const plan = this.buildExecutionPlan({ + executionId, + sandboxId, + sessionId, + userId: metadata.userId as UserId, + orgId: metadata.orgId, + mode, + prompt: request.prompt, + model, + autoCommit: request.autoCommit ?? metadata.autoCommit, + condenseOnComplete: request.condenseOnComplete ?? metadata.condenseOnComplete, + resumeContext, + existingMetadata: metadata, + kiloSessionId: metadata.kiloSessionId, + }); + + return await this.executeDirectly(plan); + } catch (error) { + // Handle ExecutionError specifically for proper error code mapping + if (isExecutionError(error)) { + if (error.code === 'EXECUTION_IN_PROGRESS') { + return this.buildStartError( + 'EXECUTION_IN_PROGRESS', + error.message, + error.activeExecutionId as ExecutionId + ); + } + // Retryable errors pass through specific code -> 503 in tRPC handler + if (error.retryable) { + // error.code is a RetryableErrorCode which matches RetryableResultCode + return this.buildStartError( + error.code as Extract['code'], + error.message + ); + } + return this.buildStartError('INTERNAL', error.message); + } + if (error instanceof TRPCError) { + return this.buildStartError(mapTRPCCodeToResultCode(error.code), error.message); + } + return this.buildStartError( + 'INTERNAL', + error instanceof Error ? error.message : String(error) + ); + } + } + + /** + * Execute a plan directly using the orchestrator. + * This replaces the queue-based enqueueExecution pattern. + */ + private async executeDirectly(plan: ExecutionPlan): Promise { + const { executionId, sessionId, mode } = plan; + + logger.withFields({ sessionId, executionId }).info('executeDirectly called'); + + // Add execution metadata to the DO + const ingestToken = executionId; + const addResult = await this.executionQueries.add({ + executionId, + mode, + streamingMode: 'websocket', + ingestToken, + }); + + if (!addResult.ok) { + logger + .withFields({ sessionId, executionId, error: addResult.error }) + .warn('Failed to add execution (may already exist)'); + } + + // Set this as the active execution + const setActiveResult = await this.executionQueries.setActiveExecution(executionId); + if (!setActiveResult.ok) { + logger + .withFields({ sessionId, executionId, error: setActiveResult.error }) + .error('Failed to set active execution'); + return this.buildStartError('INTERNAL', 'Failed to set active execution'); + } + + // Execute via orchestrator + try { + const orchestrator = this.getOrCreateOrchestrator(); + const result = await orchestrator.execute(plan); + + logger + .withFields({ sessionId, executionId, kiloSessionId: result.kiloSessionId }) + .info('Execution started successfully'); + + return this.buildStartResult(executionId); + } catch (error) { + // Execution failed - clear active execution + await this.executionQueries.clearActiveExecution(); + + // Mark execution as failed + await this.executionQueries.updateStatus({ + executionId, + status: 'failed', + error: error instanceof Error ? error.message : String(error), + completedAt: Date.now(), + }); + + throw error; // Re-throw for caller handling + } + } + + /** + * Called when an execution completes (successfully, failed, or interrupted). + * + * Updates the execution status and clears the active execution. + * With direct execution model, there's no queue to advance. + * + * @param executionId - ID of the completed execution + * @param status - Final status of the execution + * @param error - Optional error message for failed executions + */ + async onExecutionComplete( + executionId: ExecutionId, + status: 'completed' | 'failed' | 'interrupted', + error?: string + ): Promise { + const sessionId = await this.resolveSessionId(); + logger.withFields({ sessionId, executionId, status, error }).info('onExecutionComplete called'); + + // Update execution status + const updateResult = await this.updateExecutionStatus({ + executionId, + status, + error, + completedAt: Date.now(), + }); + + if (!updateResult.ok) { + logger + .withFields({ sessionId, executionId, error: updateResult.error }) + .warn('Failed to update execution status'); + } + + // Check if this was the active execution + const activeExecutionId = await this.executionQueries.getActiveExecutionId(); + if (activeExecutionId === executionId) { + // Clear the active execution + await this.executionQueries.clearActiveExecution(); + } + + // Clear any interrupt flag that may have been set + await this.executionQueries.clearInterrupt(); + + logger.withFields({ sessionId, executionId }).info('Execution complete - session is idle'); + } +} diff --git a/cloud-agent-next/src/persistence/migrations.ts b/cloud-agent-next/src/persistence/migrations.ts new file mode 100644 index 0000000000..230b1b1f90 --- /dev/null +++ b/cloud-agent-next/src/persistence/migrations.ts @@ -0,0 +1,131 @@ +/** + * Schema migration system for CloudAgentSession Durable Object. + * + * Migrations are versioned and run sequentially using blockConcurrencyWhile() + * in the DO constructor to ensure consistency. + * + * Schema is defined inline below; no separate SQL doc file. + * + * ⚠️ IMPORTANT: When modifying table schemas here, also update the corresponding + * Zod schemas in src/db/tables/ to keep them in sync. The Zod schemas provide + * type-safe query building and runtime validation for queries using these tables. + */ + +// Schema version constants +export const CURRENT_SCHEMA_VERSION = 1; +const SCHEMA_VERSION_KEY = 'schema_version'; + +type SqlStorage = DurableObjectState['storage']['sql']; + +// Migration functions for each version +// Each migration should be idempotent (use IF NOT EXISTS) +const migrations: Record void> = { + // v1: Initial schema with all tables + // Creates events, execution_leases, and command_queue tables + 1: sql => { + // Enable WAL mode for better concurrency + // Wrapped in try-catch as some test environments may not support PRAGMA + try { + sql.exec('PRAGMA journal_mode=WAL'); + } catch (_e) { + // Ignore PRAGMA errors in test environments + // WAL mode is a performance optimization, not a correctness requirement + } + + // Events table: stores all streaming events for replay + sql.exec(` + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + execution_id TEXT NOT NULL, + session_id TEXT NOT NULL, + stream_event_type TEXT NOT NULL, + payload TEXT NOT NULL, + timestamp INTEGER NOT NULL + ) + `); + + // Events indexes for efficient filtering + sql.exec('CREATE INDEX IF NOT EXISTS idx_events_execution ON events(execution_id)'); + sql.exec('CREATE INDEX IF NOT EXISTS idx_events_type ON events(stream_event_type)'); + sql.exec('CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp)'); + sql.exec('CREATE INDEX IF NOT EXISTS idx_events_id_execution ON events(id, execution_id)'); + + // Execution leases table: prevents duplicate processing + sql.exec(` + CREATE TABLE IF NOT EXISTS execution_leases ( + execution_id TEXT PRIMARY KEY, + lease_id TEXT NOT NULL, + lease_expires_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + message_id TEXT + ) + `); + + sql.exec('CREATE INDEX IF NOT EXISTS idx_leases_expires ON execution_leases(lease_expires_at)'); + + // Command queue table: stores commands from client to be processed by executor + sql.exec(` + CREATE TABLE IF NOT EXISTS command_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + execution_id TEXT NOT NULL, + message_json TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + `); + + sql.exec('CREATE INDEX IF NOT EXISTS idx_command_queue_session ON command_queue(session_id)'); + }, +}; + +/** + * Get current schema version from storage. + * Returns 0 if no version has been set (fresh DO). + */ +export async function getSchemaVersion(state: DurableObjectState): Promise { + const version = await state.storage.get(SCHEMA_VERSION_KEY); + return version ?? 0; +} + +/** + * Set schema version in storage. + */ +export async function setSchemaVersion(state: DurableObjectState, version: number): Promise { + await state.storage.put(SCHEMA_VERSION_KEY, version); +} + +/** + * Run all pending migrations up to CURRENT_SCHEMA_VERSION. + * + * This function should be called inside blockConcurrencyWhile() in the DO constructor + * to ensure migrations are atomic and no concurrent requests see partial state. + * + * @example + * ```ts + * constructor(state: DurableObjectState, env: Env) { + * super(state, env); + * state.blockConcurrencyWhile(async () => { + * await runMigrations(state); + * }); + * } + * ``` + */ +export async function runMigrations(state: DurableObjectState): Promise { + const currentVersion = await getSchemaVersion(state); + + if (currentVersion >= CURRENT_SCHEMA_VERSION) { + return; // Already up to date + } + + const sql = state.storage.sql; + + // Run each migration in order + for (let version = currentVersion + 1; version <= CURRENT_SCHEMA_VERSION; version++) { + const migration = migrations[version]; + if (migration) { + migration(sql); + } + } + + await setSchemaVersion(state, CURRENT_SCHEMA_VERSION); +} diff --git a/cloud-agent-next/src/persistence/model-utils.test.ts b/cloud-agent-next/src/persistence/model-utils.test.ts new file mode 100644 index 0000000000..380ce996c9 --- /dev/null +++ b/cloud-agent-next/src/persistence/model-utils.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeKilocodeModel } from './model-utils.js'; + +describe('normalizeKilocodeModel', () => { + it('returns undefined for empty input', () => { + expect(normalizeKilocodeModel(undefined)).toBeUndefined(); + expect(normalizeKilocodeModel(null)).toBeUndefined(); + expect(normalizeKilocodeModel('')).toBeUndefined(); + expect(normalizeKilocodeModel(' ')).toBeUndefined(); + }); + + it('prefixes non-kilo models', () => { + expect(normalizeKilocodeModel('code')).toBe('kilo/code'); + expect(normalizeKilocodeModel('anthropic/claude-sonnet-4')).toBe( + 'kilo/anthropic/claude-sonnet-4' + ); + }); + + it('preserves existing kilo prefix', () => { + expect(normalizeKilocodeModel('kilo/code')).toBe('kilo/code'); + }); +}); diff --git a/cloud-agent-next/src/persistence/model-utils.ts b/cloud-agent-next/src/persistence/model-utils.ts new file mode 100644 index 0000000000..aa27810233 --- /dev/null +++ b/cloud-agent-next/src/persistence/model-utils.ts @@ -0,0 +1,6 @@ +export function normalizeKilocodeModel(model: string | undefined | null): string | undefined { + if (!model) return undefined; + const trimmed = model.trim(); + if (!trimmed) return undefined; + return trimmed.startsWith('kilo/') ? trimmed : `kilo/${trimmed}`; +} diff --git a/cloud-agent-next/src/persistence/schemas.test.ts b/cloud-agent-next/src/persistence/schemas.test.ts new file mode 100644 index 0000000000..5b0bb0ce2b --- /dev/null +++ b/cloud-agent-next/src/persistence/schemas.test.ts @@ -0,0 +1,765 @@ +import { describe, it, expect } from 'vitest'; +import { MCPServerConfigSchema, MetadataSchema } from './schemas.js'; +import type { MCPServerConfig } from './types.js'; + +describe('MCPServerConfigSchema', () => { + describe('valid stdio configuration', () => { + it('should accept valid stdio config with command and args', () => { + const config = { + type: 'stdio' as const, + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-puppeteer'], + }; + + const result = MCPServerConfigSchema.parse(config); + expect(result).toMatchObject({ + type: 'stdio', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-puppeteer'], + }); + }); + + it('should accept stdio config without explicit type', () => { + const config = { + command: 'node', + args: ['server.js'], + }; + + const result = MCPServerConfigSchema.parse(config); + expect(result.type).toBe('stdio'); + expect(result.command).toBe('node'); + }); + + it('should accept stdio config with optional fields', () => { + const config = { + command: 'node', + args: ['server.js'], + cwd: '/path/to/project', + env: { NODE_ENV: 'production' }, + }; + + const result = MCPServerConfigSchema.parse(config); + expect(result).toMatchObject({ + type: 'stdio', + command: 'node', + args: ['server.js'], + cwd: '/path/to/project', + env: { NODE_ENV: 'production' }, + }); + }); + }); + + describe('valid SSE configuration', () => { + it('should accept valid sse config with URL', () => { + const config = { + type: 'sse' as const, + url: 'https://mcp-server.example.com/sse', + }; + + const result = MCPServerConfigSchema.parse(config); + expect(result).toMatchObject({ + type: 'sse', + url: 'https://mcp-server.example.com/sse', + }); + }); + + it('should accept sse config with headers', () => { + const config = { + type: 'sse' as const, + url: 'https://example.com/sse', + headers: { + Authorization: 'Bearer token123', + 'X-Custom-Header': 'value', + }, + }; + + const result = MCPServerConfigSchema.parse(config); + expect(result).toMatchObject({ + type: 'sse', + url: 'https://example.com/sse', + headers: { + Authorization: 'Bearer token123', + 'X-Custom-Header': 'value', + }, + }); + }); + }); + + describe('valid streamable-http configuration', () => { + it('should accept valid streamable-http config with URL', () => { + const config = { + type: 'streamable-http' as const, + url: 'https://mcp-server.example.com/stream', + }; + + const result = MCPServerConfigSchema.parse(config); + expect(result).toMatchObject({ + type: 'streamable-http', + url: 'https://mcp-server.example.com/stream', + }); + }); + + it('should accept streamable-http config with headers', () => { + const config = { + type: 'streamable-http' as const, + url: 'https://example.com/stream', + headers: { + 'X-API-Key': 'key456', + }, + }; + + const result = MCPServerConfigSchema.parse(config); + expect(result).toMatchObject({ + type: 'streamable-http', + url: 'https://example.com/stream', + headers: { + 'X-API-Key': 'key456', + }, + }); + }); + }); + + describe('stdio missing command', () => { + it('should reject stdio config without command field', () => { + const config = { + type: 'stdio' as const, + args: ['server.js'], + }; + + expect(() => MCPServerConfigSchema.parse(config)).toThrow(); + }); + + it('should reject stdio config with empty command', () => { + const config = { + command: '', + args: ['server.js'], + }; + + expect(() => MCPServerConfigSchema.parse(config)).toThrow('Command cannot be empty'); + }); + }); + + describe('SSE invalid URL', () => { + it('should reject SSE config with malformed URL', () => { + const config = { + type: 'sse' as const, + url: 'not-a-valid-url', + }; + + const result = MCPServerConfigSchema.safeParse(config); + expect(result.success).toBe(false); + if (!result.success) { + // Zod returns "Invalid input" for discriminated union validation failures + expect(result.error.issues.length).toBeGreaterThan(0); + } + }); + + it('should reject SSE config without protocol', () => { + const config = { + type: 'sse' as const, + url: 'example.com/sse', + }; + + const result = MCPServerConfigSchema.safeParse(config); + expect(result.success).toBe(false); + }); + + it('should reject streamable-http config with malformed URL', () => { + const config = { + type: 'streamable-http' as const, + url: 'invalid url with spaces', + }; + + const result = MCPServerConfigSchema.safeParse(config); + expect(result.success).toBe(false); + if (!result.success) { + // Zod returns "Invalid input" for discriminated union validation failures + expect(result.error.issues.length).toBeGreaterThan(0); + } + }); + }); + + describe('field contamination', () => { + it('should reject stdio with URL field', () => { + const config = { + type: 'stdio' as const, + command: 'node', + args: ['server.js'], + url: 'https://example.com', + }; + + expect(() => MCPServerConfigSchema.parse(config)).toThrow(); + }); + + it('should reject stdio with headers field', () => { + const config = { + command: 'node', + headers: { Authorization: 'Bearer token' }, + }; + + expect(() => MCPServerConfigSchema.parse(config)).toThrow(); + }); + + it('should reject SSE with command field', () => { + const config = { + type: 'sse' as const, + url: 'https://example.com', + command: 'node', + }; + + expect(() => MCPServerConfigSchema.parse(config)).toThrow(); + }); + + it('should reject SSE with args field', () => { + const config = { + type: 'sse' as const, + url: 'https://example.com', + args: ['--flag'], + }; + + expect(() => MCPServerConfigSchema.parse(config)).toThrow(); + }); + + it('should reject streamable-http with command field', () => { + const config = { + type: 'streamable-http' as const, + url: 'https://example.com', + command: 'node', + }; + + expect(() => MCPServerConfigSchema.parse(config)).toThrow(); + }); + + it('should reject streamable-http with env field', () => { + const config = { + type: 'streamable-http' as const, + url: 'https://example.com', + env: { NODE_ENV: 'production' }, + }; + + expect(() => MCPServerConfigSchema.parse(config)).toThrow(); + }); + }); + + describe('BaseConfig fields', () => { + it('should accept timeout field within valid range', () => { + const config = { + command: 'node', + timeout: 120, + }; + + const result = MCPServerConfigSchema.parse(config); + expect(result.timeout).toBe(120); + }); + + it('should reject timeout below minimum', () => { + const config = { + command: 'node', + timeout: 0, + }; + + expect(() => MCPServerConfigSchema.parse(config)).toThrow(); + }); + + it('should reject timeout above maximum', () => { + const config = { + command: 'node', + timeout: 3601, + }; + + expect(() => MCPServerConfigSchema.parse(config)).toThrow(); + }); + + it('should accept alwaysAllow field', () => { + const config = { + command: 'node', + alwaysAllow: ['tool1', 'tool2', 'tool3'], + }; + + const result = MCPServerConfigSchema.parse(config); + expect(result.alwaysAllow).toEqual(['tool1', 'tool2', 'tool3']); + }); + + it('should accept watchPaths field', () => { + const config = { + command: 'node', + watchPaths: ['/src', '/config'], + }; + + const result = MCPServerConfigSchema.parse(config); + expect(result.watchPaths).toEqual(['/src', '/config']); + }); + + it('should accept disabledTools field', () => { + const config = { + command: 'node', + disabledTools: ['dangerous-tool', 'deprecated-tool'], + }; + + const result = MCPServerConfigSchema.parse(config); + expect(result.disabledTools).toEqual(['dangerous-tool', 'deprecated-tool']); + }); + + it('should accept all BaseConfig fields together', () => { + const config = { + type: 'stdio' as const, + command: 'node', + args: ['server.js'], + timeout: 90, + alwaysAllow: ['read_file'], + watchPaths: ['/src'], + disabledTools: ['delete_file'], + }; + + const result = MCPServerConfigSchema.parse(config); + expect(result).toMatchObject({ + type: 'stdio', + command: 'node', + args: ['server.js'], + timeout: 90, + alwaysAllow: ['read_file'], + watchPaths: ['/src'], + disabledTools: ['delete_file'], + }); + }); + + it('should apply default values for alwaysAllow and disabledTools', () => { + const config = { + command: 'node', + }; + + const result = MCPServerConfigSchema.parse(config); + expect(result.alwaysAllow).toEqual([]); + expect(result.disabledTools).toEqual([]); + }); + + it('should apply default timeout value', () => { + const config = { + command: 'node', + }; + + const result = MCPServerConfigSchema.parse(config); + expect(result.timeout).toBe(60); + }); + }); +}); + +describe('MetadataSchema', () => { + describe('valid envVars', () => { + it('should accept valid environment variables within limits', () => { + const metadata = { + version: 1, + sessionId: 'session123', + orgId: 'org456', + userId: 'user789', + timestamp: Date.now(), + envVars: { + NODE_ENV: 'production', + API_KEY: 'secret123', + DEBUG: 'true', + }, + }; + + const result = MetadataSchema.parse(metadata); + expect(result.envVars).toEqual({ + NODE_ENV: 'production', + API_KEY: 'secret123', + DEBUG: 'true', + }); + }); + + it('should accept exactly 50 environment variables', () => { + const envVars: Record = {}; + for (let i = 1; i <= 50; i++) { + envVars[`VAR_${i}`] = `value${i}`; + } + + const metadata = { + version: 1, + sessionId: 'session123', + orgId: 'org456', + userId: 'user789', + timestamp: Date.now(), + envVars, + }; + + const result = MetadataSchema.parse(metadata); + expect(Object.keys(result.envVars!).length).toBe(50); + }); + + it('should accept keys and values at maximum length', () => { + const longKey = 'A'.repeat(256); + const longValue = 'B'.repeat(256); + + const metadata = { + version: 1, + sessionId: 'session123', + orgId: 'org456', + userId: 'user789', + timestamp: Date.now(), + envVars: { + [longKey]: longValue, + }, + }; + + const result = MetadataSchema.parse(metadata); + expect(result.envVars![longKey]).toBe(longValue); + }); + }); + + describe('too many envVars', () => { + it('should reject more than 50 environment variables', () => { + const envVars: Record = {}; + for (let i = 1; i <= 51; i++) { + envVars[`VAR_${i}`] = `value${i}`; + } + + const metadata = { + version: 1, + sessionId: 'session123', + orgId: 'org456', + userId: 'user789', + timestamp: Date.now(), + envVars, + }; + + const result = MetadataSchema.safeParse(metadata); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain( + 'Maximum 50 environment variables allowed' + ); + } + }); + }); + + describe('key too long', () => { + it('should reject env var keys exceeding 256 characters', () => { + const longKey = 'A'.repeat(257); + + const metadata = { + version: 1, + sessionId: 'session123', + orgId: 'org456', + userId: 'user789', + timestamp: Date.now(), + envVars: { + [longKey]: 'value', + }, + }; + + const result = MetadataSchema.safeParse(metadata); + expect(result.success).toBe(false); + }); + }); + + describe('value too long', () => { + it('should reject env var values exceeding 256 characters', () => { + const longValue = 'B'.repeat(257); + + const metadata = { + version: 1, + sessionId: 'session123', + orgId: 'org456', + userId: 'user789', + timestamp: Date.now(), + envVars: { + KEY: longValue, + }, + }; + + const result = MetadataSchema.safeParse(metadata); + expect(result.success).toBe(false); + }); + }); + + describe('valid setupCommands', () => { + it('should accept valid setup commands within limits', () => { + const metadata = { + version: 1, + sessionId: 'session123', + orgId: 'org456', + userId: 'user789', + timestamp: Date.now(), + setupCommands: ['npm install', 'npm run build', 'npm test'], + }; + + const result = MetadataSchema.parse(metadata); + expect(result.setupCommands).toEqual(['npm install', 'npm run build', 'npm test']); + }); + + it('should accept exactly 20 setup commands', () => { + const setupCommands: string[] = []; + for (let i = 1; i <= 20; i++) { + setupCommands.push(`command ${i}`); + } + + const metadata = { + version: 1, + sessionId: 'session123', + orgId: 'org456', + userId: 'user789', + timestamp: Date.now(), + setupCommands, + }; + + const result = MetadataSchema.parse(metadata); + expect(result.setupCommands!.length).toBe(20); + }); + + it('should accept commands at maximum length', () => { + const longCommand = 'A'.repeat(500); + + const metadata = { + version: 1, + sessionId: 'session123', + orgId: 'org456', + userId: 'user789', + timestamp: Date.now(), + setupCommands: [longCommand], + }; + + const result = MetadataSchema.parse(metadata); + expect(result.setupCommands![0]).toBe(longCommand); + }); + }); + + describe('too many commands', () => { + it('should reject more than 20 setup commands', () => { + const setupCommands: string[] = []; + for (let i = 1; i <= 21; i++) { + setupCommands.push(`command ${i}`); + } + + const metadata = { + version: 1, + sessionId: 'session123', + orgId: 'org456', + userId: 'user789', + timestamp: Date.now(), + setupCommands, + }; + + const result = MetadataSchema.safeParse(metadata); + expect(result.success).toBe(false); + }); + }); + + describe('command too long', () => { + it('should reject commands exceeding 500 characters', () => { + const longCommand = 'A'.repeat(501); + + const metadata = { + version: 1, + sessionId: 'session123', + orgId: 'org456', + userId: 'user789', + timestamp: Date.now(), + setupCommands: [longCommand], + }; + + const result = MetadataSchema.safeParse(metadata); + expect(result.success).toBe(false); + }); + }); + + describe('valid mcpServers', () => { + it('should accept valid record of MCP server configs', () => { + const mcpServers: Record = { + puppeteer: { + type: 'stdio', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-puppeteer'], + }, + remote: { + type: 'sse', + url: 'https://example.com/sse', + }, + }; + + const metadata = { + version: 1, + sessionId: 'session123', + orgId: 'org456', + userId: 'user789', + timestamp: Date.now(), + mcpServers, + }; + + const result = MetadataSchema.parse(metadata); + expect(result.mcpServers).toBeDefined(); + expect(result.mcpServers!.puppeteer.type).toBe('stdio'); + expect(result.mcpServers!.remote.type).toBe('sse'); + }); + + it('should accept server names at maximum length', () => { + const longServerName = 'A'.repeat(100); + const mcpServers: Record = { + [longServerName]: { + command: 'node', + }, + }; + + const metadata = { + version: 1, + sessionId: 'session123', + orgId: 'org456', + userId: 'user789', + timestamp: Date.now(), + mcpServers, + }; + + const result = MetadataSchema.parse(metadata); + expect(result.mcpServers![longServerName]).toBeDefined(); + }); + }); + + describe('server name too long', () => { + it('should reject server names exceeding 100 characters', () => { + const longServerName = 'A'.repeat(101); + const mcpServers: Record = { + [longServerName]: { + command: 'node', + }, + }; + + const metadata = { + version: 1, + sessionId: 'session123', + orgId: 'org456', + userId: 'user789', + timestamp: Date.now(), + mcpServers, + }; + + const result = MetadataSchema.safeParse(metadata); + expect(result.success).toBe(false); + }); + }); + + describe('required fields', () => { + it('should accept metadata with all required fields', () => { + const metadata = { + version: 1, + sessionId: 'session123', + orgId: 'org456', + userId: 'user789', + timestamp: Date.now(), + }; + + const result = MetadataSchema.parse(metadata); + expect(result).toMatchObject({ + version: 1, + sessionId: 'session123', + orgId: 'org456', + userId: 'user789', + }); + }); + + it('should reject metadata missing required fields', () => { + const metadata = { + version: 1, + sessionId: 'session123', + // Missing orgId, userId, timestamp + }; + + expect(() => MetadataSchema.parse(metadata)).toThrow(); + }); + }); + + describe('optional fields', () => { + it('should accept optional githubRepo and githubToken', () => { + const metadata = { + version: 1, + sessionId: 'session123', + orgId: 'org456', + userId: 'user789', + timestamp: Date.now(), + githubRepo: 'facebook/react', + githubToken: 'ghp_token123', + }; + + const result = MetadataSchema.parse(metadata); + expect(result.githubRepo).toBe('facebook/react'); + expect(result.githubToken).toBe('ghp_token123'); + }); + + it('should work without optional fields', () => { + const metadata = { + version: 1, + sessionId: 'session123', + orgId: 'org456', + userId: 'user789', + timestamp: Date.now(), + }; + + const result = MetadataSchema.parse(metadata); + expect(result.envVars).toBeUndefined(); + expect(result.setupCommands).toBeUndefined(); + expect(result.mcpServers).toBeUndefined(); + expect(result.githubRepo).toBeUndefined(); + expect(result.githubToken).toBeUndefined(); + }); + }); + + describe('appendSystemPrompt', () => { + it('should accept valid appendSystemPrompt', () => { + const metadata = { + version: 1, + sessionId: 'session123', + orgId: 'org456', + userId: 'user789', + timestamp: Date.now(), + appendSystemPrompt: 'Always respond in JSON format.', + }; + + const result = MetadataSchema.parse(metadata); + expect(result.appendSystemPrompt).toBe('Always respond in JSON format.'); + }); + + it('should accept appendSystemPrompt at maximum length (10000 chars)', () => { + const longPrompt = 'A'.repeat(10000); + + const metadata = { + version: 1, + sessionId: 'session123', + orgId: 'org456', + userId: 'user789', + timestamp: Date.now(), + appendSystemPrompt: longPrompt, + }; + + const result = MetadataSchema.parse(metadata); + expect(result.appendSystemPrompt).toBe(longPrompt); + }); + + it('should reject appendSystemPrompt exceeding 10000 characters', () => { + const tooLongPrompt = 'A'.repeat(10001); + + const metadata = { + version: 1, + sessionId: 'session123', + orgId: 'org456', + userId: 'user789', + timestamp: Date.now(), + appendSystemPrompt: tooLongPrompt, + }; + + const result = MetadataSchema.safeParse(metadata); + expect(result.success).toBe(false); + }); + + it('should work without appendSystemPrompt', () => { + const metadata = { + version: 1, + sessionId: 'session123', + orgId: 'org456', + userId: 'user789', + timestamp: Date.now(), + }; + + const result = MetadataSchema.parse(metadata); + expect(result.appendSystemPrompt).toBeUndefined(); + }); + }); +}); diff --git a/cloud-agent-next/src/persistence/schemas.ts b/cloud-agent-next/src/persistence/schemas.ts new file mode 100644 index 0000000000..c68ba01feb --- /dev/null +++ b/cloud-agent-next/src/persistence/schemas.ts @@ -0,0 +1,191 @@ +import * as z from 'zod'; +import { AgentModeSchema, Limits } from '../schema.js'; + +/** + * Schema for callback target configuration. + * Defined here to avoid circular dependency with router/schemas.ts. + */ +export const CallbackTargetSchema = z.object({ + url: z.string().url(), + headers: z.record(z.string(), z.string()).optional(), +}); + +/** + * Schema for image attachments that will be downloaded from R2 to the sandbox. + * Defined here to avoid circular dependency with router/schemas.ts. + * Images are stored in R2 at path: {bucket}/{userId}/{path}/{filename} + */ +export const ImagesSchema = z.object({ + path: z.string().min(1).describe('R2 path prefix under the user ID'), + files: z + .array(z.string().min(1)) + .min(1) + .describe('Ordered array of specific filenames to download'), +}); +export type Images = z.infer; + +/** + * Schema for encrypted secret envelope (RSA + AES envelope encryption). + * Matches the EncryptedEnvelope type from kilocode-backend. + * Defined here to avoid circular dependency with router/schemas.ts. + */ +export const EncryptedSecretEnvelopeSchema = z.object({ + encryptedData: z.string().describe('AES-encrypted value (base64)'), + encryptedDEK: z.string().describe('RSA-encrypted DEK (base64)'), + algorithm: z.literal('rsa-aes-256-gcm'), + version: z.literal(1), +}); + +export type EncryptedSecretEnvelope = z.infer; + +/** + * Schema for encrypted secrets - a record of key names to encrypted envelopes. + * Used to pass profile secrets securely from backend to cloud-agent worker. + */ +export const EncryptedSecretsSchema = z + .record(z.string().max(Limits.MAX_ENV_VAR_KEY_LENGTH), EncryptedSecretEnvelopeSchema) + .refine(obj => Object.keys(obj).length <= Limits.MAX_ENV_VARS, { + message: `Maximum ${Limits.MAX_ENV_VARS} encrypted secrets allowed`, + }); + +export type EncryptedSecrets = z.infer; + +export const branchNameSchema = z + .string() + .min(1, 'Branch name cannot be empty') + .max(255, 'Branch name too long') + .regex( + /^[a-zA-Z0-9._\-/]+$/, + 'Branch name can only contain alphanumeric characters, dots, dashes, underscores, and slashes' + ); + +/** + * Base configuration schema shared by all MCP server types + */ +const MCPServerBaseConfigSchema = z.object({ + disabled: z.boolean().optional(), + timeout: z.number().min(1).max(3600).optional().default(60), + alwaysAllow: z.array(z.string()).default([]), + watchPaths: z.array(z.string()).optional(), + disabledTools: z.array(z.string()).default([]), +}); + +export const MCPStdioServerConfigSchema = MCPServerBaseConfigSchema.extend({ + type: z.enum(['stdio']).optional(), + command: z.string().min(1, 'Command cannot be empty'), + args: z.array(z.string()).optional(), + cwd: z.string().optional(), + env: z.record(z.string(), z.string()).optional(), + // Field contamination prevention + url: z.undefined().optional(), + headers: z.undefined().optional(), +}).transform(data => ({ + ...data, + type: 'stdio' as const, +})); + +export const MCPSseServerConfigSchema = MCPServerBaseConfigSchema.extend({ + type: z.enum(['sse']), + url: z.string().url('URL must be a valid URL format'), + headers: z.record(z.string(), z.string()).optional(), + // Field contamination prevention + command: z.undefined().optional(), + args: z.undefined().optional(), + env: z.undefined().optional(), + cwd: z.undefined().optional(), +}).transform(data => ({ + ...data, + type: 'sse' as const, +})); + +export const MCPStreamableHttpServerConfigSchema = MCPServerBaseConfigSchema.extend({ + type: z.enum(['streamable-http']), + url: z.string().url('URL must be a valid URL format'), + headers: z.record(z.string(), z.string()).optional(), + // Field contamination prevention + command: z.undefined().optional(), + args: z.undefined().optional(), + env: z.undefined().optional(), + cwd: z.undefined().optional(), +}).transform(data => ({ + ...data, + type: 'streamable-http' as const, +})); + +/** + * MCP Server configuration schema supporting three transport types: + * - stdio: local process execution + * - sse: Server-Sent Events + * - streamable-http: HTTP streaming + */ +export const MCPServerConfigSchema = z.union([ + MCPStdioServerConfigSchema, + MCPSseServerConfigSchema, + MCPStreamableHttpServerConfigSchema, +]); + +/** + * Zod schema for CloudAgentSession metadata validation. + * Used for both DO storage and restoration validation. + */ +export const MetadataSchema = z.object({ + version: z.number(), + sessionId: z.string(), + orgId: z.string().optional(), + userId: z.string(), + botId: z.string().optional(), + kilocodeToken: z.string().optional(), + timestamp: z.number(), + githubRepo: z.string().optional(), + githubToken: z.string().optional(), + githubInstallationId: z.string().optional(), + githubAppType: z.enum(['standard', 'lite']).optional(), + gitUrl: z.string().optional(), + gitToken: z.string().optional(), + envVars: z + .record(z.string().max(256), z.string().max(256)) + .refine(obj => Object.keys(obj).length <= 50, { + message: 'Maximum 50 environment variables allowed', + }) + .optional(), + // Encrypted secrets from agent environment profiles. + // Keys are env var names, values are encrypted envelopes. + // Stored encrypted, decrypted only at execution time. + encryptedSecrets: EncryptedSecretsSchema.optional(), + setupCommands: z.array(z.string().max(500)).max(Limits.MAX_SETUP_COMMANDS).optional(), + mcpServers: z + .record(z.string().max(100), MCPServerConfigSchema) + .refine(obj => Object.keys(obj).length <= Limits.MAX_MCP_SERVERS, { + message: `Maximum ${Limits.MAX_MCP_SERVERS} MCP servers allowed`, + }) + .optional(), + upstreamBranch: branchNameSchema.optional(), + kiloSessionId: z.string().optional(), + + // Execution params + prompt: z.string().max(Limits.MAX_PROMPT_LENGTH).optional(), + mode: AgentModeSchema.optional(), + model: z.string().optional(), + autoCommit: z.boolean().optional(), + condenseOnComplete: z.boolean().optional(), + appendSystemPrompt: z.string().max(10000).optional(), + + // Lifecycle + preparedAt: z.number().optional(), + initiatedAt: z.number().optional(), + + // Callback configuration + callbackTarget: CallbackTargetSchema.optional(), + + // Image attachments + images: ImagesSchema.optional(), + + // Kilo server lifecycle tracking + kiloServerLastActivity: z.number().optional(), + + // Workspace metadata (set during prepareSession) + workspacePath: z.string().optional(), + sessionHome: z.string().optional(), + branchName: z.string().optional(), + sandboxId: z.string().optional(), +}); diff --git a/cloud-agent-next/src/persistence/types.ts b/cloud-agent-next/src/persistence/types.ts new file mode 100644 index 0000000000..c014f7202c --- /dev/null +++ b/cloud-agent-next/src/persistence/types.ts @@ -0,0 +1,205 @@ +import type { SandboxId, SessionId, SessionContext, ExecutionSession } from '../types.js'; +import type { Sandbox } from '@cloudflare/sandbox'; +import type { CloudAgentSession } from './CloudAgentSession.js'; +import type { EncryptedSecrets } from '../router/schemas.js'; +import type { CallbackTarget } from '../callbacks/index.js'; +import type { Images } from './schemas.js'; + +/** + * Base configuration shared by all MCP server types + */ +export type BaseConfig = { + /** Whether this server is disabled */ + disabled?: boolean; + /** Timeout in seconds (1-3600), default 60 */ + timeout?: number; + /** Tools that are always allowed without user confirmation */ + alwaysAllow?: string[]; + /** File paths to watch for changes */ + watchPaths?: string[]; + /** Tools that should be disabled */ + disabledTools?: string[]; +}; + +/** + * Stdio-based MCP server configuration (local process) + */ +export type StdioServerConfig = BaseConfig & { + /** Transport type - defaults to stdio */ + type?: 'stdio'; + /** Command to execute */ + command: string; + /** Command arguments */ + args?: string[]; + /** Working directory for the command */ + cwd?: string; + /** Environment variables for the command */ + env?: Record; +}; + +/** + * SSE-based MCP server configuration (Server-Sent Events) + */ +export type SseServerConfig = BaseConfig & { + /** Transport type */ + type: 'sse'; + /** Server URL */ + url: string; + /** HTTP headers */ + headers?: Record; +}; + +/** + * Streamable HTTP-based MCP server configuration + */ +export type StreamableHttpServerConfig = BaseConfig & { + /** Transport type */ + type: 'streamable-http'; + /** Server URL */ + url: string; + /** HTTP headers */ + headers?: Record; +}; + +/** + * MCP Server configuration - discriminated union of three transport types + */ +export type MCPServerConfig = StdioServerConfig | SseServerConfig | StreamableHttpServerConfig; + +export type CloudAgentSessionState = { + /** Current version timestamp (for cache invalidation) */ + version: number; + /** Session identifier (e.g., agent_abc-123) */ + sessionId: string; + /** Organization ID (optional for personal accounts) */ + orgId?: string; + /** User ID */ + userId: string; + /** Bot/service identifier (if token is for a bot) */ + botId?: string; + /** Kilocode authentication token for CLI (stored securely, never exposed in getSession) */ + kilocodeToken?: string; + /** Last save timestamp */ + timestamp: number; + /** GitHub repository (e.g., 'facebook/react') */ + githubRepo?: string; + /** GitHub token for private repos */ + githubToken?: string; + /** GitHub App installation ID for token generation */ + githubInstallationId?: string; + /** GitHub App type: 'standard' for full KiloConnect, 'lite' for read-only KiloConnect-Lite */ + githubAppType?: 'standard' | 'lite'; + /** Generic git repository URL (full HTTPS URL, e.g., 'https://gitlab.com/org/repo.git') */ + gitUrl?: string; + /** Git token for authentication (username is always 'x-access-token') */ + gitToken?: string; + /** Environment variables to inject into sandbox execution sessions (plaintext) */ + envVars?: Record; + /** + * Encrypted secret env vars from agent environment profiles. + * Stored encrypted in DO, decrypted only at execution time when injected into CLI. + * Keys are env var names, values are encrypted envelopes. + */ + encryptedSecrets?: EncryptedSecrets; + /** Installation commands to run on init/resume */ + setupCommands?: string[]; + /** MCP server configurations written to .kilocode/cli/global/setting/mcp_settings.json */ + mcpServers?: Record; + /** Upstream branch to checkout when cloning the repo */ + upstreamBranch?: string; + /** Kilo CLI session ID for continuation (from session_created event) */ + kiloSessionId?: string; + + // Execution params (for prepareSession flow) + /** The prompt/task to execute */ + prompt?: string; + /** The mode to use (e.g., 'code', 'architect') */ + mode?: string; + /** The model to use */ + model?: string; + /** Whether to auto-commit changes */ + autoCommit?: boolean; + /** Whether to condense context after execution */ + condenseOnComplete?: boolean; + /** Custom text to append to the system prompt */ + appendSystemPrompt?: string; + + // Lifecycle timestamps (for state machine) + /** Timestamp when session was prepared (state machine: prepared) */ + preparedAt?: number; + /** Timestamp when session execution started (state machine: initiated) */ + initiatedAt?: number; + + // Callback configuration + /** Optional callback target for execution completion notifications */ + callbackTarget?: CallbackTarget; + + // Image attachments + /** Optional image attachments to download from R2 to the sandbox */ + images?: Images; + + // Kilo server lifecycle tracking + /** Timestamp of last kilo server activity (for idle timeout cleanup) */ + kiloServerLastActivity?: number; + + // Workspace metadata (set during prepareSession) + /** Workspace path where the repo was cloned */ + workspacePath?: string; + /** Session home directory */ + sessionHome?: string; + /** Git branch name created for the session */ + branchName?: string; + /** Sandbox ID where the session runs */ + sandboxId?: string; +}; + +/** + * Result type for atomic DO operations with success/error feedback. + */ +export type OperationResult = { + success: boolean; + error?: string; + data?: T; +}; + +export type PersistenceEnv = { + /** Durable Object namespace for Sandbox instances */ + Sandbox: DurableObjectNamespace; + /** Durable Object namespace for CloudAgentSession metadata (SQLite-backed) with RPC support */ + CLOUD_AGENT_SESSION: DurableObjectNamespace; + /** Shared secret for JWT token validation */ + NEXTAUTH_SECRET: string; + /** Comma-separated list of allowed Origins for /stream WebSocket connections */ + WS_ALLOWED_ORIGINS?: string; + /** Optional override for Kilocode token injected into session environment (does not affect authentication) */ + KILOCODE_TOKEN_OVERRIDE?: string; + /** Optional override for Kilocode org ID injected into session environment (does not affect authentication) */ + KILOCODE_ORG_ID_OVERRIDE?: string; + /** Backend base URL for API calls and session environment variables (defaults to https://kilo.ai) */ + KILOCODE_BACKEND_BASE_URL?: string; + /** Base URL override for OpenRouter-compatible Kilo API */ + KILO_OPENROUTER_BASE?: string; + /** Kilocode CLI timeout override (seconds) */ + CLI_TIMEOUT_SECONDS?: string; + /** GitHub App slug for git commit attribution (e.g., 'kiloconnect') */ + GITHUB_APP_SLUG?: string; + /** GitHub App bot user ID for git commit email (e.g., '240665456') */ + GITHUB_APP_BOT_USER_ID?: string; + /** GitHub Lite App slug for git commit attribution (e.g., 'kiloconnect-lite') */ + GITHUB_LITE_APP_SLUG?: string; + /** GitHub Lite App bot user ID for git commit email */ + GITHUB_LITE_APP_BOT_USER_ID?: string; + /** + * RSA private key for decrypting encrypted secrets from agent environment profiles. + * Required when using encryptedSecrets feature. PEM format. + */ + AGENT_ENV_VARS_PRIVATE_KEY?: string; + + R2_ENDPOINT?: string; + R2_ATTACHMENTS_READONLY_ACCESS_KEY_ID?: string; + R2_ATTACHMENTS_READONLY_SECRET_ACCESS_KEY?: string; + R2_ATTACHMENTS_BUCKET?: string; +}; + +// Re-export commonly used types for convenience +export type { SessionContext, SandboxId, SessionId, ExecutionSession }; diff --git a/cloud-agent-next/src/router.test.ts b/cloud-agent-next/src/router.test.ts new file mode 100644 index 0000000000..c6bc760768 --- /dev/null +++ b/cloud-agent-next/src/router.test.ts @@ -0,0 +1,834 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { TRPCError } from '@trpc/server'; + +// Mock Cloudflare sandbox to prevent module resolution errors +vi.mock('@cloudflare/sandbox', () => ({ + getSandbox: vi.fn(), +})); + +const { interruptMock, buildContextMock, getOrCreateSessionMock } = vi.hoisted(() => ({ + interruptMock: vi.fn(), + buildContextMock: vi.fn(), + getOrCreateSessionMock: vi.fn(), +})); + +const { getSandboxIdForSessionMock, metadataMock } = vi.hoisted(() => ({ + getSandboxIdForSessionMock: vi.fn(), + metadataMock: vi.fn(), +})); + +vi.mock('./session-service.js', () => ({ + generateSessionId: vi.fn(() => 'agent_12345678-1234-1234-1234-123456789abc'), + fetchSessionMetadata: vi.fn(), + InvalidSessionMetadataError: class InvalidSessionMetadataError extends Error { + constructor( + public readonly userId: string, + public readonly sessionId: string, + public readonly details?: string + ) { + super(`Invalid session metadata for session ${sessionId}`); + this.name = 'InvalidSessionMetadataError'; + } + }, + SessionService: class SessionService { + constructor() { + this.buildContext = buildContextMock; + this.getOrCreateSession = getOrCreateSessionMock; + this.getSandboxIdForSession = getSandboxIdForSessionMock; + } + buildContext!: typeof buildContextMock; + getOrCreateSession!: typeof getOrCreateSessionMock; + getSandboxIdForSession!: typeof getSandboxIdForSessionMock; + get metadata() { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return metadataMock(); + } + static interrupt = interruptMock; + }, +})); + +import { getSandbox } from '@cloudflare/sandbox'; +import { generateSessionId, fetchSessionMetadata } from './session-service.js'; +import { sessionIdSchema, envVarsSchema } from './types.js'; +import { appRouter } from './router.js'; +import type { TRPCContext, SessionId } from './types.js'; +import type { CloudAgentSessionState } from './persistence/types.js'; + +type MockCAS = { + idFromName: ReturnType; + get: ReturnType; +}; + +// Note: Balance validation is now handled in the worker entry point (index.ts) +// via pre-flight validation before the tRPC handler is called. +// This returns proper HTTP status codes (401, 402) instead of SSE error events. +// See cloud-agent/src/balance-validation.ts for the implementation. +// Tests for balance validation are in cloud-agent/src/balance-validation.test.ts + +describe('router sessionId validation', () => { + it('should reject invalid session ID formats', () => { + const invalidIds = [ + // Path traversal and command injection + 'agent_../../etc/passwd', + 'agent_abc123; rm -rf /', + '../agent_12345678-1234-1234-1234-123456789abc', + // Missing or wrong prefix + 'session_12345678-1234-1234-1234-123456789abc', + '12345678-1234-1234-1234-123456789abc', + // Incomplete formats + 'agent_', + 'agent_incomplete', + '', + // Special characters + 'agent_test%00null', + 'agent_', + // Non-hex characters in UUID + 'agent_xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + 'agent_ZZZZZZZZ-ZZZZ-ZZZZ-ZZZZ-ZZZZZZZZZZZZ', + // Wrong UUID length/format + 'agent_12345678-1234-1234-1234-123456789ab', + 'agent_123456781234123412341234567890abc', + // Whitespace/extra characters + 'agent_12345678-1234-1234-1234-123456789abc ', + ' agent_12345678-1234-1234-1234-123456789abc', + ]; + + for (const invalidId of invalidIds) { + const result = sessionIdSchema.safeParse(invalidId); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toBe('Invalid session ID format'); + } + } + }); + + it('should accept valid session ID formats', () => { + const validIds = [ + 'agent_12345678-1234-1234-1234-123456789abc', + 'agent_00000000-0000-0000-0000-000000000000', + 'agent_ffffffff-ffff-ffff-ffff-ffffffffffff', + 'agent_ABCDEF01-2345-6789-ABCD-EF0123456789', // Case-insensitive + 'agent_Abcd1234-5678-90AB-cdef-0123456789aB', // Mixed case + ]; + + for (const validId of validIds) { + const result = sessionIdSchema.safeParse(validId); + expect(result.success).toBe(true); + } + }); + + it('should accept session IDs generated by generateSessionId()', () => { + const generatedId = generateSessionId(); + const result = sessionIdSchema.safeParse(generatedId); + expect(result.success).toBe(true); + }); + + describe('envVars validation', () => { + it('should reject HOME variable', () => { + const result = envVarsSchema.safeParse({ HOME: '/custom/home' }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toContain('reserved environment variables'); + expect(result.error.issues[0]?.message).toContain('HOME'); + } + }); + + it('should reject SESSION_ID variable', () => { + const result = envVarsSchema.safeParse({ SESSION_ID: 'custom-id' }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toContain('reserved environment variables'); + expect(result.error.issues[0]?.message).toContain('SESSION_ID'); + } + }); + + it('should reject SESSION_HOME variable', () => { + const result = envVarsSchema.safeParse({ SESSION_HOME: '/custom/session' }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toContain('reserved environment variables'); + expect(result.error.issues[0]?.message).toContain('SESSION_HOME'); + } + }); + + it('should reject multiple reserved variables', () => { + const result = envVarsSchema.safeParse({ + HOME: '/custom/home', + SESSION_ID: 'custom-id', + API_KEY: 'valid-key', + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toContain('reserved environment variables'); + } + }); + + it('should allow non-reserved variables', () => { + const result = envVarsSchema.safeParse({ + API_KEY: 'my-api-key', + DATABASE_URL: 'postgresql://localhost:5432/mydb', + NODE_ENV: 'production', + CUSTOM_VAR: 'custom-value', + }); + expect(result.success).toBe(true); + }); + + it('should reject undefined (schema requires actual object)', () => { + // The envVarsSchema itself requires an object when used + // Optionality is handled at the parent schema level + const result = envVarsSchema.safeParse(undefined); + expect(result.success).toBe(false); + }); + + it('should allow empty env vars object', () => { + const result = envVarsSchema.safeParse({}); + expect(result.success).toBe(true); + }); + }); + + describe('sandboxId generation with hash format', () => { + describe('format validation', () => { + it('should generate sandboxId with org prefix for organization accounts', async () => { + const { generateSandboxId } = await import('./sandbox-id.js'); + const sandboxId = await generateSandboxId('org-123', 'user-456'); + expect(sandboxId).toMatch(/^org-[0-9a-f]{48}$/); + expect(sandboxId.length).toBe(52); + }); + + it('should generate sandboxId with bot prefix when botId is provided', async () => { + const { generateSandboxId } = await import('./sandbox-id.js'); + const sandboxId = await generateSandboxId('org-123', 'user-456', 'reviewer'); + expect(sandboxId).toMatch(/^bot-[0-9a-f]{48}$/); + expect(sandboxId.length).toBe(52); + }); + }); + + describe('personal accounts', () => { + it('should generate sandboxId with usr prefix for personal accounts', async () => { + const { generateSandboxId } = await import('./sandbox-id.js'); + const sandboxId = await generateSandboxId(undefined, 'abc-123'); + expect(sandboxId).toMatch(/^usr-[0-9a-f]{48}$/); + expect(sandboxId.length).toBe(52); + }); + + it('should generate sandboxId with ubt prefix for personal bot accounts', async () => { + const { generateSandboxId } = await import('./sandbox-id.js'); + const sandboxId = await generateSandboxId(undefined, 'abc-123', 'reviewer'); + expect(sandboxId).toMatch(/^ubt-[0-9a-f]{48}$/); + expect(sandboxId.length).toBe(52); + }); + }); + + describe('collision prevention', () => { + it('should prevent collision between org and personal accounts', async () => { + const { generateSandboxId } = await import('./sandbox-id.js'); + const userId = 'same-user-id'; + + const orgSandboxId = await generateSandboxId('org-123', userId); + const personalSandboxId = await generateSandboxId(undefined, userId); + + expect(orgSandboxId).not.toBe(personalSandboxId); + expect(orgSandboxId).toMatch(/^org-[0-9a-f]{48}$/); + expect(personalSandboxId).toMatch(/^usr-[0-9a-f]{48}$/); + }); + + describe('deleteSession procedure', () => { + let mockContext: TRPCContext; + let mockSandbox: ReturnType; + let caller: ReturnType; + let cloudAgentSession: MockCAS; + + beforeEach(() => { + vi.clearAllMocks(); + interruptMock.mockResolvedValue({ + success: true, + killedProcessIds: ['p1'], + failedProcessIds: [], + message: 'stopped', + }); + buildContextMock.mockImplementation(({ sandboxId, orgId, userId, sessionId }) => ({ + sandboxId, + orgId, + userId, + sessionId, + sessionHome: `/home/${sessionId}`, + workspacePath: `/workspace/${sessionId}`, + branchName: `session/${sessionId}`, + })); + const mockSession = { token: 'session' }; + getOrCreateSessionMock.mockResolvedValue(mockSession); + + // Mock context + mockContext = { + userId: 'test-user-123', + authToken: 'test-token', + botId: undefined, + request: {} as Request, + env: { + Sandbox: {} as TRPCContext['env']['Sandbox'], + CLOUD_AGENT_SESSION: { + idFromName: vi.fn(id => ({ id })), + get: vi.fn(() => ({ + deleteSession: vi.fn().mockResolvedValue(undefined), + markAsInterrupted: vi.fn().mockResolvedValue(undefined), + getActiveExecutionId: vi.fn().mockResolvedValue(null), + getExecution: vi.fn().mockResolvedValue(null), + })), + } as unknown as TRPCContext['env']['CLOUD_AGENT_SESSION'], + NEXTAUTH_SECRET: 'test-secret', + }, + }; + cloudAgentSession = mockContext.env.CLOUD_AGENT_SESSION as unknown as MockCAS; + + // Mock sandbox with deleteSession method + mockSandbox = { + deleteSession: vi.fn().mockResolvedValue(undefined), + } as unknown as ReturnType; + + vi.mocked(getSandbox).mockReturnValue(mockSandbox); + + // Create caller with mocked context + caller = appRouter.createCaller(mockContext); + }); + + describe('successful deletion', () => { + it('should successfully delete existing session', async () => { + const sessionId: SessionId = 'agent_12345678-1234-1234-1234-123456789abc'; + const metadata: CloudAgentSessionState = { + version: 123456789, + sessionId, + orgId: 'org-123', + userId: 'test-user-123', + timestamp: 123456789, + }; + + vi.mocked(fetchSessionMetadata).mockResolvedValue(metadata); + const deleteSessionMock = vi.fn().mockResolvedValue(undefined); + vi.mocked(cloudAgentSession.get).mockReturnValue({ + deleteSession: deleteSessionMock, + markAsInterrupted: vi.fn().mockResolvedValue(undefined), + }); + + const result = await caller.deleteSession({ sessionId }); + + expect(result).toEqual({ success: true }); + expect(fetchSessionMetadata).toHaveBeenCalledWith( + mockContext.env, + 'test-user-123', + sessionId + ); + expect(getSandbox).toHaveBeenCalledWith( + mockContext.env.Sandbox, + expect.stringMatching(/^org-[0-9a-f]{48}$/) + ); + // eslint-disable-next-line @typescript-eslint/unbound-method + const sandboxDelete = vi.mocked(mockSandbox.deleteSession); + expect(sandboxDelete).toHaveBeenCalledWith(sessionId); + expect(cloudAgentSession.idFromName).toHaveBeenCalledWith( + `${metadata.userId}:${sessionId}` + ); + expect(deleteSessionMock).toHaveBeenCalled(); + }); + + it('should successfully delete session for personal account', async () => { + const sessionId: SessionId = 'agent_abcdef01-2345-6789-abcd-ef0123456789'; + const metadata: CloudAgentSessionState = { + version: 123456789, + sessionId, + orgId: undefined, // Personal account + userId: 'test-user-123', + timestamp: 123456789, + }; + + vi.mocked(fetchSessionMetadata).mockResolvedValue(metadata); + + const result = await caller.deleteSession({ sessionId }); + + expect(result).toEqual({ success: true }); + // Should use usr prefix for personal accounts + expect(getSandbox).toHaveBeenCalledWith( + mockContext.env.Sandbox, + expect.stringMatching(/^usr-[0-9a-f]{48}$/) + ); + }); + + it('should successfully delete session with botId', async () => { + const sessionId: SessionId = 'agent_11111111-2222-3333-4444-555555555555'; + const metadata: CloudAgentSessionState = { + version: 123456789, + sessionId, + orgId: 'org-123', + userId: 'test-user-123', + timestamp: 123456789, + botId: 'reviewer', + }; + + vi.mocked(fetchSessionMetadata).mockResolvedValue(metadata); + + const result = await caller.deleteSession({ sessionId }); + + expect(result).toEqual({ success: true }); + // Should include bot suffix + expect(getSandbox).toHaveBeenCalledWith( + mockContext.env.Sandbox, + expect.stringMatching(/^bot-[0-9a-f]{48}$/) + ); + }); + }); + + describe('idempotency', () => { + it('should return success for non-existent session', async () => { + const sessionId: SessionId = 'agent_00000000-0000-0000-0000-000000000000'; + + vi.mocked(fetchSessionMetadata).mockResolvedValue(null); + + const result = await caller.deleteSession({ sessionId }); + + expect(result).toEqual({ + success: true, + message: 'Session not found or already deleted', + }); + // Should not attempt to delete from sandbox or destroy session + expect(getSandbox).not.toHaveBeenCalled(); + expect(cloudAgentSession.get).not.toHaveBeenCalled(); + }); + }); + + describe('sandbox deletion failure handling', () => { + it('should continue cleanup when sandbox deletion fails', async () => { + const sessionId: SessionId = 'agent_aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + const metadata: CloudAgentSessionState = { + version: 123456789, + sessionId, + orgId: 'org-123', + userId: 'test-user-123', + timestamp: 123456789, + }; + + vi.mocked(fetchSessionMetadata).mockResolvedValue(metadata); + // Sandbox deletion fails + mockSandbox.deleteSession = vi.fn().mockRejectedValue(new Error('Sandbox unreachable')); + // DO cleanup succeeds + const deleteSessionMock = vi.mocked(cloudAgentSession.get).mockReturnValue({ + deleteSession: vi.fn().mockResolvedValue(undefined), + markAsInterrupted: vi.fn().mockResolvedValue(undefined), + }); + + const result = await caller.deleteSession({ sessionId }); + + // Should still succeed overall + expect(result).toEqual({ success: true }); + // Should have attempted both cleanups + // eslint-disable-next-line @typescript-eslint/unbound-method + const sandboxDelete = vi.mocked(mockSandbox.deleteSession); + expect(sandboxDelete).toHaveBeenCalled(); + expect(deleteSessionMock().deleteSession).toHaveBeenCalled(); + }); + }); + + describe('DO cleanup failure handling', () => { + it('should fail when DO cleanup fails', async () => { + const sessionId: SessionId = 'agent_ffffffff-ffff-ffff-ffff-ffffffffffff'; + const metadata: CloudAgentSessionState = { + version: 123456789, + sessionId, + orgId: 'org-123', + userId: 'test-user-123', + timestamp: 123456789, + }; + + vi.mocked(fetchSessionMetadata).mockResolvedValue(metadata); + const deleteSessionMock = vi.mocked(cloudAgentSession.get).mockReturnValue({ + deleteSession: vi.fn().mockRejectedValue(new Error('connection lost')), + markAsInterrupted: vi.fn().mockResolvedValue(undefined), + }); + + await expect(caller.deleteSession({ sessionId })).rejects.toThrow(TRPCError); + await expect(caller.deleteSession({ sessionId })).rejects.toThrow( + 'Failed to clean up session metadata' + ); + expect(deleteSessionMock().deleteSession).toHaveBeenCalled(); + }); + + it('should succeed when DO cleanup succeeds', async () => { + const sessionId: SessionId = 'agent_11111111-1111-1111-1111-111111111111'; + const metadata: CloudAgentSessionState = { + version: 123456789, + sessionId, + orgId: 'org-123', + userId: 'test-user-123', + timestamp: 123456789, + }; + + vi.mocked(fetchSessionMetadata).mockResolvedValue(metadata); + const deleteSessionMock = vi.mocked(cloudAgentSession.get).mockReturnValue({ + deleteSession: vi.fn().mockResolvedValue(undefined), + markAsInterrupted: vi.fn().mockResolvedValue(undefined), + }); + + const result = await caller.deleteSession({ sessionId }); + + // Should still succeed overall - partial cleanup is acceptable + expect(result).toEqual({ success: true }); + expect(deleteSessionMock().deleteSession).toHaveBeenCalled(); + }); + + it('should succeed when DO cleanup succeeds (no partial R2 path)', async () => { + const sessionId: SessionId = 'agent_22222222-2222-2222-2222-222222222222'; + const metadata: CloudAgentSessionState = { + version: 123456789, + sessionId, + orgId: 'org-123', + userId: 'test-user-123', + timestamp: 123456789, + }; + + vi.mocked(fetchSessionMetadata).mockResolvedValue(metadata); + const deleteSessionMock = vi.mocked(cloudAgentSession.get).mockReturnValue({ + deleteSession: vi.fn().mockResolvedValue(undefined), + markAsInterrupted: vi.fn().mockResolvedValue(undefined), + }); + + const result = await caller.deleteSession({ sessionId }); + + // Should still succeed overall - partial cleanup is acceptable + expect(result).toEqual({ success: true }); + expect(deleteSessionMock().deleteSession).toHaveBeenCalled(); + }); + }); + + describe('authorization', () => { + it('should require authentication', async () => { + const unauthenticatedContext: TRPCContext = { + userId: undefined, + authToken: undefined, + botId: undefined, + env: mockContext.env, + } as unknown as TRPCContext; + + const unauthenticatedCaller = appRouter.createCaller(unauthenticatedContext); + + await expect( + unauthenticatedCaller.deleteSession({ sessionId: 'agent_test' }) + ).rejects.toThrow('Authentication required'); + }); + + it('should only allow users to delete their own sessions', async () => { + const sessionId: SessionId = 'agent_99999999-8888-7777-6666-555555555555'; + + // fetchSessionMetadata is called with the requesting user's ID + // It will return null or throw if the user doesn't own the session + vi.mocked(fetchSessionMetadata).mockResolvedValue(null); + + const result = await caller.deleteSession({ sessionId }); + + // Should treat as non-existent (user can't access other user's sessions) + expect(result).toEqual({ + success: true, + message: 'Session not found or already deleted', + }); + }); + }); + + describe('error handling', () => { + it('should handle metadata fetch errors', async () => { + const sessionId: SessionId = 'agent_deadbeef-dead-beef-dead-beefdeadbeef'; + + vi.mocked(fetchSessionMetadata).mockRejectedValue(new Error('Metadata fetch failed')); + + await expect(caller.deleteSession({ sessionId })).rejects.toThrow(TRPCError); + await expect(caller.deleteSession({ sessionId })).rejects.toThrow( + 'Failed to delete session' + ); + }); + + it('should wrap non-TRPCError errors', async () => { + const sessionId: SessionId = 'agent_cafebabe-cafe-babe-cafe-babecafebabe'; + + vi.mocked(fetchSessionMetadata).mockRejectedValue(new Error('Generic error')); + + await expect(caller.deleteSession({ sessionId })).rejects.toThrow(TRPCError); + + try { + await caller.deleteSession({ sessionId }); + } catch (error) { + expect(error).toBeInstanceOf(TRPCError); + expect((error as TRPCError).code).toBe('INTERNAL_SERVER_ERROR'); + } + }); + + it('should preserve TRPCError instances', async () => { + const sessionId: SessionId = 'agent_facefeed-face-feed-face-feedfacefeed'; + const originalError = new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'Session metadata is invalid', + }); + + vi.mocked(fetchSessionMetadata).mockRejectedValue(originalError); + + await expect(caller.deleteSession({ sessionId })).rejects.toThrow(originalError); + }); + }); + }); + + it('should prevent collision between user and bot sessions', async () => { + const { generateSandboxId } = await import('./sandbox-id.js'); + const orgId = 'org-123'; + const userId = 'user-456'; + const botId = 'reviewer'; + + const userSandboxId = await generateSandboxId(orgId, userId); + const botSandboxId = await generateSandboxId(orgId, userId, botId); + + expect(userSandboxId).not.toBe(botSandboxId); + expect(userSandboxId).toMatch(/^org-[0-9a-f]{48}$/); + expect(botSandboxId).toMatch(/^bot-[0-9a-f]{48}$/); + }); + }); + + describe('getSession procedure', () => { + let mockContext: TRPCContext; + let caller: ReturnType; + let cloudAgentSession: MockCAS; + let mockGetMetadata: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockGetMetadata = vi.fn(); + + // Mock context + mockContext = { + userId: 'test-user-123', + authToken: 'test-token', + botId: undefined, + request: {} as Request, + env: { + Sandbox: {} as TRPCContext['env']['Sandbox'], + CLOUD_AGENT_SESSION: { + idFromName: vi.fn(id => ({ id })), + get: vi.fn(() => ({ + getMetadata: mockGetMetadata, + getActiveExecutionId: vi.fn().mockResolvedValue(null), + getExecution: vi.fn().mockResolvedValue(null), + })), + } as unknown as TRPCContext['env']['CLOUD_AGENT_SESSION'], + NEXTAUTH_SECRET: 'test-secret', + }, + }; + cloudAgentSession = mockContext.env.CLOUD_AGENT_SESSION as unknown as MockCAS; + + // Create caller with mocked context + caller = appRouter.createCaller(mockContext); + }); + + describe('successful retrieval', () => { + it('should return sanitized session metadata for owner', async () => { + const sessionId: SessionId = 'agent_12345678-1234-1234-1234-123456789abc'; + const metadata: CloudAgentSessionState = { + version: 123456789, + sessionId, + orgId: 'org-123', + userId: 'test-user-123', + timestamp: 123456789, + kiloSessionId: 'a0000000-0000-4000-8000-000000000001', + githubRepo: 'acme/repo', + githubToken: 'secret-token-should-not-be-returned', + gitUrl: undefined, + gitToken: undefined, + prompt: 'Build a feature', + mode: 'build', + model: 'claude-3-sonnet', + autoCommit: true, + upstreamBranch: 'main', + envVars: { API_KEY: 'secret-value', DB_URL: 'postgres://localhost' }, + setupCommands: ['npm install', 'npm run build'], + mcpServers: { + puppeteer: { command: 'npx', args: ['-y', '@mcp/puppeteer'] }, + }, + preparedAt: 1700000000000, + initiatedAt: 1700000001000, + }; + + mockGetMetadata.mockResolvedValue(metadata); + + const result = await caller.getSession({ cloudAgentSessionId: sessionId }); + + // Verify the result contains safe fields + expect(result.sessionId).toBe(sessionId); + expect(result.kiloSessionId).toBe('a0000000-0000-4000-8000-000000000001'); + expect(result.userId).toBe('test-user-123'); + expect(result.orgId).toBe('org-123'); + expect(result.githubRepo).toBe('acme/repo'); + expect(result.prompt).toBe('Build a feature'); + expect(result.mode).toBe('build'); + expect(result.model).toBe('claude-3-sonnet'); + expect(result.autoCommit).toBe(true); + expect(result.upstreamBranch).toBe('main'); + expect(result.preparedAt).toBe(1700000000000); + expect(result.initiatedAt).toBe(1700000001000); + expect(result.timestamp).toBe(123456789); + expect(result.version).toBe(123456789); + + // Verify counts are returned, not actual values + expect(result.envVarCount).toBe(2); + expect(result.setupCommandCount).toBe(2); + expect(result.mcpServerCount).toBe(1); + + // Verify secrets are NOT returned + expect(result).not.toHaveProperty('githubToken'); + expect(result).not.toHaveProperty('gitToken'); + expect(result).not.toHaveProperty('envVars'); + expect(result).not.toHaveProperty('setupCommands'); + expect(result).not.toHaveProperty('mcpServers'); + + // Verify DO was accessed with correct key + expect(cloudAgentSession.idFromName).toHaveBeenCalledWith(`test-user-123:${sessionId}`); + }); + + it('should work for personal account sessions (no orgId)', async () => { + const sessionId: SessionId = 'agent_abcdef01-2345-6789-abcd-ef0123456789'; + const metadata: CloudAgentSessionState = { + version: 123456789, + sessionId, + orgId: undefined, // Personal account + userId: 'test-user-123', + timestamp: 123456789, + prompt: 'Test prompt', + mode: 'plan', + model: 'gpt-4', + }; + + mockGetMetadata.mockResolvedValue(metadata); + + const result = await caller.getSession({ cloudAgentSessionId: sessionId }); + + expect(result.sessionId).toBe(sessionId); + expect(result.orgId).toBeUndefined(); + expect(result.mode).toBe('plan'); + }); + + it('should handle session with no optional fields', async () => { + const sessionId: SessionId = 'agent_11111111-1111-1111-1111-111111111111'; + const metadata: CloudAgentSessionState = { + version: 123456789, + sessionId, + userId: 'test-user-123', + timestamp: 123456789, + }; + + mockGetMetadata.mockResolvedValue(metadata); + + const result = await caller.getSession({ cloudAgentSessionId: sessionId }); + + expect(result.sessionId).toBe(sessionId); + expect(result.kiloSessionId).toBeUndefined(); + expect(result.orgId).toBeUndefined(); + expect(result.githubRepo).toBeUndefined(); + expect(result.prompt).toBeUndefined(); + expect(result.mode).toBeUndefined(); + expect(result.model).toBeUndefined(); + expect(result.autoCommit).toBeUndefined(); + expect(result.preparedAt).toBeUndefined(); + expect(result.initiatedAt).toBeUndefined(); + expect(result.envVarCount).toBeUndefined(); + expect(result.setupCommandCount).toBeUndefined(); + expect(result.mcpServerCount).toBeUndefined(); + }); + }); + + describe('not found', () => { + it('should return NOT_FOUND for non-existent session', async () => { + const sessionId: SessionId = 'agent_00000000-0000-0000-0000-000000000000'; + + mockGetMetadata.mockResolvedValue(null); + + await expect(caller.getSession({ cloudAgentSessionId: sessionId })).rejects.toThrow( + TRPCError + ); + await expect(caller.getSession({ cloudAgentSessionId: sessionId })).rejects.toThrow( + 'Session not found' + ); + }); + }); + + describe('cross-user access prevention', () => { + it('should isolate sessions by userId via DO key', async () => { + const sessionId: SessionId = 'agent_22222222-2222-2222-2222-222222222222'; + // Even if metadata exists for another user, the DO key includes userId + // so user A cannot access user B's session + + // The DO is keyed by userId:sessionId, so a different user would get + // a different DO instance that returns null + mockGetMetadata.mockResolvedValue(null); + + await expect(caller.getSession({ cloudAgentSessionId: sessionId })).rejects.toThrow( + 'Session not found' + ); + + // Verify the DO was keyed with the authenticated user's ID + expect(cloudAgentSession.idFromName).toHaveBeenCalledWith(`test-user-123:${sessionId}`); + }); + }); + + describe('lifecycle timestamps', () => { + it('should return preparedAt when session is prepared but not initiated', async () => { + const sessionId: SessionId = 'agent_33333333-3333-3333-3333-333333333333'; + const metadata: CloudAgentSessionState = { + version: 123456789, + sessionId, + userId: 'test-user-123', + timestamp: 123456789, + preparedAt: 1700000000000, + // initiatedAt is undefined - not yet initiated + }; + + mockGetMetadata.mockResolvedValue(metadata); + + const result = await caller.getSession({ cloudAgentSessionId: sessionId }); + + expect(result.preparedAt).toBe(1700000000000); + expect(result.initiatedAt).toBeUndefined(); + }); + + it('should return both preparedAt and initiatedAt when session is initiated', async () => { + const sessionId: SessionId = 'agent_44444444-4444-4444-4444-444444444444'; + const metadata: CloudAgentSessionState = { + version: 123456789, + sessionId, + userId: 'test-user-123', + timestamp: 123456789, + preparedAt: 1700000000000, + initiatedAt: 1700000001000, + }; + + mockGetMetadata.mockResolvedValue(metadata); + + const result = await caller.getSession({ cloudAgentSessionId: sessionId }); + + expect(result.preparedAt).toBe(1700000000000); + expect(result.initiatedAt).toBe(1700000001000); + }); + }); + + describe('authorization', () => { + it('should require authentication', async () => { + const unauthenticatedContext: TRPCContext = { + userId: undefined, + authToken: undefined, + botId: undefined, + env: mockContext.env, + } as unknown as TRPCContext; + + const unauthenticatedCaller = appRouter.createCaller(unauthenticatedContext); + + await expect( + unauthenticatedCaller.getSession({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc', + }) + ).rejects.toThrow('Authentication required'); + }); + }); + }); + }); +}); diff --git a/cloud-agent-next/src/router.ts b/cloud-agent-next/src/router.ts new file mode 100644 index 0000000000..29fcc1d957 --- /dev/null +++ b/cloud-agent-next/src/router.ts @@ -0,0 +1,18 @@ +/** + * tRPC Router - Main entry point + * + * This is a slim orchestrator that combines handler modules. + * Handler implementations are in ./router/handlers/ + */ +import { router } from './router/auth.js'; +import { createSessionManagementHandlers } from './router/handlers/session-management.js'; +import { createSessionPrepareHandlers } from './router/handlers/session-prepare.js'; +import { createSessionExecutionV2Handlers } from './router/handlers/session-execution.js'; + +export const appRouter = router({ + ...createSessionManagementHandlers(), + ...createSessionPrepareHandlers(), + ...createSessionExecutionV2Handlers(), +}); + +export type AppRouter = typeof appRouter; diff --git a/cloud-agent-next/src/router/auth.ts b/cloud-agent-next/src/router/auth.ts new file mode 100644 index 0000000000..6c57578382 --- /dev/null +++ b/cloud-agent-next/src/router/auth.ts @@ -0,0 +1,79 @@ +import { initTRPC, TRPCError } from '@trpc/server'; +import type { TRPCContext } from '../types.js'; + +/** + * Type for error cause data that should be surfaced in the response. + * Used for 409 Conflict (activeExecutionId) and 503 Retryable errors. + */ +type ErrorCauseData = { + error?: string; + message?: string; + activeExecutionId?: string; + retryable?: boolean; +}; + +// Initialize tRPC with context and error formatter +export const t = initTRPC.context().create({ + errorFormatter({ shape, error }) { + // Surface cause data in the response for specific error types + const causeData = error.cause as ErrorCauseData | undefined; + if (causeData && typeof causeData === 'object') { + return { + ...shape, + data: { + ...shape.data, + // Include structured error info from cause + ...(causeData.error && { error: causeData.error }), + ...(causeData.activeExecutionId && { activeExecutionId: causeData.activeExecutionId }), + ...(causeData.retryable !== undefined && { retryable: causeData.retryable }), + }, + }; + } + return shape; + }, +}); + +export const router = t.router; +export const publicProcedure = t.procedure; + +// Auth middleware - validates customer token +export const protectedProcedure = t.procedure.use(opts => { + if (!opts.ctx.userId || !opts.ctx.authToken) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Authentication required', + }); + } + + return opts.next({ + ctx: opts.ctx, + }); +}); + +// Internal API secret + customer token middleware (for prepareSession/updateSession) +export const internalApiProtectedProcedure = t.procedure.use(async ({ ctx, next }) => { + // 1. Validate internal API secret + const internalApiKey = ctx.request.headers.get('x-internal-api-key'); + if (!ctx.env.INTERNAL_API_SECRET) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Internal API secret not configured', + }); + } + if (!internalApiKey || internalApiKey !== ctx.env.INTERNAL_API_SECRET) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Invalid or missing internal API key', + }); + } + + // 2. Also validate customer token + if (!ctx.userId || !ctx.authToken) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Invalid customer token', + }); + } + + return next({ ctx }); +}); diff --git a/cloud-agent-next/src/router/handlers/session-execution.ts b/cloud-agent-next/src/router/handlers/session-execution.ts new file mode 100644 index 0000000000..1770fc15e5 --- /dev/null +++ b/cloud-agent-next/src/router/handlers/session-execution.ts @@ -0,0 +1,205 @@ +import { TRPCError } from '@trpc/server'; +import { protectedProcedure } from '../auth.js'; +import { withDORetry } from '../../utils/do-retry.js'; +import type { SessionId } from '../../types/ids.js'; +import type { + StartExecutionV2Request, + StartExecutionV2Result, + RetryableResultCode, +} from '../../execution/types.js'; +import { logger, withLogTags } from '../../logger.js'; +import { + InitiateFromPreparedSessionInput, + SendMessageV2Input, + type QueueAckResponse, +} from '../schemas.js'; +import type { CloudAgentSession } from '../../persistence/CloudAgentSession.js'; + +/** Retryable error codes that should map to 503 */ +const RETRYABLE_CODES: readonly RetryableResultCode[] = [ + 'SANDBOX_CONNECT_FAILED', + 'WORKSPACE_SETUP_FAILED', + 'KILO_SERVER_FAILED', + 'WRAPPER_START_FAILED', +] as const; + +function isRetryableCode(code: string): code is RetryableResultCode { + return RETRYABLE_CODES.includes(code as RetryableResultCode); +} + +function throwStartExecutionError( + result: Extract +): never { + // Handle EXECUTION_IN_PROGRESS as 409 Conflict with activeExecutionId in body + if (result.code === 'EXECUTION_IN_PROGRESS') { + throw new TRPCError({ + code: 'CONFLICT', + message: result.error, + cause: { + error: 'EXECUTION_IN_PROGRESS', + message: result.error, + activeExecutionId: result.activeExecutionId, + }, + }); + } + + // Handle retryable errors as 503 Service Unavailable with specific error code + if (isRetryableCode(result.code)) { + throw new TRPCError({ + code: 'SERVICE_UNAVAILABLE', + message: result.error, + cause: { + error: result.code, + message: result.error, + retryable: true, + }, + }); + } + + const code = + result.code === 'NOT_FOUND' + ? 'NOT_FOUND' + : result.code === 'BAD_REQUEST' + ? 'BAD_REQUEST' + : 'INTERNAL_SERVER_ERROR'; + throw new TRPCError({ + code, + message: result.error, + }); +} + +/** + * Get a typed DO stub for CloudAgentSession. + */ +function getSessionStub( + env: { CLOUD_AGENT_SESSION: DurableObjectNamespace }, + doId: DurableObjectId +): DurableObjectStub { + return env.CLOUD_AGENT_SESSION.get(doId); +} + +/** + * V2 session execution handlers. + * These use direct execution via the DO's ExecutionOrchestrator. + */ +export function createSessionExecutionV2Handlers() { + return { + /** + * V2: Initialize from a prepared session. + * + * Uses a session created via prepareSession (for backend-to-backend flows). + * The session must be in 'prepared' state (not yet initiated). + * Returns 409 Conflict if an execution is already in progress. + */ + initiateFromKilocodeSessionV2: protectedProcedure + .input(InitiateFromPreparedSessionInput) + .mutation(async ({ input, ctx }): Promise => { + return withLogTags({ source: 'initiateFromKilocodeSessionV2' }, async () => { + const sessionId = input.cloudAgentSessionId as SessionId; + + logger.setTags({ + userId: ctx.userId, + sessionId, + preparedSession: true, + }); + + logger.info('Initiating V2 session from prepared session'); + + // Get DO stub + const doKey = `${ctx.userId}:${sessionId}`; + const doId = ctx.env.CLOUD_AGENT_SESSION.idFromName(doKey); + + const startRequest: StartExecutionV2Request = { + kind: 'initiatePrepared', + userId: ctx.userId as `user_${string}`, + botId: ctx.botId, + authToken: ctx.authToken, + }; + + const startResult = await withDORetry< + DurableObjectStub, + StartExecutionV2Result + >( + () => getSessionStub(ctx.env, doId), + stub => stub.startExecutionV2(startRequest), + 'startExecutionV2' + ); + + if (!startResult.success) { + throwStartExecutionError(startResult); + } + + logger.info(`V2 prepared session started: ${startResult.status}`); + + return { + executionId: startResult.executionId, + cloudAgentSessionId: sessionId, + status: startResult.status, + streamUrl: `/stream?cloudAgentSessionId=${sessionId}`, + }; + }); + }), + + /** + * V2: Send a message to an existing session. + * + * Sends a follow-up message to an established session. + * Returns 409 Conflict if an execution is already in progress. + */ + sendMessageV2: protectedProcedure + .input(SendMessageV2Input) + .mutation(async ({ input, ctx }): Promise => { + return withLogTags({ source: 'sendMessageV2' }, async () => { + const sessionId = input.cloudAgentSessionId as SessionId; + + logger.setTags({ + userId: ctx.userId, + sessionId, + }); + + logger.info('Sending V2 message to existing session'); + + // Get DO stub + const doKey = `${ctx.userId}:${sessionId}`; + const doId = ctx.env.CLOUD_AGENT_SESSION.idFromName(doKey); + + const startRequest: StartExecutionV2Request = { + kind: 'followup', + userId: ctx.userId as `user_${string}`, + botId: ctx.botId, + prompt: input.prompt, + mode: input.mode, + model: input.model, + autoCommit: input.autoCommit, + condenseOnComplete: input.condenseOnComplete, + tokenOverrides: { + githubToken: input.githubToken, + gitToken: input.gitToken, + }, + }; + + const startResult = await withDORetry< + DurableObjectStub, + StartExecutionV2Result + >( + () => getSessionStub(ctx.env, doId), + stub => stub.startExecutionV2(startRequest), + 'startExecutionV2' + ); + + if (!startResult.success) { + throwStartExecutionError(startResult); + } + + logger.info(`V2 follow-up message started: ${startResult.status}`); + + return { + executionId: startResult.executionId, + cloudAgentSessionId: sessionId, + status: startResult.status, + streamUrl: `/stream?cloudAgentSessionId=${sessionId}`, + }; + }); + }), + }; +} diff --git a/cloud-agent-next/src/router/handlers/session-management.ts b/cloud-agent-next/src/router/handlers/session-management.ts new file mode 100644 index 0000000000..afaf176321 --- /dev/null +++ b/cloud-agent-next/src/router/handlers/session-management.ts @@ -0,0 +1,556 @@ +import { TRPCError } from '@trpc/server'; +import * as z from 'zod'; +import { getSandbox } from '@cloudflare/sandbox'; +import { logger, withLogTags } from '../../logger.js'; +import { generateSandboxId } from '../../sandbox-id.js'; +import type { SessionId, InterruptResult } from '../../types.js'; +import type { SandboxId } from '../../types.js'; +import type { AgentMode } from '../../schema.js'; +import { + InvalidSessionMetadataError, + SessionService, + fetchSessionMetadata, +} from '../../session-service.js'; +import { + cleanupWorkspace, + getSessionWorkspacePath, + getSessionHomePath, + getWrapperLogFilePath, +} from '../../workspace.js'; +import { withDORetry } from '../../utils/do-retry.js'; +import { protectedProcedure, publicProcedure } from '../auth.js'; +import { sessionIdSchema, GetSessionInput, GetSessionOutput } from '../schemas.js'; +import { computeExecutionHealth } from '../../core/execution.js'; + +/** + * Creates session management handlers. + * These handlers manage session lifecycle (delete, interrupt, logs) and health checks. + */ +export function createSessionManagementHandlers() { + const INTERRUPT_GRACE_MS = 2000; + return { + /** + * Delete a session and clean up all associated resources. + * + * Idempotency: + * - Returns success if session doesn't exist (already deleted or never created) + * - Safe to call multiple times for the same session + */ + deleteSession: protectedProcedure + .input( + z.object({ + sessionId: sessionIdSchema.describe('Session ID to delete'), + }) + ) + .mutation(async ({ input, ctx }) => { + return withLogTags({ source: 'deleteSession' }, async () => { + const sessionId = input.sessionId as SessionId; + const { userId, env } = ctx; + + logger.setTags({ userId, sessionId }); + logger.info('Starting session deletion'); + + /* - Sandbox deletion is best-effort because the sandbox may already be evicted or unreachable. + * Failing here shouldn't block metadata cleanup since the sandbox is ephemeral. + * - DO/R2 cleanup is not technically critical either because we have life cycle rules, + * so the metadata is really semi-persistent state (metadata, CLI state). + */ + try { + const metadata = await fetchSessionMetadata(env, userId, sessionId); + + if (!metadata) { + logger.info('Session not found or already deleted'); + return { + success: true, + message: 'Session not found or already deleted', + }; + } + + const sandboxId: SandboxId = await generateSandboxId( + metadata.orgId, + userId, + metadata.botId + ); + + logger.setTags({ sandboxId, orgId: metadata.orgId ?? '(personal)' }); + + const sandbox = getSandbox(env.Sandbox, sandboxId); + + // Clean up workspace directories before deleting sandbox session + // This prevents disk accumulation from abandoned sessions + const workspacePath = getSessionWorkspacePath(metadata.orgId, userId, sessionId); + const sessionHome = getSessionHomePath(sessionId); + + try { + const session = await sandbox.getSession(sessionId); + await cleanupWorkspace(session, workspacePath, sessionHome); + logger.info('Workspace directories cleaned up'); + } catch (error) { + // Log but don't fail - workspace cleanup is best-effort + logger + .withFields({ + error: error instanceof Error ? error.message : String(error), + }) + .warn('Failed to clean up workspace directories, continuing with deletion'); + } + + await sandbox + .deleteSession(sessionId) + .then(() => logger.info('Cloudflare sandbox session deleted')) + .catch(error => { + // Log but don't fail - sandbox cleanup is best-effort + logger + .withFields({ + error: error instanceof Error ? error.message : String(error), + }) + .warn('Failed to delete Cloudflare sandbox session, continuing with cleanup'); + }); + + try { + const doKey = `${userId}:${sessionId}`; + await withDORetry( + () => env.CLOUD_AGENT_SESSION.get(env.CLOUD_AGENT_SESSION.idFromName(doKey)), + stub => stub.deleteSession(), + 'deleteSession' + ); + logger.info('Session metadata destroyed'); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + logger.withFields({ error: errorMsg }).error('Failed to destroy session metadata'); + + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Failed to clean up session metadata`, + }); + } + + logger.info('Session deletion completed successfully'); + return { + success: true, + }; + } catch (error) { + if (error instanceof TRPCError) { + throw error; + } + + const errorMsg = error instanceof Error ? error.message : String(error); + logger.withFields({ error: errorMsg }).error('Session deletion failed'); + + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Failed to delete session: ${errorMsg}`, + }); + } + }); + }), + + /** + * Interrupt a running session by killing all associated kilocode processes. + * + * This endpoint allows clients to stop running executions in a session without + * deleting the session itself. Useful for canceling long-running or stuck operations. + * + * Idempotency: + * - Returns success even if no processes are found (already stopped or none running) + * - Safe to call multiple times for the same session + */ + interruptSession: protectedProcedure + .input( + z.object({ + sessionId: sessionIdSchema.describe('Session ID to interrupt'), + }) + ) + .mutation(async ({ input, ctx }): Promise => { + return withLogTags({ source: 'interruptSession' }, async () => { + const sessionId = input.sessionId as SessionId; + const { userId, env } = ctx; + + logger.setTags({ userId, sessionId }); + logger.info('Starting session interruption'); + + try { + const metadata = await fetchSessionMetadata(env, userId, sessionId); + + if (!metadata) { + logger.info('Session not found'); + return { + success: false, + killedProcessIds: [], + failedProcessIds: [], + message: 'Session not found', + }; + } + + const sandboxId: SandboxId = await generateSandboxId( + metadata.orgId, + userId, + metadata.botId + ); + + logger.setTags({ sandboxId, orgId: metadata.orgId ?? '(personal)' }); + + const sandbox = getSandbox(env.Sandbox, sandboxId); + + // Build session context for interrupt service + const sessionService = new SessionService(); + const context = sessionService.buildContext({ + sandboxId, + orgId: metadata.orgId, + userId, + sessionId, + upstreamBranch: metadata.upstreamBranch, + botId: metadata.botId, + }); + + // Mark session as interrupted in DO before killing processes (with retry) + // This signals the streaming generator to stop + const doKey = `${userId}:${sessionId}`; + const getStub = () => + env.CLOUD_AGENT_SESSION.get(env.CLOUD_AGENT_SESSION.idFromName(doKey)); + + await withDORetry(getStub, stub => stub.markAsInterrupted(), 'markAsInterrupted'); + + const interruptResult = await withDORetry( + getStub, + stub => stub.interruptExecution(), + 'interruptExecution' + ); + + if (!interruptResult.success) { + logger + .withFields({ message: interruptResult.message ?? 'No active execution' }) + .info('No active execution to interrupt via wrapper'); + } + + await scheduler.wait(INTERRUPT_GRACE_MS); + + const activeExecutionId = await withDORetry( + getStub, + stub => stub.getActiveExecutionId(), + 'getActiveExecutionId' + ); + + // Get or create the session to use for killing processes + const session = await sessionService.getOrCreateSession( + sandbox, + context, + env, + ctx.authToken, + metadata.orgId + ); + + // Kill all kilocode processes in this session + // Use pkill method as a temporary workaround for sandbox API reliability issues + const usePkill = true; + const result = await SessionService.interrupt( + sandbox, + session, + context, + usePkill, + activeExecutionId ?? undefined + ); + + logger + .withFields({ + killedCount: result.killedProcessIds.length, + failedCount: result.failedProcessIds.length, + }) + .info('Session interruption completed'); + + return result; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + logger.withFields({ error: errorMsg }).error('Session interruption failed'); + + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Failed to interrupt session: ${errorMsg}`, + }); + } + }); + }), + + /** + * Get session metadata. + * + * Returns sanitized session metadata (no secrets) including lifecycle timestamps. + * Useful for frontend idempotency - checking if a session was already initiated + * before a page refresh. + * + * Security: + * - Excludes: githubToken, gitToken, envVars values, setupCommands, mcpServers configs + * - Includes: counts of envVars, setupCommands, mcpServers for debugging + */ + getSession: protectedProcedure + .input(GetSessionInput) + .output(GetSessionOutput) + .query(async ({ input, ctx }) => { + return withLogTags({ source: 'getSession' }, async () => { + const sessionId = input.cloudAgentSessionId as SessionId; + const { userId, env } = ctx; + + logger.setTags({ userId, sessionId }); + logger.info('Fetching session metadata'); + + // Get DO stub keyed by userId:sessionId for user isolation + const doKey = `${userId}:${sessionId}`; + const getStub = () => + env.CLOUD_AGENT_SESSION.get(env.CLOUD_AGENT_SESSION.idFromName(doKey)); + + // Fetch metadata with retry + const metadata = await withDORetry(getStub, s => s.getMetadata(), 'getMetadata'); + + // Handle not found + if (!metadata) { + logger.info('Session not found'); + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Session not found', + }); + } + + // Fetch execution state from DO + const activeExecutionId = await withDORetry( + getStub, + s => s.getActiveExecutionId(), + 'getActiveExecutionId' + ); + + // Get active execution metadata if there's an active execution + let activeExecutionStatus: + | 'pending' + | 'running' + | 'completed' + | 'failed' + | 'interrupted' + | null = null; + let execution: { + startedAt: number; + lastHeartbeat?: number; + processId?: string; + error?: string; + } | null = null; + + if (activeExecutionId) { + const executionData = await withDORetry( + getStub, + s => s.getExecution(activeExecutionId), + 'getExecution' + ); + if (executionData) { + activeExecutionStatus = executionData.status; + execution = { + startedAt: executionData.startedAt, + lastHeartbeat: executionData.lastHeartbeat, + processId: executionData.processId, + error: executionData.error, + }; + } + } + + // Compute sandboxId for log correlation (uses same hash as execution) + const sandboxId = await generateSandboxId(metadata.orgId, userId, metadata.botId); + + logger.setTags({ sandboxId, orgId: metadata.orgId ?? '(personal)' }); + logger.info('Session metadata retrieved successfully'); + + // Compute execution health if there's an active execution + const executionHealth = + execution && activeExecutionStatus + ? computeExecutionHealth( + activeExecutionStatus, + execution.startedAt, + execution.lastHeartbeat + ) + : null; + + // Sanitize and return safe fields only (no tokens/secrets) + return { + sessionId: metadata.sessionId, + kiloSessionId: metadata.kiloSessionId, + userId: metadata.userId, + orgId: metadata.orgId, + sandboxId, + + githubRepo: metadata.githubRepo, + gitUrl: metadata.gitUrl, + // githubToken: OMITTED + // gitToken: OMITTED + + prompt: metadata.prompt, + // Cast mode since CloudAgentSessionState.mode is string | undefined + // but was validated at storage time to be a valid AgentMode + mode: metadata.mode as AgentMode | undefined, + model: metadata.model, + autoCommit: metadata.autoCommit, + upstreamBranch: metadata.upstreamBranch, + + // Counts only, no actual values + envVarCount: + metadata.envVars === undefined ? undefined : Object.keys(metadata.envVars).length, + setupCommandCount: metadata.setupCommands?.length, + mcpServerCount: + metadata.mcpServers === undefined + ? undefined + : Object.keys(metadata.mcpServers).length, + + // Execution status (grouped for cleaner API) + execution: + activeExecutionId && activeExecutionStatus && execution + ? { + id: activeExecutionId, + status: activeExecutionStatus, + startedAt: execution.startedAt, + lastHeartbeat: execution.lastHeartbeat ?? null, + processId: execution.processId ?? null, + error: execution.error ?? null, + health: executionHealth ?? 'unknown', + } + : null, + + // Lifecycle timestamps (critical for idempotency) + preparedAt: metadata.preparedAt, + initiatedAt: metadata.initiatedAt, + + callbackTarget: metadata.callbackTarget, + + timestamp: metadata.timestamp, + version: metadata.version, + }; + }); + }), + + /** + * Get the wrapper log file content for a specific execution. + * + * Returns the contents of /tmp/kilocode-wrapper-{executionId}.log from the sandbox. + * This is useful for debugging wrapper startup issues. + */ + getWrapperLogs: protectedProcedure + .input( + z.object({ + sessionId: sessionIdSchema.describe('Session ID'), + executionId: z.string().describe('Execution ID to get wrapper logs for'), + }) + ) + .query(async ({ input, ctx }) => { + return withLogTags({ source: 'getWrapperLogs' }, async () => { + const sessionId = input.sessionId as SessionId; + const { executionId } = input; + const { userId, env } = ctx; + + logger.setTags({ userId, sessionId, executionId }); + logger.info('Fetching wrapper logs'); + + // Fetch session metadata to get sandboxId and validate ownership + const sessionService = new SessionService(); + let sandboxId: SandboxId; + try { + sandboxId = await sessionService.getSandboxIdForSession(env, userId, sessionId); + } catch (error) { + if (error instanceof InvalidSessionMetadataError) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: `Session metadata is invalid or unavailable. Please re-initiate session ${sessionId}.`, + }); + } + + if (error instanceof TRPCError) { + throw error; + } + + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Failed to load session metadata for ${sessionId}.`, + }); + } + + logger.setTags({ sandboxId, orgId: sessionService.metadata?.orgId ?? '(personal)' }); + + const sandbox = getSandbox(env.Sandbox, sandboxId); + const logFilePath = getWrapperLogFilePath(executionId); + + // Get or create a session to read the file + const context = sessionService.buildContext({ + sandboxId, + orgId: sessionService.metadata?.orgId, + userId, + sessionId, + botId: sessionService.metadata?.botId, + }); + + const session = await sessionService.getOrCreateSession( + sandbox, + context, + env, + ctx.authToken, + sessionService.metadata?.orgId + ); + + logger.withTags({ logFilePath }).debug('Reading wrapper log file'); + + // Fetch running processes for this execution (best-effort) + let processes: Array<{ pid: number; command: string; status: string }> | undefined; + try { + type ProcessInfo = { id: string; status: string; command: string }; + const allProcesses = (await sandbox.listProcesses()) as ProcessInfo[]; + // Filter for processes belonging to this execution + // The wrapper command includes --execution-id= + processes = allProcesses + .filter((p: ProcessInfo) => p.command.includes(executionId)) + .map((p: ProcessInfo) => ({ + pid: parseInt(p.id, 10) || 0, + command: p.command, + status: p.status, + })); + } catch (err) { + // Sandbox may not be available (evicted, not started, etc.) + logger.debug('Could not fetch sandbox processes', { + error: err instanceof Error ? err.message : String(err), + }); + } + + try { + const fileInfo = await session.readFile(logFilePath, { encoding: 'utf-8' }); + + logger.info('Successfully retrieved wrapper logs'); + + return { + content: fileInfo.content, + sessionId, + executionId, + processes, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + + // Check if file doesn't exist + if (errorMsg.includes('ENOENT') || errorMsg.includes('not found')) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: `No wrapper log file found for execution ${executionId}. The wrapper may not have started or may have crashed before logging.`, + }); + } + + logger.withFields({ error: errorMsg }).error('Failed to read wrapper log file'); + + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Failed to read wrapper log file: ${errorMsg}`, + }); + } + }); + }), + + /** + * Health check endpoint + */ + health: publicProcedure.query(() => { + return { + status: 'ok', + timestamp: new Date().toISOString(), + version: '1.0.0-trpc', + }; + }), + }; +} diff --git a/cloud-agent-next/src/router/handlers/session-prepare.ts b/cloud-agent-next/src/router/handlers/session-prepare.ts new file mode 100644 index 0000000000..b144103e28 --- /dev/null +++ b/cloud-agent-next/src/router/handlers/session-prepare.ts @@ -0,0 +1,467 @@ +import { TRPCError } from '@trpc/server'; +import { getSandbox } from '@cloudflare/sandbox'; +import { logger, withLogTags } from '../../logger.js'; +import { + generateSessionId, + SessionService, + determineBranchName, + runSetupCommands, + writeMCPSettings, +} from '../../session-service.js'; +import { InstallationLookupService } from '../../services/installation-lookup-service.js'; +import { GitHubTokenService } from '../../services/github-token-service.js'; +import { internalApiProtectedProcedure } from '../auth.js'; +import { + PrepareSessionInput, + PrepareSessionOutput, + UpdateSessionInput, + UpdateSessionOutput, +} from '../schemas.js'; +import { generateSandboxId } from '../../sandbox-id.js'; +import type { SandboxId } from '../../types.js'; +import { setupWorkspace, cloneGitHubRepo, cloneGitRepo, manageBranch } from '../../workspace.js'; +import { ensureKiloServer, createKiloCliSession } from '../../kilo/server-manager.js'; +import { withDORetry } from '../../utils/do-retry.js'; + +type SessionPrepareHandlers = { + prepareSession: typeof prepareSessionHandler; + updateSession: typeof updateSessionHandler; +}; + +function setUpdateValue(updates: Record, key: string, value: unknown): void { + if (value !== undefined) { + updates[key] = value; + } +} + +function setCollectionUpdate( + updates: Record, + key: string, + value: T | undefined, + isEmpty: (value: T) => boolean +): void { + if (value === undefined) { + return; + } + + updates[key] = isEmpty(value) ? null : value; +} + +/** + * Creates session preparation handlers. + * These handlers are protected by internal API authentication (backend-to-backend). + * They support the prepare-then-initiate flow for AI Agents. + */ +export function createSessionPrepareHandlers(): SessionPrepareHandlers { + return { + prepareSession: prepareSessionHandler, + updateSession: updateSessionHandler, + }; +} + +/** + * Prepare a new session for later initiation. + * + * This creates a fully prepared session with: + * - Workspace directories created + * - Git repository cloned + * - Branch created/checked out + * - Setup commands executed + * - MCP settings configured + * - Kilo server started + * - Kilo CLI session created + * + * The session can then be updated via updateSession and initiated via startExecutionV2. + * + * Flow: + * 1. Generate cloudAgentSessionId and sandboxId + * 2. Get sandbox and setup workspace + * 3. Clone repository and create branch + * 4. Run setup commands and configure MCP + * 5. Start kilo server and create CLI session + * 6. Store all metadata in Durable Object + * 7. Return { cloudAgentSessionId, kiloSessionId } + * + * Protected by internal API authentication (x-internal-api-key header). + */ +const prepareSessionHandler = internalApiProtectedProcedure + .input(PrepareSessionInput) + .output(PrepareSessionOutput) + .mutation(async ({ input, ctx }) => { + return withLogTags({ source: 'prepareSession' }, async () => { + const sessionService = new SessionService(); + + // 1. Generate new cloudAgentSessionId and sandboxId + const cloudAgentSessionId = generateSessionId(); + const sandboxId: SandboxId = await generateSandboxId( + input.kilocodeOrganizationId, + ctx.userId, + ctx.botId + ); + + logger.setTags({ + cloudAgentSessionId, + userId: ctx.userId, + orgId: input.kilocodeOrganizationId ?? '(personal)', + sandboxId, + }); + logger.info('Preparing new session with workspace setup'); + + // 2. Lookup GitHub installation ID from database when using a GitHub repo without a token + let resolvedInstallationId: string | undefined; + let resolvedGithubAppType: 'standard' | 'lite' | undefined; + if (input.githubRepo && !input.githubToken) { + const lookupService = new InstallationLookupService(ctx.env); + logger + .withFields({ hyperdriveConfigured: lookupService.isConfigured() }) + .info('Checking for GitHub installation ID lookup'); + if (lookupService.isConfigured()) { + try { + const result = await lookupService.findInstallationId({ + githubRepo: input.githubRepo, + userId: ctx.userId, + orgId: input.kilocodeOrganizationId, + }); + logger + .withFields({ + found: !!result, + githubRepo: input.githubRepo, + userId: ctx.userId, + orgId: input.kilocodeOrganizationId, + }) + .info('Installation lookup result'); + if (result) { + resolvedInstallationId = result.installationId; + resolvedGithubAppType = result.githubAppType; + logger + .withFields({ + installationId: result.installationId, + accountLogin: result.accountLogin, + githubAppType: result.githubAppType, + }) + .info('Resolved GitHub installation ID from database'); + } + } catch (lookupError) { + logger + .withFields({ + error: lookupError instanceof Error ? lookupError.message : String(lookupError), + }) + .error('Failed to lookup GitHub installation ID'); + // Don't throw - fall through to the validation error + } + } + } + + // Validate that we have auth for GitHub repo + if (input.githubRepo && !input.githubToken && !resolvedInstallationId) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'GitHub token or active app installation required for this repository', + }); + } + + // Generate token from installation ID if using GitHub App auth + let resolvedGithubToken = input.githubToken; + if (input.githubRepo && !input.githubToken && resolvedInstallationId) { + const tokenService = new GitHubTokenService(ctx.env); + if (!tokenService.isConfigured(resolvedGithubAppType ?? 'standard')) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'GitHub App credentials not configured', + }); + } + try { + resolvedGithubToken = await tokenService.getToken( + resolvedInstallationId, + resolvedGithubAppType ?? 'standard' + ); + logger.info('Generated GitHub token from installation'); + } catch (tokenError) { + logger + .withFields({ + error: tokenError instanceof Error ? tokenError.message : String(tokenError), + installationId: resolvedInstallationId, + }) + .error('Failed to generate GitHub token from installation'); + throw new TRPCError({ + code: 'BAD_GATEWAY', + message: `Failed to generate GitHub token: ${tokenError instanceof Error ? tokenError.message : String(tokenError)}`, + }); + } + } + + // NOTE: Backend session creation (createKiloSessionInBackend) is temporarily disabled. + // The kiloSessionId will now come from the kilo CLI server's POST /session API. + // This can be re-enabled later if backend analytics/tracking is needed. + // const gitUrlForBackend = input.githubRepo + // ? `https://github.com/${input.githubRepo}` + // : input.gitUrl; + // let backendKiloSessionId: string; + // try { + // backendKiloSessionId = await sessionService.createKiloSessionInBackend( + // cloudAgentSessionId, + // ctx.authToken, + // ctx.env, + // input.kilocodeOrganizationId, + // input.mode, + // input.model, + // gitUrlForBackend + // ); + // } catch (error) { + // logger + // .withFields({ error: error instanceof Error ? error.message : String(error) }) + // .error('Failed to create cliSession in backend'); + // throw new TRPCError({ + // code: 'INTERNAL_SERVER_ERROR', + // message: `Failed to create session in backend: ${ + // error instanceof Error ? error.message : String(error) + // }`, + // }); + // } + + // 3. Get sandbox + logger.info('Getting sandbox'); + const sandbox = getSandbox(ctx.env.Sandbox, sandboxId, { sleepAfter: 900 }); + + // 4. Setup workspace directories + logger.info('Setting up workspace directories'); + const { workspacePath, sessionHome } = await setupWorkspace( + sandbox, + ctx.userId, + input.kilocodeOrganizationId, + cloudAgentSessionId + ); + + // 5. Build context and create execution session + const branchName = determineBranchName(cloudAgentSessionId, input.upstreamBranch); + const context = sessionService.buildContext({ + sandboxId, + orgId: input.kilocodeOrganizationId, + userId: ctx.userId, + sessionId: cloudAgentSessionId, + workspacePath, + sessionHome, + githubRepo: input.githubRepo, + githubToken: resolvedGithubToken, // Use resolved token (from input or generated from installation) + gitUrl: input.gitUrl, + gitToken: input.gitToken, + upstreamBranch: input.upstreamBranch, + botId: ctx.botId, + }); + + logger.info('Creating execution session'); + const session = await sessionService.getOrCreateSession( + sandbox, + context, + ctx.env, + ctx.authToken, + input.model, + input.kilocodeOrganizationId, + input.encryptedSecrets, + undefined, // createdOnPlatform + input.appendSystemPrompt + ); + + // 6. Clone repository + logger.info('Cloning repository'); + if (input.gitUrl) { + await cloneGitRepo(session, workspacePath, input.gitUrl, input.gitToken); + } else if (input.githubRepo) { + await cloneGitHubRepo(session, workspacePath, input.githubRepo, resolvedGithubToken, { + GITHUB_APP_SLUG: ctx.env.GITHUB_APP_SLUG, + GITHUB_APP_BOT_USER_ID: ctx.env.GITHUB_APP_BOT_USER_ID, + }); + } else { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Either githubRepo or gitUrl must be provided', + }); + } + + // 7. Branch management + logger + .withFields({ branchName, upstreamBranch: input.upstreamBranch }) + .info('Managing branch'); + if (input.upstreamBranch) { + // For upstream branches, use manageBranch (verifies exists remotely) + await manageBranch(session, workspacePath, branchName, true); + } else { + // For session branches, create directly (can't exist remotely with UUID-based name) + const result = await session.exec(`cd ${workspacePath} && git checkout -b '${branchName}'`); + if (result.exitCode !== 0) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Failed to create branch ${branchName}: ${result.stderr || result.stdout}`, + }); + } + } + + // 8. Run setup commands + if (input.setupCommands && input.setupCommands.length > 0) { + logger.withFields({ count: input.setupCommands.length }).info('Running setup commands'); + await runSetupCommands(session, context, input.setupCommands, true); // fail-fast + } + + // 9. Write MCP settings + if (input.mcpServers && Object.keys(input.mcpServers).length > 0) { + logger + .withFields({ count: Object.keys(input.mcpServers).length }) + .info('Writing MCP settings'); + await writeMCPSettings(sandbox, sessionHome, input.mcpServers); + } + + // 10. Start kilo server + logger.info('Starting kilo server'); + const kiloServerPort = await ensureKiloServer( + sandbox, + session, + cloudAgentSessionId, + workspacePath + ); + + // 11. Create kilo CLI session + logger.info('Creating kilo CLI session'); + const kiloSession = await createKiloCliSession(session, kiloServerPort); + const kiloSessionId = kiloSession.id; + + logger.setTags({ kiloSessionId }); + logger.info('Created kilo CLI session'); + + // 12. Get DO stub and store metadata + const doId = ctx.env.CLOUD_AGENT_SESSION.idFromName(`${ctx.userId}:${cloudAgentSessionId}`); + const stub = ctx.env.CLOUD_AGENT_SESSION.get(doId); + + const prepareResult = await stub.prepare({ + sessionId: cloudAgentSessionId, + userId: ctx.userId, + orgId: input.kilocodeOrganizationId, + botId: ctx.botId, + kiloSessionId, + prompt: input.prompt, + mode: input.mode, + model: input.model, + kilocodeToken: ctx.authToken, + githubRepo: input.githubRepo, + githubToken: input.githubToken, + githubInstallationId: resolvedInstallationId, + githubAppType: resolvedGithubAppType, + gitUrl: input.gitUrl, + gitToken: input.gitToken, + envVars: input.envVars, + encryptedSecrets: input.encryptedSecrets, + setupCommands: input.setupCommands, + mcpServers: input.mcpServers, + upstreamBranch: input.upstreamBranch, + autoCommit: input.autoCommit, + condenseOnComplete: input.condenseOnComplete, + appendSystemPrompt: input.appendSystemPrompt, + callbackTarget: input.callbackTarget, + images: input.images, + // Workspace metadata + workspacePath, + sessionHome, + branchName, + sandboxId, + }); + + if (!prepareResult.success) { + logger.withFields({ error: prepareResult.error }).error('Failed to prepare session in DO'); + throw new TRPCError({ + code: 'BAD_REQUEST', + message: prepareResult.error ?? 'Failed to prepare session', + }); + } + + // 13. Record kilo server activity for idle timeout tracking + try { + await withDORetry( + () => ctx.env.CLOUD_AGENT_SESSION.get(doId), + s => s.recordKiloServerActivity(), + 'recordKiloServerActivity' + ); + } catch (error) { + // Non-fatal - log but continue + logger + .withFields({ error: error instanceof Error ? error.message : String(error) }) + .warn('Failed to record kilo server activity'); + } + + logger.info('Session prepared successfully'); + + // 14. Return both IDs + return { cloudAgentSessionId, kiloSessionId }; + }); + }); + +/** + * Update a prepared (but not yet initiated) session. + * + * This allows modifying session configuration before initiation. + * - undefined: skip field (no change) + * - null: clear field + * - value: set field to value + * - For collections, empty array/object clears them + * + * Protected by internal API authentication (x-internal-api-key header). + */ +const updateSessionHandler = internalApiProtectedProcedure + .input(UpdateSessionInput) + .output(UpdateSessionOutput) + .mutation(async ({ input, ctx }) => { + return withLogTags({ source: 'updateSession' }, async () => { + logger.setTags({ + cloudAgentSessionId: input.cloudAgentSessionId, + userId: ctx.userId, + }); + logger.info('Updating session'); + + // 1. Get DO stub + const doId = ctx.env.CLOUD_AGENT_SESSION.idFromName( + `${ctx.userId}:${input.cloudAgentSessionId}` + ); + const stub = ctx.env.CLOUD_AGENT_SESSION.get(doId); + + // 2. Build update object + const updates: Record = {}; + + // Scalar fields - pass through as-is (undefined skips, null clears, value sets) + setUpdateValue(updates, 'mode', input.mode); + setUpdateValue(updates, 'model', input.model); + setUpdateValue(updates, 'githubToken', input.githubToken); + setUpdateValue(updates, 'gitToken', input.gitToken); + setUpdateValue(updates, 'upstreamBranch', input.upstreamBranch); + setUpdateValue(updates, 'autoCommit', input.autoCommit); + setUpdateValue(updates, 'condenseOnComplete', input.condenseOnComplete); + setUpdateValue(updates, 'appendSystemPrompt', input.appendSystemPrompt); + setUpdateValue(updates, 'callbackTarget', input.callbackTarget); + + // Collection fields - empty = clear (converted to null for DO) + setCollectionUpdate(updates, 'envVars', input.envVars, value => { + return Object.keys(value).length === 0; + }); + setCollectionUpdate(updates, 'encryptedSecrets', input.encryptedSecrets, value => { + return Object.keys(value).length === 0; + }); + setCollectionUpdate(updates, 'setupCommands', input.setupCommands, value => { + return value.length === 0; + }); + setCollectionUpdate(updates, 'mcpServers', input.mcpServers, value => { + return Object.keys(value).length === 0; + }); + + // 3. Call tryUpdate() on DO + const result = await stub.tryUpdate(updates); + + if (!result.success) { + logger.withFields({ error: result.error }).error('Failed to update session'); + throw new TRPCError({ + code: 'BAD_REQUEST', + message: result.error ?? 'Failed to update session', + }); + } + + logger.info('Session updated successfully'); + + return { success: true }; + }); + }); diff --git a/cloud-agent-next/src/router/schemas.ts b/cloud-agent-next/src/router/schemas.ts new file mode 100644 index 0000000000..7e0840eb04 --- /dev/null +++ b/cloud-agent-next/src/router/schemas.ts @@ -0,0 +1,402 @@ +import * as z from 'zod'; +import { sessionIdSchema, githubRepoSchema, gitUrlSchema, envVarsSchema } from '../types.js'; +import { + MCPServerConfigSchema, + branchNameSchema, + EncryptedSecretEnvelopeSchema, + EncryptedSecretsSchema, + CallbackTargetSchema, +} from '../persistence/schemas.js'; +import { AgentModeSchema, Limits } from '../schema.js'; + +// Re-export schemas from types.ts and persistence/schemas.ts for convenience +export { sessionIdSchema, githubRepoSchema, gitUrlSchema, envVarsSchema }; +export { MCPServerConfigSchema, branchNameSchema }; +export { AgentModeSchema, Limits }; +export { EncryptedSecretEnvelopeSchema, EncryptedSecretsSchema, CallbackTargetSchema }; + +// Re-export types +export type { EncryptedSecretEnvelope, EncryptedSecrets } from '../persistence/schemas.js'; + +/** + * Schema for image attachments that will be downloaded from R2 to the sandbox. + * Images are stored in R2 at path: {bucket}/{userId}/{path}/{filename} + */ +export const ImagesSchema = z.object({ + path: z + .string() + .min(1) + .describe('R2 path prefix under the user ID (e.g., "app-builder/msg-uuid")'), + files: z + .array(z.string().min(1)) + .min(1) + .describe('Ordered array of specific filenames to download'), +}); +export type Images = z.infer; + +/** + * Base prompt payload schema used by all execution endpoints. + * Contains the essential fields for Kilocode execution. + */ +export const PromptPayload = z.object({ + prompt: z.string().min(1, 'Prompt is required').describe('The task prompt for Kilo Code'), + mode: AgentModeSchema.describe('Kilo Code execution mode (required)'), + model: z.string().min(1, 'Model is required').describe('AI model to use (required)'), +}); + +/** + * Shared validation: ensure exactly one of githubRepo or gitUrl is provided. + * Used in .refine() for input schemas that support both git sources. + */ +export function validateGitSource( + data: T +): boolean { + const hasGithubRepo = !!data.githubRepo; + const hasGitUrl = !!data.gitUrl; + return (hasGithubRepo || hasGitUrl) && !(hasGithubRepo && hasGitUrl); +} + +const rejectCustomMode = (data: { mode?: string | null }) => data.mode !== 'custom'; + +const requiresAppendSystemPrompt = (data: { + mode?: string | null; + appendSystemPrompt?: string | null; +}) => data.mode !== 'custom' || Boolean(data.appendSystemPrompt?.trim()); + +/** + * Input schema for initiateFromKilocodeSessionV2 with prepared sessions. + * Client provides only cloudAgentSessionId - all other params come from DO metadata. + */ +export const InitiateFromPreparedSessionInput = z.object({ + cloudAgentSessionId: sessionIdSchema.describe('Cloud-agent session ID from prepareSession'), +}); + +/** + * V2 input schema for sendMessageV2 endpoint. + * Uses cloudAgentSessionId naming for consistency with prepare/initiate V2. + */ +export const SendMessageV2Input = z + .object({ + cloudAgentSessionId: sessionIdSchema.describe( + 'Cloud agent session ID (required for V2 endpoints)' + ), + autoCommit: z + .boolean() + .optional() + .default(false) + .describe('Automatically commit and push changes after execution'), + condenseOnComplete: z + .boolean() + .optional() + .default(false) + .describe('Automatically condense context after execution completes'), + githubToken: z + .string() + .optional() + .describe( + 'GitHub Personal Access Token - if provided and applicable, updates the session token and git remote. Ignored for generic git repos.' + ), + gitToken: z + .string() + .optional() + .describe( + 'Git token for authentication - if provided and session uses gitUrl, updates the session token and git remote. Ignored for GitHub repos.' + ), + images: ImagesSchema.optional().describe( + 'Optional image attachments to download from R2 to the sandbox' + ), + }) + .extend(PromptPayload.shape) + .refine(rejectCustomMode, { + message: 'custom mode requires appendSystemPrompt (use prepareSession/updateSession)', + path: ['mode'], + }); + +/** + * Input schema for prepareSession endpoint. + * Creates a session in "prepared" state for later initiation. + * Used by backend-to-backend flows. + */ +export const PrepareSessionInput = z + .object({ + prompt: z + .string() + .min(1) + .max(Limits.MAX_PROMPT_LENGTH) + .describe('The task prompt for Kilo Code'), + mode: AgentModeSchema.describe('Kilo Code execution mode'), + model: z.string().min(1).describe('AI model to use'), + + // Repository - one of these pairs required + githubRepo: githubRepoSchema + .optional() + .describe('GitHub repository in format org/repo (mutually exclusive with gitUrl)'), + githubToken: z + .string() + .optional() + .describe('GitHub Personal Access Token for private repositories'), + gitUrl: gitUrlSchema + .optional() + .describe('Generic git repository HTTPS URL (mutually exclusive with githubRepo)'), + gitToken: z.string().optional().describe('Git token for authentication with generic git repos'), + + // Optional configuration + envVars: envVarsSchema.optional().describe('Environment variables to inject into the session'), + encryptedSecrets: EncryptedSecretsSchema.optional().describe( + 'Encrypted secret env vars (from agent environment profiles). These are stored encrypted in the DO and decrypted only at execution time.' + ), + setupCommands: z + .array(z.string().max(Limits.MAX_SETUP_COMMAND_LENGTH)) + .max(Limits.MAX_SETUP_COMMANDS) + .optional() + .describe('Setup commands to run during session initialization'), + mcpServers: z + .record(z.string().max(100), MCPServerConfigSchema) + .refine(obj => Object.keys(obj).length <= Limits.MAX_MCP_SERVERS, { + message: `Maximum ${Limits.MAX_MCP_SERVERS} MCP servers allowed`, + }) + .optional() + .describe('MCP server configurations'), + upstreamBranch: branchNameSchema + .optional() + .describe('Optional upstream branch to checkout during session initialization'), + autoCommit: z + .boolean() + .optional() + .describe('Automatically commit and push changes after execution'), + condenseOnComplete: z + .boolean() + .optional() + .describe('Automatically condense context after execution completes'), + appendSystemPrompt: z + .string() + .max(10000) + .optional() + .describe('Custom text to append to the system prompt'), + + // Callback configuration + callbackTarget: CallbackTargetSchema.optional().describe( + 'Optional callback target configuration for execution completion notifications' + ), + + // Organization context + kilocodeOrganizationId: z + .string() + .uuid() + .optional() + .describe('Organization ID (UUID, optional)'), + + // Image attachments + images: ImagesSchema.optional().describe( + 'Optional image attachments to download from R2 to the sandbox' + ), + }) + .refine(validateGitSource, { + message: 'Must provide either githubRepo or gitUrl, but not both', + path: ['githubRepo'], + }) + .refine(requiresAppendSystemPrompt, { + message: 'appendSystemPrompt is required when mode is custom', + path: ['appendSystemPrompt'], + }); + +/** Output schema for prepareSession endpoint */ +export const PrepareSessionOutput = z.object({ + cloudAgentSessionId: z.string().describe('The generated cloud-agent session ID'), + kiloSessionId: z.string().describe('The generated Kilo CLI session ID'), +}); + +/** + * Input schema for updateSession endpoint. + * Updates a prepared (but not yet initiated) session. + * - undefined: skip field (no change) + * - null: clear field + * - value: set field to value + * - For collections, empty array/object clears them + */ +export const UpdateSessionInput = z + .object({ + cloudAgentSessionId: sessionIdSchema.describe('Cloud-agent session ID to update'), + + // Scalar fields - null to clear, value to set, undefined to skip + mode: AgentModeSchema.nullable().optional().describe('Mode to set (null to clear)'), + model: z.string().min(1).nullable().optional().describe('Model to set (null to clear)'), + githubToken: z.string().nullable().optional().describe('GitHub token to set (null to clear)'), + gitToken: z.string().nullable().optional().describe('Git token to set (null to clear)'), + upstreamBranch: branchNameSchema + .nullable() + .optional() + .describe('Upstream branch to set (null to clear)'), + autoCommit: z.boolean().nullable().optional().describe('Auto-commit setting (null to clear)'), + condenseOnComplete: z + .boolean() + .nullable() + .optional() + .describe('Condense context setting (null to clear)'), + appendSystemPrompt: z + .string() + .max(10000) + .nullable() + .optional() + .describe('Custom text to append to the system prompt (null to clear)'), + + // Collection fields - empty to clear, value to set, undefined to skip + envVars: envVarsSchema.optional().describe('Environment variables (empty object to clear)'), + encryptedSecrets: EncryptedSecretsSchema.optional().describe( + 'Encrypted secret env vars (empty object to clear)' + ), + setupCommands: z + .array(z.string().max(Limits.MAX_SETUP_COMMAND_LENGTH)) + .max(Limits.MAX_SETUP_COMMANDS) + .optional() + .describe('Setup commands (empty array to clear)'), + mcpServers: z + .record(z.string().max(100), MCPServerConfigSchema) + .refine(obj => Object.keys(obj).length <= Limits.MAX_MCP_SERVERS, { + message: `Maximum ${Limits.MAX_MCP_SERVERS} MCP servers allowed`, + }) + .optional() + .describe('MCP servers (empty object to clear)'), + callbackTarget: CallbackTargetSchema.nullable() + .optional() + .describe('Callback target (null to clear, value to set, undefined to skip)'), + }) + .refine(requiresAppendSystemPrompt, { + message: 'appendSystemPrompt is required when mode is custom', + path: ['appendSystemPrompt'], + }); + +/** Output schema for updateSession endpoint */ +export const UpdateSessionOutput = z.object({ + success: z.boolean().describe('Whether the update was successful'), +}); + +/** + * Input schema for getSession endpoint. + * Retrieves sanitized session metadata (no secrets). + */ +export const GetSessionInput = z.object({ + cloudAgentSessionId: sessionIdSchema.describe('Cloud-agent session ID to retrieve'), +}); + +/** + * Output schema for getSession endpoint. + * Returns sanitized session metadata with lifecycle timestamps for idempotency. + * Explicitly excludes secrets (tokens, env var values, setup commands, MCP configs). + */ +/** + * Execution status object for getSession response. + * Groups all execution-related fields for cleaner API response. + */ +export const ExecutionStatusSchema = z + .object({ + id: z.string().describe('Execution ID currently running'), + status: z + .enum(['pending', 'running', 'completed', 'failed', 'interrupted']) + .describe('Current status of the execution'), + startedAt: z.number().describe('Timestamp when execution started'), + lastHeartbeat: z + .number() + .nullable() + .describe('Last heartbeat timestamp from runner (null if never received)'), + processId: z.string().nullable().describe('Sandbox process ID (null if not yet started)'), + error: z.string().nullable().describe('Error message if execution failed (null if no error)'), + health: z + .enum(['healthy', 'stale', 'unknown']) + .describe('Health status: healthy (<1min heartbeat), unknown (1-10min), stale (>10min)'), + }) + .nullable() + .describe('Current execution status (null if no active execution)'); + +export const GetSessionOutput = z.object({ + // Session identifiers + sessionId: z.string().describe('Cloud-agent session ID'), + kiloSessionId: z.string().optional().describe('Kilo CLI session ID'), + userId: z.string().describe('Owner user ID'), + orgId: z.string().optional().describe('Organization ID if applicable'), + sandboxId: z + .string() + .optional() + .describe('Sandbox ID (hashed format like usr-abc123...) for correlating with Cloudflare logs'), + + // Repository info (no tokens) + githubRepo: z.string().optional().describe('GitHub repository in org/repo format'), + gitUrl: z.string().optional().describe('Generic git URL'), + + // Execution params + prompt: z.string().optional().describe('Task prompt'), + mode: AgentModeSchema.optional().describe('Execution mode'), + model: z.string().optional().describe('AI model'), + autoCommit: z.boolean().optional().describe('Auto-commit setting'), + upstreamBranch: z.string().optional().describe('Upstream branch name'), + + // Configuration metadata (counts only, no values) + envVarCount: z.number().optional().describe('Number of environment variables configured'), + setupCommandCount: z.number().optional().describe('Number of setup commands configured'), + mcpServerCount: z.number().optional().describe('Number of MCP servers configured'), + + // Execution status (grouped for cleaner API) + execution: ExecutionStatusSchema, + + // Lifecycle timestamps (critical for idempotency) + preparedAt: z.number().optional().describe('Timestamp when session was prepared'), + initiatedAt: z.number().optional().describe('Timestamp when session was initiated'), + + // Callback configuration (debug-friendly, URL + headers) + callbackTarget: CallbackTargetSchema.optional().describe( + 'Callback target configuration for execution completion notifications' + ), + + // Versioning + timestamp: z.number().describe('Last update timestamp'), + version: z.number().describe('Metadata version for cache invalidation'), +}); + +export type GetSessionResponse = z.infer; + +/** + * Response schema for V2 execution endpoints. + * Returns acknowledgment when execution has started. + * Returns 409 Conflict if an execution is already in progress. + */ +export const ExecutionResponse = z.object({ + cloudAgentSessionId: z.string().describe('Cloud agent session ID'), + executionId: z.string().describe('Execution ID (same as messageId sent to wrapper)'), + status: z.literal('started').describe('Execution has started'), + streamUrl: z.string().describe('WebSocket URL for streaming output'), +}); +export type ExecutionResponse = z.infer; + +/** + * @deprecated Use ExecutionResponse instead + */ +export const QueueAckResponse = ExecutionResponse; +export type QueueAckResponse = ExecutionResponse; + +/** + * Error response for 409 Conflict when execution is already in progress. + */ +export const ConflictErrorResponse = z.object({ + error: z.literal('EXECUTION_IN_PROGRESS').describe('Error code'), + message: z.string().describe('Human-readable error message'), + activeExecutionId: z.string().describe('The currently active execution ID'), +}); +export type ConflictErrorResponse = z.infer; + +/** + * Error response for 503 Service Unavailable when transient failures occur. + * These are retryable errors - client should retry with backoff. + */ +export const TransientErrorResponse = z.object({ + error: z + .enum([ + 'SANDBOX_CONNECT_FAILED', + 'WORKSPACE_SETUP_FAILED', + 'KILO_SERVER_FAILED', + 'WRAPPER_START_FAILED', + ]) + .describe('Error code indicating the type of transient failure'), + message: z.string().describe('Human-readable error message'), + retryable: z.literal(true).describe('Indicates this error is retryable'), +}); +export type TransientErrorResponse = z.infer; diff --git a/cloud-agent-next/src/sandbox-id.test.ts b/cloud-agent-next/src/sandbox-id.test.ts new file mode 100644 index 0000000000..69e3889c80 --- /dev/null +++ b/cloud-agent-next/src/sandbox-id.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; +import { generateSandboxId } from './sandbox-id.js'; + +describe('generateSandboxId', () => { + describe('length validation', () => { + it('should generate sandboxId within 63 character limit', async () => { + const sandboxId = await generateSandboxId( + '9d278969-5453-4ae3-a51f-a8d2274a7b56', + 'fd93a81c-63c2-4d14-84b3-60d6ac3b592f' + ); + expect(sandboxId.length).toBeLessThanOrEqual(63); + expect(sandboxId.length).toBe(52); // Exact expected length + }); + + it('should handle long inputs without exceeding limit', async () => { + const longOrgId = 'a'.repeat(36); + const longUserId = 'b'.repeat(36); + const longBotId = 'c'.repeat(50); + + const sandboxId = await generateSandboxId(longOrgId, longUserId, longBotId); + expect(sandboxId.length).toBe(52); + }); + }); + + describe('determinism', () => { + it('should generate same sandboxId for same inputs', async () => { + const orgId = '9d278969-5453-4ae3-a51f-a8d2274a7b56'; + const userId = 'fd93a81c-63c2-4d14-84b3-60d6ac3b592f'; + + const id1 = await generateSandboxId(orgId, userId); + const id2 = await generateSandboxId(orgId, userId); + + expect(id1).toBe(id2); + }); + + it('should be deterministic with botId', async () => { + const orgId = '9d278969-5453-4ae3-a51f-a8d2274a7b56'; + const userId = 'fd93a81c-63c2-4d14-84b3-60d6ac3b592f'; + const botId = 'reviewer'; + + const id1 = await generateSandboxId(orgId, userId, botId); + const id2 = await generateSandboxId(orgId, userId, botId); + + expect(id1).toBe(id2); + }); + }); + + describe('prefix correctness', () => { + it('should use "org" prefix for organization accounts', async () => { + const sandboxId = await generateSandboxId('org-id', 'user-id'); + expect(sandboxId).toMatch(/^org-[0-9a-f]{48}$/); + }); + + it('should use "usr" prefix for personal accounts', async () => { + const sandboxId = await generateSandboxId(undefined, 'user-id'); + expect(sandboxId).toMatch(/^usr-[0-9a-f]{48}$/); + }); + + it('should use "bot" prefix for org accounts with bot', async () => { + const sandboxId = await generateSandboxId('org-id', 'user-id', 'reviewer'); + expect(sandboxId).toMatch(/^bot-[0-9a-f]{48}$/); + }); + + it('should use "ubt" prefix for personal accounts with bot', async () => { + const sandboxId = await generateSandboxId(undefined, 'user-id', 'reviewer'); + expect(sandboxId).toMatch(/^ubt-[0-9a-f]{48}$/); + }); + }); + + describe('uniqueness', () => { + it('should generate different IDs for different orgIds', async () => { + const id1 = await generateSandboxId('org-1', 'user-id'); + const id2 = await generateSandboxId('org-2', 'user-id'); + expect(id1).not.toBe(id2); + }); + + it('should generate different IDs for different userIds', async () => { + const id1 = await generateSandboxId('org-id', 'user-1'); + const id2 = await generateSandboxId('org-id', 'user-2'); + expect(id1).not.toBe(id2); + }); + + it('should generate different IDs for different botIds', async () => { + const id1 = await generateSandboxId('org-id', 'user-id', 'bot-1'); + const id2 = await generateSandboxId('org-id', 'user-id', 'bot-2'); + expect(id1).not.toBe(id2); + }); + + it('should differ between org and personal accounts', async () => { + const userId = 'user-id'; + const orgId = await generateSandboxId('org-id', userId); + const personal = await generateSandboxId(undefined, userId); + expect(orgId).not.toBe(personal); + }); + + it('should differ with and without bot', async () => { + const withoutBot = await generateSandboxId('org-id', 'user-id'); + const withBot = await generateSandboxId('org-id', 'user-id', 'reviewer'); + expect(withoutBot).not.toBe(withBot); + }); + }); + + describe('edge cases', () => { + it('should handle special characters in IDs', async () => { + const sandboxId = await generateSandboxId('org@123', 'user#456', 'bot$789'); + expect(sandboxId.length).toBe(52); + expect(sandboxId).toMatch(/^bot-[0-9a-f]{48}$/); + }); + + it('should handle empty strings', async () => { + const sandboxId = await generateSandboxId('', '', ''); + expect(sandboxId.length).toBe(52); + }); + + it('should handle unicode characters', async () => { + const sandboxId = await generateSandboxId('org-日本', 'user-한국', 'bot-中国'); + expect(sandboxId.length).toBe(52); + }); + }); +}); diff --git a/cloud-agent-next/src/sandbox-id.ts b/cloud-agent-next/src/sandbox-id.ts new file mode 100644 index 0000000000..3d43ba3e15 --- /dev/null +++ b/cloud-agent-next/src/sandbox-id.ts @@ -0,0 +1,62 @@ +import type { SandboxId } from './types.js'; + +/** + * Generate a deterministic, Cloudflare-compatible sandboxId (≤63 chars). + * + * Format: {prefix}-{hash48} + * - prefix (3 chars): 'org'|'usr'|'bot'|'ubt' + * - hash48 (48 chars): First 48 hex chars of SHA-256 hash + * - Total: 52 characters + * + * The hash is computed from the original sandboxId format to maintain + * determinism while reducing length. + * + * @param orgId - Organization ID (undefined for personal accounts) + * @param userId - User ID (required) + * @param botId - Bot ID (optional) + * @returns Promise - Deterministic sandboxId string (52 characters) + * + * @example + * // Organization account + * await generateSandboxId('org-uuid', 'user-uuid', undefined) + * // => 'org-a1b2c3d4e5f6789012345678901234567890123456789012' + * + * @example + * // Personal account with bot + * await generateSandboxId(undefined, 'user-uuid', 'reviewer') + * // => 'ubt-f7e6d5c4b3a29182736458abc123def456789fedcba987' + */ +export async function generateSandboxId( + orgId: string | undefined, + userId: string, + botId?: string +): Promise { + // Build the original format string that would have been used + const sandboxOrgSegment = orgId ?? `user:${userId}`; + const originalFormat = botId + ? `${sandboxOrgSegment}__${userId}__bot:${botId}` + : `${sandboxOrgSegment}__${userId}`; + + // Determine prefix based on account type + let prefix: string; + if (botId) { + prefix = orgId ? 'bot' : 'ubt'; // bot in org, or user-bot + } else { + prefix = orgId ? 'org' : 'usr'; // org account or user account + } + + // Hash the original format using SHA-256 + const encoder = new TextEncoder(); + const data = encoder.encode(originalFormat); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + + // Convert to hex string + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + + // Take first 48 hex characters (192 bits of entropy) + const hash48 = hashHex.substring(0, 48); + + // Construct final sandboxId: prefix-hash (3 + 1 + 48 = 52 chars) + return `${prefix}-${hash48}` as SandboxId; +} diff --git a/cloud-agent-next/src/schema.ts b/cloud-agent-next/src/schema.ts new file mode 100644 index 0000000000..521e6d5784 --- /dev/null +++ b/cloud-agent-next/src/schema.ts @@ -0,0 +1,86 @@ +import { z } from 'zod'; +import type { MCPServerConfig } from './persistence/types.js'; + +// === Agent Modes === +/** + * Internal agent modes used by the kilo CLI. + * These are the actual modes passed to `kilo run --agent `. + */ +export const InternalAgentModes = ['plan', 'code', 'custom'] as const; +export type InternalAgentMode = (typeof InternalAgentModes)[number]; + +/** + * Input agent modes accepted by the API. + * These include aliases that map to internal modes: + * - plan: maps to 'plan' + * - code: maps to 'code' + * - build: maps to 'code' (alias) + * - orchestrator: maps to 'code' (alias) + * - architect: maps to 'plan' (alias) + * - ask: maps to 'plan' (alias) + * - custom: maps to 'custom' + */ +export const AgentModes = [ + 'plan', + 'code', + 'build', + 'orchestrator', + 'architect', + 'ask', + 'custom', +] as const; +export type AgentMode = (typeof AgentModes)[number]; +export const AgentModeSchema = z.enum(AgentModes); + +/** + * Maps input agent modes to internal modes used by kilo CLI. + */ +export function normalizeAgentMode(mode: AgentMode): InternalAgentMode { + switch (mode) { + case 'architect': + case 'ask': + case 'plan': + return 'plan'; + case 'orchestrator': + case 'build': + case 'code': + return 'code'; + case 'custom': + return 'custom'; + } +} + +// === Limits === +export const Limits = { + MAX_PROMPT_LENGTH: 100_000, // 100KB + MAX_ENV_VARS: 50, + MAX_ENV_VAR_KEY_LENGTH: 128, // Env var keys are typically short identifiers + MAX_ENV_VAR_VALUE_LENGTH: 4096, // Env var values can be longer (connection strings, etc.) + MAX_SETUP_COMMANDS: 20, + MAX_SETUP_COMMAND_LENGTH: 500, + MAX_MCP_SERVERS: 20, + SESSION_TTL_DAYS: 90, + SESSION_TTL_MS: 90 * 24 * 60 * 60 * 1000, // 90 days in milliseconds +} as const; + +// === ExecutionParams (for session-service) === +export type ExecutionParams = { + sessionId: string; // cloudAgentSessionId + kiloSessionId: string; + userId: string; + orgId?: string; + + prompt: string; + mode: AgentMode; + model: string; + + githubRepo?: string; + githubToken?: string; + gitUrl?: string; + gitToken?: string; + + envVars?: Record; + setupCommands?: string[]; + mcpServers?: Record; + autoCommit?: boolean; +}; diff --git a/cloud-agent-next/src/services/github-token-service.ts b/cloud-agent-next/src/services/github-token-service.ts new file mode 100644 index 0000000000..f313f55a9d --- /dev/null +++ b/cloud-agent-next/src/services/github-token-service.ts @@ -0,0 +1,183 @@ +import { createAppAuth } from '@octokit/auth-app'; +import { TRPCError } from '@trpc/server'; +import * as z from 'zod'; + +type TokenCacheEntry = { + token: string; + expiresAt: number; +}; + +type GitHubAppCredentials = { + appId: string; + privateKey: string; +}; + +type GeneratedToken = { + token: string; + expiresAt: number; +}; + +/** + * Type of GitHub App to use + * - 'standard': Full-featured KiloConnect app with read/write permissions + * - 'lite': Read-only KiloConnect-Lite app + */ +export type GitHubAppType = 'standard' | 'lite'; + +type GitHubTokenServiceEnv = { + GITHUB_TOKEN_CACHE?: KVNamespace; + // Standard app credentials + GITHUB_APP_ID?: string; + GITHUB_APP_PRIVATE_KEY?: string; + // Lite app credentials + GITHUB_LITE_APP_ID?: string; + GITHUB_LITE_APP_PRIVATE_KEY?: string; +}; + +const TokenCacheEntrySchema = z.object({ + token: z.string(), + expiresAt: z.number(), +}); + +const CACHE_TTL_MS = 30 * 60 * 1000; +const CACHE_KEY_PREFIX = 'gh-token:'; +const MIN_TTL_SECONDS = 60; +const EXPIRY_BUFFER_MS = 5 * 60 * 1000; + +export class GitHubTokenService { + constructor(private env: GitHubTokenServiceEnv) {} + + isConfigured(appType: GitHubAppType = 'standard'): boolean { + if (appType === 'lite') { + return Boolean(this.env.GITHUB_LITE_APP_ID && this.env.GITHUB_LITE_APP_PRIVATE_KEY); + } + return Boolean(this.env.GITHUB_APP_ID && this.env.GITHUB_APP_PRIVATE_KEY); + } + + async getToken(installationId: string, appType: GitHubAppType = 'standard'): Promise { + const numericId = this.validateInstallationId(installationId); + + // Include app type in cache key to prevent mixing tokens from different apps + const cacheKey = `${installationId}:${appType}`; + const cached = await this.getCachedToken(cacheKey); + if (cached) { + return cached; + } + + const credentials = this.getCredentials(appType); + const { token, expiresAt } = await this.generateToken(numericId, credentials); + await this.cacheToken(cacheKey, token, expiresAt); + + return token; + } + + private getCredentials(appType: GitHubAppType): GitHubAppCredentials { + if (appType === 'lite') { + const appId = this.env.GITHUB_LITE_APP_ID; + const privateKeyRaw = this.env.GITHUB_LITE_APP_PRIVATE_KEY; + if (!appId || !privateKeyRaw) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'GitHub Lite App credentials not configured', + }); + } + return { + appId, + privateKey: privateKeyRaw.replace(/\\n/g, '\n'), + }; + } + + const appId = this.env.GITHUB_APP_ID; + const privateKeyRaw = this.env.GITHUB_APP_PRIVATE_KEY; + if (!appId || !privateKeyRaw) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'GitHub App credentials not configured', + }); + } + + return { + appId, + privateKey: privateKeyRaw.replace(/\\n/g, '\n'), + }; + } + + private validateInstallationId(installationId: string): number { + const numericId = Number(installationId); + const isValid = Number.isInteger(numericId) && numericId > 0; + if (!isValid) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Invalid GitHub installation ID: ${installationId}`, + }); + } + return numericId; + } + + private async getCachedToken(cacheKey: string): Promise { + if (!this.env.GITHUB_TOKEN_CACHE) { + return null; + } + + const key = `${CACHE_KEY_PREFIX}${cacheKey}`; + const cached = await this.env.GITHUB_TOKEN_CACHE.get(key, 'json'); + const parsed = TokenCacheEntrySchema.safeParse(cached); + if (!parsed.success) { + return null; + } + + const entry = parsed.data; + if (entry.expiresAt - Date.now() < EXPIRY_BUFFER_MS) { + return null; + } + + return entry.token; + } + + private async generateToken( + installationId: number, + credentials: GitHubAppCredentials + ): Promise { + try { + const auth = createAppAuth({ + appId: credentials.appId, + privateKey: credentials.privateKey, + installationId, + }); + + const result = await auth({ type: 'installation' }); + return { + token: result.token, + expiresAt: new Date(result.expiresAt).getTime(), + }; + } catch (error) { + console.error('Failed to generate GitHub token:', error); + const message = error instanceof Error ? error.message : 'Unknown error'; + throw new TRPCError({ + code: 'BAD_GATEWAY', + message: `Failed to generate GitHub installation token: ${message}`, + cause: error, + }); + } + } + + private async cacheToken(cacheKey: string, token: string, expiresAt: number): Promise { + if (!this.env.GITHUB_TOKEN_CACHE) { + return; + } + + const remainingSeconds = Math.floor((expiresAt - Date.now()) / 1000); + if (remainingSeconds < MIN_TTL_SECONDS) { + return; + } + + const entry = { token, expiresAt } satisfies TokenCacheEntry; + const maxTtlSeconds = Math.floor(CACHE_TTL_MS / 1000); + const ttlSeconds = Math.min(maxTtlSeconds, remainingSeconds); + const key = `${CACHE_KEY_PREFIX}${cacheKey}`; + + await this.env.GITHUB_TOKEN_CACHE.put(key, JSON.stringify(entry), { + expirationTtl: ttlSeconds, + }); + } +} diff --git a/cloud-agent-next/src/services/installation-lookup-service.ts b/cloud-agent-next/src/services/installation-lookup-service.ts new file mode 100644 index 0000000000..64f5865dc4 --- /dev/null +++ b/cloud-agent-next/src/services/installation-lookup-service.ts @@ -0,0 +1,64 @@ +import { createDatabaseConnection } from '../db/database.js'; +import { PlatformIntegrationsStore } from '../db/stores/PlatformIntegrationsStore.js'; + +type InstallationLookupEnv = { + HYPERDRIVE?: { connectionString: string }; +}; + +type LookupParams = { + githubRepo: string; + userId: string; + orgId?: string; +}; + +type LookupResult = { + installationId: string; + accountLogin: string; + githubAppType: 'standard' | 'lite'; +} | null; + +export class InstallationLookupService { + private store: PlatformIntegrationsStore | null = null; + + constructor(private env: InstallationLookupEnv) {} + + isConfigured(): boolean { + return Boolean(this.env.HYPERDRIVE); + } + + private getStore(): PlatformIntegrationsStore { + if (!this.store) { + if (!this.env.HYPERDRIVE) { + throw new Error('Hyperdrive not configured'); + } + const db = createDatabaseConnection(this.env.HYPERDRIVE.connectionString); + this.store = new PlatformIntegrationsStore(db); + } + return this.store; + } + + async findInstallationId(params: LookupParams): Promise { + if (!this.isConfigured()) { + return null; + } + + const [repoOwner] = params.githubRepo.split('/'); + const store = this.getStore(); + + const result = await store.findGitHubInstallation({ + repoOwner, + userId: params.userId, + orgId: params.orgId, + }); + + if (!result) { + return null; + } + + return { + installationId: result.platform_installation_id, + accountLogin: result.platform_account_login, + githubAppType: result.github_app_type || 'standard', + }; + } +} diff --git a/cloud-agent-next/src/session-prepare.test.ts b/cloud-agent-next/src/session-prepare.test.ts new file mode 100644 index 0000000000..e2c487ff8d --- /dev/null +++ b/cloud-agent-next/src/session-prepare.test.ts @@ -0,0 +1,1351 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as schemas from './router/schemas.js'; +import * as schemaLimits from './schema.js'; + +// Create a mock execution session +const createMockExecutionSession = () => ({ + id: 'mock-session-id', + exec: vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }), + execStream: vi.fn(), + startProcess: vi.fn().mockResolvedValue({ + id: 'mock-process-id', + status: 'running', + waitForPort: vi.fn().mockResolvedValue(undefined), + }), + listProcesses: vi.fn().mockResolvedValue([]), + writeFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue(''), + mkdir: vi.fn().mockResolvedValue(undefined), +}); + +// Create a mock sandbox +const createMockSandbox = () => { + const mockSession = createMockExecutionSession(); + return { + getSession: vi.fn().mockResolvedValue(mockSession), + createSession: vi.fn().mockResolvedValue(mockSession), + listProcesses: vi.fn().mockResolvedValue([]), + mkdir: vi.fn().mockResolvedValue(undefined), + }; +}; + +// Mock Cloudflare sandbox to prevent module resolution errors +vi.mock('@cloudflare/sandbox', () => ({ + getSandbox: vi.fn(() => createMockSandbox()), +})); + +// Mock workspace functions +vi.mock('./workspace.js', () => ({ + setupWorkspace: vi.fn().mockResolvedValue({ + workspacePath: '/workspace/test', + sessionHome: '/home/test', + }), + cloneGitHubRepo: vi.fn().mockResolvedValue(undefined), + cloneGitRepo: vi.fn().mockResolvedValue(undefined), + manageBranch: vi.fn().mockResolvedValue(undefined), +})); + +// Mock kilo server-manager functions +vi.mock('./kilo/server-manager.js', () => ({ + ensureKiloServer: vi.fn().mockResolvedValue(4096), + createKiloCliSession: vi.fn().mockResolvedValue({ id: 'cli-session-abc123' }), +})); + +// Define mocks BEFORE vi.mock() to avoid hoisting issues +// vi.hoisted() ensures these are available when the mock factory runs +const { generateSessionIdMock, createKiloSessionInBackendMock, deleteKiloSessionInBackendMock } = + vi.hoisted(() => ({ + generateSessionIdMock: vi.fn(() => 'agent_12345678-1234-1234-1234-123456789abc'), + createKiloSessionInBackendMock: vi + .fn() + .mockResolvedValue('123e4567-e89b-12d3-a456-426614174000'), + deleteKiloSessionInBackendMock: vi.fn().mockResolvedValue(undefined), + })); + +// Mock session-service to isolate router tests +vi.mock('./session-service.js', () => ({ + generateSessionId: () => generateSessionIdMock(), + fetchSessionMetadata: vi.fn(), + determineBranchName: vi.fn( + (sessionId: string, upstreamBranch?: string) => upstreamBranch || `session/${sessionId}` + ), + runSetupCommands: vi.fn().mockResolvedValue(undefined), + writeMCPSettings: vi.fn().mockResolvedValue(undefined), + InvalidSessionMetadataError: class InvalidSessionMetadataError extends Error { + constructor( + public readonly userId: string, + public readonly sessionId: string, + public readonly details?: string + ) { + super(`Invalid session metadata for session ${sessionId}`); + this.name = 'InvalidSessionMetadataError'; + } + }, + SessionService: class SessionService { + createKiloSessionInBackend = createKiloSessionInBackendMock; + deleteKiloSessionInBackend = deleteKiloSessionInBackendMock; + getOrCreateSession = vi.fn().mockResolvedValue(createMockExecutionSession()); + buildContext = vi.fn().mockReturnValue({ + sandboxId: 'test-sandbox', + orgId: 'test-org', + userId: 'test-user', + sessionId: 'test-session', + workspacePath: '/workspace/test', + sessionHome: '/home/test', + }); + }, +})); + +import { appRouter } from './router.js'; +import type { TRPCContext, SessionId } from './types.js'; +import type { CloudAgentSessionState } from './persistence/types.js'; + +// Helper to create a mock DO stub +function createMockDOStub( + overrides: { + prepare?: ReturnType; + tryUpdate?: ReturnType; + tryInitiate?: ReturnType; + getMetadata?: ReturnType; + updateMetadata?: ReturnType; + deleteSession?: ReturnType; + } = {} +) { + return { + prepare: overrides.prepare ?? vi.fn().mockResolvedValue({ success: true }), + tryUpdate: overrides.tryUpdate ?? vi.fn().mockResolvedValue({ success: true }), + tryInitiate: overrides.tryInitiate ?? vi.fn().mockResolvedValue({ success: true, data: {} }), + getMetadata: overrides.getMetadata ?? vi.fn().mockResolvedValue(null), + updateMetadata: overrides.updateMetadata ?? vi.fn().mockResolvedValue(undefined), + deleteSession: overrides.deleteSession ?? vi.fn().mockResolvedValue(undefined), + markAsInterrupted: vi.fn().mockResolvedValue(undefined), + isInterrupted: vi.fn().mockResolvedValue(false), + clearInterrupted: vi.fn().mockResolvedValue(undefined), + updateKiloSessionId: vi.fn().mockResolvedValue(undefined), + updateGithubToken: vi.fn().mockResolvedValue(undefined), + }; +} + +// Helper to create a properly typed context for internal-API-protected endpoints +function createInternalApiContext(options: { + userId?: string | null; // null means explicitly no userId + authToken?: string | null; // null means explicitly no authToken + internalApiSecret?: string | null; // null means explicitly no internal API secret configured + requestInternalApiKey?: string | null; // null means no x-internal-api-key header + doStub?: ReturnType; +}): TRPCContext { + const { + userId, + authToken, + internalApiSecret, + requestInternalApiKey, + doStub = createMockDOStub(), + } = options; + + // Apply defaults only if not explicitly set to null + const effectiveUserId = + userId === undefined ? 'test-user-123' : userId === null ? undefined : userId; + const effectiveAuthToken = + authToken === undefined ? 'test-auth-token' : authToken === null ? undefined : authToken; + const effectiveInternalApiSecret = + internalApiSecret === undefined + ? 'test-internal-api-secret' + : internalApiSecret === null + ? undefined + : internalApiSecret; + const effectiveRequestInternalApiKey = + requestInternalApiKey === undefined ? 'test-internal-api-secret' : requestInternalApiKey; + + const headers = new Headers(); + if (effectiveRequestInternalApiKey !== null) { + headers.set('x-internal-api-key', effectiveRequestInternalApiKey); + } + + return { + userId: effectiveUserId, + authToken: effectiveAuthToken, + botId: undefined, + request: { + headers, + } as unknown as Request, + env: { + Sandbox: {} as TRPCContext['env']['Sandbox'], + CLOUD_AGENT_SESSION: { + idFromName: vi.fn((id: string) => ({ id })), + get: vi.fn(() => doStub), + } as unknown as TRPCContext['env']['CLOUD_AGENT_SESSION'], + INTERNAL_API_SECRET: effectiveInternalApiSecret, + NEXTAUTH_SECRET: 'test-secret', + }, + } as TRPCContext; +} + +describe('prepareSession endpoint', () => { + beforeEach(() => { + vi.clearAllMocks(); + generateSessionIdMock.mockReturnValue('agent_12345678-1234-1234-1234-123456789abc'); + createKiloSessionInBackendMock.mockResolvedValue('123e4567-e89b-12d3-a456-426614174000'); + deleteKiloSessionInBackendMock.mockResolvedValue(undefined); + }); + + describe('authentication', () => { + it('should reject request without internal API key header', async () => { + const doStub = createMockDOStub(); + const ctx = createInternalApiContext({ + requestInternalApiKey: null, // No internal API key + doStub, + }); + + const caller = appRouter.createCaller(ctx); + + await expect( + caller.prepareSession({ + prompt: 'Test prompt', + mode: 'build', + model: 'claude-3', + githubRepo: 'acme/repo', + githubToken: 'ghp_test_token', + }) + ).rejects.toThrow('Invalid or missing internal API key'); + }); + + it('should reject request with invalid internal API key', async () => { + const doStub = createMockDOStub(); + const ctx = createInternalApiContext({ + requestInternalApiKey: 'wrong-key', + internalApiSecret: 'correct-key', + doStub, + }); + + const caller = appRouter.createCaller(ctx); + + await expect( + caller.prepareSession({ + prompt: 'Test prompt', + mode: 'build', + model: 'claude-3', + githubRepo: 'acme/repo', + githubToken: 'ghp_test_token', + }) + ).rejects.toThrow('Invalid or missing internal API key'); + }); + + it('should reject request without customer token (userId)', async () => { + const doStub = createMockDOStub(); + const ctx = createInternalApiContext({ + userId: null, // Explicitly no userId + doStub, + }); + + const caller = appRouter.createCaller(ctx); + + await expect( + caller.prepareSession({ + prompt: 'Test prompt', + mode: 'build', + model: 'claude-3', + githubRepo: 'acme/repo', + githubToken: 'ghp_test_token', + }) + ).rejects.toThrow('Invalid customer token'); + }); + + it('should reject when INTERNAL_API_SECRET is not configured', async () => { + const doStub = createMockDOStub(); + const ctx = createInternalApiContext({ + internalApiSecret: null, // Explicitly no internal API secret configured + doStub, + }); + + const caller = appRouter.createCaller(ctx); + + await expect( + caller.prepareSession({ + prompt: 'Test prompt', + mode: 'build', + model: 'claude-3', + githubRepo: 'acme/repo', + githubToken: 'ghp_test_token', + }) + ).rejects.toThrow('Internal API secret not configured'); + }); + }); + + describe('success cases', () => { + it('should successfully prepare a new session with GitHub repo', async () => { + const doStub = createMockDOStub({ + prepare: vi.fn().mockResolvedValue({ success: true }), + }); + const ctx = createInternalApiContext({ doStub }); + + const caller = appRouter.createCaller(ctx); + const result = await caller.prepareSession({ + prompt: 'Test prompt', + mode: 'build', + model: 'claude-3', + githubRepo: 'acme/repo', + githubToken: 'ghp_token', + }); + + expect(result.cloudAgentSessionId).toMatch(/^agent_[0-9a-f-]+$/); + expect(result.kiloSessionId).toBe('cli-session-abc123'); + expect(doStub.prepare).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: expect.stringMatching(/^agent_/), + userId: 'test-user-123', + kiloSessionId: 'cli-session-abc123', + prompt: 'Test prompt', + mode: 'build', + model: 'claude-3', + githubRepo: 'acme/repo', + githubToken: 'ghp_token', + }) + ); + }); + + it('should successfully prepare a session with git URL', async () => { + const doStub = createMockDOStub({ + prepare: vi.fn().mockResolvedValue({ success: true }), + }); + const ctx = createInternalApiContext({ doStub }); + + const caller = appRouter.createCaller(ctx); + const result = await caller.prepareSession({ + prompt: 'Test prompt', + mode: 'build', + model: 'claude-3', + gitUrl: 'https://gitlab.com/org/repo.git', + gitToken: 'token123', + }); + + expect(result.cloudAgentSessionId).toBeDefined(); + expect(result.kiloSessionId).toBe('cli-session-abc123'); + expect(doStub.prepare).toHaveBeenCalledWith( + expect.objectContaining({ + gitUrl: 'https://gitlab.com/org/repo.git', + gitToken: 'token123', + }) + ); + }); + + it('should pass optional configuration to DO', async () => { + const doStub = createMockDOStub({ + prepare: vi.fn().mockResolvedValue({ success: true }), + }); + const ctx = createInternalApiContext({ doStub }); + + const caller = appRouter.createCaller(ctx); + await caller.prepareSession({ + prompt: 'Test prompt', + mode: 'plan', + model: 'claude-3', + githubRepo: 'acme/repo', + githubToken: 'ghp_test_token', + envVars: { API_KEY: 'secret' }, + setupCommands: ['npm install'], + mcpServers: { test: { command: 'npx', args: ['test-server'] } }, + upstreamBranch: 'feature/test-branch', + autoCommit: true, + kilocodeOrganizationId: 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + }); + + // Verify the DO was called with the expected configuration + // Note: mcpServers schema adds default values (type, timeout, alwaysAllow, disabledTools) + expect(doStub.prepare).toHaveBeenCalledTimes(1); + const callArg = doStub.prepare.mock.calls[0][0]; + expect(callArg.envVars).toEqual({ API_KEY: 'secret' }); + expect(callArg.setupCommands).toEqual(['npm install']); + expect(callArg.upstreamBranch).toBe('feature/test-branch'); + expect(callArg.autoCommit).toBe(true); + expect(callArg.orgId).toBe('f47ac10b-58cc-4372-a567-0e02b2c3d479'); + expect(callArg.mcpServers.test.command).toBe('npx'); + expect(callArg.mcpServers.test.args).toEqual(['test-server']); + }); + }); + + describe('validation', () => { + it('should reject when prompt is empty', async () => { + const ctx = createInternalApiContext({}); + const caller = appRouter.createCaller(ctx); + + await expect( + caller.prepareSession({ + prompt: '', + mode: 'build', + model: 'claude-3', + githubRepo: 'acme/repo', + }) + ).rejects.toThrow(); + }); + + it('should reject when neither githubRepo nor gitUrl is provided', async () => { + const ctx = createInternalApiContext({}); + const caller = appRouter.createCaller(ctx); + + await expect( + caller.prepareSession({ + prompt: 'Test prompt', + mode: 'build', + model: 'claude-3', + } as Parameters[0]) + ).rejects.toThrow(); + }); + + it('should reject invalid mode', async () => { + const ctx = createInternalApiContext({}); + const caller = appRouter.createCaller(ctx); + + await expect( + caller.prepareSession({ + prompt: 'Test prompt', + mode: 'invalid-mode' as Parameters[0]['mode'], + model: 'claude-3', + githubRepo: 'acme/repo', + }) + ).rejects.toThrow(); + }); + }); + + describe('error handling', () => { + it('should return error when DO prepare fails', async () => { + const doStub = createMockDOStub({ + prepare: vi.fn().mockResolvedValue({ success: false, error: 'Session already prepared' }), + }); + const ctx = createInternalApiContext({ doStub }); + + const caller = appRouter.createCaller(ctx); + + await expect( + caller.prepareSession({ + prompt: 'Test prompt', + mode: 'build', + model: 'claude-3', + githubRepo: 'acme/repo', + githubToken: 'ghp_test_token', + }) + ).rejects.toThrow('Session already prepared'); + }); + + // NOTE: Backend session creation (createKiloSessionInBackend) is currently disabled. + // The kiloSessionId now comes from the kilo CLI server's POST /session API. + // Tests for backend session creation error handling and rollback have been removed. + }); +}); + +describe('updateSession endpoint', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('authentication', () => { + it('should reject without internal API key', async () => { + const ctx = createInternalApiContext({ requestInternalApiKey: null }); + const caller = appRouter.createCaller(ctx); + + await expect( + caller.updateSession({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc' as SessionId, + mode: 'plan', + }) + ).rejects.toThrow('Invalid or missing internal API key'); + }); + + it('should reject without customer token', async () => { + const ctx = createInternalApiContext({ userId: null }); // Explicitly no userId + const caller = appRouter.createCaller(ctx); + + await expect( + caller.updateSession({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc' as SessionId, + mode: 'plan', + }) + ).rejects.toThrow('Invalid customer token'); + }); + }); + + describe('success cases', () => { + it('should successfully update a prepared session', async () => { + const doStub = createMockDOStub({ + tryUpdate: vi.fn().mockResolvedValue({ success: true }), + }); + const ctx = createInternalApiContext({ doStub }); + + const caller = appRouter.createCaller(ctx); + const result = await caller.updateSession({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc' as SessionId, + mode: 'plan', + model: 'claude-3.5-sonnet', + }); + + expect(result.success).toBe(true); + expect(doStub.tryUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + mode: 'plan', + model: 'claude-3.5-sonnet', + }) + ); + }); + + it('should clear fields with null', async () => { + const doStub = createMockDOStub({ + tryUpdate: vi.fn().mockResolvedValue({ success: true }), + }); + const ctx = createInternalApiContext({ doStub }); + + const caller = appRouter.createCaller(ctx); + await caller.updateSession({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc' as SessionId, + githubToken: null, + autoCommit: null, + }); + + expect(doStub.tryUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + githubToken: null, + autoCommit: null, + }) + ); + }); + + it('should clear collections with empty arrays/objects', async () => { + const doStub = createMockDOStub({ + tryUpdate: vi.fn().mockResolvedValue({ success: true }), + }); + const ctx = createInternalApiContext({ doStub }); + + const caller = appRouter.createCaller(ctx); + await caller.updateSession({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc' as SessionId, + envVars: {}, + setupCommands: [], + mcpServers: {}, + }); + + // Handler converts empty to null for DO + expect(doStub.tryUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + envVars: null, + setupCommands: null, + mcpServers: null, + }) + ); + }); + + it('should update collections with values', async () => { + const doStub = createMockDOStub({ + tryUpdate: vi.fn().mockResolvedValue({ success: true }), + }); + const ctx = createInternalApiContext({ doStub }); + + const caller = appRouter.createCaller(ctx); + await caller.updateSession({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc' as SessionId, + envVars: { NEW_VAR: 'value' }, + setupCommands: ['new-command'], + }); + + expect(doStub.tryUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + envVars: { NEW_VAR: 'value' }, + setupCommands: ['new-command'], + }) + ); + }); + }); + + describe('error handling', () => { + it('should return error if session not prepared', async () => { + const doStub = createMockDOStub({ + tryUpdate: vi + .fn() + .mockResolvedValue({ success: false, error: 'Session has not been prepared' }), + }); + const ctx = createInternalApiContext({ doStub }); + + const caller = appRouter.createCaller(ctx); + + await expect( + caller.updateSession({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc' as SessionId, + mode: 'plan', + }) + ).rejects.toThrow('Session has not been prepared'); + }); + + it('should return error if session already initiated', async () => { + const doStub = createMockDOStub({ + tryUpdate: vi + .fn() + .mockResolvedValue({ success: false, error: 'Session has already been initiated' }), + }); + const ctx = createInternalApiContext({ doStub }); + + const caller = appRouter.createCaller(ctx); + + await expect( + caller.updateSession({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc' as SessionId, + mode: 'plan', + }) + ).rejects.toThrow('Session has already been initiated'); + }); + }); +}); + +describe('DO state machine methods', () => { + describe('prepare()', () => { + it('should set preparedAt and return success', async () => { + // This is implicitly tested via prepareSession above, + // but we can add unit tests for CloudAgentSession class directly if needed. + // For now, the integration tests cover this path. + }); + + it('should fail if already prepared', async () => { + const doStub = createMockDOStub({ + prepare: vi.fn().mockResolvedValue({ success: false, error: 'Session already prepared' }), + }); + const ctx = createInternalApiContext({ doStub }); + + const caller = appRouter.createCaller(ctx); + + await expect( + caller.prepareSession({ + prompt: 'Test', + mode: 'build', + model: 'test', + githubRepo: 'acme/repo', + githubToken: 'ghp_test_token', + }) + ).rejects.toThrow('Session already prepared'); + }); + }); + + describe('tryUpdate()', () => { + it('should update only if prepared but not initiated', async () => { + const doStub = createMockDOStub({ + tryUpdate: vi.fn().mockResolvedValue({ success: true }), + }); + const ctx = createInternalApiContext({ doStub }); + + const caller = appRouter.createCaller(ctx); + const result = await caller.updateSession({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc' as SessionId, + mode: 'build', + }); + + expect(result.success).toBe(true); + }); + + it('should handle null values for clearing fields', async () => { + const doStub = createMockDOStub({ + tryUpdate: vi.fn().mockResolvedValue({ success: true }), + }); + const ctx = createInternalApiContext({ doStub }); + + const caller = appRouter.createCaller(ctx); + await caller.updateSession({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc' as SessionId, + gitToken: null, + }); + + expect(doStub.tryUpdate).toHaveBeenCalledWith(expect.objectContaining({ gitToken: null })); + }); + }); + + describe('tryInitiate()', () => { + it('should set initiatedAt and return metadata on success', async () => { + const metadata: CloudAgentSessionState = { + version: Date.now(), + sessionId: 'agent_12345678-1234-1234-1234-123456789abc', + userId: 'test-user', + timestamp: Date.now(), + preparedAt: Date.now() - 1000, + initiatedAt: Date.now(), + prompt: 'Test prompt', + mode: 'build', + model: 'claude-3', + kiloSessionId: '123e4567-e89b-12d3-a456-426614174000', + githubRepo: 'acme/repo', + }; + + const doStub = createMockDOStub({ + tryInitiate: vi.fn().mockResolvedValue({ success: true, data: metadata }), + }); + + // tryInitiate is called internally by initiateFromKilocodeSession + // when using prepared session mode + expect(doStub.tryInitiate).toBeDefined(); + }); + + it('should fail if session not prepared', async () => { + const doStub = createMockDOStub({ + tryInitiate: vi.fn().mockResolvedValue({ + success: false, + error: 'Session has not been prepared', + }), + }); + + const result = await doStub.tryInitiate(); + expect(result.success).toBe(false); + expect(result.error).toBe('Session has not been prepared'); + }); + + it('should fail if session already initiated', async () => { + const doStub = createMockDOStub({ + tryInitiate: vi.fn().mockResolvedValue({ + success: false, + error: 'Session has already been initiated', + }), + }); + + const result = await doStub.tryInitiate(); + expect(result.success).toBe(false); + expect(result.error).toBe('Session has already been initiated'); + }); + }); +}); + +describe('initiateFromKilocodeSession (prepared mode)', () => { + // Note: Testing subscription endpoints is more complex. + // Here we test the validation and initialization logic only. + // Full integration tests would require consuming the stream. + + describe('input validation', () => { + it('should accept prepared session input (cloudAgentSessionId only)', () => { + // This tests the schema via Zod's safeParse + const result = schemas.InitiateFromPreparedSessionInput.safeParse({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc', + }); + expect(result.success).toBe(true); + }); + + it('should reject invalid cloudAgentSessionId format', () => { + const result = schemas.InitiateFromPreparedSessionInput.safeParse({ + cloudAgentSessionId: 'invalid-id', + }); + expect(result.success).toBe(false); + }); + }); +}); + +describe('PrepareSessionInput schema validation', () => { + it('should accept all valid modes', () => { + // All valid modes including aliases + const modes = ['plan', 'code', 'build', 'orchestrator', 'architect', 'ask']; + for (const mode of modes) { + const result = schemas.PrepareSessionInput.safeParse({ + prompt: 'Test', + mode, + model: 'claude-3', + githubRepo: 'acme/repo', + }); + expect(result.success).toBe(true); + } + }); + + it('requires appendSystemPrompt for custom mode', () => { + const missingPrompt = schemas.PrepareSessionInput.safeParse({ + prompt: 'Test', + mode: 'custom', + model: 'claude-3', + githubRepo: 'acme/repo', + }); + expect(missingPrompt.success).toBe(false); + + const withPrompt = schemas.PrepareSessionInput.safeParse({ + prompt: 'Test', + mode: 'custom', + model: 'claude-3', + githubRepo: 'acme/repo', + appendSystemPrompt: 'Follow the house style.', + }); + expect(withPrompt.success).toBe(true); + }); + + it('should validate githubRepo format', () => { + // Valid formats + const validRepos = ['acme/repo', 'a/b', 'org_name/repo-name', 'org.name/repo.name']; + for (const repo of validRepos) { + const result = schemas.PrepareSessionInput.safeParse({ + prompt: 'Test', + mode: 'build', + model: 'claude-3', + githubRepo: repo, + }); + expect(result.success).toBe(true); + } + + // Invalid formats + const invalidRepos = ['just-repo', '', 'https://github.com/acme/repo']; + for (const repo of invalidRepos) { + const result = schemas.PrepareSessionInput.safeParse({ + prompt: 'Test', + mode: 'build', + model: 'claude-3', + githubRepo: repo, + }); + expect(result.success).toBe(false); + } + }); + + it('should validate gitUrl format', () => { + // Valid formats + const validUrls = [ + 'https://gitlab.com/org/repo.git', + 'https://bitbucket.org/org/repo.git', + 'https://github.mycompany.com/org/repo.git', + ]; + for (const gitUrl of validUrls) { + const result = schemas.PrepareSessionInput.safeParse({ + prompt: 'Test', + mode: 'build', + model: 'claude-3', + gitUrl, + }); + expect(result.success).toBe(true); + } + + // Invalid formats + const invalidUrls = ['not-a-url', 'git@github.com:org/repo.git', 'ftp://example.com/repo']; + for (const gitUrl of invalidUrls) { + const result = schemas.PrepareSessionInput.safeParse({ + prompt: 'Test', + mode: 'build', + model: 'claude-3', + gitUrl, + }); + expect(result.success).toBe(false); + } + }); + + it('should limit prompt length', () => { + const longPrompt = 'a'.repeat(schemaLimits.Limits.MAX_PROMPT_LENGTH + 1); + const result = schemas.PrepareSessionInput.safeParse({ + prompt: longPrompt, + mode: 'build', + model: 'claude-3', + githubRepo: 'acme/repo', + }); + expect(result.success).toBe(false); + }); + + it('should limit setup commands count', () => { + const tooManyCommands = Array(schemaLimits.Limits.MAX_SETUP_COMMANDS + 1).fill('echo test'); + const result = schemas.PrepareSessionInput.safeParse({ + prompt: 'Test', + mode: 'build', + model: 'claude-3', + githubRepo: 'acme/repo', + setupCommands: tooManyCommands, + }); + expect(result.success).toBe(false); + }); + + it('should limit setup command length', () => { + const longCommand = 'a'.repeat(schemaLimits.Limits.MAX_SETUP_COMMAND_LENGTH + 1); + const result = schemas.PrepareSessionInput.safeParse({ + prompt: 'Test', + mode: 'build', + model: 'claude-3', + githubRepo: 'acme/repo', + setupCommands: [longCommand], + }); + expect(result.success).toBe(false); + }); +}); + +describe('UpdateSessionInput schema validation', () => { + it('should accept valid cloudAgentSessionId', () => { + const result = schemas.UpdateSessionInput.safeParse({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc', + }); + expect(result.success).toBe(true); + }); + + it('should reject invalid cloudAgentSessionId', () => { + const result = schemas.UpdateSessionInput.safeParse({ + cloudAgentSessionId: 'invalid-session-id', + }); + expect(result.success).toBe(false); + }); + + it('should accept nullable scalar fields', () => { + const result = schemas.UpdateSessionInput.safeParse({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc', + mode: null, + model: null, + githubToken: null, + gitToken: null, + autoCommit: null, + }); + expect(result.success).toBe(true); + }); + + it('should accept optional fields being undefined', () => { + const result = schemas.UpdateSessionInput.safeParse({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc', + }); + expect(result.success).toBe(true); + // All optional fields should be undefined + if (result.success) { + expect(result.data.mode).toBeUndefined(); + expect(result.data.model).toBeUndefined(); + expect(result.data.envVars).toBeUndefined(); + } + }); + + it('should validate mode values', () => { + // All modes except 'custom' don't require appendSystemPrompt + // Includes aliases: architect/ask -> plan, orchestrator/build -> code + const modesWithoutPrompt = ['plan', 'code', 'build', 'orchestrator', 'architect', 'ask', null]; + for (const mode of modesWithoutPrompt) { + const result = schemas.UpdateSessionInput.safeParse({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc', + mode, + }); + expect(result.success).toBe(true); + } + + const result = schemas.UpdateSessionInput.safeParse({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc', + mode: 'invalid-mode', + }); + expect(result.success).toBe(false); + }); + + it('requires appendSystemPrompt for custom mode updates', () => { + const missingPrompt = schemas.UpdateSessionInput.safeParse({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc', + mode: 'custom', + }); + expect(missingPrompt.success).toBe(false); + + const withPrompt = schemas.UpdateSessionInput.safeParse({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc', + mode: 'custom', + appendSystemPrompt: 'Custom rules.', + }); + expect(withPrompt.success).toBe(true); + }); +}); + +describe('integration flow tests', () => { + describe('full prepare → update → initiate flow', () => { + it('should work end-to-end', async () => { + // This is a conceptual test showing the expected flow + // Real integration testing would require consuming SSE streams + + // 1. Prepare session + const prepareStub = createMockDOStub({ + prepare: vi.fn().mockResolvedValue({ success: true }), + }); + const prepareCtx = createInternalApiContext({ doStub: prepareStub }); + const prepareCaller = appRouter.createCaller(prepareCtx); + + const prepareResult = await prepareCaller.prepareSession({ + prompt: 'Test prompt', + mode: 'build', + model: 'claude-3', + githubRepo: 'acme/repo', + githubToken: 'ghp_test_token', + }); + + expect(prepareResult.cloudAgentSessionId).toBeDefined(); + expect(prepareResult.kiloSessionId).toBeDefined(); + + // 2. Update session + const updateStub = createMockDOStub({ + tryUpdate: vi.fn().mockResolvedValue({ success: true }), + }); + const updateCtx = createInternalApiContext({ doStub: updateStub }); + const updateCaller = appRouter.createCaller(updateCtx); + + const updateResult = await updateCaller.updateSession({ + cloudAgentSessionId: prepareResult.cloudAgentSessionId as SessionId, + mode: 'plan', + envVars: { DEBUG: 'true' }, + }); + + expect(updateResult.success).toBe(true); + + // 3. Initiate would be via SSE subscription - not tested here + // See session-init.ts for implementation + }); + }); +}); + +describe('MCP server count limits', () => { + it('should reject more than MAX_MCP_SERVERS in PrepareSessionInput', () => { + // Create an object with MAX_MCP_SERVERS + 1 servers + const tooManyServers: Record = {}; + for (let i = 0; i <= schemaLimits.Limits.MAX_MCP_SERVERS; i++) { + tooManyServers[`server${i}`] = { command: 'npx' }; + } + + const result = schemas.PrepareSessionInput.safeParse({ + prompt: 'Test', + mode: 'build', + model: 'claude-3', + githubRepo: 'acme/repo', + mcpServers: tooManyServers, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain('MCP servers'); + } + }); + + it('should accept exactly MAX_MCP_SERVERS in PrepareSessionInput', () => { + const maxServers: Record = {}; + for (let i = 0; i < schemaLimits.Limits.MAX_MCP_SERVERS; i++) { + maxServers[`server${i}`] = { command: 'npx' }; + } + + const result = schemas.PrepareSessionInput.safeParse({ + prompt: 'Test', + mode: 'build', + model: 'claude-3', + githubRepo: 'acme/repo', + mcpServers: maxServers, + }); + expect(result.success).toBe(true); + }); + + it('should reject more than MAX_MCP_SERVERS in UpdateSessionInput', () => { + const tooManyServers: Record = {}; + for (let i = 0; i <= schemaLimits.Limits.MAX_MCP_SERVERS; i++) { + tooManyServers[`server${i}`] = { command: 'npx' }; + } + + const result = schemas.UpdateSessionInput.safeParse({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc', + mcpServers: tooManyServers, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain('MCP servers'); + } + }); +}); + +describe('DO state machine edge cases', () => { + describe('tryInitiate required-field validation', () => { + it('should fail when metadata is missing prompt', async () => { + const incompleteMetadata: Partial = { + version: Date.now(), + sessionId: 'agent_12345678-1234-1234-1234-123456789abc', + userId: 'test-user', + timestamp: Date.now(), + preparedAt: Date.now() - 1000, + initiatedAt: Date.now(), + // Missing: prompt + mode: 'build', + model: 'claude-3', + kiloSessionId: '123e4567-e89b-12d3-a456-426614174000', + githubRepo: 'acme/repo', + }; + + const doStub = createMockDOStub({ + tryInitiate: vi.fn().mockResolvedValue({ + success: true, + data: incompleteMetadata, + }), + }); + + // The router handler should validate required fields after tryInitiate + // This tests that the validation catches missing prompt + const result = await doStub.tryInitiate(); + expect(result.success).toBe(true); + expect(result.data.prompt).toBeUndefined(); + }); + + it('should fail when metadata is missing mode', async () => { + const incompleteMetadata: Partial = { + version: Date.now(), + sessionId: 'agent_12345678-1234-1234-1234-123456789abc', + userId: 'test-user', + timestamp: Date.now(), + preparedAt: Date.now() - 1000, + initiatedAt: Date.now(), + prompt: 'Test prompt', + // Missing: mode + model: 'claude-3', + kiloSessionId: '123e4567-e89b-12d3-a456-426614174000', + githubRepo: 'acme/repo', + }; + + const doStub = createMockDOStub({ + tryInitiate: vi.fn().mockResolvedValue({ + success: true, + data: incompleteMetadata, + }), + }); + + const result = await doStub.tryInitiate(); + expect(result.success).toBe(true); + expect(result.data.mode).toBeUndefined(); + }); + + it('should fail when metadata is missing kiloSessionId', async () => { + const incompleteMetadata: Partial = { + version: Date.now(), + sessionId: 'agent_12345678-1234-1234-1234-123456789abc', + userId: 'test-user', + timestamp: Date.now(), + preparedAt: Date.now() - 1000, + initiatedAt: Date.now(), + prompt: 'Test prompt', + mode: 'build', + model: 'claude-3', + // Missing: kiloSessionId + githubRepo: 'acme/repo', + }; + + const doStub = createMockDOStub({ + tryInitiate: vi.fn().mockResolvedValue({ + success: true, + data: incompleteMetadata, + }), + }); + + const result = await doStub.tryInitiate(); + expect(result.success).toBe(true); + expect(result.data.kiloSessionId).toBeUndefined(); + }); + }); + + describe('double-init guard', () => { + it('should prevent double initiation', async () => { + const doStub = createMockDOStub({ + tryInitiate: vi + .fn() + .mockResolvedValueOnce({ + success: true, + data: { + version: Date.now(), + sessionId: 'agent_12345678-1234-1234-1234-123456789abc', + userId: 'test-user', + timestamp: Date.now(), + preparedAt: Date.now() - 1000, + initiatedAt: Date.now(), + prompt: 'Test prompt', + mode: 'build', + model: 'claude-3', + kiloSessionId: '123e4567-e89b-12d3-a456-426614174000', + githubRepo: 'acme/repo', + }, + }) + .mockResolvedValueOnce({ + success: false, + error: 'Session has already been initiated', + }), + }); + + // First call succeeds + const firstResult = await doStub.tryInitiate(); + expect(firstResult.success).toBe(true); + + // Second call fails + const secondResult = await doStub.tryInitiate(); + expect(secondResult.success).toBe(false); + expect(secondResult.error).toBe('Session has already been initiated'); + }); + + it('should preserve initiatedAt timestamp on second call attempt', async () => { + // Simulate a real DO where storage is tracked + const firstInitiatedAt = Date.now(); + let storedMetadata: CloudAgentSessionState | null = null; + + const doStub = createMockDOStub({ + tryInitiate: vi.fn().mockImplementation(async () => { + // First call: session is prepared but not initiated + if (!storedMetadata || !storedMetadata.initiatedAt) { + storedMetadata = { + version: Date.now(), + sessionId: 'agent_12345678-1234-1234-1234-123456789abc', + userId: 'test-user', + timestamp: Date.now(), + preparedAt: Date.now() - 1000, + initiatedAt: firstInitiatedAt, + prompt: 'Test prompt', + mode: 'build', + model: 'claude-3', + kiloSessionId: '123e4567-e89b-12d3-a456-426614174000', + githubRepo: 'acme/repo', + }; + return { success: true, data: storedMetadata }; + } + // Second call: session already initiated, storage unchanged + return { success: false, error: 'Session has already been initiated' }; + }), + getMetadata: vi.fn().mockImplementation(async () => storedMetadata), + }); + + // First call succeeds and sets initiatedAt + const firstResult = await doStub.tryInitiate(); + expect(firstResult.success).toBe(true); + expect(firstResult.data.initiatedAt).toBe(firstInitiatedAt); + + // Second call fails + const secondResult = await doStub.tryInitiate(); + expect(secondResult.success).toBe(false); + expect(secondResult.error).toBe('Session has already been initiated'); + + // Verify storage wasn't mutated - initiatedAt is still the first timestamp + const metadata = await doStub.getMetadata(); + expect(metadata).not.toBeNull(); + expect(metadata!.initiatedAt).toBe(firstInitiatedAt); + }); + }); + + describe('null clearing in tryUpdate', () => { + it('should pass null values to DO for clearing scalar fields', async () => { + const doStub = createMockDOStub({ + tryUpdate: vi.fn().mockResolvedValue({ success: true }), + }); + const ctx = createInternalApiContext({ doStub }); + const caller = appRouter.createCaller(ctx); + + await caller.updateSession({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc' as SessionId, + mode: null, + model: null, + githubToken: null, + gitToken: null, + autoCommit: null, + }); + + expect(doStub.tryUpdate).toHaveBeenCalledWith({ + mode: null, + model: null, + githubToken: null, + gitToken: null, + autoCommit: null, + }); + }); + }); + + describe('empty object/array clearing in tryUpdate', () => { + it('should convert empty envVars to null for clearing', async () => { + const doStub = createMockDOStub({ + tryUpdate: vi.fn().mockResolvedValue({ success: true }), + }); + const ctx = createInternalApiContext({ doStub }); + const caller = appRouter.createCaller(ctx); + + await caller.updateSession({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc' as SessionId, + envVars: {}, + }); + + expect(doStub.tryUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + envVars: null, + }) + ); + }); + + it('should convert empty setupCommands to null for clearing', async () => { + const doStub = createMockDOStub({ + tryUpdate: vi.fn().mockResolvedValue({ success: true }), + }); + const ctx = createInternalApiContext({ doStub }); + const caller = appRouter.createCaller(ctx); + + await caller.updateSession({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc' as SessionId, + setupCommands: [], + }); + + expect(doStub.tryUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + setupCommands: null, + }) + ); + }); + + it('should convert empty mcpServers to null for clearing', async () => { + const doStub = createMockDOStub({ + tryUpdate: vi.fn().mockResolvedValue({ success: true }), + }); + const ctx = createInternalApiContext({ doStub }); + const caller = appRouter.createCaller(ctx); + + await caller.updateSession({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc' as SessionId, + mcpServers: {}, + }); + + expect(doStub.tryUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + mcpServers: null, + }) + ); + }); + + it('should preserve non-empty collections', async () => { + const doStub = createMockDOStub({ + tryUpdate: vi.fn().mockResolvedValue({ success: true }), + }); + const ctx = createInternalApiContext({ doStub }); + const caller = appRouter.createCaller(ctx); + + await caller.updateSession({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc' as SessionId, + envVars: { KEY: 'value' }, + setupCommands: ['npm install'], + }); + + expect(doStub.tryUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + envVars: { KEY: 'value' }, + setupCommands: ['npm install'], + }) + ); + }); + }); + + describe('schema validation in DO methods', () => { + it('should reject invalid metadata in prepare via schema validation', async () => { + // This tests that the DO's prepare method validates against MetadataSchema + // The mock simulates what happens when schema validation fails + const doStub = createMockDOStub({ + prepare: vi.fn().mockResolvedValue({ + success: false, + error: 'Invalid metadata: {"mode":{"_errors":["Invalid enum value"]}}', + }), + }); + const ctx = createInternalApiContext({ doStub }); + const caller = appRouter.createCaller(ctx); + + await expect( + caller.prepareSession({ + prompt: 'Test', + mode: 'build', + model: 'claude-3', + githubRepo: 'acme/repo', + githubToken: 'ghp_test_token', + }) + ).rejects.toThrow('Invalid metadata'); + }); + + it('should reject invalid metadata in tryUpdate via schema validation', async () => { + // This tests that the DO's tryUpdate method validates against MetadataSchema + const doStub = createMockDOStub({ + tryUpdate: vi.fn().mockResolvedValue({ + success: false, + error: 'Invalid metadata after update: {"mode":{"_errors":["Invalid enum value"]}}', + }), + }); + const ctx = createInternalApiContext({ doStub }); + const caller = appRouter.createCaller(ctx); + + await expect( + caller.updateSession({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc' as SessionId, + mode: 'plan', + }) + ).rejects.toThrow('Invalid metadata after update'); + }); + }); +}); diff --git a/cloud-agent-next/src/session-service.test.ts b/cloud-agent-next/src/session-service.test.ts new file mode 100644 index 0000000000..dacbba9746 --- /dev/null +++ b/cloud-agent-next/src/session-service.test.ts @@ -0,0 +1,3254 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@cloudflare/sandbox', () => ({ + getSandbox: vi.fn(), +})); + +vi.mock('./workspace.js', () => { + const setupWorkspace = vi.fn(); + const cloneGitHubRepo = vi.fn(); + const cloneGitRepo = vi.fn(); + const manageBranch = vi.fn(); + const checkDiskSpace = vi.fn().mockResolvedValue({ availableMB: 5000, totalMB: 10000 }); + + return { + setupWorkspace, + cloneGitHubRepo, + cloneGitRepo, + manageBranch, + checkDiskSpace, + getSessionHomePath: (sessionId: string) => `/home/${sessionId}`, + getSessionWorkspacePath: (orgId: string, userId: string, sessionId: string) => + `/workspace/${orgId}/${userId}/sessions/${sessionId}`, + getKilocodeCliDir: (sessionHome: string) => `${sessionHome}/.kilocode/cli`, + getKilocodeTasksDir: (sessionHome: string) => `${sessionHome}/.kilocode/cli/global/tasks`, + getKilocodeLogsDir: (sessionHome: string) => `${sessionHome}/.kilocode/cli/logs`, + }; +}); + +const streamKilocodeExecutionMock = vi.hoisted(() => vi.fn()); +vi.mock('./streaming.js', () => ({ + streamKilocodeExecution: streamKilocodeExecutionMock, +})); + +import { + setupWorkspace as mockSetupWorkspace, + cloneGitHubRepo as mockCloneGitHubRepo, + manageBranch as mockManageBranch, +} from './workspace.js'; +import { InvalidSessionMetadataError, SessionService } from './session-service.js'; +import type { SandboxInstance, SessionId, SessionContext, ExecutionSession } from './types.js'; +import type { PersistenceEnv, CloudAgentSessionState } from './persistence/types.js'; + +describe('SessionService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const mockedSetupWorkspace = vi.mocked(mockSetupWorkspace); + + // Mock environment for tests + const mockEnv: PersistenceEnv = { + Sandbox: {} as unknown as PersistenceEnv['Sandbox'], + CLOUD_AGENT_SESSION: { + idFromName: vi.fn().mockReturnValue('mock-id' as unknown as DurableObjectId), + get: vi.fn().mockReturnValue({ + getMetadata: vi.fn().mockResolvedValue({ + version: 12345, + sessionId: 'test', + orgId: 'org', + userId: 'user', + timestamp: 12345, + }), + updateMetadata: vi.fn().mockResolvedValue(undefined), + deleteSession: vi.fn().mockResolvedValue(undefined), + }), + } as unknown as PersistenceEnv['CLOUD_AGENT_SESSION'], + NEXTAUTH_SECRET: 'mock-secret', + }; + + const createMetadataEnv = ( + overrides?: Partial<{ + getMetadata: ReturnType; + updateMetadata: ReturnType; + updateUpstreamBranch: ReturnType; + deleteSession: ReturnType; + }> + ) => { + const metadataStub = { + getMetadata: vi.fn().mockResolvedValue(null), + updateMetadata: vi.fn().mockResolvedValue(undefined), + updateUpstreamBranch: vi.fn().mockResolvedValue(undefined), + deleteSession: vi.fn().mockResolvedValue(undefined), + ...overrides, + } as unknown as ReturnType; + + const env: PersistenceEnv = { + ...mockEnv, + CLOUD_AGENT_SESSION: { + idFromName: vi.fn(() => 'mock-do-id' as unknown as DurableObjectId), + get: vi.fn().mockReturnValue(metadataStub), + } as unknown as PersistenceEnv['CLOUD_AGENT_SESSION'], + }; + + return { env, metadataStub }; + }; + + describe('initiate', () => { + it('provisions workspace, clones repo, and creates session branch directly', async () => { + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandboxCreateSession = vi.fn().mockResolvedValue(fakeSession); + const sandbox = { + createSession: sandboxCreateSession, + mkdir: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + const sessionId: SessionId = 'agent_test_123'; + mockedSetupWorkspace.mockResolvedValue({ + workspacePath: `/workspace/org/user/sessions/${sessionId}`, + sessionHome: `/home/${sessionId}`, + }); + + const service = new SessionService(); + const result = await service.initiate({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + githubRepo: 'acme/repo', + env: mockEnv, + }); + + expect(mockSetupWorkspace).toHaveBeenCalledWith(sandbox, 'user', 'org', sessionId); + expect(sandboxCreateSession).toHaveBeenCalledWith({ + name: sessionId, + env: { + HOME: `/home/${sessionId}`, + SESSION_ID: sessionId, + SESSION_HOME: `/home/${sessionId}`, + KILOCODE_TOKEN: 'token', + KILOCODE_ORGANIZATION_ID: 'org', + KILO_PLATFORM: 'cloud-agent', + OPENCODE_CONFIG_CONTENT: `{"permission":{"external_directory":{"/tmp/attachments/${sessionId}/**":"allow"}},"provider":{"kilo":{"options":{"apiKey":"token","kilocodeToken":"token","kilocodeOrganizationId":"org"}}},"model":"kilo/test-model"}`, + KILO_CONFIG_CONTENT: `{"permission":{"external_directory":{"/tmp/attachments/${sessionId}/**":"allow"}},"provider":{"kilo":{"options":{"apiKey":"token","kilocodeToken":"token","kilocodeOrganizationId":"org"}}},"model":"kilo/test-model"}`, + }, + cwd: `/workspace/org/user/sessions/${sessionId}`, + }); + expect(mockCloneGitHubRepo).toHaveBeenCalledWith( + fakeSession, + `/workspace/org/user/sessions/${sessionId}`, + 'acme/repo', + undefined, + { GITHUB_APP_SLUG: undefined, GITHUB_APP_BOT_USER_ID: undefined }, + undefined + ); + // For session branches, manageBranch should NOT be called + expect(mockManageBranch).not.toHaveBeenCalled(); + // Instead, session.exec should be called with git checkout -b + expect(fakeSession.exec).toHaveBeenCalledWith( + expect.stringContaining(`git checkout -b 'session/${sessionId}'`) + ); + expect(result.context.sessionId).toBe(sessionId); + expect(result.streamKilocodeExec).toBeDefined(); + }); + + it('uses manageBranch for upstream branches during initiate', async () => { + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandboxCreateSession = vi.fn().mockResolvedValue(fakeSession); + const sandbox = { + createSession: sandboxCreateSession, + mkdir: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + const sessionId: SessionId = 'agent_test_456'; + mockedSetupWorkspace.mockResolvedValue({ + workspacePath: `/workspace/org/user/sessions/${sessionId}`, + sessionHome: `/home/${sessionId}`, + }); + + const service = new SessionService(); + const upstreamBranch = 'feature/my-branch'; + await service.initiate({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + githubRepo: 'acme/repo', + env: mockEnv, + upstreamBranch, + }); + + // For upstream branches, manageBranch SHOULD be called + expect(mockManageBranch).toHaveBeenCalledWith( + fakeSession, + `/workspace/org/user/sessions/${sessionId}`, + upstreamBranch, + true + ); + // git checkout -b should NOT be called directly + expect(fakeSession.exec).not.toHaveBeenCalledWith(expect.stringContaining('git checkout -b')); + }); + }); + + describe('resume', () => { + it('resumes existing session (warm start)', async () => { + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, stdout: 'exists' }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandboxCreateSession = vi.fn().mockResolvedValue(fakeSession); + const sandbox = { + createSession: sandboxCreateSession, + mkdir: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + const service = new SessionService(); + const sessionId: SessionId = 'agent_test_456'; + const result = await service.resume({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + env: mockEnv, + }); + + expect(sandboxCreateSession).toHaveBeenCalledWith({ + name: sessionId, + env: { + HOME: `/home/${sessionId}`, + SESSION_ID: sessionId, + SESSION_HOME: `/home/${sessionId}`, + KILOCODE_TOKEN: 'token', + KILOCODE_ORGANIZATION_ID: 'org', + KILO_PLATFORM: 'cloud-agent', + OPENCODE_CONFIG_CONTENT: `{"permission":{"external_directory":{"/tmp/attachments/${sessionId}/**":"allow"}},"provider":{"kilo":{"options":{"apiKey":"token","kilocodeToken":"token","kilocodeOrganizationId":"org"}}},"model":"kilo/test-model"}`, + KILO_CONFIG_CONTENT: `{"permission":{"external_directory":{"/tmp/attachments/${sessionId}/**":"allow"}},"provider":{"kilo":{"options":{"apiKey":"token","kilocodeToken":"token","kilocodeOrganizationId":"org"}}},"model":"kilo/test-model"}`, + }, + cwd: `/workspace/org/user/sessions/${sessionId}`, + }); + // manageBranch should NOT be called when repo exists (warm start) + expect(mockManageBranch).not.toHaveBeenCalled(); + expect(result.context.sessionId).toBe(sessionId); + expect(result.streamKilocodeExec).toBeDefined(); + }); + }); + + describe('streamKilocodeExec first-execution handling', () => { + const noopStream = async function* () {}; + + it('passes isFirstExecution=true only on first initiate call', async () => { + streamKilocodeExecutionMock.mockReturnValue(noopStream()); + + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandbox = { + createSession: vi.fn().mockResolvedValue(fakeSession), + mkdir: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + const sessionId: SessionId = 'agent_first_call'; + mockedSetupWorkspace.mockResolvedValue({ + workspacePath: `/workspace/org/user/sessions/${sessionId}`, + sessionHome: `/home/${sessionId}`, + }); + + const service = new SessionService(); + const result = await service.initiate({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + githubRepo: 'acme/repo', + env: mockEnv, + }); + + // Consume the generators to trigger the underlying streamKilocodeExecution calls + for await (const _ of result.streamKilocodeExec('code', 'prompt-1')) { + // noop - just consume + } + for await (const _ of result.streamKilocodeExec('code', 'prompt-2', { + sessionId: 'custom-session', + })) { + // noop - just consume + } + + expect(streamKilocodeExecutionMock).toHaveBeenNthCalledWith( + 1, + sandbox, + fakeSession, + expect.objectContaining({ sessionId }), + 'code', + 'prompt-1', + { isFirstExecution: true, kiloSessionId: undefined }, + mockEnv + ); + expect(streamKilocodeExecutionMock).toHaveBeenNthCalledWith( + 2, + sandbox, + fakeSession, + expect.objectContaining({ sessionId }), + 'code', + 'prompt-2', + { sessionId: 'custom-session', isFirstExecution: false, kiloSessionId: undefined }, + mockEnv + ); + }); + + it('always passes isFirstExecution=false when resuming', async () => { + streamKilocodeExecutionMock.mockReturnValue(noopStream()); + + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0, stdout: 'exists' }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandbox = { + createSession: vi.fn().mockResolvedValue(fakeSession), + mkdir: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + const sessionId: SessionId = 'agent_resume_first_flag'; + + const service = new SessionService(); + const result = await service.resume({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + env: mockEnv, + }); + + result.streamKilocodeExec('code', 'prompt'); + + expect(streamKilocodeExecutionMock).toHaveBeenCalledWith( + sandbox, + fakeSession, + expect.objectContaining({ sessionId }), + 'code', + 'prompt', + expect.objectContaining({ isFirstExecution: false, kiloSessionId: undefined }), + mockEnv + ); + }); + + it('passes kiloSessionId from metadata when resuming', async () => { + streamKilocodeExecutionMock.mockReturnValue(noopStream()); + + const kiloSessionId = '123e4567-e89b-12d3-a456-426614174000'; + const { env: metadataEnv } = createMetadataEnv({ + getMetadata: vi.fn().mockResolvedValue({ + version: 12345, + sessionId: 'agent_resume_kilo', + orgId: 'org', + userId: 'user', + timestamp: 12345, + kiloSessionId, + }), + }); + + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0, stdout: 'exists' }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandbox = { + createSession: vi.fn().mockResolvedValue(fakeSession), + mkdir: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + + const service = new SessionService(); + const result = await service.resume({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId: 'agent_resume_kilo', + kilocodeToken: 'token', + kilocodeModel: 'test-model', + env: metadataEnv, + }); + + result.streamKilocodeExec('code', 'prompt'); + + expect(streamKilocodeExecutionMock).toHaveBeenCalledWith( + sandbox, + fakeSession, + expect.objectContaining({ sessionId: 'agent_resume_kilo' }), + 'code', + 'prompt', + expect.objectContaining({ isFirstExecution: false, kiloSessionId }), + metadataEnv + ); + }); + + it('captures and reuses kiloSessionId from session_created event', async () => { + const capturedKiloSessionId = '123e4567-e89b-12d3-a456-426614174000'; + + // Mock stream that emits session_created event + const mockStreamWithSessionCreated = async function* () { + yield { + streamEventType: 'kilocode', + payload: { + event: 'session_created', + sessionId: capturedKiloSessionId, + }, + }; + }; + + streamKilocodeExecutionMock.mockReturnValue(mockStreamWithSessionCreated()); + + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandbox = { + createSession: vi.fn().mockResolvedValue(fakeSession), + mkdir: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + const sessionId: SessionId = 'agent_capture_test'; + mockedSetupWorkspace.mockResolvedValue({ + workspacePath: `/workspace/org/user/sessions/${sessionId}`, + sessionHome: `/home/${sessionId}`, + }); + + const service = new SessionService(); + const result = await service.initiate({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + githubRepo: 'acme/repo', + env: mockEnv, + }); + + // First call - should not have kiloSessionId + for await (const _ of result.streamKilocodeExec('code', 'prompt-1')) { + // noop - consumes stream and captures sessionId + } + + // Second call - should reuse captured kiloSessionId + streamKilocodeExecutionMock.mockReturnValue(noopStream()); + for await (const _ of result.streamKilocodeExec('code', 'prompt-2')) { + // noop + } + + // Verify first call had no kiloSessionId + expect(streamKilocodeExecutionMock).toHaveBeenNthCalledWith( + 1, + sandbox, + fakeSession, + expect.objectContaining({ sessionId }), + 'code', + 'prompt-1', + { isFirstExecution: true, kiloSessionId: undefined }, + mockEnv + ); + + // Verify second call reused captured kiloSessionId + expect(streamKilocodeExecutionMock).toHaveBeenNthCalledWith( + 2, + sandbox, + fakeSession, + expect.objectContaining({ sessionId }), + 'code', + 'prompt-2', + { isFirstExecution: false, kiloSessionId: capturedKiloSessionId }, + mockEnv + ); + }); + }); + + describe('resume with conditional reclone', () => { + const sessionId: SessionId = 'agent_test_789'; + const orgId = 'org123'; + const userId = 'user456'; + + it('should reclone repository when workspace is missing and metadata exists', async () => { + const fakeSession = { + exec: vi + .fn() + .mockResolvedValueOnce({ success: true, exitCode: 1, stdout: '', stderr: '' }) // repo check fails + .mockResolvedValue({ success: true, exitCode: 0 }), // subsequent calls succeed + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + }; + const sandbox = { + createSession: vi.fn().mockResolvedValue(fakeSession), + mkdir: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + + const mockDOGetMetadata = vi.fn(); + const testEnv = { + ...mockEnv, + CLOUD_AGENT_SESSION: { + idFromName: vi.fn(() => 'mock-do-id' as unknown as DurableObjectId), + get: vi.fn(() => ({ + getMetadata: mockDOGetMetadata, + updateMetadata: vi.fn().mockResolvedValue(undefined), + deleteSession: vi.fn().mockResolvedValue(undefined), + })), + } as unknown as PersistenceEnv['CLOUD_AGENT_SESSION'], + }; + + // Mock: DO returns metadata with repo info + const metadata = { + version: 123456789, + sessionId, + orgId, + userId, + timestamp: 123456789, + githubRepo: 'facebook/react', + githubToken: 'test-token', + }; + mockDOGetMetadata.mockResolvedValue(metadata); + + const service = new SessionService(); + const result = await service.resume({ + sandbox, + sandboxId: `${orgId}__${userId}`, + orgId, + userId, + sessionId, + kilocodeToken: 'test-token', + kilocodeModel: 'test-model', + env: testEnv, + }); + + // Verify cloneGitHubRepo was called + expect(mockCloneGitHubRepo).toHaveBeenCalledWith( + fakeSession, + `/workspace/${orgId}/${userId}/sessions/${sessionId}`, + 'facebook/react', + 'test-token', + { GITHUB_APP_SLUG: undefined, GITHUB_APP_BOT_USER_ID: undefined } + ); + + // manageBranch should NOT be called - kilocode CLI handles branch restoration + expect(mockManageBranch).not.toHaveBeenCalled(); + + // Verify context includes repo info + expect(result.context.githubRepo).toBe('facebook/react'); + expect(result.context.githubToken).toBe('test-token'); + }); + + it('should use fresh githubToken from request instead of stale metadata token during reclone', async () => { + const fakeSession = { + exec: vi + .fn() + .mockResolvedValueOnce({ success: true, exitCode: 1, stdout: '', stderr: '' }) // repo check fails + .mockResolvedValue({ success: true, exitCode: 0 }), // subsequent calls succeed + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + }; + const sandbox = { + createSession: vi.fn().mockResolvedValue(fakeSession), + mkdir: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + + const mockDOGetMetadata = vi.fn(); + const testEnv = { + ...mockEnv, + CLOUD_AGENT_SESSION: { + idFromName: vi.fn(() => 'mock-do-id' as unknown as DurableObjectId), + get: vi.fn(() => ({ + getMetadata: mockDOGetMetadata, + updateMetadata: vi.fn().mockResolvedValue(undefined), + deleteSession: vi.fn().mockResolvedValue(undefined), + })), + } as unknown as PersistenceEnv['CLOUD_AGENT_SESSION'], + }; + + // Mock: DO returns metadata with STALE token + const metadata = { + version: 123456789, + sessionId, + orgId, + userId, + timestamp: 123456789, + githubRepo: 'facebook/react', + githubToken: 'stale-token-from-metadata', + }; + mockDOGetMetadata.mockResolvedValue(metadata); + + const service = new SessionService(); + const freshToken = 'fresh-token-from-request'; + await service.resume({ + sandbox, + sandboxId: `${orgId}__${userId}`, + orgId, + userId, + sessionId, + kilocodeToken: 'test-token', + kilocodeModel: 'test-model', + env: testEnv, + // Pass fresh token from request + githubToken: freshToken, + }); + + // Verify cloneGitHubRepo was called with FRESH token, not stale metadata token + expect(mockCloneGitHubRepo).toHaveBeenCalledWith( + fakeSession, + `/workspace/${orgId}/${userId}/sessions/${sessionId}`, + 'facebook/react', + freshToken, // Should use fresh token, not 'stale-token-from-metadata' + { GITHUB_APP_SLUG: undefined, GITHUB_APP_BOT_USER_ID: undefined } + ); + }); + + it('should fall back to metadata token when no fresh token provided during reclone', async () => { + const fakeSession = { + exec: vi + .fn() + .mockResolvedValueOnce({ success: true, exitCode: 1, stdout: '', stderr: '' }) // repo check fails + .mockResolvedValue({ success: true, exitCode: 0 }), // subsequent calls succeed + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + }; + const sandbox = { + createSession: vi.fn().mockResolvedValue(fakeSession), + mkdir: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + + const mockDOGetMetadata = vi.fn(); + const testEnv = { + ...mockEnv, + CLOUD_AGENT_SESSION: { + idFromName: vi.fn(() => 'mock-do-id' as unknown as DurableObjectId), + get: vi.fn(() => ({ + getMetadata: mockDOGetMetadata, + updateMetadata: vi.fn().mockResolvedValue(undefined), + deleteSession: vi.fn().mockResolvedValue(undefined), + })), + } as unknown as PersistenceEnv['CLOUD_AGENT_SESSION'], + }; + + // Mock: DO returns metadata with token + const metadata = { + version: 123456789, + sessionId, + orgId, + userId, + timestamp: 123456789, + githubRepo: 'facebook/react', + githubToken: 'metadata-token', + }; + mockDOGetMetadata.mockResolvedValue(metadata); + + const service = new SessionService(); + await service.resume({ + sandbox, + sandboxId: `${orgId}__${userId}`, + orgId, + userId, + sessionId, + kilocodeToken: 'test-token', + kilocodeModel: 'test-model', + env: testEnv, + // No fresh token provided + }); + + // Verify cloneGitHubRepo was called with metadata token as fallback + expect(mockCloneGitHubRepo).toHaveBeenCalledWith( + fakeSession, + `/workspace/${orgId}/${userId}/sessions/${sessionId}`, + 'facebook/react', + 'metadata-token', // Should fall back to metadata token + { GITHUB_APP_SLUG: undefined, GITHUB_APP_BOT_USER_ID: undefined } + ); + }); + + it('should throw error when workspace is missing and no metadata exists', async () => { + const mockDOGetMetadata = vi.fn(); + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 1, stdout: '', stderr: '' }), + }; + const sandbox = { + createSession: vi.fn().mockResolvedValue(fakeSession), + mkdir: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + + const testEnv = { + ...mockEnv, + CLOUD_AGENT_SESSION: { + idFromName: vi.fn(() => 'mock-do-id' as unknown as DurableObjectId), + get: vi.fn(() => ({ + getMetadata: mockDOGetMetadata, + updateMetadata: vi.fn().mockResolvedValue(undefined), + deleteSession: vi.fn().mockResolvedValue(undefined), + })), + } as unknown as PersistenceEnv['CLOUD_AGENT_SESSION'], + }; + + // Mock: DO returns null + mockDOGetMetadata.mockResolvedValue(null); + + const service = new SessionService(); + await expect( + service.resume({ + sandbox, + sandboxId: `${orgId}__${userId}`, + orgId, + userId, + sessionId, + kilocodeToken: 'test-token', + kilocodeModel: 'test-model', + env: testEnv, + }) + ).rejects.toThrow('workspace is missing and metadata could not be retrieved'); + }); + + it('should load repo metadata even when restore fails but workspace exists', async () => { + const mockDOGetMetadata = vi.fn(); + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0, stdout: 'exists' }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + }; + const sandbox = { + createSession: vi.fn().mockResolvedValue(fakeSession), + mkdir: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + + const testEnv = { + ...mockEnv, + CLOUD_AGENT_SESSION: { + idFromName: vi.fn(() => 'mock-do-id' as unknown as DurableObjectId), + get: vi.fn(() => ({ + getMetadata: mockDOGetMetadata, + updateMetadata: vi.fn().mockResolvedValue(undefined), + deleteSession: vi.fn().mockResolvedValue(undefined), + })), + } as unknown as PersistenceEnv['CLOUD_AGENT_SESSION'], + }; + + const metadata = { + version: 123456789, + sessionId, + orgId, + userId, + timestamp: 123456789, + githubRepo: 'facebook/react', + githubToken: 'test-token', + }; + mockDOGetMetadata.mockResolvedValue(metadata); + + const service = new SessionService(); + const result = await service.resume({ + sandbox, + sandboxId: `${orgId}__${userId}`, + orgId, + userId, + sessionId, + kilocodeToken: 'test-token', + kilocodeModel: 'test-model', + env: testEnv, + }); + + expect(result.context.githubRepo).toBe('facebook/react'); + expect(result.context.githubToken).toBe('test-token'); + expect(mockCloneGitHubRepo).not.toHaveBeenCalled(); + }); + }); + + describe('Environment Variable Injection', () => { + it('should inject envVars into session environment', async () => { + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandboxCreateSession = vi.fn().mockResolvedValue(fakeSession); + const sandbox = { + createSession: sandboxCreateSession, + mkdir: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + const sessionId: SessionId = 'agent_envtest_123'; + mockedSetupWorkspace.mockResolvedValue({ + workspacePath: `/workspace/org/user/sessions/${sessionId}`, + sessionHome: `/home/${sessionId}`, + }); + + const service = new SessionService(); + const envVars = { + API_KEY: 'test-key-123', + DATABASE_URL: 'postgres://localhost:5432/test', + NODE_ENV: 'development', + }; + + await service.initiate({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + githubRepo: 'acme/repo', + env: mockEnv, + envVars, + }); + + expect(sandboxCreateSession).toHaveBeenCalledWith({ + name: sessionId, + env: { + HOME: `/home/${sessionId}`, + SESSION_ID: sessionId, + SESSION_HOME: `/home/${sessionId}`, + KILOCODE_TOKEN: 'token', + KILOCODE_ORGANIZATION_ID: 'org', + KILO_PLATFORM: 'cloud-agent', + OPENCODE_CONFIG_CONTENT: `{"permission":{"external_directory":{"/tmp/attachments/${sessionId}/**":"allow"}},"provider":{"kilo":{"options":{"apiKey":"token","kilocodeToken":"token","kilocodeOrganizationId":"org"}}},"model":"kilo/test-model"}`, + KILO_CONFIG_CONTENT: `{"permission":{"external_directory":{"/tmp/attachments/${sessionId}/**":"allow"}},"provider":{"kilo":{"options":{"apiKey":"token","kilocodeToken":"token","kilocodeOrganizationId":"org"}}},"model":"kilo/test-model"}`, + API_KEY: 'test-key-123', + DATABASE_URL: 'postgres://localhost:5432/test', + NODE_ENV: 'development', + }, + cwd: `/workspace/org/user/sessions/${sessionId}`, + }); + }); + + it('should handle special characters in env var values', async () => { + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandboxCreateSession = vi.fn().mockResolvedValue(fakeSession); + const sandbox = { + createSession: sandboxCreateSession, + mkdir: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + const sessionId: SessionId = 'agent_special_chars'; + mockedSetupWorkspace.mockResolvedValue({ + workspacePath: `/workspace/org/user/sessions/${sessionId}`, + sessionHome: `/home/${sessionId}`, + }); + + const service = new SessionService(); + const envVars = { + PASSWORD: 'p@ssw0rd!#$%', + JSON_CONFIG: '{"key":"value with spaces"}', + PATH_WITH_COLON: '/usr/bin:/usr/local/bin', + }; + + await service.initiate({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + githubRepo: 'acme/repo', + env: mockEnv, + envVars, + }); + + expect(sandboxCreateSession).toHaveBeenCalledWith({ + name: sessionId, + env: expect.objectContaining({ + PASSWORD: 'p@ssw0rd!#$%', + JSON_CONFIG: '{"key":"value with spaces"}', + PATH_WITH_COLON: '/usr/bin:/usr/local/bin', + }), + cwd: `/workspace/org/user/sessions/${sessionId}`, + }); + }); + + it('should work without envVars (optional)', async () => { + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandboxCreateSession = vi.fn().mockResolvedValue(fakeSession); + const sandbox = { + createSession: sandboxCreateSession, + mkdir: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + const sessionId: SessionId = 'agent_no_env'; + mockedSetupWorkspace.mockResolvedValue({ + workspacePath: `/workspace/org/user/sessions/${sessionId}`, + sessionHome: `/home/${sessionId}`, + }); + + const service = new SessionService(); + await service.initiate({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + githubRepo: 'acme/repo', + env: mockEnv, + // No envVars provided + }); + + expect(sandboxCreateSession).toHaveBeenCalledWith({ + name: sessionId, + env: { + HOME: `/home/${sessionId}`, + SESSION_ID: sessionId, + SESSION_HOME: `/home/${sessionId}`, + KILOCODE_TOKEN: 'token', + KILOCODE_ORGANIZATION_ID: 'org', + KILO_PLATFORM: 'cloud-agent', + OPENCODE_CONFIG_CONTENT: `{"permission":{"external_directory":{"/tmp/attachments/${sessionId}/**":"allow"}},"provider":{"kilo":{"options":{"apiKey":"token","kilocodeToken":"token","kilocodeOrganizationId":"org"}}},"model":"kilo/test-model"}`, + KILO_CONFIG_CONTENT: `{"permission":{"external_directory":{"/tmp/attachments/${sessionId}/**":"allow"}},"provider":{"kilo":{"options":{"apiKey":"token","kilocodeToken":"token","kilocodeOrganizationId":"org"}}},"model":"kilo/test-model"}`, + }, + cwd: `/workspace/org/user/sessions/${sessionId}`, + }); + }); + }); + + describe('GH_TOKEN Auto-Setting', () => { + it('should set GH_TOKEN from githubToken when provided', async () => { + const fakeSession = { + exec: vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandboxCreateSession = vi.fn().mockResolvedValue(fakeSession); + const sandbox = { + createSession: sandboxCreateSession, + mkdir: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + const sessionId: SessionId = 'agent_gh_token_test'; + mockedSetupWorkspace.mockResolvedValue({ + workspacePath: `/workspace/org/user/sessions/${sessionId}`, + sessionHome: `/home/${sessionId}`, + }); + + const service = new SessionService(); + const githubToken = 'ghp_test123'; + + await service.initiate({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + githubRepo: 'acme/repo', + githubToken, + env: mockEnv, + }); + + expect(sandboxCreateSession).toHaveBeenCalledWith( + expect.objectContaining({ + env: expect.objectContaining({ + GH_TOKEN: 'ghp_test123', + }), + }) + ); + }); + + it('should NOT overwrite user-provided GH_TOKEN', async () => { + const fakeSession = { + exec: vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandboxCreateSession = vi.fn().mockResolvedValue(fakeSession); + const sandbox = { + createSession: sandboxCreateSession, + mkdir: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + const sessionId: SessionId = 'agent_gh_token_override'; + mockedSetupWorkspace.mockResolvedValue({ + workspacePath: `/workspace/org/user/sessions/${sessionId}`, + sessionHome: `/home/${sessionId}`, + }); + + const service = new SessionService(); + const githubToken = 'ghp_auto_token'; + const userProvidedToken = 'ghp_user_token'; + + await service.initiate({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + githubRepo: 'acme/repo', + githubToken, + envVars: { + GH_TOKEN: userProvidedToken, + }, + env: mockEnv, + }); + + // Should use user-provided value, not githubToken + expect(sandboxCreateSession).toHaveBeenCalledWith( + expect.objectContaining({ + env: expect.objectContaining({ + GH_TOKEN: userProvidedToken, + }), + }) + ); + }); + + it('should NOT set GH_TOKEN when githubToken is not provided', async () => { + const fakeSession = { + exec: vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandboxCreateSession = vi.fn().mockResolvedValue(fakeSession); + const sandbox = { + createSession: sandboxCreateSession, + mkdir: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + const sessionId: SessionId = 'agent_no_gh_token'; + mockedSetupWorkspace.mockResolvedValue({ + workspacePath: `/workspace/org/user/sessions/${sessionId}`, + sessionHome: `/home/${sessionId}`, + }); + + const service = new SessionService(); + + await service.initiate({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + githubRepo: 'acme/repo', + // No githubToken provided + env: mockEnv, + }); + + const callArgs = sandboxCreateSession.mock.calls[0][0]; + expect(callArgs.env).not.toHaveProperty('GH_TOKEN'); + }); + + it('should NOT set GH_TOKEN when githubToken is empty string', async () => { + const fakeSession = { + exec: vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandboxCreateSession = vi.fn().mockResolvedValue(fakeSession); + const sandbox = { + createSession: sandboxCreateSession, + mkdir: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + const sessionId: SessionId = 'agent_empty_gh_token'; + mockedSetupWorkspace.mockResolvedValue({ + workspacePath: `/workspace/org/user/sessions/${sessionId}`, + sessionHome: `/home/${sessionId}`, + }); + + const service = new SessionService(); + + await service.initiate({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + githubRepo: 'acme/repo', + githubToken: '', // Empty string + env: mockEnv, + }); + + const callArgs = sandboxCreateSession.mock.calls[0][0]; + expect(callArgs.env).not.toHaveProperty('GH_TOKEN'); + }); + + it('should NOT set GH_TOKEN when gitUrl is used even if githubToken is provided', async () => { + const fakeSession = { + exec: vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandboxCreateSession = vi.fn().mockResolvedValue(fakeSession); + const sandbox = { + createSession: sandboxCreateSession, + mkdir: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + const sessionId: SessionId = 'agent_giturl_with_ghtoken'; + mockedSetupWorkspace.mockResolvedValue({ + workspacePath: `/workspace/org/user/sessions/${sessionId}`, + sessionHome: `/home/${sessionId}`, + }); + + const service = new SessionService(); + + await service.initiate({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + gitUrl: 'https://gitlab.com/acme/repo.git', // Using gitUrl, NOT githubRepo + githubToken: 'ghp_should_be_ignored', // githubToken provided but should be ignored + env: mockEnv, + }); + + // Should NOT set GH_TOKEN because this is not a GitHub repo (no githubRepo) + const callArgs = sandboxCreateSession.mock.calls[0][0]; + expect(callArgs.env).not.toHaveProperty('GH_TOKEN'); + }); + }); + + describe('Setup Commands Execution', () => { + it('should continue executing commands when one fails during resume (lenient)', async () => { + const metadata = { + version: 123456789, + sessionId: 'agent_setup_test', + orgId: 'org', + userId: 'user', + timestamp: 123456789, + githubRepo: 'acme/repo', + setupCommands: ['npm install', 'npm run build', 'npm test'], + }; + + const { env: testEnv } = createMetadataEnv({ + getMetadata: vi.fn().mockResolvedValue(metadata), + }); + + const execResults = [ + { success: true, exitCode: 0, stdout: '' }, // repo check - repo doesn't exist + { success: true, exitCode: 0, stdout: 'command 1 ok', stderr: '' }, // npm install + { success: false, exitCode: 1, stdout: '', stderr: 'command 2 failed' }, // npm run build fails + { success: true, exitCode: 0, stdout: 'command 3 ok', stderr: '' }, // npm test + ]; + + const fakeSession = { + exec: vi + .fn() + .mockResolvedValueOnce(execResults[0]) + .mockResolvedValueOnce(execResults[1]) + .mockResolvedValueOnce(execResults[2]) + .mockResolvedValueOnce(execResults[3]), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + }; + const sandbox = { + createSession: vi.fn().mockResolvedValue(fakeSession), + mkdir: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + + const service = new SessionService(); + const sessionId: SessionId = 'agent_setup_test'; + + // Should not throw even though middle command fails during resume (lenient mode) + await service.resume({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + env: testEnv, + }); + + // All three setup commands should be executed (after the initial repo check) + expect(fakeSession.exec).toHaveBeenCalledTimes(4); // 1 repo check + 3 setup commands + expect(fakeSession.exec).toHaveBeenNthCalledWith(2, 'npm install', { + cwd: `/workspace/org/user/sessions/${sessionId}`, + timeout: 120000, + }); + expect(fakeSession.exec).toHaveBeenNthCalledWith(3, 'npm run build', { + cwd: `/workspace/org/user/sessions/${sessionId}`, + timeout: 120000, + }); + expect(fakeSession.exec).toHaveBeenNthCalledWith(4, 'npm test', { + cwd: `/workspace/org/user/sessions/${sessionId}`, + timeout: 120000, + }); + }); + + it('should throw immediately when command fails during initiate (fail-fast)', async () => { + const setupCommands = [ + 'npm install', // succeeds + 'npm install -g fake-package', // fails - should throw here + 'echo "never runs"', // should not execute + ]; + + const fakeSession = { + exec: vi + .fn() + .mockResolvedValueOnce({ exitCode: 0, stdout: 'installed', stderr: '' }) // git checkout -b succeeds + .mockResolvedValueOnce({ exitCode: 0, stdout: 'installed', stderr: '' }) // npm install succeeds + .mockResolvedValueOnce({ exitCode: 1, stdout: '', stderr: 'ERR! 404 Not Found' }), // npm install -g fails + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandboxCreateSession = vi.fn().mockResolvedValue(fakeSession); + const sandbox = { + createSession: sandboxCreateSession, + mkdir: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + const sessionId: SessionId = 'agent_failfast_test'; + mockedSetupWorkspace.mockResolvedValue({ + workspacePath: `/workspace/org/user/sessions/${sessionId}`, + sessionHome: `/home/${sessionId}`, + }); + + const service = new SessionService(); + + // Should throw when second command fails + await expect( + service.initiate({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + githubRepo: 'acme/repo', + env: mockEnv, + setupCommands, + }) + ).rejects.toMatchObject({ + name: 'SetupCommandFailedError', + command: 'npm install -g fake-package', + exitCode: 1, + stderr: 'ERR! 404 Not Found', + }); + + // Verify only three calls: git checkout -b + first setup command + second setup command that failed + expect(fakeSession.exec).toHaveBeenCalledTimes(3); + }); + + it('should run commands with 2-minute timeout', async () => { + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandboxCreateSession = vi.fn().mockResolvedValue(fakeSession); + const sandbox = { + createSession: sandboxCreateSession, + mkdir: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + const sessionId: SessionId = 'agent_timeout_test'; + mockedSetupWorkspace.mockResolvedValue({ + workspacePath: `/workspace/org/user/sessions/${sessionId}`, + sessionHome: `/home/${sessionId}`, + }); + + const service = new SessionService(); + await service.initiate({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + githubRepo: 'acme/repo', + env: mockEnv, + setupCommands: ['long-running-command'], + }); + + expect(fakeSession.exec).toHaveBeenCalledWith('long-running-command', { + cwd: `/workspace/org/user/sessions/${sessionId}`, + timeout: 120000, // 2 minutes in milliseconds + }); + }); + + it('should execute commands in workspace directory', async () => { + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandboxCreateSession = vi.fn().mockResolvedValue(fakeSession); + const sandbox = { + createSession: sandboxCreateSession, + mkdir: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + const sessionId: SessionId = 'agent_cwd_test'; + const workspacePath = `/workspace/org/user/sessions/${sessionId}`; + mockedSetupWorkspace.mockResolvedValue({ + workspacePath, + sessionHome: `/home/${sessionId}`, + }); + + const service = new SessionService(); + await service.initiate({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + githubRepo: 'acme/repo', + env: mockEnv, + setupCommands: ['pwd', 'ls -la'], + }); + + expect(fakeSession.exec).toHaveBeenCalledWith('pwd', { + cwd: workspacePath, + timeout: 120000, + }); + expect(fakeSession.exec).toHaveBeenCalledWith('ls -la', { + cwd: workspacePath, + timeout: 120000, + }); + }); + + it('should handle empty setupCommands array gracefully', async () => { + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandboxCreateSession = vi.fn().mockResolvedValue(fakeSession); + const sandbox = { + createSession: sandboxCreateSession, + mkdir: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + const sessionId: SessionId = 'agent_empty_commands'; + mockedSetupWorkspace.mockResolvedValue({ + workspacePath: `/workspace/org/user/sessions/${sessionId}`, + sessionHome: `/home/${sessionId}`, + }); + + const service = new SessionService(); + await service.initiate({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + githubRepo: 'acme/repo', + env: mockEnv, + setupCommands: [], // Empty array + }); + + // exec should only be called once for git checkout -b, not for setup commands + expect(fakeSession.exec).toHaveBeenCalledTimes(1); + expect(fakeSession.exec).toHaveBeenCalledWith(expect.stringContaining('git checkout -b')); + }); + }); + + describe('MCP Settings File Writing', () => { + it('should create directory and write file to correct path', async () => { + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandboxExec = vi.fn().mockResolvedValue({ exitCode: 0 }); + const sandboxWriteFile = vi.fn().mockResolvedValue(undefined); + const sandbox = { + createSession: vi.fn().mockResolvedValue(fakeSession), + mkdir: vi.fn().mockResolvedValue(undefined), + exec: sandboxExec, + writeFile: sandboxWriteFile, + } as unknown as SandboxInstance; + const sessionId: SessionId = 'agent_mcp_test'; + const sessionHome = `/home/${sessionId}`; + mockedSetupWorkspace.mockResolvedValue({ + workspacePath: `/workspace/org/user/sessions/${sessionId}`, + sessionHome, + }); + + const service = new SessionService(); + const mcpServers = { + puppeteer: { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-puppeteer'], + }, + }; + + await service.initiate({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + githubRepo: 'acme/repo', + env: mockEnv, + mcpServers, + }); + + // Verify directory creation + expect(sandboxExec).toHaveBeenCalledWith( + `mkdir -p ${sessionHome}/.kilocode/cli/global/settings` + ); + + // Verify file write + expect(sandboxWriteFile).toHaveBeenCalledWith( + `${sessionHome}/.kilocode/cli/global/settings/mcp_settings.json`, + expect.stringContaining('"mcpServers"') + ); + }); + + it('should handle empty mcpServers gracefully', async () => { + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandboxExec = vi.fn().mockResolvedValue({ exitCode: 0 }); + const sandboxWriteFile = vi.fn().mockResolvedValue(undefined); + const sandbox = { + createSession: vi.fn().mockResolvedValue(fakeSession), + mkdir: vi.fn().mockResolvedValue(undefined), + exec: sandboxExec, + writeFile: sandboxWriteFile, + } as unknown as SandboxInstance; + const sessionId: SessionId = 'agent_empty_mcp'; + mockedSetupWorkspace.mockResolvedValue({ + workspacePath: `/workspace/org/user/sessions/${sessionId}`, + sessionHome: `/home/${sessionId}`, + }); + + const service = new SessionService(); + await service.initiate({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + githubRepo: 'acme/repo', + env: mockEnv, + mcpServers: {}, // Empty object + }); + + // Should not attempt to write MCP settings + expect(sandboxWriteFile).not.toHaveBeenCalledWith( + expect.stringContaining('mcp_settings.json'), + expect.anything() + ); + }); + + it('should write valid JSON with correct structure', async () => { + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandboxExec = vi.fn().mockResolvedValue({ exitCode: 0 }); + const sandboxWriteFile = vi.fn().mockResolvedValue(undefined); + const sandbox = { + createSession: vi.fn().mockResolvedValue(fakeSession), + mkdir: vi.fn().mockResolvedValue(undefined), + exec: sandboxExec, + writeFile: sandboxWriteFile, + } as unknown as SandboxInstance; + const sessionId: SessionId = 'agent_mcp_json'; + mockedSetupWorkspace.mockResolvedValue({ + workspacePath: `/workspace/org/user/sessions/${sessionId}`, + sessionHome: `/home/${sessionId}`, + }); + + const service = new SessionService(); + const mcpServers = { + 'server-1': { + type: 'stdio' as const, + command: 'node', + args: ['server.js'], + }, + 'server-2': { + type: 'sse' as const, + url: 'https://example.com/mcp', + }, + }; + + await service.initiate({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + githubRepo: 'acme/repo', + env: mockEnv, + mcpServers, + }); + + const writtenContent = sandboxWriteFile.mock.calls[0]?.[1] as string; + expect(writtenContent).toBeDefined(); + + // Should be valid JSON + const parsed = JSON.parse(writtenContent); + expect(parsed).toHaveProperty('mcpServers'); + expect(parsed.mcpServers).toHaveProperty('server-1'); + expect(parsed.mcpServers).toHaveProperty('server-2'); + expect(parsed.mcpServers['server-1']).toMatchObject({ + type: 'stdio', + command: 'node', + args: ['server.js'], + }); + expect(parsed.mcpServers['server-2']).toMatchObject({ + type: 'sse', + url: 'https://example.com/mcp', + }); + }); + }); + + describe('Metadata Persistence', () => { + it('should save metadata including envVars, setupCommands, and mcpServers', async () => { + const updateMetadata = vi.fn().mockResolvedValue(undefined); + const { env: testEnv } = createMetadataEnv({ + updateMetadata, + }); + + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0, stdout: '', stderr: '' }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandbox = { + createSession: vi.fn().mockResolvedValue(fakeSession), + mkdir: vi.fn().mockResolvedValue(undefined), + exec: vi.fn().mockResolvedValue({ exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + const sessionId: SessionId = 'agent_metadata_save'; + mockedSetupWorkspace.mockResolvedValue({ + workspacePath: `/workspace/org/user/sessions/${sessionId}`, + sessionHome: `/home/${sessionId}`, + }); + + const service = new SessionService(); + const envVars = { API_KEY: 'test-123' }; + const setupCommands = ['npm install', 'npm build']; + const mcpServers = { + test: { command: 'test-server' }, + }; + + await service.initiate({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + githubRepo: 'acme/repo', + env: testEnv, + envVars, + setupCommands, + mcpServers, + }); + + // Verify metadata was saved + expect(updateMetadata).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId, + orgId: 'org', + userId: 'user', + githubRepo: 'acme/repo', + envVars: { API_KEY: 'test-123' }, + setupCommands: ['npm install', 'npm build'], + // MCPServerConfigSchema adds defaults for type, timeout, alwaysAllow, disabledTools + mcpServers: { + test: expect.objectContaining({ command: 'test-server' }), + }, + }) + ); + }); + + it('should load metadata with all fields correctly', async () => { + const metadata = { + version: 123456789, + sessionId: 'agent_metadata_load', + orgId: 'org', + userId: 'user', + timestamp: 123456789, + githubRepo: 'facebook/react', + githubToken: 'test-token', + envVars: { DATABASE_URL: 'postgres://localhost' }, + setupCommands: ['pnpm install'], + mcpServers: { github: { command: 'mcp-github' } }, + }; + + const { env: testEnv } = createMetadataEnv({ + getMetadata: vi.fn().mockResolvedValue(metadata), + }); + + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0, stdout: 'exists' }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + }; + const sandbox = { + createSession: vi.fn().mockResolvedValue(fakeSession), + mkdir: vi.fn().mockResolvedValue(undefined), + exec: vi.fn().mockResolvedValue({ exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + + const service = new SessionService(); + const result = await service.resume({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId: 'agent_metadata_load', + kilocodeToken: 'token', + kilocodeModel: 'test-model', + env: testEnv, + }); + + // Verify metadata was loaded and applied to context + expect(result.context.githubRepo).toBe('facebook/react'); + expect(result.context.githubToken).toBe('test-token'); + expect(result.context.envVars).toEqual({ DATABASE_URL: 'postgres://localhost' }); + }); + + it('should round-trip metadata (save then load returns same data)', async () => { + let savedMetadata: CloudAgentSessionState | undefined; + const getMetadata = vi.fn().mockImplementation(async () => savedMetadata ?? null); + const updateMetadata = vi.fn().mockImplementation(async (data: CloudAgentSessionState) => { + savedMetadata = data; + }); + + const { env: testEnv } = createMetadataEnv({ + getMetadata, + updateMetadata, + }); + + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0, stdout: 'exists' }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandbox = { + createSession: vi.fn().mockResolvedValue(fakeSession), + mkdir: vi.fn().mockResolvedValue(undefined), + exec: vi.fn().mockResolvedValue({ exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + const sessionId: SessionId = 'agent_roundtrip'; + mockedSetupWorkspace.mockResolvedValue({ + workspacePath: `/workspace/org/user/sessions/${sessionId}`, + sessionHome: `/home/${sessionId}`, + }); + + const originalData = { + envVars: { KEY1: 'value1', KEY2: 'value2' }, + setupCommands: ['command1', 'command2'], + mcpServers: { server1: { command: 'test' } }, + }; + + const service = new SessionService(); + + // Save + await service.initiate({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + githubRepo: 'acme/repo', + env: testEnv, + ...originalData, + }); + + // Load + const result = await service.resume({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + env: testEnv, + }); + + // Verify round-trip + expect(result.context.envVars).toEqual(originalData.envVars); + expect(savedMetadata).toBeDefined(); + expect(savedMetadata?.setupCommands).toEqual(originalData.setupCommands); + // MCPServerConfigSchema adds defaults for type, timeout, alwaysAllow, disabledTools + expect(savedMetadata?.mcpServers?.server1).toMatchObject({ command: 'test' }); + }); + }); + + describe('Invalid Metadata Handling', () => { + it('throws when Durable Object returns invalid metadata during resume', async () => { + const { env: testEnv } = createMetadataEnv({ + getMetadata: vi.fn().mockResolvedValue({ invalid: true }), + }); + + const sandbox = { + mkdir: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + + const service = new SessionService(); + await expect( + service.resume({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId: 'agent_invalid', + kilocodeToken: 'token', + kilocodeModel: 'test-model', + env: testEnv, + }) + ).rejects.toBeInstanceOf(InvalidSessionMetadataError); + }); + + it('throws when fetching sandbox id encounters invalid metadata', async () => { + const { env: testEnv } = createMetadataEnv({ + getMetadata: vi.fn().mockResolvedValue({ invalid: true }), + }); + + const service = new SessionService(); + await expect( + service.getSandboxIdForSession(testEnv, 'user', 'agent_invalid' as SessionId) + ).rejects.toBeInstanceOf(InvalidSessionMetadataError); + }); + }); + + describe('Resume Flow with Setup Commands and MCP Settings', () => { + it('should re-run setup commands from metadata on resume', async () => { + const metadata = { + version: 123456789, + sessionId: 'agent_resume_setup', + orgId: 'org', + userId: 'user', + timestamp: 123456789, + githubRepo: 'acme/repo', + setupCommands: ['npm install', 'npm run build'], + }; + + const { env: testEnv } = createMetadataEnv({ + getMetadata: vi.fn().mockResolvedValue(metadata), + }); + + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0, stdout: '' }), // repo doesn't exist + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + }; + const sandbox = { + createSession: vi.fn().mockResolvedValue(fakeSession), + mkdir: vi.fn().mockResolvedValue(undefined), + exec: vi.fn().mockResolvedValue({ exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + + const service = new SessionService(); + await service.resume({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId: 'agent_resume_setup', + kilocodeToken: 'token', + kilocodeModel: 'test-model', + env: testEnv, + }); + + // Verify setup commands were re-run (because repo didn't exist, triggering reclone) + expect(fakeSession.exec).toHaveBeenCalledWith('npm install', expect.any(Object)); + expect(fakeSession.exec).toHaveBeenCalledWith('npm run build', expect.any(Object)); + }); + + it('should re-write MCP settings from metadata on resume', async () => { + const metadata = { + version: 123456789, + sessionId: 'agent_resume_mcp', + orgId: 'org', + userId: 'user', + timestamp: 123456789, + githubRepo: 'acme/repo', + mcpServers: { + puppeteer: { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-puppeteer'], + }, + }, + }; + + const { env: testEnv } = createMetadataEnv({ + getMetadata: vi.fn().mockResolvedValue(metadata), + }); + + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0, stdout: '' }), // repo doesn't exist + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + }; + const sandboxExec = vi.fn().mockResolvedValue({ exitCode: 0 }); + const sandboxWriteFile = vi.fn().mockResolvedValue(undefined); + const sandbox = { + createSession: vi.fn().mockResolvedValue(fakeSession), + mkdir: vi.fn().mockResolvedValue(undefined), + exec: sandboxExec, + writeFile: sandboxWriteFile, + } as unknown as SandboxInstance; + + const service = new SessionService(); + await service.resume({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId: 'agent_resume_mcp', + kilocodeToken: 'token', + kilocodeModel: 'test-model', + env: testEnv, + }); + + // Verify MCP settings were re-written (because repo didn't exist, triggering reclone) + expect(sandboxWriteFile).toHaveBeenCalledWith( + expect.stringContaining('mcp_settings.json'), + expect.stringContaining('puppeteer') + ); + }); + + it('should restore envVars to context on resume', async () => { + const metadata = { + version: 123456789, + sessionId: 'agent_resume_env', + orgId: 'org', + userId: 'user', + timestamp: 123456789, + githubRepo: 'acme/repo', + envVars: { + API_KEY: 'restored-key', + DATABASE_URL: 'postgres://restored', + }, + }; + + const { env: testEnv } = createMetadataEnv({ + getMetadata: vi.fn().mockResolvedValue(metadata), + }); + + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0, stdout: 'exists' }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + }; + const sandboxCreateSession = vi.fn().mockResolvedValue(fakeSession); + const sandbox = { + createSession: sandboxCreateSession, + mkdir: vi.fn().mockResolvedValue(undefined), + exec: vi.fn().mockResolvedValue({ exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + + const service = new SessionService(); + await service.resume({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId: 'agent_resume_env', + kilocodeToken: 'token', + kilocodeModel: 'test-model', + env: testEnv, + }); + + // Verify envVars were restored when creating session + expect(sandboxCreateSession).toHaveBeenCalledWith({ + name: 'agent_resume_env', + env: expect.objectContaining({ + API_KEY: 'restored-key', + DATABASE_URL: 'postgres://restored', + }), + cwd: expect.any(String), + }); + }); + + it('should handle resume with all features combined', async () => { + const metadata = { + version: 123456789, + sessionId: 'agent_resume_all', + orgId: 'org', + userId: 'user', + timestamp: 123456789, + githubRepo: 'acme/repo', + envVars: { API_KEY: 'test' }, + setupCommands: ['npm install'], + mcpServers: { test: { command: 'test-server' } }, + }; + + const { env: testEnv } = createMetadataEnv({ + getMetadata: vi.fn().mockResolvedValue(metadata), + }); + + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0, stdout: '' }), // repo doesn't exist + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + }; + const sandboxCreateSession = vi.fn().mockResolvedValue(fakeSession); + const sandboxExec = vi.fn().mockResolvedValue({ exitCode: 0 }); + const sandboxWriteFile = vi.fn().mockResolvedValue(undefined); + const sandbox = { + createSession: sandboxCreateSession, + mkdir: vi.fn().mockResolvedValue(undefined), + exec: sandboxExec, + writeFile: sandboxWriteFile, + } as unknown as SandboxInstance; + + const service = new SessionService(); + await service.resume({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId: 'agent_resume_all', + kilocodeToken: 'token', + kilocodeModel: 'test-model', + env: testEnv, + }); + + // Verify envVars restored + expect(sandboxCreateSession).toHaveBeenCalledWith( + expect.objectContaining({ + env: expect.objectContaining({ API_KEY: 'test' }), + }) + ); + + // Verify setup commands re-run (because repo didn't exist, triggering reclone) + expect(fakeSession.exec).toHaveBeenCalledWith('npm install', expect.any(Object)); + + // Verify MCP settings re-written (because repo didn't exist, triggering reclone) + expect(sandboxWriteFile).toHaveBeenCalledWith( + expect.stringContaining('mcp_settings.json'), + expect.any(String) + ); + }); + }); + + describe('Bot Isolation and Personal Account Support', () => { + describe('getSandboxIdForSession with botId', () => { + it('should reconstruct sandboxId with bot prefix when metadata contains botId', async () => { + const service = new SessionService(); + const userId = 'user-456'; + const sessionId: SessionId = 'agent_test-session'; + + const mockMetadata = { + orgId: 'org-123', + userId, + botId: 'reviewer', + sessionId, + version: 123, + timestamp: Date.now(), + }; + + mockEnv.CLOUD_AGENT_SESSION.get = vi.fn(() => ({ + getMetadata: vi.fn().mockResolvedValue(mockMetadata), + })) as unknown as typeof mockEnv.CLOUD_AGENT_SESSION.get; + + const sandboxId = await service.getSandboxIdForSession(mockEnv, userId, sessionId); + + expect(sandboxId).toMatch(/^bot-[0-9a-f]{48}$/); + expect(sandboxId.length).toBe(52); + }); + + it('should reconstruct sandboxId with org prefix when metadata has no botId', async () => { + const service = new SessionService(); + const userId = 'user-456'; + const sessionId: SessionId = 'agent_test-session'; + + const mockMetadata = { + orgId: 'org-123', + userId, + sessionId, + version: 123, + timestamp: Date.now(), + }; + + mockEnv.CLOUD_AGENT_SESSION.get = vi.fn(() => ({ + getMetadata: vi.fn().mockResolvedValue(mockMetadata), + })) as unknown as typeof mockEnv.CLOUD_AGENT_SESSION.get; + + const sandboxId = await service.getSandboxIdForSession(mockEnv, userId, sessionId); + + expect(sandboxId).toMatch(/^org-[0-9a-f]{48}$/); + expect(sandboxId.length).toBe(52); + }); + + it('should reconstruct sandboxId with usr prefix for personal accounts', async () => { + const service = new SessionService(); + const userId = 'abc-123'; + const sessionId: SessionId = 'agent_test-session'; + + const mockMetadata = { + orgId: undefined, + userId, + sessionId, + version: 123, + timestamp: Date.now(), + }; + + mockEnv.CLOUD_AGENT_SESSION.get = vi.fn(() => ({ + getMetadata: vi.fn().mockResolvedValue(mockMetadata), + })) as unknown as typeof mockEnv.CLOUD_AGENT_SESSION.get; + + const sandboxId = await service.getSandboxIdForSession(mockEnv, userId, sessionId); + + expect(sandboxId).toMatch(/^usr-[0-9a-f]{48}$/); + expect(sandboxId.length).toBe(52); + }); + + it('should reconstruct sandboxId with ubt prefix for personal bot', async () => { + const service = new SessionService(); + const userId = 'abc-123'; + const sessionId: SessionId = 'agent_test-session'; + + const mockMetadata = { + orgId: undefined, + userId, + botId: 'reviewer', + sessionId, + version: 123, + timestamp: Date.now(), + }; + + mockEnv.CLOUD_AGENT_SESSION.get = vi.fn(() => ({ + getMetadata: vi.fn().mockResolvedValue(mockMetadata), + })) as unknown as typeof mockEnv.CLOUD_AGENT_SESSION.get; + + const sandboxId = await service.getSandboxIdForSession(mockEnv, userId, sessionId); + + expect(sandboxId).toMatch(/^ubt-[0-9a-f]{48}$/); + expect(sandboxId.length).toBe(52); + }); + }); + }); + + describe('interrupt', () => { + it('should kill processes matching the workspace path', async () => { + const sessionId: SessionId = 'agent_interrupt_test'; + const workspacePath = `/workspace/org/user/sessions/${sessionId}`; + + const sessionContext = { + sessionId, + workspacePath, + sandboxId: 'org__user', + sessionHome: `/home/${sessionId}`, + branchName: `session/${sessionId}`, + userId: 'user', + orgId: 'org', + } as SessionContext; + + // Mock processes with matching workspace path + const mockProcesses = [ + { + id: 'proc1', + status: 'running', + command: `kilocode exec --workspace=${workspacePath} --mode code`, + }, + { + id: 'proc2', + status: 'running', + command: `kilocode exec --workspace=${workspacePath} --mode architect`, + }, + ]; + + const mockKillProcess = vi.fn().mockResolvedValue(undefined); + const mockSession = { + killProcess: mockKillProcess, + } as unknown as ExecutionSession; + + const mockSandbox = { + listProcesses: vi.fn().mockResolvedValue(mockProcesses), + } as unknown as SandboxInstance; + + const result = await SessionService.interrupt(mockSandbox, mockSession, sessionContext); + + expect(result.success).toBe(true); + expect(result.killedProcessIds).toEqual(['proc1', 'proc2']); + expect(result.failedProcessIds).toEqual([]); + expect(mockKillProcess).toHaveBeenCalledTimes(2); + expect(mockKillProcess).toHaveBeenCalledWith('proc1', 'SIGTERM'); + expect(mockKillProcess).toHaveBeenCalledWith('proc2', 'SIGTERM'); + }); + + it('should NOT kill processes from other workspaces', async () => { + const sessionId: SessionId = 'agent_my_session'; + const workspacePath = `/workspace/org/user/sessions/${sessionId}`; + + const sessionContext = { + sessionId, + workspacePath, + sandboxId: 'org__user', + sessionHome: `/home/${sessionId}`, + branchName: `session/${sessionId}`, + userId: 'user', + orgId: 'org', + } as SessionContext; + + const mockProcesses = [ + { + id: 'proc1', + status: 'running', + command: `kilocode exec --workspace=${workspacePath} --mode code`, + }, + { + id: 'proc2', + status: 'running', + command: + 'kilocode exec --workspace=/workspace/org/other/sessions/other_session --mode code', + }, + { + id: 'proc3', + status: 'running', + command: 'kilocode exec --workspace=/different/path --mode architect', + }, + ]; + + const mockKillProcess = vi.fn().mockResolvedValue(undefined); + const mockSession = { + killProcess: mockKillProcess, + } as unknown as ExecutionSession; + + const mockSandbox = { + listProcesses: vi.fn().mockResolvedValue(mockProcesses), + } as unknown as SandboxInstance; + + const result = await SessionService.interrupt(mockSandbox, mockSession, sessionContext); + + // Should only kill proc1 (the one matching our workspace) + expect(result.success).toBe(true); + expect(result.killedProcessIds).toEqual(['proc1']); + expect(result.failedProcessIds).toEqual([]); + expect(mockKillProcess).toHaveBeenCalledTimes(1); + expect(mockKillProcess).toHaveBeenCalledWith('proc1', 'SIGTERM'); + }); + + it('should only kill running processes', async () => { + const sessionId: SessionId = 'agent_running_test'; + const workspacePath = `/workspace/org/user/sessions/${sessionId}`; + + const sessionContext = { + sessionId, + workspacePath, + sandboxId: 'org__user', + sessionHome: `/home/${sessionId}`, + branchName: `session/${sessionId}`, + userId: 'user', + orgId: 'org', + } as SessionContext; + + const mockProcesses = [ + { + id: 'proc1', + status: 'running', + command: `kilocode exec --workspace=${workspacePath} --mode code`, + }, + { + id: 'proc2', + status: 'stopped', + command: `kilocode exec --workspace=${workspacePath} --mode code`, + }, + { + id: 'proc3', + status: 'exited', + command: `kilocode exec --workspace=${workspacePath} --mode architect`, + }, + ]; + + const mockKillProcess = vi.fn().mockResolvedValue(undefined); + const mockSession = { + killProcess: mockKillProcess, + } as unknown as ExecutionSession; + + const mockSandbox = { + listProcesses: vi.fn().mockResolvedValue(mockProcesses), + } as unknown as SandboxInstance; + + const result = await SessionService.interrupt(mockSandbox, mockSession, sessionContext); + + // Should only kill proc1 (status='running') + expect(result.success).toBe(true); + expect(result.killedProcessIds).toEqual(['proc1']); + expect(result.failedProcessIds).toEqual([]); + expect(mockKillProcess).toHaveBeenCalledTimes(1); + expect(mockKillProcess).toHaveBeenCalledWith('proc1', 'SIGTERM'); + }); + + it('should only kill kilocode processes', async () => { + const sessionId: SessionId = 'agent_process_filter'; + const workspacePath = `/workspace/org/user/sessions/${sessionId}`; + + const sessionContext = { + sessionId, + workspacePath, + sandboxId: 'org__user', + sessionHome: `/home/${sessionId}`, + branchName: `session/${sessionId}`, + userId: 'user', + orgId: 'org', + } as SessionContext; + + const mockProcesses = [ + { + id: 'proc1', + status: 'running', + command: `kilocode exec --workspace=${workspacePath} --mode code`, + }, + { + id: 'proc2', + status: 'running', + command: `node server.js --workspace=${workspacePath}`, + }, + { + id: 'proc3', + status: 'running', + command: `bash --workspace=${workspacePath}`, + }, + { + id: 'proc4', + status: 'running', + command: `/usr/bin/python3 app.py --workspace=${workspacePath}`, + }, + ]; + + const mockKillProcess = vi.fn().mockResolvedValue(undefined); + const mockSession = { + killProcess: mockKillProcess, + } as unknown as ExecutionSession; + + const mockSandbox = { + listProcesses: vi.fn().mockResolvedValue(mockProcesses), + } as unknown as SandboxInstance; + + const result = await SessionService.interrupt(mockSandbox, mockSession, sessionContext); + + // Should only kill proc1 (contains 'kilocode') + expect(result.success).toBe(true); + expect(result.killedProcessIds).toEqual(['proc1']); + expect(result.failedProcessIds).toEqual([]); + expect(mockKillProcess).toHaveBeenCalledTimes(1); + expect(mockKillProcess).toHaveBeenCalledWith('proc1', 'SIGTERM'); + }); + + it('should return success=true when no processes found', async () => { + const sessionId: SessionId = 'agent_no_procs'; + const workspacePath = `/workspace/org/user/sessions/${sessionId}`; + + const sessionContext = { + sessionId, + workspacePath, + sandboxId: 'org__user', + sessionHome: `/home/${sessionId}`, + branchName: `session/${sessionId}`, + userId: 'user', + orgId: 'org', + } as SessionContext; + + const mockProcesses: never[] = []; + + const mockKillProcess = vi.fn(); + const mockSession = { + killProcess: mockKillProcess, + } as unknown as ExecutionSession; + + const mockSandbox = { + listProcesses: vi.fn().mockResolvedValue(mockProcesses), + } as unknown as SandboxInstance; + + const result = await SessionService.interrupt(mockSandbox, mockSession, sessionContext); + + expect(result.success).toBe(true); + expect(result.killedProcessIds).toEqual([]); + expect(result.failedProcessIds).toEqual([]); + expect(result.message).toContain('No running kilocode processes found'); + expect(mockKillProcess).not.toHaveBeenCalled(); + }); + + it('should handle partial kill failures gracefully', async () => { + const sessionId: SessionId = 'agent_partial_fail'; + const workspacePath = `/workspace/org/user/sessions/${sessionId}`; + + const sessionContext = { + sessionId, + workspacePath, + sandboxId: 'org__user', + sessionHome: `/home/${sessionId}`, + branchName: `session/${sessionId}`, + userId: 'user', + orgId: 'org', + } as SessionContext; + + const mockProcesses = [ + { + id: 'proc1', + status: 'running', + command: `kilocode exec --workspace=${workspacePath} --mode code`, + }, + { + id: 'proc2', + status: 'running', + command: `kilocode exec --workspace=${workspacePath} --mode architect`, + }, + { + id: 'proc3', + status: 'running', + command: `kilocode exec --workspace=${workspacePath} --mode debug`, + }, + ]; + + // Mock killProcess to succeed for proc1, fail for proc2, succeed for proc3 + const mockKillProcess = vi + .fn() + .mockResolvedValueOnce(undefined) // proc1 succeeds + .mockRejectedValueOnce(new Error('Permission denied')) // proc2 fails + .mockResolvedValueOnce(undefined); // proc3 succeeds + + const mockSession = { + killProcess: mockKillProcess, + } as unknown as ExecutionSession; + + const mockSandbox = { + listProcesses: vi.fn().mockResolvedValue(mockProcesses), + } as unknown as SandboxInstance; + + const result = await SessionService.interrupt(mockSandbox, mockSession, sessionContext); + + expect(result.success).toBe(true); // success because at least one was killed + expect(result.killedProcessIds).toEqual(['proc1', 'proc3']); + expect(result.failedProcessIds).toEqual(['proc2']); + expect(result.message).toContain('killed 2 process(es)'); + expect(result.message).toContain('1 failed'); + expect(mockKillProcess).toHaveBeenCalledTimes(3); + }); + }); + + describe('initiateFromKiloSession', () => { + const noopStream = async function* () {}; + + it('should setup workspace and clone repo without creating session branch', async () => { + streamKilocodeExecutionMock.mockReturnValue(noopStream()); + + const { env: testEnv } = createMetadataEnv({ + updateMetadata: vi.fn().mockResolvedValue(undefined), + }); + + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandboxCreateSession = vi.fn().mockResolvedValue(fakeSession); + const sandbox = { + createSession: sandboxCreateSession, + mkdir: vi.fn().mockResolvedValue(undefined), + exec: vi.fn().mockResolvedValue({ exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + const sessionId: SessionId = 'agent_kilo_session_test'; + const kiloSessionId = '123e4567-e89b-12d3-a456-426614174000'; + mockedSetupWorkspace.mockResolvedValue({ + workspacePath: `/workspace/org/user/sessions/${sessionId}`, + sessionHome: `/home/${sessionId}`, + }); + + const service = new SessionService(); + const result = await service.initiateFromKiloSession({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + kiloSessionId, + githubRepo: 'acme/repo', + env: testEnv, + }); + + // Should setup workspace + expect(mockSetupWorkspace).toHaveBeenCalledWith(sandbox, 'user', 'org', sessionId); + + // Should clone repo + expect(mockCloneGitHubRepo).toHaveBeenCalledWith( + fakeSession, + `/workspace/org/user/sessions/${sessionId}`, + 'acme/repo', + undefined, + { GITHUB_APP_SLUG: undefined, GITHUB_APP_BOT_USER_ID: undefined } + ); + + // Should NOT create session branch (kilo session manages its own branch) + expect(fakeSession.exec).not.toHaveBeenCalledWith(expect.stringContaining('git checkout -b')); + expect(mockManageBranch).not.toHaveBeenCalled(); + + expect(result.context.sessionId).toBe(sessionId); + expect(result.streamKilocodeExec).toBeDefined(); + }); + + it('should save kiloSessionId in metadata', async () => { + streamKilocodeExecutionMock.mockReturnValue(noopStream()); + + const updateMetadata = vi.fn().mockResolvedValue(undefined); + const { env: testEnv } = createMetadataEnv({ + updateMetadata, + }); + + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandbox = { + createSession: vi.fn().mockResolvedValue(fakeSession), + mkdir: vi.fn().mockResolvedValue(undefined), + exec: vi.fn().mockResolvedValue({ exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + const sessionId: SessionId = 'agent_kilo_metadata_test'; + const kiloSessionId = '123e4567-e89b-12d3-a456-426614174000'; + mockedSetupWorkspace.mockResolvedValue({ + workspacePath: `/workspace/org/user/sessions/${sessionId}`, + sessionHome: `/home/${sessionId}`, + }); + + const service = new SessionService(); + await service.initiateFromKiloSession({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + kiloSessionId, + githubRepo: 'acme/repo', + env: testEnv, + }); + + // Verify metadata was saved with kiloSessionId + expect(updateMetadata).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId, + kiloSessionId, + githubRepo: 'acme/repo', + }) + ); + }); + + it('should pass isFirstExecution=false since resuming existing kilo session', async () => { + streamKilocodeExecutionMock.mockReturnValue(noopStream()); + + const { env: testEnv } = createMetadataEnv({ + updateMetadata: vi.fn().mockResolvedValue(undefined), + }); + + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandbox = { + createSession: vi.fn().mockResolvedValue(fakeSession), + mkdir: vi.fn().mockResolvedValue(undefined), + exec: vi.fn().mockResolvedValue({ exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + const sessionId: SessionId = 'agent_first_exec_false'; + const kiloSessionId = '123e4567-e89b-12d3-a456-426614174000'; + mockedSetupWorkspace.mockResolvedValue({ + workspacePath: `/workspace/org/user/sessions/${sessionId}`, + sessionHome: `/home/${sessionId}`, + }); + + const service = new SessionService(); + const result = await service.initiateFromKiloSession({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + kiloSessionId, + githubRepo: 'acme/repo', + env: testEnv, + }); + + // Consume the generator + for await (const _ of result.streamKilocodeExec('code', 'test prompt')) { + // noop + } + + // Verify isFirstExecution=false and kiloSessionId is passed + expect(streamKilocodeExecutionMock).toHaveBeenCalledWith( + sandbox, + fakeSession, + expect.objectContaining({ sessionId }), + 'code', + 'test prompt', + expect.objectContaining({ isFirstExecution: false, kiloSessionId }), + testEnv + ); + }); + + it('should run setup commands after clone', async () => { + streamKilocodeExecutionMock.mockReturnValue(noopStream()); + + const { env: testEnv } = createMetadataEnv({ + updateMetadata: vi.fn().mockResolvedValue(undefined), + }); + + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandbox = { + createSession: vi.fn().mockResolvedValue(fakeSession), + mkdir: vi.fn().mockResolvedValue(undefined), + exec: vi.fn().mockResolvedValue({ exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + const sessionId: SessionId = 'agent_kilo_setup_test'; + const kiloSessionId = '123e4567-e89b-12d3-a456-426614174000'; + mockedSetupWorkspace.mockResolvedValue({ + workspacePath: `/workspace/org/user/sessions/${sessionId}`, + sessionHome: `/home/${sessionId}`, + }); + + const service = new SessionService(); + await service.initiateFromKiloSession({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + kiloSessionId, + githubRepo: 'acme/repo', + env: testEnv, + setupCommands: ['npm install', 'npm run build'], + }); + + // Verify setup commands were run + expect(fakeSession.exec).toHaveBeenCalledWith('npm install', expect.any(Object)); + expect(fakeSession.exec).toHaveBeenCalledWith('npm run build', expect.any(Object)); + }); + }); + + describe('linkKiloSessionInBackend', () => { + let originalFetch: typeof global.fetch; + + beforeEach(() => { + originalFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it('should use correct tRPC wire format with request body', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ result: { data: { success: true } } }), + }); + global.fetch = mockFetch; + + const envWithBackendUrl: PersistenceEnv = { + ...mockEnv, + KILOCODE_BACKEND_BASE_URL: 'https://test.kilo.ai', + }; + + const service = new SessionService(); + // Access private method + await service['linkKiloSessionInBackend']( + 'kilo-session-123', + 'agent-session-456', + 'auth-token', + envWithBackendUrl + ); + + // Verify the request uses POST with body (not query string) + expect(mockFetch).toHaveBeenCalledWith( + 'https://test.kilo.ai/api/trpc/cliSessions.linkCloudAgent', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer auth-token', + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ + kilo_session_id: 'kilo-session-123', + cloud_agent_session_id: 'agent-session-456', + }), + }) + ); + }); + + it('should use default backend URL when not provided', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ result: { data: { success: true } } }), + }); + global.fetch = mockFetch; + + const service = new SessionService(); + await service['linkKiloSessionInBackend']( + 'kilo-session-123', + 'agent-session-456', + 'auth-token', + mockEnv // No KILOCODE_BACKEND_URL + ); + + const calledUrl = mockFetch.mock.calls[0]?.[0] as string; + expect(calledUrl).toContain('https://api.kilo.ai'); + }); + + it('should throw error when backend returns non-200', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + text: () => Promise.resolve('Not found'), + }); + + const service = new SessionService(); + await expect( + service['linkKiloSessionInBackend']( + 'kilo-session-123', + 'agent-session-456', + 'auth-token', + mockEnv + ) + ).rejects.toThrow('Failed to link sessions: 404'); + }); + + it('should throw error when backend does not confirm success', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ result: { data: { success: false } } }), + }); + + const service = new SessionService(); + await expect( + service['linkKiloSessionInBackend']( + 'kilo-session-123', + 'agent-session-456', + 'auth-token', + mockEnv + ) + ).rejects.toThrow('Backend did not confirm successful link'); + }); + + it('should throw error when response format is unexpected', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ unexpected: 'format' }), + }); + + const service = new SessionService(); + await expect( + service['linkKiloSessionInBackend']( + 'kilo-session-123', + 'agent-session-456', + 'auth-token', + mockEnv + ) + ).rejects.toThrow('Backend did not confirm successful link'); + }); + }); + + describe('captureAndStoreBranch', () => { + it('should capture current branch and update metadata', async () => { + const updateUpstreamBranch = vi.fn().mockResolvedValue(undefined); + const existingMetadata = { + version: 123456789, + sessionId: 'agent_branch_capture', + orgId: 'org', + userId: 'user', + timestamp: 123456789, + githubRepo: 'acme/repo', + }; + const { env: testEnv } = createMetadataEnv({ + getMetadata: vi.fn().mockResolvedValue(existingMetadata), + updateUpstreamBranch, + }); + + const mockExec = vi.fn().mockResolvedValue({ + exitCode: 0, + stdout: 'feature/my-branch\n', + stderr: '', + }); + const fakeSession = { + exec: mockExec, + } as unknown as ExecutionSession; + + const context: SessionContext = { + sessionId: 'agent_branch_capture' as SessionId, + workspacePath: '/workspace/org/user/sessions/agent_branch_capture', + sandboxId: 'org__user', + sessionHome: '/home/agent_branch_capture', + branchName: 'session/agent_branch_capture', + userId: 'user', + orgId: 'org', + }; + + const service = new SessionService(); + await service['captureAndStoreBranch'](fakeSession, context, testEnv); + + // Verify git branch command was executed + expect(mockExec).toHaveBeenCalledWith( + 'cd /workspace/org/user/sessions/agent_branch_capture && git branch --show-current' + ); + + // Verify updateUpstreamBranch was called with the captured branch + expect(updateUpstreamBranch).toHaveBeenCalledWith('feature/my-branch'); + }); + + it('should handle git command failure gracefully', async () => { + const updateMetadata = vi.fn().mockResolvedValue(undefined); + const { env: testEnv } = createMetadataEnv({ + getMetadata: vi.fn().mockResolvedValue(null), + updateMetadata, + }); + + const mockExec = vi.fn().mockResolvedValue({ + exitCode: 1, + stdout: '', + stderr: 'fatal: not a git repository', + }); + const fakeSession = { + exec: mockExec, + } as unknown as ExecutionSession; + + const context: SessionContext = { + sessionId: 'agent_branch_fail' as SessionId, + workspacePath: '/workspace/org/user/sessions/agent_branch_fail', + sandboxId: 'org__user', + sessionHome: '/home/agent_branch_fail', + branchName: 'session/agent_branch_fail', + userId: 'user', + orgId: 'org', + }; + + const service = new SessionService(); + // Should not throw, just log warning + await service['captureAndStoreBranch'](fakeSession, context, testEnv); + + // Should not update metadata when git command fails + expect(updateMetadata).not.toHaveBeenCalled(); + }); + + it('should handle empty branch name gracefully', async () => { + const updateMetadata = vi.fn().mockResolvedValue(undefined); + const { env: testEnv } = createMetadataEnv({ + getMetadata: vi.fn().mockResolvedValue(null), + updateMetadata, + }); + + const mockExec = vi.fn().mockResolvedValue({ + exitCode: 0, + stdout: ' \n', // Whitespace only + stderr: '', + }); + const fakeSession = { + exec: mockExec, + } as unknown as ExecutionSession; + + const context: SessionContext = { + sessionId: 'agent_empty_branch' as SessionId, + workspacePath: '/workspace/org/user/sessions/agent_empty_branch', + sandboxId: 'org__user', + sessionHome: '/home/agent_empty_branch', + branchName: 'session/agent_empty_branch', + userId: 'user', + orgId: 'org', + }; + + const service = new SessionService(); + await service['captureAndStoreBranch'](fakeSession, context, testEnv); + + // Should not update metadata when branch name is empty + expect(updateMetadata).not.toHaveBeenCalled(); + }); + + it('should handle exec throwing an error gracefully', async () => { + const updateMetadata = vi.fn().mockResolvedValue(undefined); + const { env: testEnv } = createMetadataEnv({ + getMetadata: vi.fn().mockResolvedValue(null), + updateMetadata, + }); + + const mockExec = vi.fn().mockRejectedValue(new Error('Connection lost')); + const fakeSession = { + exec: mockExec, + } as unknown as ExecutionSession; + + const context: SessionContext = { + sessionId: 'agent_exec_error' as SessionId, + workspacePath: '/workspace/org/user/sessions/agent_exec_error', + sandboxId: 'org__user', + sessionHome: '/home/agent_exec_error', + branchName: 'session/agent_exec_error', + userId: 'user', + orgId: 'org', + }; + + const service = new SessionService(); + // Should not throw, just log warning + await service['captureAndStoreBranch'](fakeSession, context, testEnv); + + // Should not update metadata when exec throws + expect(updateMetadata).not.toHaveBeenCalled(); + }); + }); + + describe('saveSessionMetadata preserves prepared session fields', () => { + it('should preserve preparedAt, initiatedAt, prompt, mode, model, autoCommit when existingMetadata is provided', async () => { + const noopStream = async function* () {}; + streamKilocodeExecutionMock.mockReturnValue(noopStream()); + + // Existing metadata with prepared session fields + const existingMetadata: CloudAgentSessionState = { + version: 123456789, + sessionId: 'agent_preserve_test', + orgId: 'org', + userId: 'user', + timestamp: 123456789, + githubRepo: 'acme/repo', + // Prepared session fields that must be preserved + preparedAt: 1700000000000, + initiatedAt: 1700000001000, + prompt: 'Original prompt from prepareSession', + mode: 'build', + model: 'claude-3-opus', + autoCommit: true, + kiloSessionId: '123e4567-e89b-12d3-a456-426614174000', + }; + + const updateMetadata = vi.fn().mockResolvedValue(undefined); + const { env: testEnv } = createMetadataEnv({ + getMetadata: vi.fn().mockResolvedValue(existingMetadata), + updateMetadata, + }); + + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandbox = { + createSession: vi.fn().mockResolvedValue(fakeSession), + mkdir: vi.fn().mockResolvedValue(undefined), + exec: vi.fn().mockResolvedValue({ exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + const sessionId: SessionId = 'agent_preserve_test'; + mockedSetupWorkspace.mockResolvedValue({ + workspacePath: `/workspace/org/user/sessions/${sessionId}`, + sessionHome: `/home/${sessionId}`, + }); + + const service = new SessionService(); + await service.initiateFromKiloSession({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + kiloSessionId: '123e4567-e89b-12d3-a456-426614174000', + githubRepo: 'acme/repo', + env: testEnv, + // Pass existingMetadata to trigger the merge behavior + existingMetadata, + }); + + // Verify updateMetadata was called with preserved fields + expect(updateMetadata).toHaveBeenCalledWith( + expect.objectContaining({ + // These fields should be preserved from existingMetadata + preparedAt: 1700000000000, + initiatedAt: 1700000001000, + prompt: 'Original prompt from prepareSession', + mode: 'build', + model: 'claude-3-opus', + autoCommit: true, + // These fields should be updated + sessionId, + orgId: 'org', + userId: 'user', + githubRepo: 'acme/repo', + kiloSessionId: '123e4567-e89b-12d3-a456-426614174000', + }) + ); + }); + + it('should NOT have prepared fields when existingMetadata is not provided (legacy flow)', async () => { + const noopStream = async function* () {}; + streamKilocodeExecutionMock.mockReturnValue(noopStream()); + + const updateMetadata = vi.fn().mockResolvedValue(undefined); + const { env: testEnv } = createMetadataEnv({ + getMetadata: vi.fn().mockResolvedValue(null), + updateMetadata, + }); + + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandbox = { + createSession: vi.fn().mockResolvedValue(fakeSession), + mkdir: vi.fn().mockResolvedValue(undefined), + exec: vi.fn().mockResolvedValue({ exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + const sessionId: SessionId = 'agent_legacy_test'; + mockedSetupWorkspace.mockResolvedValue({ + workspacePath: `/workspace/org/user/sessions/${sessionId}`, + sessionHome: `/home/${sessionId}`, + }); + + const service = new SessionService(); + await service.initiateFromKiloSession({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + kiloSessionId: '123e4567-e89b-12d3-a456-426614174000', + githubRepo: 'acme/repo', + env: testEnv, + // No existingMetadata - legacy flow + }); + + // Verify updateMetadata was called WITHOUT prepared fields + const savedMetadata = updateMetadata.mock.calls[0]?.[0] as CloudAgentSessionState; + expect(savedMetadata).toBeDefined(); + expect(savedMetadata.preparedAt).toBeUndefined(); + expect(savedMetadata.initiatedAt).toBeUndefined(); + expect(savedMetadata.prompt).toBeUndefined(); + expect(savedMetadata.mode).toBeUndefined(); + expect(savedMetadata.model).toBeUndefined(); + expect(savedMetadata.autoCommit).toBeUndefined(); + }); + }); + + describe('isPreparedSession branch management logic', () => { + const noopStream = async function* () {}; + + it('uses manageBranch when prepared session has upstreamBranch', async () => { + streamKilocodeExecutionMock.mockReturnValue(noopStream()); + + // Existing metadata with preparedAt AND upstreamBranch + const existingMetadata: CloudAgentSessionState = { + version: 123456789, + sessionId: 'agent_upstream_test', + orgId: 'org', + userId: 'user', + timestamp: 123456789, + githubRepo: 'acme/repo', + preparedAt: 1700000000000, // This makes isPreparedSession = true + initiatedAt: 1700000001000, + upstreamBranch: 'feature/my-branch', // This triggers manageBranch path + prompt: 'Test prompt', + mode: 'build', + model: 'claude-3', + kiloSessionId: '123e4567-e89b-12d3-a456-426614174000', + }; + + const updateMetadata = vi.fn().mockResolvedValue(undefined); + const { env: testEnv } = createMetadataEnv({ + getMetadata: vi.fn().mockResolvedValue(existingMetadata), + updateMetadata, + }); + + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandbox = { + createSession: vi.fn().mockResolvedValue(fakeSession), + mkdir: vi.fn().mockResolvedValue(undefined), + exec: vi.fn().mockResolvedValue({ exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + const sessionId: SessionId = 'agent_upstream_test'; + mockedSetupWorkspace.mockResolvedValue({ + workspacePath: `/workspace/org/user/sessions/${sessionId}`, + sessionHome: `/home/${sessionId}`, + }); + + const service = new SessionService(); + await service.initiateFromKiloSession({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + kiloSessionId: '123e4567-e89b-12d3-a456-426614174000', + githubRepo: 'acme/repo', + env: testEnv, + existingMetadata, + }); + + // For prepared sessions with upstreamBranch, manageBranch SHOULD be called + expect(mockManageBranch).toHaveBeenCalledWith( + fakeSession, + `/workspace/org/user/sessions/${sessionId}`, + 'feature/my-branch', // branchName = upstreamBranch when provided + true + ); + + // git checkout -b should NOT be called directly + expect(fakeSession.exec).not.toHaveBeenCalledWith(expect.stringContaining('git checkout -b')); + }); + + it('creates session branch directly when prepared session has no upstreamBranch', async () => { + streamKilocodeExecutionMock.mockReturnValue(noopStream()); + + // Existing metadata with preparedAt but NO upstreamBranch + const existingMetadata: CloudAgentSessionState = { + version: 123456789, + sessionId: 'agent_session_branch_test', + orgId: 'org', + userId: 'user', + timestamp: 123456789, + githubRepo: 'acme/repo', + preparedAt: 1700000000000, // This makes isPreparedSession = true + initiatedAt: 1700000001000, + // NO upstreamBranch - should create session branch + prompt: 'Test prompt', + mode: 'build', + model: 'claude-3', + kiloSessionId: '123e4567-e89b-12d3-a456-426614174000', + }; + + const updateMetadata = vi.fn().mockResolvedValue(undefined); + const { env: testEnv } = createMetadataEnv({ + getMetadata: vi.fn().mockResolvedValue(existingMetadata), + updateMetadata, + }); + + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandbox = { + createSession: vi.fn().mockResolvedValue(fakeSession), + mkdir: vi.fn().mockResolvedValue(undefined), + exec: vi.fn().mockResolvedValue({ exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + const sessionId: SessionId = 'agent_session_branch_test'; + mockedSetupWorkspace.mockResolvedValue({ + workspacePath: `/workspace/org/user/sessions/${sessionId}`, + sessionHome: `/home/${sessionId}`, + }); + + const service = new SessionService(); + await service.initiateFromKiloSession({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + kiloSessionId: '123e4567-e89b-12d3-a456-426614174000', + githubRepo: 'acme/repo', + env: testEnv, + existingMetadata, + }); + + // manageBranch should NOT be called (no upstreamBranch) + expect(mockManageBranch).not.toHaveBeenCalled(); + + // git checkout -b SHOULD be called to create session branch + expect(fakeSession.exec).toHaveBeenCalledWith( + expect.stringContaining(`git checkout -b 'session/${sessionId}'`) + ); + }); + + it('skips branch operations for legacy CLI resumes (no preparedAt)', async () => { + streamKilocodeExecutionMock.mockReturnValue(noopStream()); + + // NO existingMetadata passed - simulates legacy CLI resume where + // preparedAt won't be set + const { env: testEnv } = createMetadataEnv({ + getMetadata: vi.fn().mockResolvedValue(null), + updateMetadata: vi.fn().mockResolvedValue(undefined), + }); + + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandbox = { + createSession: vi.fn().mockResolvedValue(fakeSession), + mkdir: vi.fn().mockResolvedValue(undefined), + exec: vi.fn().mockResolvedValue({ exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + const sessionId: SessionId = 'agent_legacy_cli_test'; + mockedSetupWorkspace.mockResolvedValue({ + workspacePath: `/workspace/org/user/sessions/${sessionId}`, + sessionHome: `/home/${sessionId}`, + }); + + const service = new SessionService(); + await service.initiateFromKiloSession({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + kiloSessionId: '123e4567-e89b-12d3-a456-426614174000', + githubRepo: 'acme/repo', + env: testEnv, + // NO existingMetadata - legacy flow + }); + + // manageBranch should NOT be called (CLI manages its own branch) + expect(mockManageBranch).not.toHaveBeenCalled(); + + // git checkout -b should NOT be called (CLI manages its own branch) + expect(fakeSession.exec).not.toHaveBeenCalledWith(expect.stringContaining('git checkout -b')); + }); + + it('skips branch operations when existingMetadata has no preparedAt (explicit legacy)', async () => { + streamKilocodeExecutionMock.mockReturnValue(noopStream()); + + // existingMetadata WITHOUT preparedAt - this is a legacy session + const legacyMetadata: CloudAgentSessionState = { + version: 123456789, + sessionId: 'agent_legacy_explicit_test', + orgId: 'org', + userId: 'user', + timestamp: 123456789, + githubRepo: 'acme/repo', + // NO preparedAt - makes isPreparedSession = false + kiloSessionId: '123e4567-e89b-12d3-a456-426614174000', + }; + + const updateMetadata = vi.fn().mockResolvedValue(undefined); + const { env: testEnv } = createMetadataEnv({ + getMetadata: vi.fn().mockResolvedValue(legacyMetadata), + updateMetadata, + }); + + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandbox = { + createSession: vi.fn().mockResolvedValue(fakeSession), + mkdir: vi.fn().mockResolvedValue(undefined), + exec: vi.fn().mockResolvedValue({ exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + const sessionId: SessionId = 'agent_legacy_explicit_test'; + mockedSetupWorkspace.mockResolvedValue({ + workspacePath: `/workspace/org/user/sessions/${sessionId}`, + sessionHome: `/home/${sessionId}`, + }); + + const service = new SessionService(); + await service.initiateFromKiloSession({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + kiloSessionId: '123e4567-e89b-12d3-a456-426614174000', + githubRepo: 'acme/repo', + env: testEnv, + existingMetadata: legacyMetadata, + }); + + // manageBranch should NOT be called + expect(mockManageBranch).not.toHaveBeenCalled(); + + // git checkout -b should NOT be called (legacy CLI manages its own branch) + expect(fakeSession.exec).not.toHaveBeenCalledWith(expect.stringContaining('git checkout -b')); + }); + }); +}); diff --git a/cloud-agent-next/src/session-service.ts b/cloud-agent-next/src/session-service.ts new file mode 100644 index 0000000000..69a04eb2d1 --- /dev/null +++ b/cloud-agent-next/src/session-service.ts @@ -0,0 +1,1787 @@ +import type { + ExecutionSession, + SandboxInstance, + SandboxId, + SessionContext, + SessionId, + StreamEvent, + InterruptResult, +} from './types.js'; +import type { ExecutionParams as _ExecutionParams } from './schema.js'; +import { DEFAULT_BACKEND_URL } from './constants.js'; +import { generateSandboxId } from './sandbox-id.js'; +import { + checkDiskSpace, + cloneGitHubRepo, + cloneGitRepo, + cleanupWorkspace, + getSessionHomePath, + getSessionWorkspacePath, + manageBranch, + setupWorkspace, +} from './workspace.js'; +import { logger, WithLogTags } from './logger.js'; +import { streamKilocodeExecution } from './streaming.js'; +import type { + PersistenceEnv, + CloudAgentSessionState, + MCPServerConfig, +} from './persistence/types.js'; +import { MetadataSchema } from './persistence/schemas.js'; +import { withDORetry } from './utils/do-retry.js'; +import { mergeEnvVarsWithSecrets } from './utils/encryption.js'; +import type { EncryptedSecrets, Images } from './router/schemas.js'; + +const SETUP_COMMAND_TIMEOUT_SECONDS = 120; // 2 minutes +const SANDBOX_RETRY_DEFAULTS = { + maxAttempts: 3, + baseBackoffMs: 100, + maxBackoffMs: 5000, +}; + +export function determineBranchName(sessionId: string, upstreamBranch?: string): string { + return upstreamBranch ?? `session/${sessionId}`; +} + +type SandboxRetryConfig = { + maxAttempts: number; + baseBackoffMs: number; + maxBackoffMs: number; +}; + +type RetryableSandboxError = Error & { retryable?: boolean; overloaded?: boolean }; + +function isRetryableSandboxError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const sandboxError = error as RetryableSandboxError; + if (sandboxError.overloaded === true) return false; + return sandboxError.retryable === true; +} + +function getSandboxErrorFlags(error: unknown): { + retryable?: boolean; + overloaded?: boolean; +} { + if (!(error instanceof Error)) { + return {}; + } + const sandboxError = error as RetryableSandboxError; + return { + retryable: sandboxError.retryable, + overloaded: sandboxError.overloaded, + }; +} + +function calculateSandboxBackoff(attempt: number, config: SandboxRetryConfig): number { + const exponentialBackoff = config.baseBackoffMs * Math.pow(2, attempt); + const jitteredBackoff = exponentialBackoff * Math.random(); + return Math.min(config.maxBackoffMs, jitteredBackoff); +} + +async function cleanupSandboxAttempt( + getSandbox: () => Promise, + sessionId: string, + workspacePath: string, + sessionHome: string +): Promise { + try { + const sandbox = await getSandbox(); + const session = await sandbox.getSession(sessionId); + await cleanupWorkspace(session, workspacePath, sessionHome); + await sandbox.deleteSession(sessionId); + } catch (error) { + logger + .withFields({ error: error instanceof Error ? error.message : String(error), sessionId }) + .warn('Failed to cleanup sandbox after retryable error'); + } +} + +async function withSandboxRetry( + getSandbox: () => Promise, + operation: (sandbox: SandboxInstance) => Promise, + operationName: string, + cleanup: () => Promise, + config: SandboxRetryConfig = SANDBOX_RETRY_DEFAULTS +): Promise { + let lastError: Error | undefined; + + for (let attempt = 0; attempt < config.maxAttempts; attempt++) { + try { + const sandbox = await getSandbox(); + return await operation(sandbox); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + const errorFlags = getSandboxErrorFlags(error); + + if (!isRetryableSandboxError(error)) { + logger + .withFields({ + operation: operationName, + attempt: attempt + 1, + error: lastError.message, + retryable: false, + retryableFlag: errorFlags.retryable, + overloadedFlag: errorFlags.overloaded, + }) + .warn('Sandbox operation failed with non-retryable error'); + throw lastError; + } + + if (attempt + 1 >= config.maxAttempts) { + logger + .withFields({ + operation: operationName, + attempts: attempt + 1, + error: lastError.message, + }) + .error('Sandbox operation failed after all retry attempts'); + throw lastError; + } + + await cleanup(); + + const backoffMs = calculateSandboxBackoff(attempt, config); + logger + .withFields({ + operation: operationName, + attempt: attempt + 1, + backoffMs: Math.round(backoffMs), + error: lastError.message, + retryableFlag: errorFlags.retryable, + overloadedFlag: errorFlags.overloaded, + }) + .warn('Sandbox operation failed, retrying'); + + await scheduler.wait(backoffMs); + } + } + + throw lastError ?? new Error('Unexpected sandbox retry loop exit'); +} + +export class SetupCommandFailedError extends Error { + constructor( + public readonly command: string, + public readonly exitCode: number, + public readonly stderr: string + ) { + super(`Setup command failed: ${command}`); + this.name = 'SetupCommandFailedError'; + } +} + +export class InvalidSessionMetadataError extends Error { + constructor( + public readonly userId: string, + public readonly sessionId: string, + public readonly details?: string + ) { + super(`Invalid session metadata for session ${sessionId}`); + this.name = 'InvalidSessionMetadataError'; + } +} + +/** + * Execute setup commands in the sandbox session. + * Commands run in the workspace directory with access to env vars. + * + * @param session - ExecutionSession to run commands in + * @param context - Session context (paths, IDs) + * @param setupCommands - Array of setup commands to execute + * @param failFast - Whether to stop on first failure (default: false) + */ +export async function runSetupCommands( + session: ExecutionSession, + context: SessionContext, + setupCommands: string[], + failFast: boolean = false +): Promise { + if (!setupCommands || setupCommands.length === 0) { + return; + } + + logger.setTags({ setupCommandsCount: setupCommands.length }); + logger.info('Running setup commands'); + + for (const command of setupCommands) { + try { + // Run command in workspace directory + const result = await session.exec(command, { + cwd: context.workspacePath, + timeout: SETUP_COMMAND_TIMEOUT_SECONDS * 1000, // Convert to milliseconds + }); + + if (result.exitCode !== 0) { + logger + .withFields({ + command, + exitCode: result.exitCode, + stderr: result.stderr, + }) + .warn('Setup command failed'); + + if (failFast) { + throw new SetupCommandFailedError(command, result.exitCode, result.stderr); + } + } + } catch (error) { + logger + .withFields({ + command, + error: error instanceof Error ? error.message : String(error), + }) + .error('Error executing setup command'); + + if (failFast) { + if (error instanceof SetupCommandFailedError) { + throw error; + } + throw new SetupCommandFailedError( + command, + -1, + error instanceof Error ? error.message : String(error) + ); + } + } + } + + logger.info('Setup commands completed'); +} + +// Write MCP server config to global settings file in the session home. +export async function writeMCPSettings( + sandbox: SandboxInstance, + sessionHome: string, + mcpServers: Record +): Promise { + if (!mcpServers || Object.keys(mcpServers).length === 0) { + return; + } + + const settingsDir = `${sessionHome}/.kilocode/cli/global/settings`; + const settingsPath = `${settingsDir}/mcp_settings.json`; + + // Ensure directory exists + await sandbox.exec(`mkdir -p ${settingsDir}`); + + // Generate settings JSON inline (no need for separate function) + const settingsJSON = JSON.stringify({ mcpServers }, null, 2); + + // Write settings file + await sandbox.writeFile(settingsPath, settingsJSON); + + const serverNames = Object.keys(mcpServers); + logger + .withTags({ + serverCount: serverNames.length, + serverNames: serverNames.join(', '), + }) + .info('Configured MCP servers'); +} + +/** + * Fetch session metadata from Durable Object using RPC with retry logic. + * Creates a fresh stub for each retry attempt as recommended by Cloudflare. + * @returns CloudAgentSessionState if found, null otherwise + */ +export async function fetchSessionMetadata( + env: PersistenceEnv, + userId: string, + sessionId: string +): Promise { + const doKey = `${userId}:${sessionId}`; + + const metadata = await withDORetry( + () => env.CLOUD_AGENT_SESSION.get(env.CLOUD_AGENT_SESSION.idFromName(doKey)), + stub => stub.getMetadata(), + 'getMetadata' + ); + + if (!metadata) { + return null; + } + + const parsed = MetadataSchema.safeParse(metadata); + if (!parsed.success) { + const reason = JSON.stringify(parsed.error.format()); + logger + .withFields({ + userId, + sessionId, + reason, + }) + .error('Invalid session metadata shape'); + throw new InvalidSessionMetadataError(userId, sessionId, reason); + } + + return parsed.data; +} + +/** + * Generate a unique session ID with the agent_ prefix. + */ +export function generateSessionId(): SessionId { + return `agent_${crypto.randomUUID()}`; +} + +/** + * Manages Cloudflare sessions within sandboxes. + * Sessions are bash shell execution contexts within a sandbox (like terminal tabs). + */ +export class SessionService { + private _metadata?: CloudAgentSessionState; + + /** + * Get the cached metadata (available after getSandboxIdForSession is called) + */ + get metadata(): CloudAgentSessionState | undefined { + return this._metadata; + } + + /** + * Get the sandboxId for a session by fetching and caching its metadata. + * This method should be called before resume() to avoid double-fetching metadata. + * @throws TRPCError with code 'NOT_FOUND' if session doesn't exist + */ + async getSandboxIdForSession( + env: PersistenceEnv, + userId: string, + sessionId: SessionId + ): Promise { + // Fetch and store metadata + const fetchedMetadata = await fetchSessionMetadata(env, userId, sessionId); + + if (!fetchedMetadata) { + const { TRPCError } = await import('@trpc/server'); + throw new TRPCError({ + code: 'NOT_FOUND', + message: `Session ${sessionId} not found. Please initiate a new session.`, + }); + } + + this._metadata = fetchedMetadata; + + // Reconstruct sandboxId using the hash-based format + const sandboxId: SandboxId = await generateSandboxId( + this._metadata.orgId, + userId, + this._metadata.botId + ); + + return sandboxId; + } + + /** + * Derive a SessionContext from the provided metadata. + */ + buildContext(options: { + sandboxId: SessionContext['sandboxId']; + orgId?: string; + userId: string; + sessionId: SessionId; + workspacePath?: string; + sessionHome?: string; + githubRepo?: string; + githubToken?: string; + gitUrl?: string; + gitToken?: string; + upstreamBranch?: string; + botId?: string; + }): SessionContext { + const sessionHome = options.sessionHome ?? getSessionHomePath(options.sessionId); + const workspacePath = + options.workspacePath ?? + getSessionWorkspacePath(options.orgId, options.userId, options.sessionId); + + const branchName = determineBranchName(options.sessionId, options.upstreamBranch); + + return { + sandboxId: options.sandboxId, + sessionId: options.sessionId, + sessionHome, + workspacePath, + branchName, + upstreamBranch: options.upstreamBranch, + orgId: options.orgId, + userId: options.userId, + botId: options.botId, + githubRepo: options.githubRepo, + githubToken: options.githubToken, + }; + } + + private getSaferEnvVars( + userEnvVars: Record | undefined, + sessionHome: string, + sessionId: string, + env: PersistenceEnv, + originalToken: string, + kilocodeModel: string | undefined, + originalOrgId?: string, + githubToken?: string, + githubRepo?: string, + encryptedSecrets?: EncryptedSecrets, + createdOnPlatform?: string, + appendSystemPrompt?: string + ): Record { + // Use override if available, otherwise use original values from API + const kilocodeToken = env.KILOCODE_TOKEN_OVERRIDE ?? originalToken; + const kilocodeOrganizationId = env.KILOCODE_ORG_ID_OVERRIDE ?? originalOrgId; + + // Start with user env vars + let baseEnvVars = userEnvVars || {}; + + // Decrypt and merge encrypted secrets if present + if (encryptedSecrets && Object.keys(encryptedSecrets).length > 0) { + const privateKey = env.AGENT_ENV_VARS_PRIVATE_KEY; + if (!privateKey) { + throw new Error( + 'Encrypted secrets provided but AGENT_ENV_VARS_PRIVATE_KEY is not configured on the worker' + ); + } + baseEnvVars = mergeEnvVarsWithSecrets(baseEnvVars, encryptedSecrets, privateKey); + logger + .withTags({ secretCount: Object.keys(encryptedSecrets).length }) + .info('Decrypted and merged encrypted secrets'); + } + + const envVars: Record = { + // Spread user-provided env vars (including decrypted secrets) first + ...baseEnvVars, + // Then set reserved variables to ensure they always take precedence + HOME: sessionHome, + SESSION_ID: sessionId, + SESSION_HOME: sessionHome, + // Inject Kilocode credentials (with override support) + KILOCODE_TOKEN: kilocodeToken, + // Platform identifier - defaults to 'cloud-agent' if not specified + KILO_PLATFORM: createdOnPlatform ?? 'cloud-agent', + }; + + const providerOptions: Record = { + apiKey: kilocodeToken, + kilocodeToken: kilocodeToken, + }; + if (kilocodeOrganizationId) { + providerOptions.kilocodeOrganizationId = kilocodeOrganizationId; + } + const openRouterBase = env.KILO_OPENROUTER_BASE ?? env.KILOCODE_BACKEND_BASE_URL; + if (openRouterBase) { + providerOptions.baseURL = openRouterBase; + } + const configContent: Record = { + permission: { + external_directory: { + [`/tmp/attachments/${sessionId}/**`]: 'allow', + }, + }, + provider: { + kilo: { + options: providerOptions, + }, + }, + }; + if (kilocodeModel && kilocodeModel.trim()) { + const normalizedModel = kilocodeModel.startsWith('kilo/') + ? kilocodeModel + : `kilo/${kilocodeModel}`; + configContent.model = normalizedModel; + } + // Add custom agent config if appendSystemPrompt is provided (from prepareSession) + if (appendSystemPrompt && appendSystemPrompt.trim()) { + configContent.agent = { + custom: { + prompt: appendSystemPrompt, + }, + }; + } + const configJson = JSON.stringify(configContent); + envVars.OPENCODE_CONFIG_CONTENT = configJson; + envVars.KILO_CONFIG_CONTENT = configJson; + // Set GH_TOKEN for GitHub repos only, respecting user overrides + if (githubToken && githubRepo && !baseEnvVars.GH_TOKEN) { + envVars.GH_TOKEN = githubToken; + } + + // Only add KILOCODE_ORG_ID if we have an org (personal accounts don't have one) + if (kilocodeOrganizationId) { + envVars.KILOCODE_ORGANIZATION_ID = kilocodeOrganizationId; + } + + if (env.KILOCODE_BACKEND_BASE_URL) { + envVars.KILOCODE_BACKEND_BASE_URL = env.KILOCODE_BACKEND_BASE_URL; + } + + return envVars; + } + + /** + * Get an existing session or create a new one. + * + * Sessions within a sandbox maintain isolated shell state (environment variables, + * working directory) but share the filesystem. + */ + async getOrCreateSession( + sandbox: SandboxInstance, + context: SessionContext, + env: PersistenceEnv, + originalToken: string, + kilocodeModel: string | undefined, + originalOrgId?: string, + encryptedSecrets?: EncryptedSecrets, + createdOnPlatform?: string, + appendSystemPrompt?: string + ) { + const { sessionId, sessionHome, workspacePath, envVars } = context; + + // Decrypt secrets and merge with env vars (just-in-time decryption) + const saferEnvVars = this.getSaferEnvVars( + envVars, + sessionHome, + sessionId, + env, + originalToken, + kilocodeModel, + originalOrgId, + context.githubToken, + context.githubRepo, + encryptedSecrets, + createdOnPlatform, + appendSystemPrompt + ); + + const session = await sandbox.createSession({ + name: sessionId, + env: saferEnvVars, + cwd: workspacePath, + }); + return session; + } + + async initiateWithRetry( + options: Omit & { + getSandbox: () => Promise; + retryConfig?: SandboxRetryConfig; + } + ): Promise { + const { getSandbox, retryConfig, ...rest } = options; + const workspacePath = getSessionWorkspacePath(rest.orgId, rest.userId, rest.sessionId); + const sessionHome = getSessionHomePath(rest.sessionId); + + return withSandboxRetry( + getSandbox, + sandbox => this.initiate({ ...rest, sandbox }), + 'initiateSession', + () => cleanupSandboxAttempt(getSandbox, rest.sessionId, workspacePath, sessionHome), + retryConfig + ); + } + + /** Initialize a net-new session with the given options */ + @WithLogTags('SessionService.initiate') + async initiate(options: InitiateOptions): Promise { + const { + sandbox, + sandboxId, + orgId, + userId, + sessionId, + kilocodeToken, + kilocodeModel, + githubRepo, + githubToken, + gitUrl, + gitToken, + env, + envVars, + encryptedSecrets, + setupCommands, + mcpServers, + upstreamBranch, + botId, + githubAppType, + createdOnPlatform, + shallow, + } = options; + + logger.setTags({ + sessionId, + sandboxId, + orgId, + userId, + botId, + githubRepo, + gitUrl, + }); + + logger.info('Initiating session'); + + const { workspacePath, sessionHome } = await setupWorkspace(sandbox, userId, orgId, sessionId); + + const context = this.buildContext({ + sandboxId, + orgId, + userId, + sessionId, + workspacePath, + sessionHome, + githubRepo, + githubToken, + gitUrl, + gitToken, + upstreamBranch, + botId, + }); + + // Inject env vars into context for session creation + if (envVars) { + context.envVars = envVars; + } + + const session = await this.getOrCreateSession( + sandbox, + context, + env, + kilocodeToken, + kilocodeModel, + orgId, + encryptedSecrets, + createdOnPlatform + ); + + // Check disk space before clone for observability (logs warning if low) + await checkDiskSpace(session); + + // Clone repository using appropriate method + // Shallow clone (depth: 1) can be enabled for faster checkout and reduced disk usage + const cloneOptions = shallow ? { shallow: true } : undefined; + if (gitUrl) { + await cloneGitRepo(session, workspacePath, gitUrl, gitToken, undefined, cloneOptions); + } else if (githubRepo) { + await cloneGitHubRepo( + session, + workspacePath, + githubRepo, + githubToken, + getGitAuthorEnv(env, githubAppType), + cloneOptions + ); + } + + // Checkout branch before running setup commands + if (upstreamBranch) { + // For upstream branches, use manageBranch (need to verify exists remotely) + await manageBranch(session, context.workspacePath, context.branchName, true); + } else { + // For session branches on initiate, create directly (can't exist remotely with UUID-based name) + logger.withTags({ branchName: context.branchName }).info('Creating session branch'); + const result = await session.exec( + `cd ${context.workspacePath} && git checkout -b '${context.branchName}'` + ); + if (result.exitCode !== 0) { + throw new Error( + `Failed to create session branch ${context.branchName}: ${result.stderr || result.stdout}` + ); + } + logger.withTags({ branchName: context.branchName }).info('Successfully created branch'); + } + + // Run setup commands after branch checkout + if (setupCommands && setupCommands.length > 0) { + await runSetupCommands(session, context, setupCommands, true); // fail-fast + } + + // Write MCP server settings + if (mcpServers && Object.keys(mcpServers).length > 0) { + await writeMCPSettings(sandbox, context.sessionHome, mcpServers); + } + + // Save metadata to Durable Object + const existingMetadata = await this.loadSessionMetadata(env, context); + await this.saveSessionMetadata( + env, + context, + { + githubRepo, + githubToken, + gitUrl, + gitToken, + envVars, + setupCommands, + mcpServers, + upstreamBranch, + }, + existingMetadata ?? undefined + ); + + // Track first execution to optimize DO fetch and store captured kiloSessionId + let isFirstCall = true; + let capturedKiloSessionId: string | undefined = undefined; + + const linkKiloSessionInBackend = this.linkKiloSessionInBackend.bind(this); + const captureAndStoreBranch = this.captureAndStoreBranch.bind(this); + + return { + context, + session, + streamKilocodeExec: async function* ( + mode: string, + prompt: string, + options?: { sessionId?: string; skipInterruptPolling?: boolean; images?: Images } + ) { + const currentIsFirst = isFirstCall; + isFirstCall = false; + + // Use captured kiloSessionId if available for subsequent calls + const kiloSessionId = capturedKiloSessionId; + + for await (const event of streamKilocodeExecution( + sandbox, + session, + context, + mode, + prompt, + { ...options, isFirstExecution: currentIsFirst, kiloSessionId, images: options?.images }, + env + )) { + // Capture kiloSessionId from session_created event for subsequent calls + if ( + event.streamEventType === 'kilocode' && + event.payload?.event === 'session_created' && + event.payload?.sessionId && + !capturedKiloSessionId + ) { + capturedKiloSessionId = String(event.payload.sessionId); + logger.setTags({ kiloSessionId: capturedKiloSessionId }); + void linkKiloSessionInBackend( + capturedKiloSessionId, + sessionId, + kilocodeToken, + env + ).catch((error: unknown) => { + logger + .withFields({ error: error instanceof Error ? error.message : String(error) }) + .error('Failed to link sessions in backend'); + }); + } + yield event; + } + + await captureAndStoreBranch(session, context, env); + }, + }; + } + + /** + * Initialize a cloud-agent session by resuming an existing kilo session. + * + * Client provides both kiloSessionId and githubRepo (parsed from git_url). + * + * Branch management strategy: + * - Clone repo (any branch, default is fine) + * - Kilo session handles its own branch state (knows which branch it was on) + * - After execution, we observe and capture the branch via `git branch --show-current` + * - Store captured branch in metadata for future warm starts + * + * @param options.existingMetadata - Optional existing metadata to merge with new values. + * When provided, skips the DO fetch and uses this directly for preserving fields like + * preparedAt, initiatedAt, prompt, mode, model, autoCommit. If not provided, metadata + * is fetched from the DO automatically to ensure no fields are lost. Passing this is + * an optimization when the caller already has the metadata. + */ + @WithLogTags('SessionService.initiateFromKiloSession') + async initiateFromKiloSession(options: InitiateFromKiloSessionOptions): Promise { + const { + sandbox, + sandboxId, + orgId, + userId, + sessionId, + kilocodeToken, + kilocodeModel, + kiloSessionId, + githubRepo, + githubToken, + gitUrl, + gitToken, + env, + envVars, + encryptedSecrets, + setupCommands, + mcpServers, + botId, + skipLinking, + githubAppType, + existingMetadata, + } = options; + + logger.setTags({ + sessionId, + sandboxId, + orgId, + userId, + botId, + kiloSessionId, + githubRepo, + gitUrl, + }); + + logger.info('Initiating session from existing kilo session'); + + // Setup workspace (same as initiate) + const { workspacePath, sessionHome } = await setupWorkspace(sandbox, userId, orgId, sessionId); + + // For prepared sessions, we may have an upstreamBranch to use + // For legacy CLI resumes, the CLI manages its own branch state + const isPreparedSession = existingMetadata?.preparedAt !== undefined; + + const context = this.buildContext({ + sandboxId, + orgId, + userId, + sessionId, + workspacePath, + sessionHome, + githubRepo, + githubToken, + gitUrl, + gitToken, + // For prepared sessions, use the upstreamBranch from metadata if provided + // For legacy CLI resumes, let the CLI manage its own branch state (undefined) + upstreamBranch: isPreparedSession ? existingMetadata?.upstreamBranch : undefined, + botId, + }); + + if (envVars) { + context.envVars = envVars; + } + + const session = await this.getOrCreateSession( + sandbox, + context, + env, + kilocodeToken, + kilocodeModel, + orgId, + encryptedSecrets, + undefined, // createdOnPlatform - not used for initiateFromKiloSession + existingMetadata?.appendSystemPrompt + ); + + // Check disk space before clone for observability (logs warning if low) + await checkDiskSpace(session); + + // Clone repository using appropriate method + if (gitUrl) { + await cloneGitRepo(session, workspacePath, gitUrl, gitToken); + } else if (githubRepo) { + await cloneGitHubRepo( + session, + workspacePath, + githubRepo, + githubToken, + getGitAuthorEnv(env, githubAppType) + ); + } else { + throw new Error('Either githubRepo or gitUrl must be provided'); + } + + // Branch management depends on whether this is a prepared session or CLI resume: + // - Prepared sessions (existingMetadata.preparedAt exists): Checkout branch (like initiateSessionStream) + // - CLI resumes (no preparedAt): Skip branch ops (CLI manages its own branch state) + if (isPreparedSession) { + // Use the upstreamBranch from prepared session metadata if present + const upstreamBranch = existingMetadata?.upstreamBranch; + + if (upstreamBranch) { + // For upstream branches, use manageBranch (need to verify exists remotely) + await manageBranch(session, context.workspacePath, context.branchName, true); + } else { + // For session branches on initiate, create directly (can't exist remotely with UUID-based name) + logger.withTags({ branchName: context.branchName }).info('Creating session branch'); + const result = await session.exec( + `cd ${context.workspacePath} && git checkout -b '${context.branchName}'` + ); + if (result.exitCode !== 0) { + throw new Error( + `Failed to create session branch ${context.branchName}: ${result.stderr || result.stdout}` + ); + } + logger.withTags({ branchName: context.branchName }).info('Successfully created branch'); + } + } else { + logger.info('Skipping branch operations - CLI session will manage its own branch state'); + } + + // Run setup commands (lenient mode since resuming) + if (setupCommands && setupCommands.length > 0) { + await runSetupCommands(session, context, setupCommands, false); + } + + // Write MCP settings + if (mcpServers && Object.keys(mcpServers).length > 0) { + await writeMCPSettings(sandbox, context.sessionHome, mcpServers); + } + + // Fetch metadata from DO if not provided, to ensure we preserve existing fields + const metadataToPreserve = + existingMetadata ?? (await this.loadSessionMetadata(env, context)) ?? undefined; + + // Save metadata with kiloSessionId, preserving existing prepared session fields + await this.saveSessionMetadata( + env, + context, + { + githubRepo, + githubToken, + gitUrl, + gitToken, + envVars, + setupCommands, + mcpServers, + kiloSessionId, + }, + metadataToPreserve + ); + + // Skip linking if requested (e.g., for prepared sessions where backend already linked) + if (!skipLinking) { + try { + await this.linkKiloSessionInBackend(kiloSessionId, sessionId, kilocodeToken, env); + logger.info('Linked cloud-agent session to kilo session in backend'); + } catch (error) { + logger + .withFields({ error: error instanceof Error ? error.message : String(error) }) + .warn('Failed to link sessions in backend'); + } + } else { + logger.debug('Skipping backend linking (prepared session mode)'); + } + + const captureAndStoreBranch = this.captureAndStoreBranch.bind(this); + + return { + context, + session, + streamKilocodeExec: async function* ( + mode: string, + prompt: string, + execOptions?: { sessionId?: string; skipInterruptPolling?: boolean; images?: Images } + ) { + for await (const event of streamKilocodeExecution( + sandbox, + session, + context, + mode, + prompt, + { ...execOptions, isFirstExecution: false, kiloSessionId, images: execOptions?.images }, + env + )) { + yield event; + } + + await captureAndStoreBranch(session, context, env); + }, + }; + } + + async initiateFromKiloSessionWithRetry( + options: Omit & { + getSandbox: () => Promise; + retryConfig?: SandboxRetryConfig; + } + ): Promise { + const { getSandbox, retryConfig, ...rest } = options; + const initiateOptions = rest as unknown as Omit; + const workspacePath = getSessionWorkspacePath( + initiateOptions.orgId, + initiateOptions.userId, + initiateOptions.sessionId + ); + const sessionHome = getSessionHomePath(initiateOptions.sessionId); + + return withSandboxRetry( + getSandbox, + sandbox => this.initiateFromKiloSession({ ...initiateOptions, sandbox } as T), + 'initiateFromKiloSession', + () => + cleanupSandboxAttempt(getSandbox, initiateOptions.sessionId, workspacePath, sessionHome), + retryConfig + ); + } + + /** Resume an existing session with the given options */ + @WithLogTags('SessionService.resume') + async resume(options: ResumeOptions): Promise { + const { + sandbox, + sandboxId, + orgId, + userId, + sessionId, + kilocodeToken, + kilocodeModel, + env, + githubToken: freshGithubToken, + gitToken: freshGitToken, + } = options; + + logger.setTags({ + sessionId, + sandboxId, + orgId, + userId, + }); + + logger.info('Resuming session'); + + const workspacePath = getSessionWorkspacePath(orgId, userId, sessionId); + const sessionHome = getSessionHomePath(sessionId); + + // Ensure workspace directories exist before creating session + await sandbox.mkdir(workspacePath, { recursive: true }); + await sandbox.mkdir(sessionHome, { recursive: true }); + + // Session home directory + + const metadata = await this.loadSessionMetadata(env, { userId, sessionId } as SessionContext); + + const context = this.buildContext({ + sandboxId, + orgId, + userId, + sessionId, + workspacePath, + sessionHome, + upstreamBranch: metadata?.upstreamBranch, + botId: metadata?.botId, + githubRepo: metadata?.githubRepo, + githubToken: metadata?.githubToken, + gitUrl: metadata?.gitUrl, + gitToken: metadata?.gitToken, + }); + + // Inject env vars from metadata into context (before creating session) + if (metadata?.envVars) { + context.envVars = metadata.envVars; + } + + // Create session first so we can use it for all operations + // Note: encryptedSecrets come from metadata for resume - they were stored during prepare/initiate + const session = await this.getOrCreateSession( + sandbox, + context, + env, + kilocodeToken, + kilocodeModel, + orgId, + metadata?.encryptedSecrets, + undefined, // createdOnPlatform - not used for resume + metadata?.appendSystemPrompt + ); + + // Check if workspace repo exists - if not, we may need to reclone + const repoCheck = await session.exec(`test -d ${workspacePath}/.git && echo exists`); + const repoExists = repoCheck.stdout?.includes('exists') ?? false; + + // Check disk space for observability (logs warning if low) + await checkDiskSpace(session); + + if (!repoExists) { + if (metadata) { + if (metadata?.gitUrl) { + const effectiveGitToken = freshGitToken ?? metadata.gitToken; + logger + .withTags({ gitUrl: metadata.gitUrl, hasFreshToken: !!freshGitToken }) + .info('Recloning missing repository (generic git)'); + + // Reclone the repository using generic git + await cloneGitRepo(session, workspacePath, metadata.gitUrl, effectiveGitToken); + } else if (metadata?.githubRepo) { + const effectiveGithubToken = freshGithubToken ?? metadata.githubToken; + logger + .withTags({ githubRepo: metadata.githubRepo, hasFreshToken: !!freshGithubToken }) + .info('Recloning missing repository'); + + // Reclone the repository + await cloneGitHubRepo( + session, + workspacePath, + metadata.githubRepo, + effectiveGithubToken, + getGitAuthorEnv(env, metadata.githubAppType) + ); + } else { + throw new Error( + `Session ${sessionId} workspace is missing and no repository metadata found. Please re-initiate the session.` + ); + } + } else { + throw new Error( + `Session ${sessionId} workspace is missing and metadata could not be retrieved. Please re-initiate the session.` + ); + } + } + + // Only re-run setup if we had to reclone (cold start) + // Note: We don't checkout branch here - kilocode CLI will restore workspace state when it runs + if (!repoExists) { + // Re-run setup commands (fresh clone, need to reinstall) + if (metadata?.setupCommands && metadata.setupCommands.length > 0) { + logger.info('Re-running setup commands after fresh clone'); + await runSetupCommands(session, context, metadata.setupCommands, false); // lenient + } + + // Re-write MCP settings (fresh clone) + if (metadata?.mcpServers && Object.keys(metadata.mcpServers).length > 0) { + await writeMCPSettings(sandbox, context.sessionHome, metadata.mcpServers); + } + } + + return { + context, + session, + streamKilocodeExec: ( + mode: string, + prompt: string, + options?: { sessionId?: string; skipInterruptPolling?: boolean; images?: Images } + ) => + streamKilocodeExecution( + sandbox, + session, + context, + mode, + prompt, + { + ...options, + isFirstExecution: false, + kiloSessionId: metadata?.kiloSessionId, + images: options?.images, + }, + env + ), + }; + } + + /** + * Identifies and kills all kilocode processes running in a specific session's workspace. + * This allows clients to stop running executions in a session without deleting the session itself. + * + * @param usePkill - If true, uses `pkill -f` with sessionId pattern instead of sandbox.listProcesses/killProcess. + * This is a temporary workaround for environments where sandbox process APIs are unreliable. + */ + static async interrupt( + sandbox: SandboxInstance, + session: ExecutionSession, + sessionContext: SessionContext, + usePkill: boolean = false, + executionId?: string + ): Promise { + if (usePkill) { + return SessionService.interruptWithPkill(session, sessionContext, executionId); + } + return SessionService.interruptWithSandboxApi(sandbox, session, sessionContext); + } + + /** + * Interrupt using pkill -f with the sessionId as the pattern. + * This kills any process whose command line contains the sessionId. + */ + private static async interruptWithPkill( + session: ExecutionSession, + sessionContext: SessionContext, + executionId?: string + ): Promise { + const startTime = Date.now(); + const { sessionId } = sessionContext; + + try { + const attemptPkill = async (pattern: string, label: string) => { + logger.info('Interrupting session using pkill', { + sessionId, + label, + pattern, + }); + return session.exec(`pkill -f '${pattern}'`); + }; + + let execIdError: string | null = null; + + if (executionId) { + // Prefer the wrapper execution ID for v2 sessions. + // pkill -f matches against the full command line. + const execResult = await attemptPkill(`--execution-id=${executionId}`, 'executionId'); + if (execResult.exitCode === 0) { + return { + success: true, + killedProcessIds: [], // pkill doesn't report individual PIDs + failedProcessIds: [], + message: 'Interrupted execution using pkill (executionId)', + }; + } + if (execResult.exitCode !== 1) { + execIdError = `pkill failed with exit code ${execResult.exitCode}: ${execResult.stderr}`; + logger.error('pkill command failed for executionId', { + sessionId, + executionId, + exitCode: execResult.exitCode, + stderr: execResult.stderr, + }); + } + } + + // Fall back to sessionId for legacy sessions. + const sessionResult = await attemptPkill(sessionId, 'sessionId'); + const elapsed = Date.now() - startTime; + + if (sessionResult.exitCode === 0) { + logger.info('pkill successfully killed processes', { + sessionId, + elapsedMs: elapsed, + }); + + return { + success: true, + killedProcessIds: [], // pkill doesn't report individual PIDs + failedProcessIds: [], + message: execIdError + ? `Interrupted execution using pkill (sessionId fallback). ${execIdError}` + : 'Interrupted execution using pkill', + }; + } + if (sessionResult.exitCode === 1) { + logger.info('No matching processes found for pkill', { + sessionId, + elapsedMs: elapsed, + }); + + return { + success: true, + killedProcessIds: [], + failedProcessIds: [], + message: execIdError + ? `No running processes found for this session. ${execIdError}` + : 'No running processes found for this session', + }; + } + + logger.error('pkill command failed for sessionId', { + sessionId, + exitCode: sessionResult.exitCode, + stderr: sessionResult.stderr, + elapsedMs: elapsed, + }); + + return { + success: false, + killedProcessIds: [], + failedProcessIds: [], + message: execIdError + ? `${execIdError}; sessionId pkill failed with exit code ${sessionResult.exitCode}: ${sessionResult.stderr}` + : `pkill failed with exit code ${sessionResult.exitCode}: ${sessionResult.stderr}`, + }; + } catch (error) { + logger.error('Interrupt with pkill failed', { + sessionId, + error: error instanceof Error ? error.message : String(error), + }); + + throw error; + } + } + + /** + * Interrupt using sandbox.listProcesses and session.killProcess APIs. + * This is the original implementation that enumerates and kills processes individually. + */ + private static async interruptWithSandboxApi( + sandbox: SandboxInstance, + session: ExecutionSession, + sessionContext: SessionContext + ): Promise { + type ProcessInfo = { + id: string; + status: string; + command: string; + }; + + const startTime = Date.now(); + + try { + // List all processes in the sandbox + const processes = await sandbox.listProcesses(); + + // Filter for kilocode processes in this session's workspace + const targetProcesses = processes.filter((proc: ProcessInfo) => { + const isRunning = proc.status === 'running'; + const isKilocode = proc.command.includes('kilocode'); + const isInWorkspace = proc.command.includes(`--workspace=${sessionContext.workspacePath}`); + + return isRunning && isKilocode && isInWorkspace; + }); + + if (targetProcesses.length === 0) { + logger.info('No matching kilocode processes found to interrupt', { + sessionId: sessionContext.sessionId, + workspacePath: sessionContext.workspacePath, + }); + + return { + success: true, + killedProcessIds: [], + failedProcessIds: [], + message: 'No running kilocode processes found for this session', + }; + } + + // Kill each target process + const killed: string[] = []; + const failed: string[] = []; + + for (const proc of targetProcesses) { + try { + // Send SIGTERM for graceful termination (exit code 143) + // This allows the SSE stream to properly close with an expected exit code + await session.killProcess(proc.id, 'SIGTERM'); + killed.push(proc.id); + logger.info('Successfully killed process', { + processId: proc.id, + command: proc.command, + }); + } catch (error) { + failed.push(proc.id); + logger.error('Failed to kill process', { + processId: proc.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + const elapsed = Date.now() - startTime; + logger.info('Interrupt operation completed', { + sessionId: sessionContext.sessionId, + killedCount: killed.length, + failedCount: failed.length, + elapsedMs: elapsed, + }); + + return { + success: killed.length > 0, + killedProcessIds: killed, + failedProcessIds: failed, + message: + killed.length > 0 + ? `Interrupted execution: killed ${killed.length} process(es)${failed.length > 0 ? `, ${failed.length} failed` : ''}` + : `Failed to kill any processes (${failed.length} attempts failed)`, + }; + } catch (error) { + logger.error('Interrupt operation failed', { + sessionId: sessionContext.sessionId, + error: error instanceof Error ? error.message : String(error), + }); + + throw error; + } + } + + /** + * Save session metadata to Durable Object. + * + * When `existing` is provided (e.g., from prepared session flow), merges with it + * to preserve fields like preparedAt, initiatedAt, prompt, mode, model, autoCommit. + * This avoids an extra DO read and prevents data loss. + */ + private async saveSessionMetadata( + env: PersistenceEnv, + context: SessionContext, + data: { + githubRepo?: string; + githubToken?: string; + gitUrl?: string; + gitToken?: string; + envVars?: Record; + setupCommands?: string[]; + mcpServers?: Record; + upstreamBranch?: string; + kiloSessionId?: string; + }, + existing?: CloudAgentSessionState + ): Promise { + const { orgId, userId, sessionId, botId } = context; + const doKey = `${userId}:${sessionId}`; + + // Build metadata, preserving prepared session fields from existing if provided + const metadata: CloudAgentSessionState = { + // Start with existing metadata if provided (preserves preparedAt, initiatedAt, prompt, mode, model, autoCommit) + ...(existing ?? {}), + // Always update these core fields + version: Date.now(), + sessionId, + orgId, + userId, + botId, + timestamp: Date.now(), + // Apply the new data (may override some existing fields, which is intentional) + githubRepo: data.githubRepo, + githubToken: data.githubToken, + gitUrl: data.gitUrl, + gitToken: data.gitToken, + envVars: data.envVars, + setupCommands: data.setupCommands, + mcpServers: data.mcpServers, + upstreamBranch: data.upstreamBranch, + kiloSessionId: data.kiloSessionId, + }; + + // Validate before writing + const parseResult = MetadataSchema.safeParse(metadata); + if (!parseResult.success) { + logger + .withFields({ errors: parseResult.error.format() }) + .error('Invalid metadata in saveSessionMetadata'); + throw new Error(`Invalid metadata: ${JSON.stringify(parseResult.error.format())}`); + } + + await withDORetry( + () => env.CLOUD_AGENT_SESSION.get(env.CLOUD_AGENT_SESSION.idFromName(doKey)), + stub => stub.updateMetadata(parseResult.data), + 'updateMetadata' + ); + } + + private async loadSessionMetadata( + env: PersistenceEnv, + context: SessionContext + ): Promise { + const { userId, sessionId } = context; + const metadata = await fetchSessionMetadata(env, userId, sessionId); + if (!metadata) { + logger.info('No metadata found'); + return null; + } + + return metadata; + } + + /** + * Create a minimal cliSession in kilocode-backend. + * Uses the customer's auth token (forwarded from the original request). + * Returns the generated kiloSessionId. + */ + async createKiloSessionInBackend( + cloudAgentSessionId: string, + authToken: string, + env: PersistenceEnv, + organizationId?: string, + lastMode?: string, + lastModel?: string, + gitUrl?: string + ): Promise { + const backendUrl = env.KILOCODE_BACKEND_BASE_URL || DEFAULT_BACKEND_URL; + + const input = { + created_on_platform: 'cloud-agent', + organization_id: organizationId ?? null, + cloud_agent_session_id: cloudAgentSessionId, + version: 2, + last_mode: lastMode, + last_model: lastModel, + git_url: gitUrl, + }; + + const response = await fetch(`${backendUrl}/api/trpc/cliSessions.createV2`, { + method: 'POST', + headers: { + Authorization: `Bearer ${authToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(input), + }); + + if (!response.ok) { + const text = await response.text(); + console.error('[createKiloSessionInBackend] Backend error:', { + status: response.status, + statusText: response.statusText, + body: '[redacted]', + backendUrl, + organizationId, + cloudAgentSessionId, + }); + throw new Error( + `Failed to create kilo session: ${response.status} - ${text.substring(0, 200)}` + ); + } + + const result = await response.json(); + + type TrpcResponse = { result?: { data?: { session_id?: string } } }; + const typedResult = result as TrpcResponse; + + const sessionId = typedResult.result?.data?.session_id; + if (!sessionId) { + throw new Error('Backend did not return session_id'); + } + + return sessionId; + } + + /** + * Delete a cliSession in kilocode-backend. + * Used for rollback when DO prepare() fails after backend session was created. + */ + async deleteKiloSessionInBackend( + kiloSessionId: string, + authToken: string, + env: PersistenceEnv + ): Promise { + const backendUrl = env.KILOCODE_BACKEND_BASE_URL || DEFAULT_BACKEND_URL; + + const input = { + session_id: kiloSessionId, + }; + + const response = await fetch(`${backendUrl}/api/trpc/cliSessions.delete`, { + method: 'POST', + headers: { + Authorization: `Bearer ${authToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(input), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to delete kilo session: ${response.status} ${text}`); + } + + const result = await response.json(); + + type TrpcResponse = { result?: { data?: { success?: boolean } } }; + const typedResult = result as TrpcResponse; + + if (!typedResult.result?.data?.success) { + throw new Error('Backend did not confirm successful deletion'); + } + } + + /** + * Helper to link sessions in backend using tRPC wire format + */ + private async linkKiloSessionInBackend( + kiloSessionId: string, + cloudAgentSessionId: string, + authToken: string, + env: PersistenceEnv + ): Promise { + const backendUrl = env.KILOCODE_BACKEND_BASE_URL || DEFAULT_BACKEND_URL; + + const input = { + kilo_session_id: kiloSessionId, + cloud_agent_session_id: cloudAgentSessionId, + }; + + const response = await fetch(`${backendUrl}/api/trpc/cliSessions.linkCloudAgent`, { + method: 'POST', + headers: { + Authorization: `Bearer ${authToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(input), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to link sessions: ${response.status} ${text}`); + } + + const result = await response.json(); + + type TrpcResponse = { result?: { data?: { success?: boolean } } }; + const typedResult = result as TrpcResponse; + + if (!typedResult.result?.data?.success) { + throw new Error('Backend did not confirm successful link'); + } + } + + /** + * Capture the current git branch after kilo execution and update metadata. + */ + private async captureAndStoreBranch( + session: ExecutionSession, + context: SessionContext, + env: PersistenceEnv + ): Promise { + try { + const branchResult = await session.exec( + `cd ${context.workspacePath} && git branch --show-current` + ); + + if (branchResult.exitCode !== 0) { + logger.warn('git branch --show-current failed, branch not captured'); + return; + } + + const currentBranch = branchResult.stdout.trim(); + if (!currentBranch) { + logger.warn('No branch name returned from git, branch not captured'); + return; + } + + logger.withTags({ currentBranch }).info('Captured branch after kilo execution'); + + // Update only the upstreamBranch field using dedicated DO method + // This is atomic and preserves all other metadata fields + const doKey = `${context.userId}:${context.sessionId}`; + await withDORetry( + () => env.CLOUD_AGENT_SESSION.get(env.CLOUD_AGENT_SESSION.idFromName(doKey)), + stub => stub.updateUpstreamBranch(currentBranch), + 'updateUpstreamBranch' + ); + + logger.withTags({ currentBranch }).info('Stored branch in metadata for future warm starts'); + } catch (error) { + // Non-critical - log but don't fail + logger + .withFields({ error: error instanceof Error ? error.message : String(error) }) + .warn('Failed to capture current branch after execution'); + } + } +} + +/** + * Returns the correct GitHub App slug and bot user ID for git author attribution, + * based on whether this is a standard or lite app session. + */ +function getGitAuthorEnv( + env: PersistenceEnv, + githubAppType?: 'standard' | 'lite' +): { GITHUB_APP_SLUG?: string; GITHUB_APP_BOT_USER_ID?: string } { + if (githubAppType === 'lite') { + return { + GITHUB_APP_SLUG: env.GITHUB_LITE_APP_SLUG || env.GITHUB_APP_SLUG, + GITHUB_APP_BOT_USER_ID: env.GITHUB_LITE_APP_BOT_USER_ID || env.GITHUB_APP_BOT_USER_ID, + }; + } + return { + GITHUB_APP_SLUG: env.GITHUB_APP_SLUG, + GITHUB_APP_BOT_USER_ID: env.GITHUB_APP_BOT_USER_ID, + }; +} + +export interface PreparedSession { + context: SessionContext; + session: Awaited>; + streamKilocodeExec: ( + mode: string, + prompt: string, + options?: { sessionId?: string; skipInterruptPolling?: boolean; images?: Images } + ) => AsyncGenerator; +} + +export interface InitiateOptions { + sandbox: SandboxInstance; + sandboxId: SessionContext['sandboxId']; + orgId?: string; + userId: string; + sessionId: SessionId; + kilocodeToken: string; + kilocodeModel: string; + githubRepo?: string; + githubToken?: string; + gitUrl?: string; + gitToken?: string; + env: PersistenceEnv; + envVars?: Record; + encryptedSecrets?: EncryptedSecrets; + setupCommands?: string[]; + mcpServers?: Record; + upstreamBranch?: string; + botId?: string; + /** GitHub App type for selecting correct slug/bot identity */ + githubAppType?: 'standard' | 'lite'; + /** + * Platform identifier for session creation (e.g., "slack", "cloud-agent"). + * Used to set KILO_PLATFORM env var and ultimately the session's created_on_platform. + * Defaults to "cloud-agent" if not specified. + */ + createdOnPlatform?: string; + /** + * Whether to perform a shallow clone (depth: 1) for faster checkout and reduced disk usage. + * Useful for fire-and-forget scenarios like code reviews where full history isn't needed. + */ + shallow?: boolean; +} + +export interface ResumeOptions { + sandbox: SandboxInstance; + sandboxId: SessionContext['sandboxId']; + orgId?: string; + userId: string; + sessionId: SessionId; + kilocodeToken: string; + kilocodeModel: string; + env: PersistenceEnv; + githubToken?: string; + gitToken?: string; +} + +/** + * Base options for initiateFromKiloSession (without git source). + */ +type InitiateFromKiloSessionBaseOptions = { + sandbox: SandboxInstance; + sandboxId: SessionContext['sandboxId']; + orgId?: string; + userId: string; + sessionId: SessionId; + kilocodeToken: string; + kilocodeModel: string; + kiloSessionId: string; + env: PersistenceEnv; + envVars?: Record; + encryptedSecrets?: EncryptedSecrets; + setupCommands?: string[]; + mcpServers?: Record; + botId?: string; + skipLinking?: boolean; + /** GitHub App type for selecting correct slug/bot identity */ + githubAppType?: 'standard' | 'lite'; + /** + * Existing metadata from prepared session flow. + * When provided, saveSessionMetadata will merge with it to preserve + * preparedAt, initiatedAt, prompt, mode, model, autoCommit fields. + */ + existingMetadata?: CloudAgentSessionState; +}; + +/** + * GitHub repository source - requires githubRepo, optional githubToken. + * Explicitly excludes gitUrl/gitToken to enforce mutual exclusivity. + */ +type GitHubSource = { + githubRepo: string; + githubToken?: string; + gitUrl?: undefined; + gitToken?: undefined; +}; + +/** + * Generic Git URL source - requires gitUrl, optional gitToken. + * Explicitly excludes githubRepo/githubToken to enforce mutual exclusivity. + */ +type GitUrlSource = { + gitUrl: string; + gitToken?: string; + githubRepo?: undefined; + githubToken?: undefined; +}; + +/** + * Options for initiateFromKiloSession. + * Requires exactly one of: GitHub repo (with optional token) OR Git URL (with optional token). + * TypeScript enforces this at compile time via the union type. + */ +export type InitiateFromKiloSessionOptions = InitiateFromKiloSessionBaseOptions & + (GitHubSource | GitUrlSource); diff --git a/cloud-agent-next/src/session/ingest-handlers/branch-capture.ts b/cloud-agent-next/src/session/ingest-handlers/branch-capture.ts new file mode 100644 index 0000000000..5e0f99aaab --- /dev/null +++ b/cloud-agent-next/src/session/ingest-handlers/branch-capture.ts @@ -0,0 +1,19 @@ +import type { CompleteEventData } from '../../shared/protocol.js'; + +export type BranchCaptureContext = { + updateUpstreamBranch: (branch: string) => Promise; + logger: { info: (msg: string, data?: object) => void }; +}; + +/** + * Extract and persist branch from complete event data. + */ +export async function handleBranchCapture( + data: CompleteEventData, + ctx: BranchCaptureContext +): Promise { + if (!data.currentBranch) return; + + await ctx.updateUpstreamBranch(data.currentBranch); + ctx.logger.info('Captured branch from complete event', { branch: data.currentBranch }); +} diff --git a/cloud-agent-next/src/session/ingest-handlers/execution-lifecycle.ts b/cloud-agent-next/src/session/ingest-handlers/execution-lifecycle.ts new file mode 100644 index 0000000000..375fc6e9f7 --- /dev/null +++ b/cloud-agent-next/src/session/ingest-handlers/execution-lifecycle.ts @@ -0,0 +1,28 @@ +export type ExecutionStatus = 'completed' | 'failed' | 'interrupted'; + +export type ExecutionLifecycleContext = { + updateExecutionStatus: ( + executionId: string, + status: ExecutionStatus, + error?: string + ) => Promise; + clearActiveExecution: () => Promise; + logger: { info: (msg: string, data?: object) => void }; +}; + +/** + * Handle execution completion - update status and clear active execution. + */ +export async function handleExecutionComplete( + executionId: string, + status: ExecutionStatus, + ctx: ExecutionLifecycleContext, + error?: string +): Promise { + ctx.logger.info('Execution complete', { executionId, status, error }); + + // Update the execution status and completedAt in storage + await ctx.updateExecutionStatus(executionId, status, error); + + await ctx.clearActiveExecution(); +} diff --git a/cloud-agent-next/src/session/ingest-handlers/index.ts b/cloud-agent-next/src/session/ingest-handlers/index.ts new file mode 100644 index 0000000000..6c03b09f07 --- /dev/null +++ b/cloud-agent-next/src/session/ingest-handlers/index.ts @@ -0,0 +1,11 @@ +export { + handleKilocodeEvent, + type KiloSessionCaptureContext, + type KiloSessionCaptureState, +} from './kilo-session-capture.js'; +export { handleBranchCapture, type BranchCaptureContext } from './branch-capture.js'; +export { + handleExecutionComplete, + type ExecutionLifecycleContext, + type ExecutionStatus, +} from './execution-lifecycle.js'; diff --git a/cloud-agent-next/src/session/ingest-handlers/kilo-session-capture.ts b/cloud-agent-next/src/session/ingest-handlers/kilo-session-capture.ts new file mode 100644 index 0000000000..4722351656 --- /dev/null +++ b/cloud-agent-next/src/session/ingest-handlers/kilo-session-capture.ts @@ -0,0 +1,50 @@ +import type { KilocodeEventData } from '../../shared/protocol.js'; + +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export type KiloSessionCaptureContext = { + updateKiloSessionId: (id: string) => Promise; + linkToBackend: (kiloSessionId: string) => Promise; + logger: { + info: (msg: string, data?: object) => void; + warn: (msg: string, data?: object) => void; + }; +}; + +export type KiloSessionCaptureState = { + captured: boolean; +}; + +/** + * Process a kilocode event and capture kiloSessionId if present. + * Returns true if a session ID was captured. + */ +export async function handleKilocodeEvent( + data: KilocodeEventData, + state: KiloSessionCaptureState, + ctx: KiloSessionCaptureContext +): Promise { + if (state.captured) return false; + if (data.event !== 'session_created') return false; + if (typeof data.sessionId !== 'string') return false; + if (!UUID_REGEX.test(data.sessionId)) { + ctx.logger.warn('Invalid kiloSessionId format', { sessionId: data.sessionId }); + return false; + } + + state.captured = true; + const kiloSessionId = data.sessionId; + + await ctx.updateKiloSessionId(kiloSessionId); + ctx.logger.info('Captured kiloSessionId', { kiloSessionId }); + + // Backend link is async/non-blocking + void ctx.linkToBackend(kiloSessionId).catch(err => { + ctx.logger.warn('Failed to link kiloSessionId to backend', { + kiloSessionId, + error: err instanceof Error ? err.message : String(err), + }); + }); + + return true; +} diff --git a/cloud-agent-next/src/session/queries/events.ts b/cloud-agent-next/src/session/queries/events.ts new file mode 100644 index 0000000000..22f6a57f70 --- /dev/null +++ b/cloud-agent-next/src/session/queries/events.ts @@ -0,0 +1,160 @@ +/** + * Event queries module for CloudAgentSession Durable Object. + * + * Provides type-safe SQL operations for storing and retrieving + * execution events from SQLite storage. + */ + +import type { StoredEvent } from '../../websocket/types.js'; +import type { EventId } from '../../types/ids.js'; +import { events, EventRecord, CountResult, MaxIdResult } from '../../db/tables/index.js'; +import { pushInClause, pushCondition, buildWhereClause } from '../../utils/sql-helpers.js'; + +type SqlStorage = DurableObjectState['storage']['sql']; + +// Destructure for convenient access to columns +const { columns: cols } = events; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Parameters for inserting a new event */ +export type InsertEventParams = { + executionId: string; + sessionId: string; + streamEventType: string; + payload: string; // JSON stringified + timestamp: number; +}; + +/** Query filters for finding events */ +export type EventQueryFilters = { + /** Exclusive: id > fromId */ + fromId?: EventId; + /** Only return events for these execution IDs */ + executionIds?: string[]; + /** Only return events of these types */ + eventTypes?: string[]; + /** Inclusive: timestamp >= startTime */ + startTime?: number; + /** Inclusive: timestamp <= endTime */ + endTime?: number; + /** Maximum number of events to return */ + limit?: number; +}; + +// --------------------------------------------------------------------------- +// Factory Function +// --------------------------------------------------------------------------- + +/** + * Create event queries for the CloudAgentSession Durable Object. + * + * @param sql - SqlStorage instance from the DO context + * @returns Object with event query methods + */ +export function createEventQueries(sql: SqlStorage) { + return { + /** + * Insert a new event, returning the auto-generated ID. + * + * @param params - Event data to insert + * @returns The auto-generated event ID + */ + insert(params: InsertEventParams): EventId { + const result = sql.exec( + `INSERT INTO ${events} (${cols.execution_id}, ${cols.session_id}, ${cols.stream_event_type}, ${cols.payload}, ${cols.timestamp}) + VALUES (?, ?, ?, ?, ?) + RETURNING ${cols.id}`, + params.executionId, + params.sessionId, + params.streamEventType, + params.payload, + params.timestamp + ); + + const row = [...result][0]; + return EventRecord.pick({ id: true }).parse(row).id; + }, + + /** + * Find events by filters with pagination. + * Results are ordered by ID ascending. + * + * @param filters - Query filters to apply + * @returns Array of stored events matching the filters + */ + findByFilters(filters: EventQueryFilters): StoredEvent[] { + const conditions: string[] = []; + const args: unknown[] = []; + + // Use qualified column names (events.column) for WHERE clause + pushCondition(conditions, args, `${events.id}`, '>', filters.fromId); + pushInClause(conditions, args, `${events.execution_id}`, filters.executionIds); + pushInClause(conditions, args, `${events.stream_event_type}`, filters.eventTypes); + pushCondition(conditions, args, `${events.timestamp}`, '>=', filters.startTime); + pushCondition(conditions, args, `${events.timestamp}`, '<=', filters.endTime); + + let query = `SELECT ${events.id}, ${events.execution_id}, ${events.session_id}, ${events.stream_event_type}, ${events.payload}, ${events.timestamp} FROM ${events}`; + query += buildWhereClause(conditions); + query += ` ORDER BY ${events.id} ASC`; + + if (filters.limit !== undefined) { + query += ' LIMIT ?'; + args.push(filters.limit); + } + + const result = sql.exec(query, ...args); + + return [...result].map(row => EventRecord.parse(row) as StoredEvent); + }, + + /** + * Delete events older than a given timestamp. + * + * @param timestamp - Unix timestamp threshold + * @returns Number of events deleted + */ + deleteOlderThan(timestamp: number): number { + const result = sql.exec(`DELETE FROM ${events} WHERE ${events.timestamp} < ?`, timestamp); + return result.rowsWritten; + }, + + /** + * Get event count for an execution. + * + * @param executionId - Execution ID to count events for + * @returns Number of events for the execution + */ + countByExecutionId(executionId: string): number { + const result = sql.exec( + `SELECT COUNT(*) as count FROM ${events} WHERE ${events.execution_id} = ?`, + executionId + ); + const row = [...result][0]; + if (!row) return 0; + return CountResult.parse(row).count; + }, + + /** + * Get the latest event ID (for tracking). + * + * @returns The highest event ID, or null if no events exist + */ + getLatestEventId(): EventId | null { + const result = sql.exec(`SELECT MAX(${cols.id}) as max_id FROM ${events}`); + const row = [...result][0]; + if (!row) return null; + const parsed = MaxIdResult.parse(row); + return parsed.max_id !== null ? parsed.max_id : null; + }, + }; +} + +// --------------------------------------------------------------------------- +// Type Export +// --------------------------------------------------------------------------- + +/** Type of the event queries object returned by createEventQueries */ +export type EventQueries = ReturnType; diff --git a/cloud-agent-next/src/session/queries/executions.ts b/cloud-agent-next/src/session/queries/executions.ts new file mode 100644 index 0000000000..60560b405d --- /dev/null +++ b/cloud-agent-next/src/session/queries/executions.ts @@ -0,0 +1,236 @@ +/** + * Execution CRUD operations for CloudAgentSession Durable Object. + * + * These operations use the DO's key-value storage (not SQLite) to track + * execution metadata for WebSocket streaming support. + */ + +import type { ExecutionId } from '../../types/ids.js'; +import type { ExecutionStatus } from '../../core/execution.js'; +import { canTransition, isTerminal } from '../../core/execution.js'; +import type { + ExecutionMetadata, + AddExecutionParams, + UpdateExecutionStatusParams, +} from '../types.js'; +import { Ok, Err, type Result } from '../../lib/result.js'; + +// --------------------------------------------------------------------------- +// Storage Keys +// --------------------------------------------------------------------------- + +const EXECUTIONS_KEY = 'executions'; +const ACTIVE_EXECUTION_KEY = 'active_execution_id'; +const INTERRUPT_KEY = 'interrupt_requested'; + +/** Storage interface for key-value operations */ +type KVStorage = DurableObjectState['storage']; + +// --------------------------------------------------------------------------- +// Error Types +// --------------------------------------------------------------------------- + +export type AddExecutionError = { code: 'ALREADY_EXISTS' }; + +export type UpdateStatusError = + | { code: 'NOT_FOUND' } + | { code: 'INVALID_TRANSITION'; from: ExecutionStatus; to: ExecutionStatus }; + +export type SetActiveError = { code: 'ALREADY_ACTIVE'; currentExecutionId: ExecutionId }; + +// --------------------------------------------------------------------------- +// Query Factory +// --------------------------------------------------------------------------- + +/** + * Create execution query functions bound to a DurableObject storage. + * + * @param storage - The storage instance from the DO context + * @returns Object with execution query methods + */ +export function createExecutionQueries(storage: KVStorage) { + return { + /** + * Get all executions for this session. + */ + async getAll(): Promise { + return (await storage.get(EXECUTIONS_KEY)) ?? []; + }, + + /** + * Get a specific execution by ID. + */ + async get(executionId: ExecutionId): Promise { + const executions = await this.getAll(); + return executions.find(e => e.executionId === executionId) ?? null; + }, + + /** + * Add a new execution with initial 'pending' status. + * Returns Err if an execution with the same ID already exists. + */ + async add(params: AddExecutionParams): Promise> { + const executions = await this.getAll(); + + if (executions.some(e => e.executionId === params.executionId)) { + return Err({ code: 'ALREADY_EXISTS' }); + } + + const execution: ExecutionMetadata = { + executionId: params.executionId, + status: 'pending', + startedAt: Date.now(), + mode: params.mode, + streamingMode: params.streamingMode, + ingestToken: params.ingestToken, + }; + + executions.push(execution); + await storage.put(EXECUTIONS_KEY, executions); + + return Ok(execution); + }, + + /** + * Update execution status with state machine validation. + * Automatically clears active execution when transitioning to terminal state. + */ + async updateStatus( + params: UpdateExecutionStatusParams + ): Promise> { + const executions = await this.getAll(); + const index = executions.findIndex(e => e.executionId === params.executionId); + + if (index === -1) { + return Err({ code: 'NOT_FOUND' }); + } + + const execution = executions[index]; + + if (!canTransition(execution.status, params.status)) { + return Err({ + code: 'INVALID_TRANSITION', + from: execution.status, + to: params.status, + }); + } + + execution.status = params.status; + + if (params.error !== undefined) { + execution.error = params.error; + } + + if (params.completedAt !== undefined) { + execution.completedAt = params.completedAt; + } else if (isTerminal(params.status)) { + execution.completedAt = Date.now(); + } + + executions[index] = execution; + await storage.put(EXECUTIONS_KEY, executions); + + // Clear active execution if terminal + if (isTerminal(params.status)) { + const activeId = await storage.get(ACTIVE_EXECUTION_KEY); + if (activeId === params.executionId) { + await storage.delete(ACTIVE_EXECUTION_KEY); + } + } + + return Ok(execution); + }, + + /** + * Update execution heartbeat timestamp. + * Returns false if execution not found. + */ + async updateHeartbeat(executionId: ExecutionId, timestamp: number): Promise { + const executions = await this.getAll(); + const index = executions.findIndex(e => e.executionId === executionId); + + if (index === -1) return false; + + executions[index].lastHeartbeat = timestamp; + await storage.put(EXECUTIONS_KEY, executions); + + return true; + }, + + /** + * Set process ID for long-running executions. + * Returns false if execution not found. + */ + async setProcessId(executionId: ExecutionId, processId: string): Promise { + const executions = await this.getAll(); + const index = executions.findIndex(e => e.executionId === executionId); + + if (index === -1) return false; + + executions[index].processId = processId; + await storage.put(EXECUTIONS_KEY, executions); + + return true; + }, + + /** + * Get the currently active execution ID, if any. + */ + async getActiveExecutionId(): Promise { + return (await storage.get(ACTIVE_EXECUTION_KEY)) ?? null; + }, + + /** + * Set the active execution for this session. + * Enforces single active execution per session. + * + * SAFETY NOTE: This check-then-set pattern is safe because Durable Objects + * serialize all incoming requests within a single instance. There is no + * concurrent execution of RPC methods within a DO, so no race condition + * can occur between the read and write operations. + */ + async setActiveExecution(executionId: ExecutionId): Promise> { + const currentActive = await this.getActiveExecutionId(); + + if (currentActive !== null && currentActive !== executionId) { + return Err({ + code: 'ALREADY_ACTIVE', + currentExecutionId: currentActive, + }); + } + + await storage.put(ACTIVE_EXECUTION_KEY, executionId); + return Ok(undefined); + }, + + /** + * Clear the active execution. + */ + async clearActiveExecution(): Promise { + await storage.delete(ACTIVE_EXECUTION_KEY); + }, + + /** + * Check if interrupt was requested for the current execution. + */ + async isInterruptRequested(): Promise { + return (await storage.get(INTERRUPT_KEY)) ?? false; + }, + + /** + * Request interrupt for the current execution. + */ + async requestInterrupt(): Promise { + await storage.put(INTERRUPT_KEY, true); + }, + + /** + * Clear the interrupt flag. + */ + async clearInterrupt(): Promise { + await storage.delete(INTERRUPT_KEY); + }, + }; +} + +export type ExecutionQueries = ReturnType; diff --git a/cloud-agent-next/src/session/queries/index.ts b/cloud-agent-next/src/session/queries/index.ts new file mode 100644 index 0000000000..29cb1e7f2c --- /dev/null +++ b/cloud-agent-next/src/session/queries/index.ts @@ -0,0 +1,39 @@ +/** + * Query modules for CloudAgentSession Durable Object. + * + * These modules provide type-safe operations for the DO storage. + * - Events and Leases use SQLite storage + * - Executions use key-value storage + * + * @example + * ```ts + * import { createEventQueries, createLeaseQueries, createExecutionQueries } from './queries/index.js'; + * + * const events = createEventQueries(ctx.storage.sql); + * const leases = createLeaseQueries(ctx.storage.sql); + * const executions = createExecutionQueries(ctx.storage); + * ``` + */ + +export { + createEventQueries, + type EventQueries, + type InsertEventParams, + type EventQueryFilters, +} from './events.js'; + +export { + createLeaseQueries, + type LeaseQueries, + type LeaseRecord, + type LeaseAcquireError, + type LeaseExtendError, +} from './leases.js'; + +export { + createExecutionQueries, + type ExecutionQueries, + type AddExecutionError, + type UpdateStatusError, + type SetActiveError, +} from './executions.js'; diff --git a/cloud-agent-next/src/session/queries/leases.ts b/cloud-agent-next/src/session/queries/leases.ts new file mode 100644 index 0000000000..0c69632ac1 --- /dev/null +++ b/cloud-agent-next/src/session/queries/leases.ts @@ -0,0 +1,282 @@ +/** + * Lease queries module for CloudAgentSession Durable Object. + * + * Provides type-safe SQL operations for execution lease management, + * enabling idempotent queue message processing. + */ + +import { Ok, Err, type Result } from '../../lib/result.js'; +import { calculateExpiry, isExpired } from '../../core/lease.js'; +import { + execution_leases, + ExecutionLeaseRecord, + LeaseIdAndExpiry, + LeaseIdOnly, + type ExecutionLeaseRecordType, +} from '../../db/tables/index.js'; + +type SqlStorage = DurableObjectState['storage']['sql']; + +// Destructure for convenient access to columns +const { columns: cols } = execution_leases; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Lease record from database (camelCase for API) */ +export type LeaseRecord = { + executionId: string; + leaseId: string; + leaseExpiresAt: number; + updatedAt: number; + messageId: string | null; +}; + +/** Error types for lease acquisition operations */ +export type LeaseAcquireError = + | { code: 'ALREADY_HELD'; holder: string; expiresAt: number } + | { code: 'SQL_ERROR'; message: string }; + +/** Error types for lease extension operations */ +export type LeaseExtendError = + | { code: 'NOT_FOUND' } + | { code: 'WRONG_HOLDER'; currentHolder: string } + | { code: 'SQL_ERROR'; message: string }; + +// --------------------------------------------------------------------------- +// Helper: Convert DB row to LeaseRecord +// --------------------------------------------------------------------------- + +function toLeaseRecord(row: ExecutionLeaseRecordType): LeaseRecord { + return { + executionId: row.execution_id, + leaseId: row.lease_id, + leaseExpiresAt: row.lease_expires_at, + updatedAt: row.updated_at, + messageId: row.message_id, + }; +} + +// --------------------------------------------------------------------------- +// Factory Function +// --------------------------------------------------------------------------- + +/** + * Create lease queries for the CloudAgentSession Durable Object. + * + * @param sql - SqlStorage instance from the DO context + * @returns Object with lease query methods + */ +export function createLeaseQueries(sql: SqlStorage) { + return { + /** + * Try to acquire a lease atomically. + * Returns Ok if acquired, Err if already held by another lease. + * + * SAFETY NOTE: The check-then-set pattern used here (SELECT then UPDATE/INSERT) + * is safe because Durable Objects serialize all incoming requests within a + * single instance. There is no concurrent execution of RPC methods within a DO, + * so no TOCTOU race condition can occur. An atomic UPDATE ... WHERE ... RETURNING + * pattern would be equivalent but is not required for correctness here. + * + * @param executionId - ID of the execution to acquire lease for + * @param leaseId - Unique ID for this lease attempt + * @param messageId - Queue message ID for tracking + * @param now - Current timestamp (defaults to Date.now()) + * @returns Result indicating success or reason for failure + */ + tryAcquire( + executionId: string, + leaseId: string, + messageId: string, + now: number = Date.now() + ): Result<{ acquired: true; expiresAt: number }, LeaseAcquireError> { + const expiresAt = calculateExpiry(now); + + try { + // Check if lease exists and is not expired + const existing = sql.exec( + `SELECT ${execution_leases.lease_id}, ${execution_leases.lease_expires_at} FROM ${execution_leases} WHERE ${execution_leases.execution_id} = ?`, + executionId + ); + + const existingRow = [...existing][0]; + + if (existingRow) { + // Lease exists - check if expired + const parsed = LeaseIdAndExpiry.parse(existingRow); + const existingExpiresAt = parsed.lease_expires_at; + + if (!isExpired(existingExpiresAt, now)) { + // Still held by someone else + return Err({ + code: 'ALREADY_HELD', + holder: parsed.lease_id, + expiresAt: existingExpiresAt, + }); + } + + // Expired - update to claim + sql.exec( + `UPDATE ${execution_leases} + SET ${cols.lease_id} = ?, ${cols.lease_expires_at} = ?, ${cols.updated_at} = ?, ${cols.message_id} = ? + WHERE ${execution_leases.execution_id} = ?`, + leaseId, + expiresAt, + now, + messageId, + executionId + ); + } else { + // No existing lease - insert new + sql.exec( + `INSERT INTO ${execution_leases} (${cols.execution_id}, ${cols.lease_id}, ${cols.lease_expires_at}, ${cols.updated_at}, ${cols.message_id}) + VALUES (?, ?, ?, ?, ?)`, + executionId, + leaseId, + expiresAt, + now, + messageId + ); + } + + return Ok({ acquired: true, expiresAt }); + } catch (e) { + return Err({ + code: 'SQL_ERROR', + message: e instanceof Error ? e.message : String(e), + }); + } + }, + + /** + * Extend an existing lease (heartbeat). + * + * @param executionId - ID of the execution to extend lease for + * @param leaseId - Lease ID that must match the current holder + * @param now - Current timestamp (defaults to Date.now()) + * @returns Result with new expiry time or error + */ + extend( + executionId: string, + leaseId: string, + now: number = Date.now() + ): Result<{ expiresAt: number }, LeaseExtendError> { + const expiresAt = calculateExpiry(now); + + // Verify we hold the lease before extending + const existing = sql.exec( + `SELECT ${execution_leases.lease_id} FROM ${execution_leases} WHERE ${execution_leases.execution_id} = ?`, + executionId + ); + + const row = [...existing][0]; + + if (!row) { + return Err({ code: 'NOT_FOUND' }); + } + + const parsed = LeaseIdOnly.parse(row); + if (parsed.lease_id !== leaseId) { + return Err({ + code: 'WRONG_HOLDER', + currentHolder: parsed.lease_id, + }); + } + + sql.exec( + `UPDATE ${execution_leases} SET ${cols.lease_expires_at} = ?, ${cols.updated_at} = ? WHERE ${execution_leases.execution_id} = ?`, + expiresAt, + now, + executionId + ); + + return Ok({ expiresAt }); + }, + + /** + * Release a lease (on completion). + * + * @param executionId - ID of the execution to release lease for + * @param leaseId - Lease ID that must match the current holder + * @returns true if lease was released, false if not found or wrong holder + */ + release(executionId: string, leaseId: string): boolean { + const result = sql.exec( + `DELETE FROM ${execution_leases} WHERE ${execution_leases.execution_id} = ? AND ${execution_leases.lease_id} = ?`, + executionId, + leaseId + ); + return result.rowsWritten > 0; + }, + + /** + * Get lease details for an execution. + * + * @param executionId - ID of the execution to get lease for + * @returns Lease record or null if not found + */ + get(executionId: string): LeaseRecord | null { + const result = sql.exec( + `SELECT ${execution_leases.execution_id}, ${execution_leases.lease_id}, ${execution_leases.lease_expires_at}, ${execution_leases.updated_at}, ${execution_leases.message_id} FROM ${execution_leases} WHERE ${execution_leases.execution_id} = ?`, + executionId + ); + + const row = [...result][0]; + + if (!row) return null; + + return toLeaseRecord(ExecutionLeaseRecord.parse(row)); + }, + + /** + * Check if a lease is currently held (not expired). + * + * @param executionId - ID of the execution to check + * @param now - Current timestamp (defaults to Date.now()) + * @returns true if lease is held and not expired + */ + isHeld(executionId: string, now: number = Date.now()): boolean { + const lease = this.get(executionId); + if (!lease) return false; + return !isExpired(lease.leaseExpiresAt, now); + }, + + /** + * Get all expired leases (for cleanup). + * + * @param now - Current timestamp (defaults to Date.now()) + * @returns Array of expired lease records + */ + findExpired(now: number = Date.now()): LeaseRecord[] { + const result = sql.exec( + `SELECT ${execution_leases.execution_id}, ${execution_leases.lease_id}, ${execution_leases.lease_expires_at}, ${execution_leases.updated_at}, ${execution_leases.message_id} FROM ${execution_leases} WHERE ${execution_leases.lease_expires_at} < ?`, + now + ); + + return [...result].map(row => toLeaseRecord(ExecutionLeaseRecord.parse(row))); + }, + + /** + * Delete all expired leases. + * + * @param now - Current timestamp (defaults to Date.now()) + * @returns Number of leases deleted + */ + deleteExpired(now: number = Date.now()): number { + const result = sql.exec( + `DELETE FROM ${execution_leases} WHERE ${execution_leases.lease_expires_at} < ?`, + now + ); + return result.rowsWritten; + }, + }; +} + +// --------------------------------------------------------------------------- +// Type Export +// --------------------------------------------------------------------------- + +/** Type of the lease queries object returned by createLeaseQueries */ +export type LeaseQueries = ReturnType; diff --git a/cloud-agent-next/src/session/types.ts b/cloud-agent-next/src/session/types.ts new file mode 100644 index 0000000000..3c33617a9d --- /dev/null +++ b/cloud-agent-next/src/session/types.ts @@ -0,0 +1,80 @@ +/** + * Session types for WebSocket streaming execution management. + * + * These types define the structure of execution metadata stored in + * the CloudAgentSession Durable Object's key-value storage. + */ + +import type { ExecutionId } from '../types/ids.js'; +import type { ExecutionStatus } from '../core/execution.js'; +import type { ExecutionMode, StreamingMode } from '../execution/types.js'; + +// --------------------------------------------------------------------------- +// Execution Metadata +// --------------------------------------------------------------------------- + +/** + * Execution metadata stored in session state. + * Tracks the status and configuration of each execution within a session. + */ +export type ExecutionMetadata = { + executionId: ExecutionId; + status: ExecutionStatus; + startedAt: number; + completedAt?: number; + mode: ExecutionMode; + streamingMode: StreamingMode; + error?: string; + /** Process ID for long-running sandbox processes */ + processId?: string; + lastHeartbeat?: number; + /** Token for authenticating ingest WebSocket connections */ + ingestToken?: string; +}; + +// --------------------------------------------------------------------------- +// Session State Extension +// --------------------------------------------------------------------------- + +/** + * Extended session state with WebSocket streaming support. + * These fields are stored in the DO key-value storage alongside metadata. + */ +export type CloudAgentSessionStateExtension = { + activeExecutionId?: ExecutionId; + executions?: ExecutionMetadata[]; + interruptRequested?: boolean; +}; + +// --------------------------------------------------------------------------- +// RPC Parameters +// --------------------------------------------------------------------------- + +/** + * Parameters for adding a new execution. + */ +export type AddExecutionParams = { + executionId: ExecutionId; + mode: ExecutionMode; + streamingMode: StreamingMode; + /** Token for authenticating ingest WebSocket connections */ + ingestToken?: string; +}; + +/** + * Parameters for updating execution status. + */ +export type UpdateExecutionStatusParams = { + executionId: ExecutionId; + status: ExecutionStatus; + error?: string; + completedAt?: number; +}; + +/** + * Parameters for updating execution heartbeat. + */ +export type UpdateExecutionHeartbeatParams = { + executionId: ExecutionId; + timestamp: number; +}; diff --git a/cloud-agent-next/src/shared/index.ts b/cloud-agent-next/src/shared/index.ts new file mode 100644 index 0000000000..84b7ae9d8a --- /dev/null +++ b/cloud-agent-next/src/shared/index.ts @@ -0,0 +1,7 @@ +export { + type StreamEventType, + type IngestEvent, + type WrapperCommand, + type CompleteEventData, + type KilocodeEventData, +} from './protocol.js'; diff --git a/cloud-agent-next/src/shared/kilo-types.ts b/cloud-agent-next/src/shared/kilo-types.ts new file mode 100644 index 0000000000..78868bf9a0 --- /dev/null +++ b/cloud-agent-next/src/shared/kilo-types.ts @@ -0,0 +1,392 @@ +// Generated types copied from kilo-cli SDK (subset used by KiloClient). +// Keep this file type-only to avoid bundling runtime dependencies. + +export type FileDiff = { + file: string; + before: string; + after: string; + additions: number; + deletions: number; +}; + +export type ProviderAuthError = { + name: 'ProviderAuthError'; + data: { + providerID: string; + message: string; + }; +}; + +export type UnknownError = { + name: 'UnknownError'; + data: { + message: string; + }; +}; + +export type MessageOutputLengthError = { + name: 'MessageOutputLengthError'; + data: { + [key: string]: unknown; + }; +}; + +export type MessageAbortedError = { + name: 'MessageAbortedError'; + data: { + message: string; + }; +}; + +export type ApiError = { + name: 'APIError'; + data: { + message: string; + statusCode?: number; + isRetryable: boolean; + responseHeaders?: { + [key: string]: string; + }; + responseBody?: string; + }; +}; + +export type AssistantMessage = { + id: string; + sessionID: string; + role: 'assistant'; + time: { + created: number; + completed?: number; + }; + error?: + | ProviderAuthError + | UnknownError + | MessageOutputLengthError + | MessageAbortedError + | ApiError; + parentID: string; + modelID: string; + providerID: string; + mode: string; + path: { + cwd: string; + root: string; + }; + summary?: boolean; + cost: number; + tokens: { + input: number; + output: number; + reasoning: number; + cache: { + read: number; + write: number; + }; + }; + finish?: string; +}; + +export type FilePartSourceText = { + value: string; + start: number; + end: number; +}; + +export type FileSource = { + text: FilePartSourceText; + type: 'file'; + path: string; +}; + +export type Range = { + start: { + line: number; + character: number; + }; + end: { + line: number; + character: number; + }; +}; + +export type SymbolSource = { + text: FilePartSourceText; + type: 'symbol'; + path: string; + range: Range; + name: string; + kind: number; +}; + +export type FilePartSource = FileSource | SymbolSource; + +export type TextPart = { + id: string; + sessionID: string; + messageID: string; + type: 'text'; + text: string; + synthetic?: boolean; + ignored?: boolean; + time?: { + start: number; + end?: number; + }; + metadata?: { + [key: string]: unknown; + }; +}; + +export type ReasoningPart = { + id: string; + sessionID: string; + messageID: string; + type: 'reasoning'; + text: string; + metadata?: { + [key: string]: unknown; + }; + time: { + start: number; + end?: number; + }; +}; + +export type FilePart = { + id: string; + sessionID: string; + messageID: string; + type: 'file'; + mime: string; + filename?: string; + url: string; + source?: FilePartSource; +}; + +export type ToolStatePending = { + status: 'pending'; + input: { + [key: string]: unknown; + }; + raw: string; +}; + +export type ToolStateRunning = { + status: 'running'; + input: { + [key: string]: unknown; + }; + title?: string; + metadata?: { + [key: string]: unknown; + }; + time: { + start: number; + }; +}; + +export type ToolStateCompleted = { + status: 'completed'; + input: { + [key: string]: unknown; + }; + output: string; + title: string; + metadata: { + [key: string]: unknown; + }; + time: { + start: number; + end: number; + compacted?: number; + }; + attachments?: Array; +}; + +export type ToolStateError = { + status: 'error'; + input: { + [key: string]: unknown; + }; + error: string; + metadata?: { + [key: string]: unknown; + }; + time: { + start: number; + end: number; + }; +}; + +export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError; + +export type ToolPart = { + id: string; + sessionID: string; + messageID: string; + type: 'tool'; + callID: string; + tool: string; + state: ToolState; + metadata?: { + [key: string]: unknown; + }; +}; + +export type StepStartPart = { + id: string; + sessionID: string; + messageID: string; + type: 'step-start'; + snapshot?: string; +}; + +export type StepFinishPart = { + id: string; + sessionID: string; + messageID: string; + type: 'step-finish'; + reason: string; + snapshot?: string; + cost: number; + tokens: { + input: number; + output: number; + reasoning: number; + cache: { + read: number; + write: number; + }; + }; +}; + +export type SnapshotPart = { + id: string; + sessionID: string; + messageID: string; + type: 'snapshot'; + snapshot: string; +}; + +export type PatchPart = { + id: string; + sessionID: string; + messageID: string; + type: 'patch'; + hash: string; + files: Array; +}; + +export type AgentPart = { + id: string; + sessionID: string; + messageID: string; + type: 'agent'; + name: string; + source?: { + value: string; + start: number; + end: number; + }; +}; + +export type RetryPart = { + id: string; + sessionID: string; + messageID: string; + type: 'retry'; + attempt: number; + error: ApiError; + time: { + created: number; + }; +}; + +export type CompactionPart = { + id: string; + sessionID: string; + messageID: string; + type: 'compaction'; + auto: boolean; +}; + +export type Part = + | TextPart + | { + id: string; + sessionID: string; + messageID: string; + type: 'subtask'; + prompt: string; + description: string; + agent: string; + } + | ReasoningPart + | FilePart + | ToolPart + | StepStartPart + | StepFinishPart + | SnapshotPart + | PatchPart + | AgentPart + | RetryPart + | CompactionPart; + +export type Session = { + id: string; + projectID: string; + directory: string; + parentID?: string; + summary?: { + additions: number; + deletions: number; + files: number; + diffs?: Array; + }; + share?: { + url: string; + }; + title: string; + version: string; + time: { + created: number; + updated: number; + compacting?: number; + }; + revert?: { + messageID: string; + partID?: string; + snapshot?: string; + diff?: string; + }; +}; + +export type TextPartInput = { + id?: string; + type: 'text'; + text: string; + synthetic?: boolean; + ignored?: boolean; + time?: { + start: number; + end?: number; + }; + metadata?: { + [key: string]: unknown; + }; +}; + +export type FilePartInput = { + id?: string; + type: 'file'; + mime: string; + filename?: string; + url: string; + source?: FilePartSource; +}; + +export type SessionCommandResponse = { + info: AssistantMessage; + parts: Array; +}; diff --git a/cloud-agent-next/src/shared/protocol.ts b/cloud-agent-next/src/shared/protocol.ts new file mode 100644 index 0000000000..efb9c45dd4 --- /dev/null +++ b/cloud-agent-next/src/shared/protocol.ts @@ -0,0 +1,55 @@ +/** + * Event types that flow through the streaming system. + * + * From wrapper -> DO: + * started, kilocode, output, status, heartbeat, pong, error, interrupted, complete, wrapper_resumed + * + * From DO -> /stream clients: + * All of the above, plus wrapper_disconnected, wrapper_reconnected + */ +export type StreamEventType = + // Wrapper -> DO (execution lifecycle) + | 'started' // Execution began + | 'kilocode' // Parsed JSON from kilocode stdout + | 'output' // Raw stdout/stderr + | 'status' // Status message (e.g., "Auto-committing...") + | 'heartbeat' // Keep-alive during idle periods + | 'pong' // Response to ping command from DO + | 'error' // Error occurred { error: string, fatal: boolean } + | 'interrupted' // User/signal interrupt + | 'complete' // Execution finished { exitCode, currentBranch? } + | 'wrapper_resumed' // Wrapper reconnected after disconnect (may have lost events) + // DO -> /stream clients (connection status) + | 'wrapper_disconnected' // Wrapper WebSocket closed unexpectedly + | 'wrapper_reconnected'; // Wrapper reconnected successfully + +/** + * Event envelope sent by wrapper to DO via /ingest WebSocket. + */ +export type IngestEvent = { + streamEventType: StreamEventType; + timestamp: string; // ISO 8601 + data: unknown; +}; + +/** + * Commands sent from DO to wrapper via /ingest WebSocket. + */ +export type WrapperCommand = { type: 'kill'; signal?: 'SIGTERM' | 'SIGKILL' } | { type: 'ping' }; + +/** + * Data included in 'complete' events. + */ +export type CompleteEventData = { + exitCode: number; + currentBranch?: string; // Omitted if detached HEAD +}; + +/** + * Data included in 'kilocode' events (passthrough from CLI). + */ +export type KilocodeEventData = { + event?: string; // e.g., 'session_created', 'token_usage' + sessionId?: string; // Present in session_created events + [key: string]: unknown; // Other CLI event fields +}; diff --git a/cloud-agent-next/src/streaming-helpers.test.ts b/cloud-agent-next/src/streaming-helpers.test.ts new file mode 100644 index 0000000000..e5886b4011 --- /dev/null +++ b/cloud-agent-next/src/streaming-helpers.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { tryParseJson } from './streaming-helpers.js'; + +describe('tryParseJson', () => { + const ansiLog = { + type: 'log', + message: 'Output: \u001b[32mSuccess\u001b[0m', + }; + + const successfulCases = [ + { + name: 'standard JSON object', + input: '{"type":"test","value":42}', + expected: { type: 'test', value: 42 }, + }, + { + name: 'JSON with nested objects', + input: '{"type":"tool_use","input":{"path":"test.ts"}}', + expected: { + type: 'tool_use', + input: { path: 'test.ts' }, + }, + }, + { + name: 'JSON with ANSI prefix stripped', + input: '\u001b[2K{"type":"status","message":"Starting"}', + expected: { type: 'status', message: 'Starting' }, + }, + { + name: 'JSON containing ANSI sequences inside strings', + input: JSON.stringify(ansiLog), + expected: ansiLog, + }, + { + name: 'ANSI-prefixed JSON containing ANSI strings', + input: '\u001b[2K' + JSON.stringify(ansiLog), + expected: ansiLog, + }, + ] as const; + + const failureCases = [ + { name: 'invalid JSON', input: 'Not valid JSON' }, + { name: 'empty string', input: '' }, + { name: 'partial JSON', input: '{"type":"incomplete"' }, + { name: 'ANSI-only string', input: '\u001b[32m\u001b[1m\u001b[0m' }, + ] as const; + + it.each(successfulCases)('parses $name', ({ input, expected }) => { + expect(tryParseJson(input)).toEqual(expected); + }); + + it.each(failureCases)('returns null for $name', ({ input }) => { + expect(tryParseJson(input)).toBeNull(); + }); + + it.each(['42', '"string"', 'true', 'null'])('returns null for JSON primitive %s', primitive => { + expect(tryParseJson(primitive)).toBeNull(); + }); +}); diff --git a/cloud-agent-next/src/streaming-helpers.ts b/cloud-agent-next/src/streaming-helpers.ts new file mode 100644 index 0000000000..d3a9e51b4e --- /dev/null +++ b/cloud-agent-next/src/streaming-helpers.ts @@ -0,0 +1,65 @@ +import { stripVTControlCharacters } from 'node:util'; + +/** + * Attempts to parse a string as JSON, with optional ANSI stripping. + * Tries both the original line and ANSI-stripped version. + * @returns Parsed object if successful, null otherwise + */ +export function tryParseJson(line: string): Record | null { + // Try both original and ANSI-stripped versions + for (const candidate of [line, stripVTControlCharacters(line)]) { + try { + const parsed = JSON.parse(candidate); + if (typeof parsed === 'object' && parsed !== null) { + return parsed as Record; + } + } catch { + // Continue to next candidate + } + } + return null; +} + +/** + * Result of checking if a Kilocode event is terminal. + */ +export interface TerminalEventCheck { + isTerminal: boolean; + reason?: string; +} + +/** + * Checks if a Kilocode event indicates a terminal/unrecoverable state in --auto mode. + * These events cause the CLI to wait for user input that will never come. + * + * @param payload The parsed Kilocode event payload + * @returns Object indicating if the event is terminal and why + */ +export function isTerminalKilocodeEvent(payload: Record): TerminalEventCheck { + // Ask events that indicate unrecoverable errors in --auto mode + if (payload.type === 'ask') { + // api_req_failed: Authentication or API errors that can't be resolved by retrying + if (payload.ask === 'api_req_failed') { + return { + isTerminal: true, + reason: `API request failed: ${typeof payload.content === 'string' ? payload.content : 'Unknown error'}`, + }; + } + + // payment_required_prompt: User needs to add credits to continue + if (payload.ask === 'payment_required_prompt') { + const metadata = payload.metadata as Record | undefined; + const message = + typeof metadata?.message === 'string' + ? metadata.message + : typeof metadata?.title === 'string' + ? metadata.title + : 'Credits required to continue'; + return { + isTerminal: true, + reason: message, + }; + } + } + return { isTerminal: false }; +} diff --git a/cloud-agent-next/src/streaming.test.ts b/cloud-agent-next/src/streaming.test.ts new file mode 100644 index 0000000000..477f19324c --- /dev/null +++ b/cloud-agent-next/src/streaming.test.ts @@ -0,0 +1,1211 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock @cloudflare/sandbox module before importing streaming utilities +vi.mock('@cloudflare/sandbox', () => ({ + parseSSEStream: vi.fn(), + getSandbox: vi.fn(), +})); + +import { streamKilocodeExecution } from './streaming.js'; +import type { ExecutionSession, StreamEvent, SessionContext, SandboxInstance } from './types.js'; +import { parseSSEStream } from '@cloudflare/sandbox'; +import type { PersistenceEnv } from './persistence/types.js'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +async function collectEvents(generator: AsyncGenerator): Promise { + const events: StreamEvent[] = []; + for await (const event of generator) { + events.push(event); + } + return events; +} + +async function collectEventsUntilError( + generator: AsyncGenerator +): Promise<{ events: StreamEvent[]; error: Error }> { + const events: StreamEvent[] = []; + let thrownError: Error | null = null; + + try { + for await (const event of generator) { + events.push(event); + } + } catch (error) { + thrownError = error as Error; + } + + if (!thrownError) { + throw new Error('Expected generator to throw an error, but it did not'); + } + + return { events, error: thrownError }; +} + +function createMockSandbox(): SandboxInstance { + return {} as SandboxInstance; +} + +function createMockExecutionSession(mockExecStream: ReturnType): ExecutionSession & { + writeFile: ReturnType; + deleteFile: ReturnType; +} { + const mockWriteFile = vi.fn().mockResolvedValue(undefined); + const mockDeleteFile = vi.fn().mockResolvedValue(undefined); + return { + execStream: mockExecStream, + writeFile: mockWriteFile, + deleteFile: mockDeleteFile, + } as unknown as ExecutionSession & { + writeFile: ReturnType; + deleteFile: ReturnType; + }; +} + +function mockStreamEvents(streamEvents: Array>) { + vi.mocked(parseSSEStream).mockImplementation(async function* () { + yield* streamEvents; + }); +} + +function createSessionContext(workspacePath = '/workspace/test'): SessionContext { + return { + sandboxId: 'org-ctx__user-ctx', + sessionId: 'agent_test_session', + sessionHome: '/home/agent_test_session', + workspacePath, + branchName: 'session/agent_test_session', + orgId: 'org-test', + userId: 'user-test', + }; +} + +function createFakeEnv(overrides?: { + getMetadata?: ReturnType; + clearInterrupted?: ReturnType; + isInterrupted?: ReturnType; + updateKiloSessionId?: ReturnType; +}) { + const getMetadata = overrides?.getMetadata ?? vi.fn().mockResolvedValue(null); + const clearInterrupted = overrides?.clearInterrupted ?? vi.fn().mockResolvedValue(undefined); + const isInterrupted = overrides?.isInterrupted ?? vi.fn().mockResolvedValue(false); + const updateKiloSessionId = + overrides?.updateKiloSessionId ?? vi.fn().mockResolvedValue(undefined); + + const metadataDO = { + getMetadata, + clearInterrupted, + isInterrupted, + updateKiloSessionId, + }; + + const env = { + CLOUD_AGENT_SESSION: { + idFromName: vi.fn(name => ({ name })), + get: vi.fn(() => metadataDO), + }, + } as unknown as PersistenceEnv; + + return { env, metadataDO }; +} + +describe('streamKilocodeExecution', () => { + it('streams kilocode JSON stdout and writes prompt file', async () => { + const mockStream = [ + { + type: 'stdout', + data: '{"type":"tool_use","tool":"read_file","input":{"path":"test.ts"},"meta":"preserved"}\n', + }, + { + type: 'complete', + exitCode: 0, + }, + ]; + + const mockExecStream = vi.fn().mockResolvedValue(mockStream); + const mockSession = createMockExecutionSession(mockExecStream); + const mockSandbox = createMockSandbox(); + mockStreamEvents(mockStream); + + const prompt = 'test prompt'; + const sessionContext = createSessionContext('/workspace/test'); + const events = await collectEvents( + streamKilocodeExecution(mockSandbox, mockSession, sessionContext, 'build', prompt, { + sessionId: 'session-123', + }) + ); + + expect(mockSession.writeFile).toHaveBeenCalledWith( + expect.stringContaining('/tmp/kilocode-prompt-'), + prompt + ); + expect(mockExecStream).toHaveBeenCalledTimes(1); + const command = mockExecStream.mock.calls[0]?.[0] as string; + expect(command).toContain('--mode=build'); + expect(command).toContain('--workspace=/workspace/test'); + expect(command).toContain('--json'); + + expect(events).toEqual([ + { + streamEventType: 'kilocode', + payload: { + type: 'tool_use', + tool: 'read_file', + input: { path: 'test.ts' }, + meta: 'preserved', + }, + sessionId: 'session-123', + }, + ]); + }); + + it('throws on non-zero exit code', async () => { + const mockStream = [ + { + type: 'complete', + exitCode: 2, + }, + ]; + + const mockExecStream = vi.fn().mockResolvedValue(mockStream); + const mockSession = createMockExecutionSession(mockExecStream); + const mockSandbox = createMockSandbox(); + mockStreamEvents(mockStream); + + const sessionContext = createSessionContext('/workspace/test'); + const { events, error } = await collectEventsUntilError( + streamKilocodeExecution(mockSandbox, mockSession, sessionContext, 'build', 'test prompt') + ); + + expect(mockExecStream).toHaveBeenCalledTimes(1); + expect(events).toHaveLength(0); + expect(error.message).toBe('CLI exited with code 2'); + }); + + it('throws descriptive error on timeout (exit code 124)', async () => { + const mockStream = [ + { + type: 'complete', + exitCode: 124, + }, + ]; + + const mockExecStream = vi.fn().mockResolvedValue(mockStream); + const mockSession = createMockExecutionSession(mockExecStream); + const mockSandbox = createMockSandbox(); + mockStreamEvents(mockStream); + + const sessionContext = createSessionContext('/workspace/test'); + const { events, error } = await collectEventsUntilError( + streamKilocodeExecution(mockSandbox, mockSession, sessionContext, 'build', 'test prompt') + ); + + expect(mockExecStream).toHaveBeenCalledTimes(1); + expect(events).toHaveLength(0); + expect(error.message).toContain('exceeded the'); + expect(error.message).toContain('timeout limit'); + expect(error.message).toContain('900s'); // DEFAULT_CLI_TIMEOUT_SECONDS + expect(error.message).toContain('Try simplifying your request'); + }); + + it('emits error event when stream emits error type', async () => { + const mockStream = [ + { + type: 'error', + error: 'Stream error occurred', + }, + ]; + + const mockExecStream = vi.fn().mockResolvedValue(mockStream); + const mockSession = createMockExecutionSession(mockExecStream); + const mockSandbox = createMockSandbox(); + mockStreamEvents(mockStream); + + const sessionContext = createSessionContext('/workspace/test'); + const events = await collectEvents( + streamKilocodeExecution(mockSandbox, mockSession, sessionContext, 'build', 'test prompt', { + sessionId: 'stream-session', + }) + ); + + expect(mockExecStream).toHaveBeenCalledTimes(1); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + streamEventType: 'error', + error: 'Stream error occurred', + sessionId: 'stream-session', + }); + expect(events[0]).toHaveProperty('timestamp'); + }); + + it('splits stdout lines, ignoring blanks and mixing JSON with text', async () => { + const mockStream = [ + { + type: 'stdout', + data: '{"type":"status","message":"Step 1"}\n\nPlain text output\n{"type":"status","message":"Step 2"}\n', + }, + { + type: 'complete', + exitCode: 0, + }, + ]; + + const mockExecStream = vi.fn().mockResolvedValue(mockStream); + const mockSession = createMockExecutionSession(mockExecStream); + const mockSandbox = createMockSandbox(); + mockStreamEvents(mockStream); + + const sessionContext = createSessionContext('/workspace/test'); + const events = await collectEvents( + streamKilocodeExecution(mockSandbox, mockSession, sessionContext, 'build', 'test prompt') + ); + + expect(mockExecStream).toHaveBeenCalledTimes(1); + expect(events).toHaveLength(3); + expect(events[0]).toEqual({ + streamEventType: 'kilocode', + payload: { type: 'status', message: 'Step 1' }, + sessionId: undefined, + }); + expect(events[1]).toMatchObject({ + streamEventType: 'output', + content: 'Plain text output', + source: 'stdout', + }); + expect(events[2]).toEqual({ + streamEventType: 'kilocode', + payload: { type: 'status', message: 'Step 2' }, + sessionId: undefined, + }); + }); + + const ansiOutputCases = [ + { + description: 'stdout', + chunk: { + type: 'stdout', + data: '\u001b[32mGreen text\u001b[0m with \u001b[1mbold\u001b[0m\n', + }, + expected: { + source: 'stdout' as const, + content: 'Green text with bold', + }, + }, + { + description: 'stderr', + chunk: { + type: 'stderr', + data: '\u001b[31mError:\u001b[0m Something went \u001b[1;31mwrong\u001b[0m', + }, + expected: { + source: 'stderr' as const, + content: 'Error: Something went wrong', + }, + }, + ] as const; + + it.each(ansiOutputCases)('strips ANSI sequences from %s output', async ({ chunk, expected }) => { + const mockStream = [chunk, { type: 'complete', exitCode: 0 }]; + + const mockExecStream = vi.fn().mockResolvedValue(mockStream); + const mockSession = createMockExecutionSession(mockExecStream); + const mockSandbox = createMockSandbox(); + mockStreamEvents(mockStream); + + const sessionContext = createSessionContext('/workspace/test'); + const events = await collectEvents( + streamKilocodeExecution(mockSandbox, mockSession, sessionContext, 'build', 'test prompt') + ); + + expect(mockExecStream).toHaveBeenCalledTimes(1); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + streamEventType: 'output', + source: expected.source, + content: expected.content, + }); + }); + + const ansiJsonPayload = { + type: 'log', + message: 'Output: \u001b[32mSuccess\u001b[0m', + details: 'Contains ANSI codes in payload', + }; + + const ansiJsonCases = [ + { + description: 'raw JSON containing ANSI sequences', + line: JSON.stringify(ansiJsonPayload) + '\n', + }, + { + description: 'ANSI-prefixed JSON line', + line: '\u001b[2K' + JSON.stringify(ansiJsonPayload) + '\n', + }, + { + description: 'JSON wrapped in multiple ANSI codes', + line: '\u001b[2K\u001b[32m\u001b[1m' + JSON.stringify(ansiJsonPayload) + '\u001b[0m\n', + }, + ] as const; + + it.each(ansiJsonCases)('preserves JSON payload for %s', async ({ line }) => { + const mockStream = [ + { + type: 'stdout', + data: line, + }, + { + type: 'complete', + exitCode: 0, + }, + ]; + + const mockExecStream = vi.fn().mockResolvedValue(mockStream); + const mockSession = createMockExecutionSession(mockExecStream); + const mockSandbox = createMockSandbox(); + mockStreamEvents(mockStream); + + const sessionContext = createSessionContext('/workspace/test'); + const events = await collectEvents( + streamKilocodeExecution(mockSandbox, mockSession, sessionContext, 'build', 'test prompt') + ); + + expect(mockExecStream).toHaveBeenCalledTimes(1); + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + streamEventType: 'kilocode', + payload: ansiJsonPayload, + sessionId: undefined, + }); + }); + + const sessionCases = [ + { + description: 'with sessionId', + options: { sessionId: 'session-abc-123' }, + expectedSessionId: 'session-abc-123', + }, + { + description: 'without sessionId', + options: undefined, + expectedSessionId: undefined, + }, + ] as const; + + it.each(sessionCases)( + 'propagates sessionId to output events %s', + async ({ options, expectedSessionId }) => { + const mockStream = [ + { + type: 'stdout', + data: 'Plain text output\n', + }, + { + type: 'stderr', + data: 'Error output', + }, + { + type: 'complete', + exitCode: 0, + }, + ]; + + const mockExecStream = vi.fn().mockResolvedValue(mockStream); + const mockSession = createMockExecutionSession(mockExecStream); + const mockSandbox = createMockSandbox(); + mockStreamEvents(mockStream); + + const sessionContext = createSessionContext('/workspace/test'); + const events = await collectEvents( + streamKilocodeExecution( + mockSandbox, + mockSession, + sessionContext, + 'build', + 'test prompt', + options + ) + ); + + expect(mockExecStream).toHaveBeenCalledTimes(1); + expect(events).toHaveLength(2); + expect(events[0]).toMatchObject({ + streamEventType: 'output', + content: 'Plain text output', + source: 'stdout', + }); + expect(events[0]).toHaveProperty('sessionId', expectedSessionId); + expect(events[1]).toMatchObject({ + streamEventType: 'output', + content: 'Error output', + source: 'stderr', + }); + expect(events[1]).toHaveProperty('sessionId', expectedSessionId); + } + ); + + it('does not fetch kiloSessionId when none is provided', async () => { + const getMetadata = vi.fn().mockResolvedValue({ kiloSessionId: 'unused' }); + const { env: fakeEnv } = createFakeEnv({ getMetadata }); + + const mockStream = [{ type: 'complete', exitCode: 0 }]; + const mockExecStream = vi.fn().mockResolvedValue(mockStream); + const mockSession = createMockExecutionSession(mockExecStream); + const mockSandbox = createMockSandbox(); + mockStreamEvents(mockStream); + + const sessionContext = createSessionContext('/workspace/test'); + await collectEvents( + streamKilocodeExecution( + mockSandbox, + mockSession, + sessionContext, + 'build', + 'prompt', + undefined, + fakeEnv + ) + ); + + expect(getMetadata).not.toHaveBeenCalled(); + const command = mockExecStream.mock.calls[0]?.[0] as string; + expect(command).not.toContain('--session='); + }); + + it('uses provided kiloSessionId without fetching metadata', async () => { + const kiloSessionId = '123e4567-e89b-12d3-a456-426614174000'; + const getMetadata = vi.fn().mockResolvedValue({ kiloSessionId: 'should-not-be-used' }); + const { env: fakeEnv } = createFakeEnv({ getMetadata }); + + const mockStream = [{ type: 'complete', exitCode: 0 }]; + const mockExecStream = vi.fn().mockResolvedValue(mockStream); + const mockSession = createMockExecutionSession(mockExecStream); + const mockSandbox = createMockSandbox(); + mockStreamEvents(mockStream); + + const sessionContext = createSessionContext('/workspace/test'); + await collectEvents( + streamKilocodeExecution( + mockSandbox, + mockSession, + sessionContext, + 'build', + 'prompt', + { kiloSessionId, isFirstExecution: false }, + fakeEnv + ) + ); + + expect(getMetadata).not.toHaveBeenCalled(); + const command = mockExecStream.mock.calls[0]?.[0] as string; + expect(command).toContain(`--session=${kiloSessionId}`); + }); + + it('skips kiloSessionId fetch on first execution', async () => { + const getMetadata = vi.fn().mockResolvedValue({ kiloSessionId: 'should-not-be-used' }); + const { env: fakeEnv } = createFakeEnv({ getMetadata }); + + const mockStream = [{ type: 'complete', exitCode: 0 }]; + const mockExecStream = vi.fn().mockResolvedValue(mockStream); + const mockSession = createMockExecutionSession(mockExecStream); + const mockSandbox = createMockSandbox(); + mockStreamEvents(mockStream); + + const sessionContext = createSessionContext('/workspace/test'); + await collectEvents( + streamKilocodeExecution( + mockSandbox, + mockSession, + sessionContext, + 'build', + 'prompt', + { isFirstExecution: true }, + fakeEnv + ) + ); + + expect(getMetadata).not.toHaveBeenCalled(); + const command = mockExecStream.mock.calls[0]?.[0] as string; + expect(command).not.toContain('--session='); + }); + + describe('terminal event handling', () => { + it('terminates stream and emits error when api_req_failed event is received', async () => { + const mockStream = [ + { + type: 'stdout', + data: '{"type":"say","say":"text","content":"Starting..."}\n', + }, + { + type: 'stdout', + data: '{"type":"ask","ask":"api_req_failed","content":"Could not resolve authentication method"}\n', + }, + // This event should NOT be reached + { + type: 'stdout', + data: '{"type":"say","say":"text","content":"This should not appear"}\n', + }, + { + type: 'complete', + exitCode: 0, + }, + ]; + + const mockExecStream = vi.fn().mockResolvedValue(mockStream); + const mockSession = createMockExecutionSession(mockExecStream); + const mockSandbox = createMockSandbox(); + // Add listProcesses and killProcess mocks for cleanup + (mockSandbox as unknown as Record).listProcesses = vi + .fn() + .mockResolvedValue([]); + (mockSession as unknown as Record>).killProcess = vi + .fn() + .mockResolvedValue(undefined); + + mockStreamEvents(mockStream); + + const sessionContext = createSessionContext('/workspace/test'); + const events = await collectEvents( + streamKilocodeExecution(mockSandbox, mockSession, sessionContext, 'build', 'test prompt', { + sessionId: 'session-terminal', + }) + ); + + // Should have: first say event, the api_req_failed event, and then error event + expect(events).toHaveLength(3); + + // First event: normal say event + expect(events[0]).toMatchObject({ + streamEventType: 'kilocode', + payload: { type: 'say', say: 'text', content: 'Starting...' }, + }); + + // Second event: the terminal api_req_failed event + expect(events[1]).toMatchObject({ + streamEventType: 'kilocode', + payload: { type: 'ask', ask: 'api_req_failed' }, + }); + + // Third event: error event + expect(events[2]).toMatchObject({ + streamEventType: 'error', + sessionId: 'session-terminal', + }); + expect((events[2] as { error: string }).error).toContain( + 'Could not resolve authentication method' + ); + }); + + it('terminates stream and emits error when payment_required_prompt event is received', async () => { + const mockStream = [ + { + type: 'stdout', + data: '{"type":"say","say":"text","content":"Starting task..."}\n', + }, + { + type: 'stdout', + data: '{"type":"ask","ask":"payment_required_prompt","metadata":{"title":"Paid Model - Credits Required","message":"This is a paid model. To use paid models, you need to add credits.","balance":-0.02,"buyCreditsUrl":"https://app.kilo.ai/profile"}}\n', + }, + // This event should NOT be reached + { + type: 'stdout', + data: '{"type":"say","say":"text","content":"This should not appear"}\n', + }, + { + type: 'complete', + exitCode: 0, + }, + ]; + + const mockExecStream = vi.fn().mockResolvedValue(mockStream); + const mockSession = createMockExecutionSession(mockExecStream); + const mockSandbox = createMockSandbox(); + // Add listProcesses and killProcess mocks for cleanup + (mockSandbox as unknown as Record).listProcesses = vi + .fn() + .mockResolvedValue([]); + (mockSession as unknown as Record>).killProcess = vi + .fn() + .mockResolvedValue(undefined); + + mockStreamEvents(mockStream); + + const sessionContext = createSessionContext('/workspace/test'); + const events = await collectEvents( + streamKilocodeExecution(mockSandbox, mockSession, sessionContext, 'build', 'test prompt', { + sessionId: 'session-payment', + }) + ); + + // Should have: first say event, the payment_required_prompt event, and then error event + expect(events).toHaveLength(3); + + // First event: normal say event + expect(events[0]).toMatchObject({ + streamEventType: 'kilocode', + payload: { type: 'say', say: 'text', content: 'Starting task...' }, + }); + + // Second event: the terminal payment_required_prompt event + expect(events[1]).toMatchObject({ + streamEventType: 'kilocode', + payload: { type: 'ask', ask: 'payment_required_prompt' }, + }); + + // Third event: error event with metadata.message as the error reason + expect(events[2]).toMatchObject({ + streamEventType: 'error', + sessionId: 'session-payment', + }); + expect((events[2] as { error: string }).error).toContain( + 'This is a paid model. To use paid models, you need to add credits.' + ); + }); + + it('uses metadata.title as fallback when metadata.message is absent for payment_required_prompt', async () => { + const mockStream = [ + { + type: 'stdout', + data: '{"type":"ask","ask":"payment_required_prompt","metadata":{"title":"Credits Required"}}\n', + }, + ]; + + const mockExecStream = vi.fn().mockResolvedValue(mockStream); + const mockSession = createMockExecutionSession(mockExecStream); + const mockSandbox = createMockSandbox(); + (mockSandbox as unknown as Record).listProcesses = vi + .fn() + .mockResolvedValue([]); + (mockSession as unknown as Record>).killProcess = vi + .fn() + .mockResolvedValue(undefined); + + mockStreamEvents(mockStream); + + const sessionContext = createSessionContext('/workspace/test'); + const events = await collectEvents( + streamKilocodeExecution(mockSandbox, mockSession, sessionContext, 'build', 'test prompt', { + sessionId: 'session-payment-title', + }) + ); + + // Should have the payment_required_prompt event and error event + expect(events).toHaveLength(2); + + // Error should use metadata.title as fallback + expect((events[1] as { error: string }).error).toBe('Credits Required'); + }); + + it('calls listProcesses for cleanup after terminal event', async () => { + const mockStream = [ + { + type: 'stdout', + data: '{"type":"ask","ask":"api_req_failed","content":"Auth failed"}\n', + }, + ]; + + const mockExecStream = vi.fn().mockResolvedValue(mockStream); + const mockSession = createMockExecutionSession(mockExecStream); + const mockSandbox = createMockSandbox(); + + const mockListProcesses = vi.fn().mockResolvedValue([ + { + id: 'proc-123', + status: 'running', + command: 'kilocode --mode=code --workspace=/workspace/test --auto', + }, + ]); + const mockKillProcess = vi.fn().mockResolvedValue(undefined); + + (mockSandbox as unknown as Record).listProcesses = mockListProcesses; + (mockSession as unknown as Record>).killProcess = + mockKillProcess; + + mockStreamEvents(mockStream); + + const sessionContext = createSessionContext('/workspace/test'); + await collectEvents( + streamKilocodeExecution(mockSandbox, mockSession, sessionContext, 'build', 'test prompt') + ); + + // Should have attempted to list and kill processes + expect(mockListProcesses).toHaveBeenCalledTimes(1); + expect(mockKillProcess).toHaveBeenCalledWith('proc-123', 'SIGTERM'); + }); + + it('does not kill processes that are not kilocode or not in workspace', async () => { + const mockStream = [ + { + type: 'stdout', + data: '{"type":"ask","ask":"api_req_failed","content":"Auth failed"}\n', + }, + ]; + + const mockExecStream = vi.fn().mockResolvedValue(mockStream); + const mockSession = createMockExecutionSession(mockExecStream); + const mockSandbox = createMockSandbox(); + + const mockListProcesses = vi.fn().mockResolvedValue([ + { + id: 'proc-other', + status: 'running', + command: 'node server.js', // Not kilocode + }, + { + id: 'proc-different-workspace', + status: 'running', + command: 'kilocode --mode=code --workspace=/other/workspace --auto', // Different workspace + }, + { + id: 'proc-stopped', + status: 'stopped', + command: 'kilocode --mode=code --workspace=/workspace/test --auto', // Not running + }, + ]); + const mockKillProcess = vi.fn().mockResolvedValue(undefined); + + (mockSandbox as unknown as Record).listProcesses = mockListProcesses; + (mockSession as unknown as Record>).killProcess = + mockKillProcess; + + mockStreamEvents(mockStream); + + const sessionContext = createSessionContext('/workspace/test'); + await collectEvents( + streamKilocodeExecution(mockSandbox, mockSession, sessionContext, 'build', 'test prompt') + ); + + // Should have listed processes but not killed any + expect(mockListProcesses).toHaveBeenCalledTimes(1); + expect(mockKillProcess).not.toHaveBeenCalled(); + }); + }); + + describe('stream timeout', () => { + it('clears timeout on normal completion', async () => { + const mockStream = [ + { + type: 'stdout', + data: '{"type":"status","message":"done"}\n', + }, + { + type: 'complete', + exitCode: 0, + }, + ]; + + const mockExecStream = vi.fn().mockResolvedValue(mockStream); + const mockSession = createMockExecutionSession(mockExecStream); + const mockSandbox = createMockSandbox(); + (mockSandbox as unknown as Record).listProcesses = vi + .fn() + .mockResolvedValue([]); + + mockStreamEvents(mockStream); + + // Use fake timers to ensure timeout doesn't actually fire + vi.useFakeTimers(); + + const sessionContext = createSessionContext('/workspace/test'); + const generator = streamKilocodeExecution( + mockSandbox, + mockSession, + sessionContext, + 'build', + 'test prompt' + ); + + // Collect events without advancing time significantly + const events: StreamEvent[] = []; + for await (const event of generator) { + events.push(event); + } + + // Should complete normally + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + streamEventType: 'kilocode', + payload: { type: 'status', message: 'done' }, + }); + + vi.useRealTimers(); + }); + }); + + describe('kiloSessionId capture from session_created events', () => { + it('calls updateKiloSessionId when valid UUID is received', async () => { + const validUuid = '123e4567-e89b-12d3-a456-426614174000'; + const updateKiloSessionId = vi.fn().mockResolvedValue(undefined); + const { env: fakeEnv, metadataDO } = createFakeEnv({ updateKiloSessionId }); + + const mockStream = [ + { + type: 'stdout', + data: `{"event":"session_created","sessionId":"${validUuid}"}\n`, + }, + { type: 'complete', exitCode: 0 }, + ]; + + const mockExecStream = vi.fn().mockResolvedValue(mockStream); + const mockSession = createMockExecutionSession(mockExecStream); + const mockSandbox = createMockSandbox(); + mockStreamEvents(mockStream); + + const sessionContext = createSessionContext('/workspace/test'); + await collectEvents( + streamKilocodeExecution( + mockSandbox, + mockSession, + sessionContext, + 'build', + 'prompt', + { skipInterruptPolling: true }, + fakeEnv + ) + ); + + expect(metadataDO.updateKiloSessionId).toHaveBeenCalledOnce(); + expect(metadataDO.updateKiloSessionId).toHaveBeenCalledWith(validUuid); + }); + + it('rejects invalid UUID format and does not call updateKiloSessionId', async () => { + const invalidUuid = 'not-a-uuid'; + const updateKiloSessionId = vi.fn().mockResolvedValue(undefined); + const { env: fakeEnv, metadataDO } = createFakeEnv({ updateKiloSessionId }); + + const mockStream = [ + { + type: 'stdout', + data: `{"event":"session_created","sessionId":"${invalidUuid}"}\n`, + }, + { type: 'complete', exitCode: 0 }, + ]; + + const mockExecStream = vi.fn().mockResolvedValue(mockStream); + const mockSession = createMockExecutionSession(mockExecStream); + const mockSandbox = createMockSandbox(); + mockStreamEvents(mockStream); + + const sessionContext = createSessionContext('/workspace/test'); + await collectEvents( + streamKilocodeExecution( + mockSandbox, + mockSession, + sessionContext, + 'build', + 'prompt', + { skipInterruptPolling: true }, + fakeEnv + ) + ); + + expect(metadataDO.updateKiloSessionId).not.toHaveBeenCalled(); + }); + + it('ignores duplicate session_created events after first capture', async () => { + const firstUuid = '123e4567-e89b-12d3-a456-426614174000'; + const secondUuid = '987fcdeb-51a2-3bc4-d567-890123456789'; + const updateKiloSessionId = vi.fn().mockResolvedValue(undefined); + const { env: fakeEnv, metadataDO } = createFakeEnv({ updateKiloSessionId }); + + const mockStream = [ + { + type: 'stdout', + data: `{"event":"session_created","sessionId":"${firstUuid}"}\n`, + }, + { + type: 'stdout', + data: `{"event":"session_created","sessionId":"${secondUuid}"}\n`, + }, + { type: 'complete', exitCode: 0 }, + ]; + + const mockExecStream = vi.fn().mockResolvedValue(mockStream); + const mockSession = createMockExecutionSession(mockExecStream); + const mockSandbox = createMockSandbox(); + mockStreamEvents(mockStream); + + const sessionContext = createSessionContext('/workspace/test'); + await collectEvents( + streamKilocodeExecution( + mockSandbox, + mockSession, + sessionContext, + 'build', + 'prompt', + { skipInterruptPolling: true }, + fakeEnv + ) + ); + + // Should only be called once with the first UUID + expect(metadataDO.updateKiloSessionId).toHaveBeenCalledOnce(); + expect(metadataDO.updateKiloSessionId).toHaveBeenCalledWith(firstUuid); + }); + + it('continues streaming if updateKiloSessionId fails', async () => { + const validUuid = '123e4567-e89b-12d3-a456-426614174000'; + const updateKiloSessionId = vi.fn().mockRejectedValue(new Error('DO update failed')); + const { env: fakeEnv } = createFakeEnv({ updateKiloSessionId }); + + const mockStream = [ + { + type: 'stdout', + data: `{"event":"session_created","sessionId":"${validUuid}"}\n`, + }, + { + type: 'stdout', + data: '{"type":"say","say":"text","content":"Work continues"}\n', + }, + { type: 'complete', exitCode: 0 }, + ]; + + const mockExecStream = vi.fn().mockResolvedValue(mockStream); + const mockSession = createMockExecutionSession(mockExecStream); + const mockSandbox = createMockSandbox(); + mockStreamEvents(mockStream); + + const sessionContext = createSessionContext('/workspace/test'); + const events = await collectEvents( + streamKilocodeExecution( + mockSandbox, + mockSession, + sessionContext, + 'build', + 'prompt', + { skipInterruptPolling: true }, + fakeEnv + ) + ); + + // Should still emit both events despite DO failure + expect(events).toHaveLength(2); + expect(events[1]).toMatchObject({ + streamEventType: 'kilocode', + payload: { type: 'say', content: 'Work continues' }, + }); + }); + + it('does not call updateKiloSessionId when env is not provided', async () => { + const validUuid = '123e4567-e89b-12d3-a456-426614174000'; + + const mockStream = [ + { + type: 'stdout', + data: `{"event":"session_created","sessionId":"${validUuid}"}\n`, + }, + { type: 'complete', exitCode: 0 }, + ]; + + const mockExecStream = vi.fn().mockResolvedValue(mockStream); + const mockSession = createMockExecutionSession(mockExecStream); + const mockSandbox = createMockSandbox(); + mockStreamEvents(mockStream); + + const sessionContext = createSessionContext('/workspace/test'); + // No env provided - should not throw + const events = await collectEvents( + streamKilocodeExecution(mockSandbox, mockSession, sessionContext, 'build', 'prompt') + ); + + // Should still emit the session_created event as a kilocode event + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + streamEventType: 'kilocode', + payload: { event: 'session_created', sessionId: validUuid }, + }); + }); + }); + + describe('RPC disconnection handling', () => { + function mockParseSSEStreamToThrow(errorMessage: string) { + vi.mocked(parseSSEStream).mockImplementation(function () { + return { + // eslint-disable-next-line require-yield + async *[Symbol.asyncIterator]() { + throw new Error(errorMessage); + }, + }; + }); + } + + it('should emit interrupted event on RPC disconnection error', async () => { + const mockExecStream = vi.fn().mockResolvedValue([]); + const mockSession = createMockExecutionSession(mockExecStream); + const mockSandbox = createMockSandbox(); + + mockParseSSEStreamToThrow('ReadableStream received over RPC disconnected prematurely'); + + const sessionContext = createSessionContext('/workspace/test'); + const events = await collectEvents( + streamKilocodeExecution(mockSandbox, mockSession, sessionContext, 'build', 'test prompt', { + sessionId: 'session-rpc-test', + }) + ); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + streamEventType: 'interrupted', + sessionId: 'session-rpc-test', + reason: 'Stream interrupted - please retry / resume', + }); + expect(events[0]).toHaveProperty('timestamp'); + }); + + it('should handle "Network connection lost" error', async () => { + const mockExecStream = vi.fn().mockResolvedValue([]); + const mockSession = createMockExecutionSession(mockExecStream); + const mockSandbox = createMockSandbox(); + + mockParseSSEStreamToThrow('Network connection lost'); + + const sessionContext = createSessionContext('/workspace/test'); + const events = await collectEvents( + streamKilocodeExecution(mockSandbox, mockSession, sessionContext, 'build', 'test prompt', { + sessionId: 'session-network-test', + }) + ); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + streamEventType: 'interrupted', + sessionId: 'session-network-test', + reason: 'Stream interrupted - please retry / resume', + }); + }); + + it('should handle "Container service disconnected" error', async () => { + const mockExecStream = vi.fn().mockResolvedValue([]); + const mockSession = createMockExecutionSession(mockExecStream); + const mockSandbox = createMockSandbox(); + + mockParseSSEStreamToThrow('Container service disconnected'); + + const sessionContext = createSessionContext('/workspace/test'); + const events = await collectEvents( + streamKilocodeExecution(mockSandbox, mockSession, sessionContext, 'build', 'test prompt', { + sessionId: 'session-container-test', + }) + ); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + streamEventType: 'interrupted', + sessionId: 'session-container-test', + reason: 'Stream interrupted - please retry / resume', + }); + }); + + it('should handle "Durable Object reset" error with deployment message', async () => { + const mockExecStream = vi.fn().mockResolvedValue([]); + const mockSession = createMockExecutionSession(mockExecStream); + const mockSandbox = createMockSandbox(); + + mockParseSSEStreamToThrow('Durable Object reset because its code was updated.'); + + const sessionContext = createSessionContext('/workspace/test'); + const events = await collectEvents( + streamKilocodeExecution(mockSandbox, mockSession, sessionContext, 'build', 'test prompt', { + sessionId: 'session-deployment-test', + }) + ); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + streamEventType: 'interrupted', + sessionId: 'session-deployment-test', + reason: 'Stream interrupted - please retry / resume', + }); + }); + + it('should handle "Internal error in Durable Object storage" error', async () => { + const mockExecStream = vi.fn().mockResolvedValue([]); + const mockSession = createMockExecutionSession(mockExecStream); + const mockSandbox = createMockSandbox(); + + mockParseSSEStreamToThrow('Internal error in Durable Object storage'); + + const sessionContext = createSessionContext('/workspace/test'); + const events = await collectEvents( + streamKilocodeExecution(mockSandbox, mockSession, sessionContext, 'build', 'test prompt', { + sessionId: 'session-do-storage-test', + }) + ); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + streamEventType: 'interrupted', + sessionId: 'session-do-storage-test', + reason: 'Stream interrupted - please retry / resume', + }); + }); + + it('should handle generic RPC error', async () => { + const mockExecStream = vi.fn().mockResolvedValue([]); + const mockSession = createMockExecutionSession(mockExecStream); + const mockSandbox = createMockSandbox(); + + mockParseSSEStreamToThrow('RPC connection failed unexpectedly'); + + const sessionContext = createSessionContext('/workspace/test'); + const events = await collectEvents( + streamKilocodeExecution(mockSandbox, mockSession, sessionContext, 'build', 'test prompt', { + sessionId: 'session-generic-rpc-test', + }) + ); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + streamEventType: 'interrupted', + sessionId: 'session-generic-rpc-test', + reason: 'Stream interrupted - please retry / resume', + }); + }); + + it('should NOT catch unrelated errors', async () => { + const mockExecStream = vi.fn().mockResolvedValue([]); + const mockSession = createMockExecutionSession(mockExecStream); + const mockSandbox = createMockSandbox(); + + mockParseSSEStreamToThrow('Some other unrelated error'); + + const sessionContext = createSessionContext('/workspace/test'); + const { events, error } = await collectEventsUntilError( + streamKilocodeExecution(mockSandbox, mockSession, sessionContext, 'build', 'test prompt', { + sessionId: 'session-unrelated-test', + }) + ); + + expect(events).toHaveLength(0); + expect(error.message).toBe('Some other unrelated error'); + }); + + it('should use sessionContext.sessionId when options.sessionId is not provided', async () => { + const mockExecStream = vi.fn().mockResolvedValue([]); + const mockSession = createMockExecutionSession(mockExecStream); + const mockSandbox = createMockSandbox(); + + mockParseSSEStreamToThrow('RPC disconnected'); + + const sessionContext = createSessionContext('/workspace/test'); + const events = await collectEvents( + streamKilocodeExecution( + mockSandbox, + mockSession, + sessionContext, + 'build', + 'test prompt' + // No options provided + ) + ); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + streamEventType: 'interrupted', + sessionId: sessionContext.sessionId, + reason: 'Stream interrupted - please retry / resume', + }); + }); + }); +}); diff --git a/cloud-agent-next/src/streaming.ts b/cloud-agent-next/src/streaming.ts new file mode 100644 index 0000000000..0d230e3cbc --- /dev/null +++ b/cloud-agent-next/src/streaming.ts @@ -0,0 +1,493 @@ +import { parseSSEStream } from '@cloudflare/sandbox'; +import { stripVTControlCharacters } from 'node:util'; +import type { + ExecutionSession, + SandboxInstance, + SessionContext, + StreamEvent, + SystemErrorEvent, + SystemInterruptedEvent, + SystemKilocodeEvent, + SystemOutputEvent, +} from './types.js'; +import { tryParseJson, isTerminalKilocodeEvent } from './streaming-helpers.js'; +import type { PersistenceEnv } from './persistence/types.js'; +import { logger } from './logger.js'; +import { z } from 'zod'; +import { withDORetry } from './utils/do-retry.js'; +import { downloadImagesToSandbox, buildAttachArgs } from './utils/image-download.js'; +import { createR2Client } from './utils/r2-client.js'; +import type { Images } from './router/schemas.js'; + +const uuidSchema = z.uuid(); + +const DEFAULT_CLI_TIMEOUT_SECONDS = 900; +const STREAM_TIMEOUT_BUFFER_SECONDS = 60; + +function emitKilocodeEvent( + payload: Record, + sessionId?: string +): SystemKilocodeEvent { + return { + streamEventType: 'kilocode', + payload, + sessionId, + }; +} + +function emitOutputEvent( + content: string, + source: 'stdout' | 'stderr', + timestamp: string, + sessionId?: string +): SystemOutputEvent { + return { + streamEventType: 'output', + content: stripVTControlCharacters(content), + source, + timestamp, + sessionId, + }; +} + +/** + * Kills all kilocode processes running in the specific session's workspace. + * Used for cleanup when stream terminates abnormally. + */ +async function killKilocodeProcesses( + sandbox: SandboxInstance, + session: ExecutionSession, + context: SessionContext +): Promise { + try { + interface ProcessInfo { + id: string; + status: string; + command: string; + } + + const processes = await sandbox.listProcesses(); + const targetProcesses = processes.filter((proc: ProcessInfo) => { + const isRunning = proc.status === 'running'; + const isKilocode = proc.command.includes('kilocode'); + const isInWorkspace = proc.command.includes(`--workspace=${context.workspacePath}`); + return isRunning && isKilocode && isInWorkspace; + }); + + if (targetProcesses.length === 0) { + logger.debug('No kilocode processes to kill during cleanup'); + return; + } + + for (const proc of targetProcesses) { + try { + await session.killProcess(proc.id, 'SIGTERM'); + logger.info('Killed kilocode process during cleanup', { processId: proc.id }); + } catch (err) { + logger.warn('Failed to kill kilocode process', { + processId: proc.id, + error: err instanceof Error ? err.message : String(err), + }); + } + } + } catch (err) { + logger.warn('Failed to list processes for cleanup', { + error: err instanceof Error ? err.message : String(err), + }); + } +} + +/** + * Streams Kilocode CLI execution output as structured events using async generators. + * Parses nd-json output from stdout and wraps Kilocode JSON events in SystemKilocodeEvent. + * All events use streamEventType discriminator. + * + * Includes safeguards for termination: + * - Server-side timeout (CLI timeout + 2 minutes buffer) to catch hanging streams + * - Detection of terminal Kilocode events (e.g., api_req_failed) that indicate unrecoverable states + * - Process cleanup when stream terminates abnormally + */ +export async function* streamKilocodeExecution( + sandbox: SandboxInstance, + session: ExecutionSession, + sessionCtx: SessionContext, + mode: string, + prompt: string, + options?: { + sessionId?: string; + skipInterruptPolling?: boolean; + isFirstExecution?: boolean; + kiloSessionId?: string; + images?: Images; + }, + env?: PersistenceEnv +): AsyncGenerator { + const cliTimeoutSeconds = Number(env?.CLI_TIMEOUT_SECONDS ?? DEFAULT_CLI_TIMEOUT_SECONDS); + const streamTimeoutSeconds = cliTimeoutSeconds + STREAM_TIMEOUT_BUFFER_SECONDS; + const tmpFile = `/tmp/kilocode-prompt-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`; + await session.writeFile(tmpFile, prompt); + + // Download images if provided + let attachArgs = ''; + if ( + options?.images && + env?.R2_ATTACHMENTS_READONLY_ACCESS_KEY_ID && + env?.R2_ATTACHMENTS_READONLY_SECRET_ACCESS_KEY && + env?.R2_ENDPOINT && + env?.R2_ATTACHMENTS_BUCKET && + sessionCtx.userId + ) { + const r2Client = createR2Client({ + accessKeyId: env.R2_ATTACHMENTS_READONLY_ACCESS_KEY_ID, + secretAccessKey: env.R2_ATTACHMENTS_READONLY_SECRET_ACCESS_KEY, + endpoint: env.R2_ENDPOINT, + }); + + const { localPaths, errors } = await downloadImagesToSandbox( + r2Client, + env.R2_ATTACHMENTS_BUCKET, + session, + sessionCtx.userId, + options.images + ); + + if (errors.length > 0) { + throw new Error(`Failed to download images: ${errors.join(', ')}`); + } + + attachArgs = buildAttachArgs(localPaths); + } + + // Use provided kiloSessionId when resuming; otherwise skip --session + const kiloSessionId: string | undefined = options?.kiloSessionId; + const sessionFlag = kiloSessionId ? ` --session=${kiloSessionId}` : ''; + + const command = `HOME=${sessionCtx.sessionHome} cat ${tmpFile} | kilocode --mode=${mode} --workspace=${sessionCtx.workspacePath} --auto --timeout=${cliTimeoutSeconds} --json${sessionFlag} ${attachArgs}`; + const stream = await session.execStream(command); + const { sessionId, skipInterruptPolling } = options ?? {}; + + let kiloSessionIdCaptured = false; // Track if we've already captured a kiloSessionId + let abnormalTermination = false; + let terminationReason = ''; + + // Set up server-side timeout as a safety net + let streamTimeoutId: ReturnType | null = null; + const streamTimeoutPromise = new Promise((_, reject) => { + streamTimeoutId = setTimeout(() => { + reject(new Error('STREAM_TIMEOUT')); + }, streamTimeoutSeconds * 1000); + }); + + // Set up interrupt detection if we have access to the DO and polling is not skipped + // skipInterruptPolling is used for automated sessions (e.g., code reviews) where + // manual interruption is not needed and we want to avoid DO subrequest overhead + let interruptPromise: Promise | null = null; + let interruptInterval: ReturnType | null = null; + if (env && !skipInterruptPolling) { + const doKey = `${sessionCtx.userId}:${sessionCtx.sessionId}`; + + // Clear any stale interrupt flag from previous runs (with retry) + await withDORetry( + () => env.CLOUD_AGENT_SESSION.get(env.CLOUD_AGENT_SESSION.idFromName(doKey)), + stub => stub.clearInterrupted(), + 'clearInterrupted' + ); + + // Create a promise that rejects when interrupt is detected + // Note: isInterrupted() polling does NOT use retry wrapper - the 10-second interval + // provides natural retry behavior, and we want to avoid excessive subrequests + interruptPromise = new Promise((_, reject) => { + const handle = setInterval(async () => { + try { + const metadataDO = env.CLOUD_AGENT_SESSION.get(env.CLOUD_AGENT_SESSION.idFromName(doKey)); + const interrupted = await metadataDO.isInterrupted(); + if (interrupted) { + clearInterval(handle); + logger.info('External interrupt detected, cancelling stream'); + reject(new Error('EXTERNAL_INTERRUPT')); + } + } catch (err) { + logger.error('Failed to check interrupt status', { error: String(err) }); + } + }, 10_000); // Poll every 10 seconds to avoid hitting Cloudflare's 1000 subrequest limit + interruptInterval = handle; + }); + } + + try { + // Create async iterator from parseSSEStream + const streamIterator = parseSSEStream(stream)[Symbol.asyncIterator](); + + while (true) { + // Race between getting the next event, interrupt detection, and server-side timeout + const nextPromise = streamIterator.next(); + const racers: Promise>[] = [nextPromise]; + + // Add interrupt promise if available (not for skipInterruptPolling sessions) + if (interruptPromise) { + racers.push(interruptPromise); + } + + // Always add server-side timeout + racers.push(streamTimeoutPromise); + + const result = await Promise.race(racers); + + if (result.done) break; + + const rawEvent = result.value; + if (typeof rawEvent !== 'object' || !rawEvent) continue; + const event = rawEvent as Record; + if (typeof event.type !== 'string') continue; + + const timestamp = new Date().toISOString(); + + switch (event.type) { + case 'stdout': { + const data = String(event.data || ''); + const lines = data.split('\n').filter((line: string) => line.trim()); + + for (const line of lines) { + const parsed = tryParseJson(line); + + if (parsed !== null) { + // Check if this is a session_created event + if ( + parsed.event === 'session_created' && + parsed.sessionId && + !kiloSessionIdCaptured + ) { + const capturedSessionId = String(parsed.sessionId); + const uuidResult = uuidSchema.safeParse(capturedSessionId); + if (uuidResult.success) { + kiloSessionIdCaptured = true; + + // Store the kiloSessionId in the durable object with retry (non-blocking) + if (env) { + const doKey = `${sessionCtx.userId}:${sessionCtx.sessionId}`; + + try { + await withDORetry( + () => + env.CLOUD_AGENT_SESSION.get(env.CLOUD_AGENT_SESSION.idFromName(doKey)), + stub => stub.updateKiloSessionId(capturedSessionId), + 'updateKiloSessionId' + ); + logger + .withFields({ kiloSessionId: capturedSessionId }) + .info('Captured Kilo CLI session ID'); + } catch (error) { + logger + .withFields({ + kiloSessionId: capturedSessionId, + error: error instanceof Error ? error.message : String(error), + }) + .error('Failed to save kiloSessionId'); + // Continue streaming despite failure + } + } + } else { + logger + .withFields({ invalidSessionId: capturedSessionId }) + .warn('Invalid kiloSessionId format, expected UUID'); + } + } else if (parsed.event === 'session_created' && kiloSessionIdCaptured) { + logger + .withFields({ sessionId: String(parsed.sessionId) }) + .warn('Duplicate session_created event ignored'); + } + + // Check if this is a terminal event that should stop the stream + const terminalCheck = isTerminalKilocodeEvent(parsed); + if (terminalCheck.isTerminal) { + logger.warn('Terminal Kilocode event detected', { + eventType: parsed.type, + ask: parsed.ask, + reason: terminalCheck.reason, + }); + + // Emit the terminal event so client knows what happened + yield emitKilocodeEvent(parsed, sessionId); + + // Set termination state and throw to exit the loop + abnormalTermination = true; + terminationReason = terminalCheck.reason ?? 'Terminal event received'; + throw new Error('TERMINAL_EVENT'); + } + + yield emitKilocodeEvent(parsed, sessionId); + } else { + yield emitOutputEvent(line, 'stdout', timestamp, sessionId); + } + } + break; + } + + case 'stderr': { + yield emitOutputEvent(String(event.data || ''), 'stderr', timestamp, sessionId); + break; + } + + case 'complete': { + const exitCode = Number.parseInt(String(event.exitCode || 0)); + + // Check if this was an interrupt (SIGINT=130, SIGTERM=143, SIGKILL=137) + if (exitCode === 130 || exitCode === 143 || exitCode === 137) { + const reason = + exitCode === 130 + ? 'Interrupted (SIGINT)' + : exitCode === 143 + ? 'Terminated (SIGTERM)' + : 'Killed (SIGKILL)'; + yield { + streamEventType: 'interrupted', + sessionId: options?.sessionId ?? sessionCtx.sessionId, + timestamp: new Date().toISOString(), + reason, + } satisfies SystemInterruptedEvent; + return; // Exit generator, closing the stream + } + + // Handle timeout (exit code 124) + if (exitCode === 124) { + throw new Error( + `CLI execution exceeded the ${cliTimeoutSeconds}s timeout limit. ` + + `The AI agent's task execution took too long. Try simplifying your request.` + ); + } + + // Handle other failures + if (exitCode !== 0) { + logger.withFields({ exitCode }).error('Streaming execution failed'); + throw new Error(`CLI exited with code ${exitCode}`); + } + + // Success case + logger.info('Streaming execution completed'); + return; + } + + case 'error': { + yield { + streamEventType: 'error', + error: String(event.error || 'Unknown error'), + timestamp, + sessionId, + }; + break; + } + } + } + } catch (error) { + // Check if this was due to external interrupt + if (error instanceof Error && error.message === 'EXTERNAL_INTERRUPT') { + // Stream was cancelled due to external interrupt + yield { + streamEventType: 'interrupted', + sessionId: sessionId ?? sessionCtx.sessionId, + timestamp: new Date().toISOString(), + reason: 'Interrupted by user request', + } satisfies SystemInterruptedEvent; + return; + } + + // Check if this was a stream timeout + if (error instanceof Error && error.message === 'STREAM_TIMEOUT') { + logger.error('Stream timeout exceeded', { + sessionId: sessionId ?? sessionCtx.sessionId, + timeoutSeconds: streamTimeoutSeconds, + }); + abnormalTermination = true; + terminationReason = `Stream timeout exceeded (${streamTimeoutSeconds / 60} minutes)`; + + // Emit error event before cleanup + yield { + streamEventType: 'error', + error: terminationReason, + timestamp: new Date().toISOString(), + sessionId, + } satisfies SystemErrorEvent; + + // Don't re-throw, let finally block handle cleanup + return; + } + + // Check if this was a terminal event + if (error instanceof Error && error.message === 'TERMINAL_EVENT') { + logger.warn('Stream terminated due to terminal event', { + sessionId: sessionId ?? sessionCtx.sessionId, + reason: terminationReason, + }); + + // Emit error event (the terminal event itself was already emitted) + yield { + streamEventType: 'error', + error: terminationReason, + timestamp: new Date().toISOString(), + sessionId, + } satisfies SystemErrorEvent; + + // Don't re-throw, let finally block handle cleanup + return; + } + + // Handle infrastructure errors (RPC disconnections and DO issues) + if (error instanceof Error) { + const errorMsg = error.message; + + // Check for known infrastructure error patterns (matching VibeSDK's approach) + const isInfrastructureError = + // Container/RPC disconnections + errorMsg.includes('disconnected prematurely') || + errorMsg.includes('Network connection lost') || + errorMsg.includes('Container service disconnected') || + errorMsg.includes('RPC') || + // Durable Object errors (from initial clearInterrupted() call) + errorMsg.includes('Internal error in Durable Object storage') || + errorMsg.includes('Durable Object reset'); + + if (isInfrastructureError) { + // lazy guess whether this is a deployment-related DO reset + const isDeployment = errorMsg.includes('Durable Object reset because its code was updated'); + + logger[isDeployment ? 'info' : 'warn']('Infrastructure error during stream', { + sessionId: sessionId ?? sessionCtx.sessionId, + error: errorMsg, + reason: isDeployment ? 'deployment' : 'infrastructure_issue', + }); + + // Note: Don't set abnormalTermination = true + // The container/DO is already dead/resetting, so cleanup attempts would fail. + // This is different from STREAM_TIMEOUT where container is still alive. + yield { + streamEventType: 'interrupted', + sessionId: sessionId ?? sessionCtx.sessionId, + timestamp: new Date().toISOString(), + reason: 'Stream interrupted - please retry / resume', + } satisfies SystemInterruptedEvent; + + return; + } + } + + // If not a known termination type, re-throw the error + throw error; + } finally { + // Clear the server-side timeout + if (streamTimeoutId) { + clearTimeout(streamTimeoutId); + } + + if (interruptInterval) { + clearInterval(interruptInterval); + } + + // Kill running kilocode processes on abnormal termination + if (abnormalTermination) { + logger.info('Cleaning up kilocode processes after abnormal termination'); + await killKilocodeProcesses(sandbox, session, sessionCtx); + } + + await Promise.allSettled([session.deleteFile(tmpFile)]); + } +} diff --git a/cloud-agent-next/src/types.ts b/cloud-agent-next/src/types.ts new file mode 100644 index 0000000000..50227ea062 --- /dev/null +++ b/cloud-agent-next/src/types.ts @@ -0,0 +1,235 @@ +import type { getSandbox, ExecutionSession, Sandbox } from '@cloudflare/sandbox'; +import type { CloudAgentSession } from './persistence/CloudAgentSession.js'; +import type { CallbackJob } from './callbacks/index.js'; +import * as z from 'zod'; +import { Limits } from './schema.js'; + +export const sessionIdSchema = z + .string() + .regex( + /^agent_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + 'Invalid session ID format' + ); + +export const githubRepoSchema = z + .string() + .regex(/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/, 'Invalid repository format'); + +export const gitUrlSchema = z + .string() + .url() + .refine(url => url.startsWith('https://'), 'Only HTTPS URLs are supported'); + +export const RESERVED_ENV_VARS = ['HOME', 'SESSION_ID', 'SESSION_HOME'] as const; + +export const envVarsSchema = z + .record( + z.string().max(Limits.MAX_ENV_VAR_KEY_LENGTH), + z.string().max(Limits.MAX_ENV_VAR_VALUE_LENGTH) + ) + .refine(obj => Object.keys(obj).length <= Limits.MAX_ENV_VARS, { + message: `Maximum ${Limits.MAX_ENV_VARS} environment variables allowed`, + }) + .refine( + obj => { + const keys = Object.keys(obj); + return !keys.some(key => (RESERVED_ENV_VARS as readonly string[]).includes(key)); + }, + { + message: `Cannot set reserved environment variables: ${RESERVED_ENV_VARS.join(', ')}. These are managed by the system.`, + } + ); + +export type SandboxInstance = ReturnType; + +/** Cloudflare Session instance for executing commands within a sandbox */ +export type { ExecutionSession }; + +/** Unique identifier for a sandbox (container) per organizationId-userId pair, with optional bot suffix */ +export type SandboxId = `${string}__${string}` | `${string}__${string}__${string}`; + +/** Unique identifier for a session within a sandbox */ +export type SessionId = `agent_${string}`; + +export type SessionContext = { + sandboxId: SandboxId; + sessionId: SessionId; + sessionHome: string; + workspacePath: string; + branchName: string; + /** Upstream branch requested by the user (if any) */ + upstreamBranch?: string; + orgId?: string; + userId: string; + botId?: string; + githubRepo?: string; + githubToken?: string; + envVars?: Record; +}; +/** Result of interrupting a session's running processes */ +export type InterruptResult = { + success: boolean; + killedProcessIds: string[]; + failedProcessIds: string[]; + message: string; +}; + +export type TokenPayload = { + kiloUserId: string; + apiTokenPepper: string; + version: number; + env: string; + botId?: string; +}; + +export type Env = { + Sandbox: DurableObjectNamespace; + /** Durable Object namespace for CloudAgentSession metadata (SQLite-backed) with RPC support */ + CLOUD_AGENT_SESSION: DurableObjectNamespace; + /** Queue for callback messages (optional - supports incremental rollout) */ + CALLBACK_QUEUE?: Queue; + /** KV namespace for caching GitHub installation tokens */ + GITHUB_TOKEN_CACHE?: KVNamespace; + /** GitHub App ID for token generation */ + GITHUB_APP_ID?: string; + /** GitHub App private key (PEM format) for token generation */ + GITHUB_APP_PRIVATE_KEY?: string; + /** GitHub Lite App ID for read-only token generation */ + GITHUB_LITE_APP_ID?: string; + /** GitHub Lite App private key (PEM format) for read-only token generation */ + GITHUB_LITE_APP_PRIVATE_KEY?: string; + /** GitHub Lite App slug for git commit attribution (e.g., 'kiloconnect-lite') */ + GITHUB_LITE_APP_SLUG?: string; + /** GitHub Lite App bot user ID for git commit email */ + GITHUB_LITE_APP_BOT_USER_ID?: string; + /** Shared secret for JWT token validation */ + NEXTAUTH_SECRET: string; + /** Comma-separated list of allowed Origins for /stream WebSocket connections */ + WS_ALLOWED_ORIGINS?: string; + /** Backend base URL (used for balance checks before session spin-up) */ + KILOCODE_BACKEND_BASE_URL?: string; + /** Base URL override for OpenRouter-compatible Kilo API */ + KILO_OPENROUTER_BASE?: string; + /** Wrapper idle timeout override (ms) */ + WRAPPER_IDLE_TIMEOUT_MS?: string; + /** Kilocode CLI timeout override (seconds) */ + CLI_TIMEOUT_SECONDS?: string; + /** Reaper interval override (ms) */ + REAPER_INTERVAL_MS?: string; + /** Execution stale threshold override (ms) */ + STALE_THRESHOLD_MS?: string; + /** Pending execution start timeout override (ms) */ + PENDING_START_TIMEOUT_MS?: string; + /** Kilo server idle timeout override (ms) - defaults to 15 minutes */ + KILO_SERVER_IDLE_TIMEOUT_MS?: string; + /** Shared secret for backend-to-backend authentication (prepareSession/updateSession) */ + INTERNAL_API_SECRET?: string; + /** Worker base URL for building WebSocket ingest endpoint */ + WORKER_URL?: string; + /** + * RSA private key for decrypting encrypted secrets from agent environment profiles. + * Required when using encryptedSecrets feature. PEM format (base64-encoded). + */ + AGENT_ENV_VARS_PRIVATE_KEY?: string; + /** + * Hyperdrive binding for PostgreSQL connection pooling. + * Used for looking up GitHub installation IDs from the database. + */ + HYPERDRIVE?: { connectionString: string }; + /** GitHub App slug for git commit attribution (e.g., 'kiloconnect') */ + GITHUB_APP_SLUG?: string; + /** GitHub App bot user ID for git commit email (e.g., '240665456') */ + GITHUB_APP_BOT_USER_ID?: string; +}; + +/** tRPC context passed to all procedures */ +export type TRPCContext = { + env: Env; + userId: string; + request: Request; + authToken: string; + botId?: string; +}; + +// Streaming event types + +/** + * Raw Kilocode CLI event - preserved exactly as received from stdout JSON. + * These events come directly from the Kilocode CLI and may contain any fields. + */ +export type KilocodeEvent = Record; + +/** + * System events use streamEventType discriminator to avoid collision with Kilocode's type field. + * These are internal events generated by the streaming infrastructure. + */ +export type SystemStatusEvent = { + streamEventType: 'status'; + message: string; + timestamp: string; + sessionId?: string; +}; + +export type SystemOutputEvent = { + streamEventType: 'output'; + content: string; + source: 'stdout' | 'stderr'; + timestamp: string; + sessionId?: string; +}; + +export type SystemErrorEvent = { + streamEventType: 'error'; + error: string; + details?: unknown; + timestamp: string; + sessionId?: string; +}; + +export type SystemCompleteEvent = { + streamEventType: 'complete'; + sessionId: string; + exitCode: number; + metadata: { + executionTimeMs: number; + workspace: string; + userId: string; + startedAt: string; + completedAt: string; + }; +}; + +export type SystemKilocodeEvent = { + streamEventType: 'kilocode'; + payload: KilocodeEvent; + sessionId?: string; +}; + +export type SystemInterruptedEvent = { + streamEventType: 'interrupted'; + reason: string; + timestamp: string; + sessionId?: string; +}; + +export type SystemSandboxUsageEvent = { + streamEventType: 'sandbox-usage'; + availableMB: number; + totalMB: number; + isLow: boolean; + timestamp: string; + sessionId?: string; +}; + +/** + * Union of all streaming event types. + * All events now use streamEventType discriminator - Kilocode CLI events are wrapped in SystemKilocodeEvent. + */ +export type StreamEvent = + | SystemKilocodeEvent + | SystemStatusEvent + | SystemOutputEvent + | SystemErrorEvent + | SystemCompleteEvent + | SystemInterruptedEvent + | SystemSandboxUsageEvent; diff --git a/cloud-agent-next/src/types/ids.ts b/cloud-agent-next/src/types/ids.ts new file mode 100644 index 0000000000..3afb0f0eac --- /dev/null +++ b/cloud-agent-next/src/types/ids.ts @@ -0,0 +1,56 @@ +/** + * Type-safe IDs using template literals for the WebSocket streaming feature. + * + * These IDs provide compile-time type safety and runtime validation + * for the various entity identifiers used in the cloud-agent system. + */ + +/** + * Unique identifier for an execution request. + * Format: msg_ + * + * The msg_ prefix is required by kilo server for message correlation. + * executionId === messageId for simplified correlation between + * client requests and SSE events. + */ +export type ExecutionId = `msg_${string}`; + +/** + * Session identifier - supports both: + * - `sess_*` for new WebSocket sessions + * - `agent_*` for backward compatibility with existing session format + */ +export type SessionId = `sess_${string}` | `agent_${string}`; + +/** Unique identifier for an execution lease */ +export type LeaseId = `lease_${string}`; + +/** User identifier from the authentication system */ +export type UserId = `user_${string}`; + +/** Auto-incrementing event ID in SQLite storage */ +export type EventId = number; + +// --------------------------------------------------------------------------- +// ID Generators +// --------------------------------------------------------------------------- + +/** Generate a new unique execution ID (msg_ format for kilo server compatibility) */ +export const createExecutionId = (): ExecutionId => `msg_${crypto.randomUUID()}`; + +/** Generate a new unique lease ID */ +export const createLeaseId = (): LeaseId => `lease_${crypto.randomUUID()}`; + +// --------------------------------------------------------------------------- +// Type Guards +// --------------------------------------------------------------------------- + +/** Check if a string is a valid ExecutionId (msg_ format) */ +export const isExecutionId = (s: string): s is ExecutionId => s.startsWith('msg_'); + +/** Check if a string is a valid SessionId (supports both sess_ and agent_ prefixes) */ +export const isSessionId = (s: string): s is SessionId => + s.startsWith('sess_') || s.startsWith('agent_'); + +/** Check if a string is a valid LeaseId */ +export const isLeaseId = (s: string): s is LeaseId => s.startsWith('lease_'); diff --git a/cloud-agent-next/src/types/index.ts b/cloud-agent-next/src/types/index.ts new file mode 100644 index 0000000000..a0f9990a05 --- /dev/null +++ b/cloud-agent-next/src/types/index.ts @@ -0,0 +1,4 @@ +/** + * Re-export all type definitions from the types directory. + */ +export * from './ids.js'; diff --git a/cloud-agent-next/src/utils/do-retry.test.ts b/cloud-agent-next/src/utils/do-retry.test.ts new file mode 100644 index 0000000000..b077ad9886 --- /dev/null +++ b/cloud-agent-next/src/utils/do-retry.test.ts @@ -0,0 +1,317 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { withDORetry } from './do-retry.js'; + +// Mock the logger +vi.mock('../logger.js', () => ({ + logger: { + withFields: vi.fn(() => ({ + warn: vi.fn(), + error: vi.fn(), + })), + }, +})); + +// Mock scheduler.wait (Cloudflare Workers global) +const mockSchedulerWait = vi.fn().mockResolvedValue(undefined); +vi.stubGlobal('scheduler', { wait: mockSchedulerWait }); + +// Type for mock stubs with various operations +type _MockStub = { + [K in keyof T]: T[K]; +}; + +describe('withDORetry', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('successful operations', () => { + it('returns result on first attempt success', async () => { + const mockStub = { getMetadata: vi.fn().mockResolvedValue({ id: '123' }) }; + const getStub = vi.fn().mockReturnValue(mockStub); + + const result = await withDORetry( + getStub, + (stub: typeof mockStub) => stub.getMetadata() as Promise<{ id: string }>, + 'getMetadata' + ); + + expect(result).toEqual({ id: '123' }); + expect(getStub).toHaveBeenCalledTimes(1); + expect(mockStub.getMetadata).toHaveBeenCalledTimes(1); + expect(mockSchedulerWait).not.toHaveBeenCalled(); + }); + + it('returns result after retry on retryable error', async () => { + const retryableError = Object.assign(new Error('Transient DO error'), { retryable: true }); + const mockStub1 = { getMetadata: vi.fn().mockRejectedValue(retryableError) }; + const mockStub2 = { getMetadata: vi.fn().mockResolvedValue({ id: '456' }) }; + + let callCount = 0; + const getStub = vi.fn(() => { + callCount++; + return callCount === 1 ? mockStub1 : mockStub2; + }); + + const result = await withDORetry( + getStub, + (stub: typeof mockStub1) => stub.getMetadata() as Promise<{ id: string }>, + 'getMetadata' + ); + + expect(result).toEqual({ id: '456' }); + expect(getStub).toHaveBeenCalledTimes(2); + expect(mockSchedulerWait).toHaveBeenCalledTimes(1); + }); + + it('creates fresh stub for each retry attempt', async () => { + const retryableError = Object.assign(new Error('Transient error'), { retryable: true }); + const mockStub1 = { update: vi.fn().mockRejectedValue(retryableError) }; + const mockStub2 = { update: vi.fn().mockRejectedValue(retryableError) }; + const mockStub3 = { update: vi.fn().mockResolvedValue(undefined) }; + + const stubs = [mockStub1, mockStub2, mockStub3]; + let stubIndex = 0; + const getStub = vi.fn(() => stubs[stubIndex++]); + + await withDORetry( + getStub, + (stub: typeof mockStub1) => stub.update() as Promise, + 'update' + ); + + expect(getStub).toHaveBeenCalledTimes(3); + expect(mockStub1.update).toHaveBeenCalledTimes(1); + expect(mockStub2.update).toHaveBeenCalledTimes(1); + expect(mockStub3.update).toHaveBeenCalledTimes(1); + }); + }); + + describe('retryable error detection', () => { + it('retries on error with .retryable = true', async () => { + const retryableError = Object.assign(new Error('Some error'), { retryable: true }); + const mockStub1 = { op: vi.fn().mockRejectedValue(retryableError) }; + const mockStub2 = { op: vi.fn().mockResolvedValue('success') }; + + let callCount = 0; + const getStub = vi.fn(() => (++callCount === 1 ? mockStub1 : mockStub2)); + + const result = await withDORetry( + getStub, + (stub: typeof mockStub1) => stub.op() as Promise, + 'op' + ); + + expect(result).toBe('success'); + expect(getStub).toHaveBeenCalledTimes(2); + }); + + it('does NOT retry on error message patterns without .retryable property', async () => { + // These error messages were previously retried based on string matching, + // but now we only check .retryable property per Cloudflare docs + const errorMessages = [ + 'Internal error in Durable Object storage', + 'Durable Object reset because its code was updated', + 'Network connection lost', + 'The Durable Object is overloaded', + ]; + + for (const message of errorMessages) { + vi.clearAllMocks(); + const error = new Error(message); + const mockStub = { op: vi.fn().mockRejectedValue(error) }; + const getStub = vi.fn().mockReturnValue(mockStub); + + await expect( + withDORetry(getStub, (stub: typeof mockStub) => stub.op() as Promise, 'op') + ).rejects.toThrow(message); + + // Should NOT retry - fails immediately + expect(getStub).toHaveBeenCalledTimes(1); + expect(mockSchedulerWait).not.toHaveBeenCalled(); + } + }); + + it('retries on error message patterns when .retryable = true is set', async () => { + // When Cloudflare sets .retryable = true, we should retry regardless of message + const error = Object.assign(new Error('Internal error in Durable Object storage'), { + retryable: true, + }); + const mockStub1 = { op: vi.fn().mockRejectedValue(error) }; + const mockStub2 = { op: vi.fn().mockResolvedValue('ok') }; + + let callCount = 0; + const getStub = vi.fn(() => (++callCount === 1 ? mockStub1 : mockStub2)); + + await withDORetry(getStub, (stub: typeof mockStub1) => stub.op() as Promise, 'op'); + + expect(getStub).toHaveBeenCalledTimes(2); + }); + }); + + describe('non-retryable errors', () => { + it('throws immediately on non-retryable error', async () => { + const nonRetryableError = new Error('Validation failed: invalid data'); + const mockStub = { op: vi.fn().mockRejectedValue(nonRetryableError) }; + const getStub = vi.fn().mockReturnValue(mockStub); + + await expect( + withDORetry(getStub, (stub: typeof mockStub) => stub.op() as Promise, 'op') + ).rejects.toThrow('Validation failed: invalid data'); + + expect(getStub).toHaveBeenCalledTimes(1); + expect(mockSchedulerWait).not.toHaveBeenCalled(); + }); + + it('throws immediately when .retryable = false', async () => { + const error = Object.assign(new Error('Permanent failure'), { retryable: false }); + const mockStub = { op: vi.fn().mockRejectedValue(error) }; + const getStub = vi.fn().mockReturnValue(mockStub); + + await expect( + withDORetry(getStub, (stub: typeof mockStub) => stub.op() as Promise, 'op') + ).rejects.toThrow('Permanent failure'); + + expect(getStub).toHaveBeenCalledTimes(1); + }); + + it('converts non-Error throws to Error', async () => { + const mockStub = { op: vi.fn().mockRejectedValue('string error') }; + const getStub = vi.fn().mockReturnValue(mockStub); + + await expect( + withDORetry(getStub, (stub: typeof mockStub) => stub.op() as Promise, 'op') + ).rejects.toThrow('string error'); + }); + }); + + describe('retry exhaustion', () => { + it('throws after exhausting all retry attempts', async () => { + const retryableError = Object.assign(new Error('Persistent transient error'), { + retryable: true, + }); + const mockStub = { op: vi.fn().mockRejectedValue(retryableError) }; + const getStub = vi.fn().mockReturnValue(mockStub); + + await expect( + withDORetry(getStub, (stub: typeof mockStub) => stub.op() as Promise, 'op') + ).rejects.toThrow('Persistent transient error'); + + // Default is 3 attempts + expect(getStub).toHaveBeenCalledTimes(3); + expect(mockSchedulerWait).toHaveBeenCalledTimes(2); // 2 waits between 3 attempts + }); + + it('respects custom maxAttempts config', async () => { + const retryableError = Object.assign(new Error('Error'), { retryable: true }); + const mockStub = { op: vi.fn().mockRejectedValue(retryableError) }; + const getStub = vi.fn().mockReturnValue(mockStub); + + await expect( + withDORetry(getStub, (stub: typeof mockStub) => stub.op() as Promise, 'op', { + maxAttempts: 5, + baseBackoffMs: 100, + maxBackoffMs: 5000, + }) + ).rejects.toThrow('Error'); + + expect(getStub).toHaveBeenCalledTimes(5); + expect(mockSchedulerWait).toHaveBeenCalledTimes(4); + }); + }); + + describe('backoff behavior', () => { + it('applies exponential backoff with jitter', async () => { + const retryableError = Object.assign(new Error('Error'), { retryable: true }); + const mockStub1 = { op: vi.fn().mockRejectedValue(retryableError) }; + const mockStub2 = { op: vi.fn().mockRejectedValue(retryableError) }; + const mockStub3 = { op: vi.fn().mockResolvedValue('ok') }; + + const stubs = [mockStub1, mockStub2, mockStub3]; + let stubIndex = 0; + const getStub = vi.fn(() => stubs[stubIndex++]); + + // Mock Math.random to return predictable values + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.5); + + await withDORetry(getStub, (stub: typeof mockStub1) => stub.op() as Promise, 'op', { + maxAttempts: 3, + baseBackoffMs: 100, + maxBackoffMs: 5000, + }); + + // First backoff: 100 * 0.5 * 2^0 = 50ms + // Second backoff: 100 * 0.5 * 2^1 = 100ms + expect(mockSchedulerWait).toHaveBeenCalledTimes(2); + expect(mockSchedulerWait).toHaveBeenNthCalledWith(1, 50); + expect(mockSchedulerWait).toHaveBeenNthCalledWith(2, 100); + + randomSpy.mockRestore(); + }); + + it('caps backoff at maxBackoffMs', async () => { + const retryableError = Object.assign(new Error('Error'), { retryable: true }); + const mockStub = { op: vi.fn().mockRejectedValue(retryableError) }; + const getStub = vi.fn().mockReturnValue(mockStub); + + // Mock Math.random to return 1 (max jitter) + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(1); + + await expect( + withDORetry(getStub, (stub: typeof mockStub) => stub.op() as Promise, 'op', { + maxAttempts: 5, + baseBackoffMs: 1000, + maxBackoffMs: 2000, + }) + ).rejects.toThrow(); + + // With random=1: + // Attempt 0: 1000 * 1 * 2^0 = 1000ms + // Attempt 1: 1000 * 1 * 2^1 = 2000ms (at cap) + // Attempt 2: 1000 * 1 * 2^2 = 4000ms -> capped to 2000ms + // Attempt 3: 1000 * 1 * 2^3 = 8000ms -> capped to 2000ms + expect(mockSchedulerWait).toHaveBeenNthCalledWith(1, 1000); + expect(mockSchedulerWait).toHaveBeenNthCalledWith(2, 2000); + expect(mockSchedulerWait).toHaveBeenNthCalledWith(3, 2000); + expect(mockSchedulerWait).toHaveBeenNthCalledWith(4, 2000); + + randomSpy.mockRestore(); + }); + }); + + describe('type safety', () => { + it('preserves return type from operation', async () => { + type Metadata = { id: string; name: string }; + const mockStub = { + getMetadata: vi.fn().mockResolvedValue({ id: '1', name: 'test' } as Metadata), + }; + const getStub = vi.fn().mockReturnValue(mockStub); + + const result: Metadata = await withDORetry( + getStub, + (stub: typeof mockStub) => stub.getMetadata() as Promise, + 'getMetadata' + ); + + expect(result.id).toBe('1'); + expect(result.name).toBe('test'); + }); + + it('handles void return type', async () => { + const mockStub = { deleteSession: vi.fn().mockResolvedValue(undefined) }; + const getStub = vi.fn().mockReturnValue(mockStub); + + const result = await withDORetry( + getStub, + (stub: typeof mockStub) => stub.deleteSession() as Promise, + 'deleteSession' + ); + + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/cloud-agent-next/src/utils/do-retry.ts b/cloud-agent-next/src/utils/do-retry.ts new file mode 100644 index 0000000000..d72c849a87 --- /dev/null +++ b/cloud-agent-next/src/utils/do-retry.ts @@ -0,0 +1,129 @@ +import { logger } from '../logger.js'; + +/** + * Configuration for DO retry behavior + */ +export type DORetryConfig = { + maxAttempts: number; + baseBackoffMs: number; + maxBackoffMs: number; +}; + +const DEFAULT_CONFIG: DORetryConfig = { + maxAttempts: 3, + baseBackoffMs: 100, + maxBackoffMs: 5000, +}; + +/** + * Type for errors that may have Cloudflare's retryable property + */ +type RetryableError = Error & { retryable?: boolean }; + +/** + * Check if an error is retryable based on Cloudflare's .retryable property. + * + * Per Cloudflare docs: JavaScript Errors with .retryable set to true are + * suggested to be retried for idempotent operations. + * + * We only check the documented .retryable property, not error message strings, + * as message formats are undocumented and could change. + */ +function isRetryableError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + return (error as RetryableError).retryable === true; +} + +/** + * Calculate backoff with jitter using exponential backoff formula. + * Formula: min(maxBackoff, baseBackoff * random * 2^attempt) + * + * The random multiplier provides jitter to prevent thundering herd. + */ +function calculateBackoff(attempt: number, config: DORetryConfig): number { + const exponentialBackoff = config.baseBackoffMs * Math.pow(2, attempt); + const jitteredBackoff = exponentialBackoff * Math.random(); + return Math.min(config.maxBackoffMs, jitteredBackoff); +} + +/** + * Execute a Durable Object operation with retry logic. + * + * Creates a fresh stub for each retry attempt as recommended by Cloudflare, + * since certain errors can break the stub. + * + * @param getStub - Function that returns a fresh DurableObjectStub + * @param operation - Function that performs the DO operation using the stub + * @param operationName - Name for logging purposes + * @param config - Optional retry configuration override + * @returns The result of the operation + * @throws The last error if all retries are exhausted + * + * @example + * ```typescript + * const metadata = await withDORetry( + * () => env.CLOUD_AGENT_SESSION.get(env.CLOUD_AGENT_SESSION.idFromName(key)), + * (stub) => stub.getMetadata(), + * 'getMetadata' + * ); + * ``` + */ +export async function withDORetry( + getStub: () => TStub, + operation: (stub: TStub) => Promise, + operationName: string, + config: DORetryConfig = DEFAULT_CONFIG +): Promise { + let lastError: Error | undefined; + + for (let attempt = 0; attempt < config.maxAttempts; attempt++) { + try { + // Create fresh stub for each attempt + const stub = getStub(); + return await operation(stub); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + // Check if we should retry + if (!isRetryableError(error)) { + logger + .withFields({ + operation: operationName, + attempt: attempt + 1, + error: lastError.message, + retryable: false, + }) + .warn('DO operation failed with non-retryable error'); + throw lastError; + } + + // Check if we have retries left + if (attempt + 1 >= config.maxAttempts) { + logger + .withFields({ + operation: operationName, + attempts: attempt + 1, + error: lastError.message, + }) + .error('DO operation failed after all retry attempts'); + throw lastError; + } + + // Calculate backoff and wait + const backoffMs = calculateBackoff(attempt, config); + logger + .withFields({ + operation: operationName, + attempt: attempt + 1, + backoffMs: Math.round(backoffMs), + error: lastError.message, + }) + .warn('DO operation failed, retrying'); + + await scheduler.wait(backoffMs); + } + } + + // TypeScript: This should never be reached, but satisfies the compiler + throw lastError ?? new Error('Unexpected retry loop exit'); +} diff --git a/cloud-agent-next/src/utils/encryption.test.ts b/cloud-agent-next/src/utils/encryption.test.ts new file mode 100644 index 0000000000..d3d35efca0 --- /dev/null +++ b/cloud-agent-next/src/utils/encryption.test.ts @@ -0,0 +1,240 @@ +/** + * Tests for cloud-agent encryption utilities. + * + * These tests verify that: + * 1. The shared encryption module works correctly + * 2. Secrets are properly decrypted and merged with env vars + * 3. Error handling works correctly + */ + +import { describe, test, expect, beforeAll } from 'vitest'; +import { generateKeyPairSync } from 'crypto'; +import { encryptWithPublicKey } from '../../../src/lib/encryption.js'; +import { + decryptWithPrivateKey, + decryptSecrets, + mergeEnvVarsWithSecrets, + DecryptionConfigurationError, + DecryptionFormatError, +} from './encryption'; + +// Aliases for test readability +const EncryptionConfigurationError = DecryptionConfigurationError; +const EncryptionFormatError = DecryptionFormatError; +import type { EncryptedSecretEnvelope, EncryptedSecrets } from './encryption'; + +describe('cloud-agent encryption utilities', () => { + let publicKey: string; + let privateKey: string; + let wrongPrivateKey: string; + + beforeAll(() => { + // Generate RSA key pair for testing + const { publicKey: pubKey, privateKey: privKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { + type: 'spki', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + }, + }); + + publicKey = pubKey; + privateKey = privKey; + + // Generate another key pair for testing mismatched keys + const { privateKey: wrongPrivKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { + type: 'spki', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + }, + }); + + wrongPrivateKey = wrongPrivKey; + }); + + describe('decryptWithPrivateKey', () => { + test('decrypts encrypted value correctly', () => { + const testValue = 'test secret value'; + const envelope = encryptWithPublicKey(testValue, publicKey); + + const decrypted = decryptWithPrivateKey(envelope, privateKey); + expect(decrypted).toBe(testValue); + }); + + test('handles unicode and special characters', () => { + const testValues = [ + 'Hello 世界! 🌍', + '¡Hola! ¿Cómo estás?', + 'Привет мир', + 'こんにちは', + '你好世界', + 'Emoji test 🚀 🎉 🔐', + 'Special chars: !@#$%^&*(){}[]|\\:";\'<>?,./~`', + 'Newlines\nand\ttabs', + ]; + + for (const testValue of testValues) { + const envelope = encryptWithPublicKey(testValue, publicKey); + const decrypted = decryptWithPrivateKey(envelope, privateKey); + expect(decrypted).toBe(testValue); + } + }); + + test('handles empty string', () => { + const envelope = encryptWithPublicKey('', publicKey); + const decrypted = decryptWithPrivateKey(envelope, privateKey); + expect(decrypted).toBe(''); + }); + + test('handles long strings', () => { + const longValue = 'Lorem ipsum dolor sit amet. '.repeat(1000); + const envelope = encryptWithPublicKey(longValue, publicKey); + const decrypted = decryptWithPrivateKey(envelope, privateKey); + expect(decrypted).toBe(longValue); + }); + + test('throws EncryptionConfigurationError for missing private key', () => { + const envelope = encryptWithPublicKey('test', publicKey); + + expect(() => decryptWithPrivateKey(envelope, '')).toThrow(EncryptionConfigurationError); + expect(() => decryptWithPrivateKey(envelope, '')).toThrow( + 'Private key parameter is required' + ); + }); + + test('throws EncryptionConfigurationError for wrong private key', () => { + const envelope = encryptWithPublicKey('test', publicKey); + + expect(() => decryptWithPrivateKey(envelope, wrongPrivateKey)).toThrow( + EncryptionConfigurationError + ); + expect(() => decryptWithPrivateKey(envelope, wrongPrivateKey)).toThrow('Decryption failed'); + }); + + test('throws EncryptionFormatError for invalid envelope', () => { + expect(() => + decryptWithPrivateKey(null as unknown as EncryptedSecretEnvelope, privateKey) + ).toThrow(EncryptionFormatError); + + expect(() => + decryptWithPrivateKey({} as unknown as EncryptedSecretEnvelope, privateKey) + ).toThrow(EncryptionFormatError); + }); + + test('throws EncryptionFormatError for unsupported algorithm', () => { + const envelope = encryptWithPublicKey('test', publicKey); + const badEnvelope = { + ...envelope, + algorithm: 'aes-128-cbc' as const, + } as unknown as EncryptedSecretEnvelope; + + expect(() => decryptWithPrivateKey(badEnvelope, privateKey)).toThrow(EncryptionFormatError); + expect(() => decryptWithPrivateKey(badEnvelope, privateKey)).toThrow('Unsupported algorithm'); + }); + + test('throws EncryptionFormatError for unsupported version', () => { + const envelope = encryptWithPublicKey('test', publicKey); + const badEnvelope = { + ...envelope, + version: 2, + } as unknown as EncryptedSecretEnvelope; + + expect(() => decryptWithPrivateKey(badEnvelope, privateKey)).toThrow(EncryptionFormatError); + expect(() => decryptWithPrivateKey(badEnvelope, privateKey)).toThrow('Unsupported version'); + }); + }); + + describe('decryptSecrets', () => { + test('decrypts all secrets in a record', () => { + const secrets: EncryptedSecrets = { + API_KEY: encryptWithPublicKey('secret-api-key', publicKey), + DATABASE_URL: encryptWithPublicKey('postgres://localhost/db', publicKey), + JWT_SECRET: encryptWithPublicKey('my-jwt-secret', publicKey), + }; + + const decrypted = decryptSecrets(secrets, privateKey); + + expect(decrypted).toEqual({ + API_KEY: 'secret-api-key', + DATABASE_URL: 'postgres://localhost/db', + JWT_SECRET: 'my-jwt-secret', + }); + }); + + test('returns empty object for empty secrets', () => { + const decrypted = decryptSecrets({}, privateKey); + expect(decrypted).toEqual({}); + }); + }); + + describe('mergeEnvVarsWithSecrets', () => { + test('merges env vars with decrypted secrets', () => { + const envVars = { + NODE_ENV: 'production', + PORT: '3000', + }; + + const encryptedSecrets: EncryptedSecrets = { + API_KEY: encryptWithPublicKey('secret-api-key', publicKey), + DATABASE_URL: encryptWithPublicKey('postgres://localhost/db', publicKey), + }; + + const merged = mergeEnvVarsWithSecrets(envVars, encryptedSecrets, privateKey); + + expect(merged).toEqual({ + NODE_ENV: 'production', + PORT: '3000', + API_KEY: 'secret-api-key', + DATABASE_URL: 'postgres://localhost/db', + }); + }); + + test('decrypted secrets override existing env vars with same key', () => { + const envVars = { + API_KEY: 'plaintext-key', // Will be overridden + NODE_ENV: 'production', + }; + + const encryptedSecrets: EncryptedSecrets = { + API_KEY: encryptWithPublicKey('encrypted-secret-key', publicKey), + }; + + const merged = mergeEnvVarsWithSecrets(envVars, encryptedSecrets, privateKey); + + expect(merged.API_KEY).toBe('encrypted-secret-key'); + expect(merged.NODE_ENV).toBe('production'); + }); + + test('returns env vars unchanged when no secrets provided', () => { + const envVars = { + NODE_ENV: 'production', + PORT: '3000', + }; + + const merged = mergeEnvVarsWithSecrets(envVars, {}, privateKey); + + expect(merged).toEqual(envVars); + }); + + test('returns only decrypted secrets when no env vars provided', () => { + const encryptedSecrets: EncryptedSecrets = { + API_KEY: encryptWithPublicKey('secret-api-key', publicKey), + }; + + const merged = mergeEnvVarsWithSecrets({}, encryptedSecrets, privateKey); + + expect(merged).toEqual({ + API_KEY: 'secret-api-key', + }); + }); + }); +}); diff --git a/cloud-agent-next/src/utils/encryption.ts b/cloud-agent-next/src/utils/encryption.ts new file mode 100644 index 0000000000..f0e86e04da --- /dev/null +++ b/cloud-agent-next/src/utils/encryption.ts @@ -0,0 +1,89 @@ +/** + * Encryption utilities for cloud-agent worker. + * + * This module re-exports decryption functions from the shared kilocode-backend + * encryption module and provides cloud-agent-specific helper functions. + * + * The encryption format uses RSA+AES envelope encryption: + * - DEK (Data Encryption Key) is encrypted with RSA-OAEP using SHA-256 + * - Data is encrypted with AES-256-GCM using the DEK + * - Format: { encryptedData, encryptedDEK, algorithm: 'rsa-aes-256-gcm', version: 1 } + */ + +import { + decryptWithPrivateKey, + EncryptionConfigurationError, + EncryptionFormatError, + type EncryptedEnvelope, +} from '../../../src/lib/encryption.js'; + +// Re-export with cloud-agent-specific names for API clarity +export { + decryptWithPrivateKey, + EncryptionConfigurationError as DecryptionConfigurationError, + EncryptionFormatError as DecryptionFormatError, +}; + +// Re-export the type with our naming +export type { EncryptedEnvelope as EncryptedSecretEnvelope }; + +/** + * Type alias for a map of encrypted secrets (key name -> encrypted envelope). + */ +export type EncryptedSecrets = Record; + +/** + * Decrypt all encrypted secrets and return them as a plain Record. + * + * @param encryptedSecrets - Map of key names to encrypted envelopes + * @param privateKeyPem - RSA private key in PEM format + * @returns Map of key names to decrypted plaintext values + * @throws EncryptionConfigurationError if private key is invalid + * @throws EncryptionFormatError if any envelope structure is invalid + */ +export function decryptSecrets( + encryptedSecrets: EncryptedSecrets, + privateKeyPem: string | Buffer +): Record { + const result: Record = {}; + + for (const [key, envelope] of Object.entries(encryptedSecrets)) { + result[key] = decryptWithPrivateKey(envelope, privateKeyPem); + } + + return result; +} + +/** + * Merge plaintext env vars with decrypted secrets. + * Decrypted secrets override plaintext env vars if there are conflicts. + * + * @param envVars - Plaintext environment variables (optional) + * @param encryptedSecrets - Encrypted secrets to decrypt (optional) + * @param privateKeyPem - RSA private key for decryption (required if encryptedSecrets provided) + * @returns Merged environment variables with decrypted secrets + */ +export function mergeEnvVarsWithSecrets( + envVars: Record | undefined, + encryptedSecrets: EncryptedSecrets | undefined, + privateKeyPem: string | Buffer | undefined +): Record { + const result: Record = { ...(envVars || {}) }; + + if (encryptedSecrets && Object.keys(encryptedSecrets).length > 0) { + if (!privateKeyPem) { + throw new EncryptionConfigurationError( + 'AGENT_ENV_VARS_PRIVATE_KEY is required to decrypt encrypted secrets' + ); + } + + const decrypted = decryptSecrets(encryptedSecrets, privateKeyPem); + + // Secrets override env vars (as per plan spec) + for (const [key, value] of Object.entries(decrypted)) { + result[key] = value; + } + } + + return result; +} diff --git a/cloud-agent-next/src/utils/image-download.ts b/cloud-agent-next/src/utils/image-download.ts new file mode 100644 index 0000000000..23408d71dd --- /dev/null +++ b/cloud-agent-next/src/utils/image-download.ts @@ -0,0 +1,91 @@ +import { basename } from 'node:path'; +import type { ExecutionSession } from '../types.js'; +import type { Images } from '../router/schemas.js'; +import { logger } from '../logger.js'; +import type { R2Client } from './r2-client.js'; + +export type ImageDownloadResult = { + localPaths: string[]; + errors: string[]; +}; + +/** + * Download images from R2 to the sandbox's /tmp folder using presigned URLs. + * + * R2 path structure: {userId}/{path}/{filename} + * + * Uses presigned URLs so the sandbox can download files directly via curl. + * + * @param r2Client - R2 client for generating presigned URLs + * @param bucketName - The R2 bucket name + * @param session - Sandbox execution session for file operations + * @param userId - Authenticated user ID (used in R2 path) + * @param images - Images object with path and ordered files list + * @returns Object with local paths and any errors + */ +export async function downloadImagesToSandbox( + r2Client: R2Client, + bucketName: string, + session: ExecutionSession, + userId: string, + images: NonNullable +): Promise { + const localPaths: string[] = []; + const errors: string[] = []; + + const { path, files } = images; + const r2Prefix = `${userId}/${path}`; + + const sanitizedPath = path.replace(/[^a-zA-Z0-9-_]/g, '-'); + const sanitizedUserId = userId.replace(/[^a-zA-Z0-9-_]/g, '-'); + const tmpDir = `/tmp/attachments/${session.id}/${sanitizedUserId}/${sanitizedPath}`; + + // Create tmp directory + await session.exec(`mkdir -p ${tmpDir}`); + + // Download each file using presigned URLs + for (const filename of files) { + const r2Key = `${r2Prefix}/${filename}`; + // Sanitize filename to prevent path traversal + const sanitizedFilename = basename(filename); + const localPath = `${tmpDir}/${sanitizedFilename}`; + + try { + // Generate presigned URL for this object + const presignedUrl = await r2Client.getSignedURL(bucketName, r2Key); + + // Download directly in sandbox using curl + const curlCmd = `curl -sSL --max-time 120 --retry 3 --fail "${presignedUrl}" -o "${localPath}"`; + const result = await session.exec(curlCmd); + + if (result.exitCode !== 0) { + throw new Error(`curl failed: ${result.stderr || 'unknown error'}`); + } + + localPaths.push(localPath); + logger.withFields({ r2Key, localPath }).debug('Downloaded image to sandbox'); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Failed to download ${r2Key}: ${errorMsg}`); + logger.withFields({ r2Key, error: errorMsg }).error('Failed to download image'); + } + } + + logger + .withFields({ + downloadedCount: localPaths.length, + errorCount: errors.length, + tmpDir, + }) + .info('Image download complete'); + + return { localPaths, errors }; +} + +/** + * Build --attach CLI arguments from local image paths. + */ +export function buildAttachArgs(localPaths: string[]): string { + if (localPaths.length === 0) return ''; + return localPaths.map(p => `--attach=${p}`).join(' '); +} diff --git a/cloud-agent-next/src/utils/r2-client.ts b/cloud-agent-next/src/utils/r2-client.ts new file mode 100644 index 0000000000..0f89ea5adf --- /dev/null +++ b/cloud-agent-next/src/utils/r2-client.ts @@ -0,0 +1,45 @@ +import { AwsClient } from 'aws4fetch'; + +export type R2ClientConfig = { + accessKeyId: string; + secretAccessKey: string; + endpoint: string; +}; + +export type R2Client = { + getSignedURL: (bucket: string, path: string, expiresIn?: number) => Promise; +}; + +/** + * Create an R2 client that generates presigned URLs for object access. + */ +export function createR2Client(config: R2ClientConfig): R2Client { + const aws = new AwsClient({ + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + service: 's3', + region: 'auto', + }); + + return { + /** + * Generate a presigned URL for downloading an object from R2. + * + * @param bucket - The R2 bucket name + * @param path - The object key/path within the bucket + * @param expiresIn - URL expiration time in seconds (default: 3600 = 1 hour) + * @returns Presigned URL for GET access to the object + */ + async getSignedURL(bucket: string, path: string, expiresIn: number = 3600): Promise { + const url = new URL(`/${bucket}/${path}`, config.endpoint); + url.searchParams.set('X-Amz-Expires', String(expiresIn)); + + const signedRequest = await aws.sign(url.toString(), { + method: 'GET', + aws: { signQuery: true }, + }); + + return signedRequest.url; + }, + }; +} diff --git a/cloud-agent-next/src/utils/sql-helpers.ts b/cloud-agent-next/src/utils/sql-helpers.ts new file mode 100644 index 0000000000..c1ac3f54b5 --- /dev/null +++ b/cloud-agent-next/src/utils/sql-helpers.ts @@ -0,0 +1,62 @@ +/** + * SQL query building helpers for Durable Object SQLite queries. + * Used with getTableFromZodSchema for type-safe, less boilerplate queries. + */ + +/** + * Push an IN clause condition and parameters. + * Does nothing if values is undefined or empty. + * + * @example + * pushInClause(conditions, args, `${events.execution_id}`, ['exec1', 'exec2']); + * // conditions: ['events.execution_id IN (?, ?)'] + * // args: ['exec1', 'exec2'] + */ +export function pushInClause( + conditions: string[], + args: unknown[], + column: string, + values: T[] | undefined +): boolean { + if (!values?.length) return false; + + const placeholders = values.map(() => '?').join(', '); + conditions.push(`${column} IN (${placeholders})`); + args.push(...values); + return true; +} + +/** + * Push a comparison condition. + * Does nothing if value is undefined. + * + * @example + * pushCondition(conditions, args, `${events.id}`, '>', 100); + * // conditions: ['events.id > ?'] + * // args: [100] + */ +export function pushCondition( + conditions: string[], + args: unknown[], + column: string, + op: '=' | '!=' | '>' | '<' | '>=' | '<=', + value: unknown +): boolean { + if (value === undefined) return false; + + conditions.push(`${column} ${op} ?`); + args.push(value); + return true; +} + +/** + * Build WHERE clause from conditions array. + * Returns empty string if no conditions. + * + * @example + * buildWhereClause(['id > ?', 'status = ?']) + * // ' WHERE id > ? AND status = ?' + */ +export function buildWhereClause(conditions: string[]): string { + return conditions.length > 0 ? ' WHERE ' + conditions.join(' AND ') : ''; +} diff --git a/cloud-agent-next/src/utils/table.ts b/cloud-agent-next/src/utils/table.ts new file mode 100644 index 0000000000..c10ba7087e --- /dev/null +++ b/cloud-agent-next/src/utils/table.ts @@ -0,0 +1,107 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { z } from 'zod'; + +export interface TableInput { + name: string; + columns: readonly string[]; +} + +export type TableQueryInterpolator = { + // The name of the table. Prefixed with underscore to avoid + // conflicting with a column named "name" + _name: T['name']; + // Holds the un-prefixed version of column names e.g. "id" + // Use in INSERT/UPDATE column lists where qualified names are invalid + columns: { + [K in T['columns'][number]]: K; + }; + // The valueOf and toString functions ensure that when using + // this object as a regular value, it gets turned into the + // the name of the table + valueOf: () => T['name']; + toString: () => T['name']; +} & { + // Mix-in prefixed versions of columns e.g. "users.id" + // Use in SELECT, WHERE, ORDER BY, JOIN clauses + [K in T['columns'][number]]: `${T['name']}.${K}`; +}; + +/** + * Get a convenient object for interpolating a sql table name and columns + * into a template string. + * + * @example + * const users = getTable({ name: 'users', columns: ['id', 'email'] as const }); + * + * // Use table.columns.* for INSERT column lists (unqualified) + * const { columns: cols } = users; + * sql.exec(`INSERT INTO ${users} (${cols.id}, ${cols.email}) VALUES (?, ?)`, ...); + * + * // Use table.* for SELECT/WHERE/ORDER (qualified) + * sql.exec(`SELECT ${users.email} FROM ${users} WHERE ${users.id} = ?`, ...); + * + * @param table Table description with name and columns + */ +export function getTable(table: T): TableQueryInterpolator { + const columns: { + [K in T['columns'][number]]: K; + // Need any type here because we populate this object in the loop below + } = {} as any; + + const columnsWithTable: { + [K in T['columns'][number]]: `${T['name']}.${K}`; + // Need any type here because we populate this object in the loop below + } = {} as any; + + for (const key of table.columns) { + (columns as any)[key] = key; + + (columnsWithTable as any)[key] = [table.name, key].join('.'); + } + + const result: TableQueryInterpolator = { + _name: table.name, + valueOf() { + return table.name; + }, + toString() { + return table.name; + }, + columns, + ...columnsWithTable, + }; + + return result; +} + +/** + * Get a convenient object for interpolating a sql table name and columns + * into a template string from a Zod Object schema. + * + * @example + * const UserRecord = z.object({ id: z.string(), email: z.string() }); + * const users = getTableFromZodSchema('users', UserRecord); + * + * // Use table.columns.* for INSERT column lists (unqualified) + * const { columns: cols } = users; + * sql.exec(`INSERT INTO ${users} (${cols.id}, ${cols.email}) VALUES (?, ?)`, ...); + * + * // Use table.* for SELECT/WHERE/ORDER (qualified) + * sql.exec(`SELECT ${users.email} FROM ${users} WHERE ${users.id} = ?`, ...); + * + * @param name The name of your table + * @param schema The Zod object schema + */ +export function getTableFromZodSchema>( + name: Name, + schema: Schema +): TableQueryInterpolator<{ + name: Name; + columns: Array, string>>; +}> { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return getTable({ name, columns: Object.keys(schema.shape) }) as any; +} + +// Note: getCreateTableQueryFromTable is omitted since we keep migrations.ts as raw SQL diff --git a/cloud-agent-next/src/utils/timeout.ts b/cloud-agent-next/src/utils/timeout.ts new file mode 100644 index 0000000000..d3ba983178 --- /dev/null +++ b/cloud-agent-next/src/utils/timeout.ts @@ -0,0 +1,36 @@ +/** + * Wraps an async operation with a timeout. + * + * Pattern adopted from VibeSDK for reliable timeout protection. + * The finally block ensures cleanup happens in all paths (success, timeout, error). + * + * @param operation The promise to execute + * @param timeoutMs Timeout in milliseconds + * @param errorMsg Error message to throw on timeout + * @param onTimeout Optional callback to invoke when timeout occurs + * @returns The result of the operation + * @throws Error with errorMsg if timeout is exceeded + */ +export async function withTimeout( + operation: Promise, + timeoutMs: number, + errorMsg: string, + onTimeout?: () => void +): Promise { + let timeoutId: ReturnType | undefined; + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + onTimeout?.(); + reject(new Error(errorMsg)); + }, timeoutMs); + }); + + try { + return await Promise.race([operation, timeoutPromise]); + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + } +} diff --git a/cloud-agent-next/src/websocket/filters.test.ts b/cloud-agent-next/src/websocket/filters.test.ts new file mode 100644 index 0000000000..b85b36bedc --- /dev/null +++ b/cloud-agent-next/src/websocket/filters.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect } from 'vitest'; +import { parseStreamFilters, matchesFilters } from './filters.js'; +import type { StoredEvent, StreamFilters } from './types.js'; +import type { SessionId, ExecutionId, EventId } from '../types/ids.js'; + +describe('WebSocket Filters', () => { + describe('parseStreamFilters', () => { + const sessionId = 'sess_123' as SessionId; + + it('should parse empty URL with just sessionId', () => { + const url = new URL('https://example.com/stream'); + const filters = parseStreamFilters(url, sessionId); + + expect(filters.sessionId).toBe(sessionId); + expect(filters.fromId).toBeUndefined(); + expect(filters.executionIds).toBeUndefined(); + expect(filters.eventTypes).toBeUndefined(); + expect(filters.startTime).toBeUndefined(); + expect(filters.endTime).toBeUndefined(); + }); + + it('should parse fromId as number', () => { + const url = new URL('https://example.com/stream?fromId=123'); + const filters = parseStreamFilters(url, sessionId); + + expect(filters.fromId).toBe(123); + }); + + it('should ignore non-numeric fromId', () => { + const url = new URL('https://example.com/stream?fromId=abc'); + const filters = parseStreamFilters(url, sessionId); + + expect(filters.fromId).toBeUndefined(); + }); + + it('should parse executionIds as comma-separated list', () => { + const url = new URL('https://example.com/stream?executionIds=msg_1,msg_2,msg_3'); + const filters = parseStreamFilters(url, sessionId); + + expect(filters.executionIds).toEqual(['msg_1', 'msg_2', 'msg_3']); + }); + + it('should parse eventTypes as comma-separated list', () => { + const url = new URL('https://example.com/stream?eventTypes=output,error,complete'); + const filters = parseStreamFilters(url, sessionId); + + expect(filters.eventTypes).toEqual(['output', 'error', 'complete']); + }); + + it('should parse startTime and endTime as numbers', () => { + const url = new URL('https://example.com/stream?startTime=1000&endTime=2000'); + const filters = parseStreamFilters(url, sessionId); + + expect(filters.startTime).toBe(1000); + expect(filters.endTime).toBe(2000); + }); + + it('should parse all filters together', () => { + const url = new URL( + 'https://example.com/stream?fromId=100&executionIds=msg_1,msg_2&eventTypes=output&startTime=1000&endTime=2000' + ); + const filters = parseStreamFilters(url, sessionId); + + expect(filters).toEqual({ + sessionId, + fromId: 100, + executionIds: ['msg_1', 'msg_2'], + eventTypes: ['output'], + startTime: 1000, + endTime: 2000, + }); + }); + }); + + describe('matchesFilters', () => { + const createEvent = (overrides?: Partial): StoredEvent => ({ + id: 1 as EventId, + execution_id: 'msg_1', + session_id: 'sess_1', + stream_event_type: 'output', + payload: '{}', + timestamp: 1500, + ...overrides, + }); + + const createFilters = (overrides?: Partial): StreamFilters => ({ + sessionId: 'sess_1' as SessionId, + ...overrides, + }); + + it('should match when no filters specified', () => { + const event = createEvent(); + const filters = createFilters(); + + expect(matchesFilters(event, filters)).toBe(true); + }); + + it('should match when executionId is in executionIds list', () => { + const event = createEvent({ execution_id: 'msg_2' }); + const filters = createFilters({ executionIds: ['msg_1', 'msg_2'] as ExecutionId[] }); + + expect(matchesFilters(event, filters)).toBe(true); + }); + + it('should not match when executionId is not in executionIds list', () => { + const event = createEvent({ execution_id: 'msg_3' }); + const filters = createFilters({ executionIds: ['msg_1', 'msg_2'] as ExecutionId[] }); + + expect(matchesFilters(event, filters)).toBe(false); + }); + + it('should match when eventType is in eventTypes list', () => { + const event = createEvent({ stream_event_type: 'error' }); + const filters = createFilters({ eventTypes: ['output', 'error'] }); + + expect(matchesFilters(event, filters)).toBe(true); + }); + + it('should not match when eventType is not in eventTypes list', () => { + const event = createEvent({ stream_event_type: 'complete' }); + const filters = createFilters({ eventTypes: ['output', 'error'] }); + + expect(matchesFilters(event, filters)).toBe(false); + }); + + it('should match when timestamp >= startTime (inclusive)', () => { + const event = createEvent({ timestamp: 1000 }); + const filters = createFilters({ startTime: 1000 }); + + expect(matchesFilters(event, filters)).toBe(true); + }); + + it('should not match when timestamp < startTime', () => { + const event = createEvent({ timestamp: 999 }); + const filters = createFilters({ startTime: 1000 }); + + expect(matchesFilters(event, filters)).toBe(false); + }); + + it('should match when timestamp <= endTime (inclusive)', () => { + const event = createEvent({ timestamp: 2000 }); + const filters = createFilters({ endTime: 2000 }); + + expect(matchesFilters(event, filters)).toBe(true); + }); + + it('should not match when timestamp > endTime', () => { + const event = createEvent({ timestamp: 2001 }); + const filters = createFilters({ endTime: 2000 }); + + expect(matchesFilters(event, filters)).toBe(false); + }); + + it('should require all filters to match (AND logic)', () => { + const event = createEvent({ + execution_id: 'msg_1', + stream_event_type: 'output', + timestamp: 1500, + }); + + const filters = createFilters({ + executionIds: ['msg_1'] as ExecutionId[], + eventTypes: ['output'], + startTime: 1000, + endTime: 2000, + }); + + expect(matchesFilters(event, filters)).toBe(true); + }); + + it('should not match if any filter fails', () => { + const event = createEvent({ + execution_id: 'msg_1', + stream_event_type: 'error', // Wrong type + timestamp: 1500, + }); + + const filters = createFilters({ + executionIds: ['msg_1'] as ExecutionId[], + eventTypes: ['output'], // Requires 'output' but event is 'error' + startTime: 1000, + endTime: 2000, + }); + + expect(matchesFilters(event, filters)).toBe(false); + }); + }); +}); diff --git a/cloud-agent-next/src/websocket/filters.ts b/cloud-agent-next/src/websocket/filters.ts new file mode 100644 index 0000000000..05f452f712 --- /dev/null +++ b/cloud-agent-next/src/websocket/filters.ts @@ -0,0 +1,136 @@ +/** + * WebSocket filter parsing and matching for the /stream endpoint. + * + * This module provides utilities for: + * - Parsing query parameters into StreamFilters + * - Matching stored events against filters for live broadcast + */ + +import type { StreamFilters, StreamEventType, StoredEvent } from './types.js'; +import type { ExecutionId, SessionId } from '../types/ids.js'; + +// --------------------------------------------------------------------------- +// Timestamp Parsing +// --------------------------------------------------------------------------- + +/** + * Parse a timestamp value from query parameter. + * Supports both integer milliseconds and ISO 8601 format. + * + * @param value - The raw query parameter value + * @returns Unix timestamp in milliseconds, or undefined if invalid + * + * @example + * ```ts + * parseTimestamp('1705316400000') // → 1705316400000 (integer ms) + * parseTimestamp('2024-01-15T10:30:00Z') // → 1705316400000 (ISO 8601) + * parseTimestamp('invalid') // → undefined + * parseTimestamp(null) // → undefined + * ``` + */ +function parseTimestamp(value: string | null): number | undefined { + if (!value) return undefined; + + // Try integer milliseconds first + const parsed = parseInt(value, 10); + if (!isNaN(parsed)) { + return parsed; + } + + // Try ISO 8601 timestamp + const date = new Date(value); + if (!isNaN(date.getTime())) { + return date.getTime(); + } + + return undefined; +} + +// --------------------------------------------------------------------------- +// Query Parameter Parsing +// --------------------------------------------------------------------------- + +/** + * Parse query params from /stream URL into StreamFilters. + * + * @param url - The request URL containing query parameters + * @param sessionId - The session ID (extracted from DO context) + * @returns Parsed stream filters + * + * @example + * ```ts + * const filters = parseStreamFilters( + * new URL('https://example.com/stream?fromId=5&eventTypes=output,error'), + * sessionId + * ); + * // { sessionId, fromId: 5, eventTypes: ['output', 'error'] } + * ``` + */ +export function parseStreamFilters(url: URL, sessionId: SessionId): StreamFilters { + const filters: StreamFilters = { sessionId }; + + const fromIdParam = url.searchParams.get('fromId'); + if (fromIdParam) { + const parsed = parseInt(fromIdParam, 10); + if (!isNaN(parsed)) { + filters.fromId = parsed; + } + } + + const executionIdsParam = url.searchParams.get('executionIds'); + if (executionIdsParam) { + filters.executionIds = executionIdsParam.split(',').filter(Boolean) as ExecutionId[]; + } + + const eventTypesParam = url.searchParams.get('eventTypes'); + if (eventTypesParam) { + filters.eventTypes = eventTypesParam.split(',').filter(Boolean) as StreamEventType[]; + } + + filters.startTime = parseTimestamp(url.searchParams.get('startTime')); + filters.endTime = parseTimestamp(url.searchParams.get('endTime')); + + return filters; +} + +// --------------------------------------------------------------------------- +// Event Matching +// --------------------------------------------------------------------------- + +/** + * Check if a stored event matches the given filters. + * + * Note: This does NOT check `fromId` - that filter is only used for replay queries. + * Live events are always newer than the client's cursor. + * + * @param event - The stored event to check + * @param filters - The stream filters to match against + * @returns true if the event matches all applicable filters + */ +export function matchesFilters(event: StoredEvent, filters: StreamFilters): boolean { + // Check executionIds filter + if (filters.executionIds && filters.executionIds.length > 0) { + if (!filters.executionIds.includes(event.execution_id as ExecutionId)) { + return false; + } + } + + // Check eventTypes filter + if (filters.eventTypes && filters.eventTypes.length > 0) { + if (!filters.eventTypes.includes(event.stream_event_type as StreamEventType)) { + return false; + } + } + + // Check time range (startTime is inclusive) + if (filters.startTime !== undefined && event.timestamp < filters.startTime) { + return false; + } + + // Check time range (endTime is inclusive) + if (filters.endTime !== undefined && event.timestamp > filters.endTime) { + return false; + } + + return true; +} diff --git a/cloud-agent-next/src/websocket/index.ts b/cloud-agent-next/src/websocket/index.ts new file mode 100644 index 0000000000..014ee2c4e1 --- /dev/null +++ b/cloud-agent-next/src/websocket/index.ts @@ -0,0 +1,26 @@ +/** + * WebSocket handling modules for the cloud-agent worker. + * + * This module exports: + * - Protocol types for /stream and /ingest endpoints + * - Filter parsing and matching utilities + * - Stream handler for client-facing WebSocket connections + * - Ingest handler for internal event ingestion + */ + +// Types +export * from './types.js'; + +// Filters +export * from './filters.js'; + +// Stream handler +export { + createStreamHandler, + formatStreamEvent, + createErrorMessage, + type StreamHandler, +} from './stream.js'; + +// Ingest handler +export { createIngestHandler, type IngestHandler, type IngestAttachment } from './ingest.js'; diff --git a/cloud-agent-next/src/websocket/ingest.ts b/cloud-agent-next/src/websocket/ingest.ts new file mode 100644 index 0000000000..5189e59656 --- /dev/null +++ b/cloud-agent-next/src/websocket/ingest.ts @@ -0,0 +1,481 @@ +/** + * Ingest handler for the /ingest WebSocket endpoint. + * + * This module provides the internal WebSocket handler that: + * - Accepts WebSocket connections from the wrapper process inside the sandbox + * - Persists incoming events to SQLite storage + * - Broadcasts events to connected /stream clients + * - Handles kiloSessionId capture from session_created events + * - Handles branch capture from complete events + * - Handles execution lifecycle (complete/interrupted/error) + * + * The /ingest endpoint is internal-only - it should only be called + * by the wrapper via DO fetch, not exposed to external clients. + */ + +import type { IngestEvent, StoredEvent } from './types.js'; +import type { ExecutionId, SessionId } from '../types/ids.js'; +import type { EventQueries } from '../session/queries/index.js'; +import { createErrorMessage } from './stream.js'; +import { z } from 'zod'; +import { + handleKilocodeEvent, + handleBranchCapture, + handleExecutionComplete, + type KiloSessionCaptureState, +} from '../session/ingest-handlers/index.js'; +import type { CompleteEventData, KilocodeEventData } from '../shared/protocol.js'; + +// --------------------------------------------------------------------------- +// Ingest Attachment +// --------------------------------------------------------------------------- + +/** Debounce interval for heartbeat updates (30 seconds) */ +const HEARTBEAT_DEBOUNCE_MS = 30_000; + +const completeEventSchema = z.object({ + exitCode: z.number(), + currentBranch: z.string().optional(), +}); + +const kilocodeEventSchema = z + .object({ + event: z.string().optional(), + sessionId: z.string().optional(), + }) + .passthrough(); + +const interruptedEventSchema = z.object({ + reason: z.string().optional(), + exitCode: z.number().optional(), +}); + +const errorEventSchema = z.object({ + fatal: z.boolean().optional(), + error: z.string().optional(), + message: z.string().optional(), +}); + +const createExecutionLifecycleContext = (doContext: IngestDOContext) => ({ + updateExecutionStatus: ( + id: string, + status: 'completed' | 'failed' | 'interrupted', + err?: string + ) => doContext.updateExecutionStatus(id, status, err), + clearActiveExecution: () => doContext.clearActiveExecution(), + logger: console, +}); + +const isTerminalStatus = (status?: ExecutionData['status']) => + status === 'completed' || status === 'failed' || status === 'interrupted'; + +const shouldIgnoreTerminalEvent = async ( + executionId: ExecutionId, + doContext: IngestDOContext +): Promise => { + const currentExecution = await doContext.getExecution(executionId); + return Boolean(currentExecution && isTerminalStatus(currentExecution.status)); +}; + +/** + * Attachment data stored with ingest WebSocket connections. + * This data persists across hibernation cycles. + */ +export type IngestAttachment = { + /** Execution ID for this ingest connection */ + executionId: ExecutionId; + /** Unix timestamp when connection was established */ + connectedAt: number; + /** KiloSessionId capture state - tracks if we've already captured for this exec */ + kiloSessionState: KiloSessionCaptureState; + /** Last heartbeat update timestamp for debouncing */ + lastHeartbeatUpdate: number; +}; + +// --------------------------------------------------------------------------- +// DO Context for handlers +// --------------------------------------------------------------------------- + +/** Execution data needed for validation */ +export type ExecutionData = { + executionId: string; + status: 'pending' | 'running' | 'completed' | 'failed' | 'interrupted'; + ingestToken?: string; +}; + +/** + * Context provided by the DO to the ingest handler for calling back + * into the DO for kiloSessionId capture, branch update, and lifecycle. + */ +export type IngestDOContext = { + /** Persist the kiloSessionId in DO metadata */ + updateKiloSessionId: (id: string) => Promise; + /** Link kiloSessionId to backend for analytics */ + linkKiloSessionInBackend: (id: string) => Promise; + /** Persist the upstream branch in DO metadata */ + updateUpstreamBranch: (branch: string) => Promise; + /** Clear the active execution when done */ + clearActiveExecution: () => Promise; + /** Get execution data for validation (including ingestToken) */ + getExecution: (executionId: string) => Promise; + /** Transition execution status to 'running' when wrapper connects */ + transitionToRunning: (executionId: string) => Promise; + /** Update execution heartbeat timestamp (debounced) */ + updateHeartbeat: (executionId: string, timestamp: number) => Promise; + /** Update execution status when complete/failed/interrupted */ + updateExecutionStatus: ( + executionId: string, + status: 'completed' | 'failed' | 'interrupted', + error?: string + ) => Promise; +}; + +// --------------------------------------------------------------------------- +// Ingest Handler Factory +// --------------------------------------------------------------------------- + +/** + * Create an ingest handler for the /ingest WebSocket endpoint. + * + * The handler uses Cloudflare's WebSocket hibernation API: + * - `state.acceptWebSocket()` registers the WebSocket with hibernation support + * - `serializeAttachment()` persists execution ID across hibernation cycles + * - Uses `ingest:{executionId}` tags for identification + * + * @param state - Durable Object state for WebSocket management + * @param eventQueries - Event queries module for persisting events + * @param sessionId - Session ID for this DO instance + * @param broadcastFn - Function to broadcast events to /stream clients + * @param doContext - Context for calling back into DO for session capture, branch, and lifecycle + * @returns Ingest handler object with methods for WebSocket operations + */ +export function createIngestHandler( + state: DurableObjectState, + eventQueries: EventQueries, + sessionId: SessionId, + broadcastFn: (event: StoredEvent) => void, + doContext: IngestDOContext +) { + // Track active ingest connections per execution + // Note: This map is reset on hibernation, but we can reconstruct + // from state.getWebSockets() using tags if needed + const activeConnections = new Map(); + + return { + /** + * Handle incoming /ingest WebSocket upgrade request. + * + * Flow: + * 1. Validate WebSocket upgrade header + * 2. Extract and validate executionId and token from query params + * 3. Validate execution exists and token matches + * 4. Close any existing connection for this execution + * 5. Transition execution status to 'running' + * 6. Accept WebSocket with hibernation support and ingest tag + * 7. Store execution ID in attachment for hibernation-safe access + * + * @param request - The incoming HTTP request with WebSocket upgrade + * @returns HTTP response (101 on success, error status otherwise) + */ + async handleIngestRequest(request: Request): Promise { + // Verify it's a WebSocket upgrade + const upgradeHeader = request.headers.get('Upgrade'); + if (upgradeHeader !== 'websocket') { + return new Response('Expected WebSocket upgrade', { status: 426 }); + } + + const url = new URL(request.url); + const executionId = url.searchParams.get('executionId') as ExecutionId | null; + + if (!executionId) { + return new Response('Missing executionId parameter', { status: 400 }); + } + + // Validate execution exists and token matches + const execution = await doContext.getExecution(executionId); + if (!execution) { + return new Response('Execution not found', { status: 404 }); + } + + if (execution.ingestToken !== executionId) { + return new Response('Invalid executionId', { status: 401 }); + } + + // Allow connections only for pending or running executions + if (execution.status !== 'pending' && execution.status !== 'running') { + return new Response('Execution not active', { status: 409 }); + } + + // Check for existing connection - close old one if exists + const existingWs = activeConnections.get(executionId); + if (existingWs) { + try { + existingWs.close(1000, 'Replaced by new connection'); + } catch { + // Ignore close errors on already-closed connections + } + activeConnections.delete(executionId); + } + + // Transition execution status to 'running' if not already + if (execution.status === 'pending') { + await doContext.transitionToRunning(executionId); + } + + // Create WebSocket pair + const pair = new WebSocketPair(); + const client = pair[0]; + const server = pair[1]; + + const now = Date.now(); + + // Store execution ID and capture state in attachment for hibernation-safe access + const attachment: IngestAttachment = { + executionId, + connectedAt: now, + kiloSessionState: { captured: false }, + lastHeartbeatUpdate: now, + }; + + // Accept the WebSocket with hibernation support + // Use ingest:{executionId} tag for identification + state.acceptWebSocket(server, [`ingest:${executionId}`]); + server.serializeAttachment(attachment); + + // Track the connection + activeConnections.set(executionId, server); + + // Set initial heartbeat + void doContext.updateHeartbeat(executionId, now); + + return new Response(null, { status: 101, webSocket: client }); + }, + + /** + * Handle incoming message on an ingest WebSocket. + * + * Flow: + * 1. Validate message is string (not binary) + * 2. Get execution ID from attachment + * 3. Parse and validate the ingest event + * 4. Insert event into SQLite with RETURNING id + * 5. Broadcast to /stream clients with eventId attached + * 6. Update heartbeat (debounced to every 30 seconds) + * + * @param ws - The WebSocket that received the message + * @param message - The incoming message (should be JSON string) + */ + async handleIngestMessage(ws: WebSocket, message: string | ArrayBuffer): Promise { + if (typeof message !== 'string') { + ws.send( + JSON.stringify(createErrorMessage('WS_PROTOCOL_ERROR', 'Binary messages not supported')) + ); + return; + } + + // Get execution ID from attachment + const attachment = ws.deserializeAttachment() as IngestAttachment | null; + if (!attachment) { + ws.send( + JSON.stringify(createErrorMessage('WS_INTERNAL_ERROR', 'Missing connection attachment')) + ); + return; + } + + const { executionId } = attachment; + + try { + // Parse the ingest event + const ingestEvent = JSON.parse(message) as IngestEvent; + + // Validate required fields + if (!ingestEvent.streamEventType) { + ws.send( + JSON.stringify(createErrorMessage('WS_PROTOCOL_ERROR', 'Missing streamEventType field')) + ); + return; + } + + // Normalize timestamp - use provided or current time + const timestamp = ingestEvent.timestamp + ? new Date(ingestEvent.timestamp).getTime() + : Date.now(); + + // Insert into SQLite and get the auto-generated ID + const eventId = eventQueries.insert({ + executionId, + sessionId, + streamEventType: ingestEvent.streamEventType, + payload: JSON.stringify(ingestEvent.data ?? {}), + timestamp, + }); + + // Build stored event for broadcasting + const storedEvent: StoredEvent = { + id: eventId, + execution_id: executionId, + session_id: sessionId, + stream_event_type: ingestEvent.streamEventType, + payload: JSON.stringify(ingestEvent.data ?? {}), + timestamp, + }; + + // Broadcast to all /stream clients + broadcastFn(storedEvent); + + // Update heartbeat (debounced to every HEARTBEAT_DEBOUNCE_MS) + const now = Date.now(); + if (now - attachment.lastHeartbeatUpdate >= HEARTBEAT_DEBOUNCE_MS) { + attachment.lastHeartbeatUpdate = now; + ws.serializeAttachment(attachment); + void doContext.updateHeartbeat(executionId, now); + } + + // -- Handler integrations -- + + // Handle kilocode events (session ID capture) + if (ingestEvent.streamEventType === 'kilocode') { + const parsedKilocode = kilocodeEventSchema.safeParse(ingestEvent.data); + if (parsedKilocode.success) { + await handleKilocodeEvent( + parsedKilocode.data as KilocodeEventData, + attachment.kiloSessionState, + { + updateKiloSessionId: id => doContext.updateKiloSessionId(id), + linkToBackend: id => doContext.linkKiloSessionInBackend(id), + logger: console, + } + ); + // Re-serialize attachment since kiloSessionState may have changed + ws.serializeAttachment(attachment); + } else { + console.warn('Invalid kilocode event payload', parsedKilocode.error); + } + } + + // Handle complete events (branch capture + lifecycle) + if (ingestEvent.streamEventType === 'complete') { + if (await shouldIgnoreTerminalEvent(executionId, doContext)) { + return; + } + const parsedComplete = completeEventSchema.safeParse(ingestEvent.data); + if (!parsedComplete.success) { + console.warn('Invalid complete event payload', parsedComplete.error); + return; + } + await handleBranchCapture(parsedComplete.data as CompleteEventData, { + updateUpstreamBranch: branch => doContext.updateUpstreamBranch(branch), + logger: console, + }); + await handleExecutionComplete( + executionId, + 'completed', + createExecutionLifecycleContext(doContext) + ); + } + + // Handle interrupted events + if (ingestEvent.streamEventType === 'interrupted') { + if (await shouldIgnoreTerminalEvent(executionId, doContext)) { + return; + } + const parsedInterrupted = interruptedEventSchema.safeParse(ingestEvent.data); + if (!parsedInterrupted.success) { + console.warn('Invalid interrupted event payload', parsedInterrupted.error); + return; + } + const interruptedData = parsedInterrupted.data; + await handleExecutionComplete( + executionId, + 'interrupted', + createExecutionLifecycleContext(doContext), + interruptedData.reason ?? 'User interrupted' + ); + } + + // Handle fatal errors + if (ingestEvent.streamEventType === 'error') { + const parsedError = errorEventSchema.safeParse(ingestEvent.data); + if (!parsedError.success) { + console.warn('Invalid error event payload', parsedError.error); + return; + } + const errorData = parsedError.data; + if (errorData.fatal) { + if (await shouldIgnoreTerminalEvent(executionId, doContext)) { + return; + } + await handleExecutionComplete( + executionId, + 'failed', + createExecutionLifecycleContext(doContext), + errorData.error ?? errorData.message ?? 'Fatal error' + ); + } + } + } catch (error) { + console.error('Error processing ingest message:', error); + ws.send( + JSON.stringify( + createErrorMessage( + 'WS_INTERNAL_ERROR', + error instanceof Error ? error.message : 'Failed to process event' + ) + ) + ); + } + }, + + /** + * Handle ingest WebSocket close. + * + * Removes the connection from tracking if it's the current + * connection for this execution (avoids removing a replacement + * connection that was already established). + * + * @param ws - The WebSocket that closed + */ + handleIngestClose(ws: WebSocket): void { + const attachment = ws.deserializeAttachment() as IngestAttachment | null; + if (attachment) { + const { executionId } = attachment; + // Only remove from tracking if this is the current connection for this execution + if (activeConnections.get(executionId) === ws) { + activeConnections.delete(executionId); + } + } + }, + + /** + * Check if an execution has an active ingest connection. + * + * @param executionId - Execution ID to check + * @returns True if there's an active connection for this execution + */ + hasActiveConnection(executionId: ExecutionId): boolean { + return activeConnections.has(executionId); + }, + + /** + * Get count of active ingest connections. + * + * @returns Number of active ingest WebSocket connections + */ + getActiveConnectionCount(): number { + return activeConnections.size; + }, + + /** + * Get WebSocket for a specific execution (for testing/debugging). + * + * @param executionId - Execution ID to get connection for + * @returns WebSocket if found, undefined otherwise + */ + getConnection(executionId: ExecutionId): WebSocket | undefined { + return activeConnections.get(executionId); + }, + }; +} + +/** Type of the ingest handler object returned by createIngestHandler */ +export type IngestHandler = ReturnType; diff --git a/cloud-agent-next/src/websocket/stream.ts b/cloud-agent-next/src/websocket/stream.ts new file mode 100644 index 0000000000..a310b92a9e --- /dev/null +++ b/cloud-agent-next/src/websocket/stream.ts @@ -0,0 +1,207 @@ +/** + * Stream handler for the /stream WebSocket endpoint. + * + * This module provides the client-facing WebSocket handler that: + * - Accepts WebSocket connections with hibernation support + * - Replays historical events based on client filters + * - Broadcasts new events to matching connected clients + */ + +import type { + StreamFilters, + StreamEvent, + StoredEvent, + StreamAttachment, + StreamError, + StreamErrorCode, +} from './types.js'; +import type { SessionId } from '../types/ids.js'; +import { parseStreamFilters, matchesFilters } from './filters.js'; +import type { EventQueries } from '../session/queries/index.js'; + +// --------------------------------------------------------------------------- +// Event Formatting +// --------------------------------------------------------------------------- + +/** + * Format a stored event for sending to client. + * + * @param event - The stored event from SQLite + * @param sessionId - The session ID to include in the envelope + * @returns Formatted event envelope ready for JSON serialization + */ +export function formatStreamEvent(event: StoredEvent, sessionId: SessionId): StreamEvent { + return { + eventId: event.id, + executionId: event.execution_id as StreamEvent['executionId'], + sessionId, + streamEventType: event.stream_event_type as StreamEvent['streamEventType'], + timestamp: new Date(event.timestamp).toISOString(), + data: JSON.parse(event.payload), + }; +} + +/** + * Create an error message to send to client. + * + * @param code - Error code identifying the type of error + * @param message - Human-readable error description + * @returns Error envelope ready for JSON serialization + */ +export function createErrorMessage(code: StreamErrorCode, message: string): StreamError { + return { type: 'error', code, message }; +} + +// --------------------------------------------------------------------------- +// Stream Handler Factory +// --------------------------------------------------------------------------- + +/** + * Create a stream handler for the /stream WebSocket endpoint. + * + * The handler uses Cloudflare's WebSocket hibernation API for efficiency: + * - `state.acceptWebSocket()` registers the WebSocket with hibernation support + * - `serializeAttachment()` persists filter data across hibernation cycles + * - `getWebSockets(tag)` retrieves sockets by tag for broadcasting + * + * @param state - Durable Object state for WebSocket management + * @param eventQueries - Event queries module for replaying historical events + * @param sessionId - Session ID for this DO instance + * @returns Stream handler object with methods for WebSocket operations + */ +export function createStreamHandler( + state: DurableObjectState, + eventQueries: EventQueries, + sessionId: SessionId +) { + return { + /** + * Handle incoming /stream WebSocket upgrade request. + * + * Flow: + * 1. Validate WebSocket upgrade header + * 2. Parse query parameters into filters + * 3. Accept WebSocket with hibernation support and 'stream' tag + * 4. Store filters in attachment for hibernation-safe access + * 5. Replay historical events matching filters + * 6. Return the 101 Switching Protocols response + * + * @param request - The incoming HTTP request with WebSocket upgrade + * @returns HTTP response (101 on success, error status otherwise) + */ + async handleStreamRequest(request: Request): Promise { + // Verify it's a WebSocket upgrade + const upgradeHeader = request.headers.get('Upgrade'); + if (upgradeHeader !== 'websocket') { + return new Response('Expected WebSocket upgrade', { status: 426 }); + } + + const url = new URL(request.url); + const filters = parseStreamFilters(url, sessionId); + + // Create WebSocket pair + const pair = new WebSocketPair(); + const client = pair[0]; + const server = pair[1]; + + // Store filters in attachment for hibernation-safe access + const attachment: StreamAttachment = { + filters, + connectedAt: Date.now(), + }; + + // Accept the WebSocket with hibernation support + // Use a single 'stream' tag since tags are capped at 10 + state.acceptWebSocket(server, ['stream']); + server.serializeAttachment(attachment); + + // Replay historical events immediately after accepting + await this.replayEvents(server, filters); + + return new Response(null, { status: 101, webSocket: client }); + }, + + /** + * Replay historical events to a newly connected client. + * + * Queries events using the client's filters and sends them in order. + * Uses `fromId` for exclusive pagination (id > fromId). + * + * @param ws - The WebSocket connection to send events to + * @param filters - The client's filter preferences + */ + async replayEvents(ws: WebSocket, filters: StreamFilters): Promise { + try { + // Build query filters from stream filters + const queryFilters = { + fromId: filters.fromId, + executionIds: filters.executionIds, + eventTypes: filters.eventTypes, + startTime: filters.startTime, + endTime: filters.endTime, + }; + + const events = eventQueries.findByFilters(queryFilters); + + for (const event of events) { + const formatted = formatStreamEvent(event, sessionId); + ws.send(JSON.stringify(formatted)); + } + } catch (error) { + console.error('Error replaying events:', error); + ws.send( + JSON.stringify( + createErrorMessage('WS_INTERNAL_ERROR', 'Failed to replay historical events') + ) + ); + } + }, + + /** + * Broadcast a new event to all matching /stream clients. + * + * For each connected WebSocket with the 'stream' tag: + * 1. Deserialize the attachment to get filters + * 2. Check if the event matches the client's filters + * 3. Send the formatted event if it matches + * + * @param event - The stored event to broadcast + */ + broadcastEvent(event: StoredEvent): void { + const allWs = state.getWebSockets('stream'); + + for (const ws of allWs) { + try { + // Get filters from attachment + const attachment = ws.deserializeAttachment() as StreamAttachment | null; + + if (!attachment) continue; + + const { filters } = attachment; + + // Check if event matches this client's filters + if (!matchesFilters(event, filters)) continue; + + // Send formatted event + const formatted = formatStreamEvent(event, sessionId); + ws.send(JSON.stringify(formatted)); + } catch (error) { + console.error('Error broadcasting to WebSocket:', error); + // Don't close the WebSocket on broadcast error - let the client handle reconnection + } + } + }, + + /** + * Get count of connected stream clients. + * + * @returns Number of active WebSocket connections with 'stream' tag + */ + getConnectedClientCount(): number { + return state.getWebSockets('stream').length; + }, + }; +} + +/** Type of the stream handler object returned by createStreamHandler */ +export type StreamHandler = ReturnType; diff --git a/cloud-agent-next/src/websocket/types.ts b/cloud-agent-next/src/websocket/types.ts new file mode 100644 index 0000000000..c3bb9c0221 --- /dev/null +++ b/cloud-agent-next/src/websocket/types.ts @@ -0,0 +1,171 @@ +/** + * WebSocket protocol types for the cloud-agent streaming feature. + * + * This module defines message formats for: + * - `/stream` endpoint: Client-facing WebSocket for receiving execution events + * - `/ingest` endpoint: Internal WebSocket for wrapper to push events + */ + +import type { ExecutionId, SessionId, EventId } from '../types/ids.js'; + +// Re-export shared protocol types for convenience (except IngestEvent which has a local definition) +export type { + StreamEventType as SharedStreamEventType, + WrapperCommand, + CompleteEventData, + KilocodeEventData, +} from '../shared/protocol.js'; + +// --------------------------------------------------------------------------- +// Stream Event Types +// --------------------------------------------------------------------------- + +/** + * Types of events that can flow through the streaming system. + * These map to execution lifecycle events and output streams. + */ +export type StreamEventType = + | 'output' // stdout/stderr content + | 'metadata' // execution metadata updates + | 'error' // error occurred during execution + | 'complete' // execution completed successfully + | 'interrupted' // execution was interrupted + | 'started' // execution started + | 'progress' // progress update (e.g., tokens consumed) + | 'kilocode' // Kilocode CLI structured events + | 'status'; // execution status updates + +// --------------------------------------------------------------------------- +// Server -> Client Events (/stream endpoint) +// --------------------------------------------------------------------------- + +/** + * Event envelope sent to clients connected to the /stream endpoint. + * Each event is uniquely identified and associated with an execution and session. + */ +export type StreamEvent = { + /** Auto-incrementing event ID from SQLite storage */ + eventId: EventId; + /** Execution this event belongs to */ + executionId: ExecutionId; + /** Session this event belongs to */ + sessionId: SessionId; + /** Type of stream event */ + streamEventType: StreamEventType; + /** ISO 8601 timestamp when the event occurred */ + timestamp: string; + /** Event payload - structure depends on streamEventType */ + data: unknown; +}; + +// --------------------------------------------------------------------------- +// Wrapper -> DO Events (/ingest endpoint) +// --------------------------------------------------------------------------- + +/** + * Event envelope sent by the wrapper to the Durable Object via /ingest. + * The execution and session context is established at connection time. + */ +export type IngestEvent = { + /** Type of stream event */ + streamEventType: StreamEventType; + /** ISO 8601 timestamp when the event occurred */ + timestamp: string; + /** Event payload - structure depends on streamEventType */ + data: unknown; +}; + +// --------------------------------------------------------------------------- +// Error Handling +// --------------------------------------------------------------------------- + +/** Error codes for WebSocket protocol errors */ +export type StreamErrorCode = + | 'WS_PROTOCOL_ERROR' // Invalid message format + | 'WS_AUTH_ERROR' // Authentication failed + | 'WS_SESSION_NOT_FOUND' // Session doesn't exist + | 'WS_EXECUTION_NOT_FOUND' // Execution doesn't exist + | 'WS_DUPLICATE_CONNECTION' // Ingest connection already exists for execution + | 'WS_INTERNAL_ERROR'; // Internal server error + +/** + * Error message envelope sent to clients when an error occurs. + */ +export type StreamError = { + type: 'error'; + code: StreamErrorCode; + message: string; +}; + +// --------------------------------------------------------------------------- +// Stream Filtering +// --------------------------------------------------------------------------- + +/** + * Filter options for the /stream endpoint. + * These are passed via query parameters and control which events are returned. + */ +export type StreamFilters = { + /** Session ID to filter events for (required) */ + sessionId: SessionId; + /** Only return events with ID > fromId (exclusive, for pagination) */ + fromId?: EventId; + /** Only return events for these execution IDs */ + executionIds?: ExecutionId[]; + /** Only return events of these types */ + eventTypes?: StreamEventType[]; + /** Only return events at or after this timestamp (Unix ms, inclusive) */ + startTime?: number; + /** Only return events at or before this timestamp (Unix ms, inclusive) */ + endTime?: number; +}; + +/** + * Parsed and validated query parameters for the /stream endpoint. + */ +export type ParsedStreamParams = { + sessionId: SessionId; + fromId?: EventId; + executionIds?: ExecutionId[]; + eventTypes?: StreamEventType[]; + startTime?: number; + endTime?: number; +}; + +// --------------------------------------------------------------------------- +// SQLite Storage +// --------------------------------------------------------------------------- + +/** + * Row structure for events stored in SQLite. + * Uses snake_case to match SQL conventions. + */ +export type StoredEvent = { + /** Auto-incrementing primary key */ + id: EventId; + /** Execution ID as string (without type safety at storage layer) */ + execution_id: string; + /** Session ID as string */ + session_id: string; + /** Event type as string */ + stream_event_type: string; + /** JSON stringified event data */ + payload: string; + /** Unix timestamp in milliseconds */ + timestamp: number; +}; + +// --------------------------------------------------------------------------- +// WebSocket Hibernation +// --------------------------------------------------------------------------- + +/** + * Attachment data stored with hibernating WebSocket connections. + * This data persists across hibernation cycles. + */ +export type StreamAttachment = { + /** Client's filter preferences */ + filters: StreamFilters; + /** Unix timestamp when connection was established */ + connectedAt: number; +}; diff --git a/cloud-agent-next/src/workspace.test.ts b/cloud-agent-next/src/workspace.test.ts new file mode 100644 index 0000000000..d39786451d --- /dev/null +++ b/cloud-agent-next/src/workspace.test.ts @@ -0,0 +1,423 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + manageBranch, + cloneGitHubRepo, + cloneGitRepo, + checkDiskSpace, + createSandboxUsageEvent, + LOW_DISK_THRESHOLD_MB, +} from './workspace'; +import type { ExecutionSession } from './types'; + +describe('manageBranch', () => { + let fakeSession: ExecutionSession; + let mockExec: ReturnType; + + beforeEach(() => { + mockExec = vi.fn(); + // Create a mock session with exec method + fakeSession = { + exec: mockExec, + } as unknown as ExecutionSession; + }); + + describe('when branch exists in both local and remote', () => { + it('should checkout session branch and pull leniently', async () => { + mockExec + .mockResolvedValueOnce({ exitCode: 0 }) // git fetch + .mockResolvedValueOnce({ exitCode: 0 }) // local check (exists) + .mockResolvedValueOnce({ exitCode: 0 }) // remote check (exists) + .mockResolvedValueOnce({ exitCode: 0 }) // checkout + .mockResolvedValueOnce({ exitCode: 0 }); // pull + + await manageBranch(fakeSession, '/workspace', 'feature/foo', false); + + const execCalls = mockExec.mock.calls; + expect(execCalls[3]?.[0]).toContain("git checkout 'feature/foo'"); + expect(execCalls[4]?.[0]).toContain("git pull origin 'feature/foo'"); + expect(execCalls[4]?.[0]).not.toContain('--ff-only'); + }); + + it('should checkout upstream branch without pulling', async () => { + mockExec + .mockResolvedValueOnce({ exitCode: 0 }) // git fetch + .mockResolvedValueOnce({ exitCode: 0 }) // local check (exists) + .mockResolvedValueOnce({ exitCode: 0 }) // remote check (exists) + .mockResolvedValueOnce({ exitCode: 0 }); // checkout + + await manageBranch(fakeSession, '/workspace', 'main', true); + + const execCalls = mockExec.mock.calls; + expect(execCalls[3]?.[0]).toContain("git checkout 'main'"); + // Verify NO pull occurs for upstream branches + expect(mockExec).toHaveBeenCalledTimes(4); // only fetch + 2 checks + checkout + }); + }); + + describe('when branch exists only locally', () => { + it('should checkout local branch without pulling', async () => { + mockExec + .mockResolvedValueOnce({ exitCode: 0 }) // git fetch + .mockResolvedValueOnce({ exitCode: 0 }) // local check (exists) + .mockResolvedValueOnce({ exitCode: 1 }) // remote check (does not exist) + .mockResolvedValueOnce({ exitCode: 0 }); // checkout + + await manageBranch(fakeSession, '/workspace', 'feature/local', false); + + const execCalls = mockExec.mock.calls; + expect(execCalls[3]?.[0]).toContain("git checkout 'feature/local'"); + // Verify pull was not called (should only be 4 calls total) + expect(mockExec).toHaveBeenCalledTimes(4); + }); + }); + + describe('when branch exists only remotely', () => { + it('should create tracking branch', async () => { + mockExec + .mockResolvedValueOnce({ exitCode: 0 }) // git fetch + .mockResolvedValueOnce({ exitCode: 1 }) // local check (does not exist) + .mockResolvedValueOnce({ exitCode: 0 }) // remote check (exists) + .mockResolvedValueOnce({ exitCode: 0 }); // create tracking branch + + await manageBranch(fakeSession, '/workspace', 'feature/remote', false); + + const execCalls = mockExec.mock.calls; + expect(execCalls[3]?.[0]).toContain( + "git checkout -b 'feature/remote' 'origin/feature/remote'" + ); + }); + }); + + describe('when branch does not exist anywhere', () => { + describe('and it is a session branch', () => { + it('should create new local branch', async () => { + mockExec + .mockResolvedValueOnce({ exitCode: 0 }) // git fetch + .mockResolvedValueOnce({ exitCode: 1 }) // local check (does not exist) + .mockResolvedValueOnce({ exitCode: 1 }) // remote check (does not exist) + .mockResolvedValueOnce({ exitCode: 0 }); // create new branch + + await manageBranch(fakeSession, '/workspace', 'session/123', false); + + const execCalls = mockExec.mock.calls; + const createBranchCall = execCalls[3]?.[0] as string; + expect(createBranchCall).toContain("git checkout -b 'session/123'"); + expect(createBranchCall).not.toContain('origin/'); + }); + }); + + describe('and it is an upstream branch', () => { + it('should throw error', async () => { + mockExec + .mockResolvedValueOnce({ exitCode: 0 }) // git fetch + .mockResolvedValueOnce({ exitCode: 1 }) // local check (does not exist) + .mockResolvedValueOnce({ exitCode: 1 }); // remote check (does not exist) + + await expect(manageBranch(fakeSession, '/workspace', 'main', true)).rejects.toThrow( + 'Branch "main" not found in repository' + ); + }); + }); + }); + + describe('error handling', () => { + it('should throw when checkout fails', async () => { + mockExec + .mockResolvedValueOnce({ exitCode: 0 }) // git fetch + .mockResolvedValueOnce({ exitCode: 0 }) // local check (exists) + .mockResolvedValueOnce({ exitCode: 0 }) // remote check (exists) + .mockResolvedValueOnce({ exitCode: 1, stderr: 'checkout error' }); // checkout fails + + await expect(manageBranch(fakeSession, '/workspace', 'feature/foo', false)).rejects.toThrow( + 'Failed to checkout branch feature/foo' + ); + }); + + it('should throw when creating tracking branch fails', async () => { + mockExec + .mockResolvedValueOnce({ exitCode: 0 }) // git fetch + .mockResolvedValueOnce({ exitCode: 1 }) // local check (does not exist) + .mockResolvedValueOnce({ exitCode: 0 }) // remote check (exists) + .mockResolvedValueOnce({ exitCode: 1, stderr: 'create error' }); // create tracking fails + + await expect( + manageBranch(fakeSession, '/workspace', 'feature/remote', false) + ).rejects.toThrow('Failed to create tracking branch feature/remote'); + }); + + it('should warn but not throw when session branch pull fails', async () => { + mockExec + .mockResolvedValueOnce({ exitCode: 0 }) // git fetch + .mockResolvedValueOnce({ exitCode: 0 }) // local check (exists) + .mockResolvedValueOnce({ exitCode: 0 }) // remote check (exists) + .mockResolvedValueOnce({ exitCode: 0 }) // checkout + .mockResolvedValueOnce({ + exitCode: 1, + stderr: 'CONFLICT (content): Merge conflict in file.txt', + }); // pull fails + + // Should not throw for session branches - warnings are logged but we don't assert on them + const result = await manageBranch(fakeSession, '/workspace', 'session/123', false); + + // Verify the function completed successfully despite the pull failure + expect(result).toBe('session/123'); + }); + + it('should continue when fetch fails', async () => { + mockExec + .mockResolvedValueOnce({ exitCode: 1, stderr: 'fetch error' }) // git fetch fails + .mockResolvedValueOnce({ exitCode: 0 }) // local check (exists) + .mockResolvedValueOnce({ exitCode: 1 }) // remote check (does not exist) + .mockResolvedValueOnce({ exitCode: 0 }); // checkout + + const result = await manageBranch(fakeSession, '/workspace', 'feature/local', false); + + // Verify the function continued despite fetch failure and completed successfully + const execCalls = mockExec.mock.calls; + expect(execCalls[3]?.[0]).toContain("git checkout 'feature/local'"); + expect(result).toBe('feature/local'); + }); + }); + + describe('edge cases', () => { + it('should handle branch names with slashes and dashes', async () => { + mockExec + .mockResolvedValueOnce({ exitCode: 0 }) // git fetch + .mockResolvedValueOnce({ exitCode: 0 }) // local check (exists) + .mockResolvedValueOnce({ exitCode: 0 }) // remote check (exists) + .mockResolvedValueOnce({ exitCode: 0 }) // checkout + .mockResolvedValueOnce({ exitCode: 0 }); // pull + + await manageBranch(fakeSession, '/workspace', 'feature/add-new-api', false); + + const execCalls = mockExec.mock.calls; + expect(execCalls[3]?.[0]).toContain("git checkout 'feature/add-new-api'"); + }); + }); + + describe('pull strategy behavior', () => { + it('should NOT pull for upstream branches', async () => { + mockExec + .mockResolvedValueOnce({ exitCode: 0 }) // git fetch + .mockResolvedValueOnce({ exitCode: 0 }) // local check (exists) + .mockResolvedValueOnce({ exitCode: 0 }) // remote check (exists) + .mockResolvedValueOnce({ exitCode: 0 }); // checkout + + await manageBranch(fakeSession, '/workspace', 'develop', true); + + const execCalls = mockExec.mock.calls; + expect(execCalls[3]?.[0]).toContain("git checkout 'develop'"); + // Verify NO pull occurs for upstream branches + expect(mockExec).toHaveBeenCalledTimes(4); // only fetch + 2 checks + checkout + }); + + it('should NOT use --ff-only flag for session branches', async () => { + mockExec + .mockResolvedValueOnce({ exitCode: 0 }) // git fetch + .mockResolvedValueOnce({ exitCode: 0 }) // local check (exists) + .mockResolvedValueOnce({ exitCode: 0 }) // remote check (exists) + .mockResolvedValueOnce({ exitCode: 0 }) // checkout + .mockResolvedValueOnce({ exitCode: 0 }); // pull + + await manageBranch(fakeSession, '/workspace', 'session/456', false); + + const execCalls = mockExec.mock.calls; + const pullCall = execCalls[4]?.[0] as string; + expect(pullCall).toContain("git pull origin 'session/456'"); + expect(pullCall).not.toContain('--ff-only'); + }); + + it('should succeed when branch is already up to date', async () => { + mockExec + .mockResolvedValueOnce({ exitCode: 0 }) // git fetch + .mockResolvedValueOnce({ exitCode: 0 }) // local check (exists) + .mockResolvedValueOnce({ exitCode: 0 }) // remote check (exists) + .mockResolvedValueOnce({ exitCode: 0 }) // checkout + .mockResolvedValueOnce({ + exitCode: 0, + stdout: 'Already up to date.', + }); // pull (no-op) + + const result = await manageBranch(fakeSession, '/workspace', 'feature/stable', false); + + expect(result).toBe('feature/stable'); + }); + }); +}); + +describe('disk space checking', () => { + let fakeSession: ExecutionSession; + let mockExec: ReturnType; + let mockGitCheckout: ReturnType; + + beforeEach(() => { + mockExec = vi.fn(); + mockGitCheckout = vi.fn(); + fakeSession = { + exec: mockExec, + gitCheckout: mockGitCheckout, + } as unknown as ExecutionSession; + }); + + describe('checkDiskSpace direct', () => { + it('should return DiskSpaceResult with low disk space', async () => { + // 1024 MB in bytes, 10000 MB in bytes + mockExec.mockResolvedValueOnce({ + exitCode: 0, + stdout: '1073741824 10485760000\n', + stderr: '', + }); + + const result = await checkDiskSpace(fakeSession); + + expect(result).toBeDefined(); + expect(result.availableMB).toBe(1024); + expect(result.totalMB).toBe(10000); + }); + + it('should return DiskSpaceResult with adequate disk space', async () => { + // 5000 MB in bytes, 10000 MB in bytes + mockExec.mockResolvedValueOnce({ + exitCode: 0, + stdout: '5242880000 10485760000\n', + stderr: '', + }); + + const result = await checkDiskSpace(fakeSession); + + expect(result).toBeDefined(); + expect(result.availableMB).toBe(5000); + expect(result.totalMB).toBe(10000); + }); + + it('should throw when df command fails', async () => { + mockExec.mockResolvedValueOnce({ + exitCode: 1, + stdout: '', + stderr: 'df: command not found', + }); + + await expect(checkDiskSpace(fakeSession)).rejects.toThrow('Disk check failed'); + }); + + it('should throw when df output format is unexpected', async () => { + mockExec.mockResolvedValueOnce({ + exitCode: 0, + stdout: 'unexpected output\n', + stderr: '', + }); + + await expect(checkDiskSpace(fakeSession)).rejects.toThrow('Disk check failed'); + }); + }); + + describe('createSandboxUsageEvent', () => { + it('should create event with correct fields', async () => { + // 3000 MB in bytes, 10000 MB in bytes + mockExec.mockResolvedValueOnce({ + exitCode: 0, + stdout: '3145728000 10485760000\n', + stderr: '', + }); + + const event = await createSandboxUsageEvent(fakeSession, 'session-123'); + + expect(event).toBeDefined(); + expect(event.streamEventType).toBe('sandbox-usage'); + expect(event.availableMB).toBe(3000); + expect(event.totalMB).toBe(10000); + expect(event.isLow).toBe(false); + expect(event.timestamp).toBeDefined(); + expect(event.sessionId).toBe('session-123'); + }); + + it('should set isLow to true when disk space is below threshold', async () => { + // 1000 MB in bytes, 10000 MB in bytes + mockExec.mockResolvedValueOnce({ + exitCode: 0, + stdout: '1048576000 10485760000\n', + stderr: '', + }); + + const event = await createSandboxUsageEvent(fakeSession, 'session-123'); + + expect(event.isLow).toBe(true); + }); + + it('should throw when disk check fails', async () => { + mockExec.mockResolvedValueOnce({ + exitCode: 1, + stdout: '', + stderr: 'error', + }); + + await expect(createSandboxUsageEvent(fakeSession, 'session-123')).rejects.toThrow( + 'Disk check failed' + ); + }); + }); + + describe('cloneGitHubRepo', () => { + it('should clone repository (disk space check is separate)', async () => { + mockExec + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) // git config user.name + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); // git config user.email + + // Mock gitCheckout to succeed + mockGitCheckout.mockResolvedValue({ + success: true, + exitCode: 0, + }); + + await cloneGitHubRepo(fakeSession, '/workspace', 'org/repo'); + + // Verify clone was called + expect(mockGitCheckout).toHaveBeenCalled(); + }); + }); + + describe('cloneGitRepo', () => { + it('should clone repository (disk space check is separate)', async () => { + mockExec + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) // git config user.name + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); // git config user.email + + // Mock gitCheckout to succeed + mockGitCheckout.mockResolvedValue({ + success: true, + exitCode: 0, + }); + + await cloneGitRepo(fakeSession, '/workspace', 'https://example.com/repo.git'); + + // Verify clone was called + expect(mockGitCheckout).toHaveBeenCalled(); + }); + + it('should include token in URL when provided', async () => { + mockExec + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) // git config user.name + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); // git config user.email + + // Mock gitCheckout to succeed + mockGitCheckout.mockResolvedValue({ + success: true, + exitCode: 0, + }); + + await cloneGitRepo(fakeSession, '/workspace', 'https://example.com/repo.git', 'test-token'); + + // Verify gitCheckout was called with URL containing token + expect(mockGitCheckout).toHaveBeenCalledWith( + expect.stringContaining('x-access-token:test-token'), + expect.any(Object) + ); + }); + }); + + describe('LOW_DISK_THRESHOLD_MB export', () => { + it('should export threshold constant as 2048 (2GB)', () => { + expect(LOW_DISK_THRESHOLD_MB).toBe(2048); + }); + }); +}); diff --git a/cloud-agent-next/src/workspace.ts b/cloud-agent-next/src/workspace.ts new file mode 100644 index 0000000000..bc83d963ed --- /dev/null +++ b/cloud-agent-next/src/workspace.ts @@ -0,0 +1,521 @@ +import type { SandboxInstance, ExecutionSession, SystemSandboxUsageEvent } from './types.js'; +import { logger } from './logger.js'; +import { withTimeout } from './utils/timeout.js'; + +/** + * Sanitize a string for use in filesystem paths by replacing forbidden characters with dashes. + * This handles user IDs that may contain characters like `/` or `:` (e.g., `oauth/google:1234`). + */ +export function sanitizeIdForPath(value: string): string { + return value.replace(/[/:]/g, '-'); +} + +// Sanitize a git URL by removing any credentials (username/password) from it. +function sanitizeGitUrlForLogging(gitUrl: string): string { + try { + const url = new URL(gitUrl); + // Remove username and password if present + url.username = ''; + url.password = ''; + return url.toString(); + } catch { + // If URL parsing fails, return as-is (shouldn't happen with validated URLs) + return gitUrl; + } +} + +const SESSION_HOME_ROOT = `/home`; +const KILOCODE_DIR = `.kilocode`; +const CLI_DIR = `${KILOCODE_DIR}/cli`; +const CLI_GLOBAL_TASKS_PATH = `${CLI_DIR}/global/tasks`; +const CLI_LOGS_PATH = `${CLI_DIR}/logs`; + +export function getBaseWorkspacePath( + kilocodeOrganizationId: string | undefined, + userId: string +): string { + const safeUserId = sanitizeIdForPath(userId); + // Personal accounts (no orgId) get simpler path without orgId segment + if (!kilocodeOrganizationId) { + return `/workspace/${safeUserId}`; + } + // Org accounts maintain orgId/userId structure + return `/workspace/${kilocodeOrganizationId}/${safeUserId}`; +} + +export function getSessionWorkspacePath( + kilocodeOrganizationId: string | undefined, + userId: string, + sessionId: string +): string { + return `${getBaseWorkspacePath(kilocodeOrganizationId, userId)}/sessions/${sessionId}`; +} + +export function getSessionHomePath(sessionId: string): string { + return `${SESSION_HOME_ROOT}/${sessionId}`; +} + +export function getKilocodeCliDir(sessionHome: string): string { + return `${sessionHome}/${CLI_DIR}`; +} + +export function getKilocodeLogsDir(sessionHome: string): string { + return `${sessionHome}/${CLI_LOGS_PATH}`; +} + +export function getKilocodeLogFilePath(sessionHome: string): string { + return `${getKilocodeLogsDir(sessionHome)}/cli.txt`; +} + +export function getWrapperLogFilePath(executionId: string): string { + return `/tmp/kilocode-wrapper-${executionId}.log`; +} + +export function getKilocodeTasksDir(sessionHome: string): string { + return `${sessionHome}/${CLI_GLOBAL_TASKS_PATH}`; +} + +export function getKilocodeGlobalDir(sessionHome: string): string { + return `${getKilocodeCliDir(sessionHome)}/global`; +} + +export interface SessionPaths { + workspacePath: string; + sessionHome: string; +} + +export async function setupWorkspace( + sandbox: SandboxInstance, + userId: string, + kilocodeOrganizationId: string | undefined, + sessionId: string +): Promise { + const sessionWorkspacePath = getSessionWorkspacePath(kilocodeOrganizationId, userId, sessionId); + const sessionHome = getSessionHomePath(sessionId); + + try { + await sandbox.mkdir(sessionWorkspacePath, { recursive: true }); + } catch (error) { + throw new Error( + `Failed to create workspace directory: ${error instanceof Error ? error.message : String(error)}` + ); + } + + try { + await sandbox.mkdir(sessionHome, { recursive: true }); + } catch (error) { + throw new Error( + `Failed to prepare session home: ${error instanceof Error ? error.message : String(error)}` + ); + } + + return { + workspacePath: sessionWorkspacePath, + sessionHome, + }; +} + +/** + * Clean up workspace directories for a session. + * Removes both the workspace directory and session home directory. + * + * @param session - Execution session + * @param workspacePath - Path to the session workspace (e.g., /workspace/org/user/sessions/sessionId) + * @param sessionHome - Path to the session home (e.g., /home/sessionId) + */ +export async function cleanupWorkspace( + session: ExecutionSession, + workspacePath: string, + sessionHome: string +): Promise { + logger.setTags({ workspacePath, sessionHome }); + logger.info('Cleaning up workspace directories'); + + try { + // Delete workspace directory + const workspaceResult = await session.exec(`rm -rf '${workspacePath}'`); + if (workspaceResult.exitCode !== 0) { + logger + .withFields({ stderr: workspaceResult.stderr }) + .warn('Failed to delete workspace directory'); + } + + // Delete session home directory + const homeResult = await session.exec(`rm -rf '${sessionHome}'`); + if (homeResult.exitCode !== 0) { + logger + .withFields({ stderr: homeResult.stderr }) + .warn('Failed to delete session home directory'); + } + + logger.info('Workspace cleanup completed'); + } catch (error) { + logger + .withFields({ error: error instanceof Error ? error.message : String(error) }) + .warn('Workspace cleanup encountered an error'); + // Don't throw - cleanup failures shouldn't block session termination + } +} + +export type GitAuthorConfig = { + name: string; + email: string; +}; + +export const LOW_DISK_THRESHOLD_MB = 2048; // 2GB + +/** + * Result of disk space check with structured fields. + */ +export type DiskSpaceResult = { + availableMB: number; + totalMB: number; +}; + +/** + * Check available disk space and total disk space for the container. + * Uses `df` command on the root filesystem which is available in the sandbox environment. + * Always checks `/` since all paths in the container share the same filesystem. + * + * @param session - Execution session to run the check + * @returns Structured disk space result + * @throws Error if disk check fails (command error, parse error, or exception) + */ +export async function checkDiskSpace(session: ExecutionSession): Promise { + // df -B1 gives output in bytes for clean numeric parsing (no M/G/K suffixes) + // --output=avail,size gives available and total space + // Always use "/" since all container paths share the same root filesystem + const result = await session.exec('df -B1 --output=avail,size / | tail -1'); + + if (result.exitCode !== 0) { + logger + .withFields({ exitCode: result.exitCode, stderr: result.stderr }) + .warn('Disk check: df command failed'); + throw new Error('Disk check failed'); + } + + // Output is like "123456789 5000000000" (pure numbers in bytes) + const output = result.stdout.trim(); + const match = output.match(/^(\d+)\s+(\d+)$/); + + if (!match) { + logger.withFields({ output }).warn('Disk check: unexpected df output format'); + throw new Error('Disk check failed'); + } + + const availableBytes = parseInt(match[1], 10); + const totalBytes = parseInt(match[2], 10); + const availableMB = Math.floor(availableBytes / (1024 * 1024)); + const totalMB = Math.floor(totalBytes / (1024 * 1024)); + const isLow = availableMB < LOW_DISK_THRESHOLD_MB; + + if (isLow) { + logger + .withFields({ + availableMB, + totalMB, + thresholdMB: LOW_DISK_THRESHOLD_MB, + }) + .warn('Low disk space detected'); + } + + return { + availableMB, + totalMB, + }; +} + +/** + * Create a sandbox-usage event from disk space check. + * Runs disk space check and returns a ready-to-emit event. + * + * @param session - Execution session to run the check + * @param sessionId - Optional session ID to include in the event + * @returns SystemSandboxUsageEvent ready for emission + * @throws Error if disk check fails + */ +export async function createSandboxUsageEvent( + session: ExecutionSession, + sessionId?: string +): Promise { + const result = await checkDiskSpace(session); + + return { + streamEventType: 'sandbox-usage', + availableMB: result.availableMB, + totalMB: result.totalMB, + isLow: result.availableMB < LOW_DISK_THRESHOLD_MB, + timestamp: new Date().toISOString(), + sessionId, + }; +} + +export async function cloneGitHubRepo( + session: ExecutionSession, + workspacePath: string, + githubRepo: string, + githubToken?: string, + env?: { GITHUB_APP_SLUG?: string; GITHUB_APP_BOT_USER_ID?: string }, + options?: { shallow?: boolean } +): Promise { + // Convert GitHub repo format (org/repo) to full HTTPS URL and delegate to cloneGitRepo + const gitUrl = `https://github.com/${githubRepo}.git`; + + // Build git author config from GitHub App environment variables + let gitAuthor: GitAuthorConfig | undefined; + if (env?.GITHUB_APP_SLUG && env?.GITHUB_APP_BOT_USER_ID) { + gitAuthor = { + name: `${env.GITHUB_APP_SLUG}[bot]`, + email: `${env.GITHUB_APP_BOT_USER_ID}+${env.GITHUB_APP_SLUG}[bot]@users.noreply.github.com`, + }; + } + + await cloneGitRepo(session, workspacePath, gitUrl, githubToken, gitAuthor, options); +} + +export async function cloneGitRepo( + session: ExecutionSession, + workspacePath: string, + gitUrl: string, + gitToken?: string, + gitAuthor?: GitAuthorConfig, + options?: { shallow?: boolean } +): Promise { + // Build URL with token if available (for private repos) + // Use x-access-token format which works across most git providers + let repoUrl = gitUrl; + if (gitToken) { + const url = new URL(gitUrl); + url.username = 'x-access-token'; + url.password = gitToken; + repoUrl = url.toString(); + } + + const sanitizedGitUrl = sanitizeGitUrlForLogging(gitUrl); + const shallow = options?.shallow ?? false; + logger.setTags({ gitUrl: sanitizedGitUrl, workspacePath, shallow }); + logger.info('Cloning generic git repository'); + + try { + // Git clone with 2-minute timeout to prevent indefinite hangs + const CLONE_TIMEOUT_MS = 120_000; // 2 minutes + const result = await withTimeout( + session.gitCheckout(repoUrl, { + targetDir: workspacePath, + // Use depth: 1 for shallow clones (faster, less disk space) + ...(shallow && { depth: 1 }), + }), + CLONE_TIMEOUT_MS, + `Git clone timed out after ${CLONE_TIMEOUT_MS / 1000} seconds for ${sanitizedGitUrl}` + ); + + if (!result.success) { + throw new Error(`gitCheckout failed with exit code ${result.exitCode ?? 'unknown'}`); + } + + const authorName = gitAuthor?.name ?? 'Kilo Code Cloud'; + const authorEmail = gitAuthor?.email ?? 'agent@kilocode.ai'; + + await session.exec(`cd ${workspacePath} && git config user.name "${authorName}"`); + await session.exec(`cd ${workspacePath} && git config user.email "${authorEmail}"`); + + logger.info('Successfully cloned generic git repository'); + } catch (err) { + // Log actual error for debugging + logger.error('Git clone failed', { + error: err instanceof Error ? err.message : String(err), + gitUrl: sanitizedGitUrl, + }); + // Throw generic error to avoid leaking token in response + throw new Error(`Failed to clone repository from ${sanitizedGitUrl}`); + } +} + +/** + * Update the git remote origin URL to include a new token. + * This is needed when the git token changes and we need to push/pull. + * Uses the same x-access-token format as cloneGitRepo() for consistency. + * + * @param session - Execution session + * @param workspacePath - Path to the git repository + * @param gitUrl - Full git URL (e.g., https://github.com/org/repo.git) + * @param gitToken - New git token for authentication + */ +export async function updateGitRemoteToken( + session: ExecutionSession, + workspacePath: string, + gitUrl: string, + gitToken: string +): Promise { + // Build new URL with token embedded (same format as cloneGitRepo) + const newUrl = new URL(gitUrl); + newUrl.username = 'x-access-token'; + newUrl.password = gitToken; + + const sanitizedGitUrl = sanitizeGitUrlForLogging(gitUrl); + logger.setTags({ workspacePath, gitUrl: sanitizedGitUrl }); + logger.info('Updating git remote URL with new token'); + + const result = await session.exec( + `cd '${workspacePath}' && git remote set-url origin '${newUrl.toString()}'` + ); + + if (result.exitCode !== 0) { + // Log actual error for debugging (sanitized via structured logging) + logger.error('Git remote update failed', { + exitCode: result.exitCode, + }); + // Throw generic error to avoid leaking token in response + throw new Error(`Failed to update git remote URL`); + } + + logger.info('Successfully updated git remote URL'); +} + +async function gitFetch(session: ExecutionSession, workspacePath: string): Promise { + const result = await session.exec(`cd ${workspacePath} && git fetch origin`); + if (result.exitCode !== 0) { + logger.withFields({ stderr: result.stderr }).warn('Git fetch failed'); + } +} + +async function branchExistsLocally( + session: ExecutionSession, + workspacePath: string, + branchName: string +): Promise { + const result = await session.exec( + `cd ${workspacePath} && git rev-parse --verify '${branchName}' 2>/dev/null` + ); + return result.exitCode === 0; +} + +async function branchExistsRemotely( + session: ExecutionSession, + workspacePath: string, + branchName: string +): Promise { + const result = await session.exec( + `cd ${workspacePath} && git rev-parse --verify 'origin/${branchName}' 2>/dev/null` + ); + return result.exitCode === 0; +} + +async function checkoutExistingBranch( + session: ExecutionSession, + workspacePath: string, + branchName: string +): Promise { + const result = await session.exec(`cd ${workspacePath} && git checkout '${branchName}'`); + if (result.exitCode !== 0) { + throw new Error(`Failed to checkout branch ${branchName}: ${result.stderr || result.stdout}`); + } +} + +async function pullLatestChangesLenient( + session: ExecutionSession, + workspacePath: string, + branchName: string +): Promise { + const result = await session.exec(`cd ${workspacePath} && git pull origin '${branchName}'`); + if (result.exitCode !== 0) { + // Session branches might have unpushed work or conflicts, just warn + logger + .withFields({ branchName, stderr: result.stderr }) + .warn('Could not pull branch, continuing with local version'); + } +} + +async function createTrackingBranch( + session: ExecutionSession, + workspacePath: string, + branchName: string +): Promise { + const result = await session.exec( + `cd ${workspacePath} && git checkout -b '${branchName}' 'origin/${branchName}'` + ); + if (result.exitCode !== 0) { + throw new Error( + `Failed to create tracking branch ${branchName}: ${result.stderr || result.stdout}` + ); + } +} + +async function createNewBranch( + session: ExecutionSession, + workspacePath: string, + branchName: string +): Promise { + const result = await session.exec(`cd ${workspacePath} && git checkout -b '${branchName}'`); + if (result.exitCode !== 0) { + throw new Error(`Failed to create branch ${branchName}: ${result.stderr || result.stdout}`); + } +} + +/** + * Manage branch checkout/creation. + * + * This function handles both upstream and session branches with different strategies: + * + * Upstream branches (isUpstreamBranch=true): + * - MUST exist remotely (error if not found) + * - Fetch + checkout (creates tracking branch if needed) * + * Session branches (isUpstreamBranch=false): + * - Try remote first, create fresh if not found + * - Checkout + lenient pull to sync with remote + * - Allows for unpushed work or force-pushes + * + * @param session - Execution session + * @param workspacePath - Path to the git repository + * @param branchName - Name of the branch to check out/create + * @param isUpstreamBranch - Whether this is an upstream branch (must exist remotely) + */ +export async function manageBranch( + session: ExecutionSession, + workspacePath: string, + branchName: string, + isUpstreamBranch: boolean = false +): Promise { + logger.setTags({ branchName, workspacePath }); + logger.withTags({ isUpstream: isUpstreamBranch }).info('Managing branch'); + + // Fetch latest refs from remote + await gitFetch(session, workspacePath); + + // Check branch existence in parallel + const [existsLocally, existsRemotely] = await Promise.all([ + branchExistsLocally(session, workspacePath, branchName), + branchExistsRemotely(session, workspacePath, branchName), + ]); + + logger.withTags({ existsLocally, existsRemotely }).debug('Branch status'); + + // Four explicit cases + if (existsLocally && existsRemotely) { + // Case 1: Exists in both places - checkout and sync + await checkoutExistingBranch(session, workspacePath, branchName); + + // Only pull for session branches, not upstream + if (!isUpstreamBranch) { + await pullLatestChangesLenient(session, workspacePath, branchName); + } + // For upstream: fetch already happened, checkout is done, leave as-is + } else if (existsLocally && !existsRemotely) { + // Case 2: Only exists locally - just checkout + await checkoutExistingBranch(session, workspacePath, branchName); + } else if (!existsLocally && existsRemotely) { + // Case 3: Only exists remotely - create tracking branch + await createTrackingBranch(session, workspacePath, branchName); + } else { + // Case 4: Doesn't exist anywhere + if (isUpstreamBranch) { + throw new Error( + `Branch "${branchName}" not found in repository. Please ensure the branch exists remotely.` + ); + } + await createNewBranch(session, workspacePath, branchName); + } + + logger.debug('Successfully on branch'); + return branchName; +} diff --git a/cloud-agent-next/test/env.d.ts b/cloud-agent-next/test/env.d.ts new file mode 100644 index 0000000000..3a48a709b0 --- /dev/null +++ b/cloud-agent-next/test/env.d.ts @@ -0,0 +1,10 @@ +// Type declarations for cloudflare:test module +// This enables type-safe access to env bindings in integration tests + +import type { Env } from '../src/types'; + +declare module 'cloudflare:test' { + // ProvidedEnv extends your worker's Env interface + // This gives you typed access to bindings like env.CLOUD_AGENT_SESSION + interface ProvidedEnv extends Env {} +} diff --git a/cloud-agent-next/test/integration/session/events.test.ts b/cloud-agent-next/test/integration/session/events.test.ts new file mode 100644 index 0000000000..5a414ba961 --- /dev/null +++ b/cloud-agent-next/test/integration/session/events.test.ts @@ -0,0 +1,224 @@ +/** + * Integration tests for the events query module. + * + * Uses @cloudflare/vitest-pool-workers to test against real SQLite in DOs. + * Each test gets isolated storage automatically. + * + * These tests use the /ingest WebSocket to write events and /stream to read them, + * since the eventQueries are internal to the DO and not exposed via RPC. + */ + +import { env, runInDurableObject, listDurableObjectIds } from 'cloudflare:test'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { createEventQueries } from '../../../src/session/queries/events.js'; +import type { EventId } from '../../../src/types/ids.js'; + +describe('Event Storage', () => { + beforeEach(async () => { + // Verify previous test's DOs are automatically removed (isolation) + const ids = await listDurableObjectIds(env.CLOUD_AGENT_SESSION); + expect(ids).toHaveLength(0); + }); + + it('should insert event with RETURNING id', async () => { + const id = env.CLOUD_AGENT_SESSION.idFromName('user_1:sess_1'); + const stub = env.CLOUD_AGENT_SESSION.get(id); + + // Access the DO directly and call queries on its sql storage + // The DO auto-runs migrations in constructor via blockConcurrencyWhile + const result = await runInDurableObject(stub, async (_instance, state) => { + // Create a fresh queries instance using the same storage + const events = createEventQueries(state.storage.sql); + const eventId = events.insert({ + executionId: 'exec_123', + sessionId: 'sess_1', + streamEventType: 'output', + payload: JSON.stringify({ text: 'hello world' }), + timestamp: Date.now(), + }); + + return { eventId }; + }); + + expect(result.eventId).toBeDefined(); + expect(result.eventId).toBeGreaterThan(0); + }); + + it('should find events by filters with various combinations', async () => { + const id = env.CLOUD_AGENT_SESSION.idFromName('user_1:sess_2'); + const stub = env.CLOUD_AGENT_SESSION.get(id); + + const result = await runInDurableObject(stub, async (_instance, state) => { + const events = createEventQueries(state.storage.sql); + const now = Date.now(); + + // Insert multiple events + events.insert({ + executionId: 'exec_1', + sessionId: 'sess_1', + streamEventType: 'output', + payload: JSON.stringify({ text: 'output 1' }), + timestamp: now - 5000, + }); + events.insert({ + executionId: 'exec_1', + sessionId: 'sess_1', + streamEventType: 'error', + payload: JSON.stringify({ message: 'error 1' }), + timestamp: now - 4000, + }); + events.insert({ + executionId: 'exec_2', + sessionId: 'sess_1', + streamEventType: 'output', + payload: JSON.stringify({ text: 'output 2' }), + timestamp: now - 3000, + }); + events.insert({ + executionId: 'exec_1', + sessionId: 'sess_1', + streamEventType: 'complete', + payload: JSON.stringify({ exitCode: 0 }), + timestamp: now - 2000, + }); + + // Filter by executionId + const byExecution = events.findByFilters({ executionIds: ['exec_1'] }); + + // Filter by eventType + const byType = events.findByFilters({ eventTypes: ['output'] }); + + // Filter by multiple executionIds + const byMultiExec = events.findByFilters({ executionIds: ['exec_1', 'exec_2'] }); + + // Filter by time range + const byTimeRange = events.findByFilters({ + startTime: now - 4500, + endTime: now - 2500, + }); + + // Filter with limit + const withLimit = events.findByFilters({ limit: 2 }); + + // Combined filters + const combined = events.findByFilters({ + executionIds: ['exec_1'], + eventTypes: ['output', 'error'], + }); + + return { byExecution, byType, byMultiExec, byTimeRange, withLimit, combined }; + }); + + // By execution: 3 events for exec_1 + expect(result.byExecution).toHaveLength(3); + expect(result.byExecution.every(e => e.execution_id === 'exec_1')).toBe(true); + + // By type: 2 output events + expect(result.byType).toHaveLength(2); + expect(result.byType.every(e => e.stream_event_type === 'output')).toBe(true); + + // By multiple executions: all 4 events + expect(result.byMultiExec).toHaveLength(4); + + // By time range: 2 events (error at -4000 and output 2 at -3000) + expect(result.byTimeRange).toHaveLength(2); + + // With limit: only 2 events + expect(result.withLimit).toHaveLength(2); + + // Combined (exec_1 + output/error): 2 events + expect(result.combined).toHaveLength(2); + }); + + it('should delete events older than timestamp', async () => { + const id = env.CLOUD_AGENT_SESSION.idFromName('user_1:sess_3'); + const stub = env.CLOUD_AGENT_SESSION.get(id); + + const result = await runInDurableObject(stub, async (_instance, state) => { + const events = createEventQueries(state.storage.sql); + const now = Date.now(); + + // Insert events at different times + const oldTimestamp = now - 100 * 24 * 60 * 60 * 1000; // 100 days ago + const recentTimestamp = now - 5 * 24 * 60 * 60 * 1000; // 5 days ago + + events.insert({ + executionId: 'exec_old', + sessionId: 'sess_1', + streamEventType: 'output', + payload: JSON.stringify({ text: 'old event' }), + timestamp: oldTimestamp, + }); + events.insert({ + executionId: 'exec_recent', + sessionId: 'sess_1', + streamEventType: 'output', + payload: JSON.stringify({ text: 'recent event' }), + timestamp: recentTimestamp, + }); + + // Count before cleanup + const beforeCount = events.findByFilters({}).length; + + // Delete events older than 90 days + const cutoff = now - 90 * 24 * 60 * 60 * 1000; + const deletedCount = events.deleteOlderThan(cutoff); + + // Get remaining events + const remaining = events.findByFilters({}); + + return { beforeCount, deletedCount, remaining }; + }); + + expect(result.beforeCount).toBe(2); + expect(result.deletedCount).toBe(1); + expect(result.remaining).toHaveLength(1); + expect(result.remaining[0].execution_id).toBe('exec_recent'); + }); + + it('should maintain sequential event ordering (IDs always increase)', async () => { + const id = env.CLOUD_AGENT_SESSION.idFromName('user_1:sess_4'); + const stub = env.CLOUD_AGENT_SESSION.get(id); + + const result = await runInDurableObject(stub, async (_instance, state) => { + const events = createEventQueries(state.storage.sql); + const now = Date.now(); + + // Insert events in sequence + const ids: EventId[] = []; + for (let i = 0; i < 5; i++) { + const eventId = events.insert({ + executionId: 'exec_1', + sessionId: 'sess_1', + streamEventType: 'output', + payload: JSON.stringify({ text: `event ${i}` }), + timestamp: now + i * 100, + }); + ids.push(eventId); + } + + // Query all events and verify order + const allEvents = events.findByFilters({}); + + // Query with fromId to skip first 2 (exclusive replay) + const fromId2 = events.findByFilters({ fromId: ids[1] }); + + return { ids, allEvents, fromId2 }; + }); + + // IDs should be sequential + for (let i = 1; i < result.ids.length; i++) { + expect(result.ids[i]).toBeGreaterThan(result.ids[i - 1]); + } + + // All events should be returned in ascending ID order + expect(result.allEvents).toHaveLength(5); + for (let i = 1; i < result.allEvents.length; i++) { + expect(result.allEvents[i].id).toBeGreaterThan(result.allEvents[i - 1].id); + } + + // fromId 2 should return events 3, 4, 5 (exclusive) + expect(result.fromId2).toHaveLength(3); + expect(result.fromId2[0].id).toBeGreaterThan(result.ids[1]); + }); +}); diff --git a/cloud-agent-next/test/integration/session/leases.test.ts b/cloud-agent-next/test/integration/session/leases.test.ts new file mode 100644 index 0000000000..5d3e25f80f --- /dev/null +++ b/cloud-agent-next/test/integration/session/leases.test.ts @@ -0,0 +1,139 @@ +/** + * Integration tests for the leases query module. + * + * Uses @cloudflare/vitest-pool-workers to test against real SQLite in DOs. + * Each test gets isolated storage automatically. + * + * Note: Migrations run automatically in the DO constructor via blockConcurrencyWhile(), + * so the DO is fully initialized when we get the stub. We use the DO's RPC methods + * which internally access the pre-initialized query modules. + */ + +import { env, runInDurableObject, listDurableObjectIds } from 'cloudflare:test'; +import { describe, it, expect, beforeEach } from 'vitest'; +import type { ExecutionId } from '../../../src/types/ids.js'; + +describe('Lease Acquisition', () => { + beforeEach(async () => { + // Verify previous test's DOs are automatically removed (isolation) + const ids = await listDurableObjectIds(env.CLOUD_AGENT_SESSION); + expect(ids).toHaveLength(0); + }); + + it('should acquire lease on first attempt', async () => { + const id = env.CLOUD_AGENT_SESSION.idFromName('user_1:sess_1'); + const stub = env.CLOUD_AGENT_SESSION.get(id); + + // Use the DO's RPC method directly + const result = await runInDurableObject(stub, async instance => { + return instance.acquireLease('exec_123' as ExecutionId, 'msg_1', 'lease_abc'); + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.acquired).toBe(true); + expect(result.value.expiresAt).toBeGreaterThan(Date.now()); + } + }); + + it('should reject duplicate lease acquisition when lease is held', async () => { + const id = env.CLOUD_AGENT_SESSION.idFromName('user_1:sess_2'); + const stub = env.CLOUD_AGENT_SESSION.get(id); + + const result = await runInDurableObject(stub, async instance => { + // First acquisition succeeds + const first = instance.acquireLease('exec_123' as ExecutionId, 'msg_1', 'lease_abc'); + + // Second acquisition should fail (lease still held) + const second = instance.acquireLease('exec_123' as ExecutionId, 'msg_2', 'lease_xyz'); + + return { first, second }; + }); + + expect(result.first.ok).toBe(true); + expect(result.second.ok).toBe(false); + if (!result.second.ok && result.second.error.code === 'ALREADY_HELD') { + expect(result.second.error.holder).toBe('lease_abc'); + } + }); + + it('should allow lease acquisition after expiration', async () => { + const id = env.CLOUD_AGENT_SESSION.idFromName('user_1:sess_3'); + const stub = env.CLOUD_AGENT_SESSION.get(id); + + // We can't easily simulate time passing in tests, so instead we'll + // test that acquiring a lease works, then release it, and acquire again + const result = await runInDurableObject(stub, async instance => { + // First: acquire and release + const first = instance.acquireLease('exec_123' as ExecutionId, 'msg_1', 'lease_abc'); + if (first.ok) { + instance.releaseLease('exec_123' as ExecutionId, 'lease_abc'); + } + + // Second: should succeed after release + const second = instance.acquireLease('exec_123' as ExecutionId, 'msg_2', 'lease_xyz'); + + return { first, second }; + }); + + expect(result.first.ok).toBe(true); + expect(result.second.ok).toBe(true); + }); + + it('should extend lease with heartbeat (correct leaseId)', async () => { + const id = env.CLOUD_AGENT_SESSION.idFromName('user_1:sess_4'); + const stub = env.CLOUD_AGENT_SESSION.get(id); + + const result = await runInDurableObject(stub, async instance => { + // Acquire lease + const acquire = instance.acquireLease('exec_123' as ExecutionId, 'msg_1', 'lease_abc'); + + // Extend with correct leaseId (should succeed) + const extended = instance.extendLease('exec_123' as ExecutionId, 'lease_abc'); + + return { acquire, extended }; + }); + + expect(result.acquire.ok).toBe(true); + expect(result.extended).toBe(true); + }); + + it('should reject extension with wrong leaseId', async () => { + const id = env.CLOUD_AGENT_SESSION.idFromName('user_1:sess_5'); + const stub = env.CLOUD_AGENT_SESSION.get(id); + + const result = await runInDurableObject(stub, async instance => { + // Acquire lease + const acquire = instance.acquireLease('exec_123' as ExecutionId, 'msg_1', 'lease_abc'); + + // Try to extend with wrong leaseId (should fail) + const extended = instance.extendLease('exec_123' as ExecutionId, 'wrong_lease_id'); + + return { acquire, extended }; + }); + + expect(result.acquire.ok).toBe(true); + expect(result.extended).toBe(false); + }); + + it('should release lease on completion', async () => { + const id = env.CLOUD_AGENT_SESSION.idFromName('user_1:sess_6'); + const stub = env.CLOUD_AGENT_SESSION.get(id); + + const result = await runInDurableObject(stub, async instance => { + // Acquire lease + instance.acquireLease('exec_123' as ExecutionId, 'msg_1', 'lease_abc'); + + // Release the lease + const released = instance.releaseLease('exec_123' as ExecutionId, 'lease_abc'); + + // Another consumer can now acquire + const newAcquire = instance.acquireLease('exec_123' as ExecutionId, 'msg_2', 'lease_xyz'); + + return { released, newAcquire }; + }); + + expect(result.released).toBe(true); + expect(result.newAcquire.ok).toBe(true); + }); +}); diff --git a/cloud-agent-next/test/integration/session/start-execution-v2.test.ts b/cloud-agent-next/test/integration/session/start-execution-v2.test.ts new file mode 100644 index 0000000000..d9915b1537 --- /dev/null +++ b/cloud-agent-next/test/integration/session/start-execution-v2.test.ts @@ -0,0 +1,120 @@ +/** + * Integration tests for DO-orchestrated V2 execution start. + */ + +import { env, runInDurableObject } from 'cloudflare:test'; +import { describe, it, expect } from 'vitest'; +import type { ExecutionId } from '../../../src/types/ids.js'; +import type { StartExecutionV2Request } from '../../../src/queue/types.js'; + +describe('CloudAgentSession.startExecutionV2', () => { + it('returns EXECUTION_IN_PROGRESS when an active execution exists', async () => { + const userId = 'user_exec_plan' as const; + const sessionId = 'agent_exec_plan' as const; + const doId = env.CLOUD_AGENT_SESSION.idFromName(`${userId}:${sessionId}`); + const stub = env.CLOUD_AGENT_SESSION.get(doId); + + const result = await runInDurableObject(stub, async instance => { + let capturedPlan: any = null; + (instance as any).orchestrator = { + execute: async (plan: any) => { + capturedPlan = plan; + return { messageId: plan.executionId, kiloSessionId: 'kilo_test' }; + }, + }; + + const now = Date.now(); + await instance.updateMetadata({ + version: now, + sessionId, + userId, + timestamp: now, + }); + + const activeId = 'exec_active' as ExecutionId; + await instance.addExecution({ + executionId: activeId, + mode: 'build', + streamingMode: 'websocket', + ingestToken: activeId, + }); + await instance.setActiveExecution(activeId); + + const request: StartExecutionV2Request = { + kind: 'initiate', + userId, + authToken: 'token-init', + prompt: 'do the thing', + mode: 'build', + model: 'test-model', + gitUrl: 'https://example.com/repo.git', + gitToken: 'git-token', + }; + + const startResult = await instance.startExecutionV2(request); + return { startResult, plan: capturedPlan }; + }); + + expect(result.startResult.success).toBe(false); + if (result.startResult.success) return; + + expect(result.startResult.code).toBe('EXECUTION_IN_PROGRESS'); + expect(result.startResult.activeExecutionId).toBe('exec_active'); + expect(result.plan).toBeNull(); + }); + + it('builds a launch plan for follow-up and applies token overrides', async () => { + const userId = 'user_exec_followup' as const; + const sessionId = 'agent_exec_followup' as const; + const doId = env.CLOUD_AGENT_SESSION.idFromName(`${userId}:${sessionId}`); + const stub = env.CLOUD_AGENT_SESSION.get(doId); + + const result = await runInDurableObject(stub, async instance => { + let capturedPlan: any = null; + (instance as any).orchestrator = { + execute: async (plan: any) => { + capturedPlan = plan; + return { messageId: plan.executionId, kiloSessionId: 'kilo_test' }; + }, + }; + + await instance.prepare({ + sessionId, + userId, + orgId: 'org_test', + kiloSessionId: '88888888-8888-4888-8888-888888888888', + prompt: 'prepared prompt', + mode: 'build', + model: 'test-model', + kilocodeToken: 'token-followup', + gitUrl: 'https://example.com/repo.git', + gitToken: 'old-token', + }); + + await instance.tryInitiate(); + + const request: StartExecutionV2Request = { + kind: 'followup', + userId, + prompt: 'followup prompt', + tokenOverrides: { + gitToken: 'new-token', + }, + }; + + const startResult = await instance.startExecutionV2(request); + const metadata = await instance.getMetadata(); + return { startResult, metadata, plan: capturedPlan }; + }); + + expect(result.startResult.success).toBe(true); + if (!result.startResult.success) return; + + expect(result.startResult.status).toBe('started'); + expect(result.metadata?.gitToken).toBe('new-token'); + expect(result.plan).toBeTruthy(); + expect(result.plan.workspace.shouldPrepare).toBe(false); + expect(result.plan.workspace.resumeContext.kilocodeToken).toBe('token-followup'); + expect(result.plan.workspace.resumeContext.gitToken).toBe('new-token'); + }); +}); diff --git a/cloud-agent-next/test/test-worker.ts b/cloud-agent-next/test/test-worker.ts new file mode 100644 index 0000000000..a7944fa78f --- /dev/null +++ b/cloud-agent-next/test/test-worker.ts @@ -0,0 +1,48 @@ +/** + * Test worker entry point. + * + * This is a separate worker entry for integration tests that excludes + * the Sandbox DO (which requires @cloudflare/containers at runtime). + * + * The tests only need CloudAgentSession for WebSocket testing. + * This worker intentionally does NOT import any sandbox-related code + * to avoid the @cloudflare/sandbox import chain. + */ + +import type { CloudAgentSession } from '../src/persistence/CloudAgentSession.js'; + +// Re-export CloudAgentSession for DO binding +export { CloudAgentSession } from '../src/persistence/CloudAgentSession'; + +// Minimal Env type for tests +type TestEnv = { + CLOUD_AGENT_SESSION: DurableObjectNamespace; +}; + +export default { + async fetch(request: Request, env: TestEnv): Promise { + const url = new URL(request.url); + + // Handle /stream WebSocket endpoint + if (url.pathname === '/stream') { + const upgradeHeader = request.headers.get('Upgrade'); + if (upgradeHeader !== 'websocket') { + return new Response('Expected WebSocket upgrade', { status: 426 }); + } + + const sessionId = url.searchParams.get('sessionId'); + const userId = url.searchParams.get('userId') ?? 'test_user'; + + if (!sessionId) { + return new Response('Missing sessionId parameter', { status: 400 }); + } + + const doId = env.CLOUD_AGENT_SESSION.idFromName(`${userId}:${sessionId}`); + const stub = env.CLOUD_AGENT_SESSION.get(doId); + + return stub.fetch(request); + } + + return new Response('Not Found', { status: 404 }); + }, +}; diff --git a/cloud-agent-next/test/tsconfig.json b/cloud-agent-next/test/tsconfig.json new file mode 100644 index 0000000000..8b2faaef61 --- /dev/null +++ b/cloud-agent-next/test/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["@cloudflare/vitest-pool-workers"] + }, + "include": ["**/*.ts", "env.d.ts", "../worker-configuration.d.ts"] +} diff --git a/cloud-agent-next/test/unit/execution/orchestrator.test.ts b/cloud-agent-next/test/unit/execution/orchestrator.test.ts new file mode 100644 index 0000000000..0efcf23493 --- /dev/null +++ b/cloud-agent-next/test/unit/execution/orchestrator.test.ts @@ -0,0 +1,440 @@ +/** + * Unit tests for ExecutionOrchestrator types and ExecutionError. + * + * Note: The ExecutionOrchestrator class itself requires complex mocking of + * cloudflare-specific types (@cloudflare/containers, DurableObjectStub, etc.) + * which have module resolution issues in vitest's Node environment. + * + * This file focuses on testing: + * - ExecutionError class and factory methods + * - isExecutionError type guard + * - ExecutionPlan type structure + * - WorkspacePlan variants + * - Error code classification + * + * Full integration testing of ExecutionOrchestrator is done via + * integration tests that run in the Cloudflare Workers environment. + */ + +import { describe, expect, it } from 'vitest'; +import { + ExecutionError, + isExecutionError, + type RetryableErrorCode, + type ConflictErrorCode, + type PermanentErrorCode, +} from '../../../src/execution/errors.js'; +import type { + ExecutionPlan, + ExecutionResult, + WorkspacePlan, + ModelConfig, + ResumeContext, + InitContext, + ExistingSessionMetadata, + WrapperPlan, +} from '../../../src/execution/types.js'; + +// --------------------------------------------------------------------------- +// Test Helpers +// --------------------------------------------------------------------------- + +const createResumeWorkspacePlan = (): WorkspacePlan => ({ + shouldPrepare: false, + sandboxId: 'sandbox_123', + resumeContext: { + kiloSessionId: 'kilo_sess_456', + workspacePath: '/workspace/project', + kilocodeToken: 'kilo_token', + branchName: 'feature-branch', + }, +}); + +const createInitWorkspacePlan = (): WorkspacePlan => ({ + shouldPrepare: true, + sandboxId: 'sandbox_123', + initContext: { + githubRepo: 'owner/repo', + githubToken: 'gh_token', + kilocodeToken: 'kilo_token', + kilocodeModel: 'anthropic/claude-sonnet-4-20250514', + }, +}); + +const createPreparedWorkspacePlan = (): WorkspacePlan => ({ + shouldPrepare: true, + sandboxId: 'sandbox_123', + initContext: { + githubRepo: 'owner/repo', + githubToken: 'gh_token', + kilocodeToken: 'kilo_token', + isPreparedSession: true, + kiloSessionId: 'kilo_sess_existing', + }, + existingMetadata: { + workspacePath: '/workspace/project', + kiloSessionId: 'kilo_sess_existing', + branchName: 'main', + sandboxId: 'sandbox_123', + sessionHome: '/home/agent', + }, +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('ExecutionError', () => { + // ------------------------------------------------------------------------- + // Factory Methods - Retryable Errors + // ------------------------------------------------------------------------- + + describe('retryable error factory methods', () => { + it('sandboxConnectFailed creates retryable error', () => { + const error = ExecutionError.sandboxConnectFailed('Connection refused'); + + expect(error.code).toBe('SANDBOX_CONNECT_FAILED'); + expect(error.retryable).toBe(true); + expect(error.message).toBe('Connection refused'); + expect(error.name).toBe('ExecutionError'); + }); + + it('workspaceSetupFailed creates retryable error', () => { + const error = ExecutionError.workspaceSetupFailed('Git clone failed'); + + expect(error.code).toBe('WORKSPACE_SETUP_FAILED'); + expect(error.retryable).toBe(true); + }); + + it('kiloServerFailed creates retryable error', () => { + const error = ExecutionError.kiloServerFailed('Server starting'); + + expect(error.code).toBe('KILO_SERVER_FAILED'); + expect(error.retryable).toBe(true); + }); + + it('wrapperStartFailed creates retryable error', () => { + const error = ExecutionError.wrapperStartFailed('Wrapper timeout'); + + expect(error.code).toBe('WRAPPER_START_FAILED'); + expect(error.retryable).toBe(true); + }); + }); + + // ------------------------------------------------------------------------- + // Factory Methods - Conflict Errors + // ------------------------------------------------------------------------- + + describe('conflict error factory methods', () => { + it('executionInProgress creates conflict error', () => { + const error = ExecutionError.executionInProgress('exec_active'); + + expect(error.code).toBe('EXECUTION_IN_PROGRESS'); + expect(error.retryable).toBe(false); + expect(error.activeExecutionId).toBe('exec_active'); + expect(error.message).toContain('exec_active'); + }); + }); + + // ------------------------------------------------------------------------- + // Factory Methods - Permanent Errors + // ------------------------------------------------------------------------- + + describe('permanent error factory methods', () => { + it('invalidRequest creates non-retryable error', () => { + const error = ExecutionError.invalidRequest('Missing field'); + + expect(error.code).toBe('INVALID_REQUEST'); + expect(error.retryable).toBe(false); + }); + + it('sessionNotFound creates non-retryable error', () => { + const error = ExecutionError.sessionNotFound('session_abc'); + + expect(error.code).toBe('SESSION_NOT_FOUND'); + expect(error.retryable).toBe(false); + expect(error.message).toContain('session_abc'); + }); + + it('wrapperJobConflict creates non-retryable error', () => { + const error = ExecutionError.wrapperJobConflict('Already running'); + + expect(error.code).toBe('WRAPPER_JOB_CONFLICT'); + expect(error.retryable).toBe(false); + }); + }); + + // ------------------------------------------------------------------------- + // Cause Preservation + // ------------------------------------------------------------------------- + + describe('cause preservation', () => { + it('preserves cause for debugging', () => { + const originalError = new Error('Original problem'); + const error = ExecutionError.sandboxConnectFailed('Wrapped', originalError); + + expect(error.cause).toBe(originalError); + }); + + it('works without cause', () => { + const error = ExecutionError.sandboxConnectFailed('No cause'); + + expect(error.cause).toBeUndefined(); + }); + }); + + // ------------------------------------------------------------------------- + // Error Code Classification + // ------------------------------------------------------------------------- + + describe('error code classification', () => { + const retryableCodes: RetryableErrorCode[] = [ + 'SANDBOX_CONNECT_FAILED', + 'WORKSPACE_SETUP_FAILED', + 'KILO_SERVER_FAILED', + 'WRAPPER_START_FAILED', + ]; + + it.each(retryableCodes)('%s is retryable', code => { + let error: ExecutionError; + switch (code) { + case 'SANDBOX_CONNECT_FAILED': + error = ExecutionError.sandboxConnectFailed('test'); + break; + case 'WORKSPACE_SETUP_FAILED': + error = ExecutionError.workspaceSetupFailed('test'); + break; + case 'KILO_SERVER_FAILED': + error = ExecutionError.kiloServerFailed('test'); + break; + case 'WRAPPER_START_FAILED': + error = ExecutionError.wrapperStartFailed('test'); + break; + } + + expect(error.retryable).toBe(true); + }); + + it('EXECUTION_IN_PROGRESS is not retryable', () => { + const error = ExecutionError.executionInProgress('exec_123'); + expect(error.retryable).toBe(false); + }); + + it('INVALID_REQUEST is not retryable', () => { + const error = ExecutionError.invalidRequest('Bad input'); + expect(error.retryable).toBe(false); + }); + }); +}); + +// --------------------------------------------------------------------------- +// isExecutionError Type Guard +// --------------------------------------------------------------------------- + +describe('isExecutionError', () => { + it('returns true for ExecutionError instances', () => { + const error = ExecutionError.sandboxConnectFailed('test'); + expect(isExecutionError(error)).toBe(true); + }); + + it('returns false for regular Error', () => { + const error = new Error('test'); + expect(isExecutionError(error)).toBe(false); + }); + + it('returns false for non-error values', () => { + expect(isExecutionError(null)).toBe(false); + expect(isExecutionError(undefined)).toBe(false); + expect(isExecutionError('string')).toBe(false); + expect(isExecutionError({ code: 'FAKE' })).toBe(false); + expect(isExecutionError(42)).toBe(false); + }); + + it('returns false for Error subclass with code property', () => { + class CustomError extends Error { + code = 'SANDBOX_CONNECT_FAILED'; + } + const error = new CustomError('test'); + expect(isExecutionError(error)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// WorkspacePlan Type Tests +// --------------------------------------------------------------------------- + +describe('WorkspacePlan types', () => { + it('distinguishes resume vs init via shouldPrepare', () => { + const resumePlan = createResumeWorkspacePlan(); + const initPlan = createInitWorkspacePlan(); + + expect(resumePlan.shouldPrepare).toBe(false); + expect(initPlan.shouldPrepare).toBe(true); + }); + + it('resume plan has resumeContext', () => { + const plan = createResumeWorkspacePlan(); + + if (!plan.shouldPrepare) { + expect(plan.resumeContext).toBeDefined(); + expect(plan.resumeContext.kiloSessionId).toBeDefined(); + expect(plan.resumeContext.workspacePath).toBeDefined(); + expect(plan.resumeContext.kilocodeToken).toBeDefined(); + expect(plan.resumeContext.branchName).toBeDefined(); + } + }); + + it('init plan has initContext', () => { + const plan = createInitWorkspacePlan(); + + if (plan.shouldPrepare) { + expect(plan.initContext).toBeDefined(); + expect(plan.initContext.kilocodeToken).toBeDefined(); + } + }); + + it('prepared plan has both initContext and existingMetadata', () => { + const plan = createPreparedWorkspacePlan(); + + if (plan.shouldPrepare) { + expect(plan.initContext).toBeDefined(); + expect(plan.initContext.isPreparedSession).toBe(true); + expect(plan.existingMetadata).toBeDefined(); + expect(plan.existingMetadata?.workspacePath).toBeDefined(); + } + }); + + it('resume context can have optional token overrides', () => { + const context: ResumeContext = { + kiloSessionId: 'kilo_sess', + workspacePath: '/workspace', + kilocodeToken: 'token', + branchName: 'main', + githubToken: 'gh_token_override', + gitToken: 'git_token_override', + }; + + expect(context.githubToken).toBe('gh_token_override'); + expect(context.gitToken).toBe('git_token_override'); + }); + + it('init context supports all optional fields', () => { + const context: InitContext = { + kilocodeToken: 'token', + githubRepo: 'owner/repo', + gitUrl: 'https://git.example.com/repo.git', + githubToken: 'gh_token', + gitToken: 'git_token', + envVars: { NODE_ENV: 'production' }, + setupCommands: ['npm install'], + upstreamBranch: 'main', + kiloSessionId: 'kilo_sess', + isPreparedSession: true, + kilocodeModel: 'anthropic/claude-sonnet-4-20250514', + botId: 'bot_123', + githubAppType: 'standard', + }; + + expect(context.githubAppType).toBe('standard'); + expect(context.botId).toBe('bot_123'); + }); +}); + +// --------------------------------------------------------------------------- +// ExecutionResult Type Tests +// --------------------------------------------------------------------------- + +describe('ExecutionResult types', () => { + it('contains messageId and kiloSessionId', () => { + const result: ExecutionResult = { + messageId: 'msg_123', + kiloSessionId: 'kilo_sess_456', + }; + + expect(result.messageId).toBeDefined(); + expect(result.kiloSessionId).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// ModelConfig Type Tests +// --------------------------------------------------------------------------- + +describe('ModelConfig types', () => { + it('requires modelID', () => { + const model: ModelConfig = { + modelID: 'anthropic/claude-sonnet-4-20250514', + }; + + expect(model.modelID).toBeDefined(); + }); + + it('accepts optional providerID', () => { + const modelWithProvider: ModelConfig = { + providerID: 'kilo', + modelID: 'anthropic/claude-sonnet-4-20250514', + }; + + expect(modelWithProvider.providerID).toBe('kilo'); + }); +}); + +// --------------------------------------------------------------------------- +// WrapperPlan Type Tests +// --------------------------------------------------------------------------- + +describe('WrapperPlan types', () => { + it('accepts all optional fields', () => { + const plan: WrapperPlan = { + kiloSessionId: 'kilo_sess', + kiloSessionTitle: 'My Session', + model: { modelID: 'anthropic/claude-sonnet-4-20250514' }, + autoCommit: true, + condenseOnComplete: true, + }; + + expect(plan.kiloSessionId).toBe('kilo_sess'); + expect(plan.autoCommit).toBe(true); + }); + + it('works with minimal fields', () => { + const plan: WrapperPlan = {}; + + expect(plan.kiloSessionId).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// ExistingSessionMetadata Type Tests +// --------------------------------------------------------------------------- + +describe('ExistingSessionMetadata types', () => { + it('has required fields', () => { + const metadata: ExistingSessionMetadata = { + workspacePath: '/workspace/project', + kiloSessionId: 'kilo_sess', + branchName: 'main', + }; + + expect(metadata.workspacePath).toBeDefined(); + expect(metadata.kiloSessionId).toBeDefined(); + expect(metadata.branchName).toBeDefined(); + }); + + it('accepts optional fields', () => { + const metadata: ExistingSessionMetadata = { + workspacePath: '/workspace/project', + kiloSessionId: 'kilo_sess', + branchName: 'main', + sandboxId: 'sandbox_123', + sessionHome: '/home/agent', + upstreamBranch: 'develop', + appendSystemPrompt: 'Additional instructions', + githubRepo: 'owner/repo', + gitUrl: 'https://git.example.com/repo.git', + }; + + expect(metadata.sandboxId).toBe('sandbox_123'); + expect(metadata.appendSystemPrompt).toBe('Additional instructions'); + }); +}); diff --git a/cloud-agent-next/test/unit/wrapper/lifecycle.test.ts b/cloud-agent-next/test/unit/wrapper/lifecycle.test.ts new file mode 100644 index 0000000000..526e448fc4 --- /dev/null +++ b/cloud-agent-next/test/unit/wrapper/lifecycle.test.ts @@ -0,0 +1,592 @@ +/** + * Unit tests for lifecycle management. + * + * Tests timer logic with mocked state for: + * - Inflight expiry (per-message timeout) + * - Idle timeout (session-level cleanup) + * - Drain period + * - Post-completion task triggering + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { + createLifecycleManager, + DEFAULT_INFLIGHT_TIMEOUT_MS, + DEFAULT_IDLE_TIMEOUT_MS, + type LifecycleConfig, + type LifecycleDependencies, + type LifecycleManager, +} from '../../../wrapper/src/lifecycle.js'; +import { WrapperState, type JobContext } from '../../../wrapper/src/state.js'; +import type { KiloClient } from '../../../wrapper/src/kilo-client.js'; +import type { ConnectionManager } from '../../../wrapper/src/connection.js'; + +// --------------------------------------------------------------------------- +// Test Helpers +// --------------------------------------------------------------------------- + +const createMockKiloClient = (): KiloClient => ({ + listSessions: vi.fn().mockResolvedValue([]), + createSession: vi.fn().mockResolvedValue({ id: 'kilo_sess', time: { created: '', updated: '' } }), + getSession: vi.fn().mockResolvedValue({ id: 'kilo_sess', time: { created: '', updated: '' } }), + sendPromptAsync: vi.fn().mockResolvedValue(undefined), + abortSession: vi.fn().mockResolvedValue(true), + checkHealth: vi.fn().mockResolvedValue({ healthy: true, version: '1.0.0' }), + sendCommand: vi.fn().mockResolvedValue(undefined), + answerPermission: vi.fn().mockResolvedValue(true), + answerQuestion: vi.fn().mockResolvedValue(true), + rejectQuestion: vi.fn().mockResolvedValue(true), +}); + +const createMockConnectionManager = (): ConnectionManager => ({ + open: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + isConnected: vi.fn().mockReturnValue(false), +}); + +const createDefaultConfig = (overrides: Partial = {}): LifecycleConfig => ({ + maxRuntimeMs: DEFAULT_INFLIGHT_TIMEOUT_MS, + idleTimeoutMs: DEFAULT_IDLE_TIMEOUT_MS, + autoCommit: false, + condenseOnComplete: false, + workspacePath: '/workspace', + ...overrides, +}); + +const createJobContext = (overrides: Partial = {}): JobContext => ({ + executionId: 'exec_test', + sessionId: 'session_abc', + userId: 'user_xyz', + kiloSessionId: 'kilo_sess_456', + ingestUrl: 'wss://ingest.example.com', + ingestToken: 'token_secret', + kilocodeToken: 'kilo_token_789', + ...overrides, +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('createLifecycleManager', () => { + let state: WrapperState; + let kiloClient: KiloClient; + let connectionManager: ConnectionManager; + let config: LifecycleConfig; + let manager: LifecycleManager; + + beforeEach(() => { + vi.useFakeTimers(); + state = new WrapperState(); + kiloClient = createMockKiloClient(); + connectionManager = createMockConnectionManager(); + config = createDefaultConfig(); + }); + + afterEach(() => { + if (manager) { + manager.stop(); + } + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + const createManager = (overrides: Partial = {}): LifecycleManager => { + manager = createLifecycleManager( + { ...config, ...overrides }, + { state, kiloClient, connectionManager } + ); + return manager; + }; + + // ------------------------------------------------------------------------- + // Basic Lifecycle + // ------------------------------------------------------------------------- + + describe('basic lifecycle', () => { + it('returns a manager with expected methods', () => { + const mgr = createManager(); + + expect(mgr).toHaveProperty('start'); + expect(mgr).toHaveProperty('stop'); + expect(mgr).toHaveProperty('onMessageComplete'); + expect(mgr).toHaveProperty('triggerDrainAndClose'); + expect(mgr).toHaveProperty('getMaxRuntimeMs'); + expect(mgr).toHaveProperty('signalCompletion'); + expect(mgr).toHaveProperty('setAborted'); + }); + + it('getMaxRuntimeMs returns configured value', () => { + const mgr = createManager({ maxRuntimeMs: 300000 }); + + expect(mgr.getMaxRuntimeMs()).toBe(300000); + }); + + it('uses default values when config not provided', () => { + const mgr = createManager(); + + expect(mgr.getMaxRuntimeMs()).toBe(DEFAULT_INFLIGHT_TIMEOUT_MS); + }); + }); + + // ------------------------------------------------------------------------- + // Inflight Expiry + // ------------------------------------------------------------------------- + + describe('inflight expiry', () => { + it('expires inflight entries past deadline', async () => { + const mgr = createManager(); + const sendToIngestSpy = vi.fn(); + state.setSendToIngestFn(sendToIngestSpy); + + state.startJob(createJobContext()); + const now = Date.now(); + // Add entry that expires in 3 seconds + state.addInflight('msg_1', now + 3000); + + mgr.start(); + + // Advance past the deadline + check interval (5 seconds) + await vi.advanceTimersByTimeAsync(6000); + + // Should have sent error event + expect(sendToIngestSpy).toHaveBeenCalledWith( + expect.objectContaining({ + streamEventType: 'error', + data: expect.objectContaining({ + code: 'INFLIGHT_TIMEOUT', + messageId: 'msg_1', + }), + }) + ); + + // Entry should be removed + expect(state.hasInflight('msg_1')).toBe(false); + }); + + it('sets lastError on inflight timeout', async () => { + const mgr = createManager(); + state.startJob(createJobContext()); + state.addInflight('msg_1', Date.now() + 1000); + + mgr.start(); + await vi.advanceTimersByTimeAsync(6000); + + const error = state.getLastError(); + expect(error).not.toBeNull(); + expect(error?.code).toBe('INFLIGHT_TIMEOUT'); + expect(error?.messageId).toBe('msg_1'); + }); + + it('does not expire entries before deadline', async () => { + const mgr = createManager(); + state.startJob(createJobContext()); + state.addInflight('msg_1', Date.now() + 60000); // 60 seconds from now + + mgr.start(); + await vi.advanceTimersByTimeAsync(6000); // Only 6 seconds + + expect(state.hasInflight('msg_1')).toBe(true); + }); + + it('triggers drain and close when inflight hits 0 after expiry', async () => { + const mgr = createManager(); + (connectionManager.isConnected as ReturnType).mockReturnValue(true); + + state.startJob(createJobContext()); + state.addInflight('msg_1', Date.now() + 1000); + + mgr.start(); + await vi.advanceTimersByTimeAsync(6000); + + // After drain delay, close should be called + await vi.advanceTimersByTimeAsync(500); + + expect(connectionManager.close).toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // Idle Timeout + // ------------------------------------------------------------------------- + + describe('idle timeout', () => { + it('clears job after idle timeout when no inflight', async () => { + const mgr = createManager({ idleTimeoutMs: 5000 }); + state.startJob(createJobContext()); + + mgr.start(); + + // Advance past idle timeout + check interval (10 seconds) + await vi.advanceTimersByTimeAsync(15000); + + expect(state.hasJob).toBe(false); + }); + + it('sends idle timeout error event', async () => { + const mgr = createManager({ idleTimeoutMs: 5000 }); + const sendToIngestSpy = vi.fn(); + state.setSendToIngestFn(sendToIngestSpy); + (connectionManager.isConnected as ReturnType).mockReturnValue(true); + + state.startJob(createJobContext()); + + mgr.start(); + await vi.advanceTimersByTimeAsync(15000); + + expect(sendToIngestSpy).toHaveBeenCalledWith( + expect.objectContaining({ + streamEventType: 'error', + data: expect.objectContaining({ + code: 'IDLE_TIMEOUT', + }), + }) + ); + }); + + it('sets lastError on idle timeout', async () => { + const mgr = createManager({ idleTimeoutMs: 5000 }); + state.startJob(createJobContext()); + + mgr.start(); + await vi.advanceTimersByTimeAsync(15000); + + const error = state.getLastError(); + expect(error).not.toBeNull(); + expect(error?.code).toBe('IDLE_TIMEOUT'); + }); + + it('does not trigger idle timeout when active', async () => { + const mgr = createManager({ idleTimeoutMs: 5000 }); + state.startJob(createJobContext()); + state.addInflight('msg_1', Date.now() + 120000); // Long deadline + + mgr.start(); + await vi.advanceTimersByTimeAsync(15000); + + // Job should still exist because there's inflight + expect(state.hasJob).toBe(true); + }); + + it('does not trigger idle timeout without job context', async () => { + const mgr = createManager({ idleTimeoutMs: 5000 }); + // No job started + + mgr.start(); + await vi.advanceTimersByTimeAsync(15000); + + // Nothing should happen (no job to clear) + expect(connectionManager.close).not.toHaveBeenCalled(); + }); + + it('resets idle timer on activity', async () => { + const mgr = createManager({ idleTimeoutMs: 10000 }); + state.startJob(createJobContext()); + + mgr.start(); + + // Wait 8 seconds (less than timeout) + await vi.advanceTimersByTimeAsync(8000); + + // Update activity + state.updateActivity(); + + // Wait another 8 seconds (total 16 seconds but activity was 8 seconds ago) + await vi.advanceTimersByTimeAsync(8000); + + // Job should still exist (activity was only 8 seconds ago) + expect(state.hasJob).toBe(true); + + // Wait until idle timeout from last activity + await vi.advanceTimersByTimeAsync(5000); + + // Now job should be cleared + expect(state.hasJob).toBe(false); + }); + }); + + // ------------------------------------------------------------------------- + // Message Completion + // ------------------------------------------------------------------------- + + describe('onMessageComplete', () => { + it('removes message from inflight', () => { + const mgr = createManager(); + state.startJob(createJobContext()); + state.addInflight('msg_1', Date.now() + 60000); + + mgr.onMessageComplete('msg_1'); + + expect(state.hasInflight('msg_1')).toBe(false); + }); + + it('triggers drain when last message completes', async () => { + const mgr = createManager(); + (connectionManager.isConnected as ReturnType).mockReturnValue(true); + + state.startJob(createJobContext()); + state.addInflight('msg_1', Date.now() + 60000); + + mgr.onMessageComplete('msg_1'); + + // Drain should be triggered (close called after delay) + // Need to use advanceTimersByTimeAsync for async operations + await vi.advanceTimersByTimeAsync(500); + expect(connectionManager.close).toHaveBeenCalled(); + }); + + it('does not trigger drain when other messages remain', () => { + const mgr = createManager(); + (connectionManager.isConnected as ReturnType).mockReturnValue(true); + + state.startJob(createJobContext()); + state.addInflight('msg_1', Date.now() + 60000); + state.addInflight('msg_2', Date.now() + 60000); + + mgr.onMessageComplete('msg_1'); + + // Advance past drain delay + vi.advanceTimersByTime(500); + + // Close should NOT be called - still have msg_2 + expect(connectionManager.close).not.toHaveBeenCalled(); + }); + + it('handles unknown messageId gracefully', () => { + const mgr = createManager(); + state.startJob(createJobContext()); + + // Should not throw + expect(() => mgr.onMessageComplete('unknown_msg')).not.toThrow(); + }); + }); + + // ------------------------------------------------------------------------- + // Drain and Close + // ------------------------------------------------------------------------- + + describe('triggerDrainAndClose', () => { + it('closes connection after drain delay', async () => { + const mgr = createManager(); + state.startJob(createJobContext()); + + mgr.triggerDrainAndClose(); + + // Before delay - not closed + expect(connectionManager.close).not.toHaveBeenCalled(); + + // After 250ms drain delay + await vi.advanceTimersByTimeAsync(300); + + expect(connectionManager.close).toHaveBeenCalled(); + }); + + it('is idempotent - multiple calls do not queue multiple drains', async () => { + const mgr = createManager(); + state.startJob(createJobContext()); + + mgr.triggerDrainAndClose(); + mgr.triggerDrainAndClose(); + mgr.triggerDrainAndClose(); + + await vi.advanceTimersByTimeAsync(1000); + + // Close should only be called once + expect(connectionManager.close).toHaveBeenCalledTimes(1); + }); + }); + + // ------------------------------------------------------------------------- + // Stop + // ------------------------------------------------------------------------- + + describe('stop', () => { + it('clears all timers', async () => { + const mgr = createManager({ idleTimeoutMs: 5000 }); + state.startJob(createJobContext()); + + mgr.start(); + + // Stop before idle timeout + await vi.advanceTimersByTimeAsync(3000); + mgr.stop(); + + // Advance past what would have been idle timeout + await vi.advanceTimersByTimeAsync(20000); + + // Job should still exist (timers stopped) + expect(state.hasJob).toBe(true); + }); + + it('cancels pending drain', async () => { + const mgr = createManager(); + state.startJob(createJobContext()); + + mgr.triggerDrainAndClose(); + + // Stop before drain completes + vi.advanceTimersByTime(100); + mgr.stop(); + + // Advance past drain delay + vi.advanceTimersByTime(500); + + // Close should not have been called + expect(connectionManager.close).not.toHaveBeenCalled(); + }); + + it('sets aborted flag', () => { + const mgr = createManager(); + + mgr.stop(); + + // This is internal state, verified by behavior in post-completion tests + // The stop() method sets isAborted = true + }); + }); + + // ------------------------------------------------------------------------- + // setAborted + // ------------------------------------------------------------------------- + + describe('setAborted', () => { + it('prevents post-completion tasks from running', async () => { + const mgr = createManager({ autoCommit: true }); + state.startJob(createJobContext()); + state.addInflight('msg_1', Date.now() + 60000); + + // Set aborted before completion + mgr.setAborted(); + + // Complete the message (would trigger post-completion tasks) + (connectionManager.isConnected as ReturnType).mockReturnValue(true); + mgr.onMessageComplete('msg_1'); + + // Wait for any async tasks + await vi.advanceTimersByTimeAsync(1000); + + // Auto-commit should not have been attempted + // (We can't easily test this without more mocking, but the flag is set) + }); + }); + + // ------------------------------------------------------------------------- + // signalCompletion + // ------------------------------------------------------------------------- + + describe('signalCompletion', () => { + it('can be called without error', () => { + const mgr = createManager(); + + // Should not throw + expect(() => mgr.signalCompletion()).not.toThrow(); + }); + + // Integration test: signalCompletion resolves waitForCompletion in runPostCompletionTasks + // This is tested more thoroughly in integration tests + }); + + // ------------------------------------------------------------------------- + // Post-Completion Tasks + // ------------------------------------------------------------------------- + + describe('post-completion tasks', () => { + it('runs auto-commit when enabled', async () => { + const mgr = createManager({ autoCommit: true }); + (connectionManager.isConnected as ReturnType).mockReturnValue(true); + + state.startJob(createJobContext()); + state.addInflight('msg_1', Date.now() + 60000); + + mgr.start(); + mgr.onMessageComplete('msg_1'); + + // Signal completion to unblock auto-commit waiter + mgr.signalCompletion(); + + // Post-completion tasks run before drain + // This is an integration point - actual auto-commit behavior tested elsewhere + await vi.advanceTimersByTimeAsync(1000); + }); + + it('runs condense when enabled', async () => { + const mgr = createManager({ condenseOnComplete: true }); + (connectionManager.isConnected as ReturnType).mockReturnValue(true); + + state.startJob(createJobContext()); + state.addInflight('msg_1', Date.now() + 60000); + + mgr.start(); + mgr.onMessageComplete('msg_1'); + + mgr.signalCompletion(); + + await vi.advanceTimersByTimeAsync(1000); + }); + + it('sends error event if auto-commit fails', async () => { + // This would require mocking runAutoCommit which is imported + // For unit tests, we verify the error handling path exists + // Full integration testing would mock the auto-commit module + }); + }); + + // ------------------------------------------------------------------------- + // Edge Cases + // ------------------------------------------------------------------------- + + describe('edge cases', () => { + it('handles state with no job during expiry check', async () => { + const mgr = createManager(); + + // Add inflight without job context (unusual but possible) + state.addInflight('msg_1', Date.now() - 1000); // Already expired + + mgr.start(); + + // Should not throw during expiry check + await vi.advanceTimersByTimeAsync(6000); + }); + + it('handles connection not connected during drain', async () => { + const mgr = createManager(); + (connectionManager.isConnected as ReturnType).mockReturnValue(false); + + state.startJob(createJobContext()); + + // Even without connection, triggerDrainAndClose should work + mgr.triggerDrainAndClose(); + + await vi.advanceTimersByTimeAsync(500); + + // close() should still be called (to ensure cleanup) + expect(connectionManager.close).toHaveBeenCalled(); + }); + + it('multiple inflight entries with different deadlines', async () => { + const mgr = createManager(); + const sendToIngestSpy = vi.fn(); + state.setSendToIngestFn(sendToIngestSpy); + + state.startJob(createJobContext()); + const now = Date.now(); + state.addInflight('msg_short', now + 2000); // Expires at 2s + state.addInflight('msg_long', now + 10000); // Expires at 10s + + mgr.start(); + + // After 6 seconds, only msg_short should be expired + await vi.advanceTimersByTimeAsync(6000); + + expect(state.hasInflight('msg_short')).toBe(false); + expect(state.hasInflight('msg_long')).toBe(true); + + // Check that error was sent for short one only + const errorCalls = sendToIngestSpy.mock.calls.filter( + call => call[0]?.streamEventType === 'error' + ); + expect(errorCalls).toHaveLength(1); + expect(errorCalls[0][0].data.messageId).toBe('msg_short'); + }); + }); +}); diff --git a/cloud-agent-next/test/unit/wrapper/state.test.ts b/cloud-agent-next/test/unit/wrapper/state.test.ts new file mode 100644 index 0000000000..12ee4ccde8 --- /dev/null +++ b/cloud-agent-next/test/unit/wrapper/state.test.ts @@ -0,0 +1,705 @@ +/** + * Unit tests for WrapperState class. + * + * Tests state transitions, invariants, and edge cases for the wrapper's + * centralized state management. + */ + +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { WrapperState, type JobContext, type InflightEntry } from '../../../wrapper/src/state.js'; +import type { IngestEvent } from '../../../src/shared/protocol.js'; + +// --------------------------------------------------------------------------- +// Test Helpers +// --------------------------------------------------------------------------- + +const createJobContext = (overrides: Partial = {}): JobContext => ({ + executionId: 'exec_test-123', + sessionId: 'session_abc', + userId: 'user_xyz', + kiloSessionId: 'kilo_sess_456', + ingestUrl: 'wss://ingest.example.com', + ingestToken: 'token_secret', + kilocodeToken: 'kilo_token_789', + ...overrides, +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('WrapperState', () => { + let state: WrapperState; + + beforeEach(() => { + state = new WrapperState(); + }); + + // ------------------------------------------------------------------------- + // Initial State + // ------------------------------------------------------------------------- + + describe('initial state', () => { + it('starts in idle state', () => { + expect(state.isIdle).toBe(true); + expect(state.isActive).toBe(false); + }); + + it('has no job context', () => { + expect(state.hasJob).toBe(false); + expect(state.currentJob).toBeNull(); + }); + + it('has no inflight entries', () => { + expect(state.inflightCount).toBe(0); + expect(state.inflightMessageIds).toEqual([]); + }); + + it('is not connected', () => { + expect(state.isConnected).toBe(false); + }); + + it('has no last error', () => { + expect(state.getLastError()).toBeNull(); + }); + }); + + // ------------------------------------------------------------------------- + // Job Lifecycle + // ------------------------------------------------------------------------- + + describe('job lifecycle', () => { + describe('startJob', () => { + it('stores job context', () => { + const context = createJobContext(); + state.startJob(context); + + expect(state.hasJob).toBe(true); + expect(state.currentJob).toEqual(context); + }); + + it('clears previous error on start', () => { + state.setLastError({ + code: 'TEST_ERROR', + message: 'previous error', + timestamp: Date.now(), + }); + + state.startJob(createJobContext()); + + expect(state.getLastError()).toBeNull(); + }); + + it('resets message counter on start', () => { + state.startJob(createJobContext({ executionId: 'exec_first' })); + state.nextMessageId(); // counter = 1 + state.nextMessageId(); // counter = 2 + + // Clear job and start new one + state.clearJob(); + state.startJob(createJobContext({ executionId: 'exec_second' })); + + // Counter should be reset + const messageId = state.nextMessageId(); + expect(messageId).toBe('msg_second_1'); + }); + + it('is idempotent for same executionId', () => { + const context = createJobContext({ executionId: 'exec_same' }); + state.startJob(context); + state.nextMessageId(); // counter = 1 + + // Re-start with same executionId should not reset counter + state.startJob(context); + + // Counter should NOT be reset for idempotent call + // (This is the current behavior - idempotent returns early) + expect(state.currentJob).toEqual(context); + }); + + it('allows starting new job when idle (no inflight)', () => { + state.startJob(createJobContext({ executionId: 'exec_first' })); + state.clearJob(); + + // Should be able to start a new job + expect(() => { + state.startJob(createJobContext({ executionId: 'exec_second' })); + }).not.toThrow(); + }); + + it('throws when starting different job with inflight > 0', () => { + state.startJob(createJobContext({ executionId: 'exec_first' })); + state.addInflight('msg_1', Date.now() + 60000); + + expect(() => { + state.startJob(createJobContext({ executionId: 'exec_second' })); + }).toThrow(/Cannot start new job while inflight > 0/); + }); + + it('allows replacing job when idle but same job active', () => { + state.startJob(createJobContext({ executionId: 'exec_first' })); + // No inflight, so we can replace + + state.clearJob(); + state.startJob(createJobContext({ executionId: 'exec_second' })); + + expect(state.currentJob?.executionId).toBe('exec_second'); + }); + }); + + describe('clearJob', () => { + it('clears job context', () => { + state.startJob(createJobContext()); + state.clearJob(); + + expect(state.hasJob).toBe(false); + expect(state.currentJob).toBeNull(); + }); + + it('clears inflight entries', () => { + state.startJob(createJobContext()); + state.addInflight('msg_1', Date.now() + 60000); + state.addInflight('msg_2', Date.now() + 60000); + + state.clearJob(); + + expect(state.inflightCount).toBe(0); + }); + + it('resets message counter', () => { + state.startJob(createJobContext({ executionId: 'exec_test' })); + state.nextMessageId(); + state.nextMessageId(); + + state.clearJob(); + state.startJob(createJobContext({ executionId: 'exec_new' })); + + expect(state.nextMessageId()).toBe('msg_new_1'); + }); + }); + }); + + // ------------------------------------------------------------------------- + // Inflight Management + // ------------------------------------------------------------------------- + + describe('inflight management', () => { + beforeEach(() => { + state.startJob(createJobContext()); + }); + + describe('addInflight', () => { + it('adds entry to inflight map', () => { + state.addInflight('msg_1', Date.now() + 60000); + + expect(state.inflightCount).toBe(1); + expect(state.inflightMessageIds).toContain('msg_1'); + }); + + it('transitions state to active', () => { + expect(state.isIdle).toBe(true); + + state.addInflight('msg_1', Date.now() + 60000); + + expect(state.isIdle).toBe(false); + expect(state.isActive).toBe(true); + }); + + it('updates activity timestamp', () => { + const beforeTime = Date.now(); + state.addInflight('msg_1', Date.now() + 60000); + const afterTime = Date.now(); + + const idleMs = state.getIdleMs(afterTime); + // Should be very small (just added) + expect(idleMs).toBeLessThanOrEqual(afterTime - beforeTime + 1); + }); + + it('allows multiple inflight entries', () => { + state.addInflight('msg_1', Date.now() + 60000); + state.addInflight('msg_2', Date.now() + 60000); + state.addInflight('msg_3', Date.now() + 60000); + + expect(state.inflightCount).toBe(3); + expect(state.inflightMessageIds).toEqual(['msg_1', 'msg_2', 'msg_3']); + }); + }); + + describe('removeInflight', () => { + it('removes entry from inflight map', () => { + state.addInflight('msg_1', Date.now() + 60000); + state.addInflight('msg_2', Date.now() + 60000); + + const removed = state.removeInflight('msg_1'); + + expect(removed).toBe(true); + expect(state.inflightCount).toBe(1); + expect(state.inflightMessageIds).toEqual(['msg_2']); + }); + + it('returns false for unknown messageId', () => { + state.addInflight('msg_1', Date.now() + 60000); + + const removed = state.removeInflight('msg_unknown'); + + expect(removed).toBe(false); + expect(state.inflightCount).toBe(1); + }); + + it('transitions to idle when last entry removed', () => { + state.addInflight('msg_1', Date.now() + 60000); + expect(state.isActive).toBe(true); + + state.removeInflight('msg_1'); + + expect(state.isIdle).toBe(true); + expect(state.isActive).toBe(false); + }); + + it('updates activity timestamp on successful remove', () => { + state.addInflight('msg_1', Date.now() + 60000); + const beforeRemove = Date.now(); + + state.removeInflight('msg_1'); + + const afterRemove = Date.now(); + const idleMs = state.getIdleMs(afterRemove); + expect(idleMs).toBeLessThanOrEqual(afterRemove - beforeRemove + 1); + }); + }); + + describe('hasInflight', () => { + it('returns true for existing messageId', () => { + state.addInflight('msg_1', Date.now() + 60000); + + expect(state.hasInflight('msg_1')).toBe(true); + }); + + it('returns false for unknown messageId', () => { + state.addInflight('msg_1', Date.now() + 60000); + + expect(state.hasInflight('msg_unknown')).toBe(false); + }); + }); + + describe('getExpiredInflight', () => { + it('returns entries past their deadline', () => { + const now = Date.now(); + state.addInflight('msg_expired', now - 1000); // Deadline in the past + state.addInflight('msg_valid', now + 60000); // Deadline in the future + + const expired = state.getExpiredInflight(now); + + expect(expired).toHaveLength(1); + expect(expired[0].messageId).toBe('msg_expired'); + }); + + it('returns empty array when no expired entries', () => { + const now = Date.now(); + state.addInflight('msg_1', now + 60000); + state.addInflight('msg_2', now + 60000); + + const expired = state.getExpiredInflight(now); + + expect(expired).toHaveLength(0); + }); + + it('does not remove expired entries (query only)', () => { + const now = Date.now(); + state.addInflight('msg_expired', now - 1000); + + state.getExpiredInflight(now); + + // Entry should still be in inflight + expect(state.hasInflight('msg_expired')).toBe(true); + }); + + it('includes entries at exactly deadline time', () => { + const now = Date.now(); + state.addInflight('msg_exact', now); // Deadline exactly at now + + const expired = state.getExpiredInflight(now); + + expect(expired).toHaveLength(1); + }); + }); + + describe('clearAllInflight', () => { + it('removes all inflight entries', () => { + state.addInflight('msg_1', Date.now() + 60000); + state.addInflight('msg_2', Date.now() + 60000); + state.addInflight('msg_3', Date.now() + 60000); + + state.clearAllInflight(); + + expect(state.inflightCount).toBe(0); + expect(state.isIdle).toBe(true); + }); + }); + }); + + // ------------------------------------------------------------------------- + // Message ID Generation + // ------------------------------------------------------------------------- + + describe('message ID generation', () => { + it('throws when no job context', () => { + expect(() => state.nextMessageId()).toThrow(/No job context/); + }); + + it('generates sequential IDs', () => { + state.startJob(createJobContext({ executionId: 'exec_test' })); + + expect(state.nextMessageId()).toBe('msg_test_1'); + expect(state.nextMessageId()).toBe('msg_test_2'); + expect(state.nextMessageId()).toBe('msg_test_3'); + }); + + it('strips exec_ prefix', () => { + state.startJob(createJobContext({ executionId: 'exec_abc123' })); + + expect(state.nextMessageId()).toBe('msg_abc123_1'); + }); + + it('strips execution_ prefix', () => { + state.startJob(createJobContext({ executionId: 'execution_def456' })); + + expect(state.nextMessageId()).toBe('msg_def456_1'); + }); + + it('strips msg_ prefix', () => { + state.startJob(createJobContext({ executionId: 'msg_ghi789' })); + + expect(state.nextMessageId()).toBe('msg_ghi789_1'); + }); + + it('handles executionId without prefix', () => { + state.startJob(createJobContext({ executionId: 'custom-id-123' })); + + expect(state.nextMessageId()).toBe('msg_custom-id-123_1'); + }); + }); + + // ------------------------------------------------------------------------- + // Activity Tracking + // ------------------------------------------------------------------------- + + describe('activity tracking', () => { + it('updateActivity updates timestamp', () => { + const before = Date.now(); + state.updateActivity(); + const after = Date.now(); + + const idleMs = state.getIdleMs(after); + expect(idleMs).toBeLessThanOrEqual(after - before + 1); + }); + + it('getIdleMs returns time since last activity', async () => { + state.updateActivity(); + const activityTime = Date.now(); + + // Wait a bit + await new Promise(resolve => setTimeout(resolve, 50)); + + const now = Date.now(); + const idleMs = state.getIdleMs(now); + + // Should be at least 50ms but not much more + expect(idleMs).toBeGreaterThanOrEqual(now - activityTime - 5); + expect(idleMs).toBeLessThan(200); + }); + }); + + // ------------------------------------------------------------------------- + // Error Tracking + // ------------------------------------------------------------------------- + + describe('error tracking', () => { + it('setLastError stores error', () => { + const error = { + code: 'TEST_ERROR', + message: 'Something went wrong', + timestamp: Date.now(), + }; + + state.setLastError(error); + + expect(state.getLastError()).toEqual(error); + }); + + it('setLastError with messageId', () => { + const error = { + code: 'INFLIGHT_TIMEOUT', + messageId: 'msg_123', + message: 'Timeout', + timestamp: Date.now(), + }; + + state.setLastError(error); + + expect(state.getLastError()).toEqual(error); + }); + + it('clearLastError removes error', () => { + state.setLastError({ + code: 'TEST_ERROR', + message: 'Error', + timestamp: Date.now(), + }); + + state.clearLastError(); + + expect(state.getLastError()).toBeNull(); + }); + }); + + // ------------------------------------------------------------------------- + // Connection Management + // ------------------------------------------------------------------------- + + describe('connection management', () => { + it('isConnected returns false with no WebSocket', () => { + expect(state.isConnected).toBe(false); + }); + + it('setConnections stores WebSocket and AbortController', () => { + const mockWs = { readyState: WebSocket.OPEN, close: vi.fn() } as unknown as WebSocket; + const mockAbort = new AbortController(); + + state.setConnections(mockWs, mockAbort); + + expect(state.ingestWs).toBe(mockWs); + expect(state.sseAbortController).toBe(mockAbort); + }); + + it('isConnected returns true when WebSocket is OPEN', () => { + const mockWs = { readyState: WebSocket.OPEN, close: vi.fn() } as unknown as WebSocket; + state.setConnections(mockWs, new AbortController()); + + expect(state.isConnected).toBe(true); + }); + + it('isConnected returns false when WebSocket is not OPEN', () => { + const mockWs = { readyState: WebSocket.CLOSED, close: vi.fn() } as unknown as WebSocket; + state.setConnections(mockWs, new AbortController()); + + expect(state.isConnected).toBe(false); + }); + + it('clearConnections closes WebSocket and aborts controller', () => { + const mockClose = vi.fn(); + const mockWs = { readyState: WebSocket.OPEN, close: mockClose } as unknown as WebSocket; + const mockAbort = new AbortController(); + const abortSpy = vi.spyOn(mockAbort, 'abort'); + + state.setConnections(mockWs, mockAbort); + state.clearConnections(); + + expect(mockClose).toHaveBeenCalled(); + expect(abortSpy).toHaveBeenCalled(); + expect(state.ingestWs).toBeNull(); + expect(state.sseAbortController).toBeNull(); + }); + + it('clearConnections handles close errors gracefully', () => { + const mockWs = { + readyState: WebSocket.OPEN, + close: () => { + throw new Error('Close failed'); + }, + } as unknown as WebSocket; + + state.setConnections(mockWs, new AbortController()); + + // Should not throw + expect(() => state.clearConnections()).not.toThrow(); + }); + }); + + // ------------------------------------------------------------------------- + // Send to Ingest + // ------------------------------------------------------------------------- + + describe('sendToIngest', () => { + it('does nothing when no send function set', () => { + const event: IngestEvent = { + streamEventType: 'status', + data: { message: 'test' }, + timestamp: new Date().toISOString(), + }; + + // Should not throw + expect(() => state.sendToIngest(event)).not.toThrow(); + }); + + it('calls send function when set', () => { + const mockSend = vi.fn(); + state.setSendToIngestFn(mockSend); + + const event: IngestEvent = { + streamEventType: 'status', + data: { message: 'test' }, + timestamp: new Date().toISOString(), + }; + + state.sendToIngest(event); + + expect(mockSend).toHaveBeenCalledWith(event); + }); + + it('setSendToIngestFn can clear function', () => { + const mockSend = vi.fn(); + state.setSendToIngestFn(mockSend); + state.setSendToIngestFn(null); + + const event: IngestEvent = { + streamEventType: 'status', + data: {}, + timestamp: new Date().toISOString(), + }; + + state.sendToIngest(event); + + expect(mockSend).not.toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // Status API + // ------------------------------------------------------------------------- + + describe('getStatus', () => { + it('returns idle state with no job', () => { + const status = state.getStatus(); + + expect(status).toEqual({ + state: 'idle', + executionId: undefined, + kiloSessionId: undefined, + inflight: [], + inflightCount: 0, + lastError: undefined, + }); + }); + + it('returns idle state with job but no inflight', () => { + state.startJob( + createJobContext({ + executionId: 'exec_123', + kiloSessionId: 'kilo_456', + }) + ); + + const status = state.getStatus(); + + expect(status).toEqual({ + state: 'idle', + executionId: 'exec_123', + kiloSessionId: 'kilo_456', + inflight: [], + inflightCount: 0, + lastError: undefined, + }); + }); + + it('returns active state with inflight', () => { + state.startJob( + createJobContext({ + executionId: 'exec_123', + kiloSessionId: 'kilo_456', + }) + ); + state.addInflight('msg_1', Date.now() + 60000); + state.addInflight('msg_2', Date.now() + 60000); + + const status = state.getStatus(); + + expect(status).toEqual({ + state: 'active', + executionId: 'exec_123', + kiloSessionId: 'kilo_456', + inflight: ['msg_1', 'msg_2'], + inflightCount: 2, + lastError: undefined, + }); + }); + + it('includes lastError when present', () => { + state.startJob(createJobContext()); + const error = { + code: 'INFLIGHT_TIMEOUT', + messageId: 'msg_123', + message: 'Timeout', + timestamp: Date.now(), + }; + state.setLastError(error); + + const status = state.getStatus(); + + expect(status.lastError).toEqual(error); + }); + }); + + // ------------------------------------------------------------------------- + // Edge Cases and Invariants + // ------------------------------------------------------------------------- + + describe('edge cases and invariants', () => { + it('state is IDLE iff inflightCount == 0', () => { + state.startJob(createJobContext()); + + // Initially idle + expect(state.isIdle).toBe(state.inflightCount === 0); + expect(state.isActive).toBe(state.inflightCount > 0); + + // Add one - should be active + state.addInflight('msg_1', Date.now() + 60000); + expect(state.isIdle).toBe(state.inflightCount === 0); + expect(state.isActive).toBe(state.inflightCount > 0); + + // Add another - still active + state.addInflight('msg_2', Date.now() + 60000); + expect(state.isIdle).toBe(state.inflightCount === 0); + expect(state.isActive).toBe(state.inflightCount > 0); + + // Remove one - still active + state.removeInflight('msg_1'); + expect(state.isIdle).toBe(state.inflightCount === 0); + expect(state.isActive).toBe(state.inflightCount > 0); + + // Remove last - back to idle + state.removeInflight('msg_2'); + expect(state.isIdle).toBe(state.inflightCount === 0); + expect(state.isActive).toBe(state.inflightCount > 0); + }); + + it('inflight entries independent of job context', () => { + state.startJob(createJobContext()); + state.addInflight('msg_1', Date.now() + 60000); + + // Inflight exists + expect(state.hasInflight('msg_1')).toBe(true); + + // clearJob clears inflight + state.clearJob(); + expect(state.hasInflight('msg_1')).toBe(false); + }); + + it('duplicate inflight messageId overwrites', () => { + state.startJob(createJobContext()); + const firstDeadline = Date.now() + 30000; + const secondDeadline = Date.now() + 60000; + + state.addInflight('msg_1', firstDeadline); + state.addInflight('msg_1', secondDeadline); + + // Should only have one entry + expect(state.inflightCount).toBe(1); + + // Second deadline should be used + const now = firstDeadline + 1; // Past first deadline + const expired = state.getExpiredInflight(now); + expect(expired).toHaveLength(0); // Not expired yet with second deadline + }); + }); +}); diff --git a/cloud-agent-next/tsconfig.json b/cloud-agent-next/tsconfig.json new file mode 100644 index 0000000000..b42fa284e6 --- /dev/null +++ b/cloud-agent-next/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "types": ["@types/node", "./worker-configuration.d.ts"], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "experimentalDecorators": true + }, + "include": ["worker-configuration.d.ts", "src/**/*.ts", "vitest.config.ts"] +} diff --git a/cloud-agent-next/utils/get-session-logs.sh b/cloud-agent-next/utils/get-session-logs.sh new file mode 100755 index 0000000000..70811f07c0 --- /dev/null +++ b/cloud-agent-next/utils/get-session-logs.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# Fetch Kilocode CLI logs for a session and save to /tmp/cli.txt +# +# Usage: ./get-session-logs.sh +# +# Environment variables: +# WORKER_URL - Worker base URL (e.g., https://your-worker.workers.dev) +# KILOCODE_TOKEN - Authentication token + +set -e + +SESSION_ID="${1:?Usage: $0 }" + +: "${WORKER_URL:?WORKER_URL environment variable is required}" +: "${KILOCODE_TOKEN:?KILOCODE_TOKEN environment variable is required}" + +# URL-encode the JSON input +INPUT=$(printf '{"sessionId":"%s"}' "$SESSION_ID" | jq -sRr @uri) + +# Fetch logs and capture response +RESPONSE=$(curl -s --fail-with-body "${WORKER_URL}/trpc/getSessionLogs?input=${INPUT}" \ + -H "Authorization: Bearer ${KILOCODE_TOKEN}" 2>&1) || { + echo "Error: Request failed" >&2 + echo "$RESPONSE" >&2 + exit 1 +} + +# Extract content and write to file +echo "$RESPONSE" | jq -r '.result.data.content' \ No newline at end of file diff --git a/cloud-agent-next/vitest.config.ts b/cloud-agent-next/vitest.config.ts new file mode 100644 index 0000000000..f69e69059e --- /dev/null +++ b/cloud-agent-next/vitest.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vitest/config'; + +// Unit tests - run in Node (fast, supports vi.mock and global mocking) +export default defineConfig({ + test: { + name: 'unit', + globals: true, + environment: 'node', + include: ['src/**/*.test.ts', 'test/unit/**/*.test.ts'], + exclude: ['test/integration/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: ['node_modules/', 'dist/', '**/*.test.ts'], + }, + server: { + deps: { + external: ['@cloudflare/sandbox', '@cloudflare/containers'], + }, + }, + }, +}); diff --git a/cloud-agent-next/vitest.workers.config.ts b/cloud-agent-next/vitest.workers.config.ts new file mode 100644 index 0000000000..98d3532bbf --- /dev/null +++ b/cloud-agent-next/vitest.workers.config.ts @@ -0,0 +1,38 @@ +import { defineWorkersProject } from '@cloudflare/vitest-pool-workers/config'; + +// Integration tests - run in Cloudflare Workers runtime via Miniflare +// Use cloudflare:test utilities: env, runInDurableObject, createMessageBatch, etc. +export default defineWorkersProject({ + test: { + name: 'integration', + globals: true, + include: ['test/integration/**/*.test.ts'], + deps: { + optimizer: { + ssr: { + include: ['@cloudflare/sandbox', '@cloudflare/containers'], + }, + }, + }, + poolOptions: { + workers: { + singleWorker: true, + wrangler: { + // Use test-specific wrangler config that excludes Sandbox DO + // (avoids @cloudflare/containers import issues) + configPath: './wrangler.test.jsonc', + }, + miniflare: { + // Faster queue processing in tests + queueConsumers: { + EXECUTION_QUEUE: { + maxBatchTimeout: 50, + }, + }, + // Required for SELF.queue() testing + compatibilityFlags: ['service_binding_extra_handlers'], + }, + }, + }, + }, +}); diff --git a/cloud-agent-next/worker-configuration.d.ts b/cloud-agent-next/worker-configuration.d.ts new file mode 100644 index 0000000000..82d758c63c --- /dev/null +++ b/cloud-agent-next/worker-configuration.d.ts @@ -0,0 +1,12106 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types` (hash: d14d46ee8c1269453fd89f48cd5ccfbe) +// Runtime types generated with workerd@1.20260128.0 2025-09-15 nodejs_compat +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import('./src/index'); + durableNamespaces: 'Sandbox' | 'CloudAgentSession'; + } + interface DevEnv { + GITHUB_TOKEN_CACHE: KVNamespace; + R2_BUCKET: R2Bucket; + HYPERDRIVE: Hyperdrive; + CALLBACK_QUEUE: Queue; + KILO_OPENROUTER_BASE: 'http://localhost:3000/api'; + GITHUB_APP_SLUG: 'kiloconnect-development'; + GITHUB_APP_BOT_USER_ID: '242397087'; + GITHUB_LITE_APP_ID: ''; + GITHUB_LITE_APP_SLUG: ''; + GITHUB_LITE_APP_BOT_USER_ID: ''; + WRAPPER_IDLE_TIMEOUT_MS: '120000'; + CLI_TIMEOUT_SECONDS: '900'; + REAPER_INTERVAL_MS: '300000'; + STALE_THRESHOLD_MS: '600000'; + PENDING_START_TIMEOUT_MS: '300000'; + R2_ATTACHMENTS_BUCKET: 'cloud-agent-attachments-dev'; + NEXTAUTH_SECRET: string; + INTERNAL_API_SECRET: string; + WORKER_URL: string; + KILOCODE_BACKEND_BASE_URL: string; + AGENT_ENV_VARS_PRIVATE_KEY: string; + GITHUB_APP_ID: string; + GITHUB_APP_PRIVATE_KEY: string; + WS_ALLOWED_ORIGINS: string; + Sandbox: DurableObjectNamespace; + CLOUD_AGENT_SESSION: DurableObjectNamespace; + } + interface Env { + NEXTAUTH_SECRET: string; + INTERNAL_API_SECRET: string; + WORKER_URL: string; + KILOCODE_BACKEND_BASE_URL: string; + AGENT_ENV_VARS_PRIVATE_KEY: string; + GITHUB_APP_ID: string; + GITHUB_APP_PRIVATE_KEY: string; + WS_ALLOWED_ORIGINS: string; + GITHUB_TOKEN_CACHE: KVNamespace; + R2_BUCKET: R2Bucket; + HYPERDRIVE: Hyperdrive; + CALLBACK_QUEUE: Queue; + KILO_OPENROUTER_BASE?: 'http://localhost:3000/api'; + GITHUB_APP_SLUG: 'kiloconnect-development' | 'kiloconnect'; + GITHUB_APP_BOT_USER_ID: '242397087' | '240665456'; + GITHUB_LITE_APP_ID: '' | '2745442'; + GITHUB_LITE_APP_SLUG: '' | 'kiloconnect-lite'; + GITHUB_LITE_APP_BOT_USER_ID: '' | '257753004'; + WRAPPER_IDLE_TIMEOUT_MS: '120000'; + CLI_TIMEOUT_SECONDS: '900'; + REAPER_INTERVAL_MS: '300000'; + STALE_THRESHOLD_MS: '600000'; + PENDING_START_TIMEOUT_MS: '300000'; + R2_ATTACHMENTS_BUCKET: 'cloud-agent-attachments-dev' | 'cloud-agent-attachments'; + Sandbox: DurableObjectNamespace; + CLOUD_AGENT_SESSION: DurableObjectNamespace; + } +} +interface Env extends Cloudflare.Env {} +type StringifyValues> = { + [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; +}; +declare namespace NodeJS { + interface ProcessEnv + extends StringifyValues< + Pick< + Cloudflare.Env, + | 'KILO_OPENROUTER_BASE' + | 'GITHUB_APP_SLUG' + | 'GITHUB_APP_BOT_USER_ID' + | 'GITHUB_LITE_APP_ID' + | 'GITHUB_LITE_APP_SLUG' + | 'GITHUB_LITE_APP_BOT_USER_ID' + | 'WRAPPER_IDLE_TIMEOUT_MS' + | 'CLI_TIMEOUT_SECONDS' + | 'REAPER_INTERVAL_MS' + | 'STALE_THRESHOLD_MS' + | 'PENDING_START_TIMEOUT_MS' + | 'R2_ATTACHMENTS_BUCKET' + | 'NEXTAUTH_SECRET' + | 'INTERNAL_API_SECRET' + | 'WORKER_URL' + | 'KILOCODE_BACKEND_BASE_URL' + | 'AGENT_ENV_VARS_PRIVATE_KEY' + | 'GITHUB_APP_ID' + | 'GITHUB_APP_PRIVATE_KEY' + | 'WS_ALLOWED_ORIGINS' + > + > {} +} + +// Begin runtime types +/*! ***************************************************************************** +Copyright (c) Cloudflare. All rights reserved. +Copyright (c) Microsoft Corporation. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. You may obtain a copy of the +License at http://www.apache.org/licenses/LICENSE-2.0 +THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED +WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE, +MERCHANTABLITY OR NON-INFRINGEMENT. +See the Apache Version 2.0 License for specific language governing permissions +and limitations under the License. +***************************************************************************** */ +/* eslint-disable */ +// noinspection JSUnusedGlobalSymbols +declare var onmessage: never; +/** + * The **`DOMException`** interface represents an abnormal event (called an **exception**) that occurs as a result of calling a method or accessing a property of a web API. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException) + */ +declare class DOMException extends Error { + constructor(message?: string, name?: string); + /** + * The **`message`** read-only property of the a message or description associated with the given error name. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/message) + */ + readonly message: string; + /** + * The **`name`** read-only property of the one of the strings associated with an error name. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/name) + */ + readonly name: string; + /** + * The **`code`** read-only property of the DOMException interface returns one of the legacy error code constants, or `0` if none match. + * @deprecated + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/code) + */ + readonly code: number; + static readonly INDEX_SIZE_ERR: number; + static readonly DOMSTRING_SIZE_ERR: number; + static readonly HIERARCHY_REQUEST_ERR: number; + static readonly WRONG_DOCUMENT_ERR: number; + static readonly INVALID_CHARACTER_ERR: number; + static readonly NO_DATA_ALLOWED_ERR: number; + static readonly NO_MODIFICATION_ALLOWED_ERR: number; + static readonly NOT_FOUND_ERR: number; + static readonly NOT_SUPPORTED_ERR: number; + static readonly INUSE_ATTRIBUTE_ERR: number; + static readonly INVALID_STATE_ERR: number; + static readonly SYNTAX_ERR: number; + static readonly INVALID_MODIFICATION_ERR: number; + static readonly NAMESPACE_ERR: number; + static readonly INVALID_ACCESS_ERR: number; + static readonly VALIDATION_ERR: number; + static readonly TYPE_MISMATCH_ERR: number; + static readonly SECURITY_ERR: number; + static readonly NETWORK_ERR: number; + static readonly ABORT_ERR: number; + static readonly URL_MISMATCH_ERR: number; + static readonly QUOTA_EXCEEDED_ERR: number; + static readonly TIMEOUT_ERR: number; + static readonly INVALID_NODE_TYPE_ERR: number; + static readonly DATA_CLONE_ERR: number; + get stack(): any; + set stack(value: any); +} +type WorkerGlobalScopeEventMap = { + fetch: FetchEvent; + scheduled: ScheduledEvent; + queue: QueueEvent; + unhandledrejection: PromiseRejectionEvent; + rejectionhandled: PromiseRejectionEvent; +}; +declare abstract class WorkerGlobalScope extends EventTarget { + EventTarget: typeof EventTarget; +} +/* The **`console`** object provides access to the debugging console (e.g., the Web console in Firefox). * + * The **`console`** object provides access to the debugging console (e.g., the Web console in Firefox). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console) + */ +interface Console { + 'assert'(condition?: boolean, ...data: any[]): void; + /** + * The **`console.clear()`** static method clears the console if possible. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/clear_static) + */ + clear(): void; + /** + * The **`console.count()`** static method logs the number of times that this particular call to `count()` has been called. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/count_static) + */ + count(label?: string): void; + /** + * The **`console.countReset()`** static method resets counter used with console/count_static. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/countReset_static) + */ + countReset(label?: string): void; + /** + * The **`console.debug()`** static method outputs a message to the console at the 'debug' log level. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/debug_static) + */ + debug(...data: any[]): void; + /** + * The **`console.dir()`** static method displays a list of the properties of the specified JavaScript object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dir_static) + */ + dir(item?: any, options?: any): void; + /** + * The **`console.dirxml()`** static method displays an interactive tree of the descendant elements of the specified XML/HTML element. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dirxml_static) + */ + dirxml(...data: any[]): void; + /** + * The **`console.error()`** static method outputs a message to the console at the 'error' log level. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/error_static) + */ + error(...data: any[]): void; + /** + * The **`console.group()`** static method creates a new inline group in the Web console log, causing any subsequent console messages to be indented by an additional level, until console/groupEnd_static is called. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/group_static) + */ + group(...data: any[]): void; + /** + * The **`console.groupCollapsed()`** static method creates a new inline group in the console. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupCollapsed_static) + */ + groupCollapsed(...data: any[]): void; + /** + * The **`console.groupEnd()`** static method exits the current inline group in the console. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupEnd_static) + */ + groupEnd(): void; + /** + * The **`console.info()`** static method outputs a message to the console at the 'info' log level. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/info_static) + */ + info(...data: any[]): void; + /** + * The **`console.log()`** static method outputs a message to the console. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static) + */ + log(...data: any[]): void; + /** + * The **`console.table()`** static method displays tabular data as a table. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/table_static) + */ + table(tabularData?: any, properties?: string[]): void; + /** + * The **`console.time()`** static method starts a timer you can use to track how long an operation takes. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/time_static) + */ + time(label?: string): void; + /** + * The **`console.timeEnd()`** static method stops a timer that was previously started by calling console/time_static. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeEnd_static) + */ + timeEnd(label?: string): void; + /** + * The **`console.timeLog()`** static method logs the current value of a timer that was previously started by calling console/time_static. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeLog_static) + */ + timeLog(label?: string, ...data: any[]): void; + timeStamp(label?: string): void; + /** + * The **`console.trace()`** static method outputs a stack trace to the console. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/trace_static) + */ + trace(...data: any[]): void; + /** + * The **`console.warn()`** static method outputs a warning message to the console at the 'warning' log level. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/warn_static) + */ + warn(...data: any[]): void; +} +declare const console: Console; +type BufferSource = ArrayBufferView | ArrayBuffer; +type TypedArray = + | Int8Array + | Uint8Array + | Uint8ClampedArray + | Int16Array + | Uint16Array + | Int32Array + | Uint32Array + | Float32Array + | Float64Array + | BigInt64Array + | BigUint64Array; +declare namespace WebAssembly { + class CompileError extends Error { + constructor(message?: string); + } + class RuntimeError extends Error { + constructor(message?: string); + } + type ValueType = 'anyfunc' | 'externref' | 'f32' | 'f64' | 'i32' | 'i64' | 'v128'; + interface GlobalDescriptor { + value: ValueType; + mutable?: boolean; + } + class Global { + constructor(descriptor: GlobalDescriptor, value?: any); + value: any; + valueOf(): any; + } + type ImportValue = ExportValue | number; + type ModuleImports = Record; + type Imports = Record; + type ExportValue = Function | Global | Memory | Table; + type Exports = Record; + class Instance { + constructor(module: Module, imports?: Imports); + readonly exports: Exports; + } + interface MemoryDescriptor { + initial: number; + maximum?: number; + shared?: boolean; + } + class Memory { + constructor(descriptor: MemoryDescriptor); + readonly buffer: ArrayBuffer; + grow(delta: number): number; + } + type ImportExportKind = 'function' | 'global' | 'memory' | 'table'; + interface ModuleExportDescriptor { + kind: ImportExportKind; + name: string; + } + interface ModuleImportDescriptor { + kind: ImportExportKind; + module: string; + name: string; + } + abstract class Module { + static customSections(module: Module, sectionName: string): ArrayBuffer[]; + static exports(module: Module): ModuleExportDescriptor[]; + static imports(module: Module): ModuleImportDescriptor[]; + } + type TableKind = 'anyfunc' | 'externref'; + interface TableDescriptor { + element: TableKind; + initial: number; + maximum?: number; + } + class Table { + constructor(descriptor: TableDescriptor, value?: any); + readonly length: number; + get(index: number): any; + grow(delta: number, value?: any): number; + set(index: number, value?: any): void; + } + function instantiate(module: Module, imports?: Imports): Promise; + function validate(bytes: BufferSource): boolean; +} +/** + * The **`ServiceWorkerGlobalScope`** interface of the Service Worker API represents the global execution context of a service worker. + * Available only in secure contexts. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ServiceWorkerGlobalScope) + */ +interface ServiceWorkerGlobalScope extends WorkerGlobalScope { + DOMException: typeof DOMException; + WorkerGlobalScope: typeof WorkerGlobalScope; + btoa(data: string): string; + atob(data: string): string; + setTimeout(callback: (...args: any[]) => void, msDelay?: number): number; + setTimeout( + callback: (...args: Args) => void, + msDelay?: number, + ...args: Args + ): number; + clearTimeout(timeoutId: number | null): void; + setInterval(callback: (...args: any[]) => void, msDelay?: number): number; + setInterval( + callback: (...args: Args) => void, + msDelay?: number, + ...args: Args + ): number; + clearInterval(timeoutId: number | null): void; + queueMicrotask(task: Function): void; + structuredClone(value: T, options?: StructuredSerializeOptions): T; + reportError(error: any): void; + fetch(input: RequestInfo | URL, init?: RequestInit): Promise; + self: ServiceWorkerGlobalScope; + crypto: Crypto; + caches: CacheStorage; + scheduler: Scheduler; + performance: Performance; + Cloudflare: Cloudflare; + readonly origin: string; + Event: typeof Event; + ExtendableEvent: typeof ExtendableEvent; + CustomEvent: typeof CustomEvent; + PromiseRejectionEvent: typeof PromiseRejectionEvent; + FetchEvent: typeof FetchEvent; + TailEvent: typeof TailEvent; + TraceEvent: typeof TailEvent; + ScheduledEvent: typeof ScheduledEvent; + MessageEvent: typeof MessageEvent; + CloseEvent: typeof CloseEvent; + ReadableStreamDefaultReader: typeof ReadableStreamDefaultReader; + ReadableStreamBYOBReader: typeof ReadableStreamBYOBReader; + ReadableStream: typeof ReadableStream; + WritableStream: typeof WritableStream; + WritableStreamDefaultWriter: typeof WritableStreamDefaultWriter; + TransformStream: typeof TransformStream; + ByteLengthQueuingStrategy: typeof ByteLengthQueuingStrategy; + CountQueuingStrategy: typeof CountQueuingStrategy; + ErrorEvent: typeof ErrorEvent; + MessageChannel: typeof MessageChannel; + MessagePort: typeof MessagePort; + EventSource: typeof EventSource; + ReadableStreamBYOBRequest: typeof ReadableStreamBYOBRequest; + ReadableStreamDefaultController: typeof ReadableStreamDefaultController; + ReadableByteStreamController: typeof ReadableByteStreamController; + WritableStreamDefaultController: typeof WritableStreamDefaultController; + TransformStreamDefaultController: typeof TransformStreamDefaultController; + CompressionStream: typeof CompressionStream; + DecompressionStream: typeof DecompressionStream; + TextEncoderStream: typeof TextEncoderStream; + TextDecoderStream: typeof TextDecoderStream; + Headers: typeof Headers; + Body: typeof Body; + Request: typeof Request; + Response: typeof Response; + WebSocket: typeof WebSocket; + WebSocketPair: typeof WebSocketPair; + WebSocketRequestResponsePair: typeof WebSocketRequestResponsePair; + AbortController: typeof AbortController; + AbortSignal: typeof AbortSignal; + TextDecoder: typeof TextDecoder; + TextEncoder: typeof TextEncoder; + navigator: Navigator; + Navigator: typeof Navigator; + URL: typeof URL; + URLSearchParams: typeof URLSearchParams; + URLPattern: typeof URLPattern; + Blob: typeof Blob; + File: typeof File; + FormData: typeof FormData; + Crypto: typeof Crypto; + SubtleCrypto: typeof SubtleCrypto; + CryptoKey: typeof CryptoKey; + CacheStorage: typeof CacheStorage; + Cache: typeof Cache; + FixedLengthStream: typeof FixedLengthStream; + IdentityTransformStream: typeof IdentityTransformStream; + HTMLRewriter: typeof HTMLRewriter; +} +declare function addEventListener( + type: Type, + handler: EventListenerOrEventListenerObject, + options?: EventTargetAddEventListenerOptions | boolean +): void; +declare function removeEventListener( + type: Type, + handler: EventListenerOrEventListenerObject, + options?: EventTargetEventListenerOptions | boolean +): void; +/** + * The **`dispatchEvent()`** method of the EventTarget sends an Event to the object, (synchronously) invoking the affected event listeners in the appropriate order. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/dispatchEvent) + */ +declare function dispatchEvent( + event: WorkerGlobalScopeEventMap[keyof WorkerGlobalScopeEventMap] +): boolean; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/btoa) */ +declare function btoa(data: string): string; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/atob) */ +declare function atob(data: string): string; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setTimeout) */ +declare function setTimeout(callback: (...args: any[]) => void, msDelay?: number): number; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setTimeout) */ +declare function setTimeout( + callback: (...args: Args) => void, + msDelay?: number, + ...args: Args +): number; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/clearTimeout) */ +declare function clearTimeout(timeoutId: number | null): void; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setInterval) */ +declare function setInterval(callback: (...args: any[]) => void, msDelay?: number): number; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setInterval) */ +declare function setInterval( + callback: (...args: Args) => void, + msDelay?: number, + ...args: Args +): number; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/clearInterval) */ +declare function clearInterval(timeoutId: number | null): void; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/queueMicrotask) */ +declare function queueMicrotask(task: Function): void; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/structuredClone) */ +declare function structuredClone(value: T, options?: StructuredSerializeOptions): T; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/reportError) */ +declare function reportError(error: any): void; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/fetch) */ +declare function fetch( + input: RequestInfo | URL, + init?: RequestInit +): Promise; +declare const self: ServiceWorkerGlobalScope; +/** + * The Web Crypto API provides a set of low-level functions for common cryptographic tasks. + * The Workers runtime implements the full surface of this API, but with some differences in + * the [supported algorithms](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/#supported-algorithms) + * compared to those implemented in most browsers. + * + * [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/) + */ +declare const crypto: Crypto; +/** + * The Cache API allows fine grained control of reading and writing from the Cloudflare global network cache. + * + * [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/) + */ +declare const caches: CacheStorage; +declare const scheduler: Scheduler; +/** + * The Workers runtime supports a subset of the Performance API, used to measure timing and performance, + * as well as timing of subrequests and other operations. + * + * [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/) + */ +declare const performance: Performance; +declare const Cloudflare: Cloudflare; +declare const origin: string; +declare const navigator: Navigator; +interface TestController {} +interface ExecutionContext { + waitUntil(promise: Promise): void; + passThroughOnException(): void; + readonly props: Props; +} +type ExportedHandlerFetchHandler = ( + request: Request>, + env: Env, + ctx: ExecutionContext +) => Response | Promise; +type ExportedHandlerTailHandler = ( + events: TraceItem[], + env: Env, + ctx: ExecutionContext +) => void | Promise; +type ExportedHandlerTraceHandler = ( + traces: TraceItem[], + env: Env, + ctx: ExecutionContext +) => void | Promise; +type ExportedHandlerTailStreamHandler = ( + event: TailStream.TailEvent, + env: Env, + ctx: ExecutionContext +) => TailStream.TailEventHandlerType | Promise; +type ExportedHandlerScheduledHandler = ( + controller: ScheduledController, + env: Env, + ctx: ExecutionContext +) => void | Promise; +type ExportedHandlerQueueHandler = ( + batch: MessageBatch, + env: Env, + ctx: ExecutionContext +) => void | Promise; +type ExportedHandlerTestHandler = ( + controller: TestController, + env: Env, + ctx: ExecutionContext +) => void | Promise; +interface ExportedHandler { + fetch?: ExportedHandlerFetchHandler; + tail?: ExportedHandlerTailHandler; + trace?: ExportedHandlerTraceHandler; + tailStream?: ExportedHandlerTailStreamHandler; + scheduled?: ExportedHandlerScheduledHandler; + test?: ExportedHandlerTestHandler; + email?: EmailExportedHandler; + queue?: ExportedHandlerQueueHandler; +} +interface StructuredSerializeOptions { + transfer?: any[]; +} +declare abstract class Navigator { + sendBeacon(url: string, body?: BodyInit): boolean; + readonly userAgent: string; + readonly hardwareConcurrency: number; + readonly language: string; + readonly languages: string[]; +} +interface AlarmInvocationInfo { + readonly isRetry: boolean; + readonly retryCount: number; +} +interface Cloudflare { + readonly compatibilityFlags: Record; +} +interface DurableObject { + fetch(request: Request): Response | Promise; + alarm?(alarmInfo?: AlarmInvocationInfo): void | Promise; + webSocketMessage?(ws: WebSocket, message: string | ArrayBuffer): void | Promise; + webSocketClose?( + ws: WebSocket, + code: number, + reason: string, + wasClean: boolean + ): void | Promise; + webSocketError?(ws: WebSocket, error: unknown): void | Promise; +} +type DurableObjectStub = Fetcher< + T, + 'alarm' | 'webSocketMessage' | 'webSocketClose' | 'webSocketError' +> & { + readonly id: DurableObjectId; + readonly name?: string; +}; +interface DurableObjectId { + toString(): string; + equals(other: DurableObjectId): boolean; + readonly name?: string; +} +declare abstract class DurableObjectNamespace< + T extends Rpc.DurableObjectBranded | undefined = undefined, +> { + newUniqueId(options?: DurableObjectNamespaceNewUniqueIdOptions): DurableObjectId; + idFromName(name: string): DurableObjectId; + idFromString(id: string): DurableObjectId; + get( + id: DurableObjectId, + options?: DurableObjectNamespaceGetDurableObjectOptions + ): DurableObjectStub; + getByName( + name: string, + options?: DurableObjectNamespaceGetDurableObjectOptions + ): DurableObjectStub; + jurisdiction(jurisdiction: DurableObjectJurisdiction): DurableObjectNamespace; +} +type DurableObjectJurisdiction = 'eu' | 'fedramp' | 'fedramp-high'; +interface DurableObjectNamespaceNewUniqueIdOptions { + jurisdiction?: DurableObjectJurisdiction; +} +type DurableObjectLocationHint = + | 'wnam' + | 'enam' + | 'sam' + | 'weur' + | 'eeur' + | 'apac' + | 'oc' + | 'afr' + | 'me'; +type DurableObjectRoutingMode = 'primary-only'; +interface DurableObjectNamespaceGetDurableObjectOptions { + locationHint?: DurableObjectLocationHint; + routingMode?: DurableObjectRoutingMode; +} +interface DurableObjectClass<_T extends Rpc.DurableObjectBranded | undefined = undefined> {} +interface DurableObjectState { + waitUntil(promise: Promise): void; + readonly props: Props; + readonly id: DurableObjectId; + readonly storage: DurableObjectStorage; + container?: Container; + blockConcurrencyWhile(callback: () => Promise): Promise; + acceptWebSocket(ws: WebSocket, tags?: string[]): void; + getWebSockets(tag?: string): WebSocket[]; + setWebSocketAutoResponse(maybeReqResp?: WebSocketRequestResponsePair): void; + getWebSocketAutoResponse(): WebSocketRequestResponsePair | null; + getWebSocketAutoResponseTimestamp(ws: WebSocket): Date | null; + setHibernatableWebSocketEventTimeout(timeoutMs?: number): void; + getHibernatableWebSocketEventTimeout(): number | null; + getTags(ws: WebSocket): string[]; + abort(reason?: string): void; +} +interface DurableObjectTransaction { + get(key: string, options?: DurableObjectGetOptions): Promise; + get(keys: string[], options?: DurableObjectGetOptions): Promise>; + list(options?: DurableObjectListOptions): Promise>; + put(key: string, value: T, options?: DurableObjectPutOptions): Promise; + put(entries: Record, options?: DurableObjectPutOptions): Promise; + delete(key: string, options?: DurableObjectPutOptions): Promise; + delete(keys: string[], options?: DurableObjectPutOptions): Promise; + rollback(): void; + getAlarm(options?: DurableObjectGetAlarmOptions): Promise; + setAlarm(scheduledTime: number | Date, options?: DurableObjectSetAlarmOptions): Promise; + deleteAlarm(options?: DurableObjectSetAlarmOptions): Promise; +} +interface DurableObjectStorage { + get(key: string, options?: DurableObjectGetOptions): Promise; + get(keys: string[], options?: DurableObjectGetOptions): Promise>; + list(options?: DurableObjectListOptions): Promise>; + put(key: string, value: T, options?: DurableObjectPutOptions): Promise; + put(entries: Record, options?: DurableObjectPutOptions): Promise; + delete(key: string, options?: DurableObjectPutOptions): Promise; + delete(keys: string[], options?: DurableObjectPutOptions): Promise; + deleteAll(options?: DurableObjectPutOptions): Promise; + transaction(closure: (txn: DurableObjectTransaction) => Promise): Promise; + getAlarm(options?: DurableObjectGetAlarmOptions): Promise; + setAlarm(scheduledTime: number | Date, options?: DurableObjectSetAlarmOptions): Promise; + deleteAlarm(options?: DurableObjectSetAlarmOptions): Promise; + sync(): Promise; + sql: SqlStorage; + kv: SyncKvStorage; + transactionSync(closure: () => T): T; + getCurrentBookmark(): Promise; + getBookmarkForTime(timestamp: number | Date): Promise; + onNextSessionRestoreBookmark(bookmark: string): Promise; +} +interface DurableObjectListOptions { + start?: string; + startAfter?: string; + end?: string; + prefix?: string; + reverse?: boolean; + limit?: number; + allowConcurrency?: boolean; + noCache?: boolean; +} +interface DurableObjectGetOptions { + allowConcurrency?: boolean; + noCache?: boolean; +} +interface DurableObjectGetAlarmOptions { + allowConcurrency?: boolean; +} +interface DurableObjectPutOptions { + allowConcurrency?: boolean; + allowUnconfirmed?: boolean; + noCache?: boolean; +} +interface DurableObjectSetAlarmOptions { + allowConcurrency?: boolean; + allowUnconfirmed?: boolean; +} +declare class WebSocketRequestResponsePair { + constructor(request: string, response: string); + get request(): string; + get response(): string; +} +interface AnalyticsEngineDataset { + writeDataPoint(event?: AnalyticsEngineDataPoint): void; +} +interface AnalyticsEngineDataPoint { + indexes?: ((ArrayBuffer | string) | null)[]; + doubles?: number[]; + blobs?: ((ArrayBuffer | string) | null)[]; +} +/** + * The **`Event`** interface represents an event which takes place on an `EventTarget`. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event) + */ +declare class Event { + constructor(type: string, init?: EventInit); + /** + * The **`type`** read-only property of the Event interface returns a string containing the event's type. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/type) + */ + get type(): string; + /** + * The **`eventPhase`** read-only property of the being evaluated. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/eventPhase) + */ + get eventPhase(): number; + /** + * The read-only **`composed`** property of the or not the event will propagate across the shadow DOM boundary into the standard DOM. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/composed) + */ + get composed(): boolean; + /** + * The **`bubbles`** read-only property of the Event interface indicates whether the event bubbles up through the DOM tree or not. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/bubbles) + */ + get bubbles(): boolean; + /** + * The **`cancelable`** read-only property of the Event interface indicates whether the event can be canceled, and therefore prevented as if the event never happened. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelable) + */ + get cancelable(): boolean; + /** + * The **`defaultPrevented`** read-only property of the Event interface returns a boolean value indicating whether or not the call to Event.preventDefault() canceled the event. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/defaultPrevented) + */ + get defaultPrevented(): boolean; + /** + * The Event property **`returnValue`** indicates whether the default action for this event has been prevented or not. + * @deprecated + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/returnValue) + */ + get returnValue(): boolean; + /** + * The **`currentTarget`** read-only property of the Event interface identifies the element to which the event handler has been attached. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/currentTarget) + */ + get currentTarget(): EventTarget | undefined; + /** + * The read-only **`target`** property of the dispatched. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/target) + */ + get target(): EventTarget | undefined; + /** + * The deprecated **`Event.srcElement`** is an alias for the Event.target property. + * @deprecated + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/srcElement) + */ + get srcElement(): EventTarget | undefined; + /** + * The **`timeStamp`** read-only property of the Event interface returns the time (in milliseconds) at which the event was created. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/timeStamp) + */ + get timeStamp(): number; + /** + * The **`isTrusted`** read-only property of the when the event was generated by the user agent (including via user actions and programmatic methods such as HTMLElement.focus()), and `false` when the event was dispatched via The only exception is the `click` event, which initializes the `isTrusted` property to `false` in user agents. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/isTrusted) + */ + get isTrusted(): boolean; + /** + * The **`cancelBubble`** property of the Event interface is deprecated. + * @deprecated + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelBubble) + */ + get cancelBubble(): boolean; + /** + * The **`cancelBubble`** property of the Event interface is deprecated. + * @deprecated + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelBubble) + */ + set cancelBubble(value: boolean); + /** + * The **`stopImmediatePropagation()`** method of the If several listeners are attached to the same element for the same event type, they are called in the order in which they were added. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/stopImmediatePropagation) + */ + stopImmediatePropagation(): void; + /** + * The **`preventDefault()`** method of the Event interface tells the user agent that if the event does not get explicitly handled, its default action should not be taken as it normally would be. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/preventDefault) + */ + preventDefault(): void; + /** + * The **`stopPropagation()`** method of the Event interface prevents further propagation of the current event in the capturing and bubbling phases. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/stopPropagation) + */ + stopPropagation(): void; + /** + * The **`composedPath()`** method of the Event interface returns the event's path which is an array of the objects on which listeners will be invoked. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/composedPath) + */ + composedPath(): EventTarget[]; + static readonly NONE: number; + static readonly CAPTURING_PHASE: number; + static readonly AT_TARGET: number; + static readonly BUBBLING_PHASE: number; +} +interface EventInit { + bubbles?: boolean; + cancelable?: boolean; + composed?: boolean; +} +type EventListener = (event: EventType) => void; +interface EventListenerObject { + handleEvent(event: EventType): void; +} +type EventListenerOrEventListenerObject = + | EventListener + | EventListenerObject; +/** + * The **`EventTarget`** interface is implemented by objects that can receive events and may have listeners for them. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget) + */ +declare class EventTarget = Record> { + constructor(); + /** + * The **`addEventListener()`** method of the EventTarget interface sets up a function that will be called whenever the specified event is delivered to the target. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/addEventListener) + */ + addEventListener( + type: Type, + handler: EventListenerOrEventListenerObject, + options?: EventTargetAddEventListenerOptions | boolean + ): void; + /** + * The **`removeEventListener()`** method of the EventTarget interface removes an event listener previously registered with EventTarget.addEventListener() from the target. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/removeEventListener) + */ + removeEventListener( + type: Type, + handler: EventListenerOrEventListenerObject, + options?: EventTargetEventListenerOptions | boolean + ): void; + /** + * The **`dispatchEvent()`** method of the EventTarget sends an Event to the object, (synchronously) invoking the affected event listeners in the appropriate order. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/dispatchEvent) + */ + dispatchEvent(event: EventMap[keyof EventMap]): boolean; +} +interface EventTargetEventListenerOptions { + capture?: boolean; +} +interface EventTargetAddEventListenerOptions { + capture?: boolean; + passive?: boolean; + once?: boolean; + signal?: AbortSignal; +} +interface EventTargetHandlerObject { + handleEvent: (event: Event) => any | undefined; +} +/** + * The **`AbortController`** interface represents a controller object that allows you to abort one or more Web requests as and when desired. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController) + */ +declare class AbortController { + constructor(); + /** + * The **`signal`** read-only property of the AbortController interface returns an AbortSignal object instance, which can be used to communicate with/abort an asynchronous operation as desired. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController/signal) + */ + get signal(): AbortSignal; + /** + * The **`abort()`** method of the AbortController interface aborts an asynchronous operation before it has completed. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController/abort) + */ + abort(reason?: any): void; +} +/** + * The **`AbortSignal`** interface represents a signal object that allows you to communicate with an asynchronous operation (such as a fetch request) and abort it if required via an AbortController object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal) + */ +declare abstract class AbortSignal extends EventTarget { + /** + * The **`AbortSignal.abort()`** static method returns an AbortSignal that is already set as aborted (and which does not trigger an AbortSignal/abort_event event). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_static) + */ + static abort(reason?: any): AbortSignal; + /** + * The **`AbortSignal.timeout()`** static method returns an AbortSignal that will automatically abort after a specified time. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/timeout_static) + */ + static timeout(delay: number): AbortSignal; + /** + * The **`AbortSignal.any()`** static method takes an iterable of abort signals and returns an AbortSignal. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/any_static) + */ + static any(signals: AbortSignal[]): AbortSignal; + /** + * The **`aborted`** read-only property returns a value that indicates whether the asynchronous operations the signal is communicating with are aborted (`true`) or not (`false`). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/aborted) + */ + get aborted(): boolean; + /** + * The **`reason`** read-only property returns a JavaScript value that indicates the abort reason. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/reason) + */ + get reason(): any; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_event) */ + get onabort(): any | null; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_event) */ + set onabort(value: any | null); + /** + * The **`throwIfAborted()`** method throws the signal's abort AbortSignal.reason if the signal has been aborted; otherwise it does nothing. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/throwIfAborted) + */ + throwIfAborted(): void; +} +interface Scheduler { + wait(delay: number, maybeOptions?: SchedulerWaitOptions): Promise; +} +interface SchedulerWaitOptions { + signal?: AbortSignal; +} +/** + * The **`ExtendableEvent`** interface extends the lifetime of the `install` and `activate` events dispatched on the global scope as part of the service worker lifecycle. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ExtendableEvent) + */ +declare abstract class ExtendableEvent extends Event { + /** + * The **`ExtendableEvent.waitUntil()`** method tells the event dispatcher that work is ongoing. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ExtendableEvent/waitUntil) + */ + waitUntil(promise: Promise): void; +} +/** + * The **`CustomEvent`** interface represents events initialized by an application for any purpose. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CustomEvent) + */ +declare class CustomEvent extends Event { + constructor(type: string, init?: CustomEventCustomEventInit); + /** + * The read-only **`detail`** property of the CustomEvent interface returns any data passed when initializing the event. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CustomEvent/detail) + */ + get detail(): T; +} +interface CustomEventCustomEventInit { + bubbles?: boolean; + cancelable?: boolean; + composed?: boolean; + detail?: any; +} +/** + * The **`Blob`** interface represents a blob, which is a file-like object of immutable, raw data; they can be read as text or binary data, or converted into a ReadableStream so its methods can be used for processing the data. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob) + */ +declare class Blob { + constructor(type?: ((ArrayBuffer | ArrayBufferView) | string | Blob)[], options?: BlobOptions); + /** + * The **`size`** read-only property of the Blob interface returns the size of the Blob or File in bytes. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/size) + */ + get size(): number; + /** + * The **`type`** read-only property of the Blob interface returns the MIME type of the file. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/type) + */ + get type(): string; + /** + * The **`slice()`** method of the Blob interface creates and returns a new `Blob` object which contains data from a subset of the blob on which it's called. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/slice) + */ + slice(start?: number, end?: number, type?: string): Blob; + /** + * The **`arrayBuffer()`** method of the Blob interface returns a Promise that resolves with the contents of the blob as binary data contained in an ArrayBuffer. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/arrayBuffer) + */ + arrayBuffer(): Promise; + /** + * The **`bytes()`** method of the Blob interface returns a Promise that resolves with a Uint8Array containing the contents of the blob as an array of bytes. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/bytes) + */ + bytes(): Promise; + /** + * The **`text()`** method of the string containing the contents of the blob, interpreted as UTF-8. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/text) + */ + text(): Promise; + /** + * The **`stream()`** method of the Blob interface returns a ReadableStream which upon reading returns the data contained within the `Blob`. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/stream) + */ + stream(): ReadableStream; +} +interface BlobOptions { + type?: string; +} +/** + * The **`File`** interface provides information about files and allows JavaScript in a web page to access their content. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/File) + */ +declare class File extends Blob { + constructor( + bits: ((ArrayBuffer | ArrayBufferView) | string | Blob)[] | undefined, + name: string, + options?: FileOptions + ); + /** + * The **`name`** read-only property of the File interface returns the name of the file represented by a File object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/name) + */ + get name(): string; + /** + * The **`lastModified`** read-only property of the File interface provides the last modified date of the file as the number of milliseconds since the Unix epoch (January 1, 1970 at midnight). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/lastModified) + */ + get lastModified(): number; +} +interface FileOptions { + type?: string; + lastModified?: number; +} +/** + * The Cache API allows fine grained control of reading and writing from the Cloudflare global network cache. + * + * [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/) + */ +declare abstract class CacheStorage { + /** + * The **`open()`** method of the the Cache object matching the `cacheName`. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CacheStorage/open) + */ + open(cacheName: string): Promise; + readonly default: Cache; +} +/** + * The Cache API allows fine grained control of reading and writing from the Cloudflare global network cache. + * + * [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/) + */ +declare abstract class Cache { + /* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/#delete) */ + delete(request: RequestInfo | URL, options?: CacheQueryOptions): Promise; + /* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/#match) */ + match(request: RequestInfo | URL, options?: CacheQueryOptions): Promise; + /* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/#put) */ + put(request: RequestInfo | URL, response: Response): Promise; +} +interface CacheQueryOptions { + ignoreMethod?: boolean; +} +/** + * The Web Crypto API provides a set of low-level functions for common cryptographic tasks. + * The Workers runtime implements the full surface of this API, but with some differences in + * the [supported algorithms](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/#supported-algorithms) + * compared to those implemented in most browsers. + * + * [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/) + */ +declare abstract class Crypto { + /** + * The **`Crypto.subtle`** read-only property returns a cryptographic operations. + * Available only in secure contexts. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/subtle) + */ + get subtle(): SubtleCrypto; + /** + * The **`Crypto.getRandomValues()`** method lets you get cryptographically strong random values. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/getRandomValues) + */ + getRandomValues< + T extends + | Int8Array + | Uint8Array + | Int16Array + | Uint16Array + | Int32Array + | Uint32Array + | BigInt64Array + | BigUint64Array, + >(buffer: T): T; + /** + * The **`randomUUID()`** method of the Crypto interface is used to generate a v4 UUID using a cryptographically secure random number generator. + * Available only in secure contexts. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/randomUUID) + */ + randomUUID(): string; + DigestStream: typeof DigestStream; +} +/** + * The **`SubtleCrypto`** interface of the Web Crypto API provides a number of low-level cryptographic functions. + * Available only in secure contexts. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto) + */ +declare abstract class SubtleCrypto { + /** + * The **`encrypt()`** method of the SubtleCrypto interface encrypts data. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/encrypt) + */ + encrypt( + algorithm: string | SubtleCryptoEncryptAlgorithm, + key: CryptoKey, + plainText: ArrayBuffer | ArrayBufferView + ): Promise; + /** + * The **`decrypt()`** method of the SubtleCrypto interface decrypts some encrypted data. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/decrypt) + */ + decrypt( + algorithm: string | SubtleCryptoEncryptAlgorithm, + key: CryptoKey, + cipherText: ArrayBuffer | ArrayBufferView + ): Promise; + /** + * The **`sign()`** method of the SubtleCrypto interface generates a digital signature. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/sign) + */ + sign( + algorithm: string | SubtleCryptoSignAlgorithm, + key: CryptoKey, + data: ArrayBuffer | ArrayBufferView + ): Promise; + /** + * The **`verify()`** method of the SubtleCrypto interface verifies a digital signature. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/verify) + */ + verify( + algorithm: string | SubtleCryptoSignAlgorithm, + key: CryptoKey, + signature: ArrayBuffer | ArrayBufferView, + data: ArrayBuffer | ArrayBufferView + ): Promise; + /** + * The **`digest()`** method of the SubtleCrypto interface generates a _digest_ of the given data, using the specified hash function. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/digest) + */ + digest( + algorithm: string | SubtleCryptoHashAlgorithm, + data: ArrayBuffer | ArrayBufferView + ): Promise; + /** + * The **`generateKey()`** method of the SubtleCrypto interface is used to generate a new key (for symmetric algorithms) or key pair (for public-key algorithms). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/generateKey) + */ + generateKey( + algorithm: string | SubtleCryptoGenerateKeyAlgorithm, + extractable: boolean, + keyUsages: string[] + ): Promise; + /** + * The **`deriveKey()`** method of the SubtleCrypto interface can be used to derive a secret key from a master key. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/deriveKey) + */ + deriveKey( + algorithm: string | SubtleCryptoDeriveKeyAlgorithm, + baseKey: CryptoKey, + derivedKeyAlgorithm: string | SubtleCryptoImportKeyAlgorithm, + extractable: boolean, + keyUsages: string[] + ): Promise; + /** + * The **`deriveBits()`** method of the key. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/deriveBits) + */ + deriveBits( + algorithm: string | SubtleCryptoDeriveKeyAlgorithm, + baseKey: CryptoKey, + length?: number | null + ): Promise; + /** + * The **`importKey()`** method of the SubtleCrypto interface imports a key: that is, it takes as input a key in an external, portable format and gives you a CryptoKey object that you can use in the Web Crypto API. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/importKey) + */ + importKey( + format: string, + keyData: (ArrayBuffer | ArrayBufferView) | JsonWebKey, + algorithm: string | SubtleCryptoImportKeyAlgorithm, + extractable: boolean, + keyUsages: string[] + ): Promise; + /** + * The **`exportKey()`** method of the SubtleCrypto interface exports a key: that is, it takes as input a CryptoKey object and gives you the key in an external, portable format. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/exportKey) + */ + exportKey(format: string, key: CryptoKey): Promise; + /** + * The **`wrapKey()`** method of the SubtleCrypto interface 'wraps' a key. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/wrapKey) + */ + wrapKey( + format: string, + key: CryptoKey, + wrappingKey: CryptoKey, + wrapAlgorithm: string | SubtleCryptoEncryptAlgorithm + ): Promise; + /** + * The **`unwrapKey()`** method of the SubtleCrypto interface 'unwraps' a key. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/unwrapKey) + */ + unwrapKey( + format: string, + wrappedKey: ArrayBuffer | ArrayBufferView, + unwrappingKey: CryptoKey, + unwrapAlgorithm: string | SubtleCryptoEncryptAlgorithm, + unwrappedKeyAlgorithm: string | SubtleCryptoImportKeyAlgorithm, + extractable: boolean, + keyUsages: string[] + ): Promise; + timingSafeEqual(a: ArrayBuffer | ArrayBufferView, b: ArrayBuffer | ArrayBufferView): boolean; +} +/** + * The **`CryptoKey`** interface of the Web Crypto API represents a cryptographic key obtained from one of the SubtleCrypto methods SubtleCrypto.generateKey, SubtleCrypto.deriveKey, SubtleCrypto.importKey, or SubtleCrypto.unwrapKey. + * Available only in secure contexts. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey) + */ +declare abstract class CryptoKey { + /** + * The read-only **`type`** property of the CryptoKey interface indicates which kind of key is represented by the object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/type) + */ + readonly type: string; + /** + * The read-only **`extractable`** property of the CryptoKey interface indicates whether or not the key may be extracted using `SubtleCrypto.exportKey()` or `SubtleCrypto.wrapKey()`. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/extractable) + */ + readonly extractable: boolean; + /** + * The read-only **`algorithm`** property of the CryptoKey interface returns an object describing the algorithm for which this key can be used, and any associated extra parameters. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/algorithm) + */ + readonly algorithm: + | CryptoKeyKeyAlgorithm + | CryptoKeyAesKeyAlgorithm + | CryptoKeyHmacKeyAlgorithm + | CryptoKeyRsaKeyAlgorithm + | CryptoKeyEllipticKeyAlgorithm + | CryptoKeyArbitraryKeyAlgorithm; + /** + * The read-only **`usages`** property of the CryptoKey interface indicates what can be done with the key. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/usages) + */ + readonly usages: string[]; +} +interface CryptoKeyPair { + publicKey: CryptoKey; + privateKey: CryptoKey; +} +interface JsonWebKey { + kty: string; + use?: string; + key_ops?: string[]; + alg?: string; + ext?: boolean; + crv?: string; + x?: string; + y?: string; + d?: string; + n?: string; + e?: string; + p?: string; + q?: string; + dp?: string; + dq?: string; + qi?: string; + oth?: RsaOtherPrimesInfo[]; + k?: string; +} +interface RsaOtherPrimesInfo { + r?: string; + d?: string; + t?: string; +} +interface SubtleCryptoDeriveKeyAlgorithm { + name: string; + salt?: ArrayBuffer | ArrayBufferView; + iterations?: number; + hash?: string | SubtleCryptoHashAlgorithm; + $public?: CryptoKey; + info?: ArrayBuffer | ArrayBufferView; +} +interface SubtleCryptoEncryptAlgorithm { + name: string; + iv?: ArrayBuffer | ArrayBufferView; + additionalData?: ArrayBuffer | ArrayBufferView; + tagLength?: number; + counter?: ArrayBuffer | ArrayBufferView; + length?: number; + label?: ArrayBuffer | ArrayBufferView; +} +interface SubtleCryptoGenerateKeyAlgorithm { + name: string; + hash?: string | SubtleCryptoHashAlgorithm; + modulusLength?: number; + publicExponent?: ArrayBuffer | ArrayBufferView; + length?: number; + namedCurve?: string; +} +interface SubtleCryptoHashAlgorithm { + name: string; +} +interface SubtleCryptoImportKeyAlgorithm { + name: string; + hash?: string | SubtleCryptoHashAlgorithm; + length?: number; + namedCurve?: string; + compressed?: boolean; +} +interface SubtleCryptoSignAlgorithm { + name: string; + hash?: string | SubtleCryptoHashAlgorithm; + dataLength?: number; + saltLength?: number; +} +interface CryptoKeyKeyAlgorithm { + name: string; +} +interface CryptoKeyAesKeyAlgorithm { + name: string; + length: number; +} +interface CryptoKeyHmacKeyAlgorithm { + name: string; + hash: CryptoKeyKeyAlgorithm; + length: number; +} +interface CryptoKeyRsaKeyAlgorithm { + name: string; + modulusLength: number; + publicExponent: ArrayBuffer | ArrayBufferView; + hash?: CryptoKeyKeyAlgorithm; +} +interface CryptoKeyEllipticKeyAlgorithm { + name: string; + namedCurve: string; +} +interface CryptoKeyArbitraryKeyAlgorithm { + name: string; + hash?: CryptoKeyKeyAlgorithm; + namedCurve?: string; + length?: number; +} +declare class DigestStream extends WritableStream { + constructor(algorithm: string | SubtleCryptoHashAlgorithm); + readonly digest: Promise; + get bytesWritten(): number | bigint; +} +/** + * The **`TextDecoder`** interface represents a decoder for a specific text encoding, such as `UTF-8`, `ISO-8859-2`, `KOI8-R`, `GBK`, etc. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder) + */ +declare class TextDecoder { + constructor(label?: string, options?: TextDecoderConstructorOptions); + /** + * The **`TextDecoder.decode()`** method returns a string containing text decoded from the buffer passed as a parameter. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder/decode) + */ + decode(input?: ArrayBuffer | ArrayBufferView, options?: TextDecoderDecodeOptions): string; + get encoding(): string; + get fatal(): boolean; + get ignoreBOM(): boolean; +} +/** + * The **`TextEncoder`** interface takes a stream of code points as input and emits a stream of UTF-8 bytes. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder) + */ +declare class TextEncoder { + constructor(); + /** + * The **`TextEncoder.encode()`** method takes a string as input, and returns a Global_Objects/Uint8Array containing the text given in parameters encoded with the specific method for that TextEncoder object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder/encode) + */ + encode(input?: string): Uint8Array; + /** + * The **`TextEncoder.encodeInto()`** method takes a string to encode and a destination Uint8Array to put resulting UTF-8 encoded text into, and returns a dictionary object indicating the progress of the encoding. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder/encodeInto) + */ + encodeInto(input: string, buffer: Uint8Array): TextEncoderEncodeIntoResult; + get encoding(): string; +} +interface TextDecoderConstructorOptions { + fatal: boolean; + ignoreBOM: boolean; +} +interface TextDecoderDecodeOptions { + stream: boolean; +} +interface TextEncoderEncodeIntoResult { + read: number; + written: number; +} +/** + * The **`ErrorEvent`** interface represents events providing information related to errors in scripts or in files. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent) + */ +declare class ErrorEvent extends Event { + constructor(type: string, init?: ErrorEventErrorEventInit); + /** + * The **`filename`** read-only property of the ErrorEvent interface returns a string containing the name of the script file in which the error occurred. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/filename) + */ + get filename(): string; + /** + * The **`message`** read-only property of the ErrorEvent interface returns a string containing a human-readable error message describing the problem. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/message) + */ + get message(): string; + /** + * The **`lineno`** read-only property of the ErrorEvent interface returns an integer containing the line number of the script file on which the error occurred. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/lineno) + */ + get lineno(): number; + /** + * The **`colno`** read-only property of the ErrorEvent interface returns an integer containing the column number of the script file on which the error occurred. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/colno) + */ + get colno(): number; + /** + * The **`error`** read-only property of the ErrorEvent interface returns a JavaScript value, such as an Error or DOMException, representing the error associated with this event. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/error) + */ + get error(): any; +} +interface ErrorEventErrorEventInit { + message?: string; + filename?: string; + lineno?: number; + colno?: number; + error?: any; +} +/** + * The **`MessageEvent`** interface represents a message received by a target object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent) + */ +declare class MessageEvent extends Event { + constructor(type: string, initializer: MessageEventInit); + /** + * The **`data`** read-only property of the The data sent by the message emitter; this can be any data type, depending on what originated this event. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/data) + */ + readonly data: any; + /** + * The **`origin`** read-only property of the origin of the message emitter. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/origin) + */ + readonly origin: string | null; + /** + * The **`lastEventId`** read-only property of the unique ID for the event. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/lastEventId) + */ + readonly lastEventId: string; + /** + * The **`source`** read-only property of the a WindowProxy, MessagePort, or a `MessageEventSource` (which can be a WindowProxy, message emitter. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/source) + */ + readonly source: MessagePort | null; + /** + * The **`ports`** read-only property of the containing all MessagePort objects sent with the message, in order. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/ports) + */ + readonly ports: MessagePort[]; +} +interface MessageEventInit { + data: ArrayBuffer | string; +} +/** + * The **`PromiseRejectionEvent`** interface represents events which are sent to the global script context when JavaScript Promises are rejected. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent) + */ +declare abstract class PromiseRejectionEvent extends Event { + /** + * The PromiseRejectionEvent interface's **`promise`** read-only property indicates the JavaScript rejected. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent/promise) + */ + readonly promise: Promise; + /** + * The PromiseRejectionEvent **`reason`** read-only property is any JavaScript value or Object which provides the reason passed into Promise.reject(). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent/reason) + */ + readonly reason: any; +} +/** + * The **`FormData`** interface provides a way to construct a set of key/value pairs representing form fields and their values, which can be sent using the Window/fetch, XMLHttpRequest.send() or navigator.sendBeacon() methods. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData) + */ +declare class FormData { + constructor(); + /** + * The **`append()`** method of the FormData interface appends a new value onto an existing key inside a `FormData` object, or adds the key if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append) + */ + append(name: string, value: string | Blob): void; + /** + * The **`append()`** method of the FormData interface appends a new value onto an existing key inside a `FormData` object, or adds the key if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append) + */ + append(name: string, value: string): void; + /** + * The **`append()`** method of the FormData interface appends a new value onto an existing key inside a `FormData` object, or adds the key if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append) + */ + append(name: string, value: Blob, filename?: string): void; + /** + * The **`delete()`** method of the FormData interface deletes a key and its value(s) from a `FormData` object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/delete) + */ + delete(name: string): void; + /** + * The **`get()`** method of the FormData interface returns the first value associated with a given key from within a `FormData` object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/get) + */ + get(name: string): (File | string) | null; + /** + * The **`getAll()`** method of the FormData interface returns all the values associated with a given key from within a `FormData` object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/getAll) + */ + getAll(name: string): (File | string)[]; + /** + * The **`has()`** method of the FormData interface returns whether a `FormData` object contains a certain key. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/has) + */ + has(name: string): boolean; + /** + * The **`set()`** method of the FormData interface sets a new value for an existing key inside a `FormData` object, or adds the key/value if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set) + */ + set(name: string, value: string | Blob): void; + /** + * The **`set()`** method of the FormData interface sets a new value for an existing key inside a `FormData` object, or adds the key/value if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set) + */ + set(name: string, value: string): void; + /** + * The **`set()`** method of the FormData interface sets a new value for an existing key inside a `FormData` object, or adds the key/value if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set) + */ + set(name: string, value: Blob, filename?: string): void; + /* Returns an array of key, value pairs for every entry in the list. */ + entries(): IterableIterator<[key: string, value: File | string]>; + /* Returns a list of keys in the list. */ + keys(): IterableIterator; + /* Returns a list of values in the list. */ + values(): IterableIterator; + forEach( + callback: (this: This, value: File | string, key: string, parent: FormData) => void, + thisArg?: This + ): void; + [Symbol.iterator](): IterableIterator<[key: string, value: File | string]>; +} +interface ContentOptions { + html?: boolean; +} +declare class HTMLRewriter { + constructor(); + on(selector: string, handlers: HTMLRewriterElementContentHandlers): HTMLRewriter; + onDocument(handlers: HTMLRewriterDocumentContentHandlers): HTMLRewriter; + transform(response: Response): Response; +} +interface HTMLRewriterElementContentHandlers { + element?(element: Element): void | Promise; + comments?(comment: Comment): void | Promise; + text?(element: Text): void | Promise; +} +interface HTMLRewriterDocumentContentHandlers { + doctype?(doctype: Doctype): void | Promise; + comments?(comment: Comment): void | Promise; + text?(text: Text): void | Promise; + end?(end: DocumentEnd): void | Promise; +} +interface Doctype { + readonly name: string | null; + readonly publicId: string | null; + readonly systemId: string | null; +} +interface Element { + tagName: string; + readonly attributes: IterableIterator; + readonly removed: boolean; + readonly namespaceURI: string; + getAttribute(name: string): string | null; + hasAttribute(name: string): boolean; + setAttribute(name: string, value: string): Element; + removeAttribute(name: string): Element; + before(content: string | ReadableStream | Response, options?: ContentOptions): Element; + after(content: string | ReadableStream | Response, options?: ContentOptions): Element; + prepend(content: string | ReadableStream | Response, options?: ContentOptions): Element; + append(content: string | ReadableStream | Response, options?: ContentOptions): Element; + replace(content: string | ReadableStream | Response, options?: ContentOptions): Element; + remove(): Element; + removeAndKeepContent(): Element; + setInnerContent(content: string | ReadableStream | Response, options?: ContentOptions): Element; + onEndTag(handler: (tag: EndTag) => void | Promise): void; +} +interface EndTag { + name: string; + before(content: string | ReadableStream | Response, options?: ContentOptions): EndTag; + after(content: string | ReadableStream | Response, options?: ContentOptions): EndTag; + remove(): EndTag; +} +interface Comment { + text: string; + readonly removed: boolean; + before(content: string, options?: ContentOptions): Comment; + after(content: string, options?: ContentOptions): Comment; + replace(content: string, options?: ContentOptions): Comment; + remove(): Comment; +} +interface Text { + readonly text: string; + readonly lastInTextNode: boolean; + readonly removed: boolean; + before(content: string | ReadableStream | Response, options?: ContentOptions): Text; + after(content: string | ReadableStream | Response, options?: ContentOptions): Text; + replace(content: string | ReadableStream | Response, options?: ContentOptions): Text; + remove(): Text; +} +interface DocumentEnd { + append(content: string, options?: ContentOptions): DocumentEnd; +} +/** + * This is the event type for `fetch` events dispatched on the ServiceWorkerGlobalScope. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent) + */ +declare abstract class FetchEvent extends ExtendableEvent { + /** + * The **`request`** read-only property of the the event handler. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent/request) + */ + readonly request: Request; + /** + * The **`respondWith()`** method of allows you to provide a promise for a Response yourself. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent/respondWith) + */ + respondWith(promise: Response | Promise): void; + passThroughOnException(): void; +} +type HeadersInit = Headers | Iterable> | Record; +/** + * The **`Headers`** interface of the Fetch API allows you to perform various actions on HTTP request and response headers. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers) + */ +declare class Headers { + constructor(init?: HeadersInit); + /** + * The **`get()`** method of the Headers interface returns a byte string of all the values of a header within a `Headers` object with a given name. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/get) + */ + get(name: string): string | null; + getAll(name: string): string[]; + /** + * The **`getSetCookie()`** method of the Headers interface returns an array containing the values of all Set-Cookie headers associated with a response. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/getSetCookie) + */ + getSetCookie(): string[]; + /** + * The **`has()`** method of the Headers interface returns a boolean stating whether a `Headers` object contains a certain header. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/has) + */ + has(name: string): boolean; + /** + * The **`set()`** method of the Headers interface sets a new value for an existing header inside a `Headers` object, or adds the header if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/set) + */ + set(name: string, value: string): void; + /** + * The **`append()`** method of the Headers interface appends a new value onto an existing header inside a `Headers` object, or adds the header if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/append) + */ + append(name: string, value: string): void; + /** + * The **`delete()`** method of the Headers interface deletes a header from the current `Headers` object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/delete) + */ + delete(name: string): void; + forEach( + callback: (this: This, value: string, key: string, parent: Headers) => void, + thisArg?: This + ): void; + /* Returns an iterator allowing to go through all key/value pairs contained in this object. */ + entries(): IterableIterator<[key: string, value: string]>; + /* Returns an iterator allowing to go through all keys of the key/value pairs contained in this object. */ + keys(): IterableIterator; + /* Returns an iterator allowing to go through all values of the key/value pairs contained in this object. */ + values(): IterableIterator; + [Symbol.iterator](): IterableIterator<[key: string, value: string]>; +} +type BodyInit = + | ReadableStream + | string + | ArrayBuffer + | ArrayBufferView + | Blob + | URLSearchParams + | FormData; +declare abstract class Body { + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/body) */ + get body(): ReadableStream | null; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/bodyUsed) */ + get bodyUsed(): boolean; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/arrayBuffer) */ + arrayBuffer(): Promise; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/bytes) */ + bytes(): Promise; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/text) */ + text(): Promise; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/json) */ + json(): Promise; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/formData) */ + formData(): Promise; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/blob) */ + blob(): Promise; +} +/** + * The **`Response`** interface of the Fetch API represents the response to a request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response) + */ +declare var Response: { + prototype: Response; + new (body?: BodyInit | null, init?: ResponseInit): Response; + error(): Response; + redirect(url: string, status?: number): Response; + json(any: any, maybeInit?: ResponseInit | Response): Response; +}; +/** + * The **`Response`** interface of the Fetch API represents the response to a request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response) + */ +interface Response extends Body { + /** + * The **`clone()`** method of the Response interface creates a clone of a response object, identical in every way, but stored in a different variable. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/clone) + */ + clone(): Response; + /** + * The **`status`** read-only property of the Response interface contains the HTTP status codes of the response. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/status) + */ + status: number; + /** + * The **`statusText`** read-only property of the Response interface contains the status message corresponding to the HTTP status code in Response.status. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/statusText) + */ + statusText: string; + /** + * The **`headers`** read-only property of the with the response. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/headers) + */ + headers: Headers; + /** + * The **`ok`** read-only property of the Response interface contains a Boolean stating whether the response was successful (status in the range 200-299) or not. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/ok) + */ + ok: boolean; + /** + * The **`redirected`** read-only property of the Response interface indicates whether or not the response is the result of a request you made which was redirected. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/redirected) + */ + redirected: boolean; + /** + * The **`url`** read-only property of the Response interface contains the URL of the response. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/url) + */ + url: string; + webSocket: WebSocket | null; + cf: any | undefined; + /** + * The **`type`** read-only property of the Response interface contains the type of the response. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/type) + */ + type: 'default' | 'error'; +} +interface ResponseInit { + status?: number; + statusText?: string; + headers?: HeadersInit; + cf?: any; + webSocket?: WebSocket | null; + encodeBody?: 'automatic' | 'manual'; +} +type RequestInfo> = + | Request + | string; +/** + * The **`Request`** interface of the Fetch API represents a resource request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request) + */ +declare var Request: { + prototype: Request; + new >( + input: RequestInfo | URL, + init?: RequestInit + ): Request; +}; +/** + * The **`Request`** interface of the Fetch API represents a resource request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request) + */ +interface Request> extends Body { + /** + * The **`clone()`** method of the Request interface creates a copy of the current `Request` object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/clone) + */ + clone(): Request; + /** + * The **`method`** read-only property of the `POST`, etc.) A String indicating the method of the request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/method) + */ + method: string; + /** + * The **`url`** read-only property of the Request interface contains the URL of the request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/url) + */ + url: string; + /** + * The **`headers`** read-only property of the with the request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/headers) + */ + headers: Headers; + /** + * The **`redirect`** read-only property of the Request interface contains the mode for how redirects are handled. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/redirect) + */ + redirect: string; + fetcher: Fetcher | null; + /** + * The read-only **`signal`** property of the Request interface returns the AbortSignal associated with the request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/signal) + */ + signal: AbortSignal; + cf: Cf | undefined; + /** + * The **`integrity`** read-only property of the Request interface contains the subresource integrity value of the request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/integrity) + */ + integrity: string; + /** + * The **`keepalive`** read-only property of the Request interface contains the request's `keepalive` setting (`true` or `false`), which indicates whether the browser will keep the associated request alive if the page that initiated it is unloaded before the request is complete. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/keepalive) + */ + keepalive: boolean; + /** + * The **`cache`** read-only property of the Request interface contains the cache mode of the request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/cache) + */ + cache?: 'no-store' | 'no-cache'; +} +interface RequestInit { + /* A string to set request's method. */ + method?: string; + /* A Headers object, an object literal, or an array of two-item arrays to set request's headers. */ + headers?: HeadersInit; + /* A BodyInit object or null to set request's body. */ + body?: BodyInit | null; + /* A string indicating whether request follows redirects, results in an error upon encountering a redirect, or returns the redirect (in an opaque fashion). Sets request's redirect. */ + redirect?: string; + fetcher?: Fetcher | null; + cf?: Cf; + /* A string indicating how the request will interact with the browser's cache to set request's cache. */ + cache?: 'no-store' | 'no-cache'; + /* A cryptographic hash of the resource to be fetched by request. Sets request's integrity. */ + integrity?: string; + /* An AbortSignal to set request's signal. */ + signal?: AbortSignal | null; + encodeResponseBody?: 'automatic' | 'manual'; +} +type Service< + T extends + | (new (...args: any[]) => Rpc.WorkerEntrypointBranded) + | Rpc.WorkerEntrypointBranded + | ExportedHandler + | undefined = undefined, +> = T extends new (...args: any[]) => Rpc.WorkerEntrypointBranded + ? Fetcher> + : T extends Rpc.WorkerEntrypointBranded + ? Fetcher + : T extends Exclude + ? never + : Fetcher; +type Fetcher< + T extends Rpc.EntrypointBranded | undefined = undefined, + Reserved extends string = never, +> = (T extends Rpc.EntrypointBranded + ? Rpc.Provider + : unknown) & { + fetch(input: RequestInfo | URL, init?: RequestInit): Promise; + connect(address: SocketAddress | string, options?: SocketOptions): Socket; +}; +interface KVNamespaceListKey { + name: Key; + expiration?: number; + metadata?: Metadata; +} +type KVNamespaceListResult = + | { + list_complete: false; + keys: KVNamespaceListKey[]; + cursor: string; + cacheStatus: string | null; + } + | { + list_complete: true; + keys: KVNamespaceListKey[]; + cacheStatus: string | null; + }; +interface KVNamespace { + get(key: Key, options?: Partial>): Promise; + get(key: Key, type: 'text'): Promise; + get(key: Key, type: 'json'): Promise; + get(key: Key, type: 'arrayBuffer'): Promise; + get(key: Key, type: 'stream'): Promise; + get(key: Key, options?: KVNamespaceGetOptions<'text'>): Promise; + get( + key: Key, + options?: KVNamespaceGetOptions<'json'> + ): Promise; + get(key: Key, options?: KVNamespaceGetOptions<'arrayBuffer'>): Promise; + get(key: Key, options?: KVNamespaceGetOptions<'stream'>): Promise; + get(key: Array, type: 'text'): Promise>; + get( + key: Array, + type: 'json' + ): Promise>; + get( + key: Array, + options?: Partial> + ): Promise>; + get( + key: Array, + options?: KVNamespaceGetOptions<'text'> + ): Promise>; + get( + key: Array, + options?: KVNamespaceGetOptions<'json'> + ): Promise>; + list( + options?: KVNamespaceListOptions + ): Promise>; + put( + key: Key, + value: string | ArrayBuffer | ArrayBufferView | ReadableStream, + options?: KVNamespacePutOptions + ): Promise; + getWithMetadata( + key: Key, + options?: Partial> + ): Promise>; + getWithMetadata( + key: Key, + type: 'text' + ): Promise>; + getWithMetadata( + key: Key, + type: 'json' + ): Promise>; + getWithMetadata( + key: Key, + type: 'arrayBuffer' + ): Promise>; + getWithMetadata( + key: Key, + type: 'stream' + ): Promise>; + getWithMetadata( + key: Key, + options: KVNamespaceGetOptions<'text'> + ): Promise>; + getWithMetadata( + key: Key, + options: KVNamespaceGetOptions<'json'> + ): Promise>; + getWithMetadata( + key: Key, + options: KVNamespaceGetOptions<'arrayBuffer'> + ): Promise>; + getWithMetadata( + key: Key, + options: KVNamespaceGetOptions<'stream'> + ): Promise>; + getWithMetadata( + key: Array, + type: 'text' + ): Promise>>; + getWithMetadata( + key: Array, + type: 'json' + ): Promise>>; + getWithMetadata( + key: Array, + options?: Partial> + ): Promise>>; + getWithMetadata( + key: Array, + options?: KVNamespaceGetOptions<'text'> + ): Promise>>; + getWithMetadata( + key: Array, + options?: KVNamespaceGetOptions<'json'> + ): Promise>>; + delete(key: Key): Promise; +} +interface KVNamespaceListOptions { + limit?: number; + prefix?: string | null; + cursor?: string | null; +} +interface KVNamespaceGetOptions { + type: Type; + cacheTtl?: number; +} +interface KVNamespacePutOptions { + expiration?: number; + expirationTtl?: number; + metadata?: any | null; +} +interface KVNamespaceGetWithMetadataResult { + value: Value | null; + metadata: Metadata | null; + cacheStatus: string | null; +} +type QueueContentType = 'text' | 'bytes' | 'json' | 'v8'; +interface Queue { + send(message: Body, options?: QueueSendOptions): Promise; + sendBatch( + messages: Iterable>, + options?: QueueSendBatchOptions + ): Promise; +} +interface QueueSendOptions { + contentType?: QueueContentType; + delaySeconds?: number; +} +interface QueueSendBatchOptions { + delaySeconds?: number; +} +interface MessageSendRequest { + body: Body; + contentType?: QueueContentType; + delaySeconds?: number; +} +interface QueueRetryOptions { + delaySeconds?: number; +} +interface Message { + readonly id: string; + readonly timestamp: Date; + readonly body: Body; + readonly attempts: number; + retry(options?: QueueRetryOptions): void; + ack(): void; +} +interface QueueEvent extends ExtendableEvent { + readonly messages: readonly Message[]; + readonly queue: string; + retryAll(options?: QueueRetryOptions): void; + ackAll(): void; +} +interface MessageBatch { + readonly messages: readonly Message[]; + readonly queue: string; + retryAll(options?: QueueRetryOptions): void; + ackAll(): void; +} +interface R2Error extends Error { + readonly name: string; + readonly code: number; + readonly message: string; + readonly action: string; + readonly stack: any; +} +interface R2ListOptions { + limit?: number; + prefix?: string; + cursor?: string; + delimiter?: string; + startAfter?: string; + include?: ('httpMetadata' | 'customMetadata')[]; +} +declare abstract class R2Bucket { + head(key: string): Promise; + get( + key: string, + options: R2GetOptions & { + onlyIf: R2Conditional | Headers; + } + ): Promise; + get(key: string, options?: R2GetOptions): Promise; + put( + key: string, + value: ReadableStream | ArrayBuffer | ArrayBufferView | string | null | Blob, + options?: R2PutOptions & { + onlyIf: R2Conditional | Headers; + } + ): Promise; + put( + key: string, + value: ReadableStream | ArrayBuffer | ArrayBufferView | string | null | Blob, + options?: R2PutOptions + ): Promise; + createMultipartUpload(key: string, options?: R2MultipartOptions): Promise; + resumeMultipartUpload(key: string, uploadId: string): R2MultipartUpload; + delete(keys: string | string[]): Promise; + list(options?: R2ListOptions): Promise; +} +interface R2MultipartUpload { + readonly key: string; + readonly uploadId: string; + uploadPart( + partNumber: number, + value: ReadableStream | (ArrayBuffer | ArrayBufferView) | string | Blob, + options?: R2UploadPartOptions + ): Promise; + abort(): Promise; + complete(uploadedParts: R2UploadedPart[]): Promise; +} +interface R2UploadedPart { + partNumber: number; + etag: string; +} +declare abstract class R2Object { + readonly key: string; + readonly version: string; + readonly size: number; + readonly etag: string; + readonly httpEtag: string; + readonly checksums: R2Checksums; + readonly uploaded: Date; + readonly httpMetadata?: R2HTTPMetadata; + readonly customMetadata?: Record; + readonly range?: R2Range; + readonly storageClass: string; + readonly ssecKeyMd5?: string; + writeHttpMetadata(headers: Headers): void; +} +interface R2ObjectBody extends R2Object { + get body(): ReadableStream; + get bodyUsed(): boolean; + arrayBuffer(): Promise; + bytes(): Promise; + text(): Promise; + json(): Promise; + blob(): Promise; +} +type R2Range = + | { + offset: number; + length?: number; + } + | { + offset?: number; + length: number; + } + | { + suffix: number; + }; +interface R2Conditional { + etagMatches?: string; + etagDoesNotMatch?: string; + uploadedBefore?: Date; + uploadedAfter?: Date; + secondsGranularity?: boolean; +} +interface R2GetOptions { + onlyIf?: R2Conditional | Headers; + range?: R2Range | Headers; + ssecKey?: ArrayBuffer | string; +} +interface R2PutOptions { + onlyIf?: R2Conditional | Headers; + httpMetadata?: R2HTTPMetadata | Headers; + customMetadata?: Record; + md5?: (ArrayBuffer | ArrayBufferView) | string; + sha1?: (ArrayBuffer | ArrayBufferView) | string; + sha256?: (ArrayBuffer | ArrayBufferView) | string; + sha384?: (ArrayBuffer | ArrayBufferView) | string; + sha512?: (ArrayBuffer | ArrayBufferView) | string; + storageClass?: string; + ssecKey?: ArrayBuffer | string; +} +interface R2MultipartOptions { + httpMetadata?: R2HTTPMetadata | Headers; + customMetadata?: Record; + storageClass?: string; + ssecKey?: ArrayBuffer | string; +} +interface R2Checksums { + readonly md5?: ArrayBuffer; + readonly sha1?: ArrayBuffer; + readonly sha256?: ArrayBuffer; + readonly sha384?: ArrayBuffer; + readonly sha512?: ArrayBuffer; + toJSON(): R2StringChecksums; +} +interface R2StringChecksums { + md5?: string; + sha1?: string; + sha256?: string; + sha384?: string; + sha512?: string; +} +interface R2HTTPMetadata { + contentType?: string; + contentLanguage?: string; + contentDisposition?: string; + contentEncoding?: string; + cacheControl?: string; + cacheExpiry?: Date; +} +type R2Objects = { + objects: R2Object[]; + delimitedPrefixes: string[]; +} & ( + | { + truncated: true; + cursor: string; + } + | { + truncated: false; + } +); +interface R2UploadPartOptions { + ssecKey?: ArrayBuffer | string; +} +declare abstract class ScheduledEvent extends ExtendableEvent { + readonly scheduledTime: number; + readonly cron: string; + noRetry(): void; +} +interface ScheduledController { + readonly scheduledTime: number; + readonly cron: string; + noRetry(): void; +} +interface QueuingStrategy { + highWaterMark?: number | bigint; + size?: (chunk: T) => number | bigint; +} +interface UnderlyingSink { + type?: string; + start?: (controller: WritableStreamDefaultController) => void | Promise; + write?: (chunk: W, controller: WritableStreamDefaultController) => void | Promise; + abort?: (reason: any) => void | Promise; + close?: () => void | Promise; +} +interface UnderlyingByteSource { + type: 'bytes'; + autoAllocateChunkSize?: number; + start?: (controller: ReadableByteStreamController) => void | Promise; + pull?: (controller: ReadableByteStreamController) => void | Promise; + cancel?: (reason: any) => void | Promise; +} +interface UnderlyingSource { + type?: '' | undefined; + start?: (controller: ReadableStreamDefaultController) => void | Promise; + pull?: (controller: ReadableStreamDefaultController) => void | Promise; + cancel?: (reason: any) => void | Promise; + expectedLength?: number | bigint; +} +interface Transformer { + readableType?: string; + writableType?: string; + start?: (controller: TransformStreamDefaultController) => void | Promise; + transform?: (chunk: I, controller: TransformStreamDefaultController) => void | Promise; + flush?: (controller: TransformStreamDefaultController) => void | Promise; + cancel?: (reason: any) => void | Promise; + expectedLength?: number; +} +interface StreamPipeOptions { + preventAbort?: boolean; + preventCancel?: boolean; + /** + * Pipes this readable stream to a given writable stream destination. The way in which the piping process behaves under various error conditions can be customized with a number of passed options. It returns a promise that fulfills when the piping process completes successfully, or rejects if any errors were encountered. + * + * Piping a stream will lock it for the duration of the pipe, preventing any other consumer from acquiring a reader. + * + * Errors and closures of the source and destination streams propagate as follows: + * + * An error in this source readable stream will abort destination, unless preventAbort is truthy. The returned promise will be rejected with the source's error, or with any error that occurs during aborting the destination. + * + * An error in destination will cancel this source readable stream, unless preventCancel is truthy. The returned promise will be rejected with the destination's error, or with any error that occurs during canceling the source. + * + * When this source readable stream closes, destination will be closed, unless preventClose is truthy. The returned promise will be fulfilled once this process completes, unless an error is encountered while closing the destination, in which case it will be rejected with that error. + * + * If destination starts out closed or closing, this source readable stream will be canceled, unless preventCancel is true. The returned promise will be rejected with an error indicating piping to a closed stream failed, or with any error that occurs during canceling the source. + * + * The signal option can be set to an AbortSignal to allow aborting an ongoing pipe operation via the corresponding AbortController. In this case, this source readable stream will be canceled, and destination aborted, unless the respective options preventCancel or preventAbort are set. + */ + preventClose?: boolean; + signal?: AbortSignal; +} +type ReadableStreamReadResult = + | { + done: false; + value: R; + } + | { + done: true; + value?: undefined; + }; +/** + * The `ReadableStream` interface of the Streams API represents a readable stream of byte data. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream) + */ +interface ReadableStream { + /** + * The **`locked`** read-only property of the ReadableStream interface returns whether or not the readable stream is locked to a reader. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/locked) + */ + get locked(): boolean; + /** + * The **`cancel()`** method of the ReadableStream interface returns a Promise that resolves when the stream is canceled. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/cancel) + */ + cancel(reason?: any): Promise; + /** + * The **`getReader()`** method of the ReadableStream interface creates a reader and locks the stream to it. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/getReader) + */ + getReader(): ReadableStreamDefaultReader; + /** + * The **`getReader()`** method of the ReadableStream interface creates a reader and locks the stream to it. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/getReader) + */ + getReader(options: ReadableStreamGetReaderOptions): ReadableStreamBYOBReader; + /** + * The **`pipeThrough()`** method of the ReadableStream interface provides a chainable way of piping the current stream through a transform stream or any other writable/readable pair. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/pipeThrough) + */ + pipeThrough( + transform: ReadableWritablePair, + options?: StreamPipeOptions + ): ReadableStream; + /** + * The **`pipeTo()`** method of the ReadableStream interface pipes the current `ReadableStream` to a given WritableStream and returns a Promise that fulfills when the piping process completes successfully, or rejects if any errors were encountered. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/pipeTo) + */ + pipeTo(destination: WritableStream, options?: StreamPipeOptions): Promise; + /** + * The **`tee()`** method of the two-element array containing the two resulting branches as new ReadableStream instances. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/tee) + */ + tee(): [ReadableStream, ReadableStream]; + values(options?: ReadableStreamValuesOptions): AsyncIterableIterator; + [Symbol.asyncIterator](options?: ReadableStreamValuesOptions): AsyncIterableIterator; +} +/** + * The `ReadableStream` interface of the Streams API represents a readable stream of byte data. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream) + */ +declare const ReadableStream: { + prototype: ReadableStream; + new ( + underlyingSource: UnderlyingByteSource, + strategy?: QueuingStrategy + ): ReadableStream; + new ( + underlyingSource?: UnderlyingSource, + strategy?: QueuingStrategy + ): ReadableStream; +}; +/** + * The **`ReadableStreamDefaultReader`** interface of the Streams API represents a default reader that can be used to read stream data supplied from a network (such as a fetch request). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultReader) + */ +declare class ReadableStreamDefaultReader { + constructor(stream: ReadableStream); + get closed(): Promise; + cancel(reason?: any): Promise; + /** + * The **`read()`** method of the ReadableStreamDefaultReader interface returns a Promise providing access to the next chunk in the stream's internal queue. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultReader/read) + */ + read(): Promise>; + /** + * The **`releaseLock()`** method of the ReadableStreamDefaultReader interface releases the reader's lock on the stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultReader/releaseLock) + */ + releaseLock(): void; +} +/** + * The `ReadableStreamBYOBReader` interface of the Streams API defines a reader for a ReadableStream that supports zero-copy reading from an underlying byte source. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader) + */ +declare class ReadableStreamBYOBReader { + constructor(stream: ReadableStream); + get closed(): Promise; + cancel(reason?: any): Promise; + /** + * The **`read()`** method of the ReadableStreamBYOBReader interface is used to read data into a view on a user-supplied buffer from an associated readable byte stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader/read) + */ + read(view: T): Promise>; + /** + * The **`releaseLock()`** method of the ReadableStreamBYOBReader interface releases the reader's lock on the stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader/releaseLock) + */ + releaseLock(): void; + readAtLeast( + minElements: number, + view: T + ): Promise>; +} +interface ReadableStreamBYOBReaderReadableStreamBYOBReaderReadOptions { + min?: number; +} +interface ReadableStreamGetReaderOptions { + /** + * Creates a ReadableStreamBYOBReader and locks the stream to the new reader. + * + * This call behaves the same way as the no-argument variant, except that it only works on readable byte streams, i.e. streams which were constructed specifically with the ability to handle "bring your own buffer" reading. The returned BYOB reader provides the ability to directly read individual chunks from the stream via its read() method, into developer-supplied buffers, allowing more precise control over allocation. + */ + mode: 'byob'; +} +/** + * The **`ReadableStreamBYOBRequest`** interface of the Streams API represents a 'pull request' for data from an underlying source that will made as a zero-copy transfer to a consumer (bypassing the stream's internal queues). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBRequest) + */ +declare abstract class ReadableStreamBYOBRequest { + /** + * The **`view`** getter property of the ReadableStreamBYOBRequest interface returns the current view. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBRequest/view) + */ + get view(): Uint8Array | null; + /** + * The **`respond()`** method of the ReadableStreamBYOBRequest interface is used to signal to the associated readable byte stream that the specified number of bytes were written into the ReadableStreamBYOBRequest.view. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBRequest/respond) + */ + respond(bytesWritten: number): void; + /** + * The **`respondWithNewView()`** method of the ReadableStreamBYOBRequest interface specifies a new view that the consumer of the associated readable byte stream should write to instead of ReadableStreamBYOBRequest.view. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBRequest/respondWithNewView) + */ + respondWithNewView(view: ArrayBuffer | ArrayBufferView): void; + get atLeast(): number | null; +} +/** + * The **`ReadableStreamDefaultController`** interface of the Streams API represents a controller allowing control of a ReadableStream's state and internal queue. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController) + */ +declare abstract class ReadableStreamDefaultController { + /** + * The **`desiredSize`** read-only property of the required to fill the stream's internal queue. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/desiredSize) + */ + get desiredSize(): number | null; + /** + * The **`close()`** method of the ReadableStreamDefaultController interface closes the associated stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/close) + */ + close(): void; + /** + * The **`enqueue()`** method of the ```js-nolint enqueue(chunk) ``` - `chunk` - : The chunk to enqueue. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/enqueue) + */ + enqueue(chunk?: R): void; + /** + * The **`error()`** method of the with the associated stream to error. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/error) + */ + error(reason: any): void; +} +/** + * The **`ReadableByteStreamController`** interface of the Streams API represents a controller for a readable byte stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController) + */ +declare abstract class ReadableByteStreamController { + /** + * The **`byobRequest`** read-only property of the ReadableByteStreamController interface returns the current BYOB request, or `null` if there are no pending requests. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/byobRequest) + */ + get byobRequest(): ReadableStreamBYOBRequest | null; + /** + * The **`desiredSize`** read-only property of the ReadableByteStreamController interface returns the number of bytes required to fill the stream's internal queue to its 'desired size'. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/desiredSize) + */ + get desiredSize(): number | null; + /** + * The **`close()`** method of the ReadableByteStreamController interface closes the associated stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/close) + */ + close(): void; + /** + * The **`enqueue()`** method of the ReadableByteStreamController interface enqueues a given chunk on the associated readable byte stream (the chunk is copied into the stream's internal queues). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/enqueue) + */ + enqueue(chunk: ArrayBuffer | ArrayBufferView): void; + /** + * The **`error()`** method of the ReadableByteStreamController interface causes any future interactions with the associated stream to error with the specified reason. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/error) + */ + error(reason: any): void; +} +/** + * The **`WritableStreamDefaultController`** interface of the Streams API represents a controller allowing control of a WritableStream's state. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultController) + */ +declare abstract class WritableStreamDefaultController { + /** + * The read-only **`signal`** property of the WritableStreamDefaultController interface returns the AbortSignal associated with the controller. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultController/signal) + */ + get signal(): AbortSignal; + /** + * The **`error()`** method of the with the associated stream to error. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultController/error) + */ + error(reason?: any): void; +} +/** + * The **`TransformStreamDefaultController`** interface of the Streams API provides methods to manipulate the associated ReadableStream and WritableStream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController) + */ +declare abstract class TransformStreamDefaultController { + /** + * The **`desiredSize`** read-only property of the TransformStreamDefaultController interface returns the desired size to fill the queue of the associated ReadableStream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController/desiredSize) + */ + get desiredSize(): number | null; + /** + * The **`enqueue()`** method of the TransformStreamDefaultController interface enqueues the given chunk in the readable side of the stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController/enqueue) + */ + enqueue(chunk?: O): void; + /** + * The **`error()`** method of the TransformStreamDefaultController interface errors both sides of the stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController/error) + */ + error(reason: any): void; + /** + * The **`terminate()`** method of the TransformStreamDefaultController interface closes the readable side and errors the writable side of the stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController/terminate) + */ + terminate(): void; +} +interface ReadableWritablePair { + readable: ReadableStream; + /** + * Provides a convenient, chainable way of piping this readable stream through a transform stream (or any other { writable, readable } pair). It simply pipes the stream into the writable side of the supplied pair, and returns the readable side for further use. + * + * Piping a stream will lock it for the duration of the pipe, preventing any other consumer from acquiring a reader. + */ + writable: WritableStream; +} +/** + * The **`WritableStream`** interface of the Streams API provides a standard abstraction for writing streaming data to a destination, known as a sink. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream) + */ +declare class WritableStream { + constructor(underlyingSink?: UnderlyingSink, queuingStrategy?: QueuingStrategy); + /** + * The **`locked`** read-only property of the WritableStream interface returns a boolean indicating whether the `WritableStream` is locked to a writer. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/locked) + */ + get locked(): boolean; + /** + * The **`abort()`** method of the WritableStream interface aborts the stream, signaling that the producer can no longer successfully write to the stream and it is to be immediately moved to an error state, with any queued writes discarded. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/abort) + */ + abort(reason?: any): Promise; + /** + * The **`close()`** method of the WritableStream interface closes the associated stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/close) + */ + close(): Promise; + /** + * The **`getWriter()`** method of the WritableStream interface returns a new instance of WritableStreamDefaultWriter and locks the stream to that instance. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/getWriter) + */ + getWriter(): WritableStreamDefaultWriter; +} +/** + * The **`WritableStreamDefaultWriter`** interface of the Streams API is the object returned by WritableStream.getWriter() and once created locks the writer to the `WritableStream` ensuring that no other streams can write to the underlying sink. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter) + */ +declare class WritableStreamDefaultWriter { + constructor(stream: WritableStream); + /** + * The **`closed`** read-only property of the the stream errors or the writer's lock is released. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/closed) + */ + get closed(): Promise; + /** + * The **`ready`** read-only property of the that resolves when the desired size of the stream's internal queue transitions from non-positive to positive, signaling that it is no longer applying backpressure. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/ready) + */ + get ready(): Promise; + /** + * The **`desiredSize`** read-only property of the to fill the stream's internal queue. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/desiredSize) + */ + get desiredSize(): number | null; + /** + * The **`abort()`** method of the the producer can no longer successfully write to the stream and it is to be immediately moved to an error state, with any queued writes discarded. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/abort) + */ + abort(reason?: any): Promise; + /** + * The **`close()`** method of the stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/close) + */ + close(): Promise; + /** + * The **`write()`** method of the operation. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/write) + */ + write(chunk?: W): Promise; + /** + * The **`releaseLock()`** method of the corresponding stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/releaseLock) + */ + releaseLock(): void; +} +/** + * The **`TransformStream`** interface of the Streams API represents a concrete implementation of the pipe chain _transform stream_ concept. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStream) + */ +declare class TransformStream { + constructor( + transformer?: Transformer, + writableStrategy?: QueuingStrategy, + readableStrategy?: QueuingStrategy + ); + /** + * The **`readable`** read-only property of the TransformStream interface returns the ReadableStream instance controlled by this `TransformStream`. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStream/readable) + */ + get readable(): ReadableStream; + /** + * The **`writable`** read-only property of the TransformStream interface returns the WritableStream instance controlled by this `TransformStream`. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStream/writable) + */ + get writable(): WritableStream; +} +declare class FixedLengthStream extends IdentityTransformStream { + constructor( + expectedLength: number | bigint, + queuingStrategy?: IdentityTransformStreamQueuingStrategy + ); +} +declare class IdentityTransformStream extends TransformStream< + ArrayBuffer | ArrayBufferView, + Uint8Array +> { + constructor(queuingStrategy?: IdentityTransformStreamQueuingStrategy); +} +interface IdentityTransformStreamQueuingStrategy { + highWaterMark?: number | bigint; +} +interface ReadableStreamValuesOptions { + preventCancel?: boolean; +} +/** + * The **`CompressionStream`** interface of the Compression Streams API is an API for compressing a stream of data. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CompressionStream) + */ +declare class CompressionStream extends TransformStream { + constructor(format: 'gzip' | 'deflate' | 'deflate-raw'); +} +/** + * The **`DecompressionStream`** interface of the Compression Streams API is an API for decompressing a stream of data. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DecompressionStream) + */ +declare class DecompressionStream extends TransformStream< + ArrayBuffer | ArrayBufferView, + Uint8Array +> { + constructor(format: 'gzip' | 'deflate' | 'deflate-raw'); +} +/** + * The **`TextEncoderStream`** interface of the Encoding API converts a stream of strings into bytes in the UTF-8 encoding. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoderStream) + */ +declare class TextEncoderStream extends TransformStream { + constructor(); + get encoding(): string; +} +/** + * The **`TextDecoderStream`** interface of the Encoding API converts a stream of text in a binary encoding, such as UTF-8 etc., to a stream of strings. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoderStream) + */ +declare class TextDecoderStream extends TransformStream { + constructor(label?: string, options?: TextDecoderStreamTextDecoderStreamInit); + get encoding(): string; + get fatal(): boolean; + get ignoreBOM(): boolean; +} +interface TextDecoderStreamTextDecoderStreamInit { + fatal?: boolean; + ignoreBOM?: boolean; +} +/** + * The **`ByteLengthQueuingStrategy`** interface of the Streams API provides a built-in byte length queuing strategy that can be used when constructing streams. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ByteLengthQueuingStrategy) + */ +declare class ByteLengthQueuingStrategy implements QueuingStrategy { + constructor(init: QueuingStrategyInit); + /** + * The read-only **`ByteLengthQueuingStrategy.highWaterMark`** property returns the total number of bytes that can be contained in the internal queue before backpressure is applied. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ByteLengthQueuingStrategy/highWaterMark) + */ + get highWaterMark(): number; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ByteLengthQueuingStrategy/size) */ + get size(): (chunk?: any) => number; +} +/** + * The **`CountQueuingStrategy`** interface of the Streams API provides a built-in chunk counting queuing strategy that can be used when constructing streams. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CountQueuingStrategy) + */ +declare class CountQueuingStrategy implements QueuingStrategy { + constructor(init: QueuingStrategyInit); + /** + * The read-only **`CountQueuingStrategy.highWaterMark`** property returns the total number of chunks that can be contained in the internal queue before backpressure is applied. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CountQueuingStrategy/highWaterMark) + */ + get highWaterMark(): number; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CountQueuingStrategy/size) */ + get size(): (chunk?: any) => number; +} +interface QueuingStrategyInit { + /** + * Creates a new ByteLengthQueuingStrategy with the provided high water mark. + * + * Note that the provided high water mark will not be validated ahead of time. Instead, if it is negative, NaN, or not a number, the resulting ByteLengthQueuingStrategy will cause the corresponding stream constructor to throw. + */ + highWaterMark: number; +} +interface ScriptVersion { + id?: string; + tag?: string; + message?: string; +} +declare abstract class TailEvent extends ExtendableEvent { + readonly events: TraceItem[]; + readonly traces: TraceItem[]; +} +interface TraceItem { + readonly event: + | ( + | TraceItemFetchEventInfo + | TraceItemJsRpcEventInfo + | TraceItemScheduledEventInfo + | TraceItemAlarmEventInfo + | TraceItemQueueEventInfo + | TraceItemEmailEventInfo + | TraceItemTailEventInfo + | TraceItemCustomEventInfo + | TraceItemHibernatableWebSocketEventInfo + ) + | null; + readonly eventTimestamp: number | null; + readonly logs: TraceLog[]; + readonly exceptions: TraceException[]; + readonly diagnosticsChannelEvents: TraceDiagnosticChannelEvent[]; + readonly scriptName: string | null; + readonly entrypoint?: string; + readonly scriptVersion?: ScriptVersion; + readonly dispatchNamespace?: string; + readonly scriptTags?: string[]; + readonly durableObjectId?: string; + readonly outcome: string; + readonly executionModel: string; + readonly truncated: boolean; + readonly cpuTime: number; + readonly wallTime: number; +} +interface TraceItemAlarmEventInfo { + readonly scheduledTime: Date; +} +interface TraceItemCustomEventInfo {} +interface TraceItemScheduledEventInfo { + readonly scheduledTime: number; + readonly cron: string; +} +interface TraceItemQueueEventInfo { + readonly queue: string; + readonly batchSize: number; +} +interface TraceItemEmailEventInfo { + readonly mailFrom: string; + readonly rcptTo: string; + readonly rawSize: number; +} +interface TraceItemTailEventInfo { + readonly consumedEvents: TraceItemTailEventInfoTailItem[]; +} +interface TraceItemTailEventInfoTailItem { + readonly scriptName: string | null; +} +interface TraceItemFetchEventInfo { + readonly response?: TraceItemFetchEventInfoResponse; + readonly request: TraceItemFetchEventInfoRequest; +} +interface TraceItemFetchEventInfoRequest { + readonly cf?: any; + readonly headers: Record; + readonly method: string; + readonly url: string; + getUnredacted(): TraceItemFetchEventInfoRequest; +} +interface TraceItemFetchEventInfoResponse { + readonly status: number; +} +interface TraceItemJsRpcEventInfo { + readonly rpcMethod: string; +} +interface TraceItemHibernatableWebSocketEventInfo { + readonly getWebSocketEvent: + | TraceItemHibernatableWebSocketEventInfoMessage + | TraceItemHibernatableWebSocketEventInfoClose + | TraceItemHibernatableWebSocketEventInfoError; +} +interface TraceItemHibernatableWebSocketEventInfoMessage { + readonly webSocketEventType: string; +} +interface TraceItemHibernatableWebSocketEventInfoClose { + readonly webSocketEventType: string; + readonly code: number; + readonly wasClean: boolean; +} +interface TraceItemHibernatableWebSocketEventInfoError { + readonly webSocketEventType: string; +} +interface TraceLog { + readonly timestamp: number; + readonly level: string; + readonly message: any; +} +interface TraceException { + readonly timestamp: number; + readonly message: string; + readonly name: string; + readonly stack?: string; +} +interface TraceDiagnosticChannelEvent { + readonly timestamp: number; + readonly channel: string; + readonly message: any; +} +interface TraceMetrics { + readonly cpuTime: number; + readonly wallTime: number; +} +interface UnsafeTraceMetrics { + fromTrace(item: TraceItem): TraceMetrics; +} +/** + * The **`URL`** interface is used to parse, construct, normalize, and encode URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL) + */ +declare class URL { + constructor(url: string | URL, base?: string | URL); + /** + * The **`origin`** read-only property of the URL interface returns a string containing the Unicode serialization of the origin of the represented URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/origin) + */ + get origin(): string; + /** + * The **`href`** property of the URL interface is a string containing the whole URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/href) + */ + get href(): string; + /** + * The **`href`** property of the URL interface is a string containing the whole URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/href) + */ + set href(value: string); + /** + * The **`protocol`** property of the URL interface is a string containing the protocol or scheme of the URL, including the final `':'`. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/protocol) + */ + get protocol(): string; + /** + * The **`protocol`** property of the URL interface is a string containing the protocol or scheme of the URL, including the final `':'`. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/protocol) + */ + set protocol(value: string); + /** + * The **`username`** property of the URL interface is a string containing the username component of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/username) + */ + get username(): string; + /** + * The **`username`** property of the URL interface is a string containing the username component of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/username) + */ + set username(value: string); + /** + * The **`password`** property of the URL interface is a string containing the password component of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/password) + */ + get password(): string; + /** + * The **`password`** property of the URL interface is a string containing the password component of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/password) + */ + set password(value: string); + /** + * The **`host`** property of the URL interface is a string containing the host, which is the URL.hostname, and then, if the port of the URL is nonempty, a `':'`, followed by the URL.port of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/host) + */ + get host(): string; + /** + * The **`host`** property of the URL interface is a string containing the host, which is the URL.hostname, and then, if the port of the URL is nonempty, a `':'`, followed by the URL.port of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/host) + */ + set host(value: string); + /** + * The **`hostname`** property of the URL interface is a string containing either the domain name or IP address of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hostname) + */ + get hostname(): string; + /** + * The **`hostname`** property of the URL interface is a string containing either the domain name or IP address of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hostname) + */ + set hostname(value: string); + /** + * The **`port`** property of the URL interface is a string containing the port number of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/port) + */ + get port(): string; + /** + * The **`port`** property of the URL interface is a string containing the port number of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/port) + */ + set port(value: string); + /** + * The **`pathname`** property of the URL interface represents a location in a hierarchical structure. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/pathname) + */ + get pathname(): string; + /** + * The **`pathname`** property of the URL interface represents a location in a hierarchical structure. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/pathname) + */ + set pathname(value: string); + /** + * The **`search`** property of the URL interface is a search string, also called a _query string_, that is a string containing a `'?'` followed by the parameters of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/search) + */ + get search(): string; + /** + * The **`search`** property of the URL interface is a search string, also called a _query string_, that is a string containing a `'?'` followed by the parameters of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/search) + */ + set search(value: string); + /** + * The **`hash`** property of the URL interface is a string containing a `'#'` followed by the fragment identifier of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hash) + */ + get hash(): string; + /** + * The **`hash`** property of the URL interface is a string containing a `'#'` followed by the fragment identifier of the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hash) + */ + set hash(value: string); + /** + * The **`searchParams`** read-only property of the access to the [MISSING: httpmethod('GET')] decoded query arguments contained in the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/searchParams) + */ + get searchParams(): URLSearchParams; + /** + * The **`toJSON()`** method of the URL interface returns a string containing a serialized version of the URL, although in practice it seems to have the same effect as ```js-nolint toJSON() ``` None. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/toJSON) + */ + toJSON(): string; + /*function toString() { [native code] }*/ + toString(): string; + /** + * The **`URL.canParse()`** static method of the URL interface returns a boolean indicating whether or not an absolute URL, or a relative URL combined with a base URL, are parsable and valid. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/canParse_static) + */ + static canParse(url: string, base?: string): boolean; + /** + * The **`URL.parse()`** static method of the URL interface returns a newly created URL object representing the URL defined by the parameters. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/parse_static) + */ + static parse(url: string, base?: string): URL | null; + /** + * The **`createObjectURL()`** static method of the URL interface creates a string containing a URL representing the object given in the parameter. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/createObjectURL_static) + */ + static createObjectURL(object: File | Blob): string; + /** + * The **`revokeObjectURL()`** static method of the URL interface releases an existing object URL which was previously created by calling Call this method when you've finished using an object URL to let the browser know not to keep the reference to the file any longer. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/revokeObjectURL_static) + */ + static revokeObjectURL(object_url: string): void; +} +/** + * The **`URLSearchParams`** interface defines utility methods to work with the query string of a URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams) + */ +declare class URLSearchParams { + constructor(init?: Iterable> | Record | string); + /** + * The **`size`** read-only property of the URLSearchParams interface indicates the total number of search parameter entries. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/size) + */ + get size(): number; + /** + * The **`append()`** method of the URLSearchParams interface appends a specified key/value pair as a new search parameter. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/append) + */ + append(name: string, value: string): void; + /** + * The **`delete()`** method of the URLSearchParams interface deletes specified parameters and their associated value(s) from the list of all search parameters. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/delete) + */ + delete(name: string, value?: string): void; + /** + * The **`get()`** method of the URLSearchParams interface returns the first value associated to the given search parameter. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/get) + */ + get(name: string): string | null; + /** + * The **`getAll()`** method of the URLSearchParams interface returns all the values associated with a given search parameter as an array. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/getAll) + */ + getAll(name: string): string[]; + /** + * The **`has()`** method of the URLSearchParams interface returns a boolean value that indicates whether the specified parameter is in the search parameters. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/has) + */ + has(name: string, value?: string): boolean; + /** + * The **`set()`** method of the URLSearchParams interface sets the value associated with a given search parameter to the given value. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/set) + */ + set(name: string, value: string): void; + /** + * The **`URLSearchParams.sort()`** method sorts all key/value pairs contained in this object in place and returns `undefined`. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/sort) + */ + sort(): void; + /* Returns an array of key, value pairs for every entry in the search params. */ + entries(): IterableIterator<[key: string, value: string]>; + /* Returns a list of keys in the search params. */ + keys(): IterableIterator; + /* Returns a list of values in the search params. */ + values(): IterableIterator; + forEach( + callback: (this: This, value: string, key: string, parent: URLSearchParams) => void, + thisArg?: This + ): void; + /*function toString() { [native code] }*/ + toString(): string; + [Symbol.iterator](): IterableIterator<[key: string, value: string]>; +} +declare class URLPattern { + constructor( + input?: string | URLPatternInit, + baseURL?: string | URLPatternOptions, + patternOptions?: URLPatternOptions + ); + get protocol(): string; + get username(): string; + get password(): string; + get hostname(): string; + get port(): string; + get pathname(): string; + get search(): string; + get hash(): string; + get hasRegExpGroups(): boolean; + test(input?: string | URLPatternInit, baseURL?: string): boolean; + exec(input?: string | URLPatternInit, baseURL?: string): URLPatternResult | null; +} +interface URLPatternInit { + protocol?: string; + username?: string; + password?: string; + hostname?: string; + port?: string; + pathname?: string; + search?: string; + hash?: string; + baseURL?: string; +} +interface URLPatternComponentResult { + input: string; + groups: Record; +} +interface URLPatternResult { + inputs: (string | URLPatternInit)[]; + protocol: URLPatternComponentResult; + username: URLPatternComponentResult; + password: URLPatternComponentResult; + hostname: URLPatternComponentResult; + port: URLPatternComponentResult; + pathname: URLPatternComponentResult; + search: URLPatternComponentResult; + hash: URLPatternComponentResult; +} +interface URLPatternOptions { + ignoreCase?: boolean; +} +/** + * A `CloseEvent` is sent to clients using WebSockets when the connection is closed. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent) + */ +declare class CloseEvent extends Event { + constructor(type: string, initializer?: CloseEventInit); + /** + * The **`code`** read-only property of the CloseEvent interface returns a WebSocket connection close code indicating the reason the connection was closed. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent/code) + */ + readonly code: number; + /** + * The **`reason`** read-only property of the CloseEvent interface returns the WebSocket connection close reason the server gave for closing the connection; that is, a concise human-readable prose explanation for the closure. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent/reason) + */ + readonly reason: string; + /** + * The **`wasClean`** read-only property of the CloseEvent interface returns `true` if the connection closed cleanly. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent/wasClean) + */ + readonly wasClean: boolean; +} +interface CloseEventInit { + code?: number; + reason?: string; + wasClean?: boolean; +} +type WebSocketEventMap = { + close: CloseEvent; + message: MessageEvent; + open: Event; + error: ErrorEvent; +}; +/** + * The `WebSocket` object provides the API for creating and managing a WebSocket connection to a server, as well as for sending and receiving data on the connection. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket) + */ +declare var WebSocket: { + prototype: WebSocket; + new (url: string, protocols?: string[] | string): WebSocket; + readonly READY_STATE_CONNECTING: number; + readonly CONNECTING: number; + readonly READY_STATE_OPEN: number; + readonly OPEN: number; + readonly READY_STATE_CLOSING: number; + readonly CLOSING: number; + readonly READY_STATE_CLOSED: number; + readonly CLOSED: number; +}; +/** + * The `WebSocket` object provides the API for creating and managing a WebSocket connection to a server, as well as for sending and receiving data on the connection. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket) + */ +interface WebSocket extends EventTarget { + accept(): void; + /** + * The **`WebSocket.send()`** method enqueues the specified data to be transmitted to the server over the WebSocket connection, increasing the value of `bufferedAmount` by the number of bytes needed to contain the data. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/send) + */ + send(message: (ArrayBuffer | ArrayBufferView) | string): void; + /** + * The **`WebSocket.close()`** method closes the already `CLOSED`, this method does nothing. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/close) + */ + close(code?: number, reason?: string): void; + serializeAttachment(attachment: any): void; + deserializeAttachment(): any | null; + /** + * The **`WebSocket.readyState`** read-only property returns the current state of the WebSocket connection. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/readyState) + */ + readyState: number; + /** + * The **`WebSocket.url`** read-only property returns the absolute URL of the WebSocket as resolved by the constructor. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/url) + */ + url: string | null; + /** + * The **`WebSocket.protocol`** read-only property returns the name of the sub-protocol the server selected; this will be one of the strings specified in the `protocols` parameter when creating the WebSocket object, or the empty string if no connection is established. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/protocol) + */ + protocol: string | null; + /** + * The **`WebSocket.extensions`** read-only property returns the extensions selected by the server. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/extensions) + */ + extensions: string | null; +} +declare const WebSocketPair: { + new (): { + 0: WebSocket; + 1: WebSocket; + }; +}; +interface SqlStorage { + exec>( + query: string, + ...bindings: any[] + ): SqlStorageCursor; + get databaseSize(): number; + Cursor: typeof SqlStorageCursor; + Statement: typeof SqlStorageStatement; +} +declare abstract class SqlStorageStatement {} +type SqlStorageValue = ArrayBuffer | string | number | null; +declare abstract class SqlStorageCursor> { + next(): + | { + done?: false; + value: T; + } + | { + done: true; + value?: never; + }; + toArray(): T[]; + one(): T; + raw(): IterableIterator; + columnNames: string[]; + get rowsRead(): number; + get rowsWritten(): number; + [Symbol.iterator](): IterableIterator; +} +interface Socket { + get readable(): ReadableStream; + get writable(): WritableStream; + get closed(): Promise; + get opened(): Promise; + get upgraded(): boolean; + get secureTransport(): 'on' | 'off' | 'starttls'; + close(): Promise; + startTls(options?: TlsOptions): Socket; +} +interface SocketOptions { + secureTransport?: string; + allowHalfOpen: boolean; + highWaterMark?: number | bigint; +} +interface SocketAddress { + hostname: string; + port: number; +} +interface TlsOptions { + expectedServerHostname?: string; +} +interface SocketInfo { + remoteAddress?: string; + localAddress?: string; +} +/** + * The **`EventSource`** interface is web content's interface to server-sent events. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource) + */ +declare class EventSource extends EventTarget { + constructor(url: string, init?: EventSourceEventSourceInit); + /** + * The **`close()`** method of the EventSource interface closes the connection, if one is made, and sets the ```js-nolint close() ``` None. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/close) + */ + close(): void; + /** + * The **`url`** read-only property of the URL of the source. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/url) + */ + get url(): string; + /** + * The **`withCredentials`** read-only property of the the `EventSource` object was instantiated with CORS credentials set. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/withCredentials) + */ + get withCredentials(): boolean; + /** + * The **`readyState`** read-only property of the connection. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/readyState) + */ + get readyState(): number; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/open_event) */ + get onopen(): any | null; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/open_event) */ + set onopen(value: any | null); + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/message_event) */ + get onmessage(): any | null; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/message_event) */ + set onmessage(value: any | null); + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/error_event) */ + get onerror(): any | null; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/error_event) */ + set onerror(value: any | null); + static readonly CONNECTING: number; + static readonly OPEN: number; + static readonly CLOSED: number; + static from(stream: ReadableStream): EventSource; +} +interface EventSourceEventSourceInit { + withCredentials?: boolean; + fetcher?: Fetcher; +} +interface Container { + get running(): boolean; + start(options?: ContainerStartupOptions): void; + monitor(): Promise; + destroy(error?: any): Promise; + signal(signo: number): void; + getTcpPort(port: number): Fetcher; + setInactivityTimeout(durationMs: number | bigint): Promise; +} +interface ContainerStartupOptions { + entrypoint?: string[]; + enableInternet: boolean; + env?: Record; + hardTimeout?: number | bigint; +} +/** + * The **`MessagePort`** interface of the Channel Messaging API represents one of the two ports of a MessageChannel, allowing messages to be sent from one port and listening out for them arriving at the other. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort) + */ +declare abstract class MessagePort extends EventTarget { + /** + * The **`postMessage()`** method of the transfers ownership of objects to other browsing contexts. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort/postMessage) + */ + postMessage(data?: any, options?: any[] | MessagePortPostMessageOptions): void; + /** + * The **`close()`** method of the MessagePort interface disconnects the port, so it is no longer active. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort/close) + */ + close(): void; + /** + * The **`start()`** method of the MessagePort interface starts the sending of messages queued on the port. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort/start) + */ + start(): void; + get onmessage(): any | null; + set onmessage(value: any | null); +} +/** + * The **`MessageChannel`** interface of the Channel Messaging API allows us to create a new message channel and send data through it via its two MessagePort properties. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageChannel) + */ +declare class MessageChannel { + constructor(); + /** + * The **`port1`** read-only property of the the port attached to the context that originated the channel. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageChannel/port1) + */ + readonly port1: MessagePort; + /** + * The **`port2`** read-only property of the the port attached to the context at the other end of the channel, which the message is initially sent to. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageChannel/port2) + */ + readonly port2: MessagePort; +} +interface MessagePortPostMessageOptions { + transfer?: any[]; +} +type LoopbackForExport< + T extends + | (new (...args: any[]) => Rpc.EntrypointBranded) + | ExportedHandler + | undefined = undefined, +> = T extends new (...args: any[]) => Rpc.WorkerEntrypointBranded + ? LoopbackServiceStub> + : T extends new (...args: any[]) => Rpc.DurableObjectBranded + ? LoopbackDurableObjectClass> + : T extends ExportedHandler + ? LoopbackServiceStub + : undefined; +type LoopbackServiceStub = + Fetcher & + (T extends CloudflareWorkersModule.WorkerEntrypoint + ? (opts: { props?: Props }) => Fetcher + : (opts: { props?: any }) => Fetcher); +type LoopbackDurableObjectClass = + DurableObjectClass & + (T extends CloudflareWorkersModule.DurableObject + ? (opts: { props?: Props }) => DurableObjectClass + : (opts: { props?: any }) => DurableObjectClass); +interface SyncKvStorage { + get(key: string): T | undefined; + list(options?: SyncKvListOptions): Iterable<[string, T]>; + put(key: string, value: T): void; + delete(key: string): boolean; +} +interface SyncKvListOptions { + start?: string; + startAfter?: string; + end?: string; + prefix?: string; + reverse?: boolean; + limit?: number; +} +interface WorkerStub { + getEntrypoint( + name?: string, + options?: WorkerStubEntrypointOptions + ): Fetcher; +} +interface WorkerStubEntrypointOptions { + props?: any; +} +interface WorkerLoader { + get( + name: string | null, + getCode: () => WorkerLoaderWorkerCode | Promise + ): WorkerStub; +} +interface WorkerLoaderModule { + js?: string; + cjs?: string; + text?: string; + data?: ArrayBuffer; + json?: any; + py?: string; + wasm?: ArrayBuffer; +} +interface WorkerLoaderWorkerCode { + compatibilityDate: string; + compatibilityFlags?: string[]; + allowExperimental?: boolean; + mainModule: string; + modules: Record; + env?: any; + globalOutbound?: Fetcher | null; + tails?: Fetcher[]; + streamingTails?: Fetcher[]; +} +/** + * The Workers runtime supports a subset of the Performance API, used to measure timing and performance, + * as well as timing of subrequests and other operations. + * + * [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/) + */ +declare abstract class Performance { + /* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/#performancetimeorigin) */ + get timeOrigin(): number; + /* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/#performancenow) */ + now(): number; +} +type AiImageClassificationInput = { + image: number[]; +}; +type AiImageClassificationOutput = { + score?: number; + label?: string; +}[]; +declare abstract class BaseAiImageClassification { + inputs: AiImageClassificationInput; + postProcessedOutputs: AiImageClassificationOutput; +} +type AiImageToTextInput = { + image: number[]; + prompt?: string; + max_tokens?: number; + temperature?: number; + top_p?: number; + top_k?: number; + seed?: number; + repetition_penalty?: number; + frequency_penalty?: number; + presence_penalty?: number; + raw?: boolean; + messages?: RoleScopedChatInput[]; +}; +type AiImageToTextOutput = { + description: string; +}; +declare abstract class BaseAiImageToText { + inputs: AiImageToTextInput; + postProcessedOutputs: AiImageToTextOutput; +} +type AiImageTextToTextInput = { + image: string; + prompt?: string; + max_tokens?: number; + temperature?: number; + ignore_eos?: boolean; + top_p?: number; + top_k?: number; + seed?: number; + repetition_penalty?: number; + frequency_penalty?: number; + presence_penalty?: number; + raw?: boolean; + messages?: RoleScopedChatInput[]; +}; +type AiImageTextToTextOutput = { + description: string; +}; +declare abstract class BaseAiImageTextToText { + inputs: AiImageTextToTextInput; + postProcessedOutputs: AiImageTextToTextOutput; +} +type AiMultimodalEmbeddingsInput = { + image: string; + text: string[]; +}; +type AiIMultimodalEmbeddingsOutput = { + data: number[][]; + shape: number[]; +}; +declare abstract class BaseAiMultimodalEmbeddings { + inputs: AiImageTextToTextInput; + postProcessedOutputs: AiImageTextToTextOutput; +} +type AiObjectDetectionInput = { + image: number[]; +}; +type AiObjectDetectionOutput = { + score?: number; + label?: string; +}[]; +declare abstract class BaseAiObjectDetection { + inputs: AiObjectDetectionInput; + postProcessedOutputs: AiObjectDetectionOutput; +} +type AiSentenceSimilarityInput = { + source: string; + sentences: string[]; +}; +type AiSentenceSimilarityOutput = number[]; +declare abstract class BaseAiSentenceSimilarity { + inputs: AiSentenceSimilarityInput; + postProcessedOutputs: AiSentenceSimilarityOutput; +} +type AiAutomaticSpeechRecognitionInput = { + audio: number[]; +}; +type AiAutomaticSpeechRecognitionOutput = { + text?: string; + words?: { + word: string; + start: number; + end: number; + }[]; + vtt?: string; +}; +declare abstract class BaseAiAutomaticSpeechRecognition { + inputs: AiAutomaticSpeechRecognitionInput; + postProcessedOutputs: AiAutomaticSpeechRecognitionOutput; +} +type AiSummarizationInput = { + input_text: string; + max_length?: number; +}; +type AiSummarizationOutput = { + summary: string; +}; +declare abstract class BaseAiSummarization { + inputs: AiSummarizationInput; + postProcessedOutputs: AiSummarizationOutput; +} +type AiTextClassificationInput = { + text: string; +}; +type AiTextClassificationOutput = { + score?: number; + label?: string; +}[]; +declare abstract class BaseAiTextClassification { + inputs: AiTextClassificationInput; + postProcessedOutputs: AiTextClassificationOutput; +} +type AiTextEmbeddingsInput = { + text: string | string[]; +}; +type AiTextEmbeddingsOutput = { + shape: number[]; + data: number[][]; +}; +declare abstract class BaseAiTextEmbeddings { + inputs: AiTextEmbeddingsInput; + postProcessedOutputs: AiTextEmbeddingsOutput; +} +type RoleScopedChatInput = { + role: 'user' | 'assistant' | 'system' | 'tool' | (string & NonNullable); + content: string; + name?: string; +}; +type AiTextGenerationToolLegacyInput = { + name: string; + description: string; + parameters?: { + type: 'object' | (string & NonNullable); + properties: { + [key: string]: { + type: string; + description?: string; + }; + }; + required: string[]; + }; +}; +type AiTextGenerationToolInput = { + type: 'function' | (string & NonNullable); + function: { + name: string; + description: string; + parameters?: { + type: 'object' | (string & NonNullable); + properties: { + [key: string]: { + type: string; + description?: string; + }; + }; + required: string[]; + }; + }; +}; +type AiTextGenerationFunctionsInput = { + name: string; + code: string; +}; +type AiTextGenerationResponseFormat = { + type: string; + json_schema?: any; +}; +type AiTextGenerationInput = { + prompt?: string; + raw?: boolean; + stream?: boolean; + max_tokens?: number; + temperature?: number; + top_p?: number; + top_k?: number; + seed?: number; + repetition_penalty?: number; + frequency_penalty?: number; + presence_penalty?: number; + messages?: RoleScopedChatInput[]; + response_format?: AiTextGenerationResponseFormat; + tools?: + | AiTextGenerationToolInput[] + | AiTextGenerationToolLegacyInput[] + | (object & NonNullable); + functions?: AiTextGenerationFunctionsInput[]; +}; +type AiTextGenerationToolLegacyOutput = { + name: string; + arguments: unknown; +}; +type AiTextGenerationToolOutput = { + id: string; + type: 'function'; + function: { + name: string; + arguments: string; + }; +}; +type UsageTags = { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; +}; +type AiTextGenerationOutput = { + response?: string; + tool_calls?: AiTextGenerationToolLegacyOutput[] & AiTextGenerationToolOutput[]; + usage?: UsageTags; +}; +declare abstract class BaseAiTextGeneration { + inputs: AiTextGenerationInput; + postProcessedOutputs: AiTextGenerationOutput; +} +type AiTextToSpeechInput = { + prompt: string; + lang?: string; +}; +type AiTextToSpeechOutput = + | Uint8Array + | { + audio: string; + }; +declare abstract class BaseAiTextToSpeech { + inputs: AiTextToSpeechInput; + postProcessedOutputs: AiTextToSpeechOutput; +} +type AiTextToImageInput = { + prompt: string; + negative_prompt?: string; + height?: number; + width?: number; + image?: number[]; + image_b64?: string; + mask?: number[]; + num_steps?: number; + strength?: number; + guidance?: number; + seed?: number; +}; +type AiTextToImageOutput = ReadableStream; +declare abstract class BaseAiTextToImage { + inputs: AiTextToImageInput; + postProcessedOutputs: AiTextToImageOutput; +} +type AiTranslationInput = { + text: string; + target_lang: string; + source_lang?: string; +}; +type AiTranslationOutput = { + translated_text?: string; +}; +declare abstract class BaseAiTranslation { + inputs: AiTranslationInput; + postProcessedOutputs: AiTranslationOutput; +} +/** + * Workers AI support for OpenAI's Responses API + * Reference: https://github.com/openai/openai-node/blob/master/src/resources/responses/responses.ts + * + * It's a stripped down version from its source. + * It currently supports basic function calling, json mode and accepts images as input. + * + * It does not include types for WebSearch, CodeInterpreter, FileInputs, MCP, CustomTools. + * We plan to add those incrementally as model + platform capabilities evolve. + */ +type ResponsesInput = { + background?: boolean | null; + conversation?: string | ResponseConversationParam | null; + include?: Array | null; + input?: string | ResponseInput; + instructions?: string | null; + max_output_tokens?: number | null; + parallel_tool_calls?: boolean | null; + previous_response_id?: string | null; + prompt_cache_key?: string; + reasoning?: Reasoning | null; + safety_identifier?: string; + service_tier?: 'auto' | 'default' | 'flex' | 'scale' | 'priority' | null; + stream?: boolean | null; + stream_options?: StreamOptions | null; + temperature?: number | null; + text?: ResponseTextConfig; + tool_choice?: ToolChoiceOptions | ToolChoiceFunction; + tools?: Array; + top_p?: number | null; + truncation?: 'auto' | 'disabled' | null; +}; +type ResponsesOutput = { + id?: string; + created_at?: number; + output_text?: string; + error?: ResponseError | null; + incomplete_details?: ResponseIncompleteDetails | null; + instructions?: string | Array | null; + object?: 'response'; + output?: Array; + parallel_tool_calls?: boolean; + temperature?: number | null; + tool_choice?: ToolChoiceOptions | ToolChoiceFunction; + tools?: Array; + top_p?: number | null; + max_output_tokens?: number | null; + previous_response_id?: string | null; + prompt?: ResponsePrompt | null; + reasoning?: Reasoning | null; + safety_identifier?: string; + service_tier?: 'auto' | 'default' | 'flex' | 'scale' | 'priority' | null; + status?: ResponseStatus; + text?: ResponseTextConfig; + truncation?: 'auto' | 'disabled' | null; + usage?: ResponseUsage; +}; +type EasyInputMessage = { + content: string | ResponseInputMessageContentList; + role: 'user' | 'assistant' | 'system' | 'developer'; + type?: 'message'; +}; +type ResponsesFunctionTool = { + name: string; + parameters: { + [key: string]: unknown; + } | null; + strict: boolean | null; + type: 'function'; + description?: string | null; +}; +type ResponseIncompleteDetails = { + reason?: 'max_output_tokens' | 'content_filter'; +}; +type ResponsePrompt = { + id: string; + variables?: { + [key: string]: string | ResponseInputText | ResponseInputImage; + } | null; + version?: string | null; +}; +type Reasoning = { + effort?: ReasoningEffort | null; + generate_summary?: 'auto' | 'concise' | 'detailed' | null; + summary?: 'auto' | 'concise' | 'detailed' | null; +}; +type ResponseContent = + | ResponseInputText + | ResponseInputImage + | ResponseOutputText + | ResponseOutputRefusal + | ResponseContentReasoningText; +type ResponseContentReasoningText = { + text: string; + type: 'reasoning_text'; +}; +type ResponseConversationParam = { + id: string; +}; +type ResponseCreatedEvent = { + response: Response; + sequence_number: number; + type: 'response.created'; +}; +type ResponseCustomToolCallOutput = { + call_id: string; + output: string | Array; + type: 'custom_tool_call_output'; + id?: string; +}; +type ResponseError = { + code: + | 'server_error' + | 'rate_limit_exceeded' + | 'invalid_prompt' + | 'vector_store_timeout' + | 'invalid_image' + | 'invalid_image_format' + | 'invalid_base64_image' + | 'invalid_image_url' + | 'image_too_large' + | 'image_too_small' + | 'image_parse_error' + | 'image_content_policy_violation' + | 'invalid_image_mode' + | 'image_file_too_large' + | 'unsupported_image_media_type' + | 'empty_image_file' + | 'failed_to_download_image' + | 'image_file_not_found'; + message: string; +}; +type ResponseErrorEvent = { + code: string | null; + message: string; + param: string | null; + sequence_number: number; + type: 'error'; +}; +type ResponseFailedEvent = { + response: Response; + sequence_number: number; + type: 'response.failed'; +}; +type ResponseFormatText = { + type: 'text'; +}; +type ResponseFormatJSONObject = { + type: 'json_object'; +}; +type ResponseFormatTextConfig = + | ResponseFormatText + | ResponseFormatTextJSONSchemaConfig + | ResponseFormatJSONObject; +type ResponseFormatTextJSONSchemaConfig = { + name: string; + schema: { + [key: string]: unknown; + }; + type: 'json_schema'; + description?: string; + strict?: boolean | null; +}; +type ResponseFunctionCallArgumentsDeltaEvent = { + delta: string; + item_id: string; + output_index: number; + sequence_number: number; + type: 'response.function_call_arguments.delta'; +}; +type ResponseFunctionCallArgumentsDoneEvent = { + arguments: string; + item_id: string; + name: string; + output_index: number; + sequence_number: number; + type: 'response.function_call_arguments.done'; +}; +type ResponseFunctionCallOutputItem = ResponseInputTextContent | ResponseInputImageContent; +type ResponseFunctionCallOutputItemList = Array; +type ResponseFunctionToolCall = { + arguments: string; + call_id: string; + name: string; + type: 'function_call'; + id?: string; + status?: 'in_progress' | 'completed' | 'incomplete'; +}; +interface ResponseFunctionToolCallItem extends ResponseFunctionToolCall { + id: string; +} +type ResponseFunctionToolCallOutputItem = { + id: string; + call_id: string; + output: string | Array; + type: 'function_call_output'; + status?: 'in_progress' | 'completed' | 'incomplete'; +}; +type ResponseIncludable = 'message.input_image.image_url' | 'message.output_text.logprobs'; +type ResponseIncompleteEvent = { + response: Response; + sequence_number: number; + type: 'response.incomplete'; +}; +type ResponseInput = Array; +type ResponseInputContent = ResponseInputText | ResponseInputImage; +type ResponseInputImage = { + detail: 'low' | 'high' | 'auto'; + type: 'input_image'; + /** + * Base64 encoded image + */ + image_url?: string | null; +}; +type ResponseInputImageContent = { + type: 'input_image'; + detail?: 'low' | 'high' | 'auto' | null; + /** + * Base64 encoded image + */ + image_url?: string | null; +}; +type ResponseInputItem = + | EasyInputMessage + | ResponseInputItemMessage + | ResponseOutputMessage + | ResponseFunctionToolCall + | ResponseInputItemFunctionCallOutput + | ResponseReasoningItem; +type ResponseInputItemFunctionCallOutput = { + call_id: string; + output: string | ResponseFunctionCallOutputItemList; + type: 'function_call_output'; + id?: string | null; + status?: 'in_progress' | 'completed' | 'incomplete' | null; +}; +type ResponseInputItemMessage = { + content: ResponseInputMessageContentList; + role: 'user' | 'system' | 'developer'; + status?: 'in_progress' | 'completed' | 'incomplete'; + type?: 'message'; +}; +type ResponseInputMessageContentList = Array; +type ResponseInputMessageItem = { + id: string; + content: ResponseInputMessageContentList; + role: 'user' | 'system' | 'developer'; + status?: 'in_progress' | 'completed' | 'incomplete'; + type?: 'message'; +}; +type ResponseInputText = { + text: string; + type: 'input_text'; +}; +type ResponseInputTextContent = { + text: string; + type: 'input_text'; +}; +type ResponseItem = + | ResponseInputMessageItem + | ResponseOutputMessage + | ResponseFunctionToolCallItem + | ResponseFunctionToolCallOutputItem; +type ResponseOutputItem = ResponseOutputMessage | ResponseFunctionToolCall | ResponseReasoningItem; +type ResponseOutputItemAddedEvent = { + item: ResponseOutputItem; + output_index: number; + sequence_number: number; + type: 'response.output_item.added'; +}; +type ResponseOutputItemDoneEvent = { + item: ResponseOutputItem; + output_index: number; + sequence_number: number; + type: 'response.output_item.done'; +}; +type ResponseOutputMessage = { + id: string; + content: Array; + role: 'assistant'; + status: 'in_progress' | 'completed' | 'incomplete'; + type: 'message'; +}; +type ResponseOutputRefusal = { + refusal: string; + type: 'refusal'; +}; +type ResponseOutputText = { + text: string; + type: 'output_text'; + logprobs?: Array; +}; +type ResponseReasoningItem = { + id: string; + summary: Array; + type: 'reasoning'; + content?: Array; + encrypted_content?: string | null; + status?: 'in_progress' | 'completed' | 'incomplete'; +}; +type ResponseReasoningSummaryItem = { + text: string; + type: 'summary_text'; +}; +type ResponseReasoningContentItem = { + text: string; + type: 'reasoning_text'; +}; +type ResponseReasoningTextDeltaEvent = { + content_index: number; + delta: string; + item_id: string; + output_index: number; + sequence_number: number; + type: 'response.reasoning_text.delta'; +}; +type ResponseReasoningTextDoneEvent = { + content_index: number; + item_id: string; + output_index: number; + sequence_number: number; + text: string; + type: 'response.reasoning_text.done'; +}; +type ResponseRefusalDeltaEvent = { + content_index: number; + delta: string; + item_id: string; + output_index: number; + sequence_number: number; + type: 'response.refusal.delta'; +}; +type ResponseRefusalDoneEvent = { + content_index: number; + item_id: string; + output_index: number; + refusal: string; + sequence_number: number; + type: 'response.refusal.done'; +}; +type ResponseStatus = + | 'completed' + | 'failed' + | 'in_progress' + | 'cancelled' + | 'queued' + | 'incomplete'; +type ResponseStreamEvent = + | ResponseCompletedEvent + | ResponseCreatedEvent + | ResponseErrorEvent + | ResponseFunctionCallArgumentsDeltaEvent + | ResponseFunctionCallArgumentsDoneEvent + | ResponseFailedEvent + | ResponseIncompleteEvent + | ResponseOutputItemAddedEvent + | ResponseOutputItemDoneEvent + | ResponseReasoningTextDeltaEvent + | ResponseReasoningTextDoneEvent + | ResponseRefusalDeltaEvent + | ResponseRefusalDoneEvent + | ResponseTextDeltaEvent + | ResponseTextDoneEvent; +type ResponseCompletedEvent = { + response: Response; + sequence_number: number; + type: 'response.completed'; +}; +type ResponseTextConfig = { + format?: ResponseFormatTextConfig; + verbosity?: 'low' | 'medium' | 'high' | null; +}; +type ResponseTextDeltaEvent = { + content_index: number; + delta: string; + item_id: string; + logprobs: Array; + output_index: number; + sequence_number: number; + type: 'response.output_text.delta'; +}; +type ResponseTextDoneEvent = { + content_index: number; + item_id: string; + logprobs: Array; + output_index: number; + sequence_number: number; + text: string; + type: 'response.output_text.done'; +}; +type Logprob = { + token: string; + logprob: number; + top_logprobs?: Array; +}; +type TopLogprob = { + token?: string; + logprob?: number; +}; +type ResponseUsage = { + input_tokens: number; + output_tokens: number; + total_tokens: number; +}; +type Tool = ResponsesFunctionTool; +type ToolChoiceFunction = { + name: string; + type: 'function'; +}; +type ToolChoiceOptions = 'none'; +type ReasoningEffort = 'minimal' | 'low' | 'medium' | 'high' | null; +type StreamOptions = { + include_obfuscation?: boolean; +}; +type Ai_Cf_Baai_Bge_Base_En_V1_5_Input = + | { + text: string | string[]; + /** + * The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy. + */ + pooling?: 'mean' | 'cls'; + } + | { + /** + * Batch of the embeddings requests to run using async-queue + */ + requests: { + text: string | string[]; + /** + * The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy. + */ + pooling?: 'mean' | 'cls'; + }[]; + }; +type Ai_Cf_Baai_Bge_Base_En_V1_5_Output = + | { + shape?: number[]; + /** + * Embeddings of the requested text values + */ + data?: number[][]; + /** + * The pooling method used in the embedding process. + */ + pooling?: 'mean' | 'cls'; + } + | Ai_Cf_Baai_Bge_Base_En_V1_5_AsyncResponse; +interface Ai_Cf_Baai_Bge_Base_En_V1_5_AsyncResponse { + /** + * The async request id that can be used to obtain the results. + */ + request_id?: string; +} +declare abstract class Base_Ai_Cf_Baai_Bge_Base_En_V1_5 { + inputs: Ai_Cf_Baai_Bge_Base_En_V1_5_Input; + postProcessedOutputs: Ai_Cf_Baai_Bge_Base_En_V1_5_Output; +} +type Ai_Cf_Openai_Whisper_Input = + | string + | { + /** + * An array of integers that represent the audio data constrained to 8-bit unsigned integer values + */ + audio: number[]; + }; +interface Ai_Cf_Openai_Whisper_Output { + /** + * The transcription + */ + text: string; + word_count?: number; + words?: { + word?: string; + /** + * The second this word begins in the recording + */ + start?: number; + /** + * The ending second when the word completes + */ + end?: number; + }[]; + vtt?: string; +} +declare abstract class Base_Ai_Cf_Openai_Whisper { + inputs: Ai_Cf_Openai_Whisper_Input; + postProcessedOutputs: Ai_Cf_Openai_Whisper_Output; +} +type Ai_Cf_Meta_M2M100_1_2B_Input = + | { + /** + * The text to be translated + */ + text: string; + /** + * The language code of the source text (e.g., 'en' for English). Defaults to 'en' if not specified + */ + source_lang?: string; + /** + * The language code to translate the text into (e.g., 'es' for Spanish) + */ + target_lang: string; + } + | { + /** + * Batch of the embeddings requests to run using async-queue + */ + requests: { + /** + * The text to be translated + */ + text: string; + /** + * The language code of the source text (e.g., 'en' for English). Defaults to 'en' if not specified + */ + source_lang?: string; + /** + * The language code to translate the text into (e.g., 'es' for Spanish) + */ + target_lang: string; + }[]; + }; +type Ai_Cf_Meta_M2M100_1_2B_Output = + | { + /** + * The translated text in the target language + */ + translated_text?: string; + } + | Ai_Cf_Meta_M2M100_1_2B_AsyncResponse; +interface Ai_Cf_Meta_M2M100_1_2B_AsyncResponse { + /** + * The async request id that can be used to obtain the results. + */ + request_id?: string; +} +declare abstract class Base_Ai_Cf_Meta_M2M100_1_2B { + inputs: Ai_Cf_Meta_M2M100_1_2B_Input; + postProcessedOutputs: Ai_Cf_Meta_M2M100_1_2B_Output; +} +type Ai_Cf_Baai_Bge_Small_En_V1_5_Input = + | { + text: string | string[]; + /** + * The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy. + */ + pooling?: 'mean' | 'cls'; + } + | { + /** + * Batch of the embeddings requests to run using async-queue + */ + requests: { + text: string | string[]; + /** + * The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy. + */ + pooling?: 'mean' | 'cls'; + }[]; + }; +type Ai_Cf_Baai_Bge_Small_En_V1_5_Output = + | { + shape?: number[]; + /** + * Embeddings of the requested text values + */ + data?: number[][]; + /** + * The pooling method used in the embedding process. + */ + pooling?: 'mean' | 'cls'; + } + | Ai_Cf_Baai_Bge_Small_En_V1_5_AsyncResponse; +interface Ai_Cf_Baai_Bge_Small_En_V1_5_AsyncResponse { + /** + * The async request id that can be used to obtain the results. + */ + request_id?: string; +} +declare abstract class Base_Ai_Cf_Baai_Bge_Small_En_V1_5 { + inputs: Ai_Cf_Baai_Bge_Small_En_V1_5_Input; + postProcessedOutputs: Ai_Cf_Baai_Bge_Small_En_V1_5_Output; +} +type Ai_Cf_Baai_Bge_Large_En_V1_5_Input = + | { + text: string | string[]; + /** + * The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy. + */ + pooling?: 'mean' | 'cls'; + } + | { + /** + * Batch of the embeddings requests to run using async-queue + */ + requests: { + text: string | string[]; + /** + * The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy. + */ + pooling?: 'mean' | 'cls'; + }[]; + }; +type Ai_Cf_Baai_Bge_Large_En_V1_5_Output = + | { + shape?: number[]; + /** + * Embeddings of the requested text values + */ + data?: number[][]; + /** + * The pooling method used in the embedding process. + */ + pooling?: 'mean' | 'cls'; + } + | Ai_Cf_Baai_Bge_Large_En_V1_5_AsyncResponse; +interface Ai_Cf_Baai_Bge_Large_En_V1_5_AsyncResponse { + /** + * The async request id that can be used to obtain the results. + */ + request_id?: string; +} +declare abstract class Base_Ai_Cf_Baai_Bge_Large_En_V1_5 { + inputs: Ai_Cf_Baai_Bge_Large_En_V1_5_Input; + postProcessedOutputs: Ai_Cf_Baai_Bge_Large_En_V1_5_Output; +} +type Ai_Cf_Unum_Uform_Gen2_Qwen_500M_Input = + | string + | { + /** + * The input text prompt for the model to generate a response. + */ + prompt?: string; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * Controls the creativity of the AI's responses by adjusting how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; + image: number[] | (string & NonNullable); + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + }; +interface Ai_Cf_Unum_Uform_Gen2_Qwen_500M_Output { + description?: string; +} +declare abstract class Base_Ai_Cf_Unum_Uform_Gen2_Qwen_500M { + inputs: Ai_Cf_Unum_Uform_Gen2_Qwen_500M_Input; + postProcessedOutputs: Ai_Cf_Unum_Uform_Gen2_Qwen_500M_Output; +} +type Ai_Cf_Openai_Whisper_Tiny_En_Input = + | string + | { + /** + * An array of integers that represent the audio data constrained to 8-bit unsigned integer values + */ + audio: number[]; + }; +interface Ai_Cf_Openai_Whisper_Tiny_En_Output { + /** + * The transcription + */ + text: string; + word_count?: number; + words?: { + word?: string; + /** + * The second this word begins in the recording + */ + start?: number; + /** + * The ending second when the word completes + */ + end?: number; + }[]; + vtt?: string; +} +declare abstract class Base_Ai_Cf_Openai_Whisper_Tiny_En { + inputs: Ai_Cf_Openai_Whisper_Tiny_En_Input; + postProcessedOutputs: Ai_Cf_Openai_Whisper_Tiny_En_Output; +} +interface Ai_Cf_Openai_Whisper_Large_V3_Turbo_Input { + /** + * Base64 encoded value of the audio data. + */ + audio: string; + /** + * Supported tasks are 'translate' or 'transcribe'. + */ + task?: string; + /** + * The language of the audio being transcribed or translated. + */ + language?: string; + /** + * Preprocess the audio with a voice activity detection model. + */ + vad_filter?: boolean; + /** + * A text prompt to help provide context to the model on the contents of the audio. + */ + initial_prompt?: string; + /** + * The prefix it appended the the beginning of the output of the transcription and can guide the transcription result. + */ + prefix?: string; +} +interface Ai_Cf_Openai_Whisper_Large_V3_Turbo_Output { + transcription_info?: { + /** + * The language of the audio being transcribed or translated. + */ + language?: string; + /** + * The confidence level or probability of the detected language being accurate, represented as a decimal between 0 and 1. + */ + language_probability?: number; + /** + * The total duration of the original audio file, in seconds. + */ + duration?: number; + /** + * The duration of the audio after applying Voice Activity Detection (VAD) to remove silent or irrelevant sections, in seconds. + */ + duration_after_vad?: number; + }; + /** + * The complete transcription of the audio. + */ + text: string; + /** + * The total number of words in the transcription. + */ + word_count?: number; + segments?: { + /** + * The starting time of the segment within the audio, in seconds. + */ + start?: number; + /** + * The ending time of the segment within the audio, in seconds. + */ + end?: number; + /** + * The transcription of the segment. + */ + text?: string; + /** + * The temperature used in the decoding process, controlling randomness in predictions. Lower values result in more deterministic outputs. + */ + temperature?: number; + /** + * The average log probability of the predictions for the words in this segment, indicating overall confidence. + */ + avg_logprob?: number; + /** + * The compression ratio of the input to the output, measuring how much the text was compressed during the transcription process. + */ + compression_ratio?: number; + /** + * The probability that the segment contains no speech, represented as a decimal between 0 and 1. + */ + no_speech_prob?: number; + words?: { + /** + * The individual word transcribed from the audio. + */ + word?: string; + /** + * The starting time of the word within the audio, in seconds. + */ + start?: number; + /** + * The ending time of the word within the audio, in seconds. + */ + end?: number; + }[]; + }[]; + /** + * The transcription in WebVTT format, which includes timing and text information for use in subtitles. + */ + vtt?: string; +} +declare abstract class Base_Ai_Cf_Openai_Whisper_Large_V3_Turbo { + inputs: Ai_Cf_Openai_Whisper_Large_V3_Turbo_Input; + postProcessedOutputs: Ai_Cf_Openai_Whisper_Large_V3_Turbo_Output; +} +type Ai_Cf_Baai_Bge_M3_Input = + | Ai_Cf_Baai_Bge_M3_Input_QueryAnd_Contexts + | Ai_Cf_Baai_Bge_M3_Input_Embedding + | { + /** + * Batch of the embeddings requests to run using async-queue + */ + requests: ( + | Ai_Cf_Baai_Bge_M3_Input_QueryAnd_Contexts_1 + | Ai_Cf_Baai_Bge_M3_Input_Embedding_1 + )[]; + }; +interface Ai_Cf_Baai_Bge_M3_Input_QueryAnd_Contexts { + /** + * A query you wish to perform against the provided contexts. If no query is provided the model with respond with embeddings for contexts + */ + query?: string; + /** + * List of provided contexts. Note that the index in this array is important, as the response will refer to it. + */ + contexts: { + /** + * One of the provided context content + */ + text?: string; + }[]; + /** + * When provided with too long context should the model error out or truncate the context to fit? + */ + truncate_inputs?: boolean; +} +interface Ai_Cf_Baai_Bge_M3_Input_Embedding { + text: string | string[]; + /** + * When provided with too long context should the model error out or truncate the context to fit? + */ + truncate_inputs?: boolean; +} +interface Ai_Cf_Baai_Bge_M3_Input_QueryAnd_Contexts_1 { + /** + * A query you wish to perform against the provided contexts. If no query is provided the model with respond with embeddings for contexts + */ + query?: string; + /** + * List of provided contexts. Note that the index in this array is important, as the response will refer to it. + */ + contexts: { + /** + * One of the provided context content + */ + text?: string; + }[]; + /** + * When provided with too long context should the model error out or truncate the context to fit? + */ + truncate_inputs?: boolean; +} +interface Ai_Cf_Baai_Bge_M3_Input_Embedding_1 { + text: string | string[]; + /** + * When provided with too long context should the model error out or truncate the context to fit? + */ + truncate_inputs?: boolean; +} +type Ai_Cf_Baai_Bge_M3_Output = + | Ai_Cf_Baai_Bge_M3_Ouput_Query + | Ai_Cf_Baai_Bge_M3_Output_EmbeddingFor_Contexts + | Ai_Cf_Baai_Bge_M3_Ouput_Embedding + | Ai_Cf_Baai_Bge_M3_AsyncResponse; +interface Ai_Cf_Baai_Bge_M3_Ouput_Query { + response?: { + /** + * Index of the context in the request + */ + id?: number; + /** + * Score of the context under the index. + */ + score?: number; + }[]; +} +interface Ai_Cf_Baai_Bge_M3_Output_EmbeddingFor_Contexts { + response?: number[][]; + shape?: number[]; + /** + * The pooling method used in the embedding process. + */ + pooling?: 'mean' | 'cls'; +} +interface Ai_Cf_Baai_Bge_M3_Ouput_Embedding { + shape?: number[]; + /** + * Embeddings of the requested text values + */ + data?: number[][]; + /** + * The pooling method used in the embedding process. + */ + pooling?: 'mean' | 'cls'; +} +interface Ai_Cf_Baai_Bge_M3_AsyncResponse { + /** + * The async request id that can be used to obtain the results. + */ + request_id?: string; +} +declare abstract class Base_Ai_Cf_Baai_Bge_M3 { + inputs: Ai_Cf_Baai_Bge_M3_Input; + postProcessedOutputs: Ai_Cf_Baai_Bge_M3_Output; +} +interface Ai_Cf_Black_Forest_Labs_Flux_1_Schnell_Input { + /** + * A text description of the image you want to generate. + */ + prompt: string; + /** + * The number of diffusion steps; higher values can improve quality but take longer. + */ + steps?: number; +} +interface Ai_Cf_Black_Forest_Labs_Flux_1_Schnell_Output { + /** + * The generated image in Base64 format. + */ + image?: string; +} +declare abstract class Base_Ai_Cf_Black_Forest_Labs_Flux_1_Schnell { + inputs: Ai_Cf_Black_Forest_Labs_Flux_1_Schnell_Input; + postProcessedOutputs: Ai_Cf_Black_Forest_Labs_Flux_1_Schnell_Output; +} +type Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Input = + | Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Prompt + | Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Messages; +interface Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + image?: number[] | (string & NonNullable); + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; + /** + * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model. + */ + lora?: string; +} +interface Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role?: string; + /** + * The tool call id. Must be supplied for tool calls for Mistral-3. If you don't know what to put here you can fall back to 000000001 + */ + tool_call_id?: string; + content?: + | string + | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }[] + | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }; + }[]; + image?: number[] | (string & NonNullable); + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ( + | { + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } + | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + } + )[]; + /** + * If true, the response will be streamed back incrementally. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Controls the creativity of the AI's responses by adjusting how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +type Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Output = { + /** + * The generated text response from the model + */ + response?: string; + /** + * An array of tool calls requests made during the response generation + */ + tool_calls?: { + /** + * The arguments passed to be passed to the tool call request + */ + arguments?: object; + /** + * The name of the tool to be called + */ + name?: string; + }[]; +}; +declare abstract class Base_Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct { + inputs: Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Input; + postProcessedOutputs: Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Output; +} +type Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Input = + | Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Prompt + | Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Messages + | Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Async_Batch; +interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model. + */ + lora?: string; + response_format?: Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode { + type?: 'json_object' | 'json_schema'; + json_schema?: unknown; +} +interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role: string; + /** + * The content of the message as a string. + */ + content: string; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ( + | { + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } + | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + } + )[]; + response_format?: Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode_1; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode_1 { + type?: 'json_object' | 'json_schema'; + json_schema?: unknown; +} +interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Async_Batch { + requests?: { + /** + * User-supplied reference. This field will be present in the response as well it can be used to reference the request and response. It's NOT validated to be unique. + */ + external_reference?: string; + /** + * Prompt for the text generation model + */ + prompt?: string; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; + response_format?: Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode_2; + }[]; +} +interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode_2 { + type?: 'json_object' | 'json_schema'; + json_schema?: unknown; +} +type Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Output = + | { + /** + * The generated text response from the model + */ + response: string; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; + /** + * An array of tool calls requests made during the response generation + */ + tool_calls?: { + /** + * The arguments passed to be passed to the tool call request + */ + arguments?: object; + /** + * The name of the tool to be called + */ + name?: string; + }[]; + } + | string + | Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_AsyncResponse; +interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_AsyncResponse { + /** + * The async request id that can be used to obtain the results. + */ + request_id?: string; +} +declare abstract class Base_Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast { + inputs: Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Input; + postProcessedOutputs: Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Output; +} +interface Ai_Cf_Meta_Llama_Guard_3_8B_Input { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender must alternate between 'user' and 'assistant'. + */ + role: 'user' | 'assistant'; + /** + * The content of the message as a string. + */ + content: string; + }[]; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Dictate the output format of the generated response. + */ + response_format?: { + /** + * Set to json_object to process and output generated text as JSON. + */ + type?: string; + }; +} +interface Ai_Cf_Meta_Llama_Guard_3_8B_Output { + response?: + | string + | { + /** + * Whether the conversation is safe or not. + */ + safe?: boolean; + /** + * A list of what hazard categories predicted for the conversation, if the conversation is deemed unsafe. + */ + categories?: string[]; + }; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; +} +declare abstract class Base_Ai_Cf_Meta_Llama_Guard_3_8B { + inputs: Ai_Cf_Meta_Llama_Guard_3_8B_Input; + postProcessedOutputs: Ai_Cf_Meta_Llama_Guard_3_8B_Output; +} +interface Ai_Cf_Baai_Bge_Reranker_Base_Input { + /** + * A query you wish to perform against the provided contexts. + */ + /** + * Number of returned results starting with the best score. + */ + top_k?: number; + /** + * List of provided contexts. Note that the index in this array is important, as the response will refer to it. + */ + contexts: { + /** + * One of the provided context content + */ + text?: string; + }[]; +} +interface Ai_Cf_Baai_Bge_Reranker_Base_Output { + response?: { + /** + * Index of the context in the request + */ + id?: number; + /** + * Score of the context under the index. + */ + score?: number; + }[]; +} +declare abstract class Base_Ai_Cf_Baai_Bge_Reranker_Base { + inputs: Ai_Cf_Baai_Bge_Reranker_Base_Input; + postProcessedOutputs: Ai_Cf_Baai_Bge_Reranker_Base_Output; +} +type Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Input = + | Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Prompt + | Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Messages; +interface Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model. + */ + lora?: string; + response_format?: Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_JSON_Mode; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_JSON_Mode { + type?: 'json_object' | 'json_schema'; + json_schema?: unknown; +} +interface Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role: string; + /** + * The content of the message as a string. + */ + content: string; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ( + | { + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } + | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + } + )[]; + response_format?: Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_JSON_Mode_1; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_JSON_Mode_1 { + type?: 'json_object' | 'json_schema'; + json_schema?: unknown; +} +type Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Output = { + /** + * The generated text response from the model + */ + response: string; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; + /** + * An array of tool calls requests made during the response generation + */ + tool_calls?: { + /** + * The arguments passed to be passed to the tool call request + */ + arguments?: object; + /** + * The name of the tool to be called + */ + name?: string; + }[]; +}; +declare abstract class Base_Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct { + inputs: Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Input; + postProcessedOutputs: Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Output; +} +type Ai_Cf_Qwen_Qwq_32B_Input = Ai_Cf_Qwen_Qwq_32B_Prompt | Ai_Cf_Qwen_Qwq_32B_Messages; +interface Ai_Cf_Qwen_Qwq_32B_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * JSON schema that should be fulfilled for the response. + */ + guided_json?: object; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Qwen_Qwq_32B_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role?: string; + /** + * The tool call id. Must be supplied for tool calls for Mistral-3. If you don't know what to put here you can fall back to 000000001 + */ + tool_call_id?: string; + content?: + | string + | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }[] + | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ( + | { + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } + | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + } + )[]; + /** + * JSON schema that should be fulfilled for the response. + */ + guided_json?: object; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +type Ai_Cf_Qwen_Qwq_32B_Output = { + /** + * The generated text response from the model + */ + response: string; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; + /** + * An array of tool calls requests made during the response generation + */ + tool_calls?: { + /** + * The arguments passed to be passed to the tool call request + */ + arguments?: object; + /** + * The name of the tool to be called + */ + name?: string; + }[]; +}; +declare abstract class Base_Ai_Cf_Qwen_Qwq_32B { + inputs: Ai_Cf_Qwen_Qwq_32B_Input; + postProcessedOutputs: Ai_Cf_Qwen_Qwq_32B_Output; +} +type Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Input = + | Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Prompt + | Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Messages; +interface Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * JSON schema that should be fulfilled for the response. + */ + guided_json?: object; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role?: string; + /** + * The tool call id. Must be supplied for tool calls for Mistral-3. If you don't know what to put here you can fall back to 000000001 + */ + tool_call_id?: string; + content?: + | string + | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }[] + | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ( + | { + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } + | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + } + )[]; + /** + * JSON schema that should be fulfilled for the response. + */ + guided_json?: object; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +type Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Output = { + /** + * The generated text response from the model + */ + response: string; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; + /** + * An array of tool calls requests made during the response generation + */ + tool_calls?: { + /** + * The arguments passed to be passed to the tool call request + */ + arguments?: object; + /** + * The name of the tool to be called + */ + name?: string; + }[]; +}; +declare abstract class Base_Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct { + inputs: Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Input; + postProcessedOutputs: Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Output; +} +type Ai_Cf_Google_Gemma_3_12B_It_Input = + | Ai_Cf_Google_Gemma_3_12B_It_Prompt + | Ai_Cf_Google_Gemma_3_12B_It_Messages; +interface Ai_Cf_Google_Gemma_3_12B_It_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * JSON schema that should be fulfilled for the response. + */ + guided_json?: object; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Google_Gemma_3_12B_It_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role?: string; + content?: + | string + | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }[]; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ( + | { + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } + | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + } + )[]; + /** + * JSON schema that should be fulfilled for the response. + */ + guided_json?: object; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +type Ai_Cf_Google_Gemma_3_12B_It_Output = { + /** + * The generated text response from the model + */ + response: string; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; + /** + * An array of tool calls requests made during the response generation + */ + tool_calls?: { + /** + * The arguments passed to be passed to the tool call request + */ + arguments?: object; + /** + * The name of the tool to be called + */ + name?: string; + }[]; +}; +declare abstract class Base_Ai_Cf_Google_Gemma_3_12B_It { + inputs: Ai_Cf_Google_Gemma_3_12B_It_Input; + postProcessedOutputs: Ai_Cf_Google_Gemma_3_12B_It_Output; +} +type Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Input = + | Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Prompt + | Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Messages + | Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Async_Batch; +interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * JSON schema that should be fulfilled for the response. + */ + guided_json?: object; + response_format?: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode { + type?: 'json_object' | 'json_schema'; + json_schema?: unknown; +} +interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role?: string; + /** + * The tool call id. If you don't know what to put here you can fall back to 000000001 + */ + tool_call_id?: string; + content?: + | string + | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }[] + | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ( + | { + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } + | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + } + )[]; + response_format?: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode; + /** + * JSON schema that should be fulfilled for the response. + */ + guided_json?: object; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Async_Batch { + requests: ( + | Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Prompt_Inner + | Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Messages_Inner + )[]; +} +interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Prompt_Inner { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * JSON schema that should be fulfilled for the response. + */ + guided_json?: object; + response_format?: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Messages_Inner { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role?: string; + /** + * The tool call id. If you don't know what to put here you can fall back to 000000001 + */ + tool_call_id?: string; + content?: + | string + | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }[] + | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ( + | { + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } + | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + } + )[]; + response_format?: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode; + /** + * JSON schema that should be fulfilled for the response. + */ + guided_json?: object; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +type Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Output = { + /** + * The generated text response from the model + */ + response: string; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; + /** + * An array of tool calls requests made during the response generation + */ + tool_calls?: { + /** + * The tool call id. + */ + id?: string; + /** + * Specifies the type of tool (e.g., 'function'). + */ + type?: string; + /** + * Details of the function tool. + */ + function?: { + /** + * The name of the tool to be called + */ + name?: string; + /** + * The arguments passed to be passed to the tool call request + */ + arguments?: object; + }; + }[]; +}; +declare abstract class Base_Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct { + inputs: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Input; + postProcessedOutputs: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Output; +} +type Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Input = + | Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Prompt + | Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Messages + | Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Async_Batch; +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model. + */ + lora?: string; + response_format?: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode { + type?: 'json_object' | 'json_schema'; + json_schema?: unknown; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role: string; + /** + * The content of the message as a string. + */ + content: string; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ( + | { + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } + | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + } + )[]; + response_format?: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_1; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_1 { + type?: 'json_object' | 'json_schema'; + json_schema?: unknown; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Async_Batch { + requests: (Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Prompt_1 | Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Messages_1)[]; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Prompt_1 { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model. + */ + lora?: string; + response_format?: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_2; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_2 { + type?: 'json_object' | 'json_schema'; + json_schema?: unknown; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Messages_1 { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role: string; + /** + * The content of the message as a string. + */ + content: string; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ( + | { + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } + | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + } + )[]; + response_format?: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_3; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_3 { + type?: 'json_object' | 'json_schema'; + json_schema?: unknown; +} +type Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Output = + | Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Chat_Completion_Response + | Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Text_Completion_Response + | string + | Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_AsyncResponse; +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Chat_Completion_Response { + /** + * Unique identifier for the completion + */ + id?: string; + /** + * Object type identifier + */ + object?: 'chat.completion'; + /** + * Unix timestamp of when the completion was created + */ + created?: number; + /** + * Model used for the completion + */ + model?: string; + /** + * List of completion choices + */ + choices?: { + /** + * Index of the choice in the list + */ + index?: number; + /** + * The message generated by the model + */ + message?: { + /** + * Role of the message author + */ + role: string; + /** + * The content of the message + */ + content: string; + /** + * Internal reasoning content (if available) + */ + reasoning_content?: string; + /** + * Tool calls made by the assistant + */ + tool_calls?: { + /** + * Unique identifier for the tool call + */ + id: string; + /** + * Type of tool call + */ + type: 'function'; + function: { + /** + * Name of the function to call + */ + name: string; + /** + * JSON string of arguments for the function + */ + arguments: string; + }; + }[]; + }; + /** + * Reason why the model stopped generating + */ + finish_reason?: string; + /** + * Stop reason (may be null) + */ + stop_reason?: string | null; + /** + * Log probabilities (if requested) + */ + logprobs?: {} | null; + }[]; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; + /** + * Log probabilities for the prompt (if requested) + */ + prompt_logprobs?: {} | null; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Text_Completion_Response { + /** + * Unique identifier for the completion + */ + id?: string; + /** + * Object type identifier + */ + object?: 'text_completion'; + /** + * Unix timestamp of when the completion was created + */ + created?: number; + /** + * Model used for the completion + */ + model?: string; + /** + * List of completion choices + */ + choices?: { + /** + * Index of the choice in the list + */ + index: number; + /** + * The generated text completion + */ + text: string; + /** + * Reason why the model stopped generating + */ + finish_reason: string; + /** + * Stop reason (may be null) + */ + stop_reason?: string | null; + /** + * Log probabilities (if requested) + */ + logprobs?: {} | null; + /** + * Log probabilities for the prompt (if requested) + */ + prompt_logprobs?: {} | null; + }[]; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_AsyncResponse { + /** + * The async request id that can be used to obtain the results. + */ + request_id?: string; +} +declare abstract class Base_Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8 { + inputs: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Input; + postProcessedOutputs: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Output; +} +interface Ai_Cf_Deepgram_Nova_3_Input { + audio: { + body: object; + contentType: string; + }; + /** + * Sets how the model will interpret strings submitted to the custom_topic param. When strict, the model will only return topics submitted using the custom_topic param. When extended, the model will return its own detected topics in addition to those submitted using the custom_topic param. + */ + custom_topic_mode?: 'extended' | 'strict'; + /** + * Custom topics you want the model to detect within your input audio or text if present Submit up to 100 + */ + custom_topic?: string; + /** + * Sets how the model will interpret intents submitted to the custom_intent param. When strict, the model will only return intents submitted using the custom_intent param. When extended, the model will return its own detected intents in addition those submitted using the custom_intents param + */ + custom_intent_mode?: 'extended' | 'strict'; + /** + * Custom intents you want the model to detect within your input audio if present + */ + custom_intent?: string; + /** + * Identifies and extracts key entities from content in submitted audio + */ + detect_entities?: boolean; + /** + * Identifies the dominant language spoken in submitted audio + */ + detect_language?: boolean; + /** + * Recognize speaker changes. Each word in the transcript will be assigned a speaker number starting at 0 + */ + diarize?: boolean; + /** + * Identify and extract key entities from content in submitted audio + */ + dictation?: boolean; + /** + * Specify the expected encoding of your submitted audio + */ + encoding?: 'linear16' | 'flac' | 'mulaw' | 'amr-nb' | 'amr-wb' | 'opus' | 'speex' | 'g729'; + /** + * Arbitrary key-value pairs that are attached to the API response for usage in downstream processing + */ + extra?: string; + /** + * Filler Words can help transcribe interruptions in your audio, like 'uh' and 'um' + */ + filler_words?: boolean; + /** + * Key term prompting can boost or suppress specialized terminology and brands. + */ + keyterm?: string; + /** + * Keywords can boost or suppress specialized terminology and brands. + */ + keywords?: string; + /** + * The BCP-47 language tag that hints at the primary spoken language. Depending on the Model and API endpoint you choose only certain languages are available. + */ + language?: string; + /** + * Spoken measurements will be converted to their corresponding abbreviations. + */ + measurements?: boolean; + /** + * Opts out requests from the Deepgram Model Improvement Program. Refer to our Docs for pricing impacts before setting this to true. https://dpgr.am/deepgram-mip. + */ + mip_opt_out?: boolean; + /** + * Mode of operation for the model representing broad area of topic that will be talked about in the supplied audio + */ + mode?: 'general' | 'medical' | 'finance'; + /** + * Transcribe each audio channel independently. + */ + multichannel?: boolean; + /** + * Numerals converts numbers from written format to numerical format. + */ + numerals?: boolean; + /** + * Splits audio into paragraphs to improve transcript readability. + */ + paragraphs?: boolean; + /** + * Profanity Filter looks for recognized profanity and converts it to the nearest recognized non-profane word or removes it from the transcript completely. + */ + profanity_filter?: boolean; + /** + * Add punctuation and capitalization to the transcript. + */ + punctuate?: boolean; + /** + * Redaction removes sensitive information from your transcripts. + */ + redact?: string; + /** + * Search for terms or phrases in submitted audio and replaces them. + */ + replace?: string; + /** + * Search for terms or phrases in submitted audio. + */ + search?: string; + /** + * Recognizes the sentiment throughout a transcript or text. + */ + sentiment?: boolean; + /** + * Apply formatting to transcript output. When set to true, additional formatting will be applied to transcripts to improve readability. + */ + smart_format?: boolean; + /** + * Detect topics throughout a transcript or text. + */ + topics?: boolean; + /** + * Segments speech into meaningful semantic units. + */ + utterances?: boolean; + /** + * Seconds to wait before detecting a pause between words in submitted audio. + */ + utt_split?: number; + /** + * The number of channels in the submitted audio + */ + channels?: number; + /** + * Specifies whether the streaming endpoint should provide ongoing transcription updates as more audio is received. When set to true, the endpoint sends continuous updates, meaning transcription results may evolve over time. Note: Supported only for webosockets. + */ + interim_results?: boolean; + /** + * Indicates how long model will wait to detect whether a speaker has finished speaking or pauses for a significant period of time. When set to a value, the streaming endpoint immediately finalizes the transcription for the processed time range and returns the transcript with a speech_final parameter set to true. Can also be set to false to disable endpointing + */ + endpointing?: string; + /** + * Indicates that speech has started. You'll begin receiving Speech Started messages upon speech starting. Note: Supported only for webosockets. + */ + vad_events?: boolean; + /** + * Indicates how long model will wait to send an UtteranceEnd message after a word has been transcribed. Use with interim_results. Note: Supported only for webosockets. + */ + utterance_end_ms?: boolean; +} +interface Ai_Cf_Deepgram_Nova_3_Output { + results?: { + channels?: { + alternatives?: { + confidence?: number; + transcript?: string; + words?: { + confidence?: number; + end?: number; + start?: number; + word?: string; + }[]; + }[]; + }[]; + summary?: { + result?: string; + short?: string; + }; + sentiments?: { + segments?: { + text?: string; + start_word?: number; + end_word?: number; + sentiment?: string; + sentiment_score?: number; + }[]; + average?: { + sentiment?: string; + sentiment_score?: number; + }; + }; + }; +} +declare abstract class Base_Ai_Cf_Deepgram_Nova_3 { + inputs: Ai_Cf_Deepgram_Nova_3_Input; + postProcessedOutputs: Ai_Cf_Deepgram_Nova_3_Output; +} +interface Ai_Cf_Qwen_Qwen3_Embedding_0_6B_Input { + queries?: string | string[]; + /** + * Optional instruction for the task + */ + instruction?: string; + documents?: string | string[]; + text?: string | string[]; +} +interface Ai_Cf_Qwen_Qwen3_Embedding_0_6B_Output { + data?: number[][]; + shape?: number[]; +} +declare abstract class Base_Ai_Cf_Qwen_Qwen3_Embedding_0_6B { + inputs: Ai_Cf_Qwen_Qwen3_Embedding_0_6B_Input; + postProcessedOutputs: Ai_Cf_Qwen_Qwen3_Embedding_0_6B_Output; +} +type Ai_Cf_Pipecat_Ai_Smart_Turn_V2_Input = + | { + /** + * readable stream with audio data and content-type specified for that data + */ + audio: { + body: object; + contentType: string; + }; + /** + * type of data PCM data that's sent to the inference server as raw array + */ + dtype?: 'uint8' | 'float32' | 'float64'; + } + | { + /** + * base64 encoded audio data + */ + audio: string; + /** + * type of data PCM data that's sent to the inference server as raw array + */ + dtype?: 'uint8' | 'float32' | 'float64'; + }; +interface Ai_Cf_Pipecat_Ai_Smart_Turn_V2_Output { + /** + * if true, end-of-turn was detected + */ + is_complete?: boolean; + /** + * probability of the end-of-turn detection + */ + probability?: number; +} +declare abstract class Base_Ai_Cf_Pipecat_Ai_Smart_Turn_V2 { + inputs: Ai_Cf_Pipecat_Ai_Smart_Turn_V2_Input; + postProcessedOutputs: Ai_Cf_Pipecat_Ai_Smart_Turn_V2_Output; +} +declare abstract class Base_Ai_Cf_Openai_Gpt_Oss_120B { + inputs: ResponsesInput; + postProcessedOutputs: ResponsesOutput; +} +declare abstract class Base_Ai_Cf_Openai_Gpt_Oss_20B { + inputs: ResponsesInput; + postProcessedOutputs: ResponsesOutput; +} +interface Ai_Cf_Leonardo_Phoenix_1_0_Input { + /** + * A text description of the image you want to generate. + */ + prompt: string; + /** + * Controls how closely the generated image should adhere to the prompt; higher values make the image more aligned with the prompt + */ + guidance?: number; + /** + * Random seed for reproducibility of the image generation + */ + seed?: number; + /** + * The height of the generated image in pixels + */ + height?: number; + /** + * The width of the generated image in pixels + */ + width?: number; + /** + * The number of diffusion steps; higher values can improve quality but take longer + */ + num_steps?: number; + /** + * Specify what to exclude from the generated images + */ + negative_prompt?: string; +} +/** + * The generated image in JPEG format + */ +type Ai_Cf_Leonardo_Phoenix_1_0_Output = string; +declare abstract class Base_Ai_Cf_Leonardo_Phoenix_1_0 { + inputs: Ai_Cf_Leonardo_Phoenix_1_0_Input; + postProcessedOutputs: Ai_Cf_Leonardo_Phoenix_1_0_Output; +} +interface Ai_Cf_Leonardo_Lucid_Origin_Input { + /** + * A text description of the image you want to generate. + */ + prompt: string; + /** + * Controls how closely the generated image should adhere to the prompt; higher values make the image more aligned with the prompt + */ + guidance?: number; + /** + * Random seed for reproducibility of the image generation + */ + seed?: number; + /** + * The height of the generated image in pixels + */ + height?: number; + /** + * The width of the generated image in pixels + */ + width?: number; + /** + * The number of diffusion steps; higher values can improve quality but take longer + */ + num_steps?: number; + /** + * The number of diffusion steps; higher values can improve quality but take longer + */ + steps?: number; +} +interface Ai_Cf_Leonardo_Lucid_Origin_Output { + /** + * The generated image in Base64 format. + */ + image?: string; +} +declare abstract class Base_Ai_Cf_Leonardo_Lucid_Origin { + inputs: Ai_Cf_Leonardo_Lucid_Origin_Input; + postProcessedOutputs: Ai_Cf_Leonardo_Lucid_Origin_Output; +} +interface Ai_Cf_Deepgram_Aura_1_Input { + /** + * Speaker used to produce the audio. + */ + speaker?: + | 'angus' + | 'asteria' + | 'arcas' + | 'orion' + | 'orpheus' + | 'athena' + | 'luna' + | 'zeus' + | 'perseus' + | 'helios' + | 'hera' + | 'stella'; + /** + * Encoding of the output audio. + */ + encoding?: 'linear16' | 'flac' | 'mulaw' | 'alaw' | 'mp3' | 'opus' | 'aac'; + /** + * Container specifies the file format wrapper for the output audio. The available options depend on the encoding type.. + */ + container?: 'none' | 'wav' | 'ogg'; + /** + * The text content to be converted to speech + */ + text: string; + /** + * Sample Rate specifies the sample rate for the output audio. Based on the encoding, different sample rates are supported. For some encodings, the sample rate is not configurable + */ + sample_rate?: number; + /** + * The bitrate of the audio in bits per second. Choose from predefined ranges or specific values based on the encoding type. + */ + bit_rate?: number; +} +/** + * The generated audio in MP3 format + */ +type Ai_Cf_Deepgram_Aura_1_Output = string; +declare abstract class Base_Ai_Cf_Deepgram_Aura_1 { + inputs: Ai_Cf_Deepgram_Aura_1_Input; + postProcessedOutputs: Ai_Cf_Deepgram_Aura_1_Output; +} +interface Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B_Input { + /** + * Input text to translate. Can be a single string or a list of strings. + */ + text: string | string[]; + /** + * Target language to translate to + */ + target_language: + | 'asm_Beng' + | 'awa_Deva' + | 'ben_Beng' + | 'bho_Deva' + | 'brx_Deva' + | 'doi_Deva' + | 'eng_Latn' + | 'gom_Deva' + | 'gon_Deva' + | 'guj_Gujr' + | 'hin_Deva' + | 'hne_Deva' + | 'kan_Knda' + | 'kas_Arab' + | 'kas_Deva' + | 'kha_Latn' + | 'lus_Latn' + | 'mag_Deva' + | 'mai_Deva' + | 'mal_Mlym' + | 'mar_Deva' + | 'mni_Beng' + | 'mni_Mtei' + | 'npi_Deva' + | 'ory_Orya' + | 'pan_Guru' + | 'san_Deva' + | 'sat_Olck' + | 'snd_Arab' + | 'snd_Deva' + | 'tam_Taml' + | 'tel_Telu' + | 'urd_Arab' + | 'unr_Deva'; +} +interface Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B_Output { + /** + * Translated texts + */ + translations: string[]; +} +declare abstract class Base_Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B { + inputs: Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B_Input; + postProcessedOutputs: Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B_Output; +} +type Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Input = + | Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Prompt + | Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Messages + | Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Async_Batch; +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model. + */ + lora?: string; + response_format?: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode { + type?: 'json_object' | 'json_schema'; + json_schema?: unknown; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role: string; + /** + * The content of the message as a string. + */ + content: string; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ( + | { + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } + | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + } + )[]; + response_format?: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_1; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_1 { + type?: 'json_object' | 'json_schema'; + json_schema?: unknown; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Async_Batch { + requests: ( + | Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Prompt_1 + | Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Messages_1 + )[]; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Prompt_1 { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model. + */ + lora?: string; + response_format?: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_2; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_2 { + type?: 'json_object' | 'json_schema'; + json_schema?: unknown; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Messages_1 { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role: string; + /** + * The content of the message as a string. + */ + content: string; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ( + | { + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } + | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + } + )[]; + response_format?: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_3; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_3 { + type?: 'json_object' | 'json_schema'; + json_schema?: unknown; +} +type Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Output = + | Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Chat_Completion_Response + | Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Text_Completion_Response + | string + | Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_AsyncResponse; +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Chat_Completion_Response { + /** + * Unique identifier for the completion + */ + id?: string; + /** + * Object type identifier + */ + object?: 'chat.completion'; + /** + * Unix timestamp of when the completion was created + */ + created?: number; + /** + * Model used for the completion + */ + model?: string; + /** + * List of completion choices + */ + choices?: { + /** + * Index of the choice in the list + */ + index?: number; + /** + * The message generated by the model + */ + message?: { + /** + * Role of the message author + */ + role: string; + /** + * The content of the message + */ + content: string; + /** + * Internal reasoning content (if available) + */ + reasoning_content?: string; + /** + * Tool calls made by the assistant + */ + tool_calls?: { + /** + * Unique identifier for the tool call + */ + id: string; + /** + * Type of tool call + */ + type: 'function'; + function: { + /** + * Name of the function to call + */ + name: string; + /** + * JSON string of arguments for the function + */ + arguments: string; + }; + }[]; + }; + /** + * Reason why the model stopped generating + */ + finish_reason?: string; + /** + * Stop reason (may be null) + */ + stop_reason?: string | null; + /** + * Log probabilities (if requested) + */ + logprobs?: {} | null; + }[]; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; + /** + * Log probabilities for the prompt (if requested) + */ + prompt_logprobs?: {} | null; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Text_Completion_Response { + /** + * Unique identifier for the completion + */ + id?: string; + /** + * Object type identifier + */ + object?: 'text_completion'; + /** + * Unix timestamp of when the completion was created + */ + created?: number; + /** + * Model used for the completion + */ + model?: string; + /** + * List of completion choices + */ + choices?: { + /** + * Index of the choice in the list + */ + index: number; + /** + * The generated text completion + */ + text: string; + /** + * Reason why the model stopped generating + */ + finish_reason: string; + /** + * Stop reason (may be null) + */ + stop_reason?: string | null; + /** + * Log probabilities (if requested) + */ + logprobs?: {} | null; + /** + * Log probabilities for the prompt (if requested) + */ + prompt_logprobs?: {} | null; + }[]; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_AsyncResponse { + /** + * The async request id that can be used to obtain the results. + */ + request_id?: string; +} +declare abstract class Base_Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It { + inputs: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Input; + postProcessedOutputs: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Output; +} +interface Ai_Cf_Pfnet_Plamo_Embedding_1B_Input { + /** + * Input text to embed. Can be a single string or a list of strings. + */ + text: string | string[]; +} +interface Ai_Cf_Pfnet_Plamo_Embedding_1B_Output { + /** + * Embedding vectors, where each vector is a list of floats. + */ + data: number[][]; + /** + * Shape of the embedding data as [number_of_embeddings, embedding_dimension]. + * + * @minItems 2 + * @maxItems 2 + */ + shape: [number, number]; +} +declare abstract class Base_Ai_Cf_Pfnet_Plamo_Embedding_1B { + inputs: Ai_Cf_Pfnet_Plamo_Embedding_1B_Input; + postProcessedOutputs: Ai_Cf_Pfnet_Plamo_Embedding_1B_Output; +} +interface Ai_Cf_Deepgram_Flux_Input { + /** + * Encoding of the audio stream. Currently only supports raw signed little-endian 16-bit PCM. + */ + encoding: 'linear16'; + /** + * Sample rate of the audio stream in Hz. + */ + sample_rate: string; + /** + * End-of-turn confidence required to fire an eager end-of-turn event. When set, enables EagerEndOfTurn and TurnResumed events. Valid Values 0.3 - 0.9. + */ + eager_eot_threshold?: string; + /** + * End-of-turn confidence required to finish a turn. Valid Values 0.5 - 0.9. + */ + eot_threshold?: string; + /** + * A turn will be finished when this much time has passed after speech, regardless of EOT confidence. + */ + eot_timeout_ms?: string; + /** + * Keyterm prompting can improve recognition of specialized terminology. Pass multiple keyterm query parameters to boost multiple keyterms. + */ + keyterm?: string; + /** + * Opts out requests from the Deepgram Model Improvement Program. Refer to Deepgram Docs for pricing impacts before setting this to true. https://dpgr.am/deepgram-mip + */ + mip_opt_out?: 'true' | 'false'; + /** + * Label your requests for the purpose of identification during usage reporting + */ + tag?: string; +} +/** + * Output will be returned as websocket messages. + */ +interface Ai_Cf_Deepgram_Flux_Output { + /** + * The unique identifier of the request (uuid) + */ + request_id?: string; + /** + * Starts at 0 and increments for each message the server sends to the client. + */ + sequence_id?: number; + /** + * The type of event being reported. + */ + event?: 'Update' | 'StartOfTurn' | 'EagerEndOfTurn' | 'TurnResumed' | 'EndOfTurn'; + /** + * The index of the current turn + */ + turn_index?: number; + /** + * Start time in seconds of the audio range that was transcribed + */ + audio_window_start?: number; + /** + * End time in seconds of the audio range that was transcribed + */ + audio_window_end?: number; + /** + * Text that was said over the course of the current turn + */ + transcript?: string; + /** + * The words in the transcript + */ + words?: { + /** + * The individual punctuated, properly-cased word from the transcript + */ + word: string; + /** + * Confidence that this word was transcribed correctly + */ + confidence: number; + }[]; + /** + * Confidence that no more speech is coming in this turn + */ + end_of_turn_confidence?: number; +} +declare abstract class Base_Ai_Cf_Deepgram_Flux { + inputs: Ai_Cf_Deepgram_Flux_Input; + postProcessedOutputs: Ai_Cf_Deepgram_Flux_Output; +} +interface Ai_Cf_Deepgram_Aura_2_En_Input { + /** + * Speaker used to produce the audio. + */ + speaker?: + | 'amalthea' + | 'andromeda' + | 'apollo' + | 'arcas' + | 'aries' + | 'asteria' + | 'athena' + | 'atlas' + | 'aurora' + | 'callista' + | 'cora' + | 'cordelia' + | 'delia' + | 'draco' + | 'electra' + | 'harmonia' + | 'helena' + | 'hera' + | 'hermes' + | 'hyperion' + | 'iris' + | 'janus' + | 'juno' + | 'jupiter' + | 'luna' + | 'mars' + | 'minerva' + | 'neptune' + | 'odysseus' + | 'ophelia' + | 'orion' + | 'orpheus' + | 'pandora' + | 'phoebe' + | 'pluto' + | 'saturn' + | 'thalia' + | 'theia' + | 'vesta' + | 'zeus'; + /** + * Encoding of the output audio. + */ + encoding?: 'linear16' | 'flac' | 'mulaw' | 'alaw' | 'mp3' | 'opus' | 'aac'; + /** + * Container specifies the file format wrapper for the output audio. The available options depend on the encoding type.. + */ + container?: 'none' | 'wav' | 'ogg'; + /** + * The text content to be converted to speech + */ + text: string; + /** + * Sample Rate specifies the sample rate for the output audio. Based on the encoding, different sample rates are supported. For some encodings, the sample rate is not configurable + */ + sample_rate?: number; + /** + * The bitrate of the audio in bits per second. Choose from predefined ranges or specific values based on the encoding type. + */ + bit_rate?: number; +} +/** + * The generated audio in MP3 format + */ +type Ai_Cf_Deepgram_Aura_2_En_Output = string; +declare abstract class Base_Ai_Cf_Deepgram_Aura_2_En { + inputs: Ai_Cf_Deepgram_Aura_2_En_Input; + postProcessedOutputs: Ai_Cf_Deepgram_Aura_2_En_Output; +} +interface Ai_Cf_Deepgram_Aura_2_Es_Input { + /** + * Speaker used to produce the audio. + */ + speaker?: + | 'sirio' + | 'nestor' + | 'carina' + | 'celeste' + | 'alvaro' + | 'diana' + | 'aquila' + | 'selena' + | 'estrella' + | 'javier'; + /** + * Encoding of the output audio. + */ + encoding?: 'linear16' | 'flac' | 'mulaw' | 'alaw' | 'mp3' | 'opus' | 'aac'; + /** + * Container specifies the file format wrapper for the output audio. The available options depend on the encoding type.. + */ + container?: 'none' | 'wav' | 'ogg'; + /** + * The text content to be converted to speech + */ + text: string; + /** + * Sample Rate specifies the sample rate for the output audio. Based on the encoding, different sample rates are supported. For some encodings, the sample rate is not configurable + */ + sample_rate?: number; + /** + * The bitrate of the audio in bits per second. Choose from predefined ranges or specific values based on the encoding type. + */ + bit_rate?: number; +} +/** + * The generated audio in MP3 format + */ +type Ai_Cf_Deepgram_Aura_2_Es_Output = string; +declare abstract class Base_Ai_Cf_Deepgram_Aura_2_Es { + inputs: Ai_Cf_Deepgram_Aura_2_Es_Input; + postProcessedOutputs: Ai_Cf_Deepgram_Aura_2_Es_Output; +} +interface AiModels { + '@cf/huggingface/distilbert-sst-2-int8': BaseAiTextClassification; + '@cf/stabilityai/stable-diffusion-xl-base-1.0': BaseAiTextToImage; + '@cf/runwayml/stable-diffusion-v1-5-inpainting': BaseAiTextToImage; + '@cf/runwayml/stable-diffusion-v1-5-img2img': BaseAiTextToImage; + '@cf/lykon/dreamshaper-8-lcm': BaseAiTextToImage; + '@cf/bytedance/stable-diffusion-xl-lightning': BaseAiTextToImage; + '@cf/myshell-ai/melotts': BaseAiTextToSpeech; + '@cf/google/embeddinggemma-300m': BaseAiTextEmbeddings; + '@cf/microsoft/resnet-50': BaseAiImageClassification; + '@cf/meta/llama-2-7b-chat-int8': BaseAiTextGeneration; + '@cf/mistral/mistral-7b-instruct-v0.1': BaseAiTextGeneration; + '@cf/meta/llama-2-7b-chat-fp16': BaseAiTextGeneration; + '@hf/thebloke/llama-2-13b-chat-awq': BaseAiTextGeneration; + '@hf/thebloke/mistral-7b-instruct-v0.1-awq': BaseAiTextGeneration; + '@hf/thebloke/zephyr-7b-beta-awq': BaseAiTextGeneration; + '@hf/thebloke/openhermes-2.5-mistral-7b-awq': BaseAiTextGeneration; + '@hf/thebloke/neural-chat-7b-v3-1-awq': BaseAiTextGeneration; + '@hf/thebloke/llamaguard-7b-awq': BaseAiTextGeneration; + '@hf/thebloke/deepseek-coder-6.7b-base-awq': BaseAiTextGeneration; + '@hf/thebloke/deepseek-coder-6.7b-instruct-awq': BaseAiTextGeneration; + '@cf/deepseek-ai/deepseek-math-7b-instruct': BaseAiTextGeneration; + '@cf/defog/sqlcoder-7b-2': BaseAiTextGeneration; + '@cf/openchat/openchat-3.5-0106': BaseAiTextGeneration; + '@cf/tiiuae/falcon-7b-instruct': BaseAiTextGeneration; + '@cf/thebloke/discolm-german-7b-v1-awq': BaseAiTextGeneration; + '@cf/qwen/qwen1.5-0.5b-chat': BaseAiTextGeneration; + '@cf/qwen/qwen1.5-7b-chat-awq': BaseAiTextGeneration; + '@cf/qwen/qwen1.5-14b-chat-awq': BaseAiTextGeneration; + '@cf/tinyllama/tinyllama-1.1b-chat-v1.0': BaseAiTextGeneration; + '@cf/microsoft/phi-2': BaseAiTextGeneration; + '@cf/qwen/qwen1.5-1.8b-chat': BaseAiTextGeneration; + '@cf/mistral/mistral-7b-instruct-v0.2-lora': BaseAiTextGeneration; + '@hf/nousresearch/hermes-2-pro-mistral-7b': BaseAiTextGeneration; + '@hf/nexusflow/starling-lm-7b-beta': BaseAiTextGeneration; + '@hf/google/gemma-7b-it': BaseAiTextGeneration; + '@cf/meta-llama/llama-2-7b-chat-hf-lora': BaseAiTextGeneration; + '@cf/google/gemma-2b-it-lora': BaseAiTextGeneration; + '@cf/google/gemma-7b-it-lora': BaseAiTextGeneration; + '@hf/mistral/mistral-7b-instruct-v0.2': BaseAiTextGeneration; + '@cf/meta/llama-3-8b-instruct': BaseAiTextGeneration; + '@cf/fblgit/una-cybertron-7b-v2-bf16': BaseAiTextGeneration; + '@cf/meta/llama-3-8b-instruct-awq': BaseAiTextGeneration; + '@cf/meta/llama-3.1-8b-instruct-fp8': BaseAiTextGeneration; + '@cf/meta/llama-3.1-8b-instruct-awq': BaseAiTextGeneration; + '@cf/meta/llama-3.2-3b-instruct': BaseAiTextGeneration; + '@cf/meta/llama-3.2-1b-instruct': BaseAiTextGeneration; + '@cf/deepseek-ai/deepseek-r1-distill-qwen-32b': BaseAiTextGeneration; + '@cf/ibm-granite/granite-4.0-h-micro': BaseAiTextGeneration; + '@cf/facebook/bart-large-cnn': BaseAiSummarization; + '@cf/llava-hf/llava-1.5-7b-hf': BaseAiImageToText; + '@cf/baai/bge-base-en-v1.5': Base_Ai_Cf_Baai_Bge_Base_En_V1_5; + '@cf/openai/whisper': Base_Ai_Cf_Openai_Whisper; + '@cf/meta/m2m100-1.2b': Base_Ai_Cf_Meta_M2M100_1_2B; + '@cf/baai/bge-small-en-v1.5': Base_Ai_Cf_Baai_Bge_Small_En_V1_5; + '@cf/baai/bge-large-en-v1.5': Base_Ai_Cf_Baai_Bge_Large_En_V1_5; + '@cf/unum/uform-gen2-qwen-500m': Base_Ai_Cf_Unum_Uform_Gen2_Qwen_500M; + '@cf/openai/whisper-tiny-en': Base_Ai_Cf_Openai_Whisper_Tiny_En; + '@cf/openai/whisper-large-v3-turbo': Base_Ai_Cf_Openai_Whisper_Large_V3_Turbo; + '@cf/baai/bge-m3': Base_Ai_Cf_Baai_Bge_M3; + '@cf/black-forest-labs/flux-1-schnell': Base_Ai_Cf_Black_Forest_Labs_Flux_1_Schnell; + '@cf/meta/llama-3.2-11b-vision-instruct': Base_Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct; + '@cf/meta/llama-3.3-70b-instruct-fp8-fast': Base_Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast; + '@cf/meta/llama-guard-3-8b': Base_Ai_Cf_Meta_Llama_Guard_3_8B; + '@cf/baai/bge-reranker-base': Base_Ai_Cf_Baai_Bge_Reranker_Base; + '@cf/qwen/qwen2.5-coder-32b-instruct': Base_Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct; + '@cf/qwen/qwq-32b': Base_Ai_Cf_Qwen_Qwq_32B; + '@cf/mistralai/mistral-small-3.1-24b-instruct': Base_Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct; + '@cf/google/gemma-3-12b-it': Base_Ai_Cf_Google_Gemma_3_12B_It; + '@cf/meta/llama-4-scout-17b-16e-instruct': Base_Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct; + '@cf/qwen/qwen3-30b-a3b-fp8': Base_Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8; + '@cf/deepgram/nova-3': Base_Ai_Cf_Deepgram_Nova_3; + '@cf/qwen/qwen3-embedding-0.6b': Base_Ai_Cf_Qwen_Qwen3_Embedding_0_6B; + '@cf/pipecat-ai/smart-turn-v2': Base_Ai_Cf_Pipecat_Ai_Smart_Turn_V2; + '@cf/openai/gpt-oss-120b': Base_Ai_Cf_Openai_Gpt_Oss_120B; + '@cf/openai/gpt-oss-20b': Base_Ai_Cf_Openai_Gpt_Oss_20B; + '@cf/leonardo/phoenix-1.0': Base_Ai_Cf_Leonardo_Phoenix_1_0; + '@cf/leonardo/lucid-origin': Base_Ai_Cf_Leonardo_Lucid_Origin; + '@cf/deepgram/aura-1': Base_Ai_Cf_Deepgram_Aura_1; + '@cf/ai4bharat/indictrans2-en-indic-1B': Base_Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B; + '@cf/aisingapore/gemma-sea-lion-v4-27b-it': Base_Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It; + '@cf/pfnet/plamo-embedding-1b': Base_Ai_Cf_Pfnet_Plamo_Embedding_1B; + '@cf/deepgram/flux': Base_Ai_Cf_Deepgram_Flux; + '@cf/deepgram/aura-2-en': Base_Ai_Cf_Deepgram_Aura_2_En; + '@cf/deepgram/aura-2-es': Base_Ai_Cf_Deepgram_Aura_2_Es; +} +type AiOptions = { + /** + * Send requests as an asynchronous batch job, only works for supported models + * https://developers.cloudflare.com/workers-ai/features/batch-api + */ + queueRequest?: boolean; + /** + * Establish websocket connections, only works for supported models + */ + websocket?: boolean; + /** + * Tag your requests to group and view them in Cloudflare dashboard. + * + * Rules: + * Tags must only contain letters, numbers, and the symbols: : - . / @ + * Each tag can have maximum 50 characters. + * Maximum 5 tags are allowed each request. + * Duplicate tags will removed. + */ + tags?: string[]; + gateway?: GatewayOptions; + returnRawResponse?: boolean; + prefix?: string; + extraHeaders?: object; +}; +type AiModelsSearchParams = { + author?: string; + hide_experimental?: boolean; + page?: number; + per_page?: number; + search?: string; + source?: number; + task?: string; +}; +type AiModelsSearchObject = { + id: string; + source: number; + name: string; + description: string; + task: { + id: string; + name: string; + description: string; + }; + tags: string[]; + properties: { + property_id: string; + value: string; + }[]; +}; +interface InferenceUpstreamError extends Error {} +interface AiInternalError extends Error {} +type AiModelListType = Record; +declare abstract class Ai { + aiGatewayLogId: string | null; + gateway(gatewayId: string): AiGateway; + autorag(autoragId: string): AutoRAG; + run< + Name extends keyof AiModelList, + Options extends AiOptions, + InputOptions extends AiModelList[Name]['inputs'], + >( + model: Name, + inputs: InputOptions, + options?: Options + ): Promise< + Options extends + | { + returnRawResponse: true; + } + | { + websocket: true; + } + ? Response + : InputOptions extends { + stream: true; + } + ? ReadableStream + : AiModelList[Name]['postProcessedOutputs'] + >; + models(params?: AiModelsSearchParams): Promise; + toMarkdown(): ToMarkdownService; + toMarkdown( + files: MarkdownDocument[], + options?: ConversionRequestOptions + ): Promise; + toMarkdown( + files: MarkdownDocument, + options?: ConversionRequestOptions + ): Promise; +} +type GatewayRetries = { + maxAttempts?: 1 | 2 | 3 | 4 | 5; + retryDelayMs?: number; + backoff?: 'constant' | 'linear' | 'exponential'; +}; +type GatewayOptions = { + id: string; + cacheKey?: string; + cacheTtl?: number; + skipCache?: boolean; + metadata?: Record; + collectLog?: boolean; + eventId?: string; + requestTimeoutMs?: number; + retries?: GatewayRetries; +}; +type UniversalGatewayOptions = Exclude & { + /** + ** @deprecated + */ + id?: string; +}; +type AiGatewayPatchLog = { + score?: number | null; + feedback?: -1 | 1 | null; + metadata?: Record | null; +}; +type AiGatewayLog = { + id: string; + provider: string; + model: string; + model_type?: string; + path: string; + duration: number; + request_type?: string; + request_content_type?: string; + status_code: number; + response_content_type?: string; + success: boolean; + cached: boolean; + tokens_in?: number; + tokens_out?: number; + metadata?: Record; + step?: number; + cost?: number; + custom_cost?: boolean; + request_size: number; + request_head?: string; + request_head_complete: boolean; + response_size: number; + response_head?: string; + response_head_complete: boolean; + created_at: Date; +}; +type AIGatewayProviders = + | 'workers-ai' + | 'anthropic' + | 'aws-bedrock' + | 'azure-openai' + | 'google-vertex-ai' + | 'huggingface' + | 'openai' + | 'perplexity-ai' + | 'replicate' + | 'groq' + | 'cohere' + | 'google-ai-studio' + | 'mistral' + | 'grok' + | 'openrouter' + | 'deepseek' + | 'cerebras' + | 'cartesia' + | 'elevenlabs' + | 'adobe-firefly'; +type AIGatewayHeaders = { + 'cf-aig-metadata': Record | string; + 'cf-aig-custom-cost': + | { + per_token_in?: number; + per_token_out?: number; + } + | { + total_cost?: number; + } + | string; + 'cf-aig-cache-ttl': number | string; + 'cf-aig-skip-cache': boolean | string; + 'cf-aig-cache-key': string; + 'cf-aig-event-id': string; + 'cf-aig-request-timeout': number | string; + 'cf-aig-max-attempts': number | string; + 'cf-aig-retry-delay': number | string; + 'cf-aig-backoff': string; + 'cf-aig-collect-log': boolean | string; + Authorization: string; + 'Content-Type': string; + [key: string]: string | number | boolean | object; +}; +type AIGatewayUniversalRequest = { + provider: AIGatewayProviders | string; // eslint-disable-line + endpoint: string; + headers: Partial; + query: unknown; +}; +interface AiGatewayInternalError extends Error {} +interface AiGatewayLogNotFound extends Error {} +declare abstract class AiGateway { + patchLog(logId: string, data: AiGatewayPatchLog): Promise; + getLog(logId: string): Promise; + run( + data: AIGatewayUniversalRequest | AIGatewayUniversalRequest[], + options?: { + gateway?: UniversalGatewayOptions; + extraHeaders?: object; + } + ): Promise; + getUrl(provider?: AIGatewayProviders | string): Promise; // eslint-disable-line +} +interface AutoRAGInternalError extends Error {} +interface AutoRAGNotFoundError extends Error {} +interface AutoRAGUnauthorizedError extends Error {} +interface AutoRAGNameNotSetError extends Error {} +type ComparisonFilter = { + key: string; + type: 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte'; + value: string | number | boolean; +}; +type CompoundFilter = { + type: 'and' | 'or'; + filters: ComparisonFilter[]; +}; +type AutoRagSearchRequest = { + query: string; + filters?: CompoundFilter | ComparisonFilter; + max_num_results?: number; + ranking_options?: { + ranker?: string; + score_threshold?: number; + }; + reranking?: { + enabled?: boolean; + model?: string; + }; + rewrite_query?: boolean; +}; +type AutoRagAiSearchRequest = AutoRagSearchRequest & { + stream?: boolean; + system_prompt?: string; +}; +type AutoRagAiSearchRequestStreaming = Omit & { + stream: true; +}; +type AutoRagSearchResponse = { + object: 'vector_store.search_results.page'; + search_query: string; + data: { + file_id: string; + filename: string; + score: number; + attributes: Record; + content: { + type: 'text'; + text: string; + }[]; + }[]; + has_more: boolean; + next_page: string | null; +}; +type AutoRagListResponse = { + id: string; + enable: boolean; + type: string; + source: string; + vectorize_name: string; + paused: boolean; + status: string; +}[]; +type AutoRagAiSearchResponse = AutoRagSearchResponse & { + response: string; +}; +declare abstract class AutoRAG { + list(): Promise; + search(params: AutoRagSearchRequest): Promise; + aiSearch(params: AutoRagAiSearchRequestStreaming): Promise; + aiSearch(params: AutoRagAiSearchRequest): Promise; + aiSearch(params: AutoRagAiSearchRequest): Promise; +} +interface BasicImageTransformations { + /** + * Maximum width in image pixels. The value must be an integer. + */ + width?: number; + /** + * Maximum height in image pixels. The value must be an integer. + */ + height?: number; + /** + * Resizing mode as a string. It affects interpretation of width and height + * options: + * - scale-down: Similar to contain, but the image is never enlarged. If + * the image is larger than given width or height, it will be resized. + * Otherwise its original size will be kept. + * - contain: Resizes to maximum size that fits within the given width and + * height. If only a single dimension is given (e.g. only width), the + * image will be shrunk or enlarged to exactly match that dimension. + * Aspect ratio is always preserved. + * - cover: Resizes (shrinks or enlarges) to fill the entire area of width + * and height. If the image has an aspect ratio different from the ratio + * of width and height, it will be cropped to fit. + * - crop: The image will be shrunk and cropped to fit within the area + * specified by width and height. The image will not be enlarged. For images + * smaller than the given dimensions it's the same as scale-down. For + * images larger than the given dimensions, it's the same as cover. + * See also trim. + * - pad: Resizes to the maximum size that fits within the given width and + * height, and then fills the remaining area with a background color + * (white by default). Use of this mode is not recommended, as the same + * effect can be more efficiently achieved with the contain mode and the + * CSS object-fit: contain property. + * - squeeze: Stretches and deforms to the width and height given, even if it + * breaks aspect ratio + */ + fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad' | 'squeeze'; + /** + * Image segmentation using artificial intelligence models. Sets pixels not + * within selected segment area to transparent e.g "foreground" sets every + * background pixel as transparent. + */ + segment?: 'foreground'; + /** + * When cropping with fit: "cover", this defines the side or point that should + * be left uncropped. The value is either a string + * "left", "right", "top", "bottom", "auto", or "center" (the default), + * or an object {x, y} containing focal point coordinates in the original + * image expressed as fractions ranging from 0.0 (top or left) to 1.0 + * (bottom or right), 0.5 being the center. {fit: "cover", gravity: "top"} will + * crop bottom or left and right sides as necessary, but won’t crop anything + * from the top. {fit: "cover", gravity: {x:0.5, y:0.2}} will crop each side to + * preserve as much as possible around a point at 20% of the height of the + * source image. + */ + gravity?: + | 'face' + | 'left' + | 'right' + | 'top' + | 'bottom' + | 'center' + | 'auto' + | 'entropy' + | BasicImageTransformationsGravityCoordinates; + /** + * Background color to add underneath the image. Applies only to images with + * transparency (such as PNG). Accepts any CSS color (#RRGGBB, rgba(…), + * hsl(…), etc.) + */ + background?: string; + /** + * Number of degrees (90, 180, 270) to rotate the image by. width and height + * options refer to axes after rotation. + */ + rotate?: 0 | 90 | 180 | 270 | 360; +} +interface BasicImageTransformationsGravityCoordinates { + x?: number; + y?: number; + mode?: 'remainder' | 'box-center'; +} +/** + * In addition to the properties you can set in the RequestInit dict + * that you pass as an argument to the Request constructor, you can + * set certain properties of a `cf` object to control how Cloudflare + * features are applied to that new Request. + * + * Note: Currently, these properties cannot be tested in the + * playground. + */ +interface RequestInitCfProperties extends Record { + cacheEverything?: boolean; + /** + * A request's cache key is what determines if two requests are + * "the same" for caching purposes. If a request has the same cache key + * as some previous request, then we can serve the same cached response for + * both. (e.g. 'some-key') + * + * Only available for Enterprise customers. + */ + cacheKey?: string; + /** + * This allows you to append additional Cache-Tag response headers + * to the origin response without modifications to the origin server. + * This will allow for greater control over the Purge by Cache Tag feature + * utilizing changes only in the Workers process. + * + * Only available for Enterprise customers. + */ + cacheTags?: string[]; + /** + * Force response to be cached for a given number of seconds. (e.g. 300) + */ + cacheTtl?: number; + /** + * Force response to be cached for a given number of seconds based on the Origin status code. + * (e.g. { '200-299': 86400, '404': 1, '500-599': 0 }) + */ + cacheTtlByStatus?: Record; + scrapeShield?: boolean; + apps?: boolean; + image?: RequestInitCfPropertiesImage; + minify?: RequestInitCfPropertiesImageMinify; + mirage?: boolean; + polish?: 'lossy' | 'lossless' | 'off'; + r2?: RequestInitCfPropertiesR2; + /** + * Redirects the request to an alternate origin server. You can use this, + * for example, to implement load balancing across several origins. + * (e.g.us-east.example.com) + * + * Note - For security reasons, the hostname set in resolveOverride must + * be proxied on the same Cloudflare zone of the incoming request. + * Otherwise, the setting is ignored. CNAME hosts are allowed, so to + * resolve to a host under a different domain or a DNS only domain first + * declare a CNAME record within your own zone’s DNS mapping to the + * external hostname, set proxy on Cloudflare, then set resolveOverride + * to point to that CNAME record. + */ + resolveOverride?: string; +} +interface RequestInitCfPropertiesImageDraw extends BasicImageTransformations { + /** + * Absolute URL of the image file to use for the drawing. It can be any of + * the supported file formats. For drawing of watermarks or non-rectangular + * overlays we recommend using PNG or WebP images. + */ + url: string; + /** + * Floating-point number between 0 (transparent) and 1 (opaque). + * For example, opacity: 0.5 makes overlay semitransparent. + */ + opacity?: number; + /** + * - If set to true, the overlay image will be tiled to cover the entire + * area. This is useful for stock-photo-like watermarks. + * - If set to "x", the overlay image will be tiled horizontally only + * (form a line). + * - If set to "y", the overlay image will be tiled vertically only + * (form a line). + */ + repeat?: true | 'x' | 'y'; + /** + * Position of the overlay image relative to a given edge. Each property is + * an offset in pixels. 0 aligns exactly to the edge. For example, left: 10 + * positions left side of the overlay 10 pixels from the left edge of the + * image it's drawn over. bottom: 0 aligns bottom of the overlay with bottom + * of the background image. + * + * Setting both left & right, or both top & bottom is an error. + * + * If no position is specified, the image will be centered. + */ + top?: number; + left?: number; + bottom?: number; + right?: number; +} +interface RequestInitCfPropertiesImage extends BasicImageTransformations { + /** + * Device Pixel Ratio. Default 1. Multiplier for width/height that makes it + * easier to specify higher-DPI sizes in . + */ + dpr?: number; + /** + * Allows you to trim your image. Takes dpr into account and is performed before + * resizing or rotation. + * + * It can be used as: + * - left, top, right, bottom - it will specify the number of pixels to cut + * off each side + * - width, height - the width/height you'd like to end up with - can be used + * in combination with the properties above + * - border - this will automatically trim the surroundings of an image based on + * it's color. It consists of three properties: + * - color: rgb or hex representation of the color you wish to trim (todo: verify the rgba bit) + * - tolerance: difference from color to treat as color + * - keep: the number of pixels of border to keep + */ + trim?: + | 'border' + | { + top?: number; + bottom?: number; + left?: number; + right?: number; + width?: number; + height?: number; + border?: + | boolean + | { + color?: string; + tolerance?: number; + keep?: number; + }; + }; + /** + * Quality setting from 1-100 (useful values are in 60-90 range). Lower values + * make images look worse, but load faster. The default is 85. It applies only + * to JPEG and WebP images. It doesn’t have any effect on PNG. + */ + quality?: number | 'low' | 'medium-low' | 'medium-high' | 'high'; + /** + * Output format to generate. It can be: + * - avif: generate images in AVIF format. + * - webp: generate images in Google WebP format. Set quality to 100 to get + * the WebP-lossless format. + * - json: instead of generating an image, outputs information about the + * image, in JSON format. The JSON object will contain image size + * (before and after resizing), source image’s MIME type, file size, etc. + * - jpeg: generate images in JPEG format. + * - png: generate images in PNG format. + */ + format?: 'avif' | 'webp' | 'json' | 'jpeg' | 'png' | 'baseline-jpeg' | 'png-force' | 'svg'; + /** + * Whether to preserve animation frames from input files. Default is true. + * Setting it to false reduces animations to still images. This setting is + * recommended when enlarging images or processing arbitrary user content, + * because large GIF animations can weigh tens or even hundreds of megabytes. + * It is also useful to set anim:false when using format:"json" to get the + * response quicker without the number of frames. + */ + anim?: boolean; + /** + * What EXIF data should be preserved in the output image. Note that EXIF + * rotation and embedded color profiles are always applied ("baked in" into + * the image), and aren't affected by this option. Note that if the Polish + * feature is enabled, all metadata may have been removed already and this + * option may have no effect. + * - keep: Preserve most of EXIF metadata, including GPS location if there's + * any. + * - copyright: Only keep the copyright tag, and discard everything else. + * This is the default behavior for JPEG files. + * - none: Discard all invisible EXIF metadata. Currently WebP and PNG + * output formats always discard metadata. + */ + metadata?: 'keep' | 'copyright' | 'none'; + /** + * Strength of sharpening filter to apply to the image. Floating-point + * number between 0 (no sharpening, default) and 10 (maximum). 1.0 is a + * recommended value for downscaled images. + */ + sharpen?: number; + /** + * Radius of a blur filter (approximate gaussian). Maximum supported radius + * is 250. + */ + blur?: number; + /** + * Overlays are drawn in the order they appear in the array (last array + * entry is the topmost layer). + */ + draw?: RequestInitCfPropertiesImageDraw[]; + /** + * Fetching image from authenticated origin. Setting this property will + * pass authentication headers (Authorization, Cookie, etc.) through to + * the origin. + */ + 'origin-auth'?: 'share-publicly'; + /** + * Adds a border around the image. The border is added after resizing. Border + * width takes dpr into account, and can be specified either using a single + * width property, or individually for each side. + */ + border?: + | { + color: string; + width: number; + } + | { + color: string; + top: number; + right: number; + bottom: number; + left: number; + }; + /** + * Increase brightness by a factor. A value of 1.0 equals no change, a value + * of 0.5 equals half brightness, and a value of 2.0 equals twice as bright. + * 0 is ignored. + */ + brightness?: number; + /** + * Increase contrast by a factor. A value of 1.0 equals no change, a value of + * 0.5 equals low contrast, and a value of 2.0 equals high contrast. 0 is + * ignored. + */ + contrast?: number; + /** + * Increase exposure by a factor. A value of 1.0 equals no change, a value of + * 0.5 darkens the image, and a value of 2.0 lightens the image. 0 is ignored. + */ + gamma?: number; + /** + * Increase contrast by a factor. A value of 1.0 equals no change, a value of + * 0.5 equals low contrast, and a value of 2.0 equals high contrast. 0 is + * ignored. + */ + saturation?: number; + /** + * Flips the images horizontally, vertically, or both. Flipping is applied before + * rotation, so if you apply flip=h,rotate=90 then the image will be flipped + * horizontally, then rotated by 90 degrees. + */ + flip?: 'h' | 'v' | 'hv'; + /** + * Slightly reduces latency on a cache miss by selecting a + * quickest-to-compress file format, at a cost of increased file size and + * lower image quality. It will usually override the format option and choose + * JPEG over WebP or AVIF. We do not recommend using this option, except in + * unusual circumstances like resizing uncacheable dynamically-generated + * images. + */ + compression?: 'fast'; +} +interface RequestInitCfPropertiesImageMinify { + javascript?: boolean; + css?: boolean; + html?: boolean; +} +interface RequestInitCfPropertiesR2 { + /** + * Colo id of bucket that an object is stored in + */ + bucketColoId?: number; +} +/** + * Request metadata provided by Cloudflare's edge. + */ +type IncomingRequestCfProperties = IncomingRequestCfPropertiesBase & + IncomingRequestCfPropertiesBotManagementEnterprise & + IncomingRequestCfPropertiesCloudflareForSaaSEnterprise & + IncomingRequestCfPropertiesGeographicInformation & + IncomingRequestCfPropertiesCloudflareAccessOrApiShield; +interface IncomingRequestCfPropertiesBase extends Record { + /** + * [ASN](https://www.iana.org/assignments/as-numbers/as-numbers.xhtml) of the incoming request. + * + * @example 395747 + */ + asn?: number; + /** + * The organization which owns the ASN of the incoming request. + * + * @example "Google Cloud" + */ + asOrganization?: string; + /** + * The original value of the `Accept-Encoding` header if Cloudflare modified it. + * + * @example "gzip, deflate, br" + */ + clientAcceptEncoding?: string; + /** + * The number of milliseconds it took for the request to reach your worker. + * + * @example 22 + */ + clientTcpRtt?: number; + /** + * The three-letter [IATA](https://en.wikipedia.org/wiki/IATA_airport_code) + * airport code of the data center that the request hit. + * + * @example "DFW" + */ + colo: string; + /** + * Represents the upstream's response to a + * [TCP `keepalive` message](https://tldp.org/HOWTO/TCP-Keepalive-HOWTO/overview.html) + * from cloudflare. + * + * For workers with no upstream, this will always be `1`. + * + * @example 3 + */ + edgeRequestKeepAliveStatus: IncomingRequestCfPropertiesEdgeRequestKeepAliveStatus; + /** + * The HTTP Protocol the request used. + * + * @example "HTTP/2" + */ + httpProtocol: string; + /** + * The browser-requested prioritization information in the request object. + * + * If no information was set, defaults to the empty string `""` + * + * @example "weight=192;exclusive=0;group=3;group-weight=127" + * @default "" + */ + requestPriority: string; + /** + * The TLS version of the connection to Cloudflare. + * In requests served over plaintext (without TLS), this property is the empty string `""`. + * + * @example "TLSv1.3" + */ + tlsVersion: string; + /** + * The cipher for the connection to Cloudflare. + * In requests served over plaintext (without TLS), this property is the empty string `""`. + * + * @example "AEAD-AES128-GCM-SHA256" + */ + tlsCipher: string; + /** + * Metadata containing the [`HELLO`](https://www.rfc-editor.org/rfc/rfc5246#section-7.4.1.2) and [`FINISHED`](https://www.rfc-editor.org/rfc/rfc5246#section-7.4.9) messages from this request's TLS handshake. + * + * If the incoming request was served over plaintext (without TLS) this field is undefined. + */ + tlsExportedAuthenticator?: IncomingRequestCfPropertiesExportedAuthenticatorMetadata; +} +interface IncomingRequestCfPropertiesBotManagementBase { + /** + * Cloudflare’s [level of certainty](https://developers.cloudflare.com/bots/concepts/bot-score/) that a request comes from a bot, + * represented as an integer percentage between `1` (almost certainly a bot) and `99` (almost certainly human). + * + * @example 54 + */ + score: number; + /** + * A boolean value that is true if the request comes from a good bot, like Google or Bing. + * Most customers choose to allow this traffic. For more details, see [Traffic from known bots](https://developers.cloudflare.com/firewall/known-issues-and-faq/#how-does-firewall-rules-handle-traffic-from-known-bots). + */ + verifiedBot: boolean; + /** + * A boolean value that is true if the request originates from a + * Cloudflare-verified proxy service. + */ + corporateProxy: boolean; + /** + * A boolean value that's true if the request matches [file extensions](https://developers.cloudflare.com/bots/reference/static-resources/) for many types of static resources. + */ + staticResource: boolean; + /** + * List of IDs that correlate to the Bot Management heuristic detections made on a request (you can have multiple heuristic detections on the same request). + */ + detectionIds: number[]; +} +interface IncomingRequestCfPropertiesBotManagement { + /** + * Results of Cloudflare's Bot Management analysis + */ + botManagement: IncomingRequestCfPropertiesBotManagementBase; + /** + * Duplicate of `botManagement.score`. + * + * @deprecated + */ + clientTrustScore: number; +} +interface IncomingRequestCfPropertiesBotManagementEnterprise + extends IncomingRequestCfPropertiesBotManagement { + /** + * Results of Cloudflare's Bot Management analysis + */ + botManagement: IncomingRequestCfPropertiesBotManagementBase & { + /** + * A [JA3 Fingerprint](https://developers.cloudflare.com/bots/concepts/ja3-fingerprint/) to help profile specific SSL/TLS clients + * across different destination IPs, Ports, and X509 certificates. + */ + ja3Hash: string; + }; +} +interface IncomingRequestCfPropertiesCloudflareForSaaSEnterprise { + /** + * Custom metadata set per-host in [Cloudflare for SaaS](https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/). + * + * This field is only present if you have Cloudflare for SaaS enabled on your account + * and you have followed the [required steps to enable it]((https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/domain-support/custom-metadata/)). + */ + hostMetadata?: HostMetadata; +} +interface IncomingRequestCfPropertiesCloudflareAccessOrApiShield { + /** + * Information about the client certificate presented to Cloudflare. + * + * This is populated when the incoming request is served over TLS using + * either Cloudflare Access or API Shield (mTLS) + * and the presented SSL certificate has a valid + * [Certificate Serial Number](https://ldapwiki.com/wiki/Certificate%20Serial%20Number) + * (i.e., not `null` or `""`). + * + * Otherwise, a set of placeholder values are used. + * + * The property `certPresented` will be set to `"1"` when + * the object is populated (i.e. the above conditions were met). + */ + tlsClientAuth: + | IncomingRequestCfPropertiesTLSClientAuth + | IncomingRequestCfPropertiesTLSClientAuthPlaceholder; +} +/** + * Metadata about the request's TLS handshake + */ +interface IncomingRequestCfPropertiesExportedAuthenticatorMetadata { + /** + * The client's [`HELLO` message](https://www.rfc-editor.org/rfc/rfc5246#section-7.4.1.2), encoded in hexadecimal + * + * @example "44372ba35fa1270921d318f34c12f155dc87b682cf36a790cfaa3ba8737a1b5d" + */ + clientHandshake: string; + /** + * The server's [`HELLO` message](https://www.rfc-editor.org/rfc/rfc5246#section-7.4.1.2), encoded in hexadecimal + * + * @example "44372ba35fa1270921d318f34c12f155dc87b682cf36a790cfaa3ba8737a1b5d" + */ + serverHandshake: string; + /** + * The client's [`FINISHED` message](https://www.rfc-editor.org/rfc/rfc5246#section-7.4.9), encoded in hexadecimal + * + * @example "084ee802fe1348f688220e2a6040a05b2199a761f33cf753abb1b006792d3f8b" + */ + clientFinished: string; + /** + * The server's [`FINISHED` message](https://www.rfc-editor.org/rfc/rfc5246#section-7.4.9), encoded in hexadecimal + * + * @example "084ee802fe1348f688220e2a6040a05b2199a761f33cf753abb1b006792d3f8b" + */ + serverFinished: string; +} +/** + * Geographic data about the request's origin. + */ +interface IncomingRequestCfPropertiesGeographicInformation { + /** + * The [ISO 3166-1 Alpha 2](https://www.iso.org/iso-3166-country-codes.html) country code the request originated from. + * + * If your worker is [configured to accept TOR connections](https://support.cloudflare.com/hc/en-us/articles/203306930-Understanding-Cloudflare-Tor-support-and-Onion-Routing), this may also be `"T1"`, indicating a request that originated over TOR. + * + * If Cloudflare is unable to determine where the request originated this property is omitted. + * + * The country code `"T1"` is used for requests originating on TOR. + * + * @example "GB" + */ + country?: Iso3166Alpha2Code | 'T1'; + /** + * If present, this property indicates that the request originated in the EU + * + * @example "1" + */ + isEUCountry?: '1'; + /** + * A two-letter code indicating the continent the request originated from. + * + * @example "AN" + */ + continent?: ContinentCode; + /** + * The city the request originated from + * + * @example "Austin" + */ + city?: string; + /** + * Postal code of the incoming request + * + * @example "78701" + */ + postalCode?: string; + /** + * Latitude of the incoming request + * + * @example "30.27130" + */ + latitude?: string; + /** + * Longitude of the incoming request + * + * @example "-97.74260" + */ + longitude?: string; + /** + * Timezone of the incoming request + * + * @example "America/Chicago" + */ + timezone?: string; + /** + * If known, the ISO 3166-2 name for the first level region associated with + * the IP address of the incoming request + * + * @example "Texas" + */ + region?: string; + /** + * If known, the ISO 3166-2 code for the first-level region associated with + * the IP address of the incoming request + * + * @example "TX" + */ + regionCode?: string; + /** + * Metro code (DMA) of the incoming request + * + * @example "635" + */ + metroCode?: string; +} +/** Data about the incoming request's TLS certificate */ +interface IncomingRequestCfPropertiesTLSClientAuth { + /** Always `"1"`, indicating that the certificate was presented */ + certPresented: '1'; + /** + * Result of certificate verification. + * + * @example "FAILED:self signed certificate" + */ + certVerified: Exclude; + /** The presented certificate's revokation status. + * + * - A value of `"1"` indicates the certificate has been revoked + * - A value of `"0"` indicates the certificate has not been revoked + */ + certRevoked: '1' | '0'; + /** + * The certificate issuer's [distinguished name](https://knowledge.digicert.com/generalinformation/INFO1745.html) + * + * @example "CN=cloudflareaccess.com, C=US, ST=Texas, L=Austin, O=Cloudflare" + */ + certIssuerDN: string; + /** + * The certificate subject's [distinguished name](https://knowledge.digicert.com/generalinformation/INFO1745.html) + * + * @example "CN=*.cloudflareaccess.com, C=US, ST=Texas, L=Austin, O=Cloudflare" + */ + certSubjectDN: string; + /** + * The certificate issuer's [distinguished name](https://knowledge.digicert.com/generalinformation/INFO1745.html) ([RFC 2253](https://www.rfc-editor.org/rfc/rfc2253.html) formatted) + * + * @example "CN=cloudflareaccess.com, C=US, ST=Texas, L=Austin, O=Cloudflare" + */ + certIssuerDNRFC2253: string; + /** + * The certificate subject's [distinguished name](https://knowledge.digicert.com/generalinformation/INFO1745.html) ([RFC 2253](https://www.rfc-editor.org/rfc/rfc2253.html) formatted) + * + * @example "CN=*.cloudflareaccess.com, C=US, ST=Texas, L=Austin, O=Cloudflare" + */ + certSubjectDNRFC2253: string; + /** The certificate issuer's distinguished name (legacy policies) */ + certIssuerDNLegacy: string; + /** The certificate subject's distinguished name (legacy policies) */ + certSubjectDNLegacy: string; + /** + * The certificate's serial number + * + * @example "00936EACBE07F201DF" + */ + certSerial: string; + /** + * The certificate issuer's serial number + * + * @example "2489002934BDFEA34" + */ + certIssuerSerial: string; + /** + * The certificate's Subject Key Identifier + * + * @example "BB:AF:7E:02:3D:FA:A6:F1:3C:84:8E:AD:EE:38:98:EC:D9:32:32:D4" + */ + certSKI: string; + /** + * The certificate issuer's Subject Key Identifier + * + * @example "BB:AF:7E:02:3D:FA:A6:F1:3C:84:8E:AD:EE:38:98:EC:D9:32:32:D4" + */ + certIssuerSKI: string; + /** + * The certificate's SHA-1 fingerprint + * + * @example "6b9109f323999e52259cda7373ff0b4d26bd232e" + */ + certFingerprintSHA1: string; + /** + * The certificate's SHA-256 fingerprint + * + * @example "acf77cf37b4156a2708e34c4eb755f9b5dbbe5ebb55adfec8f11493438d19e6ad3f157f81fa3b98278453d5652b0c1fd1d71e5695ae4d709803a4d3f39de9dea" + */ + certFingerprintSHA256: string; + /** + * The effective starting date of the certificate + * + * @example "Dec 22 19:39:00 2018 GMT" + */ + certNotBefore: string; + /** + * The effective expiration date of the certificate + * + * @example "Dec 22 19:39:00 2018 GMT" + */ + certNotAfter: string; +} +/** Placeholder values for TLS Client Authorization */ +interface IncomingRequestCfPropertiesTLSClientAuthPlaceholder { + certPresented: '0'; + certVerified: 'NONE'; + certRevoked: '0'; + certIssuerDN: ''; + certSubjectDN: ''; + certIssuerDNRFC2253: ''; + certSubjectDNRFC2253: ''; + certIssuerDNLegacy: ''; + certSubjectDNLegacy: ''; + certSerial: ''; + certIssuerSerial: ''; + certSKI: ''; + certIssuerSKI: ''; + certFingerprintSHA1: ''; + certFingerprintSHA256: ''; + certNotBefore: ''; + certNotAfter: ''; +} +/** Possible outcomes of TLS verification */ +declare type CertVerificationStatus = + /** Authentication succeeded */ + | 'SUCCESS' + /** No certificate was presented */ + | 'NONE' + /** Failed because the certificate was self-signed */ + | 'FAILED:self signed certificate' + /** Failed because the certificate failed a trust chain check */ + | 'FAILED:unable to verify the first certificate' + /** Failed because the certificate not yet valid */ + | 'FAILED:certificate is not yet valid' + /** Failed because the certificate is expired */ + | 'FAILED:certificate has expired' + /** Failed for another unspecified reason */ + | 'FAILED'; +/** + * An upstream endpoint's response to a TCP `keepalive` message from Cloudflare. + */ +declare type IncomingRequestCfPropertiesEdgeRequestKeepAliveStatus = + | 0 /** Unknown */ + | 1 /** no keepalives (not found) */ + | 2 /** no connection re-use, opening keepalive connection failed */ + | 3 /** no connection re-use, keepalive accepted and saved */ + | 4 /** connection re-use, refused by the origin server (`TCP FIN`) */ + | 5; /** connection re-use, accepted by the origin server */ +/** ISO 3166-1 Alpha-2 codes */ +declare type Iso3166Alpha2Code = + | 'AD' + | 'AE' + | 'AF' + | 'AG' + | 'AI' + | 'AL' + | 'AM' + | 'AO' + | 'AQ' + | 'AR' + | 'AS' + | 'AT' + | 'AU' + | 'AW' + | 'AX' + | 'AZ' + | 'BA' + | 'BB' + | 'BD' + | 'BE' + | 'BF' + | 'BG' + | 'BH' + | 'BI' + | 'BJ' + | 'BL' + | 'BM' + | 'BN' + | 'BO' + | 'BQ' + | 'BR' + | 'BS' + | 'BT' + | 'BV' + | 'BW' + | 'BY' + | 'BZ' + | 'CA' + | 'CC' + | 'CD' + | 'CF' + | 'CG' + | 'CH' + | 'CI' + | 'CK' + | 'CL' + | 'CM' + | 'CN' + | 'CO' + | 'CR' + | 'CU' + | 'CV' + | 'CW' + | 'CX' + | 'CY' + | 'CZ' + | 'DE' + | 'DJ' + | 'DK' + | 'DM' + | 'DO' + | 'DZ' + | 'EC' + | 'EE' + | 'EG' + | 'EH' + | 'ER' + | 'ES' + | 'ET' + | 'FI' + | 'FJ' + | 'FK' + | 'FM' + | 'FO' + | 'FR' + | 'GA' + | 'GB' + | 'GD' + | 'GE' + | 'GF' + | 'GG' + | 'GH' + | 'GI' + | 'GL' + | 'GM' + | 'GN' + | 'GP' + | 'GQ' + | 'GR' + | 'GS' + | 'GT' + | 'GU' + | 'GW' + | 'GY' + | 'HK' + | 'HM' + | 'HN' + | 'HR' + | 'HT' + | 'HU' + | 'ID' + | 'IE' + | 'IL' + | 'IM' + | 'IN' + | 'IO' + | 'IQ' + | 'IR' + | 'IS' + | 'IT' + | 'JE' + | 'JM' + | 'JO' + | 'JP' + | 'KE' + | 'KG' + | 'KH' + | 'KI' + | 'KM' + | 'KN' + | 'KP' + | 'KR' + | 'KW' + | 'KY' + | 'KZ' + | 'LA' + | 'LB' + | 'LC' + | 'LI' + | 'LK' + | 'LR' + | 'LS' + | 'LT' + | 'LU' + | 'LV' + | 'LY' + | 'MA' + | 'MC' + | 'MD' + | 'ME' + | 'MF' + | 'MG' + | 'MH' + | 'MK' + | 'ML' + | 'MM' + | 'MN' + | 'MO' + | 'MP' + | 'MQ' + | 'MR' + | 'MS' + | 'MT' + | 'MU' + | 'MV' + | 'MW' + | 'MX' + | 'MY' + | 'MZ' + | 'NA' + | 'NC' + | 'NE' + | 'NF' + | 'NG' + | 'NI' + | 'NL' + | 'NO' + | 'NP' + | 'NR' + | 'NU' + | 'NZ' + | 'OM' + | 'PA' + | 'PE' + | 'PF' + | 'PG' + | 'PH' + | 'PK' + | 'PL' + | 'PM' + | 'PN' + | 'PR' + | 'PS' + | 'PT' + | 'PW' + | 'PY' + | 'QA' + | 'RE' + | 'RO' + | 'RS' + | 'RU' + | 'RW' + | 'SA' + | 'SB' + | 'SC' + | 'SD' + | 'SE' + | 'SG' + | 'SH' + | 'SI' + | 'SJ' + | 'SK' + | 'SL' + | 'SM' + | 'SN' + | 'SO' + | 'SR' + | 'SS' + | 'ST' + | 'SV' + | 'SX' + | 'SY' + | 'SZ' + | 'TC' + | 'TD' + | 'TF' + | 'TG' + | 'TH' + | 'TJ' + | 'TK' + | 'TL' + | 'TM' + | 'TN' + | 'TO' + | 'TR' + | 'TT' + | 'TV' + | 'TW' + | 'TZ' + | 'UA' + | 'UG' + | 'UM' + | 'US' + | 'UY' + | 'UZ' + | 'VA' + | 'VC' + | 'VE' + | 'VG' + | 'VI' + | 'VN' + | 'VU' + | 'WF' + | 'WS' + | 'YE' + | 'YT' + | 'ZA' + | 'ZM' + | 'ZW'; +/** The 2-letter continent codes Cloudflare uses */ +declare type ContinentCode = 'AF' | 'AN' | 'AS' | 'EU' | 'NA' | 'OC' | 'SA'; +type CfProperties = + | IncomingRequestCfProperties + | RequestInitCfProperties; +interface D1Meta { + duration: number; + size_after: number; + rows_read: number; + rows_written: number; + last_row_id: number; + changed_db: boolean; + changes: number; + /** + * The region of the database instance that executed the query. + */ + served_by_region?: string; + /** + * The three letters airport code of the colo that executed the query. + */ + served_by_colo?: string; + /** + * True if-and-only-if the database instance that executed the query was the primary. + */ + served_by_primary?: boolean; + timings?: { + /** + * The duration of the SQL query execution by the database instance. It doesn't include any network time. + */ + sql_duration_ms: number; + }; + /** + * Number of total attempts to execute the query, due to automatic retries. + * Note: All other fields in the response like `timings` only apply to the last attempt. + */ + total_attempts?: number; +} +interface D1Response { + success: true; + meta: D1Meta & Record; + error?: never; +} +type D1Result = D1Response & { + results: T[]; +}; +interface D1ExecResult { + count: number; + duration: number; +} +type D1SessionConstraint = + // Indicates that the first query should go to the primary, and the rest queries + // using the same D1DatabaseSession will go to any replica that is consistent with + // the bookmark maintained by the session (returned by the first query). + | 'first-primary' + // Indicates that the first query can go anywhere (primary or replica), and the rest queries + // using the same D1DatabaseSession will go to any replica that is consistent with + // the bookmark maintained by the session (returned by the first query). + | 'first-unconstrained'; +type D1SessionBookmark = string; +declare abstract class D1Database { + prepare(query: string): D1PreparedStatement; + batch(statements: D1PreparedStatement[]): Promise[]>; + exec(query: string): Promise; + /** + * Creates a new D1 Session anchored at the given constraint or the bookmark. + * All queries executed using the created session will have sequential consistency, + * meaning that all writes done through the session will be visible in subsequent reads. + * + * @param constraintOrBookmark Either the session constraint or the explicit bookmark to anchor the created session. + */ + withSession(constraintOrBookmark?: D1SessionBookmark | D1SessionConstraint): D1DatabaseSession; + /** + * @deprecated dump() will be removed soon, only applies to deprecated alpha v1 databases. + */ + dump(): Promise; +} +declare abstract class D1DatabaseSession { + prepare(query: string): D1PreparedStatement; + batch(statements: D1PreparedStatement[]): Promise[]>; + /** + * @returns The latest session bookmark across all executed queries on the session. + * If no query has been executed yet, `null` is returned. + */ + getBookmark(): D1SessionBookmark | null; +} +declare abstract class D1PreparedStatement { + bind(...values: unknown[]): D1PreparedStatement; + first(colName: string): Promise; + first>(): Promise; + run>(): Promise>; + all>(): Promise>; + raw(options: { columnNames: true }): Promise<[string[], ...T[]]>; + raw(options?: { columnNames?: false }): Promise; +} +// `Disposable` was added to TypeScript's standard lib types in version 5.2. +// To support older TypeScript versions, define an empty `Disposable` interface. +// Users won't be able to use `using`/`Symbol.dispose` without upgrading to 5.2, +// but this will ensure type checking on older versions still passes. +// TypeScript's interface merging will ensure our empty interface is effectively +// ignored when `Disposable` is included in the standard lib. +interface Disposable {} +/** + * The returned data after sending an email + */ +interface EmailSendResult { + /** + * The Email Message ID + */ + messageId: string; +} +/** + * An email message that can be sent from a Worker. + */ +interface EmailMessage { + /** + * Envelope From attribute of the email message. + */ + readonly from: string; + /** + * Envelope To attribute of the email message. + */ + readonly to: string; +} +/** + * An email message that is sent to a consumer Worker and can be rejected/forwarded. + */ +interface ForwardableEmailMessage extends EmailMessage { + /** + * Stream of the email message content. + */ + readonly raw: ReadableStream; + /** + * An [Headers object](https://developer.mozilla.org/en-US/docs/Web/API/Headers). + */ + readonly headers: Headers; + /** + * Size of the email message content. + */ + readonly rawSize: number; + /** + * Reject this email message by returning a permanent SMTP error back to the connecting client including the given reason. + * @param reason The reject reason. + * @returns void + */ + setReject(reason: string): void; + /** + * Forward this email message to a verified destination address of the account. + * @param rcptTo Verified destination address. + * @param headers A [Headers object](https://developer.mozilla.org/en-US/docs/Web/API/Headers). + * @returns A promise that resolves when the email message is forwarded. + */ + forward(rcptTo: string, headers?: Headers): Promise; + /** + * Reply to the sender of this email message with a new EmailMessage object. + * @param message The reply message. + * @returns A promise that resolves when the email message is replied. + */ + reply(message: EmailMessage): Promise; +} +/** A file attachment for an email message */ +type EmailAttachment = + | { + disposition: 'inline'; + contentId: string; + filename: string; + type: string; + content: string | ArrayBuffer | ArrayBufferView; + } + | { + disposition: 'attachment'; + contentId?: undefined; + filename: string; + type: string; + content: string | ArrayBuffer | ArrayBufferView; + }; +/** An Email Address */ +interface EmailAddress { + name: string; + email: string; +} +/** + * A binding that allows a Worker to send email messages. + */ +interface SendEmail { + send(message: EmailMessage): Promise; + send(builder: { + from: string | EmailAddress; + to: string | string[]; + subject: string; + replyTo?: string | EmailAddress; + cc?: string | string[]; + bcc?: string | string[]; + headers?: Record; + text?: string; + html?: string; + attachments?: EmailAttachment[]; + }): Promise; +} +declare abstract class EmailEvent extends ExtendableEvent { + readonly message: ForwardableEmailMessage; +} +declare type EmailExportedHandler = ( + message: ForwardableEmailMessage, + env: Env, + ctx: ExecutionContext +) => void | Promise; +declare module 'cloudflare:email' { + let _EmailMessage: { + prototype: EmailMessage; + new (from: string, to: string, raw: ReadableStream | string): EmailMessage; + }; + export { _EmailMessage as EmailMessage }; +} +/** + * Hello World binding to serve as an explanatory example. DO NOT USE + */ +interface HelloWorldBinding { + /** + * Retrieve the current stored value + */ + get(): Promise<{ + value: string; + ms?: number; + }>; + /** + * Set a new stored value + */ + set(value: string): Promise; +} +interface Hyperdrive { + /** + * Connect directly to Hyperdrive as if it's your database, returning a TCP socket. + * + * Calling this method returns an identical socket to if you call + * `connect("host:port")` using the `host` and `port` fields from this object. + * Pick whichever approach works better with your preferred DB client library. + * + * Note that this socket is not yet authenticated -- it's expected that your + * code (or preferably, the client library of your choice) will authenticate + * using the information in this class's readonly fields. + */ + connect(): Socket; + /** + * A valid DB connection string that can be passed straight into the typical + * client library/driver/ORM. This will typically be the easiest way to use + * Hyperdrive. + */ + readonly connectionString: string; + /* + * A randomly generated hostname that is only valid within the context of the + * currently running Worker which, when passed into `connect()` function from + * the "cloudflare:sockets" module, will connect to the Hyperdrive instance + * for your database. + */ + readonly host: string; + /* + * The port that must be paired the the host field when connecting. + */ + readonly port: number; + /* + * The username to use when authenticating to your database via Hyperdrive. + * Unlike the host and password, this will be the same every time + */ + readonly user: string; + /* + * The randomly generated password to use when authenticating to your + * database via Hyperdrive. Like the host field, this password is only valid + * within the context of the currently running Worker instance from which + * it's read. + */ + readonly password: string; + /* + * The name of the database to connect to. + */ + readonly database: string; +} +// Copyright (c) 2024 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +type ImageInfoResponse = + | { + format: 'image/svg+xml'; + } + | { + format: string; + fileSize: number; + width: number; + height: number; + }; +type ImageTransform = { + width?: number; + height?: number; + background?: string; + blur?: number; + border?: + | { + color?: string; + width?: number; + } + | { + top?: number; + bottom?: number; + left?: number; + right?: number; + }; + brightness?: number; + contrast?: number; + fit?: 'scale-down' | 'contain' | 'pad' | 'squeeze' | 'cover' | 'crop'; + flip?: 'h' | 'v' | 'hv'; + gamma?: number; + segment?: 'foreground'; + gravity?: + | 'face' + | 'left' + | 'right' + | 'top' + | 'bottom' + | 'center' + | 'auto' + | 'entropy' + | { + x?: number; + y?: number; + mode: 'remainder' | 'box-center'; + }; + rotate?: 0 | 90 | 180 | 270; + saturation?: number; + sharpen?: number; + trim?: + | 'border' + | { + top?: number; + bottom?: number; + left?: number; + right?: number; + width?: number; + height?: number; + border?: + | boolean + | { + color?: string; + tolerance?: number; + keep?: number; + }; + }; +}; +type ImageDrawOptions = { + opacity?: number; + repeat?: boolean | string; + top?: number; + left?: number; + bottom?: number; + right?: number; +}; +type ImageInputOptions = { + encoding?: 'base64'; +}; +type ImageOutputOptions = { + format: 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp' | 'image/avif' | 'rgb' | 'rgba'; + quality?: number; + background?: string; + anim?: boolean; +}; +interface ImagesBinding { + /** + * Get image metadata (type, width and height) + * @throws {@link ImagesError} with code 9412 if input is not an image + * @param stream The image bytes + */ + info(stream: ReadableStream, options?: ImageInputOptions): Promise; + /** + * Begin applying a series of transformations to an image + * @param stream The image bytes + * @returns A transform handle + */ + input(stream: ReadableStream, options?: ImageInputOptions): ImageTransformer; +} +interface ImageTransformer { + /** + * Apply transform next, returning a transform handle. + * You can then apply more transformations, draw, or retrieve the output. + * @param transform + */ + transform(transform: ImageTransform): ImageTransformer; + /** + * Draw an image on this transformer, returning a transform handle. + * You can then apply more transformations, draw, or retrieve the output. + * @param image The image (or transformer that will give the image) to draw + * @param options The options configuring how to draw the image + */ + draw( + image: ReadableStream | ImageTransformer, + options?: ImageDrawOptions + ): ImageTransformer; + /** + * Retrieve the image that results from applying the transforms to the + * provided input + * @param options Options that apply to the output e.g. output format + */ + output(options: ImageOutputOptions): Promise; +} +type ImageTransformationOutputOptions = { + encoding?: 'base64'; +}; +interface ImageTransformationResult { + /** + * The image as a response, ready to store in cache or return to users + */ + response(): Response; + /** + * The content type of the returned image + */ + contentType(): string; + /** + * The bytes of the response + */ + image(options?: ImageTransformationOutputOptions): ReadableStream; +} +interface ImagesError extends Error { + readonly code: number; + readonly message: string; + readonly stack?: string; +} +/** + * Media binding for transforming media streams. + * Provides the entry point for media transformation operations. + */ +interface MediaBinding { + /** + * Creates a media transformer from an input stream. + * @param media - The input media bytes + * @returns A MediaTransformer instance for applying transformations + */ + input(media: ReadableStream): MediaTransformer; +} +/** + * Media transformer for applying transformation operations to media content. + * Handles sizing, fitting, and other input transformation parameters. + */ +interface MediaTransformer { + /** + * Applies transformation options to the media content. + * @param transform - Configuration for how the media should be transformed + * @returns A generator for producing the transformed media output + */ + transform(transform: MediaTransformationInputOptions): MediaTransformationGenerator; +} +/** + * Generator for producing media transformation results. + * Configures the output format and parameters for the transformed media. + */ +interface MediaTransformationGenerator { + /** + * Generates the final media output with specified options. + * @param output - Configuration for the output format and parameters + * @returns The final transformation result containing the transformed media + */ + output(output: MediaTransformationOutputOptions): MediaTransformationResult; +} +/** + * Result of a media transformation operation. + * Provides multiple ways to access the transformed media content. + */ +interface MediaTransformationResult { + /** + * Returns the transformed media as a readable stream of bytes. + * @returns A stream containing the transformed media data + */ + media(): ReadableStream; + /** + * Returns the transformed media as an HTTP response object. + * @returns The transformed media as a Response, ready to store in cache or return to users + */ + response(): Response; + /** + * Returns the MIME type of the transformed media. + * @returns The content type string (e.g., 'image/jpeg', 'video/mp4') + */ + contentType(): string; +} +/** + * Configuration options for transforming media input. + * Controls how the media should be resized and fitted. + */ +type MediaTransformationInputOptions = { + /** How the media should be resized to fit the specified dimensions */ + fit?: 'contain' | 'cover' | 'scale-down'; + /** Target width in pixels */ + width?: number; + /** Target height in pixels */ + height?: number; +}; +/** + * Configuration options for Media Transformations output. + * Controls the format, timing, and type of the generated output. + */ +type MediaTransformationOutputOptions = { + /** + * Output mode determining the type of media to generate + */ + mode?: 'video' | 'spritesheet' | 'frame' | 'audio'; + /** Whether to include audio in the output */ + audio?: boolean; + /** + * Starting timestamp for frame extraction or start time for clips. (e.g. '2s'). + */ + time?: string; + /** + * Duration for video clips, audio extraction, and spritesheet generation (e.g. '5s'). + */ + duration?: string; + /** + * Number of frames in the spritesheet. + */ + imageCount?: number; + /** + * Output format for the generated media. + */ + format?: 'jpg' | 'png' | 'm4a'; +}; +/** + * Error object for media transformation operations. + * Extends the standard Error interface with additional media-specific information. + */ +interface MediaError extends Error { + readonly code: number; + readonly message: string; + readonly stack?: string; +} +declare module 'cloudflare:node' { + interface NodeStyleServer { + listen(...args: unknown[]): this; + address(): { + port?: number | null | undefined; + }; + } + export function httpServerHandler(port: number): ExportedHandler; + export function httpServerHandler(options: { port: number }): ExportedHandler; + export function httpServerHandler(server: NodeStyleServer): ExportedHandler; +} +type Params

= Record; +type EventContext = { + request: Request>; + functionPath: string; + waitUntil: (promise: Promise) => void; + passThroughOnException: () => void; + next: (input?: Request | string, init?: RequestInit) => Promise; + env: Env & { + ASSETS: { + fetch: typeof fetch; + }; + }; + params: Params

; + data: Data; +}; +type PagesFunction< + Env = unknown, + Params extends string = any, + Data extends Record = Record, +> = (context: EventContext) => Response | Promise; +type EventPluginContext = { + request: Request>; + functionPath: string; + waitUntil: (promise: Promise) => void; + passThroughOnException: () => void; + next: (input?: Request | string, init?: RequestInit) => Promise; + env: Env & { + ASSETS: { + fetch: typeof fetch; + }; + }; + params: Params

; + data: Data; + pluginArgs: PluginArgs; +}; +type PagesPluginFunction< + Env = unknown, + Params extends string = any, + Data extends Record = Record, + PluginArgs = unknown, +> = (context: EventPluginContext) => Response | Promise; +declare module 'assets:*' { + export const onRequest: PagesFunction; +} +// Copyright (c) 2022-2023 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +declare module 'cloudflare:pipelines' { + export abstract class PipelineTransformationEntrypoint< + Env = unknown, + I extends PipelineRecord = PipelineRecord, + O extends PipelineRecord = PipelineRecord, + > { + protected env: Env; + protected ctx: ExecutionContext; + constructor(ctx: ExecutionContext, env: Env); + /** + * run receives an array of PipelineRecord which can be + * transformed and returned to the pipeline + * @param records Incoming records from the pipeline to be transformed + * @param metadata Information about the specific pipeline calling the transformation entrypoint + * @returns A promise containing the transformed PipelineRecord array + */ + public run(records: I[], metadata: PipelineBatchMetadata): Promise; + } + export type PipelineRecord = Record; + export type PipelineBatchMetadata = { + pipelineId: string; + pipelineName: string; + }; + export interface Pipeline { + /** + * The Pipeline interface represents the type of a binding to a Pipeline + * + * @param records The records to send to the pipeline + */ + send(records: T[]): Promise; + } +} +// PubSubMessage represents an incoming PubSub message. +// The message includes metadata about the broker, the client, and the payload +// itself. +// https://developers.cloudflare.com/pub-sub/ +interface PubSubMessage { + // Message ID + readonly mid: number; + // MQTT broker FQDN in the form mqtts://BROKER.NAMESPACE.cloudflarepubsub.com:PORT + readonly broker: string; + // The MQTT topic the message was sent on. + readonly topic: string; + // The client ID of the client that published this message. + readonly clientId: string; + // The unique identifier (JWT ID) used by the client to authenticate, if token + // auth was used. + readonly jti?: string; + // A Unix timestamp (seconds from Jan 1, 1970), set when the Pub/Sub Broker + // received the message from the client. + readonly receivedAt: number; + // An (optional) string with the MIME type of the payload, if set by the + // client. + readonly contentType: string; + // Set to 1 when the payload is a UTF-8 string + // https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc3901063 + readonly payloadFormatIndicator: number; + // Pub/Sub (MQTT) payloads can be UTF-8 strings, or byte arrays. + // You can use payloadFormatIndicator to inspect this before decoding. + payload: string | Uint8Array; +} +// JsonWebKey extended by kid parameter +interface JsonWebKeyWithKid extends JsonWebKey { + // Key Identifier of the JWK + readonly kid: string; +} +interface RateLimitOptions { + key: string; +} +interface RateLimitOutcome { + success: boolean; +} +interface RateLimit { + /** + * Rate limit a request based on the provided options. + * @see https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/ + * @returns A promise that resolves with the outcome of the rate limit. + */ + limit(options: RateLimitOptions): Promise; +} +// Namespace for RPC utility types. Unfortunately, we can't use a `module` here as these types need +// to referenced by `Fetcher`. This is included in the "importable" version of the types which +// strips all `module` blocks. +declare namespace Rpc { + // Branded types for identifying `WorkerEntrypoint`/`DurableObject`/`Target`s. + // TypeScript uses *structural* typing meaning anything with the same shape as type `T` is a `T`. + // For the classes exported by `cloudflare:workers` we want *nominal* typing (i.e. we only want to + // accept `WorkerEntrypoint` from `cloudflare:workers`, not any other class with the same shape) + export const __RPC_STUB_BRAND: '__RPC_STUB_BRAND'; + export const __RPC_TARGET_BRAND: '__RPC_TARGET_BRAND'; + export const __WORKER_ENTRYPOINT_BRAND: '__WORKER_ENTRYPOINT_BRAND'; + export const __DURABLE_OBJECT_BRAND: '__DURABLE_OBJECT_BRAND'; + export const __WORKFLOW_ENTRYPOINT_BRAND: '__WORKFLOW_ENTRYPOINT_BRAND'; + export interface RpcTargetBranded { + [__RPC_TARGET_BRAND]: never; + } + export interface WorkerEntrypointBranded { + [__WORKER_ENTRYPOINT_BRAND]: never; + } + export interface DurableObjectBranded { + [__DURABLE_OBJECT_BRAND]: never; + } + export interface WorkflowEntrypointBranded { + [__WORKFLOW_ENTRYPOINT_BRAND]: never; + } + export type EntrypointBranded = + | WorkerEntrypointBranded + | DurableObjectBranded + | WorkflowEntrypointBranded; + // Types that can be used through `Stub`s + export type Stubable = RpcTargetBranded | ((...args: any[]) => any); + // Types that can be passed over RPC + // The reason for using a generic type here is to build a serializable subset of structured + // cloneable composite types. This allows types defined with the "interface" keyword to pass the + // serializable check as well. Otherwise, only types defined with the "type" keyword would pass. + type Serializable = + // Structured cloneables + | BaseType + // Structured cloneable composites + | Map< + T extends Map ? Serializable : never, + T extends Map ? Serializable : never + > + | Set ? Serializable : never> + | ReadonlyArray ? Serializable : never> + | { + [K in keyof T]: K extends number | string ? Serializable : never; + } + // Special types + | Stub + // Serialized as stubs, see `Stubify` + | Stubable; + // Base type for all RPC stubs, including common memory management methods. + // `T` is used as a marker type for unwrapping `Stub`s later. + interface StubBase extends Disposable { + [__RPC_STUB_BRAND]: T; + dup(): this; + } + export type Stub = Provider & StubBase; + // This represents all the types that can be sent as-is over an RPC boundary + type BaseType = + | void + | undefined + | null + | boolean + | number + | bigint + | string + | TypedArray + | ArrayBuffer + | DataView + | Date + | Error + | RegExp + | ReadableStream + | WritableStream + | Request + | Response + | Headers; + // Recursively rewrite all `Stubable` types with `Stub`s + // prettier-ignore + type Stubify = T extends Stubable ? Stub : T extends Map ? Map, Stubify> : T extends Set ? Set> : T extends Array ? Array> : T extends ReadonlyArray ? ReadonlyArray> : T extends BaseType ? T : T extends { + [key: string | number]: any; + } ? { + [K in keyof T]: Stubify; + } : T; + // Recursively rewrite all `Stub`s with the corresponding `T`s. + // Note we use `StubBase` instead of `Stub` here to avoid circular dependencies: + // `Stub` depends on `Provider`, which depends on `Unstubify`, which would depend on `Stub`. + // prettier-ignore + type Unstubify = T extends StubBase ? V : T extends Map ? Map, Unstubify> : T extends Set ? Set> : T extends Array ? Array> : T extends ReadonlyArray ? ReadonlyArray> : T extends BaseType ? T : T extends { + [key: string | number]: unknown; + } ? { + [K in keyof T]: Unstubify; + } : T; + type UnstubifyAll = { + [I in keyof A]: Unstubify; + }; + // Utility type for adding `Provider`/`Disposable`s to `object` types only. + // Note `unknown & T` is equivalent to `T`. + type MaybeProvider = T extends object ? Provider : unknown; + type MaybeDisposable = T extends object ? Disposable : unknown; + // Type for method return or property on an RPC interface. + // - Stubable types are replaced by stubs. + // - Serializable types are passed by value, with stubable types replaced by stubs + // and a top-level `Disposer`. + // Everything else can't be passed over PRC. + // Technically, we use custom thenables here, but they quack like `Promise`s. + // Intersecting with `(Maybe)Provider` allows pipelining. + // prettier-ignore + type Result = R extends Stubable ? Promise> & Provider : R extends Serializable ? Promise & MaybeDisposable> & MaybeProvider : never; + // Type for method or property on an RPC interface. + // For methods, unwrap `Stub`s in parameters, and rewrite returns to be `Result`s. + // Unwrapping `Stub`s allows calling with `Stubable` arguments. + // For properties, rewrite types to be `Result`s. + // In each case, unwrap `Promise`s. + type MethodOrProperty = V extends (...args: infer P) => infer R + ? (...args: UnstubifyAll

) => Result> + : Result>; + // Type for the callable part of an `Provider` if `T` is callable. + // This is intersected with methods/properties. + type MaybeCallableProvider = T extends (...args: any[]) => any ? MethodOrProperty : unknown; + // Base type for all other types providing RPC-like interfaces. + // Rewrites all methods/properties to be `MethodOrProperty`s, while preserving callable types. + // `Reserved` names (e.g. stub method names like `dup()`) and symbols can't be accessed over RPC. + export type Provider< + T extends object, + Reserved extends string = never, + > = MaybeCallableProvider & + Pick< + { + [K in keyof T]: MethodOrProperty; + }, + Exclude> + >; +} +declare namespace Cloudflare { + // Type of `env`. + // + // The specific project can extend `Env` by redeclaring it in project-specific files. Typescript + // will merge all declarations. + // + // You can use `wrangler types` to generate the `Env` type automatically. + interface Env {} + // Project-specific parameters used to inform types. + // + // This interface is, again, intended to be declared in project-specific files, and then that + // declaration will be merged with this one. + // + // A project should have a declaration like this: + // + // interface GlobalProps { + // // Declares the main module's exports. Used to populate Cloudflare.Exports aka the type + // // of `ctx.exports`. + // mainModule: typeof import("my-main-module"); + // + // // Declares which of the main module's exports are configured with durable storage, and + // // thus should behave as Durable Object namsepace bindings. + // durableNamespaces: "MyDurableObject" | "AnotherDurableObject"; + // } + // + // You can use `wrangler types` to generate `GlobalProps` automatically. + interface GlobalProps {} + // Evaluates to the type of a property in GlobalProps, defaulting to `Default` if it is not + // present. + type GlobalProp = K extends keyof GlobalProps + ? GlobalProps[K] + : Default; + // The type of the program's main module exports, if known. Requires `GlobalProps` to declare the + // `mainModule` property. + type MainModule = GlobalProp<'mainModule', {}>; + // The type of ctx.exports, which contains loopback bindings for all top-level exports. + type Exports = { + [K in keyof MainModule]: LoopbackForExport & + // If the export is listed in `durableNamespaces`, then it is also a + // DurableObjectNamespace. + (K extends GlobalProp<'durableNamespaces', never> + ? MainModule[K] extends new (...args: any[]) => infer DoInstance + ? DoInstance extends Rpc.DurableObjectBranded + ? DurableObjectNamespace + : DurableObjectNamespace + : DurableObjectNamespace + : {}); + }; +} +declare namespace CloudflareWorkersModule { + export type RpcStub = Rpc.Stub; + export const RpcStub: { + new (value: T): Rpc.Stub; + }; + export abstract class RpcTarget implements Rpc.RpcTargetBranded { + [Rpc.__RPC_TARGET_BRAND]: never; + } + // `protected` fields don't appear in `keyof`s, so can't be accessed over RPC + export abstract class WorkerEntrypoint + implements Rpc.WorkerEntrypointBranded + { + [Rpc.__WORKER_ENTRYPOINT_BRAND]: never; + protected ctx: ExecutionContext; + protected env: Env; + constructor(ctx: ExecutionContext, env: Env); + email?(message: ForwardableEmailMessage): void | Promise; + fetch?(request: Request): Response | Promise; + queue?(batch: MessageBatch): void | Promise; + scheduled?(controller: ScheduledController): void | Promise; + tail?(events: TraceItem[]): void | Promise; + tailStream?( + event: TailStream.TailEvent + ): TailStream.TailEventHandlerType | Promise; + test?(controller: TestController): void | Promise; + trace?(traces: TraceItem[]): void | Promise; + } + export abstract class DurableObject + implements Rpc.DurableObjectBranded + { + [Rpc.__DURABLE_OBJECT_BRAND]: never; + protected ctx: DurableObjectState; + protected env: Env; + constructor(ctx: DurableObjectState, env: Env); + alarm?(alarmInfo?: AlarmInvocationInfo): void | Promise; + fetch?(request: Request): Response | Promise; + webSocketMessage?(ws: WebSocket, message: string | ArrayBuffer): void | Promise; + webSocketClose?( + ws: WebSocket, + code: number, + reason: string, + wasClean: boolean + ): void | Promise; + webSocketError?(ws: WebSocket, error: unknown): void | Promise; + } + export type WorkflowDurationLabel = + | 'second' + | 'minute' + | 'hour' + | 'day' + | 'week' + | 'month' + | 'year'; + export type WorkflowSleepDuration = `${number} ${WorkflowDurationLabel}${'s' | ''}` | number; + export type WorkflowDelayDuration = WorkflowSleepDuration; + export type WorkflowTimeoutDuration = WorkflowSleepDuration; + export type WorkflowRetentionDuration = WorkflowSleepDuration; + export type WorkflowBackoff = 'constant' | 'linear' | 'exponential'; + export type WorkflowStepConfig = { + retries?: { + limit: number; + delay: WorkflowDelayDuration | number; + backoff?: WorkflowBackoff; + }; + timeout?: WorkflowTimeoutDuration | number; + }; + export type WorkflowEvent = { + payload: Readonly; + timestamp: Date; + instanceId: string; + }; + export type WorkflowStepEvent = { + payload: Readonly; + timestamp: Date; + type: string; + }; + export abstract class WorkflowStep { + do>(name: string, callback: () => Promise): Promise; + do>( + name: string, + config: WorkflowStepConfig, + callback: () => Promise + ): Promise; + sleep: (name: string, duration: WorkflowSleepDuration) => Promise; + sleepUntil: (name: string, timestamp: Date | number) => Promise; + waitForEvent>( + name: string, + options: { + type: string; + timeout?: WorkflowTimeoutDuration | number; + } + ): Promise>; + } + export abstract class WorkflowEntrypoint< + Env = unknown, + T extends Rpc.Serializable | unknown = unknown, + > implements Rpc.WorkflowEntrypointBranded + { + [Rpc.__WORKFLOW_ENTRYPOINT_BRAND]: never; + protected ctx: ExecutionContext; + protected env: Env; + constructor(ctx: ExecutionContext, env: Env); + run(event: Readonly>, step: WorkflowStep): Promise; + } + export function waitUntil(promise: Promise): void; + export function withEnv(newEnv: unknown, fn: () => unknown): unknown; + export function withExports(newExports: unknown, fn: () => unknown): unknown; + export function withEnvAndExports( + newEnv: unknown, + newExports: unknown, + fn: () => unknown + ): unknown; + export const env: Cloudflare.Env; + export const exports: Cloudflare.Exports; +} +declare module 'cloudflare:workers' { + export = CloudflareWorkersModule; +} +interface SecretsStoreSecret { + /** + * Get a secret from the Secrets Store, returning a string of the secret value + * if it exists, or throws an error if it does not exist + */ + get(): Promise; +} +declare module 'cloudflare:sockets' { + function _connect(address: string | SocketAddress, options?: SocketOptions): Socket; + export { _connect as connect }; +} +type MarkdownDocument = { + name: string; + blob: Blob; +}; +type ConversionResponse = + | { + name: string; + mimeType: string; + format: 'markdown'; + tokens: number; + data: string; + } + | { + name: string; + mimeType: string; + format: 'error'; + error: string; + }; +type ImageConversionOptions = { + descriptionLanguage?: 'en' | 'es' | 'fr' | 'it' | 'pt' | 'de'; +}; +type EmbeddedImageConversionOptions = ImageConversionOptions & { + convert?: boolean; + maxConvertedImages?: number; +}; +type ConversionOptions = { + html?: { + images?: EmbeddedImageConversionOptions & { + convertOGImage?: boolean; + }; + }; + docx?: { + images?: EmbeddedImageConversionOptions; + }; + image?: ImageConversionOptions; + pdf?: { + images?: EmbeddedImageConversionOptions; + metadata?: boolean; + }; +}; +type ConversionRequestOptions = { + gateway?: GatewayOptions; + extraHeaders?: object; + conversionOptions?: ConversionOptions; +}; +type SupportedFileFormat = { + mimeType: string; + extension: string; +}; +declare abstract class ToMarkdownService { + transform( + files: MarkdownDocument[], + options?: ConversionRequestOptions + ): Promise; + transform( + files: MarkdownDocument, + options?: ConversionRequestOptions + ): Promise; + supported(): Promise; +} +declare namespace TailStream { + interface Header { + readonly name: string; + readonly value: string; + } + interface FetchEventInfo { + readonly type: 'fetch'; + readonly method: string; + readonly url: string; + readonly cfJson?: object; + readonly headers: Header[]; + } + interface JsRpcEventInfo { + readonly type: 'jsrpc'; + } + interface ScheduledEventInfo { + readonly type: 'scheduled'; + readonly scheduledTime: Date; + readonly cron: string; + } + interface AlarmEventInfo { + readonly type: 'alarm'; + readonly scheduledTime: Date; + } + interface QueueEventInfo { + readonly type: 'queue'; + readonly queueName: string; + readonly batchSize: number; + } + interface EmailEventInfo { + readonly type: 'email'; + readonly mailFrom: string; + readonly rcptTo: string; + readonly rawSize: number; + } + interface TraceEventInfo { + readonly type: 'trace'; + readonly traces: (string | null)[]; + } + interface HibernatableWebSocketEventInfoMessage { + readonly type: 'message'; + } + interface HibernatableWebSocketEventInfoError { + readonly type: 'error'; + } + interface HibernatableWebSocketEventInfoClose { + readonly type: 'close'; + readonly code: number; + readonly wasClean: boolean; + } + interface HibernatableWebSocketEventInfo { + readonly type: 'hibernatableWebSocket'; + readonly info: + | HibernatableWebSocketEventInfoClose + | HibernatableWebSocketEventInfoError + | HibernatableWebSocketEventInfoMessage; + } + interface CustomEventInfo { + readonly type: 'custom'; + } + interface FetchResponseInfo { + readonly type: 'fetch'; + readonly statusCode: number; + } + type EventOutcome = + | 'ok' + | 'canceled' + | 'exception' + | 'unknown' + | 'killSwitch' + | 'daemonDown' + | 'exceededCpu' + | 'exceededMemory' + | 'loadShed' + | 'responseStreamDisconnected' + | 'scriptNotFound'; + interface ScriptVersion { + readonly id: string; + readonly tag?: string; + readonly message?: string; + } + interface Onset { + readonly type: 'onset'; + readonly attributes: Attribute[]; + // id for the span being opened by this Onset event. + readonly spanId: string; + readonly dispatchNamespace?: string; + readonly entrypoint?: string; + readonly executionModel: string; + readonly scriptName?: string; + readonly scriptTags?: string[]; + readonly scriptVersion?: ScriptVersion; + readonly info: + | FetchEventInfo + | JsRpcEventInfo + | ScheduledEventInfo + | AlarmEventInfo + | QueueEventInfo + | EmailEventInfo + | TraceEventInfo + | HibernatableWebSocketEventInfo + | CustomEventInfo; + } + interface Outcome { + readonly type: 'outcome'; + readonly outcome: EventOutcome; + readonly cpuTime: number; + readonly wallTime: number; + } + interface SpanOpen { + readonly type: 'spanOpen'; + readonly name: string; + // id for the span being opened by this SpanOpen event. + readonly spanId: string; + readonly info?: FetchEventInfo | JsRpcEventInfo | Attributes; + } + interface SpanClose { + readonly type: 'spanClose'; + readonly outcome: EventOutcome; + } + interface DiagnosticChannelEvent { + readonly type: 'diagnosticChannel'; + readonly channel: string; + readonly message: any; + } + interface Exception { + readonly type: 'exception'; + readonly name: string; + readonly message: string; + readonly stack?: string; + } + interface Log { + readonly type: 'log'; + readonly level: 'debug' | 'error' | 'info' | 'log' | 'warn'; + readonly message: object; + } + // This marks the worker handler return information. + // This is separate from Outcome because the worker invocation can live for a long time after + // returning. For example - Websockets that return an http upgrade response but then continue + // streaming information or SSE http connections. + interface Return { + readonly type: 'return'; + readonly info?: FetchResponseInfo; + } + interface Attribute { + readonly name: string; + readonly value: string | string[] | boolean | boolean[] | number | number[] | bigint | bigint[]; + } + interface Attributes { + readonly type: 'attributes'; + readonly info: Attribute[]; + } + type EventType = + | Onset + | Outcome + | SpanOpen + | SpanClose + | DiagnosticChannelEvent + | Exception + | Log + | Return + | Attributes; + // Context in which this trace event lives. + interface SpanContext { + // Single id for the entire top-level invocation + // This should be a new traceId for the first worker stage invoked in the eyeball request and then + // same-account service-bindings should reuse the same traceId but cross-account service-bindings + // should use a new traceId. + readonly traceId: string; + // spanId in which this event is handled + // for Onset and SpanOpen events this would be the parent span id + // for Outcome and SpanClose these this would be the span id of the opening Onset and SpanOpen events + // For Hibernate and Mark this would be the span under which they were emitted. + // spanId is not set ONLY if: + // 1. This is an Onset event + // 2. We are not inheriting any SpanContext. (e.g. this is a cross-account service binding or a new top-level invocation) + readonly spanId?: string; + } + interface TailEvent { + // invocation id of the currently invoked worker stage. + // invocation id will always be unique to every Onset event and will be the same until the Outcome event. + readonly invocationId: string; + // Inherited spanContext for this event. + readonly spanContext: SpanContext; + readonly timestamp: Date; + readonly sequence: number; + readonly event: Event; + } + type TailEventHandler = ( + event: TailEvent + ) => void | Promise; + type TailEventHandlerObject = { + outcome?: TailEventHandler; + spanOpen?: TailEventHandler; + spanClose?: TailEventHandler; + diagnosticChannel?: TailEventHandler; + exception?: TailEventHandler; + log?: TailEventHandler; + return?: TailEventHandler; + attributes?: TailEventHandler; + }; + type TailEventHandlerType = TailEventHandler | TailEventHandlerObject; +} +// Copyright (c) 2022-2023 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +/** + * Data types supported for holding vector metadata. + */ +type VectorizeVectorMetadataValue = string | number | boolean | string[]; +/** + * Additional information to associate with a vector. + */ +type VectorizeVectorMetadata = + | VectorizeVectorMetadataValue + | Record; +type VectorFloatArray = Float32Array | Float64Array; +interface VectorizeError { + code?: number; + error: string; +} +/** + * Comparison logic/operation to use for metadata filtering. + * + * This list is expected to grow as support for more operations are released. + */ +type VectorizeVectorMetadataFilterOp = '$eq' | '$ne' | '$lt' | '$lte' | '$gt' | '$gte'; +type VectorizeVectorMetadataFilterCollectionOp = '$in' | '$nin'; +/** + * Filter criteria for vector metadata used to limit the retrieved query result set. + */ +type VectorizeVectorMetadataFilter = { + [field: string]: + | Exclude + | null + | { + [Op in VectorizeVectorMetadataFilterOp]?: Exclude< + VectorizeVectorMetadataValue, + string[] + > | null; + } + | { + [Op in VectorizeVectorMetadataFilterCollectionOp]?: Exclude< + VectorizeVectorMetadataValue, + string[] + >[]; + }; +}; +/** + * Supported distance metrics for an index. + * Distance metrics determine how other "similar" vectors are determined. + */ +type VectorizeDistanceMetric = 'euclidean' | 'cosine' | 'dot-product'; +/** + * Metadata return levels for a Vectorize query. + * + * Default to "none". + * + * @property all Full metadata for the vector return set, including all fields (including those un-indexed) without truncation. This is a more expensive retrieval, as it requires additional fetching & reading of un-indexed data. + * @property indexed Return all metadata fields configured for indexing in the vector return set. This level of retrieval is "free" in that no additional overhead is incurred returning this data. However, note that indexed metadata is subject to truncation (especially for larger strings). + * @property none No indexed metadata will be returned. + */ +type VectorizeMetadataRetrievalLevel = 'all' | 'indexed' | 'none'; +interface VectorizeQueryOptions { + topK?: number; + namespace?: string; + returnValues?: boolean; + returnMetadata?: boolean | VectorizeMetadataRetrievalLevel; + filter?: VectorizeVectorMetadataFilter; +} +/** + * Information about the configuration of an index. + */ +type VectorizeIndexConfig = + | { + dimensions: number; + metric: VectorizeDistanceMetric; + } + | { + preset: string; // keep this generic, as we'll be adding more presets in the future and this is only in a read capacity + }; +/** + * Metadata about an existing index. + * + * This type is exclusively for the Vectorize **beta** and will be deprecated once Vectorize RC is released. + * See {@link VectorizeIndexInfo} for its post-beta equivalent. + */ +interface VectorizeIndexDetails { + /** The unique ID of the index */ + readonly id: string; + /** The name of the index. */ + name: string; + /** (optional) A human readable description for the index. */ + description?: string; + /** The index configuration, including the dimension size and distance metric. */ + config: VectorizeIndexConfig; + /** The number of records containing vectors within the index. */ + vectorsCount: number; +} +/** + * Metadata about an existing index. + */ +interface VectorizeIndexInfo { + /** The number of records containing vectors within the index. */ + vectorCount: number; + /** Number of dimensions the index has been configured for. */ + dimensions: number; + /** ISO 8601 datetime of the last processed mutation on in the index. All changes before this mutation will be reflected in the index state. */ + processedUpToDatetime: number; + /** UUIDv4 of the last mutation processed by the index. All changes before this mutation will be reflected in the index state. */ + processedUpToMutation: number; +} +/** + * Represents a single vector value set along with its associated metadata. + */ +interface VectorizeVector { + /** The ID for the vector. This can be user-defined, and must be unique. It should uniquely identify the object, and is best set based on the ID of what the vector represents. */ + id: string; + /** The vector values */ + values: VectorFloatArray | number[]; + /** The namespace this vector belongs to. */ + namespace?: string; + /** Metadata associated with the vector. Includes the values of other fields and potentially additional details. */ + metadata?: Record; +} +/** + * Represents a matched vector for a query along with its score and (if specified) the matching vector information. + */ +type VectorizeMatch = Pick, 'values'> & + Omit & { + /** The score or rank for similarity, when returned as a result */ + score: number; + }; +/** + * A set of matching {@link VectorizeMatch} for a particular query. + */ +interface VectorizeMatches { + matches: VectorizeMatch[]; + count: number; +} +/** + * Results of an operation that performed a mutation on a set of vectors. + * Here, `ids` is a list of vectors that were successfully processed. + * + * This type is exclusively for the Vectorize **beta** and will be deprecated once Vectorize RC is released. + * See {@link VectorizeAsyncMutation} for its post-beta equivalent. + */ +interface VectorizeVectorMutation { + /* List of ids of vectors that were successfully processed. */ + ids: string[]; + /* Total count of the number of processed vectors. */ + count: number; +} +/** + * Result type indicating a mutation on the Vectorize Index. + * Actual mutations are processed async where the `mutationId` is the unique identifier for the operation. + */ +interface VectorizeAsyncMutation { + /** The unique identifier for the async mutation operation containing the changeset. */ + mutationId: string; +} +/** + * A Vectorize Vector Search Index for querying vectors/embeddings. + * + * This type is exclusively for the Vectorize **beta** and will be deprecated once Vectorize RC is released. + * See {@link Vectorize} for its new implementation. + */ +declare abstract class VectorizeIndex { + /** + * Get information about the currently bound index. + * @returns A promise that resolves with information about the current index. + */ + public describe(): Promise; + /** + * Use the provided vector to perform a similarity search across the index. + * @param vector Input vector that will be used to drive the similarity search. + * @param options Configuration options to massage the returned data. + * @returns A promise that resolves with matched and scored vectors. + */ + public query( + vector: VectorFloatArray | number[], + options?: VectorizeQueryOptions + ): Promise; + /** + * Insert a list of vectors into the index dataset. If a provided id exists, an error will be thrown. + * @param vectors List of vectors that will be inserted. + * @returns A promise that resolves with the ids & count of records that were successfully processed. + */ + public insert(vectors: VectorizeVector[]): Promise; + /** + * Upsert a list of vectors into the index dataset. If a provided id exists, it will be replaced with the new values. + * @param vectors List of vectors that will be upserted. + * @returns A promise that resolves with the ids & count of records that were successfully processed. + */ + public upsert(vectors: VectorizeVector[]): Promise; + /** + * Delete a list of vectors with a matching id. + * @param ids List of vector ids that should be deleted. + * @returns A promise that resolves with the ids & count of records that were successfully processed (and thus deleted). + */ + public deleteByIds(ids: string[]): Promise; + /** + * Get a list of vectors with a matching id. + * @param ids List of vector ids that should be returned. + * @returns A promise that resolves with the raw unscored vectors matching the id set. + */ + public getByIds(ids: string[]): Promise; +} +/** + * A Vectorize Vector Search Index for querying vectors/embeddings. + * + * Mutations in this version are async, returning a mutation id. + */ +declare abstract class Vectorize { + /** + * Get information about the currently bound index. + * @returns A promise that resolves with information about the current index. + */ + public describe(): Promise; + /** + * Use the provided vector to perform a similarity search across the index. + * @param vector Input vector that will be used to drive the similarity search. + * @param options Configuration options to massage the returned data. + * @returns A promise that resolves with matched and scored vectors. + */ + public query( + vector: VectorFloatArray | number[], + options?: VectorizeQueryOptions + ): Promise; + /** + * Use the provided vector-id to perform a similarity search across the index. + * @param vectorId Id for a vector in the index against which the index should be queried. + * @param options Configuration options to massage the returned data. + * @returns A promise that resolves with matched and scored vectors. + */ + public queryById(vectorId: string, options?: VectorizeQueryOptions): Promise; + /** + * Insert a list of vectors into the index dataset. If a provided id exists, an error will be thrown. + * @param vectors List of vectors that will be inserted. + * @returns A promise that resolves with a unique identifier of a mutation containing the insert changeset. + */ + public insert(vectors: VectorizeVector[]): Promise; + /** + * Upsert a list of vectors into the index dataset. If a provided id exists, it will be replaced with the new values. + * @param vectors List of vectors that will be upserted. + * @returns A promise that resolves with a unique identifier of a mutation containing the upsert changeset. + */ + public upsert(vectors: VectorizeVector[]): Promise; + /** + * Delete a list of vectors with a matching id. + * @param ids List of vector ids that should be deleted. + * @returns A promise that resolves with a unique identifier of a mutation containing the delete changeset. + */ + public deleteByIds(ids: string[]): Promise; + /** + * Get a list of vectors with a matching id. + * @param ids List of vector ids that should be returned. + * @returns A promise that resolves with the raw unscored vectors matching the id set. + */ + public getByIds(ids: string[]): Promise; +} +/** + * The interface for "version_metadata" binding + * providing metadata about the Worker Version using this binding. + */ +type WorkerVersionMetadata = { + /** The ID of the Worker Version using this binding */ + id: string; + /** The tag of the Worker Version using this binding */ + tag: string; + /** The timestamp of when the Worker Version was uploaded */ + timestamp: string; +}; +interface DynamicDispatchLimits { + /** + * Limit CPU time in milliseconds. + */ + cpuMs?: number; + /** + * Limit number of subrequests. + */ + subRequests?: number; +} +interface DynamicDispatchOptions { + /** + * Limit resources of invoked Worker script. + */ + limits?: DynamicDispatchLimits; + /** + * Arguments for outbound Worker script, if configured. + */ + outbound?: { + [key: string]: any; + }; +} +interface DispatchNamespace { + /** + * @param name Name of the Worker script. + * @param args Arguments to Worker script. + * @param options Options for Dynamic Dispatch invocation. + * @returns A Fetcher object that allows you to send requests to the Worker script. + * @throws If the Worker script does not exist in this dispatch namespace, an error will be thrown. + */ + get( + name: string, + args?: { + [key: string]: any; + }, + options?: DynamicDispatchOptions + ): Fetcher; +} +declare module 'cloudflare:workflows' { + /** + * NonRetryableError allows for a user to throw a fatal error + * that makes a Workflow instance fail immediately without triggering a retry + */ + export class NonRetryableError extends Error { + public constructor(message: string, name?: string); + } +} +declare abstract class Workflow { + /** + * Get a handle to an existing instance of the Workflow. + * @param id Id for the instance of this Workflow + * @returns A promise that resolves with a handle for the Instance + */ + public get(id: string): Promise; + /** + * Create a new instance and return a handle to it. If a provided id exists, an error will be thrown. + * @param options Options when creating an instance including id and params + * @returns A promise that resolves with a handle for the Instance + */ + public create(options?: WorkflowInstanceCreateOptions): Promise; + /** + * Create a batch of instances and return handle for all of them. If a provided id exists, an error will be thrown. + * `createBatch` is limited at 100 instances at a time or when the RPC limit for the batch (1MiB) is reached. + * @param batch List of Options when creating an instance including name and params + * @returns A promise that resolves with a list of handles for the created instances. + */ + public createBatch(batch: WorkflowInstanceCreateOptions[]): Promise; +} +type WorkflowDurationLabel = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year'; +type WorkflowSleepDuration = `${number} ${WorkflowDurationLabel}${'s' | ''}` | number; +type WorkflowRetentionDuration = WorkflowSleepDuration; +interface WorkflowInstanceCreateOptions { + /** + * An id for your Workflow instance. Must be unique within the Workflow. + */ + id?: string; + /** + * The event payload the Workflow instance is triggered with + */ + params?: PARAMS; + /** + * The retention policy for Workflow instance. + * Defaults to the maximum retention period available for the owner's account. + */ + retention?: { + successRetention?: WorkflowRetentionDuration; + errorRetention?: WorkflowRetentionDuration; + }; +} +type InstanceStatus = { + status: + | 'queued' // means that instance is waiting to be started (see concurrency limits) + | 'running' + | 'paused' + | 'errored' + | 'terminated' // user terminated the instance while it was running + | 'complete' + | 'waiting' // instance is hibernating and waiting for sleep or event to finish + | 'waitingForPause' // instance is finishing the current work to pause + | 'unknown'; + error?: { + name: string; + message: string; + }; + output?: unknown; +}; +interface WorkflowError { + code?: number; + message: string; +} +declare abstract class WorkflowInstance { + public id: string; + /** + * Pause the instance. + */ + public pause(): Promise; + /** + * Resume the instance. If it is already running, an error will be thrown. + */ + public resume(): Promise; + /** + * Terminate the instance. If it is errored, terminated or complete, an error will be thrown. + */ + public terminate(): Promise; + /** + * Restart the instance. + */ + public restart(): Promise; + /** + * Returns the current status of the instance. + */ + public status(): Promise; + /** + * Send an event to this instance. + */ + public sendEvent({ type, payload }: { type: string; payload: unknown }): Promise; +} diff --git a/cloud-agent-next/wrangler.jsonc b/cloud-agent-next/wrangler.jsonc new file mode 100644 index 0000000000..f660d8536a --- /dev/null +++ b/cloud-agent-next/wrangler.jsonc @@ -0,0 +1,268 @@ +/** + * For more details on how to configure Wrangler, refer to: + * https://developers.cloudflare.com/workers/wrangler/configuration/ + */ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "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, + "dev": { + "port": 8794, + "local_protocol": "http", + "ip": "0.0.0.0", + }, + "observability": { + "enabled": true, + }, + "logpush": true, + "routes": [ + { + "pattern": "cloud-agent-next.kilosessions.ai", + "custom_domain": true, + }, + ], + "vars": { + "KILOCODE_BACKEND_BASE_URL": "https://api.kilo.ai", + "GITHUB_APP_SLUG": "kiloconnect", + "GITHUB_APP_BOT_USER_ID": "240665456", + "GITHUB_APP_ID": "2193792", + "GITHUB_LITE_APP_ID": "2745442", + "GITHUB_LITE_APP_SLUG": "kiloconnect-lite", + "GITHUB_LITE_APP_BOT_USER_ID": "257753004", + "WORKER_URL": "https://cloud-agent-next.kilosessions.ai", + "WRAPPER_IDLE_TIMEOUT_MS": "120000", + "CLI_TIMEOUT_SECONDS": "900", + "REAPER_INTERVAL_MS": "300000", + "STALE_THRESHOLD_MS": "600000", + "PENDING_START_TIMEOUT_MS": "300000", + "R2_ATTACHMENTS_BUCKET": "cloud-agent-attachments", + "WS_ALLOWED_ORIGINS": "https://app.kilo.ai", + }, + "placement": { "mode": "smart" }, + /** + * Bindings + * Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including + * databases, object storage, AI inference, real-time communication and more. + * https://developers.cloudflare.com/workers/runtime-apis/bindings/ + */ + /** + * Environment Variables + * https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables + */ + // "vars": { "MY_VARIABLE": "production_value" } + /** + * Note: Use secrets to store sensitive data. + * https://developers.cloudflare.com/workers/configuration/secrets/ + */ + /** + * Static Assets + * https://developers.cloudflare.com/workers/static-assets/binding/ + */ + // "assets": { "directory": "./public/", "binding": "ASSETS" } + /** + * Service Bindings (communicate between multiple Workers) + * https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings + */ + // "services": [{ "binding": "MY_SERVICE", "service": "my-service" }] + /** + * Smart Placement + * Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement + */ + // "placement": { "mode": "smart" } + /** + * Bindings + * Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including + * databases, object storage, AI inference, real-time communication and more. + * https://developers.cloudflare.com/workers/runtime-apis/bindings/ + */ + /** + * Environment Variables + * https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables + */ + // "vars": { "MY_VARIABLE": "production_value" } + /** + * Note: Use secrets to store sensitive data. + * https://developers.cloudflare.com/workers/configuration/secrets/ + */ + /** + * Static Assets + * https://developers.cloudflare.com/workers/static-assets/binding/ + */ + // "assets": { "directory": "./public/", "binding": "ASSETS" } + /** + * Service Bindings (communicate between multiple Workers) + * https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings + */ + // "services": [{ "binding": "MY_SERVICE", "service": "my-service" }] + "r2_buckets": [ + { + "binding": "R2_BUCKET", + "bucket_name": "kilocode-sessions", + }, + ], + "containers": [ + { + "class_name": "Sandbox", + "image": "./Dockerfile", + "instance_type": "standard-4", + "image_vars": { + "KILOCODE_CLI_VERSION": "v0.26.0", + }, + "max_instances": 200, + "rollout_active_grace_period": 1800, + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "Sandbox", + "name": "Sandbox", + }, + { + "class_name": "CloudAgentSession", + "name": "CLOUD_AGENT_SESSION", + }, + ], + }, + "migrations": [ + { + "new_sqlite_classes": ["Sandbox"], + "tag": "v1", + }, + { + "new_sqlite_classes": ["CloudAgentSession"], + "tag": "v2", + }, + ], + /** + * Hyperdrive Bindings + * https://developers.cloudflare.com/hyperdrive/ + */ + "hyperdrive": [ + { + "binding": "HYPERDRIVE", + "id": "624ec80650dd414199349f4e217ddb10", + "localConnectionString": "postgres://postgres:postgres@localhost:5432/postgres", + }, + ], + /** + * KV Namespace Bindings + */ + "kv_namespaces": [ + { + "binding": "GITHUB_TOKEN_CACHE", + "id": "ab4d777d134a43248639044613ea29ef", + }, + ], + /** + * Queue Bindings + * https://developers.cloudflare.com/queues/configuration/configure-queues/ + */ + "queues": { + "producers": [ + { + "binding": "CALLBACK_QUEUE", + "queue": "cloud-agent-next-callback-queue", + }, + ], + "consumers": [ + { + "queue": "cloud-agent-next-callback-queue", + "max_batch_size": 5, + "max_retries": 0, + "type": "unbound", + }, + ], + }, + /** + * Named Environments + * https://developers.cloudflare.com/workers/wrangler/configuration/#named-environments + */ + "env": { + "dev": { + "name": "cloud-agent-next-dev", + "hyperdrive": [ + { + "binding": "HYPERDRIVE", + "id": "624ec80650dd414199349f4e217ddb10", + "localConnectionString": "postgres://postgres:postgres@localhost:5432/postgres", + }, + ], + "kv_namespaces": [ + { + "binding": "GITHUB_TOKEN_CACHE", + "id": "33b5f1f1be064e919934bee83df4067c", + }, + ], + "vars": { + "KILOCODE_BACKEND_BASE_URL": "http://localhost:3000", + "KILO_OPENROUTER_BASE": "http://localhost:3000/api", + "GITHUB_APP_SLUG": "kiloconnect-development", + "GITHUB_APP_BOT_USER_ID": "242397087", + "GITHUB_APP_ID": "2245043", + "GITHUB_LITE_APP_ID": "", + "GITHUB_LITE_APP_SLUG": "", + "GITHUB_LITE_APP_BOT_USER_ID": "", + "WORKER_URL": "http://localhost:8794", + "WRAPPER_IDLE_TIMEOUT_MS": "120000", + "CLI_TIMEOUT_SECONDS": "900", + "REAPER_INTERVAL_MS": "300000", + "STALE_THRESHOLD_MS": "600000", + "PENDING_START_TIMEOUT_MS": "300000", + "R2_ATTACHMENTS_BUCKET": "cloud-agent-attachments-dev", + "WS_ALLOWED_ORIGINS": "http://localhost:3000,http://192.168.200.174:3000", + }, + "r2_buckets": [ + { + "binding": "R2_BUCKET", + "bucket_name": "kilocode-sessions-dev", + }, + ], + "containers": [ + { + "class_name": "Sandbox", + "image": "./Dockerfile.dev", + "instance_type": "standard-4", + "image_vars": { + "KILOCODE_CLI_VERSION": "next", + }, + "max_instances": 10, + "rollout_active_grace_period": 60, + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "Sandbox", + "name": "Sandbox", + }, + { + "class_name": "CloudAgentSession", + "name": "CLOUD_AGENT_SESSION", + }, + ], + }, + "queues": { + "producers": [ + { + "binding": "CALLBACK_QUEUE", + "queue": "cloud-agent-next-callback-queue-dev", + }, + ], + "consumers": [ + { + "queue": "cloud-agent-next-callback-queue-dev", + "max_batch_size": 5, + "max_retries": 0, + "type": "unbound", + }, + ], + }, + }, + }, +} diff --git a/cloud-agent-next/wrangler.test.jsonc b/cloud-agent-next/wrangler.test.jsonc new file mode 100644 index 0000000000..0d2c79726a --- /dev/null +++ b/cloud-agent-next/wrangler.test.jsonc @@ -0,0 +1,33 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "cloud-agent-test", + "main": "test/test-worker.ts", + "compatibility_date": "2025-09-15", + "compatibility_flags": ["nodejs_compat"], + "durable_objects": { + "bindings": [ + { + "class_name": "CloudAgentSession", + "name": "CLOUD_AGENT_SESSION", + }, + ], + }, + "migrations": [ + { + "new_sqlite_classes": ["CloudAgentSession"], + "tag": "v2", + }, + ], + "queues": { + "producers": [{ "binding": "EXECUTION_QUEUE", "queue": "cloud-agent-executions" }], + "consumers": [ + { + "queue": "cloud-agent-executions", + "max_batch_size": 1, + "max_retries": 3, + "dead_letter_queue": "cloud-agent-executions-dlq", + "max_concurrency": 10, + }, + ], + }, +} diff --git a/cloud-agent-next/wrapper/build.ts b/cloud-agent-next/wrapper/build.ts new file mode 100644 index 0000000000..bcab3b77ff --- /dev/null +++ b/cloud-agent-next/wrapper/build.ts @@ -0,0 +1,10 @@ +await Bun.build({ + entrypoints: ['./src/main.ts'], + outdir: './dist', + naming: 'wrapper.js', + target: 'bun', + minify: true, + sourcemap: 'external', +}); + +console.log('Build complete: dist/wrapper.js'); diff --git a/cloud-agent-next/wrapper/package.json b/cloud-agent-next/wrapper/package.json new file mode 100644 index 0000000000..7fc8294566 --- /dev/null +++ b/cloud-agent-next/wrapper/package.json @@ -0,0 +1,14 @@ +{ + "name": "@kilocode/cloud-agent-wrapper", + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "bun run build.ts", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + } +} diff --git a/cloud-agent-next/wrapper/src/auto-commit.ts b/cloud-agent-next/wrapper/src/auto-commit.ts new file mode 100644 index 0000000000..be2983d0f8 --- /dev/null +++ b/cloud-agent-next/wrapper/src/auto-commit.ts @@ -0,0 +1,164 @@ +import type { IngestEvent } from '../../src/shared/protocol.js'; +import type { KiloClient } from './kilo-client.js'; +import { exec, getCurrentBranch, logToFile } from './utils.js'; + +/** Default timeout for auto-commit operation (5 minutes) */ +const DEFAULT_AUTO_COMMIT_TIMEOUT_MS = 5 * 60 * 1000; + +export type AutoCommitResult = { + /** Whether the operation was aborted (kill signal or fatal error during execution) */ + wasAborted: boolean; + /** Whether the operation completed successfully */ + success: boolean; + /** Error message if failed */ + error?: string; +}; + +export type AutoCommitOptions = { + workspacePath: string; + upstreamBranch?: string; + model?: string; + onEvent: (event: IngestEvent) => void; + kiloClient: KiloClient; + kiloSessionId: string; + /** Arm the completion waiter before sending a prompt */ + expectCompletion: () => void; + /** Wait for the completion event (call after sending prompt) */ + waitForCompletion: () => Promise; + /** Check if the execution was aborted (kill signal or fatal error) */ + wasAborted: () => boolean; + /** Timeout for the entire operation in ms (default: 5 minutes) */ + timeoutMs?: number; +}; + +function buildAutoCommitPrompt(hasUpstream: boolean): string { + const lines = [ + 'Commit and push all uncommitted changes. Follow these guidelines:', + '1. Create a clear, concise commit message summarizing the changes', + '2. Stage all modified and new files (git add -A)', + '3. If pre-commit hooks fail, retry with --no-verify', + '4. Push to the current branch', + '5. Do NOT force push', + '6. If you detect secrets or credentials, decline to commit and explain why', + ]; + if (!hasUpstream) { + lines.push('7. Do NOT push to main or master branches - if on these branches, skip the push'); + } + return lines.join('\n'); +} + +export async function runAutoCommit(opts: AutoCommitOptions): Promise { + const timeoutMs = opts.timeoutMs ?? DEFAULT_AUTO_COMMIT_TIMEOUT_MS; + const sendStatus = (msg: string) => + opts.onEvent({ + streamEventType: 'status', + data: { message: msg }, + timestamp: new Date().toISOString(), + }); + + // Check if already aborted before starting + if (opts.wasAborted()) { + logToFile('auto-commit: skipped - execution was aborted'); + return { wasAborted: true, success: false }; + } + + try { + // Check current branch + const branch = await getCurrentBranch(opts.workspacePath); + if (!branch) { + sendStatus('Auto-commit skipped: detached HEAD state'); + return { wasAborted: false, success: true }; + } + + // Branch protection + const hasUpstream = opts.upstreamBranch !== undefined && opts.upstreamBranch !== ''; + if (!hasUpstream && (branch === 'main' || branch === 'master')) { + sendStatus(`Auto-commit skipped: cannot commit to ${branch}`); + return { wasAborted: false, success: true }; + } + + // Check for changes + const status = await exec(`cd "${opts.workspacePath}" && git status --porcelain`); + if (!status.stdout.trim()) { + sendStatus('No uncommitted changes'); + return { wasAborted: false, success: true }; + } + + // Check again before sending prompt + if (opts.wasAborted()) { + logToFile('auto-commit: aborted before sending prompt'); + return { wasAborted: true, success: false }; + } + + sendStatus('Auto-committing changes...'); + + // Select prompt based on explicit upstream branch + const prompt = buildAutoCommitPrompt(hasUpstream); + + // Arm the completion waiter BEFORE sending the prompt + opts.expectCompletion(); + + // Send prompt via server API + logToFile(`auto-commit: sending prompt to session ${opts.kiloSessionId}`); + await opts.kiloClient.sendPromptAsync({ + sessionId: opts.kiloSessionId, + prompt, + agent: 'build', + model: opts.model ? { modelID: opts.model } : undefined, + }); + + // Wait for completion with timeout + logToFile('auto-commit: waiting for completion'); + const completionPromise = opts.waitForCompletion(); + const timeoutPromise = new Promise<'timeout'>(resolve => + setTimeout(() => resolve('timeout'), timeoutMs) + ); + + const result = await Promise.race([ + completionPromise.then(() => 'done' as const), + timeoutPromise, + ]); + + if (result === 'timeout') { + logToFile('auto-commit: timed out, aborting session'); + // Abort the session to stop the running prompt + try { + await opts.kiloClient.abortSession({ sessionId: opts.kiloSessionId }); + logToFile('auto-commit: session aborted after timeout'); + } catch (abortError) { + logToFile( + `auto-commit: failed to abort session: ${abortError instanceof Error ? abortError.message : String(abortError)}` + ); + } + opts.onEvent({ + streamEventType: 'error', + data: { error: 'Auto-commit timed out', fatal: false }, + timestamp: new Date().toISOString(), + }); + // Treat timeout as abort to prevent further operations on potentially inconsistent state + return { wasAborted: true, success: false, error: 'Timed out' }; + } + + // Check if aborted during execution + if (opts.wasAborted()) { + logToFile('auto-commit: aborted during execution'); + return { wasAborted: true, success: false }; + } + + logToFile('auto-commit: completed'); + sendStatus('Auto-commit completed'); + return { wasAborted: false, success: true }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + logToFile(`auto-commit: error - ${errorMsg}`); + opts.onEvent({ + streamEventType: 'error', + data: { + error: `Auto-commit failed: ${errorMsg}`, + fatal: false, + }, + timestamp: new Date().toISOString(), + }); + return { wasAborted: false, success: false, error: errorMsg }; + } +} diff --git a/cloud-agent-next/wrapper/src/condense-on-complete.ts b/cloud-agent-next/wrapper/src/condense-on-complete.ts new file mode 100644 index 0000000000..63d6948162 --- /dev/null +++ b/cloud-agent-next/wrapper/src/condense-on-complete.ts @@ -0,0 +1,117 @@ +import type { IngestEvent } from '../../src/shared/protocol.js'; +import type { KiloClient } from './kilo-client.js'; +import { logToFile } from './utils.js'; + +/** Default timeout for condense operation (3 minutes) */ +const DEFAULT_CONDENSE_TIMEOUT_MS = 3 * 60 * 1000; + +export type CondenseResult = { + /** Whether the operation was aborted (kill signal or fatal error during execution) */ + wasAborted: boolean; + /** Whether the operation completed successfully */ + success: boolean; + /** Error message if failed */ + error?: string; +}; + +export type CondenseOnCompleteOptions = { + workspacePath: string; + kiloSessionId: string; + model?: string; + onEvent: (event: IngestEvent) => void; + kiloClient: KiloClient; + /** Arm the completion waiter before sending a prompt */ + expectCompletion: () => void; + /** Wait for the completion event (call after sending prompt) */ + waitForCompletion: () => Promise; + /** Check if the execution was aborted (kill signal or fatal error) */ + wasAborted: () => boolean; + /** Timeout for the entire operation in ms (default: 3 minutes) */ + timeoutMs?: number; +}; + +export async function runCondenseOnComplete( + opts: CondenseOnCompleteOptions +): Promise { + const timeoutMs = opts.timeoutMs ?? DEFAULT_CONDENSE_TIMEOUT_MS; + const sendStatus = (msg: string) => + opts.onEvent({ + streamEventType: 'status', + data: { message: msg }, + timestamp: new Date().toISOString(), + }); + + // Check if already aborted before starting + if (opts.wasAborted()) { + logToFile('condense: skipped - execution was aborted'); + return { wasAborted: true, success: false }; + } + + try { + sendStatus('Condensing context...'); + + // Arm the completion waiter BEFORE sending the prompt + opts.expectCompletion(); + + // Send /compact command via server API + logToFile(`condense: sending /compact command to session ${opts.kiloSessionId}`); + await opts.kiloClient.sendCommand({ + sessionId: opts.kiloSessionId, + command: 'compact', + }); + + // Wait for completion with timeout + logToFile('condense: waiting for completion'); + const completionPromise = opts.waitForCompletion(); + const timeoutPromise = new Promise<'timeout'>(resolve => + setTimeout(() => resolve('timeout'), timeoutMs) + ); + + const result = await Promise.race([ + completionPromise.then(() => 'done' as const), + timeoutPromise, + ]); + + if (result === 'timeout') { + logToFile('condense: timed out, aborting session'); + // Abort the session to stop the running prompt + try { + await opts.kiloClient.abortSession({ sessionId: opts.kiloSessionId }); + logToFile('condense: session aborted after timeout'); + } catch (abortError) { + logToFile( + `condense: failed to abort session: ${abortError instanceof Error ? abortError.message : String(abortError)}` + ); + } + opts.onEvent({ + streamEventType: 'error', + data: { error: 'Condense operation timed out', fatal: false }, + timestamp: new Date().toISOString(), + }); + // Treat timeout as abort to prevent further operations on potentially inconsistent state + return { wasAborted: true, success: false, error: 'Timed out' }; + } + + // Check if aborted during execution + if (opts.wasAborted()) { + logToFile('condense: aborted during execution'); + return { wasAborted: true, success: false }; + } + + logToFile('condense: completed'); + sendStatus('Context condensed successfully'); + return { wasAborted: false, success: true }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + logToFile(`condense: error - ${errorMsg}`); + opts.onEvent({ + streamEventType: 'error', + data: { + error: `Condense context failed: ${errorMsg}`, + fatal: false, + }, + timestamp: new Date().toISOString(), + }); + return { wasAborted: false, success: false, error: errorMsg }; + } +} diff --git a/cloud-agent-next/wrapper/src/connection.ts b/cloud-agent-next/wrapper/src/connection.ts new file mode 100644 index 0000000000..6360775374 --- /dev/null +++ b/cloud-agent-next/wrapper/src/connection.ts @@ -0,0 +1,440 @@ +/** + * Connection management for the long-running wrapper. + * + * Handles: + * - Ingest WebSocket connection (for sending events to DO) + * - SSE consumer (for receiving events from kilo server) + * + * Connections are opened on-demand when the wrapper transitions from IDLE to ACTIVE, + * and closed when transitioning back to IDLE (after drain period). + */ + +import type { WrapperState } from './state.js'; +import type { IngestEvent, WrapperCommand } from '../../src/shared/protocol.js'; +import { createSSEConsumer, isTerminalErrorEvent, type SSEConsumer } from './sse-consumer.js'; +import { logToFile } from './utils.js'; + +// --------------------------------------------------------------------------- +// Kilo Event Types (from kilo-cli SDK) +// --------------------------------------------------------------------------- + +/** + * Time information for messages. + */ +type MessageTime = { + created: number; + completed?: number; +}; + +/** + * Assistant message info from message.updated event. + * Mirrors AssistantMessage from kilo-cli SDK. + */ +type AssistantMessageInfo = { + id: string; + sessionID: string; + role: 'assistant'; + time: MessageTime; + parentID: string; + modelID: string; + providerID: string; + mode: string; + agent: string; + path: { cwd: string; root: string }; + cost: number; + tokens: { + input: number; + output: number; + reasoning: number; + cache: { read: number; write: number }; + }; + error?: unknown; + summary?: boolean; + finish?: string; +}; + +/** + * User message info from message.updated event. + */ +type UserMessageInfo = { + id: string; + sessionID: string; + role: 'user'; + time: MessageTime; + agent: string; + model: { providerID: string; modelID: string }; + summary?: { title?: string; body?: string }; + system?: string; + tools?: Record; + variant?: string; +}; + +/** + * Message info can be either user or assistant message. + */ +type MessageInfo = UserMessageInfo | AssistantMessageInfo; + +/** + * Type guard for message.updated kilocode event data. + * Kilo server sends: {type: "message.updated", properties: {info: {...}}} + * After mapping: {type: "message.updated", properties: {info: {...}}, event: "message.updated"} + */ +function isMessageUpdatedEvent( + data: unknown +): data is { event: 'message.updated'; properties: { info: MessageInfo } } { + if (typeof data !== 'object' || data === null) return false; + const obj = data as Record; + if (obj.event !== 'message.updated') return false; + const props = obj.properties as Record | undefined; + return ( + typeof props === 'object' && + props !== null && + typeof props.info === 'object' && + props.info !== null + ); +} + +/** + * Type guard for completed assistant message. + */ +function isCompletedAssistantMessage( + info: MessageInfo +): info is AssistantMessageInfo & { time: { completed: number } } { + return info.role === 'assistant' && typeof info.time.completed === 'number'; +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type ConnectionConfig = { + kiloServerPort: number; +}; + +export type ConnectionCallbacks = { + /** Called when a completion event is detected for a message */ + onMessageComplete: (messageId: string) => void; + /** Called when a terminal error is detected */ + onTerminalError: (reason: string) => void; + /** Called when a command is received from DO */ + onCommand: (cmd: WrapperCommand) => void; + /** Called when the connection unexpectedly closes */ + onDisconnect: (reason: string) => void; + /** Called on any completion event to signal post-processing waiters */ + onCompletionSignal: () => void; +}; + +type WebSocketCtor = new ( + url: string, + options?: { headers?: Record } | string | string[] +) => WebSocket; + +// --------------------------------------------------------------------------- +// Connection Manager +// --------------------------------------------------------------------------- + +export type ConnectionManager = { + /** Open ingest WS and SSE consumer. Resolves when both are connected. */ + open: () => Promise; + /** Close both connections gracefully. */ + close: () => Promise; + /** Check if currently connected. */ + isConnected: () => boolean; +}; + +/** + * Create a connection manager that handles ingest WS and SSE consumer. + * + * The connections are stored in WrapperState for reference, but actual + * management (open/close) happens here. + */ +export function createConnectionManager( + state: WrapperState, + config: ConnectionConfig, + callbacks: ConnectionCallbacks +): ConnectionManager { + let sseConsumer: SSEConsumer | null = null; + let ingestWs: WebSocket | null = null; + let heartbeatInterval: ReturnType | null = null; + + // Event buffer for disconnection periods + const MAX_BUFFER_SIZE = 1000; + const eventBuffer: IngestEvent[] = []; + let bufferOverflowed = false; + + /** + * Send an event to the ingest WebSocket. + * Buffers events if disconnected. + */ + function sendToIngest(event: IngestEvent): void { + if (ingestWs && ingestWs.readyState === WebSocket.OPEN) { + ingestWs.send(JSON.stringify(event)); + } else { + // Buffer events while disconnected + if (eventBuffer.length < MAX_BUFFER_SIZE) { + eventBuffer.push(event); + } else { + bufferOverflowed = true; + } + } + } + + /** + * Flush buffered events after reconnection. + */ + function flushBuffer(): void { + if (!ingestWs || ingestWs.readyState !== WebSocket.OPEN) return; + + // Send resume marker so DO knows we may have lost events + if (eventBuffer.length > 0 || bufferOverflowed) { + ingestWs.send( + JSON.stringify({ + streamEventType: 'wrapper_resumed', + timestamp: new Date().toISOString(), + data: { bufferedEvents: eventBuffer.length, eventsLost: bufferOverflowed }, + }) + ); + } + + // Flush buffer + for (const event of eventBuffer) { + ingestWs.send(JSON.stringify(event)); + } + eventBuffer.length = 0; + bufferOverflowed = false; + } + + /** + * Open the ingest WebSocket connection. + */ + async function openIngestWs(): Promise { + const job = state.currentJob; + if (!job) { + throw new Error('Cannot open ingest WS: no job context'); + } + + const url = new URL(job.ingestUrl); + url.searchParams.set('executionId', job.executionId); + url.searchParams.set('sessionId', job.sessionId); + url.searchParams.set('userId', job.userId); + + const wsUrl = url.toString(); + logToFile(`ingest WS connecting to: ${wsUrl}`); + + return new Promise((resolve, reject) => { + // Bun's WebSocket supports headers parameter + const WebSocketWithHeaders = WebSocket as unknown as WebSocketCtor; + + // Use kilocodeToken (user JWT) for auth - ingestToken is just executionId for DO validation + const ws = new WebSocketWithHeaders(wsUrl, { + headers: { + Authorization: `Bearer ${job.kilocodeToken}`, + }, + }); + + ws.onopen = () => { + logToFile(`ingest WS connected to: ${wsUrl}`); + ingestWs = ws; + flushBuffer(); + resolve(); + }; + + ws.onclose = () => { + logToFile(`ingest WS closed: ${wsUrl}`); + if (ingestWs === ws) { + ingestWs = null; + callbacks.onDisconnect('ingest websocket closed'); + } + }; + + ws.onerror = () => { + logToFile(`ingest WS error connecting to: ${wsUrl}`); + if (!ingestWs) { + reject(new Error(`Failed to connect to ingest: ${wsUrl}`)); + } + }; + + ws.onmessage = event => { + try { + const cmd = JSON.parse(String(event.data)) as WrapperCommand; + callbacks.onCommand(cmd); + } catch { + // Ignore parse errors + } + }; + + // Timeout for initial connection + setTimeout(() => { + if (!ingestWs) { + ws.close(); + reject(new Error('Ingest connection timeout')); + } + }, 10_000); + }); + } + + /** + * Open the SSE consumer for kilo server events. + */ + async function openSSEConsumer(): Promise { + const baseUrl = `http://127.0.0.1:${config.kiloServerPort}`; + const abortController = new AbortController(); + + sseConsumer = await createSSEConsumer({ + baseUrl, + onActivity: () => { + // Called for ALL SSE events including heartbeats - for activity tracking + state.updateActivity(); + state.recordSseEvent(); + }, + onEvent: (event: IngestEvent) => { + // Forward to ingest (heartbeats already filtered out) + sendToIngest(event); + + // Check for terminal errors + if (event.streamEventType === 'kilocode') { + const data = event.data as Record; + const terminal = isTerminalErrorEvent({ event: String(data.event ?? ''), data }); + if (terminal.isTerminal) { + callbacks.onTerminalError(terminal.reason ?? 'terminal error'); + return; + } + + // Check for completion events using typed event guards + if (isMessageUpdatedEvent(data)) { + const { info } = data.properties; + logToFile( + `message.updated: role=${info.role} hasCompleted=${typeof info.time?.completed === 'number'} msgId=${info.id}` + ); + if (isCompletedAssistantMessage(info)) { + // Guard: only process completions for our current session + const currentSessionId = state.currentJob?.kiloSessionId; + if (currentSessionId && info.sessionID !== currentSessionId) { + logToFile( + `ignoring completion for different session: event=${info.sessionID} current=${currentSessionId}` + ); + return; + } + + logToFile(`assistant message completed: ${info.id}`); + // Signal completion for post-processing waiters + callbacks.onCompletionSignal(); + // Note: We don't call onMessageComplete here because kilo's assistant message ID + // differs from our tracked user message ID. session.idle handles inflight cleanup. + } + } + + // session.idle is the primary completion signal - it means the assistant finished + // and the session is waiting for the next user input + if (data.event === 'session.idle') { + logToFile(`session.idle received - marking all inflight as complete`); + // Complete ALL inflight messages for this job - the session is idle + const inflightIds = state.inflightMessageIds; + for (const messageId of inflightIds) { + logToFile(`completing inflight messageId=${messageId}`); + callbacks.onMessageComplete(messageId); + } + callbacks.onCompletionSignal(); + } + } + }, + onConnected: () => { + logToFile('SSE consumer connected'); + }, + onClose: reason => { + logToFile(`SSE consumer closed: ${reason}`); + if (sseConsumer) { + callbacks.onDisconnect(`SSE closed: ${reason}`); + } + }, + onError: error => { + logToFile(`SSE consumer error: ${error.message}`); + }, + }); + + // Store abort controller in state (ingestWs is guaranteed set after openIngestWs resolves) + if (!ingestWs) { + throw new Error('ingestWs not set after openIngestWs'); + } + state.setConnections(ingestWs, abortController); + state.setSendToIngestFn(sendToIngest); + } + + /** + * Start heartbeat interval. + */ + function startHeartbeat(): void { + const job = state.currentJob; + if (!job) return; + + heartbeatInterval = setInterval(() => { + if (ingestWs?.readyState === WebSocket.OPEN) { + ingestWs.send( + JSON.stringify({ + streamEventType: 'heartbeat', + data: { executionId: job.executionId }, + timestamp: new Date().toISOString(), + }) + ); + } + }, 20_000); + } + + /** + * Stop heartbeat interval. + */ + function stopHeartbeat(): void { + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + heartbeatInterval = null; + } + } + + return { + open: async () => { + logToFile('opening connections'); + + // Open both connections + await openIngestWs(); + await openSSEConsumer(); + + // Start heartbeat + startHeartbeat(); + + logToFile('connections opened'); + }, + + close: async () => { + logToFile('closing connections'); + + // Stop heartbeat + stopHeartbeat(); + + // Stop SSE consumer + if (sseConsumer) { + sseConsumer.stop(); + sseConsumer = null; + } + + // Close ingest WS + if (ingestWs) { + try { + ingestWs.close(); + } catch { + // Ignore close errors + } + ingestWs = null; + } + + // Clear state references + state.clearConnections(); + state.setSendToIngestFn(null); + + logToFile('connections closed'); + }, + + isConnected: () => { + return ingestWs !== null && ingestWs.readyState === WebSocket.OPEN && sseConsumer !== null; + }, + }; +} diff --git a/cloud-agent-next/wrapper/src/event-parser.ts b/cloud-agent-next/wrapper/src/event-parser.ts new file mode 100644 index 0000000000..b72a123984 --- /dev/null +++ b/cloud-agent-next/wrapper/src/event-parser.ts @@ -0,0 +1,41 @@ +import { stripVTControlCharacters } from 'node:util'; +import type { IngestEvent } from '../../src/shared/protocol.js'; + +export function stripAnsi(str: string): string { + return stripVTControlCharacters(str); +} + +export function parseKilocodeOutput(line: string): IngestEvent { + const timestamp = new Date().toISOString(); + + const candidates = [line, stripAnsi(line)]; + for (const candidate of candidates) { + try { + const parsed = JSON.parse(candidate); + return { streamEventType: 'kilocode', data: parsed, timestamp }; + } catch { + // try next candidate + } + } + + const clean = stripAnsi(line); + return { streamEventType: 'output', data: { content: clean, source: 'stdout' }, timestamp }; +} + +export type TerminalCheck = { isTerminal: true; reason: string } | { isTerminal: false }; + +export function isTerminalEvent(data: Record): TerminalCheck { + if (data.event === 'payment_required') { + return { isTerminal: true, reason: 'Payment required' }; + } + if (data.event === 'insufficient_funds') { + return { isTerminal: true, reason: 'Insufficient funds' }; + } + if (data.type === 'ask' && data.ask === 'api_req_failed') { + const text = String(data.text ?? ''); + if (text.includes('payment') || text.includes('credit') || text.includes('balance')) { + return { isTerminal: true, reason: 'API request failed: payment issue' }; + } + } + return { isTerminal: false }; +} diff --git a/cloud-agent-next/wrapper/src/kilo-client.ts b/cloud-agent-next/wrapper/src/kilo-client.ts new file mode 100644 index 0000000000..64fce77743 --- /dev/null +++ b/cloud-agent-next/wrapper/src/kilo-client.ts @@ -0,0 +1,206 @@ +import { logToFile } from './utils.js'; +import type { + Session, + SessionCommandResponse, + TextPartInput, +} from '../../src/shared/kilo-types.js'; + +// Re-export types that callers may need +export type { Session, SessionCommandResponse }; + +/** + * Message part structure for sending messages. + * Uses TextPartInput from kilo types for compatibility. + */ +export type MessagePart = TextPartInput; + +/** + * Options for creating a session. + */ +export type CreateSessionOptions = { + /** Parent session ID for branching */ + parentID?: string; + /** Session title */ + title?: string; +}; + +/** + * Options for sending a prompt. + */ +export type SendPromptOptions = { + /** The session ID to send the prompt to */ + sessionId: string; + /** Message ID - kilo will use this ID for the message */ + messageId?: string; + /** The prompt text (shorthand for parts with single text) */ + prompt?: string; + /** Full parts array (takes precedence over prompt) */ + parts?: MessagePart[]; + /** Agent mode (e.g., 'code', 'architect', 'ask') */ + agent?: string; + /** Model configuration */ + model?: { providerID?: string; modelID: string }; + /** Don't wait for AI reply - just queue the message */ + noReply?: boolean; + /** Custom system prompt override */ + system?: string; + /** Enable/disable specific tools */ + tools?: Record; +}; + +/** + * Options for aborting a session. + */ +export type AbortSessionOptions = { + sessionId: string; +}; + +/** + * Options for sending a command. + */ +export type SendCommandOptions = { + sessionId: string; + command: string; + args?: string; +}; + +/** + * Permission response type. + */ +export type PermissionResponse = 'always' | 'once' | 'reject'; + +/** + * Client for interacting with a kilo serve instance. + */ +export type KiloClient = { + /** List all sessions */ + listSessions: () => Promise; + /** Create a new session */ + createSession: (opts?: CreateSessionOptions) => Promise; + /** Get a session by ID */ + getSession: (sessionId: string) => Promise; + /** Send a prompt asynchronously (returns immediately, results via SSE) */ + sendPromptAsync: (opts: SendPromptOptions) => Promise; + /** Abort a running session */ + abortSession: (opts: AbortSessionOptions) => Promise; + /** Check server health */ + checkHealth: () => Promise<{ healthy: boolean; version: string }>; + /** Send a command (slash command) to a session */ + sendCommand: (opts: SendCommandOptions) => Promise; + /** Answer a permission request */ + answerPermission: (permissionId: string, response: PermissionResponse) => Promise; + /** Answer a question */ + answerQuestion: (questionId: string, answers: string[]) => Promise; + /** Reject a question */ + rejectQuestion: (questionId: string) => Promise; +}; + +/** + * Create a client for interacting with a kilo serve instance. + */ +export function createKiloClient(baseUrl: string): KiloClient { + /** + * Make HTTP request and return Response. + * Shared by requestJson and requestNoContent. + */ + async function makeRequest(method: string, path: string, body?: unknown): Promise { + const url = `${baseUrl}${path}`; + logToFile(`kilo-client ${method} ${path}`); + + const response = await fetch(url, { + method, + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`kilo API error: ${response.status} ${response.statusText} - ${text}`); + } + + return response; + } + + /** + * Make HTTP request expecting JSON response. + */ + async function requestJson(method: string, path: string, body?: unknown): Promise { + const response = await makeRequest(method, path, body); + return response.json() as Promise; + } + + /** + * Make HTTP request expecting no content (204). + */ + async function requestNoContent(method: string, path: string, body?: unknown): Promise { + await makeRequest(method, path, body); + } + + return { + checkHealth: () => requestJson<{ healthy: boolean; version: string }>('GET', '/global/health'), + + listSessions: () => requestJson('GET', '/session'), + + createSession: (opts?: CreateSessionOptions) => + requestJson('POST', '/session', { + parentID: opts?.parentID, + title: opts?.title, + }), + + getSession: (sessionId: string) => requestJson('GET', `/session/${sessionId}`), + + sendPromptAsync: async (opts: SendPromptOptions) => { + // Build parts array from either parts or prompt + const parts: MessagePart[] = + opts.parts ?? (opts.prompt ? [{ type: 'text', text: opts.prompt }] : []); + + if (parts.length === 0) { + throw new Error('sendPromptAsync requires either parts or prompt'); + } + + await requestNoContent('POST', `/session/${opts.sessionId}/prompt_async`, { + parts, + messageID: opts.messageId, + agent: opts.agent, + model: opts.model + ? { + providerID: opts.model.providerID ?? 'kilo', + modelID: opts.model.modelID, + } + : undefined, + noReply: opts.noReply, + system: opts.system, + tools: opts.tools, + }); + }, + + abortSession: (opts: AbortSessionOptions) => + requestJson('POST', `/session/${opts.sessionId}/abort`), + + sendCommand: async (opts: SendCommandOptions) => { + // Commands are sent via POST /session/:sessionId/command + return requestJson('POST', `/session/${opts.sessionId}/command`, { + command: opts.command, + args: opts.args, + }); + }, + + answerPermission: async (permissionId: string, response: PermissionResponse) => { + // Permission replies go to POST /permission/:permissionId/reply + await requestNoContent('POST', `/permission/${permissionId}/reply`, { response }); + return true; + }, + + answerQuestion: async (questionId: string, answers: string[]) => { + // Question answers go to POST /question/:questionId/reply + await requestNoContent('POST', `/question/${questionId}/reply`, { answers }); + return true; + }, + + rejectQuestion: async (questionId: string) => { + // Question rejections go to POST /question/:questionId/reject + await requestNoContent('POST', `/question/${questionId}/reject`, {}); + return true; + }, + }; +} diff --git a/cloud-agent-next/wrapper/src/kilocode-runner.ts b/cloud-agent-next/wrapper/src/kilocode-runner.ts new file mode 100644 index 0000000000..2f6a21aaee --- /dev/null +++ b/cloud-agent-next/wrapper/src/kilocode-runner.ts @@ -0,0 +1,225 @@ +import { spawn, type ChildProcess } from 'child_process'; +import type { IngestEvent } from '../../src/shared/protocol.js'; +import { parseKilocodeOutput, isTerminalEvent, stripAnsi } from './event-parser.js'; + +export type RunnerOptions = { + mode: string; + prompt: string; + workspacePath: string; + kiloSessionId?: string; + idleTimeoutMs: number; + maxRuntimeMs?: number; + onEvent: (event: IngestEvent) => void; + onTerminalEvent: (reason: string) => void; +}; + +/** Result of waiting for the runner to complete */ +export type RunnerResult = { + exitCode: number; + signal: string | null; + wasKilled: boolean; +}; + +export type KilocodeRunner = { + wait: () => Promise; + kill: (signal?: NodeJS.Signals) => void; + process: ChildProcess; +}; + +export function createKilocodeRunner(opts: RunnerOptions): KilocodeRunner { + // mode must be 'plan', 'code', or 'custom' - normalized upstream from input modes + const args = ['run', '--format', 'json', '--agent', opts.mode]; + + if (opts.kiloSessionId) { + console.log('TODO:FLORIAN UNCOMMENT WHEN SESSIONS ARE READY'); + console.log(opts.kiloSessionId); + //args.push('--session', opts.kiloSessionId); + } + + const proc = spawn('kilo', args, { + cwd: opts.workspacePath, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const stdin = proc.stdin; + const stdout = proc.stdout; + const stderr = proc.stderr; + + if (!stdin || !stdout || !stderr) { + throw new Error('Failed to open kilo stdio streams'); + } + + // Track spawn errors - if the binary doesn't exist or can't be executed, + // spawn() doesn't throw but emits an 'error' event instead + let spawnError: Error | null = null; + proc.on('error', (err: Error) => { + spawnError = err; + opts.onEvent({ + streamEventType: 'error', + data: { error: `Failed to start kilocode: ${err.message}`, fatal: true }, + timestamp: new Date().toISOString(), + }); + }); + + // Write prompt to stdin + stdin.write(opts.prompt); + stdin.end(); + + // Idle timeout tracking + let lastActivity = Date.now(); + const idleCheck = + opts.idleTimeoutMs > 0 + ? setInterval(() => { + if (Date.now() - lastActivity > opts.idleTimeoutMs) { + opts.onEvent({ + streamEventType: 'error', + data: { error: 'Idle timeout exceeded', fatal: true }, + timestamp: new Date().toISOString(), + }); + proc.kill('SIGTERM'); + } + }, 30_000) + : null; + + // Hard timeout - maximum total runtime regardless of activity + const maxRuntimeTimer = + opts.maxRuntimeMs && opts.maxRuntimeMs > 0 + ? setTimeout(() => { + opts.onEvent({ + streamEventType: 'error', + data: { error: 'Execution time limit reached', fatal: true }, + timestamp: new Date().toISOString(), + }); + proc.kill('SIGTERM'); + }, opts.maxRuntimeMs) + : null; + + // Process stdout - line buffered + let stdoutBuffer = ''; + stdout.on('data', (chunk: Buffer) => { + lastActivity = Date.now(); + stdoutBuffer += chunk.toString(); + + const lines = stdoutBuffer.split('\n'); + stdoutBuffer = lines.pop() ?? ''; + + for (const line of lines) { + if (!line.trim()) continue; + + const event = parseKilocodeOutput(line); + if (event.streamEventType === 'kilocode') { + const terminal = isTerminalEvent(event.data as Record); + if (terminal.isTerminal) { + opts.onEvent(event); + opts.onTerminalEvent(terminal.reason); + proc.kill('SIGTERM'); + return; + } + } + opts.onEvent(event); + } + }); + + // Flush stdout buffer on close + stdout.on('close', () => { + if (stdoutBuffer.trim()) { + opts.onEvent(parseKilocodeOutput(stdoutBuffer)); + } + }); + + // Process stderr - line buffered + let stderrBuffer = ''; + stderr.on('data', (chunk: Buffer) => { + lastActivity = Date.now(); + stderrBuffer += chunk.toString(); + + const lines = stderrBuffer.split('\n'); + stderrBuffer = lines.pop() ?? ''; + + for (const line of lines) { + if (!line.trim()) continue; + opts.onEvent({ + streamEventType: 'output', + data: { content: stripAnsi(line), source: 'stderr' }, + timestamp: new Date().toISOString(), + }); + } + }); + + // Flush stderr buffer on close + stderr.on('close', () => { + if (stderrBuffer.trim()) { + opts.onEvent({ + streamEventType: 'output', + data: { content: stripAnsi(stderrBuffer), source: 'stderr' }, + timestamp: new Date().toISOString(), + }); + } + }); + + // Track whether process was explicitly killed via kill() method + let wasExplicitlyKilled = false; + + // Track if process has already exited (for early exit before wait() is called) + let exitResult: RunnerResult | null = null; + + // Listen for exit immediately so we capture it even if wait() is called late + proc.on('exit', (code, signal) => { + if (idleCheck) clearInterval(idleCheck); + exitResult = { + exitCode: code ?? (spawnError ? 1 : 0), + signal: signal ?? null, + wasKilled: wasExplicitlyKilled, + }; + }); + + return { + wait: () => + new Promise(resolve => { + const resolveOnce = (result: RunnerResult) => { + if (idleCheck) clearInterval(idleCheck); + if (maxRuntimeTimer) clearTimeout(maxRuntimeTimer); + resolve(result); + }; + + // If spawn error already occurred, resolve immediately + // The 'error' event fires before wait() is called when binary is missing + if (spawnError) { + resolveOnce({ + exitCode: 1, + signal: null, + wasKilled: false, + }); + return; + } + + // If process already exited before wait() was called, resolve immediately + if (exitResult) { + resolve(exitResult); + return; + } + + // Otherwise wait for exit event + proc.once('exit', (code, signal) => { + resolveOnce({ + exitCode: code ?? (spawnError ? 1 : 0), + signal: signal ?? null, + wasKilled: wasExplicitlyKilled, + }); + }); + + // Handle spawn errors that occur after wait() is called + proc.once('error', () => { + resolveOnce({ + exitCode: 1, + signal: null, + wasKilled: false, + }); + }); + }), + kill: (signal: NodeJS.Signals = 'SIGTERM') => { + wasExplicitlyKilled = true; + proc.kill(signal); + }, + process: proc, + }; +} diff --git a/cloud-agent-next/wrapper/src/lifecycle.ts b/cloud-agent-next/wrapper/src/lifecycle.ts new file mode 100644 index 0000000000..7b7faac76f --- /dev/null +++ b/cloud-agent-next/wrapper/src/lifecycle.ts @@ -0,0 +1,464 @@ +/** + * Lifecycle management for the long-running wrapper. + * + * Handles: + * - Inflight expiry (per-message timeout) + * - Idle timeout (session-level cleanup) + * - Drain period (grace period before closing connections) + * - Auto-commit and condense on completion + */ + +import type { WrapperState } from './state.js'; +import type { KiloClient } from './kilo-client.js'; +import type { ConnectionManager } from './connection.js'; +import { runAutoCommit } from './auto-commit.js'; +import { runCondenseOnComplete } from './condense-on-complete.js'; +import { logToFile } from './utils.js'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Interval for checking inflight expiry (5 seconds) */ +const INFLIGHT_CHECK_INTERVAL_MS = 5_000; + +/** Interval for checking idle timeout (10 seconds) */ +const IDLE_CHECK_INTERVAL_MS = 10_000; + +/** Grace period before closing connections after inflight hits 0 (250ms) */ +const DRAIN_DELAY_MS = 250; + +/** Default per-message timeout if MAX_RUNTIME_MS not set (20 minutes) */ +export const DEFAULT_INFLIGHT_TIMEOUT_MS = 1_200_000; + +/** Default idle timeout if IDLE_TIMEOUT_MS not set (2 minutes) */ +export const DEFAULT_IDLE_TIMEOUT_MS = 120_000; + +/** SSE inactivity timeout - if no SSE events for this long while active, assume broken (2 minutes) */ +const SSE_INACTIVITY_TIMEOUT_MS = 120_000; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type LifecycleConfig = { + /** Per-message deadline timeout (from MAX_RUNTIME_MS env var) */ + maxRuntimeMs: number; + /** Session-level idle timeout (from IDLE_TIMEOUT_MS env var) */ + idleTimeoutMs: number; + /** Enable auto-commit on completion */ + autoCommit: boolean; + /** Enable condense on completion */ + condenseOnComplete: boolean; + /** Workspace path for auto-commit/condense */ + workspacePath: string; + /** Upstream branch for auto-commit */ + upstreamBranch?: string; + /** Model for auto-commit/condense */ + model?: string; +}; + +export type LifecycleDependencies = { + state: WrapperState; + kiloClient: KiloClient; + connectionManager: ConnectionManager; +}; + +export type LifecycleManager = { + /** Start lifecycle timers */ + start: () => void; + /** Stop lifecycle timers */ + stop: () => void; + /** Called when a message completes - checks if inflight is empty */ + onMessageComplete: (messageId: string) => void; + /** Called to trigger drain and close sequence */ + triggerDrainAndClose: () => void; + /** Get the max runtime in ms */ + getMaxRuntimeMs: () => number; + /** Signal completion for post-processing waiters (called by connection on completion events) */ + signalCompletion: () => void; + /** Set the aborted flag to prevent post-completion tasks from running */ + setAborted: () => void; +}; + +// --------------------------------------------------------------------------- +// Lifecycle Manager +// --------------------------------------------------------------------------- + +export function createLifecycleManager( + config: LifecycleConfig, + deps: LifecycleDependencies +): LifecycleManager { + const { state, kiloClient, connectionManager } = deps; + + let inflightCheckInterval: ReturnType | null = null; + let idleCheckInterval: ReturnType | null = null; + let drainTimeout: ReturnType | null = null; + let isDraining = false; + let isAborted = false; + + // Completion waiter for post-processing tasks (auto-commit, condense) + let postProcessingResolve: (() => void) | null = null; + let postProcessingCompleted = false; + + /** + * Check for expired inflight entries and handle timeouts. + */ + function checkInflightExpiry(): void { + const now = Date.now(); + const expired = state.getExpiredInflight(now); + + for (const entry of expired) { + logToFile(`inflight timeout: messageId=${entry.messageId}`); + + // Send timeout error event to ingest + state.sendToIngest({ + streamEventType: 'error', + data: { + error: `Prompt ${entry.messageId} timed out after ${(now - entry.startedAt) / 1000}s`, + fatal: false, + code: 'INFLIGHT_TIMEOUT', + messageId: entry.messageId, + }, + timestamp: new Date().toISOString(), + }); + + // Cache error in state + state.setLastError({ + code: 'INFLIGHT_TIMEOUT', + messageId: entry.messageId, + message: `Prompt timed out after ${(now - entry.startedAt) / 1000}s`, + timestamp: now, + }); + + // Remove from inflight + state.removeInflight(entry.messageId); + } + + // Check if inflight is now empty + if (state.isIdle && connectionManager.isConnected()) { + triggerDrainAndClose(); + } + } + + /** + * Check for idle timeout and cleanup stale job context. + */ + function checkIdleTimeout(): void { + // Only check when has job context + if (!state.hasJob) return; + + const now = Date.now(); + + // Check SSE inactivity while active (inflight > 0) + // If we have inflight prompts but SSE has gone silent, something is broken + if (state.isActive && connectionManager.isConnected()) { + const sseInactivityMs = state.getSseInactivityMs(now); + + // Only check if we've ever received SSE events (give initial connection time) + if (sseInactivityMs !== null && sseInactivityMs >= SSE_INACTIVITY_TIMEOUT_MS) { + logToFile( + `SSE inactivity timeout: no events for ${sseInactivityMs / 1000}s while ${state.inflightCount} prompts inflight` + ); + + // Send error event + state.sendToIngest({ + streamEventType: 'error', + data: { + error: `SSE stream inactive for ${sseInactivityMs / 1000}s - assuming connection broken`, + fatal: true, + code: 'SSE_INACTIVITY_TIMEOUT', + }, + timestamp: new Date().toISOString(), + }); + + // Cache error + state.setLastError({ + code: 'SSE_INACTIVITY_TIMEOUT', + message: `SSE stream inactive for ${sseInactivityMs / 1000}s`, + timestamp: now, + }); + + // Abort kilo session + const job = state.currentJob; + if (job) { + kiloClient.abortSession({ sessionId: job.kiloSessionId }).catch(() => {}); + } + + // Mark as aborted so we don't send 'complete' event, then close + isAborted = true; + state.clearAllInflight(); + triggerDrainAndClose(); + return; + } + + // Also check if we've been waiting too long for initial SSE events + // after connection was established (give 30 seconds for first event) + if (!state.hasSseActivity()) { + const idleMs = state.getIdleMs(now); + const SSE_INITIAL_TIMEOUT_MS = 30_000; + if (idleMs >= SSE_INITIAL_TIMEOUT_MS) { + logToFile( + `SSE initial timeout: no events received within ${idleMs / 1000}s of connection` + ); + + state.sendToIngest({ + streamEventType: 'error', + data: { + error: `No SSE events received within ${idleMs / 1000}s - assuming connection broken`, + fatal: true, + code: 'SSE_INITIAL_TIMEOUT', + }, + timestamp: new Date().toISOString(), + }); + + state.setLastError({ + code: 'SSE_INITIAL_TIMEOUT', + message: `No SSE events received within ${idleMs / 1000}s`, + timestamp: now, + }); + + const job = state.currentJob; + if (job) { + kiloClient.abortSession({ sessionId: job.kiloSessionId }).catch(() => {}); + } + + // Mark as aborted so we don't send 'complete' event, then close + isAborted = true; + state.clearAllInflight(); + triggerDrainAndClose(); + return; + } + } + } + + // Check idle timeout when not active (inflight == 0) + if (state.isIdle) { + const idleMs = state.getIdleMs(now); + + if (idleMs >= config.idleTimeoutMs) { + logToFile(`idle timeout: ${idleMs / 1000}s`); + + // Send idle timeout event if connected + if (connectionManager.isConnected()) { + state.sendToIngest({ + streamEventType: 'error', + data: { + error: `Session idle for ${idleMs / 1000}s`, + fatal: false, + code: 'IDLE_TIMEOUT', + }, + timestamp: new Date().toISOString(), + }); + } + + // Cache error in state + state.setLastError({ + code: 'IDLE_TIMEOUT', + message: `Session idle for ${idleMs / 1000}s`, + timestamp: now, + }); + + // Close connection and clear job + void connectionManager.close(); + state.clearJob(); + } + } + } + + /** + * Signal that a completion event was received (called by connection manager). + * This resolves any pending waitForCompletion() promises used by post-processing tasks. + */ + function signalCompletion(): void { + postProcessingCompleted = true; + if (postProcessingResolve) { + postProcessingResolve(); + postProcessingResolve = null; + } + } + + /** + * Run post-completion tasks (auto-commit, condense). + */ + async function runPostCompletionTasks(): Promise { + const job = state.currentJob; + if (!job) return; + + // Use the shared completion state for waiting on post-processing commands + const expectCompletion = () => { + postProcessingCompleted = false; + postProcessingResolve = null; + }; + + const waitForCompletion = (): Promise => { + if (postProcessingCompleted) return Promise.resolve(); + return new Promise(resolve => { + postProcessingResolve = resolve; + }); + }; + + const wasAborted = () => isAborted; + + // Run auto-commit if enabled + if (config.autoCommit) { + logToFile('running auto-commit'); + try { + await runAutoCommit({ + workspacePath: config.workspacePath, + upstreamBranch: config.upstreamBranch, + model: config.model, + onEvent: event => state.sendToIngest(event), + kiloClient, + kiloSessionId: job.kiloSessionId, + expectCompletion, + waitForCompletion, + wasAborted, + }); + logToFile('auto-commit complete'); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logToFile(`auto-commit error: ${msg}`); + state.sendToIngest({ + streamEventType: 'error', + data: { error: `Auto-commit failed: ${msg}`, fatal: false }, + timestamp: new Date().toISOString(), + }); + } + } + + // Run condense if enabled + if (config.condenseOnComplete) { + logToFile('running condense'); + try { + await runCondenseOnComplete({ + workspacePath: config.workspacePath, + kiloSessionId: job.kiloSessionId, + model: config.model, + onEvent: event => state.sendToIngest(event), + kiloClient, + expectCompletion, + waitForCompletion, + wasAborted, + }); + logToFile('condense complete'); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logToFile(`condense error: ${msg}`); + state.sendToIngest({ + streamEventType: 'error', + data: { error: `Condense failed: ${msg}`, fatal: false }, + timestamp: new Date().toISOString(), + }); + } + } + } + + /** + * Trigger drain period and close connections. + * Sends complete event (unless aborted), runs post-completion tasks, then closes after drain delay. + */ + function triggerDrainAndClose(): void { + if (isDraining) return; + isDraining = true; + + logToFile(`starting drain period (isAborted=${isAborted})`); + + // Send complete event to ingest so DO can update execution status and trigger callbacks + // BUT only if not aborted - fatal errors already sent their own terminal event + const job = state.currentJob; + if (job && !isAborted) { + logToFile(`sending complete event for executionId=${job.executionId}`); + state.sendToIngest({ + streamEventType: 'complete', + data: { + exitCode: 0, + executionId: job.executionId, + kiloSessionId: job.kiloSessionId, + }, + timestamp: new Date().toISOString(), + }); + } else if (job && isAborted) { + logToFile(`skipping complete event - execution was aborted`); + } + + // Run post-completion tasks, then drain and close + runPostCompletionTasks() + .catch(err => + logToFile( + `post-completion tasks failed: ${err instanceof Error ? err.message : String(err)}` + ) + ) + .finally(() => { + drainTimeout = setTimeout(() => { + logToFile('drain complete, closing connections'); + connectionManager + .close() + .catch(err => + logToFile(`close failed: ${err instanceof Error ? err.message : String(err)}`) + ) + .finally(() => { + isDraining = false; + drainTimeout = null; + }); + }, DRAIN_DELAY_MS); + }); + } + + /** + * Handle message completion. + */ + function onMessageComplete(messageId: string): void { + const removed = state.removeInflight(messageId); + if (!removed) { + logToFile(`completion for unknown messageId=${messageId}`); + return; + } + + logToFile(`message complete: messageId=${messageId} remaining=${state.inflightCount}`); + + // Check if all inflight are done + if (state.isIdle && connectionManager.isConnected()) { + triggerDrainAndClose(); + } + } + + return { + start: () => { + logToFile('starting lifecycle timers'); + inflightCheckInterval = setInterval(checkInflightExpiry, INFLIGHT_CHECK_INTERVAL_MS); + idleCheckInterval = setInterval(checkIdleTimeout, IDLE_CHECK_INTERVAL_MS); + }, + + stop: () => { + logToFile('stopping lifecycle timers'); + isAborted = true; + + if (inflightCheckInterval) { + clearInterval(inflightCheckInterval); + inflightCheckInterval = null; + } + + if (idleCheckInterval) { + clearInterval(idleCheckInterval); + idleCheckInterval = null; + } + + if (drainTimeout) { + clearTimeout(drainTimeout); + drainTimeout = null; + } + }, + + onMessageComplete, + triggerDrainAndClose, + signalCompletion, + + setAborted: () => { + isAborted = true; + logToFile('abort flag set - post-completion tasks will be skipped'); + }, + + getMaxRuntimeMs: () => config.maxRuntimeMs, + }; +} diff --git a/cloud-agent-next/wrapper/src/main.ts b/cloud-agent-next/wrapper/src/main.ts new file mode 100644 index 0000000000..f90a42f4c9 --- /dev/null +++ b/cloud-agent-next/wrapper/src/main.ts @@ -0,0 +1,287 @@ +/** + * Long-running wrapper entry point. + * + * The wrapper runs as a long-running HTTP server that: + * - Stays alive for the lifetime of the sandbox session + * - Exposes an HTTP API for the Worker to send commands + * - Connects to /ingest WebSocket on-demand (only when active) + * - Handles SSE event forwarding, auto-commit, and condensation + * + * Configuration is via environment variables (session-level). + * Execution-specific config is passed via HTTP API. + */ + +import { WrapperState } from './state.js'; +import { createKiloClient } from './kilo-client.js'; +import { createConnectionManager } from './connection.js'; +import { + createLifecycleManager, + DEFAULT_INFLIGHT_TIMEOUT_MS, + DEFAULT_IDLE_TIMEOUT_MS, +} from './lifecycle.js'; +import { createServer } from './server.js'; +import { logToFile } from './utils.js'; +import type { WrapperCommand } from '../../src/shared/protocol.js'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Version string for health check */ +const VERSION = '2.0.0'; + +/** Grace period before force exit during shutdown (20 seconds) */ +const SHUTDOWN_TIMEOUT_MS = 20_000; + +// --------------------------------------------------------------------------- +// Environment Variable Parsing +// --------------------------------------------------------------------------- + +function getRequiredEnv(name: string): string { + const value = process.env[name]; + if (!value) { + logToFile(`ERROR: Missing required environment variable: ${name}`); + console.error(`Missing required environment variable: ${name}`); + process.exit(1); + } + return value; +} + +function getOptionalEnv(name: string, defaultValue: string): string { + return process.env[name] ?? defaultValue; +} + +function getOptionalEnvInt(name: string, defaultValue: number): number { + const value = process.env[name]; + if (!value) return defaultValue; + const parsed = parseInt(value, 10); + if (isNaN(parsed)) { + logToFile(`WARNING: Invalid integer for ${name}: ${value}, using default ${defaultValue}`); + return defaultValue; + } + return parsed; +} + +function getOptionalEnvBool(name: string, defaultValue: boolean): boolean { + const value = process.env[name]; + if (!value) return defaultValue; + return value.toLowerCase() === 'true' || value === '1'; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + logToFile('wrapper starting (long-running mode)'); + + // Parse environment variables + const wrapperPort = getOptionalEnvInt('WRAPPER_PORT', 5000); + const kiloServerPort = getOptionalEnvInt('KILO_SERVER_PORT', 4000); + const workspacePath = getRequiredEnv('WORKSPACE_PATH'); + + const autoCommit = getOptionalEnvBool('AUTO_COMMIT', false); + const condenseOnComplete = getOptionalEnvBool('CONDENSE_ON_COMPLETE', false); + const upstreamBranch = getOptionalEnv('UPSTREAM_BRANCH', ''); + const model = getOptionalEnv('MODEL', ''); + + const maxRuntimeMs = getOptionalEnvInt('MAX_RUNTIME_MS', DEFAULT_INFLIGHT_TIMEOUT_MS); + const idleTimeoutMs = getOptionalEnvInt('IDLE_TIMEOUT_MS', DEFAULT_IDLE_TIMEOUT_MS); + + // Set log path if not already set + if (!process.env.WRAPPER_LOG_PATH) { + process.env.WRAPPER_LOG_PATH = `/tmp/kilocode-wrapper-${Date.now()}.log`; + } + + logToFile( + `config: wrapperPort=${wrapperPort} kiloServerPort=${kiloServerPort} workspacePath=${workspacePath}` + ); + logToFile( + `config: autoCommit=${autoCommit} condenseOnComplete=${condenseOnComplete} maxRuntimeMs=${maxRuntimeMs} idleTimeoutMs=${idleTimeoutMs}` + ); + + // Create state + const state = new WrapperState(); + + // Create kilo client + const kiloServerBaseUrl = `http://127.0.0.1:${kiloServerPort}`; + const kiloClient = createKiloClient(kiloServerBaseUrl); + + // Verify kilo server is reachable + try { + const health = await kiloClient.checkHealth(); + logToFile(`kilo server healthy: version=${health.version}`); + } catch (error) { + logToFile( + `kilo server health check failed: ${error instanceof Error ? error.message : String(error)}` + ); + console.error('Kilo server is not reachable at', kiloServerBaseUrl); + process.exit(1); + } + + // Create connection manager + const connectionManager = createConnectionManager( + state, + { kiloServerPort }, + { + onMessageComplete: (messageId: string) => { + lifecycleManager.onMessageComplete(messageId); + }, + onTerminalError: (reason: string) => { + logToFile(`terminal error: ${reason}`); + state.sendToIngest({ + streamEventType: 'error', + data: { error: reason, fatal: true }, + timestamp: new Date().toISOString(), + }); + // Abort the session if possible + const job = state.currentJob; + if (job) { + kiloClient.abortSession({ sessionId: job.kiloSessionId }).catch(() => {}); + } + // Mark as aborted (don't send 'complete' since we sent fatal error), then close + lifecycleManager.setAborted(); + state.clearAllInflight(); + lifecycleManager.triggerDrainAndClose(); + }, + onCommand: (cmd: WrapperCommand) => { + logToFile(`command received: ${cmd.type}`); + if (cmd.type === 'kill') { + // Send interrupted event before aborting + state.sendToIngest({ + streamEventType: 'interrupted', + data: { reason: 'User killed execution' }, + timestamp: new Date().toISOString(), + }); + // Abort the kilo session + const job = state.currentJob; + if (job) { + kiloClient.abortSession({ sessionId: job.kiloSessionId }).catch(() => {}); + } + // Mark as aborted (don't send 'complete' since we sent interrupted), then close + lifecycleManager.setAborted(); + state.clearAllInflight(); + lifecycleManager.triggerDrainAndClose(); + } + if (cmd.type === 'ping') { + state.sendToIngest({ + streamEventType: 'pong', + data: { executionId: state.currentJob?.executionId }, + timestamp: new Date().toISOString(), + }); + } + }, + onDisconnect: (reason: string) => { + logToFile(`disconnect: ${reason}`); + state.setLastError({ + code: 'DISCONNECT', + message: reason, + timestamp: Date.now(), + }); + state.clearAllInflight(); + // Also close SSE consumer to avoid orphaned connection + void connectionManager.close(); + }, + onCompletionSignal: () => { + // Signal completion to lifecycle manager for post-processing waiters + lifecycleManager.signalCompletion(); + }, + } + ); + + // Create lifecycle manager + const lifecycleManager = createLifecycleManager( + { + maxRuntimeMs, + idleTimeoutMs, + autoCommit, + condenseOnComplete, + workspacePath, + upstreamBranch: upstreamBranch || undefined, + model: model || undefined, + }, + { + state, + kiloClient, + connectionManager, + } + ); + + // Create HTTP server + const server = createServer( + { + port: wrapperPort, + kiloServerPort, + workspacePath, + version: VERSION, + }, + { + state, + kiloClient, + openConnection: () => connectionManager.open(), + getMaxRuntimeMs: () => lifecycleManager.getMaxRuntimeMs(), + setAborted: () => lifecycleManager.setAborted(), + }, + () => lifecycleManager.triggerDrainAndClose() + ); + + // Start lifecycle timers + lifecycleManager.start(); + + logToFile(`wrapper ready on port ${wrapperPort}`); + console.log(`Wrapper listening on port ${wrapperPort}`); + + // Graceful shutdown handler + let isShuttingDown = false; + + function handleShutdown(signal: string): void { + if (isShuttingDown) return; + isShuttingDown = true; + + logToFile(`shutdown signal: ${signal}`); + console.error(`Received ${signal}, shutting down...`); + + // Send interrupted event if connected + state.sendToIngest({ + streamEventType: 'interrupted', + data: { reason: `Container shutdown: ${signal}` }, + timestamp: new Date().toISOString(), + }); + + // Stop lifecycle timers + lifecycleManager.stop(); + + // Abort kilo session if running + const job = state.currentJob; + if (job) { + kiloClient.abortSession({ sessionId: job.kiloSessionId }).catch(() => {}); + } + + // Close connections + void connectionManager.close(); + + // Stop HTTP server + server.stop(); + + // Force exit after timeout + setTimeout(() => { + logToFile('force exit after timeout'); + process.exit(1); + }, SHUTDOWN_TIMEOUT_MS); + + // Try graceful exit + setTimeout(() => { + logToFile('graceful exit'); + process.exit(0); + }, 1000); + } + + process.on('SIGTERM', () => handleShutdown('SIGTERM')); + process.on('SIGINT', () => handleShutdown('SIGINT')); +} + +main().catch(err => { + logToFile(`fatal error: ${err instanceof Error ? err.message : String(err)}`); + console.error('Wrapper fatal error:', err); + process.exit(1); +}); diff --git a/cloud-agent-next/wrapper/src/server.ts b/cloud-agent-next/wrapper/src/server.ts new file mode 100644 index 0000000000..348a17862b --- /dev/null +++ b/cloud-agent-next/wrapper/src/server.ts @@ -0,0 +1,551 @@ +/** + * HTTP Server for the long-running wrapper. + * + * Exposes the wrapper's HTTP API for the Worker to interact with: + * - GET /health - Health check + * - GET /job/status - Current job status + * - POST /job/start - Start a new job + * - POST /job/prompt - Send a prompt + * - POST /job/command - Send a command + * - POST /job/answer-permission - Answer a permission request + * - POST /job/answer-question - Answer a question + * - POST /job/reject-question - Reject a question + * - POST /job/abort - Abort the current job + */ + +import type { WrapperState, JobContext } from './state.js'; +import type { KiloClient } from './kilo-client.js'; +import { logToFile } from './utils.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type ServerConfig = { + port: number; + kiloServerPort: number; + workspacePath: string; + version: string; +}; + +export type ServerDependencies = { + state: WrapperState; + kiloClient: KiloClient; + openConnection: () => Promise; + getMaxRuntimeMs: () => number; + /** Set the aborted flag to skip post-completion tasks */ + setAborted: () => void; +}; + +// Request body types +type StartJobBody = { + executionId: string; + ingestUrl: string; + ingestToken: string; + sessionId: string; + userId: string; + kilocodeToken: string; + kiloSessionId?: string; + kiloSessionTitle?: string; +}; + +type PromptBody = { + prompt?: string; + /** Message parts - only text parts are supported (file parts require URL upload which isn't implemented) */ + parts?: Array<{ type: 'text'; text: string }>; + model?: { providerID?: string; modelID: string }; + agent?: string; + messageId?: string; + system?: string; + tools?: Record; +}; + +type CommandBody = { + command: string; + args?: string; +}; + +type AnswerPermissionBody = { + permissionId: string; + response: 'always' | 'once' | 'reject'; +}; + +type AnswerQuestionBody = { + questionId: string; + answers: string[]; +}; + +type RejectQuestionBody = { + questionId: string; +}; + +// --------------------------------------------------------------------------- +// Helper Functions +// --------------------------------------------------------------------------- + +function jsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function errorResponse(error: string, message: string, status: number): Response { + return jsonResponse({ error, message }, status); +} + +/** + * Validate required string fields on a request body. + * Returns array of missing field names. + */ +function getMissingFields>( + body: T, + requiredFields: readonly (keyof T)[] +): string[] { + return requiredFields.filter(field => !body[field]) as string[]; +} + +// --------------------------------------------------------------------------- +// Route Handlers +// --------------------------------------------------------------------------- + +function createHealthHandler(config: ServerConfig, state: WrapperState) { + return (): Response => { + return jsonResponse({ + healthy: true, + state: state.isActive ? 'active' : 'idle', + inflightCount: state.inflightCount, + version: config.version, + }); + }; +} + +function createStatusHandler(state: WrapperState) { + return (): Response => { + return jsonResponse(state.getStatus()); + }; +} + +function createStartJobHandler(deps: ServerDependencies, kiloClient: KiloClient) { + return async (req: Request): Promise => { + const { state } = deps; + + let body: StartJobBody; + try { + body = (await req.json()) as StartJobBody; + } catch { + return errorResponse('INVALID_REQUEST', 'Invalid JSON body', 400); + } + + // Validate required fields + const requiredFields = [ + 'executionId', + 'ingestUrl', + 'ingestToken', + 'sessionId', + 'userId', + 'kilocodeToken', + ] as const; + const missing = getMissingFields(body, requiredFields); + if (missing.length > 0) { + return errorResponse( + 'INVALID_REQUEST', + `Missing required fields: ${missing.join(', ')}`, + 400 + ); + } + + // Check for idempotent call (same executionId) + const currentJob = state.currentJob; + if (currentJob && currentJob.executionId === body.executionId) { + logToFile(`job/start: idempotent call for executionId=${body.executionId}`); + return jsonResponse({ + status: 'started', + kiloSessionId: currentJob.kiloSessionId, + }); + } + + // Check for conflict (different executionId while active) + if (currentJob && state.isActive) { + logToFile( + `job/start: conflict - active execution ${currentJob.executionId}, requested ${body.executionId}` + ); + return errorResponse( + 'JOB_CONFLICT', + `Cannot start new job while execution ${currentJob.executionId} is active`, + 409 + ); + } + + // Create or resume kilo session + let kiloSessionId: string; + try { + if (body.kiloSessionId) { + // Resume existing session - verify it exists + await kiloClient.getSession(body.kiloSessionId); + kiloSessionId = body.kiloSessionId; + logToFile(`job/start: resuming kilo session ${kiloSessionId}`); + } else { + // Create new session + const session = await kiloClient.createSession({ + title: body.kiloSessionTitle, + }); + kiloSessionId = session.id; + logToFile(`job/start: created kilo session ${kiloSessionId}`); + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logToFile(`job/start: failed to create/resume session: ${msg}`); + return errorResponse('SESSION_ERROR', `Failed to create/resume kilo session: ${msg}`, 500); + } + + // Build job context + const jobContext: JobContext = { + executionId: body.executionId, + sessionId: body.sessionId, + userId: body.userId, + kiloSessionId, + ingestUrl: body.ingestUrl, + ingestToken: body.ingestToken, + kilocodeToken: body.kilocodeToken, + }; + + // Start the job (this stores context but doesn't connect yet) + try { + state.startJob(jobContext); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logToFile(`job/start: state.startJob failed: ${msg}`); + return errorResponse('JOB_CONFLICT', msg, 409); + } + + logToFile( + `job/start: job started executionId=${body.executionId} kiloSessionId=${kiloSessionId}` + ); + return jsonResponse({ status: 'started', kiloSessionId }); + }; +} + +function createPromptHandler(deps: ServerDependencies) { + return async (req: Request): Promise => { + const { state, kiloClient, openConnection, getMaxRuntimeMs } = deps; + + const job = state.currentJob; + if (!job) { + return errorResponse('NO_JOB', 'Call /job/start first', 400); + } + + let body: PromptBody; + try { + body = (await req.json()) as PromptBody; + } catch { + return errorResponse('INVALID_REQUEST', 'Invalid JSON body', 400); + } + + // Validate prompt content + if (!body.prompt && !body.parts) { + return errorResponse('INVALID_REQUEST', 'Either prompt or parts is required', 400); + } + const messageId = body.messageId ?? state.nextMessageId(); + + // Open connection if idle + if (state.isIdle && !state.isConnected) { + try { + await openConnection(); + logToFile(`job/prompt: connection opened`); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logToFile(`job/prompt: failed to open connection: ${msg}`); + return errorResponse('CONNECTION_ERROR', `Failed to open connection: ${msg}`, 500); + } + } + + // Calculate deadline + const deadline = Date.now() + getMaxRuntimeMs(); + + // Track inflight + state.addInflight(messageId, deadline); + + // Send to kilo server with the messageId we're tracking + try { + await kiloClient.sendPromptAsync({ + sessionId: job.kiloSessionId, + messageId, // Pass our tracked messageId to kilo + parts: body.parts, + prompt: body.prompt, + agent: body.agent, + model: body.model, + system: body.system, + tools: body.tools, + }); + logToFile(`job/prompt: sent messageId=${messageId}`); + } catch (error) { + // Remove from inflight on failure + state.removeInflight(messageId); + const msg = error instanceof Error ? error.message : String(error); + logToFile(`job/prompt: failed to send: ${msg}`); + return errorResponse('SEND_ERROR', `Failed to send prompt: ${msg}`, 500); + } + + return jsonResponse({ status: 'sent', messageId }); + }; +} + +function createCommandHandler(deps: ServerDependencies) { + return async (req: Request): Promise => { + const { state, kiloClient } = deps; + + const job = state.currentJob; + if (!job) { + return errorResponse('NO_JOB', 'Call /job/start first', 400); + } + + let body: CommandBody; + try { + body = (await req.json()) as CommandBody; + } catch { + return errorResponse('INVALID_REQUEST', 'Invalid JSON body', 400); + } + + if (!body.command) { + return errorResponse('INVALID_REQUEST', 'command is required', 400); + } + + // Commands are synchronous - call kilo server directly + // Note: Commands do NOT open connection or track inflight + try { + const result = await kiloClient.sendCommand({ + sessionId: job.kiloSessionId, + command: body.command, + args: body.args, + }); + state.updateActivity(); + logToFile(`job/command: sent command=${body.command}`); + return jsonResponse({ status: 'sent', result }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logToFile(`job/command: failed: ${msg}`); + return errorResponse('COMMAND_ERROR', `Failed to send command: ${msg}`, 500); + } + }; +} + +function createAnswerPermissionHandler(deps: ServerDependencies) { + return async (req: Request): Promise => { + const { state, kiloClient } = deps; + + if (!state.hasJob) { + return errorResponse('NO_JOB', 'Call /job/start first', 400); + } + + let body: AnswerPermissionBody; + try { + body = (await req.json()) as AnswerPermissionBody; + } catch { + return errorResponse('INVALID_REQUEST', 'Invalid JSON body', 400); + } + + if (!body.permissionId || !body.response) { + return errorResponse('INVALID_REQUEST', 'permissionId and response are required', 400); + } + + try { + const success = await kiloClient.answerPermission(body.permissionId, body.response); + state.updateActivity(); + logToFile( + `job/answer-permission: permissionId=${body.permissionId} response=${body.response}` + ); + return jsonResponse({ status: 'answered', success }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logToFile(`job/answer-permission: failed: ${msg}`); + return errorResponse('PERMISSION_ERROR', `Failed to answer permission: ${msg}`, 500); + } + }; +} + +function createAnswerQuestionHandler(deps: ServerDependencies) { + return async (req: Request): Promise => { + const { state, kiloClient } = deps; + + if (!state.hasJob) { + return errorResponse('NO_JOB', 'Call /job/start first', 400); + } + + let body: AnswerQuestionBody; + try { + body = (await req.json()) as AnswerQuestionBody; + } catch { + return errorResponse('INVALID_REQUEST', 'Invalid JSON body', 400); + } + + if (!body.questionId || !body.answers) { + return errorResponse('INVALID_REQUEST', 'questionId and answers are required', 400); + } + + try { + const success = await kiloClient.answerQuestion(body.questionId, body.answers); + state.updateActivity(); + logToFile(`job/answer-question: questionId=${body.questionId}`); + return jsonResponse({ status: 'answered', success }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logToFile(`job/answer-question: failed: ${msg}`); + return errorResponse('QUESTION_ERROR', `Failed to answer question: ${msg}`, 500); + } + }; +} + +function createRejectQuestionHandler(deps: ServerDependencies) { + return async (req: Request): Promise => { + const { state, kiloClient } = deps; + + if (!state.hasJob) { + return errorResponse('NO_JOB', 'Call /job/start first', 400); + } + + let body: RejectQuestionBody; + try { + body = (await req.json()) as RejectQuestionBody; + } catch { + return errorResponse('INVALID_REQUEST', 'Invalid JSON body', 400); + } + + if (!body.questionId) { + return errorResponse('INVALID_REQUEST', 'questionId is required', 400); + } + + try { + const success = await kiloClient.rejectQuestion(body.questionId); + state.updateActivity(); + logToFile(`job/reject-question: questionId=${body.questionId}`); + return jsonResponse({ status: 'rejected', success }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logToFile(`job/reject-question: failed: ${msg}`); + return errorResponse('QUESTION_ERROR', `Failed to reject question: ${msg}`, 500); + } + }; +} + +function createAbortHandler(deps: ServerDependencies, triggerDrainAndClose: () => void) { + return async (_req: Request): Promise => { + const { state, kiloClient, setAborted } = deps; + + const job = state.currentJob; + if (!job) { + return errorResponse('NO_JOB', 'No active job to abort', 400); + } + + // Set aborted flag FIRST to prevent post-completion tasks from running + setAborted(); + + // Abort the kilo session + try { + await kiloClient.abortSession({ sessionId: job.kiloSessionId }); + logToFile(`job/abort: aborted kilo session ${job.kiloSessionId}`); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logToFile(`job/abort: abort request failed (continuing): ${msg}`); + } + + // Send abort event to ingest + state.sendToIngest({ + streamEventType: 'interrupted', + data: { reason: 'aborted via API' }, + timestamp: new Date().toISOString(), + }); + + // Clear inflight and trigger close + state.clearAllInflight(); + triggerDrainAndClose(); + + return jsonResponse({ status: 'aborted' }); + }; +} + +// --------------------------------------------------------------------------- +// Server Creation +// --------------------------------------------------------------------------- + +export type WrapperServer = { + server: ReturnType; + stop: () => void; +}; + +export function createServer( + config: ServerConfig, + deps: ServerDependencies, + triggerDrainAndClose: () => void +): WrapperServer { + const { state, kiloClient } = deps; + + // Create route handlers + const healthHandler = createHealthHandler(config, state); + const statusHandler = createStatusHandler(state); + const startJobHandler = createStartJobHandler(deps, kiloClient); + const promptHandler = createPromptHandler(deps); + const commandHandler = createCommandHandler(deps); + const answerPermissionHandler = createAnswerPermissionHandler(deps); + const answerQuestionHandler = createAnswerQuestionHandler(deps); + const rejectQuestionHandler = createRejectQuestionHandler(deps); + const abortHandler = createAbortHandler(deps, triggerDrainAndClose); + + // Route table + type RouteHandler = (req: Request) => Response | Promise; + const routes: Record> = { + GET: { + '/health': healthHandler, + '/job/status': statusHandler, + }, + POST: { + '/job/start': startJobHandler, + '/job/prompt': promptHandler, + '/job/command': commandHandler, + '/job/answer-permission': answerPermissionHandler, + '/job/answer-question': answerQuestionHandler, + '/job/reject-question': rejectQuestionHandler, + '/job/abort': abortHandler, + }, + }; + + const server = Bun.serve({ + port: config.port, + fetch: async (req: Request): Promise => { + const url = new URL(req.url); + const method = req.method; + const path = url.pathname; + + logToFile(`HTTP ${method} ${path}`); + + // Look up route + const methodRoutes = routes[method]; + if (!methodRoutes) { + return errorResponse('METHOD_NOT_ALLOWED', `Method ${method} not allowed`, 405); + } + + const handler = methodRoutes[path]; + if (!handler) { + return errorResponse('NOT_FOUND', `Path ${path} not found`, 404); + } + + try { + return await handler(req); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logToFile(`HTTP handler error: ${msg}`); + return errorResponse('INTERNAL_ERROR', msg, 500); + } + }, + }); + + logToFile(`HTTP server listening on port ${config.port}`); + + return { + server, + stop: async () => { + await server.stop(); + logToFile('HTTP server stopped'); + }, + }; +} diff --git a/cloud-agent-next/wrapper/src/sse-consumer.ts b/cloud-agent-next/wrapper/src/sse-consumer.ts new file mode 100644 index 0000000000..f17c0ee9ab --- /dev/null +++ b/cloud-agent-next/wrapper/src/sse-consumer.ts @@ -0,0 +1,306 @@ +import type { IngestEvent } from '../../src/shared/protocol.js'; +import { logToFile } from './utils.js'; + +/** + * Type guard for checking if a value is a plain object (Record). + */ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * SSE event from the kilo serve /event endpoint. + * The first event is always 'server.connected'. + * Subsequent events are bus events from the kilo server. + */ +export type KiloSSEEvent = { + /** Event type/name from SSE */ + event: string; + /** Parsed JSON data */ + data: unknown; +}; + +/** + * Options for creating an SSE consumer. + */ +export type SSEConsumerOptions = { + /** Base URL of the kilo serve instance */ + baseUrl: string; + /** Callback for each event received (excluding internal events like heartbeats) */ + onEvent: (event: IngestEvent) => void; + /** Callback when any SSE event is received (including heartbeats) - for activity tracking */ + onActivity?: () => void; + /** Callback when the SSE connection is established */ + onConnected?: () => void; + /** Callback when the SSE connection is closed */ + onClose?: (reason: string) => void; + /** Callback when an error occurs */ + onError?: (error: Error) => void; +}; + +/** + * SSE consumer handle. + */ +export type SSEConsumer = { + /** Stop consuming events and close the connection */ + stop: () => void; + /** Check if still consuming */ + isActive: () => boolean; +}; + +/** + * Map a kilo serve SSE event to an IngestEvent for forwarding to the cloud-agent ingest. + * + * The kilo serve emits events like: + * - server.connected (initial connection event) + * - session.* events + * - message.* events (message_created, message_part_updated, message_completed, etc.) + * - Various other bus events + */ +function mapToIngestEvent(sseEvent: KiloSSEEvent): IngestEvent | null { + const timestamp = new Date().toISOString(); + + // Skip internal SSE events that shouldn't be forwarded to ingest + // - server.connected: initial connection ack + // - server.heartbeat: SSE keepalive (30s interval) + if (sseEvent.event === 'server.connected' || sseEvent.event === 'server.heartbeat') { + return null; + } + + // All other events are forwarded as 'kilocode' events + // The data structure from kilo serve is: {type: "event.name", properties: {...}} + // We spread the data and add 'event' field for consistency with existing consumers + return { + streamEventType: 'kilocode', + data: { + ...(isRecord(sseEvent.data) ? sseEvent.data : {}), + event: sseEvent.event, + }, + timestamp, + }; +} + +/** + * Parse SSE text format into event objects. + * SSE format is: + * event: \n + * data: \n + * \n + * + * @param chunk - The text chunk to parse + * @param flush - If true, flush any pending event even without trailing delimiter + */ +function parseSSEChunk(chunk: string, flush = false): KiloSSEEvent[] { + const events: KiloSSEEvent[] = []; + const lines = chunk.split('\n'); + + let currentEvent: string | null = null; + let currentData: string[] = []; + + const emitEvent = () => { + if (currentData.length === 0) { + // No data collected, nothing to emit + currentEvent = null; + return; + } + + const dataStr = currentData.join('\n'); + let data: unknown; + try { + data = dataStr ? JSON.parse(dataStr) : {}; + } catch { + // If data isn't valid JSON, pass it as a string + data = dataStr; + } + + // Kilo server sends events in format: data: {"type": "event.name", "properties": {...}} + // without an explicit "event:" field. Extract the event name from the data's "type" field. + let eventName = currentEvent; + if (eventName === null && isRecord(data) && typeof data.type === 'string') { + eventName = data.type; + } + + if (eventName !== null) { + events.push({ event: eventName, data }); + } + + currentEvent = null; + currentData = []; + }; + + for (const line of lines) { + if (line.startsWith('event:')) { + currentEvent = line.slice(6).trim(); + } else if (line.startsWith('data:')) { + currentData.push(line.slice(5).trim()); + } else if (line === '' && currentData.length > 0) { + // Empty line signals end of event (check data.length instead of currentEvent) + emitEvent(); + } + } + + // If flush is requested, emit any pending event without trailing delimiter + if (flush) { + emitEvent(); + } + + return events; +} + +/** + * Create an SSE consumer that connects to the kilo serve /event endpoint + * and forwards events to the provided callback. + */ +export async function createSSEConsumer(opts: SSEConsumerOptions): Promise { + const url = `${opts.baseUrl}/event`; + let active = true; + const abortController = new AbortController(); + + logToFile(`connecting to SSE endpoint: ${url}`); + + // Start consuming in background (fire and forget) + void (async () => { + try { + const response = await fetch(url, { + headers: { + Accept: 'text/event-stream', + }, + signal: abortController.signal, + }); + + if (!response.ok) { + throw new Error(`SSE connection failed: ${response.status} ${response.statusText}`); + } + + if (!response.body) { + throw new Error('SSE response has no body'); + } + + logToFile('SSE connection established'); + opts.onConnected?.(); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let chunkCount = 0; + + while (active) { + const { done, value } = await reader.read(); + + if (done) { + logToFile(`SSE stream ended after ${chunkCount} chunks`); + // Flush any remaining buffer content as a final event + if (buffer.trim()) { + const events = parseSSEChunk(buffer, true); + for (const sseEvent of events) { + opts.onActivity?.(); + const ingestEvent = mapToIngestEvent(sseEvent); + if (ingestEvent) { + opts.onEvent(ingestEvent); + } + } + } + break; + } + + const chunk = decoder.decode(value, { stream: true }); + buffer += chunk; + chunkCount++; + + // Log first few chunks and periodically after that + if (chunkCount <= 3 || chunkCount % 20 === 0) { + logToFile( + `SSE chunk #${chunkCount}: ${chunk.length} bytes, preview=${chunk.slice(0, 100).replace(/\n/g, '\\n')}` + ); + } + + // Process complete events (ended by double newline) + const parts = buffer.split('\n\n'); + buffer = parts.pop() ?? ''; // Keep incomplete part in buffer + + for (const part of parts) { + if (!part.trim()) continue; + + const events = parseSSEChunk(part + '\n\n'); + for (const sseEvent of events) { + logToFile(`SSE event parsed: type=${sseEvent.event}`); + + // Call activity callback for ALL events (including heartbeats) + opts.onActivity?.(); + + // Only forward non-internal events to ingest + const ingestEvent = mapToIngestEvent(sseEvent); + if (ingestEvent) { + opts.onEvent(ingestEvent); + } + } + } + } + + opts.onClose?.('stream ended'); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + opts.onClose?.('aborted'); + } else { + logToFile(`SSE error: ${error instanceof Error ? error.message : String(error)}`); + opts.onError?.(error instanceof Error ? error : new Error(String(error))); + } + } + })(); + + return { + stop: () => { + if (active) { + active = false; + abortController.abort(); + logToFile('SSE consumer stopped'); + } + }, + isActive: () => active, + }; +} + +/** + * Check if an SSE event indicates execution completion. + * This is used to detect when a prompt has finished processing. + */ +export function isCompletionEvent(event: KiloSSEEvent): boolean { + // Look for session status changes that indicate completion + // The exact event names depend on kilo serve's bus events + const completionEvents = [ + 'session.completed', + 'session.idle', + 'message.completed', + 'assistant.completed', + ]; + + return completionEvents.includes(event.event); +} + +/** + * Check if an SSE event indicates an error that should terminate execution. + */ +export function isTerminalErrorEvent(event: KiloSSEEvent): { + isTerminal: boolean; + reason?: string; +} { + // Check for payment/billing related errors + if (event.event === 'payment_required' || event.event === 'insufficient_funds') { + return { isTerminal: true, reason: event.event }; + } + + // Check for API errors in event data + if (isRecord(event.data) && event.data.error) { + const errorStr = String(event.data.error); + if ( + errorStr.includes('payment') || + errorStr.includes('credit') || + errorStr.includes('balance') || + errorStr.includes('quota') + ) { + return { isTerminal: true, reason: errorStr }; + } + } + + return { isTerminal: false }; +} diff --git a/cloud-agent-next/wrapper/src/state.ts b/cloud-agent-next/wrapper/src/state.ts new file mode 100644 index 0000000000..3389e93d43 --- /dev/null +++ b/cloud-agent-next/wrapper/src/state.ts @@ -0,0 +1,364 @@ +/** + * WrapperState - Single source of truth for wrapper state. + * + * All wrapper state is centralized here. Other modules receive a WrapperState + * instance and interact with it through methods. This makes state transitions + * explicit, simplifies testing, and prevents scattered state bugs. + * + * State model: + * - IDLE: inflight.size == 0 (no pending prompt completions) + * - ACTIVE: inflight.size > 0 (one or more prompts waiting for completion) + */ + +import type { IngestEvent } from '../../src/shared/protocol.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface InflightEntry { + messageId: string; + startedAt: number; + deadlineAt: number; +} + +export interface JobContext { + executionId: string; + sessionId: string; + userId: string; + kiloSessionId: string; + ingestUrl: string; + ingestToken: string; + kilocodeToken: string; +} + +export interface LastError { + code: string; + messageId?: string; + message: string; + timestamp: number; +} + +export interface WrapperStatus { + state: 'idle' | 'active'; + executionId?: string; + kiloSessionId?: string; + inflight: string[]; + inflightCount: number; + lastError?: LastError; +} + +// --------------------------------------------------------------------------- +// WrapperState Class +// --------------------------------------------------------------------------- + +export class WrapperState { + // Job context (set on /job/start, cleared on idle timeout) + private job: JobContext | null = null; + + // Inflight prompts (keyed by messageId) + private inflight = new Map(); + + // Connection state - managed externally, stored here for reference + private _ingestWs: WebSocket | null = null; + private _sseAbortController: AbortController | null = null; + + // Activity tracking + private lastActivityAt = Date.now(); + private _lastError: LastError | null = null; + + // SSE activity tracking (separate from general activity - tracks actual SSE events) + private lastSseEventAt = 0; // 0 means no SSE events received yet + + // Message counter for ID generation + private messageCounter = 0; + + // Callbacks for sending events to ingest + private _sendToIngestFn: ((event: IngestEvent) => void) | null = null; + + // --------------------------------------------------------------------------- + // State Queries + // --------------------------------------------------------------------------- + + get isIdle(): boolean { + return this.inflight.size === 0; + } + + get isActive(): boolean { + return this.inflight.size > 0; + } + + get hasJob(): boolean { + return this.job !== null; + } + + get currentJob(): JobContext | null { + return this.job; + } + + get inflightCount(): number { + return this.inflight.size; + } + + get inflightMessageIds(): string[] { + return Array.from(this.inflight.keys()); + } + + // --------------------------------------------------------------------------- + // Job Lifecycle + // --------------------------------------------------------------------------- + + /** + * Start a new job. If a job with the same executionId is already active, + * this is a no-op (idempotent). If a different job is active and has + * inflight prompts, throws an error (caller should return 409). + */ + startJob(context: JobContext): void { + // Idempotent: same executionId returns early + if (this.job && this.job.executionId === context.executionId) { + return; + } + + // Conflict: different job with inflight prompts + if (this.job && this.job.executionId !== context.executionId && this.isActive) { + throw new Error(`Cannot start new job while inflight > 0 (active: ${this.job.executionId})`); + } + + // Start new job + this.job = context; + this._lastError = null; + this.messageCounter = 0; + this.updateActivity(); + } + + /** + * Clear job context. Called on idle timeout or explicit reset. + */ + clearJob(): void { + this.job = null; + this.inflight.clear(); + this.messageCounter = 0; + } + + // --------------------------------------------------------------------------- + // Inflight Management + // --------------------------------------------------------------------------- + + /** + * Add a prompt to the inflight map. + */ + addInflight(messageId: string, deadlineAt: number): void { + this.inflight.set(messageId, { + messageId, + startedAt: Date.now(), + deadlineAt, + }); + this.updateActivity(); + } + + /** + * Remove a prompt from the inflight map. + * Returns true if the messageId was found and removed. + */ + removeInflight(messageId: string): boolean { + const removed = this.inflight.delete(messageId); + if (removed) { + this.updateActivity(); + } + return removed; + } + + /** + * Get all inflight entries that have exceeded their deadline. + */ + getExpiredInflight(now: number): InflightEntry[] { + const expired: InflightEntry[] = []; + for (const entry of this.inflight.values()) { + if (now >= entry.deadlineAt) { + expired.push(entry); + } + } + return expired; + } + + /** + * Clear all inflight entries. Called on abort or disconnect. + */ + clearAllInflight(): void { + this.inflight.clear(); + } + + /** + * Check if a specific messageId is inflight. + */ + hasInflight(messageId: string): boolean { + return this.inflight.has(messageId); + } + + // --------------------------------------------------------------------------- + // Connection Management + // --------------------------------------------------------------------------- + + get isConnected(): boolean { + return this._ingestWs !== null && this._ingestWs.readyState === WebSocket.OPEN; + } + + get ingestWs(): WebSocket | null { + return this._ingestWs; + } + + get sseAbortController(): AbortController | null { + return this._sseAbortController; + } + + /** + * Store connection references. Actual connection management is in connection.ts. + */ + setConnections(ws: WebSocket, sseAbortController: AbortController): void { + this._ingestWs = ws; + this._sseAbortController = sseAbortController; + } + + /** + * Clear connection references and close if open. + */ + clearConnections(): void { + if (this._sseAbortController) { + this._sseAbortController.abort(); + this._sseAbortController = null; + } + if (this._ingestWs) { + try { + this._ingestWs.close(); + } catch { + // Ignore close errors + } + this._ingestWs = null; + } + } + + /** + * Set the function used to send events to ingest. + * This is set by connection.ts when connection is established. + */ + setSendToIngestFn(fn: ((event: IngestEvent) => void) | null): void { + this._sendToIngestFn = fn; + } + + /** + * Send an event to ingest WebSocket. + * Silently drops the event if not connected (events are buffered in ConnectionManager). + */ + sendToIngest(event: IngestEvent): void { + if (!this._sendToIngestFn) { + return; + } + this._sendToIngestFn(event); + } + + // --------------------------------------------------------------------------- + // Activity Tracking + // --------------------------------------------------------------------------- + + /** + * Update last activity timestamp. Called on any meaningful action. + */ + updateActivity(): void { + this.lastActivityAt = Date.now(); + } + + /** + * Get milliseconds since last activity. + */ + getIdleMs(now: number): number { + return now - this.lastActivityAt; + } + + // --------------------------------------------------------------------------- + // SSE Activity Tracking + // --------------------------------------------------------------------------- + + /** + * Record that an SSE event was received. + */ + recordSseEvent(): void { + this.lastSseEventAt = Date.now(); + } + + /** + * Get milliseconds since last SSE event. + * Returns null if no SSE events have been received yet. + */ + getSseInactivityMs(now: number): number | null { + if (this.lastSseEventAt === 0) return null; + return now - this.lastSseEventAt; + } + + /** + * Check if SSE events have ever been received. + */ + hasSseActivity(): boolean { + return this.lastSseEventAt > 0; + } + + // --------------------------------------------------------------------------- + // Error Tracking + // --------------------------------------------------------------------------- + + /** + * Set the last error. This is cached for Worker to poll via /job/status. + */ + setLastError(error: LastError): void { + this._lastError = error; + } + + /** + * Get the last error. + */ + getLastError(): LastError | null { + return this._lastError; + } + + /** + * Clear the last error. + */ + clearLastError(): void { + this._lastError = null; + } + + // --------------------------------------------------------------------------- + // Message ID Generation + // --------------------------------------------------------------------------- + + /** + * Generate the next messageId for this job. + * Format: msg__ + */ + nextMessageId(): string { + if (!this.job) { + throw new Error('No job context - call startJob() first'); + } + this.messageCounter++; + // Strip known prefixes if present + const base = this.job.executionId.replace(/^(exec_|execution_|msg_)/, ''); + return `msg_${base}_${this.messageCounter}`; + } + + // --------------------------------------------------------------------------- + // Status for API Responses + // --------------------------------------------------------------------------- + + /** + * Get current wrapper status for /job/status endpoint. + */ + getStatus(): WrapperStatus { + return { + state: this.isActive ? 'active' : 'idle', + executionId: this.job?.executionId, + kiloSessionId: this.job?.kiloSessionId, + inflight: this.inflightMessageIds, + inflightCount: this.inflightCount, + lastError: this._lastError ?? undefined, + }; + } +} diff --git a/cloud-agent-next/wrapper/src/utils.ts b/cloud-agent-next/wrapper/src/utils.ts new file mode 100644 index 0000000000..4ceabec533 --- /dev/null +++ b/cloud-agent-next/wrapper/src/utils.ts @@ -0,0 +1,39 @@ +import { spawn } from 'child_process'; +import { appendFileSync } from 'fs'; + +export type ExecResult = { + stdout: string; + stderr: string; + exitCode: number; +}; + +export function exec(command: string): Promise { + return new Promise((resolve, reject) => { + const proc = spawn('sh', ['-c', command], { stdio: ['pipe', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + + proc.stdout.on('data', d => (stdout += d)); + proc.stderr.on('data', d => (stderr += d)); + proc.on('exit', code => resolve({ stdout, stderr, exitCode: code ?? 0 })); + proc.on('error', reject); + }); +} + +export async function getCurrentBranch(workspacePath: string): Promise { + try { + const result = await exec(`cd ${workspacePath} && git branch --show-current`); + return result.stdout.trim(); + } catch { + return ''; + } +} + +export function logToFile(message: string): void { + const logPath = process.env.WRAPPER_LOG_PATH || '/tmp/kilocode-wrapper.log'; + try { + appendFileSync(logPath, `${new Date().toISOString()} ${message}\n`); + } catch { + // Ignore logging failures to avoid breaking the wrapper + } +} diff --git a/cloud-agent-next/wrapper/tsconfig.json b/cloud-agent-next/wrapper/tsconfig.json new file mode 100644 index 0000000000..6783e7e9dd --- /dev/null +++ b/cloud-agent-next/wrapper/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "./dist", + "rootDir": "..", + "declaration": false, + "noEmit": true, + "types": ["bun", "node"] + }, + "include": ["src/**/*", "../src/shared/**/*"], + "exclude": ["node_modules", "dist"] +} From f32253097d6238261807edbe77d158cc4c7ec6d9 Mon Sep 17 00:00:00 2001 From: syn Date: Wed, 4 Feb 2026 11:46:33 -0600 Subject: [PATCH 3/3] Drop container limit during dev, need to ask CF for a bump --- cloud-agent-next/wrangler.jsonc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloud-agent-next/wrangler.jsonc b/cloud-agent-next/wrangler.jsonc index f660d8536a..922507755b 100644 --- a/cloud-agent-next/wrangler.jsonc +++ b/cloud-agent-next/wrangler.jsonc @@ -113,7 +113,7 @@ "image_vars": { "KILOCODE_CLI_VERSION": "v0.26.0", }, - "max_instances": 200, + "max_instances": 10, "rollout_active_grace_period": 1800, }, ],