diff --git a/.agents/skills/testing-multi-worker-dev/SKILL.md b/.agents/skills/testing-multi-worker-dev/SKILL.md new file mode 100644 index 0000000000..c4a2805cf8 --- /dev/null +++ b/.agents/skills/testing-multi-worker-dev/SKILL.md @@ -0,0 +1,75 @@ +--- +name: testing-multi-worker-dev +description: + How to run and test kody's multi-worker local dev (main kody worker + + kody-runtime secondary config), including known wrangler multi-config pitfalls + (secondary worker env-name suffix, remote AI binding, .env secrets not + propagated) and how to verify runtime-worker forwarding. +--- + +# Testing multi-worker local dev (kody + kody-runtime) + +## Basics + +- Node 26 required: `export PATH="$HOME/.nvm/versions/node/v26.7.0/bin:$PATH"`. +- Run `npm run dev` in tmux; it starts the client watcher, the mock Cloudflare + API worker, and `wrangler dev --local` with TWO `--config`s (main + `packages/worker/wrangler.jsonc` + `packages/runtime-worker/wrangler.jsonc`) + in one miniflare. Default port 3742; the CLI picks the next free port if taken + — stale `workerd` processes commonly hold 3742, so `pkill -9 workerd` before + restarting. +- Local dev uses `--env production` (CLOUDFLARE_ENV defaults to production in + `wrangler-env.ts`). +- Migrate + seed login: `npm run migrate:local` then + `node tools/seed-test-data.ts --local` → `kody@example.com` / `ilikecode` + (admin) and `jane@example.com` / `ilikecode`. +- Healthchecks: main `GET /health` → `{"ok":true,...}`; runtime worker serves + `GET /__runtime/health` (only reachable through the service binding locally — + hitting it on the main port should 404). + +## Known wrangler multi-config pitfalls (handled by a generated dev config) + +Wrangler applies `--local`, `--var`, and `.env`-derived secrets only to the +PRIMARY config, registers each worker under `-`, and treats a +secondary config's `ai` binding as always-remote (dev fails to boot with "Failed +to start the remote proxy session"; `"remote": false` is NOT enough). + +`wrangler-env.ts` therefore never passes the committed runtime config to +`wrangler dev` directly: `tools/local-runtime-dev-config.ts` generates +`packages/runtime-worker/wrangler-local-dev.generated.json` (gitignored) on each +dev start, which pins the secondary's registered name to `kody-runtime`, +rewrites its `script_name: "kody"` refs to the primary's dev name +(`kody-`), drops the `ai` binding, and injects `APP_BASE_URL`, +`COOKIE_SECRET`, `SECRET_STORE_KEY`, and `WRANGLER_IS_LOCAL_DEV` from the dev +process env. If runtime-owned paths 503 with `Worker "kody-runtime" not found` +or the runtime worker 500s on missing vars, inspect that generated file first. + +## Jobs-worker extraction pitfall (boot failure) + +After the jobs-worker extraction (ADR 0016), `npm run dev` may fail with +`service core:user:kody-production: Uncaught TypeError: Class extends value undefined is not a constructor or null` +(in miniflare's `createDurableObjectWrapper`) and "The Workers runtime failed to +start". Root cause: local dev (`--env production`) inherits the TOP-LEVEL +migrations chain in `packages/worker/wrangler.jsonc`, which still nets out +`JobManager` as a live DO class on the main script (v6 adds +JobManager+JobRunner, v9 only deletes JobRunner) — but `JobManager` is no longer +exported from `packages/worker/src/index.ts` (it moved to +`packages/jobs-worker`, which uses a `transferred_classes` migration from +`kody-production`). Miniflare replays the full chain fresh and tries to wrap the +missing class. The fix is a migration chain without the v6/v9 pair (matching +`preview`/`test`); if the error reappears, check that the top-level chain in +`packages/worker/wrangler.jsonc` does not create classes the main script no +longer exports. + +## Verifying forwarding + +- Unauthenticated `GET /@/packages/` on the main port: 302 → + `/login` proves the runtime lane handled it (broken binding gives 503 + instead). +- `POST /@/api/package-invocations/x/y` without bearer → 401 + `{"ok":false,"error":{"code":"unauthorized"}}` from the runtime worker. +- Binding status line in dev output: + `env.RUNTIME_WORKER (...) Worker local [connected]` vs `[not connected]`. +- Beware: logged-in package-app requests redirect to the real + `https://kodyapps.dev` (production `PACKAGE_APP_BASE_URL`) — don't follow the + handoff into prod. diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a8b447c645..d69e1e26d3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -244,7 +244,7 @@ jobs: deploy: runs-on: ubuntu-latest name: 🚀 Deploy to production - timeout-minutes: 8 + timeout-minutes: 12 needs: - sha-guard - deploy-jobs-worker @@ -290,6 +290,20 @@ jobs: set -euo pipefail node tools/ci/production-resources.ts ensure --out-config packages/worker/wrangler-production.generated.json | tee -a "$GITHUB_OUTPUT" + # Package runtime worker (ADR 0016). Shares the main worker's data + # plane, so it derives resource ids from the generated main config and + # patches the main config's cross-script references in place. + - name: 🧩 Generate runtime worker config + id: runtime_config + run: | + set -euo pipefail + node tools/ci/runtime-worker-config.ts generate \ + --env production \ + --main-config packages/worker/wrangler-production.generated.json \ + --worker-name kody-runtime \ + --main-worker-name kody-production \ + --out-config packages/runtime-worker/wrangler-production.generated.json | tee -a "$GITHUB_OUTPUT" + - name: 🔐 Sync Cloudflare Secrets (bulk) env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} @@ -322,6 +336,29 @@ jobs: --set-from-env-optional STRIPE_SECRET_KEY --set-from-env-optional STRIPE_WEBHOOK_SECRET + # Runtime-lane secret allowlist (ADR 0016): only the secrets the + # package runtime lane actually reads. COOKIE_SECRET/SECRET_STORE_KEY + # (app-origin sessions + user secret store), AI_GATEWAY_ID (capability + # embeddings), SENTRY_DSN (error reporting), CLOUDFLARE_API_TOKEN + # (Cloudflare REST capabilities). Billing (Stripe), waiting-list (Kit), + # OAuth client credentials, and maintenance secrets stay main-only so a + # runtime-Worker compromise cannot reach them. + - name: 🔐 Sync Cloudflare Secrets to runtime worker (bulk) + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + WRANGLER_CONFIG: + ${{ steps.runtime_config.outputs.runtime_wrangler_config }} + COOKIE_SECRET: ${{ secrets.COOKIE_SECRET }} + SECRET_STORE_KEY: ${{ secrets.SECRET_STORE_KEY }} + AI_GATEWAY_ID: ${{ secrets.AI_GATEWAY_ID }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + run: > + node tools/ci/sync-worker-secrets.ts --env production --name + kody-runtime --config "$WRANGLER_CONFIG" --set-from-env COOKIE_SECRET + --set-from-env-optional SECRET_STORE_KEY --set-from-env-optional + AI_GATEWAY_ID --set-from-env-optional SENTRY_DSN + --set-from-env-optional CLOUDFLARE_API_TOKEN + - name: 🗄️ Apply D1 Migrations env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} @@ -330,6 +367,69 @@ jobs: node ./wrangler-env.ts d1 migrations apply APP_DB --remote --config "$WRANGLER_CONFIG" node ./wrangler-env.ts d1 migrations apply AUDIT_DB --remote --config "$WRANGLER_CONFIG" + # Deploys before the main worker: the main worker's cross-script + # Durable Object / service bindings require the kody-runtime script to + # exist. On the first production deploy this also runs the + # transferred_classes migration; see + # docs/contributing/architecture/runtime-worker-migration-runbook.md. + - name: ☁️ Deploy runtime worker to Cloudflare Workers + id: deploy_runtime + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + DEPLOY_COMMIT_SHA: ${{ needs.sha-guard.outputs.deploy_sha }} + WRANGLER_CONFIG: + ${{ steps.runtime_config.outputs.runtime_wrangler_config }} + run: | + set -euo pipefail + + is_retryable_deploy_failure() { + node <<'NODE' + const fs = require('node:fs') + const text = fs.readFileSync('deploy-runtime.log', 'utf8').replace(/\u001b\[[0-9;]*m/g, '') + const retryablePatterns = [ + /workflows\.api\.error\.internal_server/i, + /Cloudflare API[\s\S]*\[code:\s*10001\]/i, + ] + process.exit(retryablePatterns.some((pattern) => pattern.test(text)) ? 0 : 1) + NODE + } + + deploy_attempts=3 + deploy_delay_seconds=10 + for attempt in $(seq 1 "$deploy_attempts"); do + echo "Runtime deploy attempt $attempt/$deploy_attempts" + set +e + # Explicit --name: without it wrangler appends the environment to + # the script name (kody-runtime-production), which would not match + # the main worker's cross-script bindings. + npm run deploy -- --name kody-runtime --config "$WRANGLER_CONFIG" --var "APP_COMMIT_SHA:${DEPLOY_COMMIT_SHA}" 2>&1 | tee deploy-runtime.log + deploy_status="${PIPESTATUS[0]}" + set -e + + if [ "$deploy_status" -eq 0 ]; then + break + fi + + if [ "$attempt" -eq "$deploy_attempts" ]; then + echo "Runtime deploy failed after $deploy_attempts attempts." >&2 + exit "$deploy_status" + fi + + if ! is_retryable_deploy_failure; then + echo "Runtime deploy failed with a non-retryable error." >&2 + exit "$deploy_status" + fi + + echo "Retryable Cloudflare deploy failure detected; retrying in ${deploy_delay_seconds}s." + sleep "$deploy_delay_seconds" + deploy_delay_seconds=$((deploy_delay_seconds * 2)) + done + + RUNTIME_URL="$(node -e 'const fs = require("node:fs"); const text = fs.readFileSync(process.argv[1], "utf8").replace(/\u001b\[[0-9;]*m/g, ""); const worker = process.argv[2]; const escaped = worker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const workerUrlRegex = new RegExp(`https://(?:[a-zA-Z0-9-]+\\.)?${escaped}\\.[a-zA-Z0-9._-]+\\.workers\\.dev`, "g"); const matches = text.match(workerUrlRegex); process.stdout.write(matches?.at(-1) ?? "")' deploy-runtime.log kody-runtime)" + if [ -n "$RUNTIME_URL" ]; then + echo "url=$RUNTIME_URL" >> "$GITHUB_OUTPUT" + fi + - name: ☁️ Deploy to Cloudflare Workers id: deploy env: @@ -446,6 +546,48 @@ jobs: fi exit 1 + - name: 🩺 Healthcheck runtime worker (production) + shell: bash + env: + RUNTIME_URL: ${{ steps.deploy_runtime.outputs.url }} + EXPECTED_COMMIT_SHA: ${{ needs.sha-guard.outputs.deploy_sha }} + run: | + set -euo pipefail + + if [ -z "${RUNTIME_URL:-}" ]; then + echo "Missing runtime worker deploy URL output; cannot run healthcheck." >&2 + exit 1 + fi + + if [ -z "${EXPECTED_COMMIT_SHA:-}" ]; then + echo "Missing expected commit SHA; cannot verify runtime deployment version." >&2 + exit 1 + fi + + HEALTHCHECK_URL="${RUNTIME_URL%/}/__runtime/health" + echo "Healthcheck URL: $HEALTHCHECK_URL" + + attempts=20 + delay_seconds=3 + + for i in $(seq 1 "$attempts"); do + echo "Attempt $i/$attempts" + if curl --fail --silent --show-error --location --max-time 10 \ + --header "Accept: application/json" \ + "$HEALTHCHECK_URL" > runtime-health.json && \ + node -e "const fs = require('node:fs'); const json = JSON.parse(fs.readFileSync('runtime-health.json','utf8')); const expected = process.env.EXPECTED_COMMIT_SHA; if (json?.status !== 'ok') { console.error('runtime-healthcheck-unexpected-response', json); process.exit(1); } if (json?.commitSha !== expected) { console.error('runtime-healthcheck-unexpected-commit-sha', { expected, actual: json?.commitSha }); process.exit(1); } console.log('runtime-healthcheck-ok', json);"; then + exit 0 + fi + sleep "$delay_seconds" + done + + echo "Runtime worker healthcheck failed after ${attempts} attempts: $HEALTHCHECK_URL" >&2 + if [ -f runtime-health.json ]; then + echo "Last response body:" >&2 + cat runtime-health.json >&2 + fi + exit 1 + - name: 🧪 Execute smoke check (production) shell: bash env: diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 9ccfbb1ccb..d7795ff83d 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -52,7 +52,7 @@ jobs: deploy: runs-on: ubuntu-latest name: 🔎 Deploy Preview Resources - timeout-minutes: 4 + timeout-minutes: 8 environment: name: preview-${{ github.event.pull_request.number || inputs.pr_number || @@ -138,6 +138,27 @@ jobs: set -euo pipefail node tools/ci/preview-resources.ts ensure --worker-name "$APP_WORKER_NAME" --out-config packages/worker/wrangler-preview.generated.json | tee -a "$GITHUB_OUTPUT" + # Package runtime worker (ADR 0016). Shares the preview data plane, so + # its config derives resource ids from the main generated config. The + # bootstrap variant of the main config breaks the fresh-pair circular + # reference (main binds runtime's Durable Objects, runtime binds + # main's): deploy order is main (bootstrap) → runtime → main (full). + - name: 🧩 Generate runtime worker preview config + id: runtime_config + env: + APP_WORKER_NAME: ${{ steps.names.outputs.worker_name }} + WRANGLER_CONFIG: ${{ steps.resources.outputs.wrangler_config }} + run: | + set -euo pipefail + node tools/ci/runtime-worker-config.ts generate \ + --env preview \ + --main-config "$WRANGLER_CONFIG" \ + --worker-name "${APP_WORKER_NAME}-runtime" \ + --main-worker-name "$APP_WORKER_NAME" \ + --out-config packages/runtime-worker/wrangler-preview.generated.json \ + --out-main-bootstrap-config packages/worker/wrangler-preview-bootstrap.generated.json | tee -a "$GITHUB_OUTPUT" + echo "runtime_worker_name=${APP_WORKER_NAME}-runtime" >> "$GITHUB_OUTPUT" + - name: 🗄️ Apply D1 Migrations (preview) env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} @@ -275,6 +296,41 @@ jobs: # worker's JOBS service binding. node ./wrangler-env.ts deploy --config "$WRANGLER_CONFIG" --name "${APP_WORKER_NAME}-jobs" --var "APP_COMMIT_SHA:${DEPLOY_COMMIT_SHA}" + - name: ☁️ Bootstrap preview app Worker (no runtime references) + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_ENV: preview + APP_WORKER_NAME: ${{ steps.names.outputs.worker_name }} + WRANGLER_CONFIG: + ${{ steps.runtime_config.outputs.main_bootstrap_wrangler_config }} + DEPLOY_COMMIT_SHA: ${{ github.sha }} + run: | + set -euo pipefail + npm run deploy -- --name "$APP_WORKER_NAME" --config "$WRANGLER_CONFIG" --var "APP_COMMIT_SHA:${DEPLOY_COMMIT_SHA}" + + - name: ☁️ Deploy preview runtime Worker + id: deploy_runtime + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_ENV: preview + RUNTIME_WORKER_NAME: + ${{ steps.runtime_config.outputs.runtime_worker_name }} + WRANGLER_CONFIG: + ${{ steps.runtime_config.outputs.runtime_wrangler_config }} + DEPLOY_COMMIT_SHA: ${{ github.sha }} + run: | + set -euo pipefail + echo "worker_name=$RUNTIME_WORKER_NAME" >> "$GITHUB_OUTPUT" + + npm run deploy -- --name "$RUNTIME_WORKER_NAME" --config "$WRANGLER_CONFIG" --var "APP_COMMIT_SHA:${DEPLOY_COMMIT_SHA}" 2>&1 | tee deploy-runtime.log + + RUNTIME_URL="$(node -e 'const fs = require("node:fs"); const text = fs.readFileSync(process.argv[1], "utf8").replace(/\u001b\[[0-9;]*m/g, ""); const worker = process.argv[2]; const escaped = worker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const workerUrlRegex = new RegExp(`https://(?:[a-zA-Z0-9-]+\\.)?${escaped}\\.[a-zA-Z0-9._-]+\\.workers\\.dev`, "g"); const matches = text.match(workerUrlRegex); process.stdout.write(matches?.at(-1) ?? "")' deploy-runtime.log "$RUNTIME_WORKER_NAME")" + if [ -n "$RUNTIME_URL" ]; then + echo "url=$RUNTIME_URL" >> "$GITHUB_OUTPUT" + fi + - name: ☁️ Deploy preview app Worker id: deploy_preview env: @@ -319,6 +375,10 @@ jobs: APP_WORKER_NAME: ${{ steps.names.outputs.worker_name }} OVERRIDES_FILE: ${{ steps.deploy_mocks.outputs.overrides_file }} WRANGLER_CONFIG: ${{ steps.resources.outputs.wrangler_config }} + RUNTIME_WORKER_NAME: + ${{ steps.runtime_config.outputs.runtime_worker_name }} + RUNTIME_WRANGLER_CONFIG: + ${{ steps.runtime_config.outputs.runtime_wrangler_config }} PREVIEW_URL: ${{ steps.deploy_preview.outputs.url }} AI_GATEWAY_ID: ${{ secrets.AI_GATEWAY_ID_PREVIEW }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }} @@ -339,9 +399,23 @@ jobs: extra_args+=(--set "APP_BASE_URL=$PREVIEW_URL") fi + # Both workers must share the session-signing and secret-store keys: + # the runtime worker reads app-origin session cookies for inline + # package apps and package-app handoffs. + PREVIEW_COOKIE_SECRET="$(openssl rand -hex 32)" + echo "::add-mask::$PREVIEW_COOKIE_SECRET" + extra_args+=(--set "COOKIE_SECRET=$PREVIEW_COOKIE_SECRET") + # Intentionally omit KIT_API_KEY so preview/E2E waiting-list joins use # the non-production no-op path instead of writing to production Kit. - node tools/ci/sync-worker-secrets.ts --env "" --name "$APP_WORKER_NAME" --config "$WRANGLER_CONFIG" --from-dotenv "$ENV_FILE" --from-dotenv "$OVERRIDES_FILE" --set-from-env-optional AI_GATEWAY_ID --set-from-env-optional SENTRY_DSN --generate-cookie-secret --include-empty "${extra_args[@]}" + node tools/ci/sync-worker-secrets.ts --env "" --name "$APP_WORKER_NAME" --config "$WRANGLER_CONFIG" --from-dotenv "$ENV_FILE" --from-dotenv "$OVERRIDES_FILE" --set-from-env-optional AI_GATEWAY_ID --set-from-env-optional SENTRY_DSN --include-empty "${extra_args[@]}" + # The runtime worker intentionally reuses the same dotenv-based sync + # here (unlike production, which uses a strict runtime-lane + # allowlist): the preview values are .env.example placeholders plus + # per-PR mock-endpoint overrides — capability execution on the + # runtime worker needs the mock endpoints, and no production secret + # is present in either file. + node tools/ci/sync-worker-secrets.ts --env "" --name "$RUNTIME_WORKER_NAME" --config "$RUNTIME_WRANGLER_CONFIG" --from-dotenv "$ENV_FILE" --from-dotenv "$OVERRIDES_FILE" --set-from-env-optional AI_GATEWAY_ID --set-from-env-optional SENTRY_DSN --include-empty "${extra_args[@]}" - name: 📤 Upload Worker source maps to Sentry env: @@ -410,6 +484,48 @@ jobs: fi exit 1 + - name: 🩺 Healthcheck runtime worker (preview) + shell: bash + env: + RUNTIME_URL: ${{ steps.deploy_runtime.outputs.url }} + EXPECTED_COMMIT_SHA: ${{ github.sha }} + run: | + set -euo pipefail + + if [ -z "${RUNTIME_URL:-}" ]; then + echo "Missing runtime worker preview URL output; cannot run healthcheck." >&2 + exit 1 + fi + + if [ -z "${EXPECTED_COMMIT_SHA:-}" ]; then + echo "Missing expected commit SHA; cannot verify runtime deployment version." >&2 + exit 1 + fi + + HEALTHCHECK_URL="${RUNTIME_URL%/}/__runtime/health" + echo "Healthcheck URL: $HEALTHCHECK_URL" + + attempts=30 + delay_seconds=3 + + for i in $(seq 1 "$attempts"); do + echo "Attempt $i/$attempts" + if curl --fail --silent --show-error --location --max-time 10 \ + --header "Accept: application/json" \ + "$HEALTHCHECK_URL" > runtime-health.json && \ + node -e "const fs = require('node:fs'); const json = JSON.parse(fs.readFileSync('runtime-health.json','utf8')); const expected = process.env.EXPECTED_COMMIT_SHA; if (json?.status !== 'ok') { console.error('runtime-healthcheck-unexpected-response', json); process.exit(1); } if (json?.commitSha !== expected) { console.error('runtime-healthcheck-unexpected-commit-sha', { expected, actual: json?.commitSha }); process.exit(1); } console.log('runtime-healthcheck-ok', json);"; then + exit 0 + fi + sleep "$delay_seconds" + done + + echo "Runtime worker healthcheck failed after ${attempts} attempts: $HEALTHCHECK_URL" >&2 + if [ -f runtime-health.json ]; then + echo "Last response body:" >&2 + cat runtime-health.json >&2 + fi + exit 1 + - name: 🧾 Write preview link summary if: steps.deploy_preview.outputs.url != '' env: @@ -417,6 +533,8 @@ jobs: PREVIEW_VERSION_URL: '${{ steps.deploy_preview.outputs.version_preview_url }}' WORKER_NAME: ${{ steps.deploy_preview.outputs.worker_name }} + RUNTIME_WORKER_NAME: ${{ steps.deploy_runtime.outputs.worker_name }} + RUNTIME_URL: ${{ steps.deploy_runtime.outputs.url }} MOCK_SUMMARY: ${{ steps.deploy_mocks.outputs.mock_summary }} D1_DATABASE_NAME: ${{ steps.resources.outputs.d1_database_name }} OAUTH_KV_TITLE: ${{ steps.resources.outputs.oauth_kv_title }} @@ -427,6 +545,9 @@ jobs: echo "$PREVIEW_URL" echo "" printf 'Worker: `%s`\n' "$WORKER_NAME" + if [ -n "${RUNTIME_URL:-}" ]; then + printf 'Runtime worker: `%s` (%s)\n' "$RUNTIME_WORKER_NAME" "$RUNTIME_URL" + fi if [ -n "${PREVIEW_VERSION_URL:-}" ]; then printf 'Version preview: %s\n' "$PREVIEW_VERSION_URL" fi @@ -453,6 +574,8 @@ jobs: PREVIEW_VERSION_URL: '${{ steps.deploy_preview.outputs.version_preview_url }}' WORKER_NAME: ${{ steps.deploy_preview.outputs.worker_name }} + RUNTIME_WORKER_NAME: ${{ steps.deploy_runtime.outputs.worker_name }} + RUNTIME_URL: ${{ steps.deploy_runtime.outputs.url }} MOCK_SUMMARY: ${{ steps.deploy_mocks.outputs.mock_comment_summary }} D1_DATABASE_NAME: ${{ steps.resources.outputs.d1_database_name }} OAUTH_KV_TITLE: ${{ steps.resources.outputs.oauth_kv_title }} @@ -465,6 +588,11 @@ jobs: "", `Worker: \`${process.env.WORKER_NAME}\``, ]; + if (process.env.RUNTIME_URL?.trim()) { + bodyLines.push( + `Runtime worker: \`${process.env.RUNTIME_WORKER_NAME}\` (${process.env.RUNTIME_URL.trim()})`, + ); + } if (process.env.PREVIEW_VERSION_URL?.trim()) { bodyLines.push( `Version preview: ${process.env.PREVIEW_VERSION_URL.trim()}`, diff --git a/.gitignore b/.gitignore index f76e8bf025..b03698afe9 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ packages/worker/public/client-manifest.json packages/worker/wrangler-preview.generated.json packages/worker/wrangler-production.generated.json packages/worker/src/generated/ +packages/runtime-worker/wrangler-local-dev.generated.json diff --git a/docs/contributing/architecture/index.md b/docs/contributing/architecture/index.md index 377aa16df6..0ae876101b 100644 --- a/docs/contributing/architecture/index.md +++ b/docs/contributing/architecture/index.md @@ -42,6 +42,10 @@ to become. - [Run records](./run-records.md): per-user execution history and logs across every runtime surface (`RunLog` Durable Object, `runs` MCP domain, `/account/activity`). +- [Runtime worker migration runbook](./runtime-worker-migration-runbook.md): the + coordinated deploy order and Durable Object script migration for extracting + the package runtime lane into the `kody-runtime` Worker + ([ADR 0016](../decisions/0016-mono-worker-extraction.md)). - [Primitives map](./primitives.yaml): stable taxonomy of system primitives and invariants for the visual-recap skill (`.agents/skills/visual-recap/SKILL.md`). It is not a living feature changelog diff --git a/docs/contributing/architecture/runtime-worker-migration-runbook.md b/docs/contributing/architecture/runtime-worker-migration-runbook.md new file mode 100644 index 0000000000..5084728069 --- /dev/null +++ b/docs/contributing/architecture/runtime-worker-migration-runbook.md @@ -0,0 +1,172 @@ +# Runtime worker migration runbook + +How to move the package runtime lane from the main `kody` Worker into the +`kody-runtime` Worker (`packages/runtime-worker/`) in production, per +[ADR 0016](../decisions/0016-mono-worker-extraction.md). The genuinely risky +step is the one-time Durable Object **script migration**: the storage of +`StorageRunner`, `RunLog`, `PackageRealtimeSession`, and +`PackageServiceInstance` moves from the `kody` script to the `kody-runtime` +script via a Wrangler `transferred_classes` migration. + +> **Warning:** the implementation session that authored this document must NOT +> execute these production steps. The runbook is executed by an operator (the +> parent session) after preview verification and PR merge. + +## Ownership after the split + +| Concern | Owner | +| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| Package-app origin (`PACKAGE_APP_BASE_URL`, `kodyapps.dev`) custom domain | `kody-runtime` | +| Inline package-app serving (`/apps/...` on the app origin) | `kody-runtime` (forwarded by main via the `RUNTIME_WORKER` service binding) | +| Package invocation API | `kody-runtime` (forwarded by main) | +| `DynamicCallableWorkflow` (Cloudflare Workflow) | `kody-runtime` (main binds it cross-script) | +| `StorageRunner`, `RunLog`, `PackageRealtimeSession`, `PackageServiceInstance` | `kody-runtime` (main binds them cross-script) | +| `UserMeter` and every other Durable Object, app, MCP, OAuth, email, jobs | `kody` (runtime binds what it needs cross-script) | +| `APP_DB` / `AUDIT_DB` / KV / R2 / queues / Vectorize / AI | Shared resources; each worker binds directly (no RPC proxying) | + +## Migration configuration + +Committed in `packages/runtime-worker/wrangler.jsonc` (production applies it on +the first `kody-runtime` deploy; the exact transfer set is protected by +`tools/ci/durable-object-baseline.json`). The committed `from_script: "kody"` is +rewritten by `tools/ci/runtime-worker-config.ts` to the actual deployed main +script name (`kody-production` — wrangler appends the environment to the +top-level name) at deploy time: + +```jsonc +"migrations": [ + { + "tag": "v1", + "transferred_classes": [ + { "from": "StorageRunner", "from_script": "kody", "to": "StorageRunner" }, + { "from": "RunLog", "from_script": "kody", "to": "RunLog" }, + { "from": "PackageRealtimeSession", "from_script": "kody", "to": "PackageRealtimeSession" }, + { "from": "PackageServiceInstance", "from_script": "kody", "to": "PackageServiceInstance" } + ] + } +] +``` + +Cloudflare requirements for a `transferred_classes` migration: + +- The source script (`kody-production`) must still exist and must still contain + the migration history that created the classes; the transfer removes them from + the source script's Durable Object namespace registry. +- The destination script must export classes under the `to` names. +- The source script must stop exporting/serving the classes in the **same + coordinated deploy window** — after the transfer, DO requests routed through + the old script's namespaces fail. +- Existing DO ids, storage, and alarms move with the class. In-flight requests + against the old namespace during the switch can error; run at a quiet time. + +## Workflow cutover caveat + +`DynamicCallableWorkflow` moves to `kody-runtime` as a **new** Cloudflare +Workflow (`kody-runtime-dynamic-callable-workflows`) — Workflows cannot be +transferred between scripts. Any workflow instances still running on the old +`kody`-owned workflow at cutover are orphaned once the main worker stops +exporting a workflow binding for them; deploy at a quiet time and treat +in-flight dynamic callable runs at the cutover moment as lost (they surface as +failed runs). + +## Coordinated production deploy order + +The merged main-branch deploy workflow (`.github/workflows/deploy.yml`) encodes +this order; the operator's job is to merge and watch, not to run wrangler by +hand. + +1. **Preview verification (before merging).** The PR's preview deploy creates a + fresh worker pair (`kody-pr-` and `kody-pr--runtime`). Verify: + - both healthchecks passed in the preview workflow (`/health` on main, + `/__runtime/health` on runtime); + - a package app loads on the preview origin (`/apps//`); + - a package invocation runs end to end (create a run, watch its RunLog stream + complete). Preview cannot rehearse the `transferred_classes` migration + itself (preview pairs are created fresh with `new_sqlite_classes`); the + transfer is exercised for the first time in production, which is why the + deploy order below matters. +2. **Detach the `kodyapps.dev` custom domain from `kody` (one-time, just before + merging).** Cloudflare refuses to assign a custom domain that another Worker + still owns, so the first `kody-runtime` deploy fails while `kody` holds + `kodyapps.dev`. In the Cloudflare dashboard (Workers & Pages → `kody` → + Settings → Domains & Routes) remove the `kodyapps.dev` custom domain, then + merge promptly — between the detach and the runtime deploy's domain attach, + package-app traffic on `kodyapps.dev` is unserved. This is only needed on the + first production deploy; later deploys find the domain already on + `kody-runtime`. +3. **Merge the PR.** The production deploy workflow then: + 1. generates the runtime config from the provisioned main config + (`tools/ci/runtime-worker-config.ts`); + 2. syncs secrets to both workers; + 3. applies D1 migrations (shared databases, applied once); + 4. **deploys `kody-runtime` first** — this applies the `v1` + `transferred_classes` migration, moving the four classes' storage out of + `kody`. From this moment the still-running old `kody` deployment serves + runtime traffic against namespaces that have moved; the window until step + 5 completes must be short and is why the two deploys are consecutive steps + in one job. There is deliberately no maintenance-mode traffic gate: the + accepted risk (per ADR 0016) is that runtime requests hitting the old + `kody` deployment during this seconds-long window error and surface as + failed runs/invocations, which is why the deploy runs at a quiet time; + 5. **deploys `kody` second** with the config that binds the four classes + cross-script (`script_name: "kody-runtime"`) plus the `RUNTIME_WORKER` + service binding, and stops routing runtime requests in-process; + 6. healthchecks `kody` (`/health`) and `kody-runtime` (`/__runtime/health`), + then runs the execute smoke check. +4. **Post-deploy verification.** + - Load a production package app on `https://kodyapps.dev`. + - Run a package invocation; confirm the run appears with streaming logs + (proves RunLog storage transferred, not recreated empty). + - Open an existing pre-migration run's logs (proves old DO storage moved). + - Check Sentry for both workers. + +## Manual execution (only if the workflow cannot run) + +From a checkout of the merged commit, with `CLOUDFLARE_API_TOKEN` set (plus the +secret values used below — mirror the `🔐 Sync Cloudflare Secrets` steps in +`.github/workflows/deploy.yml`, which are the source of truth for the exact +secret lists). The steps mirror the workflow order: generate configs, sync +secrets to both workers, apply D1 migrations, then deploy runtime → main. + +```sh +node tools/ci/production-resources.ts ensure --out-config packages/worker/wrangler-production.generated.json +node tools/ci/runtime-worker-config.ts generate \ + --env production \ + --main-config packages/worker/wrangler-production.generated.json \ + --worker-name kody-runtime \ + --main-worker-name kody \ + --out-config packages/runtime-worker/wrangler-production.generated.json + +# Sync secrets to both workers (full secret set to main; runtime-lane +# allowlist to kody-runtime — see deploy.yml for the current lists): +node tools/ci/sync-worker-secrets.ts --env production \ + --config packages/worker/wrangler-production.generated.json \ + --set-from-env COOKIE_SECRET ... # copy the main-worker flag list from deploy.yml +node tools/ci/sync-worker-secrets.ts --env production \ + --config packages/runtime-worker/wrangler-production.generated.json \ + --set-from-env COOKIE_SECRET \ + --set-from-env-optional SECRET_STORE_KEY \ + --set-from-env-optional AI_GATEWAY_ID \ + --set-from-env-optional SENTRY_DSN \ + --set-from-env-optional CLOUDFLARE_API_TOKEN + +# Apply shared D1 migrations once (both workers bind the same databases): +node ./wrangler-env.ts d1 migrations apply APP_DB --remote --config packages/worker/wrangler-production.generated.json +node ./wrangler-env.ts d1 migrations apply AUDIT_DB --remote --config packages/worker/wrangler-production.generated.json + +npm run deploy -- --config packages/runtime-worker/wrangler-production.generated.json +npm run deploy -- --config packages/worker/wrangler-production.generated.json +``` + +## Rollback + +- **Runtime deploy failed before the migration applied:** nothing moved; the old + `kody` deployment still owns the classes. Fix and retry. +- **After the transfer applied:** roll _forward_ on the runtime worker (redeploy + a fixed `kody-runtime`). Do not attempt to transfer the classes back to `kody` + under incident pressure — a reverse `transferred_classes` migration is + possible but is a second risky migration; it needs its own reviewed config + change (the deploy guardrails intentionally refuse unreviewed migration + edits). +- The main worker can always be rolled back independently as long as its config + keeps the cross-script bindings. diff --git a/package.json b/package.json index 04749ccdae..0ce8411aac 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "backup:deploy": "wrangler deploy --config packages/backup-control-plane/wrangler.jsonc", "status:build": "wrangler deploy --dry-run --config packages/status/wrangler.jsonc", "status:deploy": "wrangler deploy --config packages/status/wrangler.jsonc", + "runtime:build": "node ./wrangler-env.ts deploy --dry-run --outdir .wrangler/runtime-dry-run --config packages/runtime-worker/wrangler.jsonc", "jobs:build": "wrangler deploy --dry-run --outdir .tmp/jobs-worker-build --config packages/jobs-worker/wrangler.jsonc --env production", "jobs:deploy": "wrangler deploy --config packages/jobs-worker/wrangler.jsonc --env production", "backup:readiness": "node tools/disaster-recovery/canonical-readiness-cli.ts", @@ -63,7 +64,7 @@ "test:workers": "nx run worker:test-workers", "test:push": "CI=1 npm run test && CI=1 npm run test:e2e:run", "inspect": "npx -y @mcpjam/inspector inspector", - "validate": "concurrently -n format,lint,typecheck,test,e2e,mcp,backup-build,status-build,jobs-build,primitives,migrations,deploy-guardrails,docs -c green,yellow,magenta,blue,cyan,red,blueBright,yellowBright,cyanBright,white,gray,redBright,greenBright \"npm run format:check\" \"npm run lint\" \"npm run typecheck\" \"CI=1 npm run test\" \"CI=1 npm run test:e2e:run\" \"npm run test:mcp\" \"npm run backup:build\" \"npm run status:build\" \"npm run jobs:build\" \"npm run primitives:check\" \"npm run migrations:check\" \"npm run deploy-guardrails:check\" \"npm run docs:check-temporal\"", + "validate": "concurrently -n format,lint,typecheck,test,e2e,mcp,backup-build,status-build,jobs-build,runtime-build,primitives,migrations,deploy-guardrails,docs -c green,yellow,magenta,blue,cyan,red,blueBright,yellowBright,cyanBright,cyan,white,gray,redBright,greenBright \"npm run format:check\" \"npm run lint\" \"npm run typecheck\" \"CI=1 npm run test\" \"CI=1 npm run test:e2e:run\" \"npm run test:mcp\" \"npm run backup:build\" \"npm run status:build\" \"npm run jobs:build\" \"npm run runtime:build\" \"npm run primitives:check\" \"npm run migrations:check\" \"npm run deploy-guardrails:check\" \"npm run docs:check-temporal\"", "validate:fix": "npm run format && npm run lint:fix", "test:e2e:ensure": "node tools/ensure-playwright-browser.ts", "test:e2e:a11y": "playwright test e2e/a11y.spec.ts", diff --git a/packages/runtime-worker/project.json b/packages/runtime-worker/project.json new file mode 100644 index 0000000000..fd3fe479db --- /dev/null +++ b/packages/runtime-worker/project.json @@ -0,0 +1,13 @@ +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "runtime-worker", + "projectType": "application", + "targets": { + "build": { + "executor": "nx:run-commands", + "options": { + "command": "npm run runtime:build" + } + } + } +} diff --git a/packages/runtime-worker/readme.md b/packages/runtime-worker/readme.md new file mode 100644 index 0000000000..45a6bd151d --- /dev/null +++ b/packages/runtime-worker/readme.md @@ -0,0 +1,23 @@ +# kody runtime worker + +The package runtime lane extracted from the main `kody` Worker per +[ADR 0016](../../docs/contributing/decisions/0016-mono-worker-extraction.md): +the package-app origin (`PACKAGE_APP_BASE_URL`), inline package-app serving, the +package invocation API, dynamic callable workflows, and the runtime Durable +Objects (`StorageRunner`, `RunLog`, `PackageRealtimeSession`, +`PackageServiceInstance`). + +The Worker entry module is +[`packages/worker/src/runtime-worker.ts`](../worker/src/runtime-worker.ts): the +runtime lane shares the main Worker's source tree, import maps, and pre-bundled +`src/generated/` modules, so this package holds only the deploy configuration. +The entry module is typechecked and tested through the `worker` Nx project. + +- `wrangler.jsonc` — the committed base config (script name `kody-runtime`). + Deployable configs are generated from it plus the main Worker's generated + config by + [`tools/ci/runtime-worker-config.ts`](../../tools/ci/runtime-worker-config.ts). +- Build check: `npm run runtime:build` (part of `npm run validate`). +- Deploys/previews: see `.github/workflows/deploy.yml` and `preview.yml`. +- Production Durable Object storage transfer: see the + [migration runbook](../../docs/contributing/architecture/runtime-worker-migration-runbook.md). diff --git a/packages/runtime-worker/wrangler.jsonc b/packages/runtime-worker/wrangler.jsonc new file mode 100644 index 0000000000..4986c16601 --- /dev/null +++ b/packages/runtime-worker/wrangler.jsonc @@ -0,0 +1,414 @@ +{ + "$schema": "../../node_modules/wrangler/config-schema.json", + // Package runtime Worker (ADR 0016): the untrusted-code execution lane + // extracted from the main `kody` Worker. Owns the package-app origin + // (PACKAGE_APP_BASE_URL), inline package-app serving, the package + // invocation API, dynamic callable workflows, and the runtime Durable + // Objects (StorageRunner, RunLog, PackageRealtimeSession, + // PackageServiceInstance). The entry module lives in + // packages/worker/src/runtime-worker.ts so the shared source tree, + // `#worker`/`#app` import maps, and the pre-bundled `src/generated/` + // modules resolve identically to the main Worker. + "name": "kody-runtime", + "compatibility_date": "2026-04-16", + "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"], + // Same rationale as the main worker: package runtime lanes fan out into + // many D1 + Durable Object subrequests per invocation. + "limits": { + "subrequests": 100000, + }, + "main": "../worker/src/runtime-worker.ts", + "minify": true, + "upload_source_maps": true, + // Mirrors packages/worker/wrangler.jsonc: the module-graph builders + // dynamically import the pre-bundled worker-bundler modules from + // `packages/worker/src/generated/` (base_dir defaults to the entry + // module's directory, which is packages/worker/src). + "find_additional_modules": true, + "rules": [ + { + "type": "Text", + "globs": ["./**/*.md", "../**/*.md"], + "fallthrough": true, + }, + { + "type": "ESModule", + "globs": ["generated/*.mjs", "./generated/*.mjs"], + "fallthrough": true, + }, + { + "type": "CompiledWasm", + "globs": ["generated/*.wasm"], + "fallthrough": true, + }, + ], + // Production Durable Object storage moves with the classes from the main + // `kody` script via a transfer migration; this applies on the first + // production deploy of this Worker. See + // docs/contributing/architecture/runtime-worker-migration-runbook.md for + // the coordinated deploy order. The preview environment overrides this + // with `new_sqlite_classes` because preview worker pairs are created + // fresh per PR with no storage to preserve. + "migrations": [ + { + "tag": "v1", + "transferred_classes": [ + { + "from": "StorageRunner", + "from_script": "kody", + "to": "StorageRunner", + }, + { + "from": "RunLog", + "from_script": "kody", + "to": "RunLog", + }, + { + "from": "PackageRealtimeSession", + "from_script": "kody", + "to": "PackageRealtimeSession", + }, + { + "from": "PackageServiceInstance", + "from_script": "kody", + "to": "PackageServiceInstance", + }, + ], + }, + ], + "observability": { + "enabled": true, + "traces": { + "enabled": true, + }, + }, + "env": { + "production": { + "observability": { + "enabled": true, + "traces": { + "enabled": true, + "destinations": ["sentry-otlp-traces"], + }, + }, + "worker_loaders": [ + { + "binding": "LOADER", + }, + { + "binding": "APP_LOADER", + }, + ], + "workflows": [ + { + "binding": "DYNAMIC_CALLABLE_WORKFLOWS", + "name": "kody-runtime-dynamic-callable-workflows", + "class_name": "DynamicCallableWorkflow", + }, + ], + "durable_objects": { + "bindings": [ + // Runtime-owned classes, local to this script. + { + "class_name": "StorageRunner", + "name": "STORAGE_RUNNER", + }, + { + "class_name": "RunLog", + "name": "RUN_LOG", + }, + { + "class_name": "PackageRealtimeSession", + "name": "PACKAGE_REALTIME_SESSION", + }, + { + "class_name": "PackageServiceInstance", + "name": "PACKAGE_SERVICE_INSTANCE", + }, + // Main-worker classes reached cross-script. UserMeter stays + // in the main Worker per ADR 0016; the rest back capability + // execution paths reachable from package code. + { + "class_name": "UserMeter", + "name": "USER_METER", + "script_name": "kody", + }, + { + "class_name": "MCP", + "name": "MCP_OBJECT", + "script_name": "kody", + }, + { + "class_name": "RemoteConnectorSession", + "name": "REMOTE_CONNECTOR_SESSION", + "script_name": "kody", + }, + { + "class_name": "McpClientHub", + "name": "MCP_CLIENT_HUB", + "script_name": "kody", + }, + { + "class_name": "Mailbox", + "name": "MAILBOX", + "script_name": "kody", + }, + { + "class_name": "RepoSession", + "name": "REPO_SESSION", + "script_name": "kody", + }, + ], + }, + // Jobs worker service binding (ADR 0016): package save/delete paths + // sync the JobManager alarm through this coarse-grained contract. + "services": [ + { + "binding": "JOBS", + "service": "kody-jobs", + "entrypoint": "JobsService", + }, + ], + "d1_databases": [ + { + "binding": "APP_DB", + "database_name": "kody", + "migrations_dir": "./../worker/migrations", + }, + { + "binding": "AUDIT_DB", + "database_name": "kody-audit", + "migrations_dir": "./../worker/audit-migrations", + }, + ], + "kv_namespaces": [ + { + "binding": "OAUTH_KV", + }, + { + "binding": "BUNDLE_ARTIFACTS_KV", + }, + ], + "r2_buckets": [ + { + "binding": "COMMUNITY_ASSETS", + "bucket_name": "kody-community-assets", + }, + // Capability execution on the runtime lane reads email + // attachments (email-attachment-get) from the shared blob store. + { + "binding": "EMAIL_BLOBS", + "bucket_name": "kody-email-blobs", + }, + ], + "queues": { + "producers": [ + { + "binding": "PLATFORM_FEEDBACK_DISPATCH_QUEUE", + "queue": "kody-platform-feedback-dispatch", + }, + { + "binding": "COMMUNITY_ACTIVITY_DISPATCH_QUEUE", + "queue": "kody-community-activity-dispatch", + }, + { + "binding": "SCHEDULED_DISPATCH_QUEUE", + "queue": "kody-scheduled-dispatch", + }, + { + "binding": "PACKAGE_EVENTS_DISPATCH_QUEUE", + "queue": "kody-package-events-dispatch", + }, + { + "binding": "WEBHOOK_DISPATCH_QUEUE", + "queue": "kody-webhook-dispatch", + }, + ], + }, + "vectorize": [ + { + "binding": "CAPABILITY_VECTOR_INDEX", + "index_name": "kody-capabilities-prod", + }, + ], + "analytics_engine_datasets": [ + { + "binding": "USAGE_EVENTS", + "dataset": "kody_usage_events", + }, + { + "binding": "FLAG_EXPOSURES", + "dataset": "kody_flag_exposures", + }, + { + "binding": "MCP_PROTOCOL_EVENTS", + "dataset": "kody_mcp_protocol_events", + }, + ], + "ai": { + "binding": "AI", + }, + "vars": { + "SENTRY_ENVIRONMENT": "production", + "SENTRY_TRACES_SAMPLE_RATE": 0, + "ARTIFACTS_NAMESPACE": "production", + "PACKAGE_APP_BASE_URL": "https://kodyapps.dev", + }, + }, + "preview": { + // Preview worker pairs are created fresh per PR: the runtime-owned + // classes are new namespaces here, not a transfer. + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": [ + "StorageRunner", + "RunLog", + "PackageRealtimeSession", + "PackageServiceInstance", + ], + }, + ], + "worker_loaders": [ + { + "binding": "LOADER", + }, + { + "binding": "APP_LOADER", + }, + ], + "workflows": [ + { + "binding": "DYNAMIC_CALLABLE_WORKFLOWS", + "name": "kody-runtime-preview-dynamic-callable-workflows", + "class_name": "DynamicCallableWorkflow", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "StorageRunner", + "name": "STORAGE_RUNNER", + }, + { + "class_name": "RunLog", + "name": "RUN_LOG", + }, + { + "class_name": "PackageRealtimeSession", + "name": "PACKAGE_REALTIME_SESSION", + }, + { + "class_name": "PackageServiceInstance", + "name": "PACKAGE_SERVICE_INSTANCE", + }, + { + "class_name": "UserMeter", + "name": "USER_METER", + "script_name": "kody", + }, + { + "class_name": "MCP", + "name": "MCP_OBJECT", + "script_name": "kody", + }, + { + "class_name": "RemoteConnectorSession", + "name": "REMOTE_CONNECTOR_SESSION", + "script_name": "kody", + }, + { + "class_name": "McpClientHub", + "name": "MCP_CLIENT_HUB", + "script_name": "kody", + }, + { + "class_name": "Mailbox", + "name": "MAILBOX", + "script_name": "kody", + }, + { + "class_name": "RepoSession", + "name": "REPO_SESSION", + "script_name": "kody", + }, + ], + }, + // Jobs worker service binding (ADR 0016): the preview config + // generator rewrites the service to the per-preview jobs worker + // name, same as the main worker's JOBS binding. + "services": [ + { + "binding": "JOBS", + "service": "kody-jobs", + "entrypoint": "JobsService", + }, + ], + "d1_databases": [ + { + "binding": "APP_DB", + "database_name": "kody-preview", + "migrations_dir": "./../worker/migrations", + }, + { + "binding": "AUDIT_DB", + "database_name": "kody-preview-audit", + "migrations_dir": "./../worker/audit-migrations", + }, + ], + "kv_namespaces": [ + { + "binding": "OAUTH_KV", + }, + { + "binding": "BUNDLE_ARTIFACTS_KV", + }, + ], + "r2_buckets": [ + { + "binding": "COMMUNITY_ASSETS", + "bucket_name": "kody-preview-community-assets", + }, + { + "binding": "EMAIL_BLOBS", + "bucket_name": "kody-preview-email-blobs", + }, + ], + "queues": { + "producers": [ + { + "binding": "WEBHOOK_DISPATCH_QUEUE", + "queue": "kody-preview-webhook-dispatch", + }, + ], + }, + "vectorize": [ + { + "binding": "CAPABILITY_VECTOR_INDEX", + "index_name": "kody-capabilities-preview", + }, + ], + "analytics_engine_datasets": [ + { + "binding": "USAGE_EVENTS", + "dataset": "kody_usage_events_preview", + }, + { + "binding": "FLAG_EXPOSURES", + "dataset": "kody_flag_exposures_preview", + }, + { + "binding": "MCP_PROTOCOL_EVENTS", + "dataset": "kody_mcp_protocol_events_preview", + }, + ], + "ai": { + "binding": "AI", + }, + "vars": { + "SENTRY_ENVIRONMENT": "preview", + "ARTIFACTS_NAMESPACE": "preview", + }, + }, + }, +} diff --git a/packages/shared/src/runtime-worker.ts b/packages/shared/src/runtime-worker.ts new file mode 100644 index 0000000000..6c61560a2b --- /dev/null +++ b/packages/shared/src/runtime-worker.ts @@ -0,0 +1,27 @@ +/** + * Cross-worker contract between the main `kody` Worker and the package + * runtime Worker (`packages/runtime-worker`, script `kody-runtime`). + * + * The main Worker reaches the runtime Worker over a plain HTTP service + * binding (`RUNTIME_WORKER`). The contract is deliberately coarse-grained: + * whole runtime-owned requests (package-app origin, inline package apps, + * package invocation API) are forwarded wholesale, and the only structured + * endpoint is the deploy healthcheck below. Durable Object access in either + * direction (RunLog reads from the main Worker, UserMeter writes from the + * runtime Worker) uses cross-script Durable Object bindings, not RPC. + */ + +/** Healthcheck endpoint served by the runtime Worker. */ +export const runtimeWorkerHealthPath = '/__runtime/health' + +export type RuntimeWorkerHealth = { + status: 'ok' + commitSha: string | null +} + +export function buildRuntimeWorkerHealth( + commitSha: string | undefined, +): RuntimeWorkerHealth { + const trimmed = commitSha?.trim() + return { status: 'ok', commitSha: trimmed ? trimmed : null } +} diff --git a/packages/worker/src/env-schema.ts b/packages/worker/src/env-schema.ts index c3fcf95002..9089a5cc20 100644 --- a/packages/worker/src/env-schema.ts +++ b/packages/worker/src/env-schema.ts @@ -45,6 +45,16 @@ const optionalAiSchema = createSchema( }, ) +// Service binding to the package runtime Worker (`kody-runtime`). Present in +// production/preview and multi-worker local dev; absent in tests and +// single-worker dev, where the main Worker serves the runtime lane itself. +const optionalFetcherSchema = createSchema( + (value, _context) => { + if (value === undefined) return { value: undefined } + return { value: value as Fetcher } + }, +) + const optionalNonEmptyStringSchema = createSchema( (value, context) => { if (value === undefined) return { value: undefined } @@ -189,6 +199,7 @@ export const EnvSchema = object({ MCP_CLIENT_HUB: requiredDurableObjectNamespaceSchema( 'Missing MCP_CLIENT_HUB binding for user-added MCP server connections.', ), + RUNTIME_WORKER: optionalFetcherSchema, APP_BASE_URL: optionalUrlStringSchema, // Comma-separated legacy app hostnames (for example `heykody.dev`) the // Worker keeps serving alongside the canonical `APP_BASE_URL` host during diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 61d1ab20ac..c9dab12f85 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -68,6 +68,7 @@ import { handleMailboxImportRequest } from '#worker/dr/mailbox-import-maintenanc import { OAuthPurgeCoordinator } from './oauth-purge.ts' import { verifyPublicFormProtection } from '#app/public-form-protection.ts' import { getLegacyHostRedirectResponse } from '#worker/app-legacy-redirect.ts' +import { isRuntimeWorkerOwnedRequest } from '#worker/runtime-worker-routing.ts' import { isNamespacedAppEndpointPath, isNamespacedPackageInvocationEndpointPath, @@ -523,6 +524,15 @@ const workerHandler = { async fetch(request: Request, env: Env, ctx: ExecutionContext) { const url = new URL(request.url) + // Package runtime lane extraction (ADR 0016): when the runtime Worker + // service binding is configured, runtime-owned requests (package-app + // origin, inline package apps, package invocation API) are forwarded + // wholesale to the `kody-runtime` Worker. Without the binding (tests, + // single-worker local dev) the in-process handlers below keep serving. + if (env.RUNTIME_WORKER && isRuntimeWorkerOwnedRequest(request, env)) { + return env.RUNTIME_WORKER.fetch(request) + } + // Host isolation for hosted package apps runs before every other route: // nothing first-party may be reachable on the package-app origin, and the // app origin must not execute package code once that origin is configured. diff --git a/packages/worker/src/runtime-worker-routing.ts b/packages/worker/src/runtime-worker-routing.ts new file mode 100644 index 0000000000..81c04378e8 --- /dev/null +++ b/packages/worker/src/runtime-worker-routing.ts @@ -0,0 +1,20 @@ +import { getPackageAppBaseUrl } from '#worker/app-base-url.ts' +import { isPackageInvocationApiRequest } from '#worker/package-invocations/http.ts' +import { isPackageAppRequestPath } from '#worker/package-runtime/package-app-serve.ts' + +/** + * Requests owned by the package runtime Worker (`kody-runtime`): everything on + * the package-app origin, inline package-app paths on the app origin, and the + * package invocation API. When the main Worker has a `RUNTIME_WORKER` service + * binding it forwards these wholesale; without the binding (tests, single + * Worker local dev) the main Worker keeps serving them in-process. + */ +export function isRuntimeWorkerOwnedRequest(request: Request, env: Env) { + const url = new URL(request.url) + const packageAppOrigin = getPackageAppBaseUrl({ env }) + if (packageAppOrigin && url.origin === packageAppOrigin) return true + return ( + isPackageInvocationApiRequest(url.pathname) || + isPackageAppRequestPath(url.pathname) + ) +} diff --git a/packages/worker/src/runtime-worker.ts b/packages/worker/src/runtime-worker.ts new file mode 100644 index 0000000000..43cb684011 --- /dev/null +++ b/packages/worker/src/runtime-worker.ts @@ -0,0 +1,84 @@ +import * as Sentry from '@sentry/cloudflare' +import { + buildRuntimeWorkerHealth, + runtimeWorkerHealthPath, +} from '@kody-internal/shared/runtime-worker.ts' +import { StorageRunner } from './storage-runner.ts' +import { RunLog } from './run-records/run-log-do.ts' +import { PackageRealtimeSession } from '#worker/package-runtime/realtime-session.ts' +import { PackageServiceInstance } from '#worker/package-runtime/package-service.ts' +import { DynamicCallableWorkflow } from '#worker/package-runtime/package-workflows.ts' +import { PackageAppRuntimeBridge } from '#worker/package-runtime/package-app.ts' +import { KodyFetchGateway } from '#mcp/fetch-gateway.ts' +import { getWorkerSentryOptions } from './sentry-options.ts' +import { + handlePackageInvocationApiRequest, + isPackageInvocationApiRequest, +} from './package-invocations/http.ts' +import { + handlePackageAppRequest, + isPackageAppRequestPath, +} from '#app/handlers/package-app.ts' +import { handlePackageAppOriginRequest } from '#app/package-app-origin.ts' + +/** + * Package runtime Worker entrypoint (script `kody-runtime`, deployed from + * `packages/runtime-worker/wrangler.jsonc`). + * + * Owns the untrusted-code execution lane extracted from the main `kody` + * Worker per ADR 0016: the package-app origin (`PACKAGE_APP_BASE_URL`), + * inline package-app serving, the package invocation API, dynamic callable + * workflows, and the runtime Durable Objects exported below. The main Worker + * forwards runtime-owned requests here over the `RUNTIME_WORKER` service + * binding (see `runtime-worker-routing.ts` and + * `@kody-internal/shared/runtime-worker.ts`). + * + * `KodyFetchGateway` and `PackageAppRuntimeBridge` are loopback + * `ctx.exports` entrypoints for dynamically loaded package isolates, so this + * script exports its own instances rather than calling back into the main + * Worker. + */ +export { + StorageRunner, + RunLog, + PackageRealtimeSession, + PackageServiceInstance, + DynamicCallableWorkflow, + PackageAppRuntimeBridge, + KodyFetchGateway, +} + +const runtimeWorkerHandler = { + async fetch(request: Request, env: Env, ctx: ExecutionContext) { + const url = new URL(request.url) + + if (url.pathname === runtimeWorkerHealthPath) { + return Response.json( + buildRuntimeWorkerHealth( + (env as { APP_COMMIT_SHA?: string }).APP_COMMIT_SHA, + ), + ) + } + + const packageAppOriginResponse = await handlePackageAppOriginRequest( + request, + env, + ) + if (packageAppOriginResponse) return packageAppOriginResponse + + if (isPackageInvocationApiRequest(url.pathname)) { + return handlePackageInvocationApiRequest(request, env, ctx) + } + + if (isPackageAppRequestPath(url.pathname)) { + return handlePackageAppRequest(request, env) + } + + return new Response('Not Found', { status: 404 }) + }, +} satisfies ExportedHandler + +export default Sentry.withSentry( + (env: Env) => getWorkerSentryOptions(env), + runtimeWorkerHandler, +) diff --git a/packages/worker/worker-configuration.d.ts b/packages/worker/worker-configuration.d.ts index f8a69bd47b..e288ee09ac 100644 --- a/packages/worker/worker-configuration.d.ts +++ b/packages/worker/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types --config=packages/worker/wrangler.jsonc --env=production ./packages/worker/worker-configuration.d.ts` (hash: 624fc342264e1aa155884bcca4b048c3) +// Generated by Wrangler by running `wrangler types --config=packages/worker/wrangler.jsonc --env=production ./packages/worker/worker-configuration.d.ts` (hash: a35eaeaadab0d1cbaa45f7c5bd9789d5) // Runtime types generated with workerd@1.20260730.1 2026-04-16 global_fetch_strictly_public,nodejs_compat interface __BaseEnv_Env { OAUTH_KV: KVNamespace; @@ -40,20 +40,29 @@ interface __BaseEnv_Env { DR_BACKUP_BUCKET_NAME: "kody-production-backups"; BACKUP_MANIFEST_SIGNING_KEY_ID: "kody-dr-2026-07"; BACKUP_MANIFEST_VERIFYING_PUBLIC_KEY_SPKI_BASE64: "MCowBQYDK2VwAyEA3jqjPcGTzWefE5PGyRBdUQKAEj7FFGtFIz+223sbt9A="; + COOKIE_SECRET: string; + SECRET_STORE_KEY: string; + APP_BASE_URL: string; + GITHUB_CLIENT_ID: string; + GITHUB_CLIENT_SECRET: string; + GOOGLE_CLIENT_ID: string; + GOOGLE_CLIENT_SECRET: string; + X_CLIENT_ID: string; + X_CLIENT_SECRET: string; MCP_OBJECT: DurableObjectNamespace; REMOTE_CONNECTOR_SESSION: DurableObjectNamespace; MCP_CLIENT_HUB: DurableObjectNamespace; OAUTH_PURGE_COORDINATOR: DurableObjectNamespace; - STORAGE_RUNNER: DurableObjectNamespace; - RUN_LOG: DurableObjectNamespace; + STORAGE_RUNNER: DurableObjectNamespace /* StorageRunner from kody-runtime */; + RUN_LOG: DurableObjectNamespace /* RunLog from kody-runtime */; USER_METER: DurableObjectNamespace; MAILBOX: DurableObjectNamespace; STRIPE_PLAN_REFRESH: DurableObjectNamespace; REPO_SESSION: DurableObjectNamespace; - PACKAGE_REALTIME_SESSION: DurableObjectNamespace; - PACKAGE_SERVICE_INSTANCE: DurableObjectNamespace; + PACKAGE_REALTIME_SESSION: DurableObjectNamespace /* PackageRealtimeSession from kody-runtime */; + PACKAGE_SERVICE_INSTANCE: DurableObjectNamespace /* PackageServiceInstance from kody-runtime */; JOBS: Service /* entrypoint JobsService from kody-jobs */; - DYNAMIC_CALLABLE_WORKFLOWS: Workflow[0]['payload']>; + DYNAMIC_CALLABLE_WORKFLOWS: Workflow /* DynamicCallableWorkflow from kody-runtime */; } declare namespace Cloudflare { interface GlobalProps { @@ -67,7 +76,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } declare module "*.md" { const value: string; diff --git a/packages/worker/wrangler.jsonc b/packages/worker/wrangler.jsonc index a2ad26ba77..856f84f860 100644 --- a/packages/worker/wrangler.jsonc +++ b/packages/worker/wrangler.jsonc @@ -72,10 +72,12 @@ "new_sqlite_classes": ["SchedulerDO"], "tag": "v4", }, - { - "new_sqlite_classes": ["JobManager", "JobRunner"], - "tag": "v6", - }, + // v6 (JobManager+JobRunner) and v9 (delete JobRunner) are omitted: + // JobManager moved to the jobs worker (ADR 0016) and is no longer + // exported here, so a fresh script replaying this chain (local dev, + // a new deployment) must not create it. The deployed production + // script is already past these tags, so wrangler's migration diff + // for it is unaffected. { "deleted_classes": ["SchedulerDO"], "tag": "v7", @@ -84,10 +86,6 @@ "new_sqlite_classes": ["StorageRunner"], "tag": "v8", }, - { - "deleted_classes": ["JobRunner"], - "tag": "v9", - }, { "new_sqlite_classes": ["AgentTurnRunner"], "tag": "v10", @@ -196,11 +194,24 @@ "binding": "APP_LOADER", }, ], + // The runtime lane's workflow moved to the kody-runtime script (ADR + // 0016); MCP durable escalation in this Worker starts instances over + // the cross-script binding. "workflows": [ { "binding": "DYNAMIC_CALLABLE_WORKFLOWS", - "name": "kody-dynamic-callable-workflows", + "name": "kody-runtime-dynamic-callable-workflows", "class_name": "DynamicCallableWorkflow", + "script_name": "kody-runtime", + }, + ], + // Package runtime Worker (ADR 0016): runtime-owned requests are + // forwarded wholesale over this service binding (see + // packages/worker/src/runtime-worker-routing.ts). + "services": [ + { + "binding": "RUNTIME_WORKER", + "service": "kody-runtime", }, ], "assets": { @@ -234,13 +245,18 @@ "class_name": "OAuthPurgeCoordinator", "name": "OAUTH_PURGE_COORDINATOR", }, + // Runtime-owned Durable Objects live in the kody-runtime script + // (ADR 0016); storage transferred there via that script's + // `transferred_classes` migration. { "class_name": "StorageRunner", "name": "STORAGE_RUNNER", + "script_name": "kody-runtime", }, { "class_name": "RunLog", "name": "RUN_LOG", + "script_name": "kody-runtime", }, { "class_name": "UserMeter", @@ -261,10 +277,12 @@ { "class_name": "PackageRealtimeSession", "name": "PACKAGE_REALTIME_SESSION", + "script_name": "kody-runtime", }, { "class_name": "PackageServiceInstance", "name": "PACKAGE_SERVICE_INSTANCE", + "script_name": "kody-runtime", }, ], }, @@ -585,8 +603,18 @@ "workflows": [ { "binding": "DYNAMIC_CALLABLE_WORKFLOWS", - "name": "kody-preview-dynamic-callable-workflows", + "name": "kody-runtime-preview-dynamic-callable-workflows", "class_name": "DynamicCallableWorkflow", + "script_name": "kody-runtime", + }, + ], + // Preview deploys generate per-PR worker names; the deploy config + // generator rewrites "kody-runtime" service/script references to the + // PR's runtime worker name (tools/ci/runtime-worker-config.ts). + "services": [ + { + "binding": "RUNTIME_WORKER", + "service": "kody-runtime", }, ], "assets": { @@ -623,10 +651,12 @@ { "class_name": "StorageRunner", "name": "STORAGE_RUNNER", + "script_name": "kody-runtime", }, { "class_name": "RunLog", "name": "RUN_LOG", + "script_name": "kody-runtime", }, { "class_name": "UserMeter", @@ -647,10 +677,12 @@ { "class_name": "PackageRealtimeSession", "name": "PACKAGE_REALTIME_SESSION", + "script_name": "kody-runtime", }, { "class_name": "PackageServiceInstance", "name": "PACKAGE_SERVICE_INSTANCE", + "script_name": "kody-runtime", }, ], }, diff --git a/tools/check-deploy-guardrails.node.test.ts b/tools/check-deploy-guardrails.node.test.ts index 0eaa93f8f5..941d30aff6 100644 --- a/tools/check-deploy-guardrails.node.test.ts +++ b/tools/check-deploy-guardrails.node.test.ts @@ -95,7 +95,7 @@ test('Durable Object guard protects migration tags and bound classes', () => { ).toEqual([ expect.stringContaining('was removed, renamed, or changed'), expect.stringContaining( - 'new_sqlite_classes migration "renamed-v1" is not recorded', + 'new_sqlite_classes migration "renamed-v1" at migrations is not recorded', ), ]) @@ -136,6 +136,207 @@ test('Durable Object guard protects migration tags and bound classes', () => { ).toEqual([expect.stringContaining('binding identities')]) }) +test('Durable Object guard protects transferred_classes migrations', () => { + const allowlist = { + version: 1, + deletions: [], + } satisfies DurableObjectDeletionAllowlist + const transfer = { + from: 'RunLog', + from_script: 'kody', + to: 'RunLog', + } + const transferBaseline = { + path: configPath, + protected_migrations: [], + protected_transfer_migrations: [ + { tag: 'v1', transferred_classes: [transfer] }, + ], + protected_binding_sets: [], + } satisfies DurableObjectBaseline['configs'][number] + + const matching = { + migrations: [{ tag: 'v1', transferred_classes: [transfer] }], + } + expect( + checkDurableObjectConfig(configPath, matching, transferBaseline, allowlist), + ).toEqual([]) + + const retargeted = { + migrations: [ + { + tag: 'v1', + transferred_classes: [{ ...transfer, from_script: 'other-worker' }], + }, + ], + } + expect( + checkDurableObjectConfig( + configPath, + retargeted, + transferBaseline, + allowlist, + ), + ).toEqual([ + expect.stringContaining( + 'protected transferred_classes migration "v1" at migrations was removed, renamed, or changed', + ), + ]) + + const unrecorded = { + migrations: [ + { tag: 'v1', transferred_classes: [transfer] }, + { tag: 'v2', transferred_classes: [transfer] }, + ], + } + expect( + checkDurableObjectConfig( + configPath, + unrecorded, + transferBaseline, + allowlist, + ), + ).toEqual([ + expect.stringContaining( + 'transferred_classes migration "v2" at migrations is not recorded', + ), + ]) +}) + +test('Durable Object guard covers environment-specific migration lists', () => { + const allowlist = { + version: 1, + deletions: [], + } satisfies DurableObjectDeletionAllowlist + const envBaseline = { + path: configPath, + protected_migrations: [ + { + tag: 'v1', + location: 'env.preview.migrations', + new_sqlite_classes: ['RunLog'], + }, + ], + protected_transfer_migrations: [ + { + tag: 'v1', + transferred_classes: [ + { from: 'RunLog', from_script: 'kody', to: 'RunLog' }, + ], + }, + ], + protected_binding_sets: [], + } satisfies DurableObjectBaseline['configs'][number] + + // The same tag may exist at the top level and in an env override. + const matching = { + migrations: [ + { + tag: 'v1', + transferred_classes: [ + { from: 'RunLog', from_script: 'kody', to: 'RunLog' }, + ], + }, + ], + env: { + preview: { + migrations: [{ tag: 'v1', new_sqlite_classes: ['RunLog'] }], + }, + }, + } + expect( + checkDurableObjectConfig(configPath, matching, envBaseline, allowlist), + ).toEqual([]) + + const changedEnvMigration = { + ...matching, + env: { + preview: { + migrations: [{ tag: 'v1', new_sqlite_classes: ['SomethingElse'] }], + }, + }, + } + expect( + checkDurableObjectConfig( + configPath, + changedEnvMigration, + envBaseline, + allowlist, + ), + ).toEqual([ + expect.stringContaining( + 'protected new_sqlite_classes migration "v1" at env.preview.migrations was removed, renamed, or changed', + ), + ]) + + const unrecordedEnvMigration = { + ...matching, + env: { + preview: { + migrations: [ + { tag: 'v1', new_sqlite_classes: ['RunLog'] }, + { tag: 'v2', new_sqlite_classes: ['Unrecorded'] }, + ], + }, + }, + } + expect( + checkDurableObjectConfig( + configPath, + unrecordedEnvMigration, + envBaseline, + allowlist, + ), + ).toEqual([ + expect.stringContaining( + 'new_sqlite_classes migration "v2" at env.preview.migrations is not recorded', + ), + ]) + + const duplicateTagInEnv = { + ...matching, + env: { + preview: { + migrations: [ + { tag: 'v1', new_sqlite_classes: ['RunLog'] }, + { tag: 'v1', new_sqlite_classes: ['RunLog'] }, + ], + }, + }, + } + expect( + checkDurableObjectConfig( + configPath, + duplicateTagInEnv, + envBaseline, + allowlist, + ), + ).toContainEqual( + expect.stringContaining( + 'duplicate Durable Object migration tag "v1" in env.preview.migrations', + ), + ) + + const envDeletion = { + ...matching, + env: { + preview: { + migrations: [ + { tag: 'v1', new_sqlite_classes: ['RunLog'] }, + { tag: 'v2', deleted_classes: ['RunLog'] }, + ], + }, + }, + } + expect( + checkDurableObjectConfig(configPath, envDeletion, envBaseline, allowlist), + ).toContainEqual( + expect.stringContaining( + 'destructive deletion v2 [RunLog] is not an exact entry', + ), + ) +}) + test('workflow guard permits destructive commands only in operator-dispatched jobs', () => { const automaticWorkflow = `on: push: diff --git a/tools/check-deploy-guardrails.ts b/tools/check-deploy-guardrails.ts index ac4549bcd8..b28a58dd25 100644 --- a/tools/check-deploy-guardrails.ts +++ b/tools/check-deploy-guardrails.ts @@ -18,6 +18,21 @@ export const defaultWorkflowDirectory = path.join('.github', 'workflows') type ProtectedMigration = { tag: string new_sqlite_classes: Array + /** Migration list location; defaults to the top-level "migrations". */ + location?: string +} + +type TransferredClass = { + from: string + from_script: string + to: string +} + +type ProtectedTransferMigration = { + tag: string + transferred_classes: Array + /** Migration list location; defaults to the top-level "migrations". */ + location?: string } type ProtectedBindingSet = { @@ -28,6 +43,7 @@ type ProtectedBindingSet = { type DurableObjectBaselineConfig = { path: string protected_migrations: Array + protected_transfer_migrations?: Array protected_binding_sets: Array } @@ -50,6 +66,7 @@ export type DurableObjectDeletionAllowlist = { type WranglerMigration = { tag?: unknown new_sqlite_classes?: unknown + transferred_classes?: unknown deleted_classes?: unknown } @@ -93,6 +110,40 @@ function sameStrings( ) } +function normalizeTransferredClasses( + value: unknown, +): Array | null { + if (!Array.isArray(value)) return null + const transfers: Array = [] + for (const entry of value) { + if (!entry || typeof entry !== 'object') return null + const candidate = entry as Record + if ( + typeof candidate.from !== 'string' || + typeof candidate.from_script !== 'string' || + typeof candidate.to !== 'string' + ) { + return null + } + transfers.push({ + from: candidate.from, + from_script: candidate.from_script, + to: candidate.to, + }) + } + return transfers +} + +function transferKey(transfer: TransferredClass): string { + return JSON.stringify([transfer.from, transfer.from_script, transfer.to]) +} + +function sortedTransferKeys( + transfers: ReadonlyArray, +): Array { + return transfers.map((transfer) => transferKey(transfer)).sort() +} + function deletionKey(deletion: AllowedDeletion): string { return JSON.stringify([ deletion.config, @@ -167,6 +218,45 @@ function collectDurableObjectBindings( return result } +const defaultMigrationLocation = 'migrations' + +type MigrationSection = { + location: string + migrations: Array +} + +/** + * Collects every Durable Object migration list in the config: the top-level + * `migrations` plus each environment-specific `env..migrations` + * override (wrangler environments may declare their own list, e.g. the + * runtime worker's preview `new_sqlite_classes`). + */ +function collectMigrationSections( + config: WranglerConfig, +): Array { + const sections: Array = [] + if (Array.isArray(config.migrations)) { + sections.push({ + location: defaultMigrationLocation, + migrations: config.migrations as Array, + }) + } + const env = config.env + if (env && typeof env === 'object' && !Array.isArray(env)) { + for (const [envName, envConfig] of Object.entries(env)) { + if (!envConfig || typeof envConfig !== 'object') continue + const envMigrations = (envConfig as { migrations?: unknown }).migrations + if (Array.isArray(envMigrations)) { + sections.push({ + location: `env.${envName}.migrations`, + migrations: envMigrations as Array, + }) + } + } + } + return sections +} + export function checkDurableObjectConfig( configPath: string, config: WranglerConfig, @@ -174,68 +264,136 @@ export function checkDurableObjectConfig( allowlist: DurableObjectDeletionAllowlist, ): Array { const errors: Array = [] - const migrations = Array.isArray(config.migrations) - ? (config.migrations as Array) - : [] - const migrationsByTag = new Map() - - for (const migration of migrations) { - if (typeof migration.tag === 'string') { - if (migrationsByTag.has(migration.tag)) { - errors.push( - `${configPath}: duplicate Durable Object migration tag "${migration.tag}".`, + const sections = collectMigrationSections(config) + const migrationsByLocationAndTag = new Map() + + // Duplicate-tag validation is scoped per migration list: an environment + // override may legitimately reuse a tag (e.g. "v1") that the top level + // also uses. + for (const section of sections) { + const seenTags = new Set() + for (const migration of section.migrations) { + if (typeof migration.tag === 'string') { + if (seenTags.has(migration.tag)) { + errors.push( + `${configPath}: duplicate Durable Object migration tag "${migration.tag}" in ${section.location}.`, + ) + } + seenTags.add(migration.tag) + migrationsByLocationAndTag.set( + `${section.location}\u0000${migration.tag}`, + migration, ) } - migrationsByTag.set(migration.tag, migration) } } for (const protectedMigration of baseline.protected_migrations) { - const current = migrationsByTag.get(protectedMigration.tag) + const location = protectedMigration.location ?? defaultMigrationLocation + const current = migrationsByLocationAndTag.get( + `${location}\u0000${protectedMigration.tag}`, + ) const currentClasses = sortedStrings(current?.new_sqlite_classes) const expectedClasses = protectedMigration.new_sqlite_classes.toSorted() if (!currentClasses || !sameStrings(currentClasses, expectedClasses)) { errors.push( - `${configPath}: protected new_sqlite_classes migration "${protectedMigration.tag}" was removed, renamed, or changed (expected ${expectedClasses.join(', ')}).`, + `${configPath}: protected new_sqlite_classes migration "${protectedMigration.tag}" at ${location} was removed, renamed, or changed (expected ${expectedClasses.join(', ')}).`, ) } } - const protectedMigrationTags = new Set( - baseline.protected_migrations.map(({ tag }) => tag), + const protectedMigrationKeys = new Set( + baseline.protected_migrations.map( + ({ tag, location }) => + `${location ?? defaultMigrationLocation}\u0000${tag}`, + ), ) - for (const migration of migrations) { + for (const section of sections) { + for (const migration of section.migrations) { + if ( + migration.new_sqlite_classes !== undefined && + typeof migration.tag === 'string' && + !protectedMigrationKeys.has(`${section.location}\u0000${migration.tag}`) + ) { + errors.push( + `${configPath}: new_sqlite_classes migration "${migration.tag}" at ${section.location} is not recorded in ${defaultDurableObjectBaselinePath}. Update the reviewed baseline so this migration remains protected after it lands.`, + ) + } + } + } + + // Transfer migrations move Durable Object storage between scripts; + // changing or removing one after it ships would orphan or duplicate + // storage, so every transferred_classes migration must exactly match a + // reviewed baseline entry. + const protectedTransferMigrations = + baseline.protected_transfer_migrations ?? [] + for (const protectedTransfer of protectedTransferMigrations) { + const location = protectedTransfer.location ?? defaultMigrationLocation + const current = migrationsByLocationAndTag.get( + `${location}\u0000${protectedTransfer.tag}`, + ) + const currentTransfers = normalizeTransferredClasses( + current?.transferred_classes, + ) if ( - migration.new_sqlite_classes !== undefined && - typeof migration.tag === 'string' && - !protectedMigrationTags.has(migration.tag) + !currentTransfers || + !sameStrings( + sortedTransferKeys(currentTransfers), + sortedTransferKeys(protectedTransfer.transferred_classes), + ) ) { errors.push( - `${configPath}: new_sqlite_classes migration "${migration.tag}" is not recorded in ${defaultDurableObjectBaselinePath}. Update the reviewed baseline so this migration remains protected after it lands.`, + `${configPath}: protected transferred_classes migration "${protectedTransfer.tag}" at ${location} was removed, renamed, or changed (expected ${protectedTransfer.transferred_classes.map((transfer) => `${transfer.from_script}/${transfer.from} -> ${transfer.to}`).join(', ')}).`, ) } } + const protectedTransferKeys = new Set( + protectedTransferMigrations.map( + ({ tag, location }) => + `${location ?? defaultMigrationLocation}\u0000${tag}`, + ), + ) + for (const section of sections) { + for (const migration of section.migrations) { + if ( + migration.transferred_classes !== undefined && + typeof migration.tag === 'string' && + !protectedTransferKeys.has(`${section.location}\u0000${migration.tag}`) + ) { + errors.push( + `${configPath}: transferred_classes migration "${migration.tag}" at ${section.location} is not recorded in ${defaultDurableObjectBaselinePath}. Update the reviewed baseline so this migration remains protected after it lands.`, + ) + } + } + } const allowedDeletionKeys = new Set( allowlist.deletions.map((deletion) => deletionKey(deletion)), ) - for (const migration of migrations) { - if (migration.deleted_classes === undefined) continue - const classes = sortedStrings(migration.deleted_classes) - if (typeof migration.tag !== 'string' || !classes || classes.length === 0) { - errors.push( - `${configPath}: every deleted_classes migration must have a string tag and a non-empty string class list.`, - ) - continue - } - const candidate = { - config: configPath, - tag: migration.tag, - classes, - } - if (!allowedDeletionKeys.has(deletionKey(candidate))) { - errors.push( - `${configPath}: destructive deletion ${migration.tag} [${classes.join(', ')}] is not an exact entry in ${defaultDurableObjectDeletionAllowlistPath}. Durable Object class deletion requires an explicit reviewed allowlist change.`, - ) + for (const section of sections) { + for (const migration of section.migrations) { + if (migration.deleted_classes === undefined) continue + const classes = sortedStrings(migration.deleted_classes) + if ( + typeof migration.tag !== 'string' || + !classes || + classes.length === 0 + ) { + errors.push( + `${configPath}: every deleted_classes migration must have a string tag and a non-empty string class list.`, + ) + continue + } + const candidate = { + config: configPath, + tag: migration.tag, + classes, + } + if (!allowedDeletionKeys.has(deletionKey(candidate))) { + errors.push( + `${configPath}: destructive deletion ${migration.tag} [${classes.join(', ')}] is not an exact entry in ${defaultDurableObjectDeletionAllowlistPath}. Durable Object class deletion requires an explicit reviewed allowlist change.`, + ) + } } } diff --git a/tools/ci/do-deletion-allowlist.json b/tools/ci/do-deletion-allowlist.json index 965677e35a..35b12ae53b 100644 --- a/tools/ci/do-deletion-allowlist.json +++ b/tools/ci/do-deletion-allowlist.json @@ -6,11 +6,6 @@ "tag": "v7", "classes": ["SchedulerDO"] }, - { - "config": "packages/worker/wrangler.jsonc", - "tag": "v9", - "classes": ["JobRunner"] - }, { "config": "packages/worker/wrangler.jsonc", "tag": "v16", diff --git a/tools/ci/durable-object-baseline.json b/tools/ci/durable-object-baseline.json index 3532fa3365..947e062b99 100644 --- a/tools/ci/durable-object-baseline.json +++ b/tools/ci/durable-object-baseline.json @@ -4,125 +4,375 @@ { "path": "packages/worker/wrangler.jsonc", "protected_migrations": [ - { "tag": "v1", "new_sqlite_classes": ["MCP"] }, - { "tag": "v2", "new_sqlite_classes": ["ChatAgent"] }, + { + "tag": "v1", + "new_sqlite_classes": ["MCP"] + }, + { + "tag": "v2", + "new_sqlite_classes": ["ChatAgent"] + }, + { + "tag": "v3", + "new_sqlite_classes": ["HomeConnectorSession", "HomeMCP"] + }, + { + "tag": "v4", + "new_sqlite_classes": ["SchedulerDO"] + }, + { + "tag": "v8", + "new_sqlite_classes": ["StorageRunner"] + }, + { + "tag": "v10", + "new_sqlite_classes": ["AgentTurnRunner"] + }, + { + "tag": "v11", + "new_sqlite_classes": ["RepoSession"] + }, + { + "tag": "v13", + "new_sqlite_classes": ["PackageRealtimeSession"] + }, + { + "tag": "v14", + "new_sqlite_classes": ["PackageServiceInstance"] + }, + { + "tag": "v18", + "new_sqlite_classes": ["McpClientHub"] + }, + { + "tag": "v19", + "new_sqlite_classes": ["OAuthPurgeCoordinator"] + }, + { + "tag": "v20", + "new_sqlite_classes": ["RunLog"] + }, + { + "tag": "v21", + "new_sqlite_classes": ["UserMeter"] + }, + { + "tag": "v22", + "new_sqlite_classes": ["Mailbox"] + }, + { + "tag": "v23", + "new_sqlite_classes": ["StripePlanRefresh"] + }, + { + "tag": "v1", + "location": "env.preview.migrations", + "new_sqlite_classes": ["MCP"] + }, + { + "tag": "v2", + "location": "env.preview.migrations", + "new_sqlite_classes": ["ChatAgent"] + }, { "tag": "v3", + "location": "env.preview.migrations", "new_sqlite_classes": ["HomeConnectorSession", "HomeMCP"] }, - { "tag": "v4", "new_sqlite_classes": ["SchedulerDO"] }, { - "tag": "v6", - "new_sqlite_classes": ["JobManager", "JobRunner"] + "tag": "v4", + "location": "env.preview.migrations", + "new_sqlite_classes": ["SchedulerDO"] + }, + { + "tag": "v8", + "location": "env.preview.migrations", + "new_sqlite_classes": ["StorageRunner"] + }, + { + "tag": "v10", + "location": "env.preview.migrations", + "new_sqlite_classes": ["AgentTurnRunner"] + }, + { + "tag": "v11", + "location": "env.preview.migrations", + "new_sqlite_classes": ["RepoSession"] }, - { "tag": "v8", "new_sqlite_classes": ["StorageRunner"] }, - { "tag": "v10", "new_sqlite_classes": ["AgentTurnRunner"] }, - { "tag": "v11", "new_sqlite_classes": ["RepoSession"] }, { "tag": "v13", + "location": "env.preview.migrations", "new_sqlite_classes": ["PackageRealtimeSession"] }, { "tag": "v14", + "location": "env.preview.migrations", "new_sqlite_classes": ["PackageServiceInstance"] }, - { "tag": "v18", "new_sqlite_classes": ["McpClientHub"] }, + { + "tag": "v18", + "location": "env.preview.migrations", + "new_sqlite_classes": ["McpClientHub"] + }, { "tag": "v19", + "location": "env.preview.migrations", "new_sqlite_classes": ["OAuthPurgeCoordinator"] }, - { "tag": "v20", "new_sqlite_classes": ["RunLog"] }, - { "tag": "v21", "new_sqlite_classes": ["UserMeter"] }, - { "tag": "v22", "new_sqlite_classes": ["Mailbox"] }, - { "tag": "v23", "new_sqlite_classes": ["StripePlanRefresh"] } + { + "tag": "v20", + "location": "env.preview.migrations", + "new_sqlite_classes": ["RunLog"] + }, + { + "tag": "v21", + "location": "env.preview.migrations", + "new_sqlite_classes": ["UserMeter"] + }, + { + "tag": "v22", + "location": "env.preview.migrations", + "new_sqlite_classes": ["Mailbox"] + }, + { + "tag": "v23", + "location": "env.preview.migrations", + "new_sqlite_classes": ["StripePlanRefresh"] + }, + { + "tag": "v1", + "location": "env.test.migrations", + "new_sqlite_classes": ["MCP"] + }, + { + "tag": "v2", + "location": "env.test.migrations", + "new_sqlite_classes": ["ChatAgent"] + }, + { + "tag": "v3", + "location": "env.test.migrations", + "new_sqlite_classes": ["HomeConnectorSession", "HomeMCP"] + }, + { + "tag": "v4", + "location": "env.test.migrations", + "new_sqlite_classes": ["SchedulerDO"] + }, + { + "tag": "v8", + "location": "env.test.migrations", + "new_sqlite_classes": ["StorageRunner"] + }, + { + "tag": "v10", + "location": "env.test.migrations", + "new_sqlite_classes": ["AgentTurnRunner"] + }, + { + "tag": "v11", + "location": "env.test.migrations", + "new_sqlite_classes": ["RepoSession"] + }, + { + "tag": "v13", + "location": "env.test.migrations", + "new_sqlite_classes": ["PackageRealtimeSession"] + }, + { + "tag": "v14", + "location": "env.test.migrations", + "new_sqlite_classes": ["PackageServiceInstance"] + }, + { + "tag": "v18", + "location": "env.test.migrations", + "new_sqlite_classes": ["McpClientHub"] + }, + { + "tag": "v19", + "location": "env.test.migrations", + "new_sqlite_classes": ["OAuthPurgeCoordinator"] + }, + { + "tag": "v20", + "location": "env.test.migrations", + "new_sqlite_classes": ["RunLog"] + }, + { + "tag": "v21", + "location": "env.test.migrations", + "new_sqlite_classes": ["UserMeter"] + }, + { + "tag": "v22", + "location": "env.test.migrations", + "new_sqlite_classes": ["Mailbox"] + }, + { + "tag": "v23", + "location": "env.test.migrations", + "new_sqlite_classes": ["StripePlanRefresh"] + } ], "protected_binding_sets": [ { "location": "env.production.durable_objects", "bindings": [ - { "name": "MCP_OBJECT", "class_name": "MCP" }, + { + "name": "MCP_OBJECT", + "class_name": "MCP" + }, { "name": "REMOTE_CONNECTOR_SESSION", "class_name": "RemoteConnectorSession" }, - { "name": "MCP_CLIENT_HUB", "class_name": "McpClientHub" }, + { + "name": "MCP_CLIENT_HUB", + "class_name": "McpClientHub" + }, { "name": "OAUTH_PURGE_COORDINATOR", "class_name": "OAuthPurgeCoordinator" }, - { "name": "STORAGE_RUNNER", "class_name": "StorageRunner" }, - { "name": "RUN_LOG", "class_name": "RunLog" }, - { "name": "USER_METER", "class_name": "UserMeter" }, - { "name": "MAILBOX", "class_name": "Mailbox" }, + { + "name": "STORAGE_RUNNER", + "class_name": "StorageRunner", + "script_name": "kody-runtime" + }, + { + "name": "RUN_LOG", + "class_name": "RunLog", + "script_name": "kody-runtime" + }, + { + "name": "USER_METER", + "class_name": "UserMeter" + }, + { + "name": "MAILBOX", + "class_name": "Mailbox" + }, { "name": "STRIPE_PLAN_REFRESH", "class_name": "StripePlanRefresh" }, - { "name": "REPO_SESSION", "class_name": "RepoSession" }, + { + "name": "REPO_SESSION", + "class_name": "RepoSession" + }, { "name": "PACKAGE_REALTIME_SESSION", - "class_name": "PackageRealtimeSession" + "class_name": "PackageRealtimeSession", + "script_name": "kody-runtime" }, { "name": "PACKAGE_SERVICE_INSTANCE", - "class_name": "PackageServiceInstance" + "class_name": "PackageServiceInstance", + "script_name": "kody-runtime" } ] }, { "location": "env.preview.durable_objects", "bindings": [ - { "name": "MCP_OBJECT", "class_name": "MCP" }, + { + "name": "MCP_OBJECT", + "class_name": "MCP" + }, { "name": "REMOTE_CONNECTOR_SESSION", "class_name": "RemoteConnectorSession" }, - { "name": "MCP_CLIENT_HUB", "class_name": "McpClientHub" }, + { + "name": "MCP_CLIENT_HUB", + "class_name": "McpClientHub" + }, { "name": "OAUTH_PURGE_COORDINATOR", "class_name": "OAuthPurgeCoordinator" }, - { "name": "STORAGE_RUNNER", "class_name": "StorageRunner" }, - { "name": "RUN_LOG", "class_name": "RunLog" }, - { "name": "USER_METER", "class_name": "UserMeter" }, - { "name": "MAILBOX", "class_name": "Mailbox" }, + { + "name": "STORAGE_RUNNER", + "class_name": "StorageRunner", + "script_name": "kody-runtime" + }, + { + "name": "RUN_LOG", + "class_name": "RunLog", + "script_name": "kody-runtime" + }, + { + "name": "USER_METER", + "class_name": "UserMeter" + }, + { + "name": "MAILBOX", + "class_name": "Mailbox" + }, { "name": "STRIPE_PLAN_REFRESH", "class_name": "StripePlanRefresh" }, - { "name": "REPO_SESSION", "class_name": "RepoSession" }, + { + "name": "REPO_SESSION", + "class_name": "RepoSession" + }, { "name": "PACKAGE_REALTIME_SESSION", - "class_name": "PackageRealtimeSession" + "class_name": "PackageRealtimeSession", + "script_name": "kody-runtime" }, { "name": "PACKAGE_SERVICE_INSTANCE", - "class_name": "PackageServiceInstance" + "class_name": "PackageServiceInstance", + "script_name": "kody-runtime" } ] }, { "location": "env.test.durable_objects", "bindings": [ - { "name": "MCP_OBJECT", "class_name": "MCP" }, + { + "name": "MCP_OBJECT", + "class_name": "MCP" + }, { "name": "REMOTE_CONNECTOR_SESSION", "class_name": "RemoteConnectorSession" }, - { "name": "MCP_CLIENT_HUB", "class_name": "McpClientHub" }, + { + "name": "MCP_CLIENT_HUB", + "class_name": "McpClientHub" + }, { "name": "OAUTH_PURGE_COORDINATOR", "class_name": "OAuthPurgeCoordinator" }, - { "name": "STORAGE_RUNNER", "class_name": "StorageRunner" }, - { "name": "RUN_LOG", "class_name": "RunLog" }, - { "name": "USER_METER", "class_name": "UserMeter" }, - { "name": "MAILBOX", "class_name": "Mailbox" }, + { + "name": "STORAGE_RUNNER", + "class_name": "StorageRunner" + }, + { + "name": "RUN_LOG", + "class_name": "RunLog" + }, + { + "name": "USER_METER", + "class_name": "UserMeter" + }, + { + "name": "MAILBOX", + "class_name": "Mailbox" + }, { "name": "STRIPE_PLAN_REFRESH", "class_name": "StripePlanRefresh" }, - { "name": "REPO_SESSION", "class_name": "RepoSession" }, + { + "name": "REPO_SESSION", + "class_name": "RepoSession" + }, { "name": "PACKAGE_REALTIME_SESSION", "class_name": "PackageRealtimeSession" @@ -140,21 +390,200 @@ "protected_migrations": [], "protected_binding_sets": [] }, + { + "path": "packages/runtime-worker/wrangler.jsonc", + "protected_migrations": [ + { + "tag": "v1", + "location": "env.preview.migrations", + "new_sqlite_classes": [ + "StorageRunner", + "RunLog", + "PackageRealtimeSession", + "PackageServiceInstance" + ] + } + ], + "protected_transfer_migrations": [ + { + "tag": "v1", + "transferred_classes": [ + { + "from": "StorageRunner", + "from_script": "kody", + "to": "StorageRunner" + }, + { + "from": "RunLog", + "from_script": "kody", + "to": "RunLog" + }, + { + "from": "PackageRealtimeSession", + "from_script": "kody", + "to": "PackageRealtimeSession" + }, + { + "from": "PackageServiceInstance", + "from_script": "kody", + "to": "PackageServiceInstance" + } + ] + } + ], + "protected_binding_sets": [ + { + "location": "env.production.durable_objects", + "bindings": [ + { + "name": "STORAGE_RUNNER", + "class_name": "StorageRunner" + }, + { + "name": "RUN_LOG", + "class_name": "RunLog" + }, + { + "name": "PACKAGE_REALTIME_SESSION", + "class_name": "PackageRealtimeSession" + }, + { + "name": "PACKAGE_SERVICE_INSTANCE", + "class_name": "PackageServiceInstance" + }, + { + "name": "USER_METER", + "class_name": "UserMeter", + "script_name": "kody" + }, + { + "name": "MCP_OBJECT", + "class_name": "MCP", + "script_name": "kody" + }, + { + "name": "REMOTE_CONNECTOR_SESSION", + "class_name": "RemoteConnectorSession", + "script_name": "kody" + }, + { + "name": "MCP_CLIENT_HUB", + "class_name": "McpClientHub", + "script_name": "kody" + }, + { + "name": "MAILBOX", + "class_name": "Mailbox", + "script_name": "kody" + }, + { + "name": "REPO_SESSION", + "class_name": "RepoSession", + "script_name": "kody" + } + ] + }, + { + "location": "env.preview.durable_objects", + "bindings": [ + { + "name": "STORAGE_RUNNER", + "class_name": "StorageRunner" + }, + { + "name": "RUN_LOG", + "class_name": "RunLog" + }, + { + "name": "PACKAGE_REALTIME_SESSION", + "class_name": "PackageRealtimeSession" + }, + { + "name": "PACKAGE_SERVICE_INSTANCE", + "class_name": "PackageServiceInstance" + }, + { + "name": "USER_METER", + "class_name": "UserMeter", + "script_name": "kody" + }, + { + "name": "MCP_OBJECT", + "class_name": "MCP", + "script_name": "kody" + }, + { + "name": "REMOTE_CONNECTOR_SESSION", + "class_name": "RemoteConnectorSession", + "script_name": "kody" + }, + { + "name": "MCP_CLIENT_HUB", + "class_name": "McpClientHub", + "script_name": "kody" + }, + { + "name": "MAILBOX", + "class_name": "Mailbox", + "script_name": "kody" + }, + { + "name": "REPO_SESSION", + "class_name": "RepoSession", + "script_name": "kody" + } + ] + } + ] + }, { "path": "packages/jobs-worker/wrangler.jsonc", - "protected_migrations": [], + "protected_migrations": [ + { + "tag": "v1", + "location": "env.test.migrations", + "new_sqlite_classes": ["JobManager"] + } + ], "protected_binding_sets": [ { "location": "env.production.durable_objects", - "bindings": [{ "name": "JOB_MANAGER", "class_name": "JobManager" }] + "bindings": [ + { + "name": "JOB_MANAGER", + "class_name": "JobManager" + } + ] }, { "location": "env.preview.durable_objects", - "bindings": [{ "name": "JOB_MANAGER", "class_name": "JobManager" }] + "bindings": [ + { + "name": "JOB_MANAGER", + "class_name": "JobManager" + } + ] }, { "location": "env.test.durable_objects", - "bindings": [{ "name": "JOB_MANAGER", "class_name": "JobManager" }] + "bindings": [ + { + "name": "JOB_MANAGER", + "class_name": "JobManager" + } + ] + } + ], + "protected_transfer_migrations": [ + { + "tag": "v1", + "transferred_classes": [ + { + "from": "JobManager", + "from_script": "kody-production", + "to": "JobManager" + } + ] } ] } diff --git a/tools/ci/preview-resources.ts b/tools/ci/preview-resources.ts index d11691e480..e0b4aca1ed 100644 --- a/tools/ci/preview-resources.ts +++ b/tools/ci/preview-resources.ts @@ -413,6 +413,9 @@ function listMockServerNames() { } function deletePreviewWorkers(workerName: string, dryRun: boolean) { + // The runtime worker binds the app worker's Durable Object classes, so + // delete it first to avoid dangling cross-script references. + deleteWorkerScript({ name: `${workerName}-runtime`, dryRun }) deleteWorkerScript({ name: workerName, dryRun }) deleteWorkerScript({ name: `${workerName}-jobs`, dryRun }) for (const service of listMockServerNames()) { diff --git a/tools/ci/resource-utils.node.test.ts b/tools/ci/resource-utils.node.test.ts index 1673786d55..8ae4faa40d 100644 --- a/tools/ci/resource-utils.node.test.ts +++ b/tools/ci/resource-utils.node.test.ts @@ -129,28 +129,17 @@ test('writeGeneratedWranglerConfig preserves migrations and copies environment a { binding: 'COMMUNITY_ASSETS', bucket_name: 'kody-community-assets' }, { binding: 'EMAIL_BLOBS', bucket_name: 'kody-email-blobs' }, ]) - // Hosted package apps need their own registrable domain attached to the - // Worker. The routes are generated from the base-URL vars rather than - // committed, because a committed route would make `wrangler dev` resolve - // every local request as that production host. - // - // `routes` replaces the Worker's whole custom-domain set, so the app origin - // must be listed too: publishing only the package-app domain detaches - // heykody.dev and deletes its DNS record. + // The routes are generated from the base-URL vars rather than committed, + // because a committed route would make `wrangler dev` resolve every local + // request as that production host. The package-app host is attached to + // the runtime Worker (ADR 0016), so the main Worker publishes only the + // app origin (plus legacy origins). const packageAppBaseUrl = productionConfig.env?.production?.vars?.PACKAGE_APP_BASE_URL expect(typeof packageAppBaseUrl).toBe('string') const packageAppHostname = new URL(String(packageAppBaseUrl)).hostname expect(productionConfig.env?.production?.routes).toEqual([ { pattern: 'heykody.dev', custom_domain: true }, - { - pattern: packageAppHostname, - custom_domain: true, - }, - { - pattern: `*.${packageAppHostname}/*`, - zone_name: packageAppHostname, - }, ]) // Publishing routes otherwise drops the workers.dev trigger. expect(productionConfig.env?.production?.workers_dev).toBe(true) @@ -325,18 +314,10 @@ test('writeGeneratedWranglerConfig keeps legacy app hosts attached during a doma } }>(await readFile(outPath, 'utf8')) const packageAppBaseUrl = config.env?.production?.vars?.PACKAGE_APP_BASE_URL - const packageAppHostname = new URL(String(packageAppBaseUrl)).hostname + expect(typeof packageAppBaseUrl).toBe('string') expect(config.env?.production?.routes).toEqual([ { pattern: 'heykody.app', custom_domain: true }, { pattern: 'heykody.dev', custom_domain: true }, - { - pattern: packageAppHostname, - custom_domain: true, - }, - { - pattern: `*.${packageAppHostname}/*`, - zone_name: packageAppHostname, - }, ]) // A legacy host equal to the package-app domain would silently collapse diff --git a/tools/ci/resource-utils.ts b/tools/ci/resource-utils.ts index 2805a1b076..627d70ec5d 100644 --- a/tools/ci/resource-utils.ts +++ b/tools/ci/resource-utils.ts @@ -1357,21 +1357,18 @@ function addPackageAppCustomDomainRoute(input: { }), ) + // The package-app host is attached to the runtime Worker (ADR 0016), not + // this one — its apex custom-domain route and the per-user wildcard zone + // route are generated by tools/ci/runtime-worker-config.ts. The main + // Worker publishes the app origin and any legacy origins. const wildcardRoutePattern = packageAppWildcardRoutePattern(packageAppHostname) const newRoutes: Array> = [ appHostname, ...legacyHostnames, - packageAppHostname, ] .filter((hostname) => !existingRoutePatterns.has(hostname)) .map((pattern) => ({ pattern, custom_domain: true })) - if (!existingRoutePatterns.has(wildcardRoutePattern)) { - newRoutes.push({ - pattern: wildcardRoutePattern, - zone_name: packageAppZoneName, - }) - } input.targetEnv.routes = [...existingRoutes, ...newRoutes] // Publishing routes flips `workers_dev` to false, which removed the @@ -1383,7 +1380,7 @@ function addPackageAppCustomDomainRoute(input: { ? `, ${legacyHostnames.join(', ')} (APP_LEGACY_HOSTS)` : '' console.error( - `Worker routes: ${appHostname} (APP_BASE_URL)${legacyNote}, ${packageAppHostname} (PACKAGE_APP_BASE_URL custom domain), ${wildcardRoutePattern} (zone ${packageAppZoneName}); workers.dev trigger kept`, + `Worker routes: ${appHostname} (APP_BASE_URL)${legacyNote}; workers.dev trigger kept. Package-app host ${packageAppHostname} (PACKAGE_APP_BASE_URL custom domain) and ${wildcardRoutePattern} (zone ${packageAppZoneName}) are attached to the runtime worker.`, ) } diff --git a/tools/ci/runtime-worker-config.node.test.ts b/tools/ci/runtime-worker-config.node.test.ts new file mode 100644 index 0000000000..01cee99799 --- /dev/null +++ b/tools/ci/runtime-worker-config.node.test.ts @@ -0,0 +1,270 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { expect, test } from 'vitest' +import { consoleError } from '#worker/test-support/console-spies.ts' + +import { generate } from './runtime-worker-config.ts' +import { parseJsonc } from './resource-utils.ts' + +const runtimeBaseConfigPath = 'packages/runtime-worker/wrangler.jsonc' + +function buildMainGeneratedConfig(envName: string) { + const env = { + durable_objects: { + bindings: [ + { name: 'USER_METER', class_name: 'UserMeter' }, + { + name: 'STORAGE_RUNNER', + class_name: 'StorageRunner', + script_name: 'kody-runtime', + }, + { + name: 'RUN_LOG', + class_name: 'RunLog', + script_name: 'kody-runtime', + }, + ], + }, + services: [ + { binding: 'RUNTIME_WORKER', service: 'kody-runtime' }, + { binding: 'JOBS', service: 'kody-pr-7-jobs', entrypoint: 'JobsService' }, + ], + workflows: [ + { + binding: 'DYNAMIC_CALLABLE_WORKFLOWS', + name: 'kody-runtime-dynamic-callable-workflows', + class_name: 'DynamicCallableWorkflow', + script_name: 'kody-runtime', + }, + ], + d1_databases: [ + { + binding: 'APP_DB', + database_name: 'kody-pr-7-db', + database_id: 'd1-app-id', + migrations_dir: './migrations', + }, + { + binding: 'AUDIT_DB', + database_name: 'kody-pr-7-audit-db', + database_id: 'd1-audit-id', + migrations_dir: './audit-migrations', + }, + ], + kv_namespaces: [ + { binding: 'OAUTH_KV', id: 'kv-oauth-id' }, + { binding: 'BUNDLE_ARTIFACTS_KV', id: 'kv-bundle-id' }, + ], + r2_buckets: [ + { + binding: 'COMMUNITY_ASSETS', + bucket_name: 'kody-pr-7-community-assets', + }, + { + binding: 'EMAIL_BLOBS', + bucket_name: 'kody-pr-7-email-blobs', + }, + ], + queues: { + producers: [ + { + binding: 'WEBHOOK_DISPATCH_QUEUE', + queue: 'kody-pr-7-webhook-dispatch', + }, + ...(envName === 'production' + ? [ + { + binding: 'PLATFORM_FEEDBACK_DISPATCH_QUEUE', + queue: 'kody-platform-feedback-dispatch', + }, + { + binding: 'COMMUNITY_ACTIVITY_DISPATCH_QUEUE', + queue: 'kody-community-activity-dispatch', + }, + { + binding: 'SCHEDULED_DISPATCH_QUEUE', + queue: 'kody-scheduled-dispatch', + }, + { + binding: 'PACKAGE_EVENTS_DISPATCH_QUEUE', + queue: 'kody-package-events-dispatch', + }, + ] + : []), + ], + }, + vectorize: [ + { + binding: 'CAPABILITY_VECTOR_INDEX', + index_name: 'kody-capabilities-pr-7', + }, + ], + analytics_engine_datasets: [ + { binding: 'USAGE_EVENTS', dataset: 'kody_usage_events_pr' }, + { binding: 'FLAG_EXPOSURES', dataset: 'kody_flag_exposures_pr' }, + { + binding: 'MCP_PROTOCOL_EVENTS', + dataset: 'kody_mcp_protocol_events_pr', + }, + ], + vars: { + APP_BASE_URL: 'https://kody-pr-7.example.workers.dev', + PACKAGE_APP_BASE_URL: + envName === 'production' ? 'https://kodyapps.dev' : '', + }, + } + return { name: 'kody', env: { [envName]: env } } +} + +test('generate rewrites worker names, copies resource ids, and writes a bootstrap config', async () => { + consoleError.mockImplementation(() => {}) + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'kody-runtime-config-')) + try { + const mainConfigPath = path.join(tempDir, 'main.generated.json') + await writeFile( + mainConfigPath, + JSON.stringify(buildMainGeneratedConfig('preview')), + ) + const outConfigPath = path.join(tempDir, 'runtime.generated.json') + const bootstrapPath = path.join(tempDir, 'main-bootstrap.generated.json') + + await generate({ + envName: 'preview', + mainConfigPath, + runtimeWorkerName: 'kody-pr-7-runtime', + mainWorkerName: 'kody-pr-7', + baseConfigPath: runtimeBaseConfigPath, + outConfigPath, + outMainBootstrapConfigPath: bootstrapPath, + }) + + const runtimeConfig = parseJsonc<{ + name?: string + env?: { + preview?: { + durable_objects?: { bindings?: Array> } + d1_databases?: Array> + queues?: { producers?: Array> } + routes?: unknown + vars?: Record + workflows?: Array> + } + } + }>(await readFile(outConfigPath, 'utf8')) + + expect(runtimeConfig.name).toBe('kody-pr-7-runtime') + const previewEnv = runtimeConfig.env?.preview + // Cross-script references point at the resolved main worker name. + const userMeter = previewEnv?.durable_objects?.bindings?.find( + (binding) => binding.name === 'USER_METER', + ) + expect(userMeter?.script_name).toBe('kody-pr-7') + // Resource identifiers are copied from the provisioned main config. + expect(previewEnv?.d1_databases?.[0]).toMatchObject({ + binding: 'APP_DB', + database_name: 'kody-pr-7-db', + database_id: 'd1-app-id', + }) + expect(previewEnv?.queues?.producers?.[0]).toMatchObject({ + binding: 'WEBHOOK_DISPATCH_QUEUE', + queue: 'kody-pr-7-webhook-dispatch', + }) + // The workflow gets a per-worker name. + expect(previewEnv?.workflows?.[0]?.name).toBe( + 'kody-pr-7-runtime-dynamic-callable-workflows', + ) + // Preview has no package-app domain, so no routes are published. + expect(previewEnv?.routes).toBeUndefined() + // The main worker's resolved vars are merged in. + expect(previewEnv?.vars?.APP_BASE_URL).toBe( + 'https://kody-pr-7.example.workers.dev', + ) + + // The main config was patched in place to reference the resolved + // runtime worker name. + const patchedMain = parseJsonc<{ + env?: { + preview?: { + services?: Array> + durable_objects?: { bindings?: Array> } + } + } + }>(await readFile(mainConfigPath, 'utf8')) + expect(patchedMain.env?.preview?.services?.[0]?.service).toBe( + 'kody-pr-7-runtime', + ) + + // The bootstrap config has no runtime references: the service binding + // is dropped and cross-script Durable Objects become local. + const bootstrap = parseJsonc<{ + env?: { + preview?: { + services?: Array> + durable_objects?: { bindings?: Array> } + workflows?: Array> + } + } + }>(await readFile(bootstrapPath, 'utf8')) + expect(bootstrap.env?.preview?.services).toEqual([ + { binding: 'JOBS', service: 'kody-pr-7-jobs', entrypoint: 'JobsService' }, + ]) + const bootstrapStorageRunner = + bootstrap.env?.preview?.durable_objects?.bindings?.find( + (binding) => binding.name === 'STORAGE_RUNNER', + ) + expect(bootstrapStorageRunner?.script_name).toBeUndefined() + expect(bootstrap.env?.preview?.workflows?.[0]).toMatchObject({ + name: 'kody-pr-7-bootstrap-dynamic-callable-workflows', + }) + expect(bootstrap.env?.preview?.workflows?.[0]?.script_name).toBeUndefined() + } finally { + await rm(tempDir, { force: true, recursive: true }) + } +}) + +test('generate publishes the package-app custom domain for production', async () => { + consoleError.mockImplementation(() => {}) + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'kody-runtime-prod-')) + try { + const mainConfigPath = path.join(tempDir, 'main.generated.json') + await writeFile( + mainConfigPath, + JSON.stringify(buildMainGeneratedConfig('production')), + ) + const outConfigPath = path.join(tempDir, 'runtime.generated.json') + + await generate({ + envName: 'production', + mainConfigPath, + runtimeWorkerName: 'kody-runtime', + mainWorkerName: 'kody', + baseConfigPath: runtimeBaseConfigPath, + outConfigPath, + }) + + const runtimeConfig = parseJsonc<{ + env?: { + production?: { + routes?: Array<{ pattern: string; custom_domain?: boolean }> + workers_dev?: boolean + migrations?: unknown + } + } + migrations?: Array<{ tag?: string; transferred_classes?: unknown }> + }>(await readFile(outConfigPath, 'utf8')) + + expect(runtimeConfig.env?.production?.routes).toEqual([ + { pattern: 'kodyapps.dev', custom_domain: true }, + { pattern: '*.kodyapps.dev/*', zone_name: 'kodyapps.dev' }, + ]) + expect(runtimeConfig.env?.production?.workers_dev).toBe(true) + // The storage transfer migration survives generation untouched. + expect(runtimeConfig.migrations?.[0]?.tag).toBe('v1') + expect( + Array.isArray(runtimeConfig.migrations?.[0]?.transferred_classes), + ).toBe(true) + } finally { + await rm(tempDir, { force: true, recursive: true }) + } +}) diff --git a/tools/ci/runtime-worker-config.ts b/tools/ci/runtime-worker-config.ts new file mode 100644 index 0000000000..65c7da840c --- /dev/null +++ b/tools/ci/runtime-worker-config.ts @@ -0,0 +1,457 @@ +import { readFile, writeFile } from 'node:fs/promises' +import { + packageAppWildcardRoutePattern, + parseJsonc, + readPackageAppZoneName, +} from './resource-utils.ts' +import { isExecutedDirectly } from '../node-runtime.ts' + +/** + * Generate deployable Wrangler configs for the package runtime Worker + * (ADR 0016) from the committed base config plus the main Worker's generated + * config. + * + * The runtime Worker shares the main Worker's data plane (same D1 databases, + * KV namespaces, R2 buckets, queues), so resource identifiers are copied + * from the main Worker's already-provisioned generated config rather than + * re-provisioned. Cross-script references (`script_name`, `from_script`, + * `service`) committed as `kody`/`kody-runtime` are rewritten to the actual + * worker names, which lets preview deploys use per-PR names. + * + * For preview it also writes a *bootstrap* variant of the main Worker config + * with every runtime-worker reference removed. Fresh preview worker pairs + * have a circular reference (main binds runtime's Durable Objects, runtime + * binds main's), so the deploy order is: main (bootstrap, self-contained) → + * runtime → main (full config). + */ + +const defaultBaseConfigPath = 'packages/runtime-worker/wrangler.jsonc' +const committedRuntimeName = 'kody-runtime' +const committedMainName = 'kody' + +type JsonRecord = Record + +function fail(message: string): never { + console.error(message) + process.exit(1) +} + +function asRecord(value: unknown, label: string): JsonRecord { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + fail(`${label} is not an object.`) + } + return value as JsonRecord +} + +function getEnvSection(config: JsonRecord, envName: string, label: string) { + const env = asRecord(config.env, `${label} "env"`) + return asRecord(env[envName], `${label} "env.${envName}"`) +} + +/** + * Rewrite committed worker-name references (`script_name`, `from_script`, + * `service` values) to the resolved deploy names, anywhere in the config. + */ +function rewriteWorkerNameReferences( + value: unknown, + names: { runtimeWorkerName: string; mainWorkerName: string }, +): void { + if (Array.isArray(value)) { + for (const entry of value) rewriteWorkerNameReferences(entry, names) + return + } + if (!value || typeof value !== 'object') return + const record = value as JsonRecord + for (const key of ['script_name', 'from_script', 'service']) { + if (record[key] === committedRuntimeName) { + record[key] = names.runtimeWorkerName + } else if (record[key] === committedMainName) { + record[key] = names.mainWorkerName + } + } + for (const child of Object.values(record)) { + rewriteWorkerNameReferences(child, names) + } +} + +function findByKey( + entries: unknown, + key: string, + value: string, +): JsonRecord | undefined { + if (!Array.isArray(entries)) return undefined + return entries.find( + (entry) => + entry && + typeof entry === 'object' && + (entry as JsonRecord)[key] === value, + ) as JsonRecord | undefined +} + +/** + * Copy provisioned resource identifiers from the main Worker's generated env + * into the runtime env, matched by binding name. The runtime Worker binds a + * strict subset of the main Worker's resources; every runtime binding must + * resolve or the deploy would target the wrong (or a nonexistent) resource. + */ +function copyResourceIdentifiers(input: { + runtimeEnv: JsonRecord + mainEnv: JsonRecord + envName: string +}) { + const { runtimeEnv, mainEnv, envName } = input + + const copies: Array<{ + section: string + key: string + fields: Array + }> = [ + { + section: 'd1_databases', + key: 'binding', + fields: ['database_name', 'database_id'], + }, + { section: 'kv_namespaces', key: 'binding', fields: ['id', 'title'] }, + { section: 'r2_buckets', key: 'binding', fields: ['bucket_name'] }, + { section: 'vectorize', key: 'binding', fields: ['index_name'] }, + { + section: 'analytics_engine_datasets', + key: 'binding', + fields: ['dataset'], + }, + // Service bindings (the JOBS binding to the jobs worker): the main + // generated config carries the resolved per-environment worker name + // (`kody-jobs` in production, `-jobs` in preview). + { section: 'services', key: 'binding', fields: ['service'] }, + ] + for (const { section, key, fields } of copies) { + const runtimeEntries = runtimeEnv[section] + if (!Array.isArray(runtimeEntries)) continue + for (const entry of runtimeEntries) { + const record = asRecord(entry, `runtime env.${envName}.${section} entry`) + const bindingName = record[key] + if (typeof bindingName !== 'string') { + fail(`runtime env.${envName}.${section} entry is missing "${key}".`) + } + const mainEntry = findByKey(mainEnv[section], key, bindingName) + if (!mainEntry) { + fail( + `main generated config env.${envName}.${section} has no entry for binding "${bindingName}" required by the runtime worker.`, + ) + } + for (const field of fields) { + if (mainEntry[field] !== undefined) record[field] = mainEntry[field] + } + } + } + + const runtimeQueues = runtimeEnv.queues + if (runtimeQueues && typeof runtimeQueues === 'object') { + const runtimeProducers = (runtimeQueues as JsonRecord).producers + const mainProducers = + mainEnv.queues && typeof mainEnv.queues === 'object' + ? (mainEnv.queues as JsonRecord).producers + : undefined + if (Array.isArray(runtimeProducers)) { + for (const entry of runtimeProducers) { + const record = asRecord( + entry, + `runtime env.${envName}.queues.producers entry`, + ) + const bindingName = record.binding + if (typeof bindingName !== 'string') { + fail(`runtime env.${envName}.queues.producers entry missing binding.`) + } + const mainEntry = findByKey(mainProducers, 'binding', bindingName) + if (!mainEntry) { + fail( + `main generated config env.${envName}.queues.producers has no entry for binding "${bindingName}" required by the runtime worker.`, + ) + } + record.queue = mainEntry.queue + } + } + } + + // The runtime Worker needs the main Worker's resolved app config (base + // URLs, email domains, deploy environment) plus its own overrides. + const mainVars = + mainEnv.vars && typeof mainEnv.vars === 'object' + ? (mainEnv.vars as JsonRecord) + : {} + const runtimeVars = + runtimeEnv.vars && typeof runtimeEnv.vars === 'object' + ? (runtimeEnv.vars as JsonRecord) + : {} + runtimeEnv.vars = { ...mainVars, ...runtimeVars } +} + +/** + * Attach the package-app origin as this Worker's custom domain, plus the + * per-user subdomain wildcard zone route (decision 0017; Cloudflare custom + * domains cannot be wildcards). The main Worker's generated config no longer + * lists the package-app host (see tools/ci/resource-utils.ts), so the + * runtime Worker owns both routes. + */ +function addPackageAppRoute(runtimeEnv: JsonRecord, envName: string) { + const vars = + runtimeEnv.vars && typeof runtimeEnv.vars === 'object' + ? (runtimeEnv.vars as JsonRecord) + : {} + const packageAppBaseUrl = vars.PACKAGE_APP_BASE_URL + if (typeof packageAppBaseUrl !== 'string' || !packageAppBaseUrl.trim()) { + return + } + let hostname: string + try { + hostname = new URL(packageAppBaseUrl).hostname + } catch { + fail( + `runtime env.${envName}.vars.PACKAGE_APP_BASE_URL is not a valid URL: ${packageAppBaseUrl}`, + ) + } + const zoneName = readPackageAppZoneName(hostname) + if (!zoneName) { + fail( + `runtime env.${envName}.vars.PACKAGE_APP_BASE_URL host "${hostname}" has no registrable zone name. Use a hostname on a public suffix (for example kodyapps.dev).`, + ) + } + const wildcardPattern = packageAppWildcardRoutePattern(hostname) + const existingRoutes = Array.isArray(runtimeEnv.routes) + ? runtimeEnv.routes.filter( + (route) => + !route || + typeof route !== 'object' || + ((route as JsonRecord).pattern !== hostname && + (route as JsonRecord).pattern !== wildcardPattern), + ) + : [] + runtimeEnv.routes = [ + ...existingRoutes, + { pattern: hostname, custom_domain: true }, + { pattern: wildcardPattern, zone_name: zoneName }, + ] + // Keep the workers.dev trigger as the deploy healthcheck target and a + // backup access path (publishing routes would otherwise disable it). + runtimeEnv.workers_dev = true +} + +function removeRuntimeReferencesFromMainEnv( + mainEnv: JsonRecord, + runtimeWorkerName: string, + mainWorkerName: string, +) { + if (Array.isArray(mainEnv.services)) { + mainEnv.services = mainEnv.services.filter( + (entry) => + !( + entry && + typeof entry === 'object' && + (entry as JsonRecord).service === runtimeWorkerName + ), + ) + } + const durableObjects = mainEnv.durable_objects + if (durableObjects && typeof durableObjects === 'object') { + const bindings = (durableObjects as JsonRecord).bindings + if (Array.isArray(bindings)) { + for (const entry of bindings) { + if ( + entry && + typeof entry === 'object' && + (entry as JsonRecord).script_name === runtimeWorkerName + ) { + // Bind locally during bootstrap; the main Worker still exports + // these classes and its migration history still creates them. + delete (entry as JsonRecord).script_name + } + } + } + } + if (Array.isArray(mainEnv.workflows)) { + for (const entry of mainEnv.workflows) { + if ( + entry && + typeof entry === 'object' && + (entry as JsonRecord).script_name === runtimeWorkerName + ) { + delete (entry as JsonRecord).script_name + // A bootstrap-owned workflow needs its own name; the runtime + // Worker claims the cross-script name once it deploys. + ;(entry as JsonRecord).name = + `${mainWorkerName}-bootstrap-dynamic-callable-workflows` + } + } + } +} + +export type CliOptions = { + envName: string + mainConfigPath: string + runtimeWorkerName: string + mainWorkerName: string + baseConfigPath: string + outConfigPath: string + outMainBootstrapConfigPath?: string +} + +function parseArgs(argv: Array): CliOptions { + const options: Record = {} + for (let index = 0; index < argv.length; index += 2) { + const flag = argv[index] + const value = argv[index + 1] + if (!flag?.startsWith('--') || value === undefined) { + fail(`Invalid argument pair: ${flag ?? ''} ${value ?? ''}`) + } + options[flag.slice(2)] = value + } + const envName = options.env + const mainConfigPath = options['main-config'] + const runtimeWorkerName = options['worker-name'] + const mainWorkerName = options['main-worker-name'] + const outConfigPath = options['out-config'] + if ( + !envName || + !mainConfigPath || + !runtimeWorkerName || + !mainWorkerName || + !outConfigPath + ) { + fail( + 'Usage: node tools/ci/runtime-worker-config.ts generate --env --main-config --worker-name --main-worker-name
--out-config [--base-config ] [--out-main-bootstrap-config ]', + ) + } + return { + envName, + mainConfigPath, + runtimeWorkerName, + mainWorkerName, + baseConfigPath: options['base-config'] ?? defaultBaseConfigPath, + outConfigPath, + outMainBootstrapConfigPath: options['out-main-bootstrap-config'], + } +} + +export async function generate(options: CliOptions) { + const names = { + runtimeWorkerName: options.runtimeWorkerName, + mainWorkerName: options.mainWorkerName, + } + + const runtimeConfig = parseJsonc( + await readFile(options.baseConfigPath, 'utf8'), + ) + const mainConfig = parseJsonc( + await readFile(options.mainConfigPath, 'utf8'), + ) + + const runtimeEnv = getEnvSection( + runtimeConfig, + options.envName, + `runtime config "${options.baseConfigPath}"`, + ) + const mainEnv = getEnvSection( + mainConfig, + options.envName, + `main generated config "${options.mainConfigPath}"`, + ) + + runtimeConfig.name = options.runtimeWorkerName + delete runtimeConfig.$schema + copyResourceIdentifiers({ + runtimeEnv, + mainEnv, + envName: options.envName, + }) + rewriteWorkerNameReferences(runtimeConfig, names) + if (Array.isArray(runtimeEnv.workflows)) { + // Renaming assumes the single dynamic-callable workflow; a second + // entry would silently receive the same name and bind the wrong + // workflow, so fail loudly instead. + if (runtimeEnv.workflows.length > 1) { + fail( + `runtime env.${options.envName}.workflows declares ${String(runtimeEnv.workflows.length)} entries; the generator only knows how to name the single dynamic-callable workflow. Extend the naming logic before adding workflows.`, + ) + } + for (const entry of runtimeEnv.workflows) { + if (entry && typeof entry === 'object') { + ;(entry as JsonRecord).name = + `${options.runtimeWorkerName}-dynamic-callable-workflows` + } + } + } + addPackageAppRoute(runtimeEnv, options.envName) + await writeFile( + options.outConfigPath, + `${JSON.stringify(runtimeConfig, null, '\t')}\n`, + ) + console.error(`Wrote runtime worker config: ${options.outConfigPath}`) + + // Point the main Worker's generated config at the resolved runtime worker + // name (in place), and align the cross-script workflow name. + rewriteWorkerNameReferences(mainConfig, names) + const mainEnvAfterRewrite = getEnvSection( + mainConfig, + options.envName, + `main generated config "${options.mainConfigPath}"`, + ) + if (Array.isArray(mainEnvAfterRewrite.workflows)) { + for (const entry of mainEnvAfterRewrite.workflows) { + if ( + entry && + typeof entry === 'object' && + (entry as JsonRecord).script_name === options.runtimeWorkerName + ) { + ;(entry as JsonRecord).name = + `${options.runtimeWorkerName}-dynamic-callable-workflows` + } + } + } + await writeFile( + options.mainConfigPath, + `${JSON.stringify(mainConfig, null, '\t')}\n`, + ) + console.error( + `Patched main worker config in place: ${options.mainConfigPath}`, + ) + + if (options.outMainBootstrapConfigPath) { + const bootstrapConfig = parseJsonc(JSON.stringify(mainConfig)) + const bootstrapEnv = getEnvSection( + bootstrapConfig, + options.envName, + `main bootstrap config`, + ) + removeRuntimeReferencesFromMainEnv( + bootstrapEnv, + options.runtimeWorkerName, + options.mainWorkerName, + ) + await writeFile( + options.outMainBootstrapConfigPath, + `${JSON.stringify(bootstrapConfig, null, '\t')}\n`, + ) + console.error( + `Wrote main worker bootstrap config: ${options.outMainBootstrapConfigPath}`, + ) + } + + console.log(`runtime_wrangler_config=${options.outConfigPath}`) + if (options.outMainBootstrapConfigPath) { + console.log( + `main_bootstrap_wrangler_config=${options.outMainBootstrapConfigPath}`, + ) + } +} + +export async function main() { + const [command, ...rest] = process.argv.slice(2) + if (command !== 'generate') { + fail('Usage: node tools/ci/runtime-worker-config.ts generate ...') + } + await generate(parseArgs(rest)) +} + +if (isExecutedDirectly(import.meta.url)) { + await main() +} diff --git a/tools/local-runtime-dev-config.ts b/tools/local-runtime-dev-config.ts new file mode 100644 index 0000000000..03e709c888 --- /dev/null +++ b/tools/local-runtime-dev-config.ts @@ -0,0 +1,89 @@ +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { parseJsonc } from './ci/resource-utils.ts' + +type JsonRecord = Record + +/** + * Builds a local-dev variant of the runtime worker config for multi-config + * `wrangler dev`. Wrangler applies `--var` and dotenv-derived values only to + * the primary config, registers each worker under `-`, and treats + * a secondary config's `ai` binding as always-remote — so the committed + * runtime config cannot be passed to `wrangler dev` as-is. The generated + * config pins the dev worker names so the committed cross-script references + * (`kody` <-> `kody-runtime`) resolve, drops the `ai` binding (optional in + * the env schema), and injects the runtime lane's required vars from the + * dev process environment. + */ +export async function writeLocalRuntimeDevConfig({ + runtimeConfigPath, + envName, + mainWorkerDevName, + port, +}: { + runtimeConfigPath: string + envName: string + mainWorkerDevName: string + port: string | undefined +}) { + const sourceText = await readFile(runtimeConfigPath, 'utf8') + const config = parseJsonc(sourceText) + const envs = config.env + if (!envs || typeof envs !== 'object') { + throw new Error(`${runtimeConfigPath} is missing "env".`) + } + const runtimeEnv = (envs as JsonRecord)[envName] + if (!runtimeEnv || typeof runtimeEnv !== 'object') { + throw new Error(`${runtimeConfigPath} is missing "env.${envName}".`) + } + const envRecord = runtimeEnv as JsonRecord + + // Pin the registered dev name to the committed worker name so the main + // config's `service`/`script_name` references to "kody-runtime" resolve. + envRecord.name = config.name + + // The secondary config's `ai` binding is an always-remote type in + // miniflare and fails dev startup without a real Cloudflare session. + delete envRecord.ai + + // Cross-script bindings back to the main worker must target the name it + // registers under in dev (`-`). + const durableObjects = envRecord.durable_objects + if (durableObjects && typeof durableObjects === 'object') { + const bindings = (durableObjects as JsonRecord).bindings + if (Array.isArray(bindings)) { + for (const binding of bindings) { + if (!binding || typeof binding !== 'object') continue + const record = binding as JsonRecord + if (record.script_name === 'kody') { + record.script_name = mainWorkerDevName + } + } + } + } + + const vars = + envRecord.vars && typeof envRecord.vars === 'object' + ? (envRecord.vars as JsonRecord) + : {} + envRecord.vars = vars + vars.WRANGLER_IS_LOCAL_DEV = 'true' + if (port) { + vars.APP_BASE_URL = process.env.APP_BASE_URL || `http://localhost:${port}` + } else if (process.env.APP_BASE_URL) { + vars.APP_BASE_URL = process.env.APP_BASE_URL + } + for (const key of ['COOKIE_SECRET', 'SECRET_STORE_KEY']) { + const value = process.env[key] + if (value) vars[key] = value + } + + // Written next to the committed config so its relative paths (entry + // module, migrations_dir) keep resolving. + const outputPath = path.join( + path.dirname(runtimeConfigPath), + 'wrangler-local-dev.generated.json', + ) + await writeFile(outputPath, `${JSON.stringify(config, null, '\t')}\n`) + return outputPath +} diff --git a/wrangler-env.ts b/wrangler-env.ts index ceec621374..47763c7deb 100644 --- a/wrangler-env.ts +++ b/wrangler-env.ts @@ -16,6 +16,7 @@ import { jobsWorkerWranglerConfigPath, resolveWranglerConfigPath, } from './tools/wrangler-env-config.ts' +import { writeLocalRuntimeDevConfig } from './tools/local-runtime-dev-config.ts' const envName = process.env.CLOUDFLARE_ENV ?? 'production' const portWaitTimeoutMs = 5000 @@ -33,6 +34,7 @@ const hasInspectorPortFlag = args.some( ) const commandArgs = [...args] +let shouldAddRuntimeDevConfig = false if ( !hasConfigFlag && @@ -52,16 +54,42 @@ if ( ) { commandArgs.push('--config', jobsWorkerWranglerConfigPath) } + // Multi-worker local dev (ADR 0016): the main worker's production env + // binds the runtime worker (RUNTIME_WORKER service binding plus + // cross-script Durable Objects), so `wrangler dev` runs both scripts in + // one Miniflare via a secondary --config, which resolves those bindings + // locally. The secondary config is a generated local-dev variant (see + // tools/local-runtime-dev-config.ts) because wrangler applies `--var` + // only to the primary config, registers workers under `-`, + // and treats a secondary `ai` binding as always-remote. + const runtimeWorkerConfigPath = 'packages/runtime-worker/wrangler.jsonc' + // The test env runs the runtime lane in-process (no RUNTIME_WORKER + // binding), and the runtime config defines no test env. + if ( + args[0] === 'dev' && + envName !== 'test' && + existsSync( + resolveWranglerConfigPath(runtimeWorkerConfigPath, process.cwd()), + ) + ) { + shouldAddRuntimeDevConfig = true + } } // The main worker config references pre-bundled modules in `src/generated/` // (see tools/build-worker-bundler-modules.ts), so make sure they exist before // any wrangler command that builds the worker. Skipped for explicit `--config` -// invocations (mock servers, backup control plane) which don't use them. +// invocations (mock servers, backup control plane) which don't use them — +// except the runtime worker, whose entry module lives in the same source +// tree as the main worker and imports the same generated modules. const isWorkerBuildCommand = ['dev', 'build', 'deploy', 'versions'].includes( args[0] ?? '', ) -if (isWorkerBuildCommand && !hasConfigFlag) { +const configArgValue = getArgValue(args, '--config') +const isRuntimeWorkerConfig = Boolean( + configArgValue?.includes('runtime-worker'), +) +if (isWorkerBuildCommand && (!hasConfigFlag || isRuntimeWorkerConfig)) { await ensureWorkerBundlerModules() } @@ -121,6 +149,16 @@ if (isDevCommand && !hasInspectorPortFlag) { commandArgs.push('--inspector-port', resolvedInspectorPort) } +if (shouldAddRuntimeDevConfig) { + const runtimeDevConfigPath = await writeLocalRuntimeDevConfig({ + runtimeConfigPath: 'packages/runtime-worker/wrangler.jsonc', + envName, + mainWorkerDevName: `kody-${envName}`, + port: resolvedPort, + }) + commandArgs.push('--config', runtimeDevConfigPath) +} + const processEnv = { ...process.env, CLOUDFLARE_ENV: envName,