diff --git a/.env.example b/.env.example index 6000a028..284dde6f 100644 --- a/.env.example +++ b/.env.example @@ -2,12 +2,12 @@ # builds. Both files are gitignored. Variables set in the process environment take precedence. # # Source checkouts of the CLI, MCP server, desktop app and website read these files directly. -# Packaged apps bake CONVEX_URL and VECTIS_WEB_URL in at package time and ignore the environment. +# Packaged apps bake VECTIS_CLOUD_URL and VECTIS_WEB_URL in at package time and ignore the +# environment. -# Convex. `pnpm exec convex dev` writes the first three for your development deployment. -CONVEX_DEPLOYMENT=dev:your-deployment-123 -CONVEX_URL=https://your-deployment-123.convex.cloud -CONVEX_SITE_URL=https://your-deployment-123.convex.site +# The cloud Worker (apps/cloud). Development builds may use the local `pnpm --filter +# @vectis/cloud dev` origin, http://127.0.0.1:8787; production builds require HTTPS. +VECTIS_CLOUD_URL=https://cloud-dev.example.com # Clerk. `clerk env pull` writes these for the selected instance. The secret key is only used by # the Clerk CLI and never reaches a build. @@ -15,15 +15,16 @@ CLERK_PUBLISHABLE_KEY=pk_test_replace_with_your_publishable_key CLERK_SECRET_KEY=sk_test_replace_with_your_secret_key # The HTTPS origin that serves the website and /connect for this deployment, without a path. -VECTIS_WEB_URL=https://vectis-dev.your-account.workers.dev +VECTIS_WEB_URL=https://dev.example.com -# Convex deployment variables. These are not read from this file: set them on the deployment -# with `pnpm exec convex env set ` (add `--prod` for production). +# Cloud Worker settings are not read from this file. Plain values live in the `vars` of +# apps/cloud/wrangler.jsonc for each environment; the secret is set with +# `pnpm --filter @vectis/cloud exec wrangler secret put CLERK_WEBHOOK_SECRET --env ` +# (locally in apps/cloud/.dev.vars). # -# CLERK_JWT_ISSUER_DOMAIN Clerk Frontend API URL, for example https://your-app.clerk.accounts.dev -# VECTIS_MACHINE_ISSUER The deployment's .convex.site URL; issues machine tokens -# VECTIS_MACHINE_PRIVATE_KEY RS256 PKCS#8 PEM used to sign machine tokens -# VECTIS_WEB_URL Same value as above; used for GitHub callbacks and redirects +# VECTIS_WEB_URL Same value as above; CORS origin, GitHub callbacks and redirects +# VECTIS_CLOUD_URL The Worker's own origin; GitHub webhook and manifest URLs +# CLERK_ISSUER Clerk Frontend API URL, for example https://your-app.clerk.accounts.dev # VECTIS_GITHUB_APP_NAME GitHub App name, for example "Vectis [DEV]" # VECTIS_GITHUB_APP_PUBLIC "false" keeps the GitHub App installable only by its owner -# CLERK_WEBHOOK_SECRET Signing secret of the Clerk user.deleted webhook (whsec_...) +# CLERK_WEBHOOK_SECRET Secret: signing secret of the Clerk user.deleted webhook (whsec_...) diff --git a/.github/labels.json b/.github/labels.json index 1596c71d..3959d94c 100644 --- a/.github/labels.json +++ b/.github/labels.json @@ -57,7 +57,7 @@ { "name": "area:cloud", "color": "d4c5f9", - "description": "Convex backend, pairing, remote control, and the GitHub App." + "description": "Cloud Worker, pairing, remote control, and the GitHub App." }, { "name": "area:web", diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 91c9e590..1c8ca1f9 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -30,7 +30,7 @@ jobs: - run: pnpm site:build env: VECTIS_FLAVOR: production - CONVEX_URL: ${{ vars.CONVEX_URL }} + VECTIS_CLOUD_URL: ${{ vars.VECTIS_CLOUD_URL }} CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} - run: pnpm --filter @vectis/web exec wrangler deploy --env production --dry-run --outdir ../../dist/site-worker - id: artifact @@ -67,8 +67,7 @@ jobs: - run: node scripts/site-artifact.ts verify env: VECTIS_SITE_ARTIFACT_DIGEST: ${{ needs.build.outputs.digest }} - - run: pnpm --filter @vectis/web exec wrangler deploy ../../dist/site-worker/worker.js --config wrangler.jsonc --env production --no-bundle --var VECTIS_CONVEX_SITE:${CONVEX_SITE_URL} + - run: pnpm --filter @vectis/web exec wrangler deploy ../../dist/site-worker/worker.js --config wrangler.jsonc --env production --no-bundle env: - CONVEX_SITE_URL: ${{ vars.CONVEX_SITE_URL }} CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7e86497a..4d3d3f94 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,9 +45,15 @@ jobs: node-version: 24.22.2 cache: pnpm - run: pnpm install --frozen-lockfile - - run: pnpm exec convex deploy -y + # Migrations are additive, so the running Worker keeps working until the new one deploys. + - run: pnpm --filter @vectis/cloud exec wrangler d1 migrations apply DB --env production --remote env: - CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + - run: pnpm --filter @vectis/cloud exec wrangler deploy --env production + env: + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} desktop: name: desktop @@ -57,7 +63,7 @@ jobs: timeout-minutes: 120 environment: release env: - CONVEX_URL: ${{ vars.CONVEX_URL }} + VECTIS_CLOUD_URL: ${{ vars.VECTIS_CLOUD_URL }} VECTIS_WEB_URL: ${{ vars.VECTIS_WEB_URL }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.oxfmtrc.json b/.oxfmtrc.json index cdc35e03..bba804b5 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -1,4 +1,4 @@ { "$schema": "./node_modules/oxfmt/configuration_schema.json", - "ignorePatterns": ["convex/_generated/**"] + "ignorePatterns": ["apps/cloud/worker-configuration.d.ts"] } diff --git a/.oxlintrc.json b/.oxlintrc.json index ae688564..55824538 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,3 +1,3 @@ { - "ignorePatterns": ["convex/_generated/**"] + "ignorePatterns": ["apps/cloud/worker-configuration.d.ts"] } diff --git a/AGENTS.md b/AGENTS.md index 97461b0c..8da7a0c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Vectis -Vectis runs repository-scoped GitHub Actions runners in disposable local virtual machines. A local background service owns processes, virtual machines, credentials, and durable execution state. The desktop app, CLI, and MCP server are clients of the same local contract. A Convex backend owns cloud identity, the GitHub App, and remote commands. CI execution always stays on the host. +Vectis runs repository-scoped GitHub Actions runners in disposable local virtual machines. A local background service owns processes, virtual machines, credentials, and durable execution state. The desktop app, CLI, and MCP server are clients of the same local contract. A Cloudflare Worker (`apps/cloud`, D1 and one Durable Object per machine) owns cloud identity, the GitHub App, and remote commands. CI execution always stays on the host. ## What we never compromise on @@ -51,7 +51,7 @@ Before calling a change done, walk this list and say which entries applied: - **Contract.** Commands are variants of `Command` in `packages/protocol/src/index.ts` and are listed in `capabilities`. Read-only remote queries go through `LocalQuery` in `packages/protocol/src/controller.ts`. - **Service.** `apps/server/src/service.ts` handles each command; local HTTP endpoints live in `apps/server/src/http.ts` with a matching `VectisClient` method in `packages/client/src/index.ts`. -- **Cloud.** Cloud-backed commands go through `packages/client/src/cloud-relay.ts` and a Convex function in `convex/`. Regenerate `convex/_generated` after changing Convex functions. +- **Cloud.** Cloud-backed commands go through `packages/client/src/cloud-relay.ts` and a route of the cloud Worker in `apps/cloud`. Machine and human routes are typed in `packages/protocol/src/cloud.ts`; schema changes are new files in `apps/cloud/migrations`. - **CLI.** Help text and parsing in `apps/cli/src/main.ts`. The help text also generates the public CLI reference. - **Desktop.** `apps/desktop/src/renderer/lib/operations.ts` (labels, cancellable and preparation commands) and the relevant view in `apps/desktop/src/renderer/views`. - **MCP.** `vectis_command` derives its schema from `Command` and usually needs no change. New tools belong in `apps/mcp/src/server.ts`. @@ -63,19 +63,19 @@ Before calling a change done, walk this list and say which entries applied: ## Development - Node 24 and pnpm (`packageManager` in `package.json`) with the committed lockfile. `pnpm install` installs. -- `pnpm build` compiles the headless apps, packages, Convex code, and scripts into `dist/`. Anything run from `dist/` needs a fresh build. +- `pnpm build` compiles the headless apps, packages, and scripts into `dist/`. Anything run from `dist/` needs a fresh build. - `pnpm vectis --help` runs the built CLI. `pnpm mcp` runs the built MCP server. Pass `--home "$(mktemp -d)"`. - `pnpm desktop:start` launches the development desktop (`Vectis Dev`) with a standalone Node runtime for the background service. Set `VECTIS_HOME` first. - `pnpm web:dev` runs the Astro website. `pnpm docs:dev` runs the Blume documentation. `pnpm site:build` builds both and stages them into `apps/web/site` with the docs under `/docs`. - Web, docs, and desktop builds download General Sans from Fontshare into the gitignored `packages/design/fonts`. Its license forbids committing the font files. `VECTIS_ALLOW_FONT_FALLBACK=1` builds without them. - The Swift helpers live in `native/apple`: `swift build --package-path native/apple`, then ad-hoc sign `vectis-vm` with `native/apple/entitlements.plist`. QEMU build notes are in `native/qemu/README.md`. -- Source checkouts are development builds. They read `CONVEX_URL`, `VECTIS_WEB_URL` and `CLERK_PUBLISHABLE_KEY` from the repository `.env.local` (see `.env.example`); `pnpm exec convex dev` fills in the Convex values. Production packages (`pnpm package:cli --flavor production`) and `pnpm site:deploy` read `.env.production.local` and bake the values in. -- Never deploy (Convex, Cloudflare, `site:deploy`, `site:deploy:dev`), publish packages, push tags or create releases unless asked. [RELEASING.md](RELEASING.md) describes how CI and releases run. +- Source checkouts are development builds. They read `VECTIS_CLOUD_URL`, `VECTIS_WEB_URL` and `CLERK_PUBLISHABLE_KEY` from the repository `.env.local` (see `.env.example`). `pnpm --filter @vectis/cloud dev` runs the cloud Worker locally with a local D1 database. Production packages (`pnpm package:cli --flavor production`) and `pnpm site:deploy` read `.env.production.local` and bake the values in. +- Never deploy (the cloud Worker, D1 migrations with `--remote`, `site:deploy`, `site:deploy:dev`), publish packages, push tags or create releases unless asked. [RELEASING.md](RELEASING.md) describes how CI and releases run. ## Verifying - Smallest proof that the change works: `pnpm exec vitest run ` for the tests you touched, plus `pnpm typecheck` or `pnpm lint` when types or lint rules are affected. `pnpm check` runs format, lint, typecheck, and tests together. -- Tests are colocated `*.test.ts` files; Convex tests live in `tests/convex` and use `convex-test`. +- Tests are colocated `*.test.ts` files. Cloud Worker tests live in `apps/cloud/test` and run inside workerd through `@cloudflare/vitest-plugin` (vitest project `cloud`). - Every test uses its own temporary home (`mkdtemp`) and synthetic data. Stub launchd, the Keychain, and cloud deployments the way existing tests do. - Test authentication, ownership, failure recovery, migration preservation, and real protocol boundaries. Do not add tests that only mirror the implementation or assert static UI markup. - Native VM runs and real GitHub job evidence are reported separately from synthetic tests. Never claim a VM boot, a GitHub job, signing, or a deployment without evidence. @@ -90,23 +90,23 @@ Before calling a change done, walk this list and say which entries applied: ## How it works -Clients reach the service over an authenticated loopback HTTP API: `127.0.0.1`, a random port, and a bearer token in the home's `connection.json`. Cross-origin browser requests are rejected. Commands become durable operations in SQLite, keyed for idempotency, and the runner package supervises VMs, disks, and guest processes. When a host is paired with the cloud, the service keeps an outgoing authenticated relay to Convex: remote commands arrive through it and replay locally under the same idempotency rules, and GitHub webhooks, runner leases, and job state flow through Convex. The host never exposes an inbound port. +Clients reach the service over an authenticated loopback HTTP API: `127.0.0.1`, a random port, and a bearer token in the home's `connection.json`. Cross-origin browser requests are rejected. Commands become durable operations in SQLite, keyed for idempotency, and the runner package supervises VMs, disks, and guest processes. When a host is paired with the cloud, the service keeps an outgoing authenticated WebSocket to its machine hub in the cloud Worker plus HTTPS calls: the cloud signals changes over the socket, the service syncs, remote commands replay locally under the same idempotency rules, and GitHub webhooks, runner leases, and job state flow through the Worker. The cloud is event-driven; nothing polls it on a timer except a 15-minute safety sync. The host never exposes an inbound port. ## Where code lives - `apps/server` - local service: HTTP API, SQLite store, command dispatch, image preparation, cloud relay startup. - `apps/cli`, `apps/mcp` - headless control surfaces. - `apps/desktop` - Electron shell and React renderer over the shared local client. -- `apps/web` - Astro website, the browser `/connect` account page, and a Cloudflare Worker that proxies GitHub callbacks to Convex. +- `apps/web` - Astro website, the browser `/connect` account page, and a Cloudflare Worker that proxies GitHub callbacks to the cloud Worker. - `apps/docs` - public Blume documentation. Public Markdown lives only in `apps/docs/docs`; the CLI reference is generated from the built CLI help. -- `packages/protocol` - Effect Schema contracts shared by every surface and Convex. No runtime logic. +- `packages/protocol` - Effect Schema contracts shared by every surface and the cloud Worker. No runtime logic. - `packages/client` - local and remote clients, cloud relay, pairing, Keychain and LaunchAgent helpers. - `packages/runner` - VM runtime and supervision, QEMU, the Apple helper bridge, guest preparation, storage inspection. - `packages/github`, `packages/migration` - GitHub App contracts, job and runner logic, workflow migration previews. - `packages/design` - design tokens, fonts, and the shared site header and footer. -- `convex` - cloud identity, authorization, GitHub integration, and remote operations. +- `apps/cloud` - cloud Worker: identity, authorization, GitHub integration, and remote operations on D1 and Durable Objects. - `native/apple`, `native/qemu` - Apple virtualization and Keychain helpers, QEMU build notes. -- `scripts` - docs generation, site staging, packaging, and verification. `tests/convex` - Convex tests. +- `scripts` - docs generation, site staging, packaging, and verification. - `skills/vectis` - portable agent guidance for using Vectis. ## Taste diff --git a/README.md b/README.md index b98f7de9..5949a2ae 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ A background service on your Mac owns the VMs, disks and credentials. When GitHu ## Self-hosting and building from source -Vectis is MIT licensed and every part of it is in this repository: the service, the apps, the Convex backend, the website and the GitHub App manifest. See [Build from source](https://vectis.kerd.dev/docs/self-hosting/build-from-source) and [Run your own cloud](https://vectis.kerd.dev/docs/self-hosting/cloud). +Vectis is MIT licensed and every part of it is in this repository: the service, the apps, the cloud Worker, the website and the GitHub App manifest. See [Build from source](https://vectis.kerd.dev/docs/self-hosting/build-from-source) and [Run your own cloud](https://vectis.kerd.dev/docs/self-hosting/cloud). ```sh pnpm install diff --git a/RELEASING.md b/RELEASING.md index beaa2bb3..38fea460 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -43,22 +43,21 @@ Rulesets need a public repository on the free plan. After the repository is publ | Secret | `APPLE_API_KEY_P8` | Contents of the App Store Connect API key (`.p8`) | | Variable | `APPLE_API_KEY_ID`, `APPLE_API_ISSUER` | The API key ID and issuer ID | | Variable | `MACOS_SIGNING_IDENTITY` | For example `Developer ID Application: Name (TEAMID)` | -| Variable | `CONVEX_URL`, `VECTIS_WEB_URL` | The production deployment baked into the apps | +| Variable | `VECTIS_CLOUD_URL`, `VECTIS_WEB_URL` | The production deployment baked into the apps | `production` (deployment branches and tags: `main`, `v*`): -| Kind | Name | Value | -| -------- | -------------------------------------------------------- | ----------------------------------------------- | -| Secret | `CONVEX_DEPLOY_KEY` | Production deploy key from the Convex dashboard | -| Secret | `CLOUDFLARE_API_TOKEN` | Token that can deploy Workers on the account | -| Variable | `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID | -| Variable | `CONVEX_URL`, `CONVEX_SITE_URL`, `CLERK_PUBLISHABLE_KEY` | Production values for the website build | +| Kind | Name | Value | +| -------- | ------------------------------------------- | ------------------------------------------------------ | +| Secret | `CLOUDFLARE_API_TOKEN` | Token that can deploy Workers and migrate D1 databases | +| Variable | `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID | +| Variable | `VECTIS_CLOUD_URL`, `CLERK_PUBLISHABLE_KEY` | Production values for the website build | ## Cut a release 1. Open a pull request titled `chore(release): vX.Y.Z` that only changes `version` in the root `package.json`. Merge it. 2. Tag the merge commit and push the tag: `git tag vX.Y.Z && git push origin vX.Y.Z`. A tag with a hyphen, such as `v0.2.0-rc.1`, becomes a prerelease. -3. Approve the `release` deployment. The workflow checks the tag against the version, runs `check`, deploys the Convex backend, builds, signs and notarizes the app in the macOS VM, publishes the GitHub release with generated notes, and redeploys the website so the changelog and download pick up the release. +3. Approve the `release` deployment. The workflow checks the tag against the version, runs `check`, applies D1 migrations and deploys the cloud Worker, builds, signs and notarizes the app in the macOS VM, publishes the GitHub release with generated notes, and redeploys the website so the changelog and download pick up the release. 4. Installed apps find the update within four hours. Backend changes must keep working with the previous release, because installed apps update at their own pace. diff --git a/apps/cli/src/main.test.ts b/apps/cli/src/main.test.ts index 37ac48c2..188975fa 100644 --- a/apps/cli/src/main.test.ts +++ b/apps/cli/src/main.test.ts @@ -87,7 +87,7 @@ test("machine configure settles without --wait and status prints runner capacity "--max-runners", "3", "--memory-budget-mib", - "8192", + "2048", "--key", "cli-configure", "--json", @@ -99,7 +99,7 @@ test("machine configure settles without --wait and status prints runner capacity ); expect(server.service.snapshot().machine).toMatchObject({ maxRunners: 3, - memoryBudgetMiB: 8192, + memoryBudgetMiB: 2048, }); output.mockClear(); vi.resetModules(); diff --git a/apps/cloud/migrations/0001_init.sql b/apps/cloud/migrations/0001_init.sql new file mode 100644 index 00000000..77f6da41 --- /dev/null +++ b/apps/cloud/migrations/0001_init.sql @@ -0,0 +1,290 @@ +-- Column names mirror the protocol field names. Timestamps are milliseconds since the epoch, +-- booleans are 0 or 1, and nested values are JSON text in *Json columns. + +CREATE TABLE machines ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + localId TEXT NOT NULL, + name TEXT NOT NULL, + createdAt INTEGER NOT NULL, + environmentsJson TEXT, + paused INTEGER, + UNIQUE (owner, localId) +); + +CREATE TABLE machineCredentials ( + machineId TEXT PRIMARY KEY REFERENCES machines (id) ON DELETE CASCADE, + digest TEXT NOT NULL, + createdAt INTEGER NOT NULL +); + +CREATE TABLE pairings ( + requestDigest TEXT PRIMARY KEY, + owner TEXT NOT NULL, + machineId TEXT NOT NULL REFERENCES machines (id) ON DELETE CASCADE, + expiresAt INTEGER NOT NULL +); +CREATE INDEX pairings_owner ON pairings (owner); +CREATE INDEX pairings_expiry ON pairings (expiresAt); + +CREATE TABLE controllers ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + name TEXT NOT NULL, + requestDigest TEXT NOT NULL UNIQUE, + credentialDigest TEXT NOT NULL, + pairingExpiresAt INTEGER NOT NULL, + expiresAt INTEGER NOT NULL, + revoked INTEGER NOT NULL DEFAULT 0, + createdAt INTEGER NOT NULL +); +CREATE INDEX controllers_owner ON controllers (owner, revoked); +CREATE INDEX controllers_expiry ON controllers (expiresAt); + +CREATE TABLE operations ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + machineId TEXT NOT NULL REFERENCES machines (id) ON DELETE CASCADE, + key TEXT NOT NULL, + commandJson TEXT NOT NULL, + phase TEXT NOT NULL CHECK ( + phase IN ('accepted', 'claimed', 'running', 'action_required', 'succeeded', 'failed', 'cancelled') + ), + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + resultJson TEXT, + cancelRequested INTEGER NOT NULL DEFAULT 0, + UNIQUE (owner, key) +); +CREATE INDEX operations_machine_phase ON operations (machineId, phase, createdAt); +CREATE INDEX operations_accepted ON operations (createdAt) WHERE phase = 'accepted'; +CREATE INDEX operations_updated ON operations (updatedAt); + +CREATE TABLE inspections ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + machineId TEXT NOT NULL REFERENCES machines (id) ON DELETE CASCADE, + key TEXT NOT NULL, + queryJson TEXT NOT NULL, + expiresAt INTEGER NOT NULL, + responseJson TEXT, + pending INTEGER NOT NULL, + UNIQUE (owner, key) +); +CREATE INDEX inspections_machine_pending ON inspections (machineId, pending, expiresAt); +CREATE INDEX inspections_expiry ON inspections (expiresAt); + +CREATE TABLE githubApps ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + appId INTEGER NOT NULL, + slug TEXT NOT NULL, + ownerId INTEGER NOT NULL, + clientId TEXT NOT NULL, + privateKey TEXT NOT NULL, + clientSecret TEXT NOT NULL, + webhookSecret TEXT NOT NULL +); + +CREATE TABLE githubAppSetups ( + stateDigest TEXT PRIMARY KEY, + ownerId INTEGER NOT NULL, + ownerLogin TEXT NOT NULL, + organization INTEGER, + expiresAt INTEGER NOT NULL, + consumed INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE githubLinks ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + digest TEXT NOT NULL UNIQUE, + verifier TEXT, + browserDigest TEXT, + completionDigest TEXT, + expiresAt INTEGER NOT NULL, + phase TEXT NOT NULL CHECK (phase IN ('pending', 'exchanging', 'review', 'failed')), + userJson TEXT +); +CREATE INDEX githubLinks_owner ON githubLinks (owner); +CREATE INDEX githubLinks_expiry ON githubLinks (expiresAt); + +CREATE TABLE githubAccounts ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + githubId INTEGER NOT NULL UNIQUE, + login TEXT NOT NULL, + installationsJson TEXT NOT NULL, + verifiedAt INTEGER NOT NULL +); +CREATE INDEX githubAccounts_owner ON githubAccounts (owner); + +CREATE TABLE repositoryBindings ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + accountId TEXT NOT NULL REFERENCES githubAccounts (id) ON DELETE CASCADE, + machineId TEXT NOT NULL REFERENCES machines (id) ON DELETE CASCADE, + repositoryId INTEGER NOT NULL, + repositoryName TEXT NOT NULL, + repositoryOwner TEXT, + installationId INTEGER NOT NULL, + environmentId TEXT NOT NULL, + enabled INTEGER NOT NULL, + infraRerun INTEGER NOT NULL DEFAULT 0, + automatic INTEGER NOT NULL DEFAULT 0, + capacityMode TEXT NOT NULL DEFAULT 'demand' CHECK (capacityMode IN ('demand', 'scale_set')), + accessJson TEXT, + cacheVolumeJson TEXT, + verifiedAt INTEGER NOT NULL, + UNIQUE (machineId, repositoryId, environmentId) +); +CREATE INDEX repositoryBindings_owner ON repositoryBindings (owner); +CREATE INDEX repositoryBindings_repository + ON repositoryBindings (installationId, repositoryId, enabled, automatic); + +-- Scale sets outlive their binding and machine: deleting either leaves the row in phase +-- 'deleting' so the cron can remove the remote set. No foreign keys on purpose. +CREATE TABLE runnerScaleSets ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + installationId INTEGER NOT NULL, + repositoryId INTEGER NOT NULL, + bindingId TEXT NOT NULL, + machineId TEXT NOT NULL, + repositoryOwner TEXT NOT NULL, + repositoryName TEXT NOT NULL, + githubUserId INTEGER NOT NULL, + githubLogin TEXT NOT NULL, + scaleSetId INTEGER, + name TEXT NOT NULL, + generation INTEGER NOT NULL, + sessionId TEXT, + phase TEXT NOT NULL CHECK ( + phase IN ('opening', 'active', 'refreshing', 'closing', 'closed', 'action_required', 'deleting') + ), + cleanupDueAt INTEGER, + cleanupAttempt INTEGER NOT NULL DEFAULT 0, + updatedAt INTEGER NOT NULL, + UNIQUE (bindingId, machineId) +); +CREATE INDEX runnerScaleSets_cleanup ON runnerScaleSets (cleanupDueAt) WHERE phase = 'deleting'; + +CREATE TABLE runnerLeases ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + machineId TEXT NOT NULL REFERENCES machines (id) ON DELETE CASCADE, + bindingId TEXT NOT NULL REFERENCES repositoryBindings (id) ON DELETE CASCADE, + environmentId TEXT NOT NULL, + os TEXT NOT NULL CHECK (os IN ('linux', 'macos', 'windows')), + key TEXT NOT NULL, + environmentRevision TEXT, + phase TEXT NOT NULL CHECK (phase IN ('preparing', 'ready', 'action_required', 'released')), + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + runnerId INTEGER, + encodedConfig TEXT, + cacheWrite INTEGER, + cacheWriteReason TEXT, + cacheRunJson TEXT, + configExpiresAt INTEGER, + accessJson TEXT, + scaleSetGeneration INTEGER, + jobId INTEGER, + automatic INTEGER NOT NULL DEFAULT 0, + UNIQUE (machineId, key) +); +CREATE INDEX runnerLeases_machine_phase ON runnerLeases (machineId, phase); +CREATE INDEX runnerLeases_config_expiry ON runnerLeases (configExpiresAt) + WHERE configExpiresAt IS NOT NULL; +CREATE INDEX runnerLeases_updated ON runnerLeases (updatedAt); + +-- The primary key makes a job claimable by one demand row; see runners/demand.ts. +CREATE TABLE runnerDemands ( + installationId INTEGER NOT NULL, + repositoryId INTEGER NOT NULL, + jobId INTEGER NOT NULL, + owner TEXT NOT NULL, + bindingId TEXT NOT NULL REFERENCES repositoryBindings (id) ON DELETE CASCADE, + operationId TEXT NOT NULL, + attempt INTEGER NOT NULL, + failures INTEGER NOT NULL DEFAULT 0, + updatedAt INTEGER NOT NULL, + PRIMARY KEY (installationId, repositoryId, jobId) +); +CREATE INDEX runnerDemands_binding ON runnerDemands (bindingId); + +CREATE TABLE githubJobs ( + installationId INTEGER NOT NULL, + repositoryId INTEGER NOT NULL, + jobId INTEGER NOT NULL, + runId INTEGER NOT NULL, + runAttempt INTEGER, + actorId INTEGER, + triggeringActorId INTEGER, + name TEXT NOT NULL, + htmlUrl TEXT, + workflowName TEXT, + status TEXT NOT NULL CHECK (status IN ('waiting', 'queued', 'in_progress', 'completed')), + conclusion TEXT, + labelsJson TEXT NOT NULL, + runnerId INTEGER, + runnerName TEXT, + updatedAt INTEGER NOT NULL, + PRIMARY KEY (installationId, repositoryId, jobId) +); +CREATE INDEX githubJobs_repository ON githubJobs (installationId, repositoryId, status, updatedAt); +CREATE INDEX githubJobs_recent ON githubJobs (installationId, repositoryId, updatedAt); +CREATE INDEX githubJobs_runner ON githubJobs (installationId, repositoryId, runnerName) + WHERE runnerName IS NOT NULL; +CREATE INDEX githubJobs_queued ON githubJobs (installationId, repositoryId) + WHERE status = 'queued'; +CREATE INDEX githubJobs_updated ON githubJobs (updatedAt); + +CREATE TABLE githubDeliveries ( + deliveryId TEXT PRIMARY KEY, + event TEXT NOT NULL, + action TEXT, + repositoryId INTEGER, + installationId INTEGER, + jobId INTEGER, + receivedAt INTEGER NOT NULL +); +CREATE INDEX githubDeliveries_received ON githubDeliveries (receivedAt); + +CREATE TABLE githubRedeliveryCheckpoint ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + deliveredAt INTEGER NOT NULL, + scannedAt INTEGER NOT NULL +); + +CREATE TABLE githubRedeliveryRequests ( + guid TEXT PRIMARY KEY, + requestedAt INTEGER NOT NULL +); +CREATE INDEX githubRedeliveryRequests_requested ON githubRedeliveryRequests (requestedAt); + +-- Receipts prevent duplicate GitHub POSTs within the 30-day retention window. +CREATE TABLE runCommandReceipts ( + machineId TEXT NOT NULL REFERENCES machines (id) ON DELETE CASCADE, + key TEXT NOT NULL, + bindingId TEXT NOT NULL, + requestHash TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('submitted', 'accepted', 'uncertain', 'rejected')), + outcomeJson TEXT, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + PRIMARY KEY (machineId, key) +); +CREATE INDEX runCommandReceipts_updated ON runCommandReceipts (updatedAt); + +CREATE TABLE migrationPreviews ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + machineId TEXT NOT NULL REFERENCES machines (id) ON DELETE CASCADE, + bindingId TEXT NOT NULL REFERENCES repositoryBindings (id) ON DELETE CASCADE, + reportJson TEXT NOT NULL, + environmentRevision TEXT, + createdAt INTEGER NOT NULL, + expiresAt INTEGER NOT NULL +); +CREATE INDEX migrationPreviews_machine ON migrationPreviews (machineId); +CREATE INDEX migrationPreviews_expiry ON migrationPreviews (expiresAt); diff --git a/apps/cloud/package.json b/apps/cloud/package.json new file mode 100644 index 00000000..2c4232a5 --- /dev/null +++ b/apps/cloud/package.json @@ -0,0 +1,19 @@ +{ + "name": "@vectis/cloud", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev --env development", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "jose": "6.2.13" + }, + "devDependencies": { + "@cloudflare/vitest-plugin": "1.4.8", + "@cloudflare/workers-types": "5.20260929.1", + "typescript": "7.0.2", + "vitest": "5.0.5", + "wrangler": "4.153.0" + } +} diff --git a/apps/cloud/src/account.ts b/apps/cloud/src/account.ts new file mode 100644 index 00000000..bcfe66e0 --- /dev/null +++ b/apps/cloud/src/account.ts @@ -0,0 +1,152 @@ +import { Schema } from "effect"; +import { GitHubUser, type AccountOverview } from "../../../packages/protocol/src/cloud.js"; +import type { HumanContext, HumanHandler, RawHandler } from "./context.js"; +import type { Env } from "./env.js"; +import { accountRecord, type GitHubAccountRow, type GitHubLinkRow } from "./db.js"; +import { listMachines, purgeMachine } from "./machines.js"; +import { accountBindings } from "./github/repositories.js"; +import { markScaleSetsForCleanup } from "./runners/scale-sets.js"; +import { readBody } from "./http.js"; +export async function overview({ env, owner }: HumanContext): Promise { + const [machines, bindings, accounts, pending, controllers, app] = await Promise.all([ + listMachines(env, owner), + accountBindings(env, owner), + env.DB.prepare("SELECT * FROM githubAccounts WHERE owner = ? LIMIT 100") + .bind(owner) + .all(), + env.DB.prepare("SELECT * FROM githubLinks WHERE owner = ? AND expiresAt > ? LIMIT 20") + .bind(owner, Date.now()) + .all(), + env.DB.prepare( + "SELECT id, name, createdAt, expiresAt FROM controllers WHERE owner = ? AND revoked = 0 AND expiresAt > ? LIMIT 20", + ) + .bind(owner, Date.now()) + .all(), + env.DB.prepare("SELECT slug FROM githubApps WHERE singleton = 1").first<{ slug: string }>(), + ]); + return { + machines, + bindings, + github: { + accounts: accounts.results.map((row) => { + const { id, githubId, login, installations, verifiedAt } = accountRecord(row); + return { id, githubId, login, installations, verifiedAt }; + }), + pending: pending.results.map((row) => ({ + id: row.id, + phase: row.phase, + ...(row.userJson + ? { user: Schema.decodeUnknownSync(GitHubUser)(JSON.parse(row.userJson)) } + : {}), + })), + }, + controllers: controllers.results, + installation: app ? { url: `https://github.com/apps/${app.slug}/installations/new` } : null, + }; +} +async function purgeOwner(env: Env, owner: string) { + for (;;) { + const machines = await env.DB.prepare("SELECT id FROM machines WHERE owner = ? LIMIT 100") + .bind(owner) + .all<{ id: string }>(); + if (!machines.results.length) break; + for (const machine of machines.results) await purgeMachine(env, machine.id); + } + for (;;) { + const bindings = await env.DB.prepare( + "SELECT id FROM repositoryBindings WHERE owner = ? LIMIT 100", + ) + .bind(owner) + .all<{ id: string }>(); + if (!bindings.results.length) break; + await markScaleSetsForCleanup( + env, + bindings.results.map((b) => b.id), + ); + await env.DB.batch( + bindings.results.map((b) => + env.DB.prepare("DELETE FROM repositoryBindings WHERE id = ?").bind(b.id), + ), + ); + } + for (const table of [ + "githubAccounts", + "githubLinks", + "controllers", + "pairings", + "operations", + "inspections", + ]) { + for (;;) { + const result = await env.DB.prepare( + `DELETE FROM ${table} WHERE rowid IN (SELECT rowid FROM ${table} WHERE owner = ? LIMIT 100)`, + ) + .bind(owner) + .run(); + if (result.meta.changes < 100) break; + } + } +} +export const removeAccount: HumanHandler<"/account/remove"> = async ({ env, owner }) => { + await purgeOwner(env, owner); + return null; +}; +export async function verifyClerkWebhook( + secret: string, + headers: Pick, + body: Uint8Array, + now = Date.now(), +) { + const id = headers.get("svix-id"); + const timestamp = headers.get("svix-timestamp"); + const signatures = headers.get("svix-signature"); + if (!secret.startsWith("whsec_") || !id || !timestamp || !signatures) return false; + if (!/^[0-9]{1,12}$/.test(timestamp) || Math.abs(now - Number(timestamp) * 1000) > 300000) + return false; + const key = await crypto.subtle.importKey( + "raw", + Uint8Array.from(atob(secret.slice(6)), (character) => character.charCodeAt(0)), + { name: "HMAC", hash: "SHA-256" }, + false, + ["verify"], + ); + const signed = new Uint8Array([...new TextEncoder().encode(`${id}.${timestamp}.`), ...body]); + for (const entry of signatures.split(" ")) { + const [version, value] = entry.split(","); + if (version !== "v1" || !value || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) continue; + const provided = Uint8Array.from(atob(value), (character) => character.charCodeAt(0)); + if (await crypto.subtle.verify("HMAC", key, provided, signed)) return true; + } + return false; +} + +const Payload = Schema.Struct({ + type: Schema.String, + data: Schema.Struct({ id: Schema.optional(Schema.String) }), +}); +export const clerkWebhook: RawHandler = async ({ env }, request) => { + if (!env.CLERK_WEBHOOK_SECRET) + return new Response("Clerk webhooks are not configured", { status: 503 }); + let body: Uint8Array; + try { + body = await readBody(request, 256 * 1024); + } catch { + return new Response("Payload too large", { status: 413 }); + } + let valid = false; + try { + valid = await verifyClerkWebhook(env.CLERK_WEBHOOK_SECRET, request.headers, body); + } catch { + /* Invalid secret or signature encoding. */ + } + if (!valid) return new Response("Invalid signature", { status: 401 }); + let payload: typeof Payload.Type; + try { + payload = Schema.decodeUnknownSync(Payload)(JSON.parse(new TextDecoder().decode(body))); + } catch { + return new Response("Invalid payload", { status: 400 }); + } + if (payload.type === "user.deleted" && payload.data.id) + await purgeOwner(env, `${env.CLERK_ISSUER}|${payload.data.id}`); + return Response.json({ accepted: true }, { status: 202 }); +}; diff --git a/apps/cloud/src/auth/controller.ts b/apps/cloud/src/auth/controller.ts new file mode 100644 index 00000000..7ba1483d --- /dev/null +++ b/apps/cloud/src/auth/controller.ts @@ -0,0 +1,27 @@ +import type { Env } from "../env.js"; +import { CloudError } from "../errors.js"; +import type { ControllerRow } from "../db.js"; +import { sameDigest, sha256Hex } from "../ids.js"; + +export const unauthorizedController = () => + new CloudError("controller_unauthorized", 401, { nextStep: "Sign in again with vectis login." }); + +export async function authenticateController( + env: Env, + header: string | null, + now = Date.now(), +): Promise { + const match = /^Bearer ([a-z0-9]{1,80})\.([A-Za-z0-9_-]{43})$/.exec(header ?? ""); + if (!match?.[1] || !match[2]) throw unauthorizedController(); + const row = await env.DB.prepare("SELECT * FROM controllers WHERE id = ?") + .bind(match[1]) + .first(); + if ( + !row || + row.revoked || + row.expiresAt <= now || + !sameDigest(row.credentialDigest, await sha256Hex(match[2])) + ) + throw unauthorizedController(); + return row; +} diff --git a/apps/cloud/src/auth/human.ts b/apps/cloud/src/auth/human.ts new file mode 100644 index 00000000..e8cacbcd --- /dev/null +++ b/apps/cloud/src/auth/human.ts @@ -0,0 +1,31 @@ +import { createRemoteJWKSet, jwtVerify, type JWTVerifyGetKey } from "jose"; +import type { Env } from "../env.js"; +import { unauthorizedHuman } from "../errors.js"; + +const keySets = new Map(); + +// Verifies a Clerk session token. The owner string is +// `${issuer}|${subject}`, which is what the Clerk webhook purges. +export async function authenticateHuman(env: Env, request: Request) { + const header = request.headers.get("Authorization"); + if (!header?.startsWith("Bearer ")) throw unauthorizedHuman(); + const issuer = env.CLERK_ISSUER; + let keys = keySets.get(issuer); + if (!keys) { + keys = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`)); + keySets.set(issuer, keys); + } + try { + const { payload } = await jwtVerify(header.slice(7), keys, { + issuer, + algorithms: ["RS256"], + clockTolerance: 5, + }); + // Clerk sets azp to the origin the session belongs to; only our web origin may act. + if (!payload.sub || (payload.azp !== undefined && payload.azp !== env.VECTIS_WEB_URL)) + throw unauthorizedHuman(); + return `${issuer}|${payload.sub}`; + } catch { + throw unauthorizedHuman(); + } +} diff --git a/apps/cloud/src/auth/machine.ts b/apps/cloud/src/auth/machine.ts new file mode 100644 index 00000000..f21735e2 --- /dev/null +++ b/apps/cloud/src/auth/machine.ts @@ -0,0 +1,45 @@ +import type { Env } from "../env.js"; +import { CloudError, unauthorizedMachine } from "../errors.js"; +import { machineRecord, type Machine, type MachineRow } from "../db.js"; +import { sameDigest, sha256Hex } from "../ids.js"; + +const bearer = /^([a-z0-9]{1,80})\.([A-Za-z0-9_-]{43})$/; + +export const machineRevoked = () => + new CloudError("machine_revoked", 401, { + nextStep: "This machine was removed from its account. Disconnect it, then pair it again.", + }); + +// Every machine request carries `.`; the cloud stores only the secret's digest. +export async function authenticateMachine(env: Env, credential: string | null): Promise { + const match = credential === null ? null : bearer.exec(credential); + if (!match?.[1] || !match[2]) throw unauthorizedMachine(); + const row = await env.DB.prepare( + "SELECT m.*, c.digest FROM machines m JOIN machineCredentials c ON c.machineId = m.id WHERE m.id = ?", + ) + .bind(match[1]) + .first(); + if (!row || !sameDigest(row.digest, await sha256Hex(match[2]))) throw machineRevoked(); + const { digest: _digest, ...machine } = row; + return machineRecord(machine); +} + +export function bearerCredential(request: Request) { + const header = request.headers.get("Authorization"); + return header?.startsWith("Bearer ") ? header.slice(7) : null; +} + +// Browsers and Node's WebSocket cannot set headers on the upgrade, so the credential travels as a +// second subprotocol: "vectis.v1, auth.". +export function socketCredential(request: Request) { + const offered = (request.headers.get("Sec-WebSocket-Protocol") ?? "") + .split(",") + .map((item) => item.trim()); + const encoded = offered.find((item) => item.startsWith("auth."))?.slice(5); + if (!offered.includes("vectis.v1") || !encoded || !/^[A-Za-z0-9_-]+$/.test(encoded)) return null; + try { + return atob(encoded.replaceAll("-", "+").replaceAll("_", "/")); + } catch { + return null; + } +} diff --git a/apps/cloud/src/authority.ts b/apps/cloud/src/authority.ts new file mode 100644 index 00000000..6586f0b0 --- /dev/null +++ b/apps/cloud/src/authority.ts @@ -0,0 +1,44 @@ +import type { Env } from "./env.js"; +import { CloudError } from "./errors.js"; +import { + accountRecord, + bindingRecord, + type BindingRow, + type GitHubAccountRow, + type Machine, +} from "./db.js"; + +// A machine may act on a binding only while the binding, its machine and its verified GitHub +// account all belong to the same owner. Preparing a runner also needs an enabled binding and a +// ready environment. +export async function bindingAuthority( + env: Env, + machine: Machine, + bindingId: string, + preparing: boolean, +) { + const [bindingRow, accountRow] = await env.DB.batch([ + env.DB.prepare("SELECT * FROM repositoryBindings WHERE id = ?").bind(bindingId), + env.DB.prepare( + "SELECT a.* FROM githubAccounts a JOIN repositoryBindings b ON b.accountId = a.id WHERE b.id = ?", + ).bind(bindingId), + ]); + const row = bindingRow?.results[0] as BindingRow | undefined; + const accountValue = accountRow?.results[0] as GitHubAccountRow | undefined; + if ( + !row || + row.machineId !== machine.id || + row.owner !== machine.owner || + (preparing && !row.enabled) + ) + throw new CloudError("repository_access_denied", 403); + if (!accountValue || accountValue.owner !== machine.owner) + throw new CloudError("repository_access_denied", 403); + const binding = bindingRecord(row); + const environment = machine.environments.find((item) => item.id === binding.environmentId); + if (preparing && environment?.state !== "ready") + throw new CloudError("environment_unavailable", 409, { + nextStep: "Prepare the environment and reconnect the machine.", + }); + return { machine, binding, account: accountRecord(accountValue), environment }; +} diff --git a/apps/cloud/src/context.ts b/apps/cloud/src/context.ts new file mode 100644 index 00000000..ccc05470 --- /dev/null +++ b/apps/cloud/src/context.ts @@ -0,0 +1,40 @@ +import type { + HumanPath, + HumanRequest, + HumanResponse, + MachinePath, + MachineRequest, + MachineResponse, +} from "../../../packages/protocol/src/cloud.js"; +import type { Env } from "./env.js"; +import type { Machine } from "./db.js"; +import { CloudError } from "./errors.js"; + +export interface Context { + env: Env; + ctx: ExecutionContext; +} +export interface MachineContext extends Context { + machine: Machine; +} +export interface HumanContext extends Context { + owner: string; +} + +// Inputs are already decoded against the protocol schema; results are sent as JSON. +export type MachineHandler

= ( + c: MachineContext, + input: MachineRequest

, +) => Promise>; +export type HumanHandler

= ( + c: HumanContext, + input: HumanRequest

, +) => Promise>; +// Routes that own their request parsing and response: pairing, controllers, webhooks, OAuth. +export type RawHandler = (c: Context, request: Request) => Promise; +// Cron work. Each sweep handles one bounded batch of due rows and must be safe to repeat. +export type Sweep = (env: Env, now: number) => Promise; + +export const notImplemented = (): never => { + throw new CloudError("not_implemented", 501); +}; diff --git a/apps/cloud/src/controllers.ts b/apps/cloud/src/controllers.ts new file mode 100644 index 00000000..89faae28 --- /dev/null +++ b/apps/cloud/src/controllers.ts @@ -0,0 +1,127 @@ +import type { HumanHandler, RawHandler } from "./context.js"; +import { PairingSecret } from "../../../packages/protocol/src/cloud.js"; +import { ControllerRequest } from "../../../packages/protocol/src/controller.js"; +import type { ControllerRow } from "./db.js"; +import { CloudError } from "./errors.js"; +import { decode, json, readJson } from "./http.js"; +import { newId, sha256Hex } from "./ids.js"; +import { authenticateController } from "./auth/controller.js"; +import { submitOperation, cancelOperation, getOperation } from "./operations.js"; +import { submitInspection, getInspection } from "./inspections.js"; +import { listMachines } from "./machines.js"; +export const approveController: HumanHandler<"/controllers/approve"> = async ( + { env, owner }, + input, +) => { + const now = Date.now(); + if ( + !input.name.trim() || + !Number.isFinite(input.expiresAt) || + input.expiresAt <= now || + input.expiresAt > now + 600000 + ) + throw new CloudError("invalid_controller_request"); + const id = newId(); + await env.DB.prepare( + `INSERT INTO controllers (id, owner, name, requestDigest, credentialDigest, pairingExpiresAt, expiresAt, revoked, createdAt) SELECT ?, ?, ?, ?, ?, ?, ?, 0, ? WHERE (SELECT COUNT(*) FROM controllers WHERE owner = ? AND revoked = 0 AND expiresAt > ?) < 20 ON CONFLICT(requestDigest) DO NOTHING`, + ) + .bind( + id, + owner, + input.name, + input.requestDigest, + input.credentialDigest, + input.expiresAt, + now + 90 * 86400000, + now, + owner, + now, + ) + .run(); + const row = await env.DB.prepare("SELECT * FROM controllers WHERE requestDigest = ?") + .bind(input.requestDigest) + .first(); + if (!row) + throw new CloudError("controller_limit", 409, { + nextStep: "Revoke an unused CLI or desktop connection.", + }); + if ( + row.owner !== owner || + row.revoked || + row.name !== input.name || + row.credentialDigest !== input.credentialDigest || + row.pairingExpiresAt !== input.expiresAt + ) + throw new CloudError("controller_conflict"); + return { controllerId: row.id }; +}; +export const revokeController: HumanHandler<"/controllers/revoke"> = async ( + { env, owner }, + { id }, +) => { + const row = await env.DB.prepare("SELECT owner FROM controllers WHERE id = ?") + .bind(id) + .first<{ owner: string }>(); + if (row?.owner !== owner) throw new CloudError("controller_missing", 404); + await env.DB.prepare("UPDATE controllers SET revoked = 1 WHERE id = ? AND revoked = 0") + .bind(id) + .run(); + return null; +}; +export const controllerPairing: RawHandler = async ({ env }, request) => { + const { secret } = decode(PairingSecret, await readJson(request, 4096)); + const now = Date.now(); + const row = await env.DB.prepare( + "SELECT id AS controllerId, expiresAt FROM controllers WHERE requestDigest = ? AND revoked = 0 AND pairingExpiresAt > ? AND expiresAt > ?", + ) + .bind(await sha256Hex(secret), now, now) + .first<{ controllerId: string; expiresAt: number }>(); + return json(row ?? { state: "pending" }); +}; +export const controllerRequest: RawHandler = async ({ env }, request) => { + try { + const controller = await authenticateController(env, request.headers.get("Authorization")); + const input = decode(ControllerRequest, await readJson(request, 65536)); + const owner = controller.owner; + switch (input.type) { + case "operation.submit": + return json({ + operationId: await submitOperation(env, owner, { + ...input, + commandJson: JSON.stringify(input.command), + }), + }); + case "operation.cancel": + return json({ operationId: await cancelOperation(env, owner, input.id) }); + case "operation.get": + return json(await getOperation(env, owner, input.id)); + case "query.submit": + return json(await submitInspection(env, owner, input.machineId, input.key, input.query)); + case "query.get": + return json(await getInspection(env, owner, input.id)); + case "machines.list": + return json( + (await listMachines(env, owner)).map( + ({ id, localId, name, lastSeenAt, online, environments, paused }) => ({ + id, + localId, + name, + lastSeenAt, + online, + environments, + paused, + }), + ), + ); + case "controller.revoke": + await env.DB.prepare("UPDATE controllers SET revoked = 1 WHERE id = ?") + .bind(controller.id) + .run(); + return json({ revoked: true }); + } + } catch (error) { + if (error instanceof CloudError) + return json(error.body(), error.code === "controller_unauthorized" ? 401 : 409); + throw error; + } +}; diff --git a/apps/cloud/src/cron.ts b/apps/cloud/src/cron.ts new file mode 100644 index 00000000..905663ef --- /dev/null +++ b/apps/cloud/src/cron.ts @@ -0,0 +1,26 @@ +import type { Env } from "./env.js"; +import type { Sweep } from "./context.js"; +import { sweepOperations } from "./operations.js"; +import { sweepDemand } from "./runners/demand.js"; +import { sweepLeases } from "./runners/leases.js"; +import { sweepScaleSets } from "./runners/scale-sets.js"; +import { sweepRedelivery } from "./github/redelivery.js"; +import { sweepRetention } from "./retention.js"; + +// Two triggers are the only scheduler: due rows are found by indexed timestamps, so a sweep +// with nothing due reads almost nothing. Sweeps run one after another so the invocation stays +// within its subrequest budget, and one failing sweep never blocks the others. +const schedules: Record = { + "*/2 * * * *": [sweepOperations, sweepLeases, sweepScaleSets, sweepRedelivery, sweepDemand], + "0 3 * * *": [sweepRetention], +}; + +export async function runCron(env: Env, cron: string, now: number) { + for (const sweep of schedules[cron] ?? []) { + try { + await sweep(env, now); + } catch (error) { + console.error("sweep failed", sweep.name, error); + } + } +} diff --git a/apps/cloud/src/db.ts b/apps/cloud/src/db.ts new file mode 100644 index 00000000..ab508a89 --- /dev/null +++ b/apps/cloud/src/db.ts @@ -0,0 +1,279 @@ +import type { EnvironmentSummary, OperationPhase } from "../../../packages/protocol/src/cloud.js"; +import type { + RepositoryAccess, + RunnerAccess, +} from "../../../packages/protocol/src/repository-access.js"; +import type { CacheVolume } from "../../../packages/protocol/src/cache-volume.js"; + +// Raw rows exactly as D1 returns them (see migrations/0001_init.sql). Booleans are 0 or 1 and +// *Json columns are JSON text; the record helpers below decode the ones most modules share. + +export type Flag = 0 | 1; + +export interface MachineRow { + id: string; + owner: string; + localId: string; + name: string; + createdAt: number; + environmentsJson: string | null; + paused: Flag | null; +} +export interface OperationRow { + id: string; + owner: string; + machineId: string; + key: string; + commandJson: string; + phase: OperationPhase; + createdAt: number; + updatedAt: number; + resultJson: string | null; + cancelRequested: Flag; +} +export interface InspectionRow { + id: string; + owner: string; + machineId: string; + key: string; + queryJson: string; + expiresAt: number; + responseJson: string | null; + pending: Flag; +} +export interface ControllerRow { + id: string; + owner: string; + name: string; + requestDigest: string; + credentialDigest: string; + pairingExpiresAt: number; + expiresAt: number; + revoked: Flag; + createdAt: number; +} +export interface PairingRow { + requestDigest: string; + owner: string; + machineId: string; + expiresAt: number; +} +export interface GitHubAppRow { + singleton: 1; + appId: number; + slug: string; + ownerId: number; + clientId: string; + privateKey: string; + clientSecret: string; + webhookSecret: string; +} +export interface GitHubAppSetupRow { + stateDigest: string; + ownerId: number; + ownerLogin: string; + organization: Flag | null; + expiresAt: number; + consumed: Flag; +} +export interface GitHubLinkRow { + id: string; + owner: string; + digest: string; + verifier: string | null; + browserDigest: string | null; + completionDigest: string | null; + expiresAt: number; + phase: "pending" | "exchanging" | "review" | "failed"; + userJson: string | null; +} +export interface GitHubAccountRow { + id: string; + owner: string; + githubId: number; + login: string; + installationsJson: string; + verifiedAt: number; +} +export interface BindingRow { + id: string; + owner: string; + accountId: string; + machineId: string; + repositoryId: number; + repositoryName: string; + repositoryOwner: string | null; + installationId: number; + environmentId: string; + enabled: Flag; + infraRerun: Flag; + automatic: Flag; + capacityMode: "demand" | "scale_set"; + accessJson: string | null; + cacheVolumeJson: string | null; + verifiedAt: number; +} +export interface ScaleSetRow { + id: string; + owner: string; + installationId: number; + repositoryId: number; + bindingId: string; + machineId: string; + repositoryOwner: string; + repositoryName: string; + githubUserId: number; + githubLogin: string; + scaleSetId: number | null; + name: string; + generation: number; + sessionId: string | null; + phase: + | "opening" + | "active" + | "refreshing" + | "closing" + | "closed" + | "action_required" + | "deleting"; + cleanupDueAt: number | null; + cleanupAttempt: number; + updatedAt: number; +} +export interface LeaseRow { + id: string; + owner: string; + machineId: string; + bindingId: string; + environmentId: string; + os: "linux" | "macos" | "windows"; + key: string; + environmentRevision: string | null; + phase: "preparing" | "ready" | "action_required" | "released"; + createdAt: number; + updatedAt: number; + runnerId: number | null; + encodedConfig: string | null; + cacheWrite: Flag | null; + cacheWriteReason: string | null; + cacheRunJson: string | null; + configExpiresAt: number | null; + accessJson: string | null; + scaleSetGeneration: number | null; + jobId: number | null; + automatic: Flag; +} +export interface DemandRow { + installationId: number; + repositoryId: number; + jobId: number; + owner: string; + bindingId: string; + operationId: string; + attempt: number; + failures: number; + updatedAt: number; +} +export interface JobRow { + installationId: number; + repositoryId: number; + jobId: number; + runId: number; + runAttempt: number | null; + actorId: number | null; + triggeringActorId: number | null; + name: string; + htmlUrl: string | null; + workflowName: string | null; + status: "waiting" | "queued" | "in_progress" | "completed"; + conclusion: string | null; + labelsJson: string; + runnerId: number | null; + runnerName: string | null; + updatedAt: number; +} +export interface DeliveryRow { + deliveryId: string; + event: string; + action: string | null; + repositoryId: number | null; + installationId: number | null; + jobId: number | null; + receivedAt: number; +} +export interface RunReceiptRow { + machineId: string; + key: string; + bindingId: string; + requestHash: string; + state: "submitted" | "accepted" | "uncertain" | "rejected"; + outcomeJson: string | null; + createdAt: number; + updatedAt: number; +} +export interface MigrationPreviewRow { + id: string; + owner: string; + machineId: string; + bindingId: string; + reportJson: string; + environmentRevision: string | null; + createdAt: number; + expiresAt: number; +} + +export const flag = (value: boolean | undefined): Flag => (value ? 1 : 0); + +export function parseJson(value: string | null): T | undefined { + return value === null ? undefined : (JSON.parse(value) as T); +} + +export type Machine = Omit & { + environments: EnvironmentSummary[]; + // Undefined until the machine reported its status at least once. + paused: boolean | undefined; +}; +export function machineRecord(row: MachineRow): Machine { + const { environmentsJson, paused, ...rest } = row; + return { + ...rest, + environments: parseJson(environmentsJson) ?? [], + paused: paused === null ? undefined : paused === 1, + }; +} + +export type Binding = Omit< + BindingRow, + "enabled" | "infraRerun" | "automatic" | "accessJson" | "cacheVolumeJson" | "repositoryOwner" +> & { + repositoryOwner: string | undefined; + enabled: boolean; + infraRerun: boolean; + automatic: boolean; + access: RepositoryAccess | undefined; + cacheVolume: CacheVolume | undefined; +}; +export function bindingRecord(row: BindingRow): Binding { + const { enabled, infraRerun, automatic, accessJson, cacheVolumeJson, repositoryOwner, ...rest } = + row; + return { + ...rest, + repositoryOwner: repositoryOwner ?? undefined, + enabled: enabled === 1, + infraRerun: infraRerun === 1, + automatic: automatic === 1, + access: parseJson(accessJson), + cacheVolume: parseJson(cacheVolumeJson), + }; +} + +export type Installation = { id: number; accountId: number; login: string }; +export type GitHubAccount = Omit & { + installations: Installation[]; +}; +export function accountRecord(row: GitHubAccountRow): GitHubAccount { + const { installationsJson, ...rest } = row; + return { ...rest, installations: parseJson(installationsJson) ?? [] }; +} + +export type { RunnerAccess }; diff --git a/apps/cloud/src/env.ts b/apps/cloud/src/env.ts new file mode 100644 index 00000000..dabdaf40 --- /dev/null +++ b/apps/cloud/src/env.ts @@ -0,0 +1,17 @@ +import type { MachineHub } from "./hub.js"; + +export interface Env { + DB: D1Database; + HUB: DurableObjectNamespace; + // HTTPS origin that serves /connect. Browser requests from any other origin are refused. + VECTIS_WEB_URL: string; + // This Worker's own public origin, used for GitHub webhook and callback URLs. + VECTIS_CLOUD_URL: string; + // Clerk Frontend API URL. Human owners are `${CLERK_ISSUER}|${sub}`. + CLERK_ISSUER: string; + VECTIS_GITHUB_APP_NAME: string; + // "false" keeps the GitHub App installable only by its owner. + VECTIS_GITHUB_APP_PUBLIC: string; + // Secret (wrangler secret put / .dev.vars): signing secret of the Clerk user.deleted webhook. + CLERK_WEBHOOK_SECRET?: string; +} diff --git a/apps/cloud/src/errors.ts b/apps/cloud/src/errors.ts new file mode 100644 index 00000000..3d0505e8 --- /dev/null +++ b/apps/cloud/src/errors.ts @@ -0,0 +1,21 @@ +import type { CloudErrorBody } from "../../../packages/protocol/src/cloud.js"; + +// Every refusal a handler can explain. index.ts turns it into a CloudErrorBody response; anything +// else becomes 500 cloud_unavailable. Extra fields (for example resetAt) are passed through. +export class CloudError extends Error { + constructor( + readonly code: string, + readonly status = 409, + readonly details: Omit & Record = {}, + ) { + super(details.message ?? code); + } + body(): CloudErrorBody & Record { + return { ...this.details, code: this.code }; + } +} + +export const unauthorizedMachine = () => + new CloudError("machine_unauthorized", 401, { nextStep: "Reconnect this machine to Vectis." }); +export const unauthorizedHuman = () => + new CloudError("unauthorized", 401, { nextStep: "Sign in to Vectis." }); diff --git a/convex/githubAccess.ts b/apps/cloud/src/github/access.ts similarity index 81% rename from convex/githubAccess.ts rename to apps/cloud/src/github/access.ts index 47b42a73..9eab3085 100644 --- a/convex/githubAccess.ts +++ b/apps/cloud/src/github/access.ts @@ -1,5 +1,4 @@ -"use node"; -import { ConvexError } from "convex/values"; +import { CloudError } from "../errors.js"; import { GitHubAppClient, GitHubApprovalError, @@ -9,7 +8,7 @@ import { GitHubAccessError, GitHubUnavailableError, GitHubRateLimitError, -} from "../packages/github/src/app-client.js"; +} from "../../../../packages/github/src/app-client.js"; export async function repositoryAccess( client: GitHubAppClient, @@ -25,7 +24,7 @@ export async function repositoryAccess( error instanceof GitHubPermissionError || error instanceof GitHubAccessError ) - throw new ConvexError({ code: error.code, message: error.message, nextStep: error.nextStep }); + throw new CloudError(error.code, 403, { message: error.message, nextStep: error.nextStep }); throw error; } } @@ -35,8 +34,7 @@ export async function githubErrors(operation: () => Promise): Promise { return await operation(); } catch (error) { if (error instanceof GitHubUnavailableError || error instanceof GitHubRateLimitError) - throw new ConvexError({ - code: error.code, + throw new CloudError(error.code, 502, { status: error.status, message: error.message, nextStep: error.nextStep, diff --git a/apps/cloud/src/github/app.ts b/apps/cloud/src/github/app.ts new file mode 100644 index 00000000..b9247dbe --- /dev/null +++ b/apps/cloud/src/github/app.ts @@ -0,0 +1,32 @@ +import type { Env } from "../env.js"; +import { CloudError } from "../errors.js"; +import type { GitHubAppRow } from "../db.js"; + +export async function loadApp(env: Env): Promise { + const app = await env.DB.prepare( + "SELECT * FROM githubApps WHERE singleton = 1", + ).first(); + if (!app) + throw new CloudError("github_app_unavailable", 503, { + nextStep: "Create the Vectis GitHub App for this deployment before connecting repositories.", + }); + return app; +} + +export async function hasApp(env: Env) { + return ( + (await env.DB.prepare("SELECT 1 AS present FROM githubApps WHERE singleton = 1").first()) !== + null + ); +} + +// GitHub delivers webhooks straight to this Worker, where their signature is verified, so the +// website can sit entirely behind Cloudflare Access in private deployments. +export const webhookUrl = (env: Env) => `${env.VECTIS_CLOUD_URL}/github/webhook`; + +export function appConfig(env: Env) { + return { + name: env.VECTIS_GITHUB_APP_NAME || "Vectis", + public: env.VECTIS_GITHUB_APP_PUBLIC !== "false", + }; +} diff --git a/apps/cloud/src/github/bindings-readiness.ts b/apps/cloud/src/github/bindings-readiness.ts new file mode 100644 index 00000000..f9930d93 --- /dev/null +++ b/apps/cloud/src/github/bindings-readiness.ts @@ -0,0 +1,196 @@ +import type { MachineHandler } from "../context.js"; +import type { Env } from "../env.js"; +import { + accountRecord, + bindingRecord, + type Machine, + type BindingRow, + type GitHubAccountRow, +} from "../db.js"; +import { CloudError } from "../errors.js"; +import { loadApp, hasApp } from "./app.js"; +import { GitHubAppClient } from "../../../../packages/github/src/app-client.js"; +import type { + RepositoryReadinessInput, + ReadinessCheck, +} from "../../../../packages/protocol/src/readiness.js"; +async function readinessAuthority(env: Env, machine: Machine, input: RepositoryReadinessInput) { + const credential = await env.DB.prepare( + "SELECT c.digest FROM machineCredentials c JOIN machines m ON m.id = c.machineId WHERE m.id = ? AND m.owner = ?", + ) + .bind(machine.id, machine.owner) + .first<{ digest: string }>(); + if (!credential) throw new CloudError("machine_revoked", 401); + const accounts = ( + await env.DB.prepare("SELECT * FROM githubAccounts WHERE owner = ? ORDER BY rowid LIMIT 100") + .bind(machine.owner) + .all() + ).results.map(accountRecord); + const bindings = ( + await env.DB.prepare( + "SELECT * FROM repositoryBindings WHERE machineId = ? ORDER BY rowid LIMIT 100", + ) + .bind(machine.id) + .all() + ).results.map(bindingRecord); + const binding = + "bindingId" in input + ? bindings.find((b) => b.id === input.bindingId) + : bindings + .toSorted((a, b) => Number(b.enabled) - Number(a.enabled)) + .find((b) => { + const account = accounts.find((a) => a.id === b.accountId); + return ( + b.owner === machine.owner && + account && + `${b.repositoryOwner ?? account.login}/${b.repositoryName}`.toLowerCase() === + input.repositoryName.toLowerCase() && + (input.environmentId === undefined || input.environmentId === b.environmentId) + ); + }); + if (("bindingId" in input && !binding) || (binding && binding.owner !== machine.owner)) + throw new CloudError("repository_access_denied", 403); + const account = binding ? accounts.find((a) => a.id === binding.accountId) : undefined; + if (binding && !account) throw new CloudError("repository_access_denied", 403); + return { + credentialDigest: credential.digest, + repositoryName: + "repositoryName" in input + ? input.repositoryName + : `${binding?.repositoryOwner ?? account?.login}/${binding?.repositoryName}`, + environmentId: + "environmentId" in input + ? (input.environmentId ?? binding?.environmentId) + : binding?.environmentId, + accounts: (binding ? (account ? [account] : []) : accounts).map(({ githubId, login }) => ({ + githubId, + login, + })), + binding: binding + ? { + enabled: binding.enabled, + automatic: binding.automatic, + infraRerun: binding.infraRerun, + repositoryId: binding.repositoryId, + installationId: binding.installationId, + cacheVolumeEnabled: binding.cacheVolume?.enabled ?? false, + } + : null, + app: (await hasApp(env)) ? await loadApp(env) : null, + }; +} +export const repositoryReadiness: MachineHandler<"/machine/repositories/readiness"> = async ( + c, + { input }, +) => { + const authority = await readinessAuthority(c.env, c.machine, input); + const checks: ReadinessCheck[] = [ + { + id: "github.account", + status: authority.accounts.length ? "ok" : "blocked", + ...(authority.accounts.length + ? {} + : { + code: "github_account_missing", + nextStep: "Link a GitHub account on the Vectis account page.", + target: { area: "connections", section: "github" } as const, + }), + message: authority.accounts.length + ? "A verified GitHub account is linked." + : "No GitHub account is linked to this machine's owner.", + }, + ]; + const [owner, repo] = authority.repositoryName.split("/"); + if (!authority.accounts.length || !authority.app || !owner || !repo) { + checks.push({ + id: "github.app", + status: authority.app ? "unknown" : "blocked", + code: authority.app ? "github_account_missing" : "github_app_unavailable", + message: authority.app + ? "Link a GitHub account before verifying repository access." + : "The Vectis GitHub App is not configured.", + nextStep: "Configure the GitHub App and link your GitHub account.", + target: { area: "github" }, + }); + for (const id of ["github.admin", "github.approval"]) + checks.push({ + id, + status: "unknown", + message: "Repository policy cannot be read until GitHub App access is verified.", + nextStep: "Resolve GitHub access, then retry readiness.", + target: { area: "github" }, + }); + } else { + const client = new GitHubAppClient(authority.app); + const accounts = [...authority.accounts].sort( + (a, b) => + Number(b.login.toLowerCase() === owner.toLowerCase()) - + Number(a.login.toLowerCase() === owner.toLowerCase()), + ); + let best: ReadinessCheck[] | undefined; + for (const account of accounts) { + const candidate = await client.repositoryReadiness({ + owner, + repo, + githubUserId: account.githubId, + githubLogin: account.login, + ...(authority.binding + ? { + repositoryId: authority.binding.repositoryId, + installationId: authority.binding.installationId, + } + : {}), + }); + if ( + !best || + candidate.filter((check) => check.status === "ok").length > + best.filter((check) => check.status === "ok").length + ) + best = candidate; + if (candidate.every((check) => check.status === "ok")) break; + } + if (best) checks.push(...best); + } + const binding = authority.binding; + checks.push({ + id: "repository.binding", + status: binding?.enabled && binding.automatic ? "ok" : "attention", + message: !binding?.enabled + ? "The repository is not connected to this machine and environment." + : binding.automatic + ? "Automatic runners are enabled." + : "The repository is connected; automatic runners are disabled.", + ...(!binding?.enabled + ? { + code: "binding_missing", + nextStep: + "Connect the repository with the verified GitHub account and a ready environment.", + } + : !binding.automatic + ? { + code: "automatic_disabled", + nextStep: + "Enable automatic runners for this repository if jobs should start automatically.", + } + : {}), + target: { area: "repositories" }, + }); + // Recheck credentials after external reads so revocation cannot yield a ready result. + const current = await readinessAuthority(c.env, c.machine, input); + if (JSON.stringify(authority) !== JSON.stringify(current)) + return { + checks: checks.map((check) => ({ + ...check, + status: "unknown" as const, + code: "readiness_changed", + message: "Repository connection or authorization changed during this check.", + nextStep: "Run repository readiness again.", + })), + ...(current.environmentId ? { environmentId: current.environmentId } : {}), + }; + return { + checks, + cacheVolumeEnabled: binding?.enabled === true && binding.cacheVolumeEnabled, + ...(authority.environmentId ? { environmentId: authority.environmentId } : {}), + }; +}; diff --git a/apps/cloud/src/github/identity-store.ts b/apps/cloud/src/github/identity-store.ts new file mode 100644 index 00000000..8f944b1f --- /dev/null +++ b/apps/cloud/src/github/identity-store.ts @@ -0,0 +1,148 @@ +import { Schema } from "effect"; +import { GitHubUser } from "../../../../packages/protocol/src/cloud.js"; +import type { Env } from "../env.js"; +import type { GitHubLinkRow, GitHubAccountRow } from "../db.js"; +import { CloudError } from "../errors.js"; +import { newId } from "../ids.js"; +import { loadApp } from "./app.js"; +import { markScaleSetsForCleanup } from "../runners/scale-sets.js"; +import { notifyMachine } from "../notify.js"; +export async function createLink( + env: Env, + input: { owner: string; digest: string; verifier: string; browserDigest: string }, +) { + const app = await loadApp(env), + now = Date.now(); + const result = await env.DB.prepare( + `INSERT INTO githubLinks (id, owner, digest, verifier, browserDigest, expiresAt, phase) SELECT ?, ?, ?, ?, ?, ?, 'pending' WHERE (SELECT COUNT(*) FROM githubLinks WHERE owner = ? AND expiresAt > ?) < 5`, + ) + .bind( + newId(), + input.owner, + input.digest, + input.verifier, + input.browserDigest, + now + 600000, + input.owner, + now, + ) + .run(); + if (!result.meta.changes) throw new CloudError("github_link_rate_limit"); + return { clientId: app.clientId }; +} +export async function claimLink(env: Env, input: { digest: string; browserDigest: string }) { + const app = await loadApp(env); + const link = await env.DB.prepare("SELECT * FROM githubLinks WHERE digest = ?") + .bind(input.digest) + .first(); + if (!link?.verifier || link.browserDigest !== input.browserDigest) return null; + const result = await env.DB.prepare( + "UPDATE githubLinks SET phase = 'exchanging', verifier = NULL WHERE id = ? AND phase = 'pending' AND expiresAt > ? AND browserDigest = ?", + ) + .bind(link.id, Date.now(), input.browserDigest) + .run(); + return result.meta.changes + ? { + id: link.id, + verifier: link.verifier, + clientId: app.clientId, + clientSecret: app.clientSecret, + appId: app.appId, + } + : null; +} +export async function finishLink( + env: Env, + input: { id: string; user?: typeof GitHubUser.Type; completionDigest?: string }, +) { + await env.DB.prepare( + "UPDATE githubLinks SET phase = ?, userJson = ?, completionDigest = ? WHERE id = ? AND phase = 'exchanging' AND expiresAt > ?", + ) + .bind( + input.user ? "review" : "failed", + input.user ? JSON.stringify(input.user) : null, + input.completionDigest ?? null, + input.id, + Date.now(), + ) + .run(); +} +export async function confirmLink( + env: Env, + input: { owner: string; id: string; browserDigest: string; completionDigest: string }, +) { + const link = await env.DB.prepare( + "SELECT * FROM githubLinks WHERE id = ? AND owner = ? AND phase = 'review' AND browserDigest = ? AND completionDigest = ? AND expiresAt > ?", + ) + .bind(input.id, input.owner, input.browserDigest, input.completionDigest, Date.now()) + .first(); + if (!link?.userJson) throw new CloudError("github_link_unavailable"); + const user = Schema.decodeUnknownSync(GitHubUser)(JSON.parse(link.userJson)); + const existing = await env.DB.prepare("SELECT * FROM githubAccounts WHERE githubId = ?") + .bind(user.id) + .first(); + if (existing && existing.owner !== input.owner) + throw new CloudError("github_account_already_linked"); + const results = await env.DB.batch([ + env.DB.prepare( + `INSERT INTO githubAccounts (id, owner, githubId, login, installationsJson, verifiedAt) SELECT ?, ?, ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM githubLinks WHERE id = ? AND phase = 'review' AND expiresAt > ?) ON CONFLICT(githubId) DO UPDATE SET login = excluded.login, installationsJson = excluded.installationsJson, verifiedAt = excluded.verifiedAt WHERE githubAccounts.owner = excluded.owner`, + ).bind( + existing?.id ?? newId(), + input.owner, + user.id, + user.login, + JSON.stringify(user.installations), + Date.now(), + link.id, + Date.now(), + ), + env.DB.prepare( + "DELETE FROM githubLinks WHERE id = ? AND EXISTS (SELECT 1 FROM githubAccounts WHERE githubId = ? AND owner = ?)", + ).bind(link.id, user.id, input.owner), + ]); + const saved = await env.DB.prepare("SELECT owner, id FROM githubAccounts WHERE githubId = ?") + .bind(user.id) + .first<{ owner: string; id: string }>(); + if (saved?.owner !== input.owner) throw new CloudError("github_account_already_linked"); + if (!results[0]?.meta.changes) throw new CloudError("github_link_unavailable"); + const machines = await env.DB.prepare( + "SELECT DISTINCT machineId FROM repositoryBindings WHERE accountId = ?", + ) + .bind(saved.id) + .all<{ machineId: string }>(); + await Promise.all(machines.results.map((m) => notifyMachine(env, m.machineId))); +} +export async function unlink(env: Env, owner: string, id: string) { + const account = await env.DB.prepare("SELECT owner FROM githubAccounts WHERE id = ?") + .bind(id) + .first<{ owner: string }>(); + if (account?.owner !== owner) throw new CloudError("github_account_unavailable"); + const bindings = await env.DB.prepare( + "SELECT id, machineId FROM repositoryBindings WHERE accountId = ?", + ) + .bind(id) + .all<{ id: string; machineId: string }>(); + await markScaleSetsForCleanup( + env, + bindings.results.map((b) => b.id), + ); + await env.DB.prepare("DELETE FROM githubAccounts WHERE id = ? AND owner = ?") + .bind(id, owner) + .run(); + await Promise.all( + [...new Set(bindings.results.map((b) => b.machineId))].map((id) => notifyMachine(env, id)), + ); +} +export async function discard(env: Env, owner: string, id: string) { + const result = await env.DB.prepare("DELETE FROM githubLinks WHERE id = ? AND owner = ?") + .bind(id, owner) + .run(); + if (!result.meta.changes) throw new CloudError("github_link_unavailable"); +} +export async function accountsForMachine(env: Env, owner: string) { + return ( + await env.DB.prepare("SELECT id, githubId, login FROM githubAccounts WHERE owner = ? LIMIT 100") + .bind(owner) + .all<{ id: string; githubId: number; login: string }>() + ).results; +} diff --git a/apps/cloud/src/github/job-store.ts b/apps/cloud/src/github/job-store.ts new file mode 100644 index 00000000..26ce569f --- /dev/null +++ b/apps/cloud/src/github/job-store.ts @@ -0,0 +1,185 @@ +import { Schema } from "effect"; +import { GitHubJob } from "../../../../packages/github/src/job.js"; +import { jobAccess } from "../../../../packages/github/src/access-policy.js"; +import type { RepositoryAccess } from "../../../../packages/protocol/src/repository-access.js"; +import type { Job } from "../../../../packages/protocol/src/jobs.js"; +import type { JobRow, DeliveryRow } from "../db.js"; +import type { Env } from "../env.js"; +import { notifyMachine } from "../notify.js"; +import { demandForRepository } from "../runners/demand.js"; + +export function mergeJob( + previous: JobRow | null, + job: GitHubJob & Pick, +): JobRow { + // GitHub offers waiting, requested and pending jobs to no runner until it queues them, for + // example after an environment approval. They rank with queued, so the newest report wins. + const status = job.status === "requested" || job.status === "pending" ? "waiting" : job.status; + const order = { waiting: 0, queued: 0, in_progress: 1, completed: 2 }; + const stale = previous !== null && order[status] < order[previous.status]; + const htmlUrl = stale ? (previous.htmlUrl ?? job.html_url) : (job.html_url ?? previous?.htmlUrl); + const workflowName = stale + ? (previous.workflowName ?? job.workflow_name) + : (job.workflow_name ?? previous?.workflowName); + const record = { + installationId: job.installationId, + repositoryId: job.repositoryId, + jobId: job.id, + runId: stale ? previous.runId : job.run_id, + ...optionalActors(job, previous, stale), + name: stale ? previous.name : job.name, + status: stale ? previous.status : status, + conclusion: stale ? previous.conclusion : (job.conclusion ?? previous?.conclusion ?? null), + labelsJson: stale ? previous.labelsJson : JSON.stringify(job.labels), + runnerId: + (stale ? (previous.runnerId ?? job.runner_id) : (job.runner_id ?? previous?.runnerId)) ?? + null, + runnerName: + (stale + ? (previous.runnerName ?? job.runner_name) + : (job.runner_name ?? previous?.runnerName)) ?? null, + htmlUrl: htmlUrl ?? null, + workflowName: workflowName ?? null, + }; + const candidate = { ...record, updatedAt: previous?.updatedAt ?? 0 }; + if ( + previous && + Object.keys(candidate).every( + (key) => Reflect.get(previous, key) === Reflect.get(candidate, key), + ) + ) + return previous; + return { ...record, updatedAt: Math.max(job.updatedAt, (previous?.updatedAt ?? 0) + 1) }; +} + +function optionalActors(job: GitHubJob, previous: JobRow | null, stale: boolean) { + const sameAttempt = + previous?.runId === job.run_id && + (job.run_attempt === undefined || previous.runAttempt === job.run_attempt); + const runAttempt = stale + ? (previous?.runAttempt ?? undefined) + : (job.run_attempt ?? (sameAttempt ? (previous?.runAttempt ?? undefined) : undefined)); + const actorId = stale + ? (previous?.actorId ?? undefined) + : (job.actor_id ?? (sameAttempt ? (previous?.actorId ?? undefined) : undefined)); + const triggeringActorId = stale + ? (previous?.triggeringActorId ?? undefined) + : (job.triggering_actor_id ?? + (sameAttempt ? (previous?.triggeringActorId ?? undefined) : undefined)); + return { + runAttempt: runAttempt ?? null, + actorId: actorId ?? null, + triggeringActorId: triggeringActorId ?? null, + }; +} + +export function publicJob(row: JobRow, policy?: RepositoryAccess): Job { + const { + installationId: _installation, + repositoryId: _repository, + labelsJson, + runAttempt, + actorId, + triggeringActorId, + htmlUrl, + workflowName, + ...job + } = row; + const labels: string[] = JSON.parse(labelsJson); + const access = + row.status === "queued" || row.status === "waiting" + ? jobAccess(policy, { + runAttempt: runAttempt ?? undefined, + actorId: actorId ?? undefined, + triggeringActorId: triggeringActorId ?? undefined, + }) + : undefined; + return { + ...job, + labels, + ...(runAttempt === null ? {} : { runAttempt }), + ...(actorId === null ? {} : { actorId }), + ...(triggeringActorId === null ? {} : { triggeringActorId }), + ...(htmlUrl === null ? {} : { htmlUrl }), + ...(workflowName === null ? {} : { workflowName }), + ...(access ? { access } : {}), + }; +} + +export async function jobChanged(env: Env, row: JobRow) { + const machines = await env.DB.prepare(`SELECT DISTINCT machineId FROM repositoryBindings + WHERE installationId = ? AND repositoryId = ? AND enabled = 1`) + .bind(row.installationId, row.repositoryId) + .all<{ machineId: string }>(); + await Promise.all(machines.results.map(({ machineId }) => notifyMachine(env, machineId))); + if (row.status !== "in_progress") + await demandForRepository(env, row.installationId, row.repositoryId); +} + +export async function storeJob( + env: Env, + input: GitHubJob, + installationId: number, + repositoryId: number, + delivery?: DeliveryRow, +) { + const job = Schema.decodeUnknownSync(GitHubJob)(input); + for (let attempt = 0; attempt < 5; attempt++) { + const previous = await env.DB.prepare(`SELECT * FROM githubJobs + WHERE installationId = ? AND repositoryId = ? AND jobId = ?`) + .bind(installationId, repositoryId, job.id) + .first(); + const merged = mergeJob(previous, { + ...job, + installationId, + repositoryId, + updatedAt: Date.now(), + }); + const row = { ...merged, installationId, repositoryId }; + const changed = merged !== previous; + const statements: D1PreparedStatement[] = []; + if (changed) { + const columns = Object.keys(row); + statements.push( + env.DB.prepare(`INSERT INTO githubJobs (${columns.join(",")}) + SELECT ${columns.map(() => "?").join(",")} WHERE ${delivery ? "NOT EXISTS (SELECT 1 FROM githubDeliveries WHERE deliveryId = ?)" : "1"} + ON CONFLICT (installationId, repositoryId, jobId) DO UPDATE SET + ${columns + .slice(3) + .map((column) => `${column} = excluded.${column}`) + .join(",")} + WHERE githubJobs.updatedAt = ?`).bind( + ...Object.values(row), + ...(delivery ? [delivery.deliveryId] : []), + previous?.updatedAt ?? -1, + ), + ); + } + if (delivery) + statements.push( + env.DB.prepare(`INSERT INTO githubDeliveries + (deliveryId,event,action,repositoryId,installationId,jobId,receivedAt) + SELECT ?,?,?,?,?,?,? ${changed ? "WHERE changes() = 1" : ""} ON CONFLICT DO NOTHING`).bind( + delivery.deliveryId, + delivery.event, + delivery.action, + delivery.repositoryId, + delivery.installationId, + delivery.jobId, + delivery.receivedAt, + ), + ); + if (!statements.length) return { row, changed: false, duplicate: false }; + const result = await env.DB.batch(statements); + if (delivery && !result.at(-1)?.meta.changes) { + const duplicate = await env.DB.prepare("SELECT 1 FROM githubDeliveries WHERE deliveryId = ?") + .bind(delivery.deliveryId) + .first(); + if (duplicate) return { row, changed: false, duplicate: true }; + continue; + } + if (changed && !result[0]?.meta.changes) continue; + return { row, changed, duplicate: false }; + } + throw new Error("GitHub job persistence lost too many concurrent updates."); +} diff --git a/apps/cloud/src/github/jobs-private.ts b/apps/cloud/src/github/jobs-private.ts new file mode 100644 index 00000000..4a96850a --- /dev/null +++ b/apps/cloud/src/github/jobs-private.ts @@ -0,0 +1,71 @@ +import type { Env } from "../env.js"; +import { bindingRecord, accountRecord, type BindingRow, type GitHubAccountRow } from "../db.js"; +import { restrictiveAccess } from "../../../../packages/github/src/access-policy.js"; +import { bindingAuthority } from "../authority.js"; +import type { MachineContext } from "../context.js"; +import { CloudError } from "../errors.js"; +import { GitHubAppClient } from "../../../../packages/github/src/app-client.js"; +import { loadApp } from "./app.js"; +import { repositoryAccess } from "./access.js"; + +export async function restrictiveAuthority(env: Env, installationId: number, repositoryId: number) { + const rows = await env.DB.prepare(`SELECT b.* FROM repositoryBindings b + JOIN machines m ON m.id = b.machineId AND m.owner = b.owner + JOIN githubAccounts a ON a.id = b.accountId AND a.owner = b.owner + WHERE b.installationId = ? AND b.repositoryId = ? AND b.enabled = 1 LIMIT 100`) + .bind(installationId, repositoryId) + .all(); + for (const row of rows.results) { + const binding = bindingRecord(row); + if (!restrictiveAccess(binding.access)) continue; + const account = await env.DB.prepare("SELECT * FROM githubAccounts WHERE id = ?") + .bind(binding.accountId) + .first(); + if (account) return { binding, account: accountRecord(account) }; + } + return null; +} + +export async function jobAuthority(c: MachineContext, bindingId: string) { + const authority = await bindingAuthority(c.env, c.machine, bindingId, false); + if (!authority.binding.enabled) throw new CloudError("repository_access_denied", 403); + return authority; +} +export async function githubAuthority(c: MachineContext, bindingId: string) { + const authority = await jobAuthority(c, bindingId); + const client = new GitHubAppClient(await loadApp(c.env)); + const access = await accessFor(client, authority); + return { ...authority, client, access }; +} +export async function accessFor( + client: GitHubAppClient, + authority: NonNullable>>, +) { + const { binding, account } = authority; + const access = await repositoryAccess(client, { + owner: binding.repositoryOwner ?? account.login, + repo: binding.repositoryName, + repositoryId: binding.repositoryId, + githubUserId: account.githubId, + githubLogin: account.login, + purpose: "jobs", + }); + if (access.installationId !== binding.installationId) + throw new CloudError("installation_changed", 409); + return access; +} +export async function pendingRuns(env: Env, installationId: number, repositoryId: number) { + const results = await env.DB.batch<{ runId: number }>( + ["queued", "in_progress", "waiting"].map((status) => + env.DB.prepare( + "SELECT runId FROM githubJobs WHERE installationId = ? AND repositoryId = ? AND status = ? LIMIT 101", + ).bind(installationId, repositoryId, status), + ), + ); + return { + runIds: [ + ...new Set(results.flatMap((result) => result.results.slice(0, 100).map((row) => row.runId))), + ], + complete: results.every((result) => result.results.length <= 100), + }; +} diff --git a/apps/cloud/src/github/jobs.ts b/apps/cloud/src/github/jobs.ts new file mode 100644 index 00000000..d2ec7284 --- /dev/null +++ b/apps/cloud/src/github/jobs.ts @@ -0,0 +1,221 @@ +import { Schema } from "effect"; +import type { Env } from "../env.js"; +import type { Machine, JobRow } from "../db.js"; +import { CloudError } from "../errors.js"; +import type { JobObservations, JobLogs } from "../../../../packages/protocol/src/jobs.js"; +import { JobLogRequest } from "../../../../packages/protocol/src/jobs.js"; +import type { MachineHandler, MachineContext } from "../context.js"; +import { GitHubJob } from "../../../../packages/github/src/job.js"; +import { GitHubApiError } from "../../../../packages/github/src/app-client.js"; +import { readRunActors, withRunActors } from "../../../../packages/github/src/run-actors.js"; +import { scanRepositoryJobs } from "../../../../packages/github/src/job-scan.js"; +import { pageJobLog } from "../../../../packages/github/src/job-logs.js"; +import { githubErrors } from "./access.js"; +import { + githubAuthority, + jobAuthority, + restrictiveAuthority, + pendingRuns, +} from "./jobs-private.js"; +import { publicJob, storeJob, jobChanged } from "./job-store.js"; + +export const listJobs: MachineHandler<"/machine/jobs/list"> = async (c, input) => { + const { binding } = await jobAuthority(c, input.bindingId); + const jobs = await c.env.DB.prepare(`SELECT * FROM githubJobs INDEXED BY githubJobs_recent + WHERE installationId = ? AND repositoryId = ? ORDER BY updatedAt DESC LIMIT 100`) + .bind(binding.installationId, binding.repositoryId) + .all(); + return jobs.results.map((row) => publicJob(row, binding.access)); +}; + +async function save( + c: MachineContext, + bindingId: string, + installationId: number, + repositoryId: number, + jobs: readonly GitHubJob[], +) { + const { binding } = await jobAuthority(c, bindingId); + if ( + binding.installationId !== installationId || + binding.repositoryId !== repositoryId || + jobs.length > 50 + ) + throw new CloudError("repository_access_changed", 403); + let changed: JobRow | undefined; + const rows: JobRow[] = []; + for (const job of jobs) { + const result = await storeJob(c.env, job, installationId, repositoryId); + rows.push(result.row); + if (result.changed) { + // Preserve a demand-triggering state when another changed job follows it in this page. + if (!changed || result.row.status !== "in_progress") changed = result.row; + } + } + if (changed) await jobChanged(c.env, changed); + return rows.map((row) => publicJob(row, binding.access)); +} + +export const scanJobs: MachineHandler<"/machine/jobs/scan"> = async (c, input) => + githubErrors(async () => { + const { client, access } = await githubAuthority(c, input.bindingId); + const restrictive = await restrictiveAuthority( + c.env, + access.installationId, + access.repositoryId, + ); + const known = await pendingRuns(c.env, access.installationId, access.repositoryId); + // Each logical request can retry four times; reserve 16 fetches for repository authority. + const result = await scanRepositoryJobs( + (path) => client.request(access.token, access.path + path), + async (jobs) => { + await save(c, input.bindingId, access.installationId, access.repositoryId, jobs); + }, + known.runIds, + 8, + restrictive ? access.repositoryId : undefined, + ); + return { ...result, complete: result.complete && known.complete }; + }); + +export const refreshJob: MachineHandler<"/machine/jobs/refresh"> = async (c, input) => + githubErrors(async () => { + if (!Number.isSafeInteger(input.jobId) || input.jobId <= 0) + throw new CloudError("invalid_job_id", 400); + const { client, access } = await githubAuthority(c, input.bindingId); + const job = Schema.decodeUnknownSync(GitHubJob)( + await client + .request(access.token, `${access.path}/actions/jobs/${input.jobId}`) + .catch((error: unknown) => { + if (error instanceof GitHubApiError && error.status === 404) + throw new CloudError("job_not_found", 404, { + message: "GitHub has no job with this ID in the repository.", + nextStep: "Check the job ID with vectis job list, then retry.", + }); + throw error; + }), + ); + if (job.id !== input.jobId) throw new CloudError("job_identity_mismatch", 502); + const restrictive = await restrictiveAuthority( + c.env, + access.installationId, + access.repositoryId, + ); + const enriched = restrictive + ? withRunActors( + job, + await readRunActors( + (path) => client.request(access.token, access.path + path), + access.repositoryId, + job.run_id, + ), + ) + : job; + const rows = await save(c, input.bindingId, access.installationId, access.repositoryId, [ + enriched, + ]); + const result = rows[0]; + if (!result) throw new Error("GitHub job persistence failed."); + return result; + }); + +export const jobLogs: MachineHandler<"/machine/jobs/logs"> = async (c, input) => + githubErrors(async () => { + if (!Schema.is(JobLogRequest)(input)) + throw new CloudError("invalid_job_log_request", 400, { + message: "Provide a positive job ID and a nonnegative safe integer byte cursor.", + nextStep: "Pass the previous next cursor as after.", + }); + const authority = await githubAuthority(c, input.bindingId); + const { client, access } = authority; + const job = Schema.decodeUnknownSync(GitHubJob)( + await client.request(access.token, `${access.path}/actions/jobs/${input.jobId}`), + ); + if (job.id !== input.jobId) throw new CloudError("job_identity_mismatch", 502); + const run = Schema.decodeUnknownSync( + Schema.Struct({ id: Schema.Int, repository: Schema.Struct({ id: Schema.Int }) }), + )(await client.request(access.token, `${access.path}/actions/runs/${job.run_id}`)); + if (run.id !== job.run_id || run.repository.id !== access.repositoryId) + throw new CloudError("repository_access_denied", 403, { + message: "The job belongs to another repository.", + nextStep: "Use the binding for this job's repository.", + }); + const status = job.status === "requested" || job.status === "pending" ? "queued" : job.status; + let result: JobLogs; + try { + const log = await client.downloadJobLog( + access.token, + `${access.path}/actions/jobs/${input.jobId}/logs`, + input.after, + ); + result = pageJobLog(log, status, input.after); + } catch (error) { + if (error instanceof GitHubApiError && error.status === 404 && status !== "completed") + result = { text: "", next: input.after ?? 0, status, complete: false, truncated: false }; + else + throw new CloudError("job_logs_unavailable", 502, { + message: "GitHub job logs could not be downloaded.", + nextStep: + "Retry when GitHub makes the log available; logs may have expired or exceeded the 64 MiB download limit.", + }); + } + const current = await jobAuthority(c, input.bindingId); + if ( + current.binding.repositoryId !== access.repositoryId || + current.binding.installationId !== access.installationId || + current.binding.accountId !== authority.binding.accountId + ) + throw new CloudError("repository_access_denied", 403); + return result; + }); + +export async function jobMarkers( + env: Env, + bindings: readonly { id: string; installationId: number; repositoryId: number }[], +): Promise> { + if (!bindings.length) return {}; + const results = await env.DB.batch<{ updatedAt: number }>( + bindings.map((binding) => + env.DB.prepare(`SELECT updatedAt FROM githubJobs INDEXED BY githubJobs_recent + WHERE installationId = ? AND repositoryId = ? ORDER BY updatedAt DESC LIMIT 1`).bind( + binding.installationId, + binding.repositoryId, + ), + ), + ); + return Object.fromEntries( + bindings.map((binding, index) => [binding.id, results[index]?.results[0]?.updatedAt ?? null]), + ); +} + +export async function leaseObservations( + env: Env, + machine: Machine, + leaseIds: readonly string[], +): Promise { + if (leaseIds.length > 20) throw new CloudError("invalid_lease_ids", 400); + if (!leaseIds.length) return []; + const results = await env.DB.batch( + [...new Set(leaseIds)].map((id) => + env.DB.prepare(`SELECT j.*, l.id AS leaseId, l.runnerId AS leaseRunnerId FROM runnerLeases l + JOIN repositoryBindings b ON b.id = l.bindingId AND b.machineId = l.machineId AND b.owner = l.owner + JOIN githubAccounts a ON a.id = b.accountId AND a.owner = l.owner + JOIN githubJobs j ON j.installationId = b.installationId AND j.repositoryId = b.repositoryId AND j.runnerName = ? + WHERE l.id = ? AND l.machineId = ? AND l.owner = ? LIMIT 2`).bind( + `vectis-${id}`, + id, + machine.id, + machine.owner, + ), + ), + ); + return results.flatMap((result) => { + if (result.results.length !== 1) return []; + const row = result.results[0]; + if (!row) return []; + const { leaseId, leaseRunnerId, ...job } = row; + if (leaseRunnerId !== null && job.runnerId !== null && leaseRunnerId !== job.runnerId) + return []; + return [{ leaseId, job: publicJob(job) }]; + }); +} diff --git a/apps/cloud/src/github/migrations.ts b/apps/cloud/src/github/migrations.ts new file mode 100644 index 00000000..add5e7b2 --- /dev/null +++ b/apps/cloud/src/github/migrations.ts @@ -0,0 +1,196 @@ +import { Schema } from "effect"; +import { GitHubAppClient } from "../../../../packages/github/src/app-client.js"; +import { readRepositoryWorkflows } from "../../../../packages/github/src/workflow-repository.js"; +import { publishMigration as publishPullRequest } from "../../../../packages/github/src/migration-pr.js"; +import { previewMigration } from "../../../../packages/migration/src/index.js"; +import { RepositoryMigration } from "../../../../packages/protocol/src/migrations.js"; +import { bindingAuthority } from "../authority.js"; +import type { MachineContext, MachineHandler } from "../context.js"; +import { machineRecord, type MachineRow, type MigrationPreviewRow } from "../db.js"; +import { VectisError } from "../../../../packages/protocol/src/index.js"; +import { CloudError } from "../errors.js"; +import { newId } from "../ids.js"; +import { loadApp } from "./app.js"; +import { githubErrors, repositoryAccess } from "./access.js"; + +async function migrationErrors(operation: () => Promise) { + try { + return await githubErrors(operation); + } catch (error) { + if (error instanceof VectisError) + throw new CloudError(error.code, 409, { message: error.message, nextStep: error.nextStep }); + throw error; + } +} + +async function currentMachine(c: MachineContext) { + const row = await c.env.DB.prepare("SELECT * FROM machines WHERE id = ?") + .bind(c.machine.id) + .first(); + if (!row || row.owner !== c.machine.owner) throw new CloudError("machine_revoked", 401); + return machineRecord(row); +} + +async function verified(c: MachineContext, bindingId: string) { + const { binding, environment } = await bindingAuthority(c.env, c.machine, bindingId, true); + if (!environment?.revision) return false; + const row = await c.env.DB.prepare(`SELECT 1 AS found FROM + (SELECT * FROM runnerLeases WHERE machineId = ? AND phase = 'released' ORDER BY rowid DESC LIMIT 100) l + JOIN (SELECT * FROM githubJobs WHERE installationId = ? AND repositoryId = ? ORDER BY updatedAt DESC LIMIT 100) j + ON j.runnerId = l.runnerId + WHERE l.bindingId = ? AND l.environmentRevision = ? AND l.owner = ? + AND l.runnerId IS NOT NULL AND j.status = 'completed' AND j.conclusion = 'success' LIMIT 1`) + .bind( + c.machine.id, + binding.installationId, + binding.repositoryId, + binding.id, + environment.revision, + c.machine.owner, + ) + .first(); + return row !== null; +} + +async function owned(c: MachineContext, previewId: string) { + const preview = await c.env.DB.prepare("SELECT * FROM migrationPreviews WHERE id = ?") + .bind(previewId) + .first(); + if ( + !preview || + preview.owner !== c.machine.owner || + preview.machineId !== c.machine.id || + preview.expiresAt < Date.now() + ) + throw new CloudError("migration_preview_unavailable", 404); + const { environment } = await bindingAuthority( + c.env, + await currentMachine(c), + preview.bindingId, + true, + ); + if (!preview.environmentRevision || preview.environmentRevision !== environment?.revision) + throw new CloudError("migration_environment_changed", 409); + return preview; +} + +export const analyzeMigration: MachineHandler<"/machine/migrations/analyze"> = async (c, args) => + migrationErrors(async () => { + const { binding, account, environment } = await bindingAuthority( + c.env, + c.machine, + args.bindingId, + true, + ); + if (!environment) throw new CloudError("environment_unavailable", 409); + const client = new GitHubAppClient(await loadApp(c.env)); + const access = await repositoryAccess(client, { + owner: binding.repositoryOwner ?? account.login, + repo: binding.repositoryName, + repositoryId: binding.repositoryId, + githubUserId: account.githubId, + githubLogin: account.login, + purpose: "migration-read", + }); + if (access.installationId !== binding.installationId) + throw new CloudError("installation_changed", 409); + const source = await readRepositoryWorkflows( + (path) => client.request(access.token, access.path + path), + access.repositoryId, + ); + const labels = + environment.os === "linux" + ? ["ubuntu-24.04-arm", "ubuntu-24.04", "ubuntu-latest"] + : environment.os === "windows" + ? ["windows-11-arm", "windows-latest", "windows-2025", "windows-2022"] + : ["macos-27", "macos-26", "macos-latest"]; + const targets = labels.map((from) => ({ from, to: `vectis-${environment.id}` })); + const report = { + baseBranch: source.baseBranch, + baseCommit: source.baseCommit, + files: source.files.map((file) => { + const preview = previewMigration(file.source, targets); + return { + path: file.path, + mode: file.mode, + before: file.source, + after: preview.source, + changed: preview.changed, + findings: [...preview.findings], + ...(preview.jobs ? { jobs: [...preview.jobs] } : {}), + }; + }), + }; + const isVerified = await verified(c, args.bindingId); + const current = await bindingAuthority(c.env, await currentMachine(c), args.bindingId, true); + if ( + current.environment?.revision !== environment.revision || + current.binding.environmentId !== binding.environmentId + ) + throw new CloudError("migration_environment_changed", 409); + const reportJson = JSON.stringify(report); + if (new TextEncoder().encode(reportJson).length > 768 * 1024) + throw new CloudError("migration_preview_too_large", 409); + const previewId = newId(); + const now = Date.now(); + const result = + await c.env.DB.prepare(`INSERT INTO migrationPreviews (id, owner, machineId, bindingId, reportJson, environmentRevision, createdAt, expiresAt) + SELECT ?, ?, ?, ?, ?, ?, ?, ? WHERE (SELECT count(*) FROM migrationPreviews WHERE machineId = ? AND expiresAt >= ?) < 100`) + .bind( + previewId, + c.machine.owner, + c.machine.id, + current.binding.id, + reportJson, + current.environment?.revision ?? null, + now, + now + 86400000, + c.machine.id, + now, + ) + .run(); + if (!result.meta.changes) throw new CloudError("migration_preview_limit", 409); + return { previewId, verified: isVerified, ...report }; + }); + +export const publishMigration: MachineHandler<"/machine/migrations/publish"> = async (c, args) => + migrationErrors(async () => { + const preview = await owned(c, args.previewId); + if (!(await verified(c, preview.bindingId))) + throw new CloudError("environment_not_verified", 409, { + nextStep: + "Complete a successful GitHub job and its runner cleanup on this binding before publishing a migration.", + }); + const report = Schema.decodeUnknownSync(RepositoryMigration)(JSON.parse(preview.reportJson)); + const files = report.files + .filter((file) => file.changed) + .map((file) => ({ path: file.path, mode: file.mode, content: file.after })); + if (!files.length) throw new CloudError("migration_no_changes", 409); + const { binding, account } = await bindingAuthority(c.env, c.machine, preview.bindingId, true); + const access = await repositoryAccess(new GitHubAppClient(await loadApp(c.env)), { + owner: binding.repositoryOwner ?? account.login, + repo: binding.repositoryName, + repositoryId: binding.repositoryId, + githubUserId: account.githubId, + githubLogin: account.login, + purpose: "migration-write", + }); + if (access.installationId !== binding.installationId) + throw new CloudError("installation_changed", 409); + await owned(c, args.previewId); + const findings = report.files.flatMap((file) => + file.findings.map((finding) => `- ${file.path}: ${finding.job}: ${finding.reason}`), + ); + return publishPullRequest( + { + repositoryId: access.repositoryId, + owner: binding.repositoryOwner ?? account.login, + repository: binding.repositoryName, + baseBranch: report.baseBranch, + baseCommit: report.baseCommit, + files, + body: `Move compatible ARM64 workflow jobs to the verified local Vectis environment \`${binding.environmentId}\`. Unchanged jobs retain their existing runners.\n\nReview the workflow diff and required toolchains before merging. The machine must be online to accept jobs.\n\n${findings.length ? "Manual review findings:\n" + findings.join("\n") : "No automatic migration findings."}`, + }, + access.token, + ); + }); diff --git a/convex/githubOAuth.ts b/apps/cloud/src/github/oauth.ts similarity index 75% rename from convex/githubOAuth.ts rename to apps/cloud/src/github/oauth.ts index 8dc05e33..13c53195 100644 --- a/convex/githubOAuth.ts +++ b/apps/cloud/src/github/oauth.ts @@ -1,12 +1,21 @@ +import { githubUserAgent } from "../../../../packages/github/src/api-request.js"; +import { sha256Hex as digest } from "../ids.js"; +import { CloudError } from "../errors.js"; import { Schema } from "effect"; -import { httpAction } from "./_generated/server.js"; -import { internal } from "./_generated/api.js"; -import { human } from "./auth.js"; -import { readBody, smallJson } from "./httpBody.js"; -import { webUrl } from "./site.js"; - -const callbackUrl = () => `${webUrl()}/api/github/oauth/callback`; -const connectUrl = () => `${webUrl()}/connect?github=1`; +import type { Context, RawHandler, HumanHandler, MachineHandler } from "../context.js"; +import { authenticateHuman } from "../auth/human.js"; +import { readBody, readJson } from "../http.js"; +import { + createLink, + confirmLink, + claimLink, + finishLink, + unlink, + discard, + accountsForMachine, +} from "./identity-store.js"; +const callbackUrl = (c: Context) => `${c.env.VECTIS_WEB_URL}/api/github/oauth/callback`; +const connectUrl = (c: Context) => `${c.env.VECTIS_WEB_URL}/connect?github=1`; const base64 = (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes)) .replaceAll("+", "-") @@ -14,8 +23,6 @@ const base64 = (bytes: Uint8Array) => .replaceAll("=", ""); const hash = async (value: string) => new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value))); -const digest = async (value: string) => - Array.from(await hash(value), (b) => b.toString(16).padStart(2, "0")).join(""); const browserCookie = "__Host-vectis-github-browser"; const completionCookie = "__Host-vectis-github-completion"; const cookie = (name: string, value: string) => @@ -29,15 +36,15 @@ function readCookie(request: Request, name: string) { return value && /^[A-Za-z0-9_-]{43}$/.test(value) ? value : undefined; } const privateHeaders = { "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" }; -export const begin = httpAction(async (ctx, request) => { - if (request.headers.get("Origin") !== webUrl()) +export const oauthBegin: RawHandler = async (c, request) => { + if (request.headers.get("Origin") !== c.env.VECTIS_WEB_URL) return new Response("Origin denied", { status: 403 }); try { - const owner = await human(ctx); + const owner = await authenticateHuman(c.env, request); const browser = base64(crypto.getRandomValues(new Uint8Array(32))); const state = base64(crypto.getRandomValues(new Uint8Array(32))); const verifier = base64(crypto.getRandomValues(new Uint8Array(32))); - const { clientId } = await ctx.runMutation(internal.githubIdentity.create, { + const { clientId } = await createLink(c.env, { owner, digest: await digest(state), verifier, @@ -45,7 +52,7 @@ export const begin = httpAction(async (ctx, request) => { }); const params = new URLSearchParams({ client_id: clientId, - redirect_uri: callbackUrl(), + redirect_uri: callbackUrl(c), state, code_challenge: base64(await hash(verifier)), code_challenge_method: "S256", @@ -63,20 +70,20 @@ export const begin = httpAction(async (ctx, request) => { { status: 400, headers: privateHeaders }, ); } -}); -export const confirm = httpAction(async (ctx, request) => { - if (request.headers.get("Origin") !== webUrl()) +}; +export const oauthConfirm: RawHandler = async (c, request) => { + if (request.headers.get("Origin") !== c.env.VECTIS_WEB_URL) return new Response("Origin denied", { status: 403 }); const browser = readCookie(request, browserCookie); const completion = readCookie(request, completionCookie); if (!browser || !completion) return new Response("Browser verification required", { status: 403, headers: privateHeaders }); try { - const owner = await human(ctx); - const body = await smallJson(request); + const owner = await authenticateHuman(c.env, request); + const body = await readJson(request, 4096); if (!body || typeof body !== "object" || !("id" in body) || typeof body.id !== "string") return new Response("Invalid request", { status: 400, headers: privateHeaders }); - await ctx.runMutation(internal.githubIdentity.confirm, { + await confirmLink(c.env, { owner, id: body.id, browserDigest: await digest(browser), @@ -86,13 +93,13 @@ export const confirm = httpAction(async (ctx, request) => { for (const name of [browserCookie, completionCookie]) headers.append("Set-Cookie", `${name}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`); return Response.json({ linked: true }, { headers }); - } catch { + } catch (error) { return Response.json( - { code: "github_link_unavailable" }, + { code: error instanceof CloudError ? error.code : "github_link_unavailable" }, { status: 400, headers: privateHeaders }, ); } -}); +}; const Token = Schema.Struct({ access_token: Schema.NonEmptyString, token_type: Schema.Literal("bearer"), @@ -110,18 +117,16 @@ const Installations = Schema.Struct({ ), }); async function json(url: string, init: RequestInit, signal: AbortSignal): Promise { - const response = await fetch(url, { - ...init, - redirect: "error", - signal, - }); + const headers = new Headers(init.headers); + headers.set("User-Agent", githubUserAgent); + const response = await fetch(url, { ...init, headers, redirect: "manual", signal }); if (!response.ok) { await response.body?.cancel(); throw new Error("GitHub request failed."); } return JSON.parse(new TextDecoder().decode(await readBody(response, 512 * 1024))); } -export const callback = httpAction(async (ctx, request) => { +export const oauthCallback: RawHandler = async (c, request) => { const params = new URL(request.url).searchParams; const state = params.get("state"); const code = params.get("code"); @@ -129,7 +134,7 @@ export const callback = httpAction(async (ctx, request) => { if (!state) return new Response(null, { status: 303, - headers: { ...headers, Location: connectUrl() }, + headers: { ...headers, Location: connectUrl(c) }, }); if (!/^[A-Za-z0-9_-]{43}$/.test(state)) return new Response("Start GitHub linking from Vectis before authorizing this App.", { @@ -142,7 +147,7 @@ export const callback = httpAction(async (ctx, request) => { status: 403, headers, }); - const claim = await ctx.runMutation(internal.githubIdentity.claim, { + const claim = await claimLink(c.env, { digest: await digest(state), browserDigest: await digest(browser), }); @@ -167,7 +172,7 @@ export const callback = httpAction(async (ctx, request) => { client_id: claim.clientId, client_secret: claim.clientSecret, code, - redirect_uri: callbackUrl(), + redirect_uri: callbackUrl(c), code_verifier: claim.verifier, }).toString(), }, @@ -208,7 +213,7 @@ export const callback = httpAction(async (ctx, request) => { } if (!complete) throw new Error("Too many installations."); const completion = base64(crypto.getRandomValues(new Uint8Array(32))); - await ctx.runMutation(internal.githubIdentity.finish, { + await finishLink(c.env, { id: claim.id, user: { ...user, installations }, completionDigest: await digest(completion), @@ -217,15 +222,34 @@ export const callback = httpAction(async (ctx, request) => { status: 303, headers: { ...headers, - Location: connectUrl(), + Location: connectUrl(c), "Set-Cookie": cookie(completionCookie, completion), }, }); } catch { - await ctx.runMutation(internal.githubIdentity.finish, { id: claim.id }); + await finishLink(c.env, { id: claim.id }); return new Response( "GitHub verification did not finish. Return to Vectis and start a new request.", { status: 502, headers }, ); } -}); +}; + +export const unlinkAccount: HumanHandler<"/github/accounts/unlink"> = async ( + { env, owner }, + { id }, +) => { + await unlink(env, owner, id); + return null; +}; +export const discardLink: HumanHandler<"/github/links/discard"> = async ( + { env, owner }, + { id }, +) => { + await discard(env, owner, id); + return null; +}; +export const machineGitHubAccounts: MachineHandler<"/machine/github/accounts"> = async ({ + env, + machine, +}) => accountsForMachine(env, machine.owner); diff --git a/apps/cloud/src/github/redelivery.ts b/apps/cloud/src/github/redelivery.ts new file mode 100644 index 00000000..23d0c501 --- /dev/null +++ b/apps/cloud/src/github/redelivery.ts @@ -0,0 +1,158 @@ +import { Schema } from "effect"; +import { createGitHubAppJwt, GitHubApiError } from "../../../../packages/github/src/app-client.js"; +import { githubUserAgent } from "../../../../packages/github/src/api-request.js"; +import type { Sweep } from "../context.js"; +import { loadApp } from "./app.js"; +import { CloudError } from "../errors.js"; + +const minute = 60_000; +const maxAge = 3 * 24 * 60 * minute; +const events = new Set(["workflow_job", "installation", "installation_repositories", "repository"]); +const deliveries = Schema.Array( + Schema.Struct({ + id: Schema.String.check(Schema.isPattern(/^\d{1,30}$/)), + guid: Schema.NonEmptyString, + delivered_at: Schema.String, + status_code: Schema.NullOr(Schema.Int), + event: Schema.String, + }), +); + +function nextCursor(link: string | null) { + if (!link) return null; + for (const part of link.split(",")) { + const match = part.match(/<([^>]+)>;\s*rel="next"/); + const url = match?.[1]; + if (url) { + const cursor = new URL(url).searchParams.get("cursor"); + if (!cursor) throw new Error("GitHub delivery pagination is missing its cursor."); + return cursor; + } + } + return null; +} + +export const sweepRedelivery: Sweep = async (env, now) => { + let app; + try { + app = await loadApp(env); + } catch (error) { + if (error instanceof CloudError && error.code === "github_app_unavailable") return; + throw error; + } + const checkpoint = await env.DB.prepare( + "SELECT deliveredAt FROM githubRedeliveryCheckpoint WHERE singleton = 1", + ).first<{ deliveredAt: number }>(); + const state = { app, checkpoint }; + const since = Math.max(state.checkpoint?.deliveredAt ?? now - 60 * minute, now - maxAge); + const through = now - minute; + const deadline = now + 90_000; + const signal = AbortSignal.timeout(90_000); + const headers = { + Authorization: `Bearer ${createGitHubAppJwt(state.app)}`, + Accept: "application/vnd.github+json", + "User-Agent": githubUserAgent, + "X-GitHub-Api-Version": "2026-03-10", + }; + let fetches = 0; + const request = async (path: string, method: "GET" | "POST") => { + if (++fetches > 40) throw new Error("GitHub delivery request budget exhausted."); + const response = await fetch(`https://api.github.com${path}`, { + method, + headers, + redirect: "manual", + signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]), + }); + if (!response.ok) { + await response.body?.cancel(); + throw new GitHubApiError(response.status); + } + return response; + }; + + const attempts = new Map(); + let complete = false; + let cursor: string | null = null; + try { + for (let page = 0; page < 10; page++) { + const params = new URLSearchParams({ per_page: "100" }); + if (cursor) params.set("cursor", cursor); + const response = await request(`/app/hook/deliveries?${params}`, "GET"); + // Delivery IDs exceed Number.MAX_SAFE_INTEGER, so they must stay strings. + const text = (await response.text()).replace(/"id":\s*(\d+)/g, '"id":"$1"'); + const rows = Schema.decodeUnknownSync(deliveries)(JSON.parse(text)); + if (rows.length > 100) throw new Error("GitHub exceeded the delivery page limit."); + for (const row of rows) { + const deliveredAt = Date.parse(row.delivered_at); + if (!Number.isFinite(deliveredAt)) throw new Error("Invalid GitHub delivery timestamp."); + if (deliveredAt < since) { + complete = true; + break; + } + if (!events.has(row.event)) continue; + const successful = + row.status_code !== null && row.status_code >= 200 && row.status_code < 300; + const existing = attempts.get(row.guid); + if (!existing || deliveredAt > existing.deliveredAt) + attempts.set(row.guid, { + id: row.id, + deliveredAt, + successful: successful || existing?.successful === true, + }); + else existing.successful ||= successful; + } + cursor = nextCursor(response.headers.get("link")); + if (complete || !cursor || rows.length === 0) { + complete = true; + break; + } + } + } catch (error) { + console.warn("GitHub webhook delivery listing failed; checkpoint unchanged.", String(error)); + return; + } + if (!complete) { + console.warn("GitHub webhook delivery page limit reached; checkpoint unchanged."); + return; + } + + const candidates = [...attempts.entries()] + .filter(([, attempt]) => !attempt.successful && attempt.deliveredAt <= through) + .sort((a, b) => a[1].deliveredAt - b[1].deliveredAt); + let requested = 0; + for (const [guid, attempt] of candidates) { + if (requested >= 20 || fetches >= 40 || Date.now() >= deadline || signal.aborted) { + complete = false; + break; + } + const claim = await env.DB.prepare( + "INSERT INTO githubRedeliveryRequests (guid, requestedAt) VALUES (?, ?) ON CONFLICT DO NOTHING", + ) + .bind(guid, now) + .run(); + if (!claim.meta.changes) continue; + requested++; + try { + const response = await request(`/app/hook/deliveries/${attempt.id}/attempts`, "POST"); + await response.body?.cancel(); + } catch (error) { + console.warn("GitHub webhook redelivery request failed; it will not be repeated.", { + guid, + deliveryId: attempt.id, + error, + }); + complete = false; + break; + } + } + if (!complete) { + console.warn("GitHub webhook redelivery scan incomplete; checkpoint unchanged."); + return; + } + // Fresh attempts remain in the next scan, even when this run saw newer successful deliveries. + await env.DB.prepare(`INSERT INTO githubRedeliveryCheckpoint (singleton, deliveredAt, scannedAt) VALUES (1, ?, ?) + ON CONFLICT (singleton) DO UPDATE SET deliveredAt = excluded.deliveredAt, scannedAt = excluded.scannedAt + WHERE githubRedeliveryCheckpoint.deliveredAt < excluded.deliveredAt`) + .bind(through, now) + .run(); +}; diff --git a/apps/cloud/src/github/repositories.ts b/apps/cloud/src/github/repositories.ts new file mode 100644 index 00000000..6eb0ea38 --- /dev/null +++ b/apps/cloud/src/github/repositories.ts @@ -0,0 +1,300 @@ +import type { Env } from "../env.js"; +import { + machineRecord, + bindingRecord, + type Machine, + type MachineRow, + type GitHubAccountRow, + type BindingRow, + type Binding, +} from "../db.js"; +import type { AccountOverview } from "../../../../packages/protocol/src/cloud.js"; +import type { MachineRepositories } from "../../../../packages/protocol/src/repositories.js"; +import type { HumanHandler, MachineHandler } from "../context.js"; +import { bindingAuthority } from "../authority.js"; +import { CloudError } from "../errors.js"; +import { newId } from "../ids.js"; +import { notifyMachine, requestDemand } from "../notify.js"; +import { markScaleSetsForCleanup } from "../runners/scale-sets.js"; +import { workflowLabel } from "../../../../packages/github/src/runner-labels.js"; +import { restrictiveAccess } from "../../../../packages/github/src/access-policy.js"; +import { GitHubAppClient } from "../../../../packages/github/src/app-client.js"; +import { RepositoryAccess } from "../../../../packages/protocol/src/repository-access.js"; +import { + CacheVolume, + defaultCacheSizeGiB, +} from "../../../../packages/protocol/src/cache-volume.js"; +import { Schema } from "effect"; +import { loadApp } from "./app.js"; +import { repositoryAccess } from "./access.js"; +export { repositoryReadiness } from "./bindings-readiness.js"; + +async function authorize(env: Env, owner: string, accountId: string, machineId: string) { + const [account, target] = await Promise.all([ + env.DB.prepare("SELECT * FROM githubAccounts WHERE id = ?") + .bind(accountId) + .first(), + env.DB.prepare( + "SELECT m.*, c.digest AS credentialDigest FROM machines m JOIN machineCredentials c ON c.machineId = m.id WHERE m.id = ?", + ) + .bind(machineId) + .first(), + ]); + if (!account || account.owner !== owner || target?.owner !== owner) + throw new CloudError("repository_access_denied", 403); + return { account, machine: machineRecord(target), credentialDigest: target.credentialDigest }; +} +async function enable( + env: Env, + owner: string, + input: { + accountId: string; + machineId: string; + environmentId: string; + repositoryName: string; + repositoryOwner?: string | undefined; + }, + machineOnly: boolean, +) { + const initial = await authorize(env, owner, input.accountId, input.machineId); + if ( + machineOnly && + !initial.machine.environments.some((e) => e.id === input.environmentId && e.state === "ready") + ) + throw new CloudError("environment_unavailable"); + if ( + (input.repositoryOwner !== undefined && !/^[A-Za-z0-9-]+$/.test(input.repositoryOwner)) || + !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,79}$/.test(input.environmentId) + ) + throw new CloudError("invalid_repository_binding"); + const { repositoryId, installationId } = await repositoryAccess( + new GitHubAppClient(await loadApp(env)), + { + owner: input.repositoryOwner ?? initial.account.login, + githubLogin: initial.account.login, + repo: input.repositoryName, + githubUserId: initial.account.githubId, + }, + ); + const current = await authorize(env, owner, input.accountId, input.machineId); + if (initial.credentialDigest !== current.credentialDigest) + throw new CloudError("machine_revoked", 401); + if ( + machineOnly && + !current.machine.environments.some((e) => e.id === input.environmentId && e.state === "ready") + ) + throw new CloudError("environment_unavailable"); + if (![repositoryId, installationId].every((n) => Number.isSafeInteger(n) && n > 0)) + throw new CloudError("invalid_repository_binding"); + const existing = await env.DB.prepare( + "SELECT * FROM repositoryBindings WHERE machineId = ? AND repositoryId = ? AND environmentId = ?", + ) + .bind(input.machineId, repositoryId, input.environmentId) + .first(); + if (existing && existing.owner !== owner) throw new CloudError("repository_access_denied", 403); + if ( + existing && + (existing.accountId !== input.accountId || existing.installationId !== installationId) + ) + await markScaleSetsForCleanup(env, [existing.id]); + const id = existing?.id ?? newId(); + await env.DB.prepare( + `INSERT INTO repositoryBindings (id, owner, accountId, machineId, repositoryId, repositoryName, repositoryOwner, installationId, environmentId, enabled, verifiedAt) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ? WHERE ((SELECT COUNT(*) FROM repositoryBindings WHERE machineId = ?) < 100 OR EXISTS (SELECT 1 FROM repositoryBindings WHERE machineId = ? AND repositoryId = ? AND environmentId = ?)) AND EXISTS (SELECT 1 FROM githubAccounts WHERE id = ? AND owner = ?) AND EXISTS (SELECT 1 FROM machines WHERE id = ? AND owner = ?) ON CONFLICT(machineId, repositoryId, environmentId) DO UPDATE SET accountId = excluded.accountId, repositoryName = excluded.repositoryName, repositoryOwner = excluded.repositoryOwner, installationId = excluded.installationId, enabled = 1, verifiedAt = excluded.verifiedAt WHERE repositoryBindings.owner = excluded.owner`, + ) + .bind( + id, + owner, + input.accountId, + input.machineId, + repositoryId, + input.repositoryName, + input.repositoryOwner ?? null, + installationId, + input.environmentId, + Date.now(), + input.machineId, + input.machineId, + repositoryId, + input.environmentId, + input.accountId, + owner, + input.machineId, + owner, + ) + .run(); + const saved = await env.DB.prepare( + "SELECT id FROM repositoryBindings WHERE machineId = ? AND repositoryId = ? AND environmentId = ?", + ) + .bind(input.machineId, repositoryId, input.environmentId) + .first<{ id: string }>(); + if (!saved) throw new CloudError("binding_limit"); + await notifyMachine(env, input.machineId); + return { bindingId: saved.id }; +} +export const enableRepository: HumanHandler<"/repositories/enable"> = async ( + { env, owner }, + input, +) => enable(env, owner, input, false); +export const connectRepository: MachineHandler<"/machine/repositories/connect"> = async ( + { env, machine }, + input, +) => enable(env, machine.owner, { ...input, machineId: machine.id }, true); +async function ownedBinding( + env: Env, + owner: string, + id: string, + machineId?: string, + enabled = false, +) { + const row = await env.DB.prepare( + "SELECT b.* FROM repositoryBindings b JOIN githubAccounts a ON a.id = b.accountId AND a.owner = b.owner WHERE b.id = ? AND b.owner = ?", + ) + .bind(id, owner) + .first(); + if (!row || (machineId && row.machineId !== machineId) || (enabled && !row.enabled)) + throw new CloudError( + machineId ? "repository_access_denied" : "binding_missing", + machineId ? 403 : 404, + ); + return bindingRecord(row); +} +async function machineBinding(env: Env, machine: Machine, id: string) { + const { binding } = await bindingAuthority(env, machine, id, false); + if (!binding.enabled) throw new CloudError("repository_access_denied", 403); + return binding; +} +async function disable(env: Env, binding: Binding) { + await markScaleSetsForCleanup(env, [binding.id]); + await env.DB.prepare( + "UPDATE repositoryBindings SET enabled = 0, automatic = 0, infraRerun = 0 WHERE id = ? AND (enabled != 0 OR automatic != 0 OR infraRerun != 0)", + ) + .bind(binding.id) + .run(); + await notifyMachine(env, binding.machineId); + return null; +} +export const disableRepository: HumanHandler<"/repositories/disable"> = async ( + { env, owner }, + { id }, +) => disable(env, await ownedBinding(env, owner, id)); +export const disconnectRepository: MachineHandler<"/machine/repositories/disconnect"> = async ( + { env, machine }, + { bindingId }, +) => disable(env, await ownedBinding(env, machine.owner, bindingId, machine.id)); +async function update( + env: Env, + binding: Binding, + column: string, + value: string | number, + demand = false, +) { + const result = await env.DB.prepare( + `UPDATE repositoryBindings SET ${column} = ? WHERE id = ? AND ${column} IS NOT ?`, + ) + .bind(value, binding.id, value) + .run(); + if (result.meta.changes) { + await notifyMachine(env, binding.machineId); + if (demand) await requestDemand(env, binding.machineId); + } + return null; +} +function assertPolicy(access: Binding["access"]) { + if (restrictiveAccess(access)) + throw new CloudError("scale_set_policy_unsupported", 409, { + message: "Scale set messages do not identify the workflow actor or approval status.", + nextStep: "Use demand capacity for actor restrictions or required approval.", + }); +} +export const setAutomatic: MachineHandler<"/machine/repositories/set-automatic"> = async ( + { env, machine }, + { bindingId, enabled }, +) => + update(env, await machineBinding(env, machine, bindingId), "automatic", Number(enabled), enabled); +export const setInfraRerun: MachineHandler<"/machine/repositories/set-infra-rerun"> = async ( + { env, machine }, + { bindingId, enabled }, +) => update(env, await machineBinding(env, machine, bindingId), "infraRerun", Number(enabled)); +export const setCapacityMode: MachineHandler<"/machine/repositories/set-capacity-mode"> = async ( + { env, machine }, + { bindingId, capacityMode }, +) => { + const binding = await machineBinding(env, machine, bindingId); + if (capacityMode === "scale_set") assertPolicy(binding.access); + if (capacityMode === "demand" && binding.capacityMode !== capacityMode) + await markScaleSetsForCleanup(env, [binding.id]); + return update(env, binding, "capacityMode", capacityMode, capacityMode === "demand"); +}; +export const setCacheVolume: MachineHandler<"/machine/repositories/set-cache-volume"> = async ( + { env, machine }, + { bindingId, enabled, sizeGiB }, +) => { + const binding = await machineBinding(env, machine, bindingId); + const cache = { + enabled, + sizeGiB: sizeGiB ?? binding.cacheVolume?.sizeGiB ?? defaultCacheSizeGiB, + }; + if (!Schema.is(CacheVolume)(cache)) throw new CloudError("invalid_cache_volume"); + return update(env, binding, "cacheVolumeJson", JSON.stringify(cache)); +}; +export const setAccess: MachineHandler<"/machine/repositories/set-access"> = async ( + { env, machine }, + { bindingId, actors, approval }, +) => { + const binding = await machineBinding(env, machine, bindingId); + const access = { + actors: actors === "any" ? ("any" as const) : { ids: [...new Set(actors.ids)] }, + approval, + }; + if (!Schema.is(RepositoryAccess)(access)) throw new CloudError("invalid_repository_access"); + if (binding.capacityMode === "scale_set") assertPolicy(access); + return update(env, binding, "accessJson", JSON.stringify(access), true); +}; +export async function machineRepositories( + env: Env, + machine: Machine, +): Promise { + const rows = await env.DB.prepare( + "SELECT b.*, a.login FROM repositoryBindings b JOIN githubAccounts a ON a.id = b.accountId AND a.owner = b.owner WHERE b.machineId = ? AND b.owner = ? AND b.enabled = 1 ORDER BY b.rowid LIMIT 100", + ) + .bind(machine.id, machine.owner) + .all(); + return rows.results.map((row) => { + const b = bindingRecord(row); + return { + id: b.id, + repositoryId: b.repositoryId, + installationId: b.installationId, + repositoryName: `${b.repositoryOwner ?? row.login}/${b.repositoryName}`, + environmentId: b.environmentId, + runsOn: workflowLabel(b.environmentId), + automatic: b.automatic, + infraRerun: b.infraRerun, + capacityMode: b.capacityMode, + ...(b.access ? { access: b.access } : {}), + ...(b.cacheVolume ? { cacheVolume: b.cacheVolume } : {}), + }; + }); +} +export async function accountBindings( + env: Env, + owner: string, +): Promise { + const rows = await env.DB.prepare( + "SELECT * FROM repositoryBindings WHERE owner = ? ORDER BY rowid LIMIT 100", + ) + .bind(owner) + .all(); + return rows.results.map((row) => { + const { owner: _owner, ...binding } = bindingRecord(row); + return { ...binding, runsOn: workflowLabel(binding.environmentId) }; + }); +} +export async function purgeBinding(env: Env, bindingId: string): Promise { + const row = await env.DB.prepare("SELECT machineId FROM repositoryBindings WHERE id = ?") + .bind(bindingId) + .first<{ machineId: string }>(); + await markScaleSetsForCleanup(env, [bindingId]); + await env.DB.prepare("DELETE FROM repositoryBindings WHERE id = ?").bind(bindingId).run(); + if (row) await notifyMachine(env, row.machineId); +} diff --git a/apps/cloud/src/github/runs-receipts.ts b/apps/cloud/src/github/runs-receipts.ts new file mode 100644 index 00000000..1dae03a2 --- /dev/null +++ b/apps/cloud/src/github/runs-receipts.ts @@ -0,0 +1,63 @@ +import { Schema } from "effect"; +import { RunControlOutcome } from "../../../../packages/protocol/src/runs.js"; +import type { MachineContext } from "../context.js"; +import type { RunReceiptRow } from "../db.js"; +import { CloudError } from "../errors.js"; + +export class Receipts { + constructor( + private readonly c: MachineContext, + private readonly bindingId: string, + private readonly key: string, + private readonly requestHash: string, + ) {} + + async lookup() { + const receipt = await this.c.env.DB.prepare( + "SELECT * FROM runCommandReceipts WHERE machineId = ? AND key = ?", + ) + .bind(this.c.machine.id, this.key) + .first(); + if ( + receipt && + (receipt.bindingId !== this.bindingId || receipt.requestHash !== this.requestHash) + ) + throw new CloudError("idempotency_conflict", 409); + return receipt; + } + + outcome(receipt: RunReceiptRow) { + return receipt.outcomeJson === null + ? undefined + : Schema.decodeUnknownSync(RunControlOutcome)(JSON.parse(receipt.outcomeJson)); + } + + async claim() { + const now = Date.now(); + const result = await this.c.env.DB.prepare(`INSERT INTO runCommandReceipts + (machineId, key, bindingId, requestHash, state, createdAt, updatedAt) + VALUES (?, ?, ?, ?, 'submitted', ?, ?) ON CONFLICT (machineId, key) DO NOTHING`) + .bind(this.c.machine.id, this.key, this.bindingId, this.requestHash, now, now) + .run(); + const receipt = await this.lookup(); + if (!receipt) throw new Error("Run command receipt persistence failed."); + return { fresh: result.meta.changes === 1, receipt }; + } + + async finish(outcome: RunControlOutcome) { + const result = await this.c.env.DB.prepare(`UPDATE runCommandReceipts + SET state = ?, outcomeJson = ?, updatedAt = ? + WHERE machineId = ? AND key = ? AND bindingId = ? AND requestHash = ? AND state = 'submitted'`) + .bind( + outcome.state, + JSON.stringify(outcome), + Date.now(), + this.c.machine.id, + this.key, + this.bindingId, + this.requestHash, + ) + .run(); + if (!result.meta.changes) throw new Error("Run command receipt completion failed."); + } +} diff --git a/apps/cloud/src/github/runs.ts b/apps/cloud/src/github/runs.ts new file mode 100644 index 00000000..e64b674c --- /dev/null +++ b/apps/cloud/src/github/runs.ts @@ -0,0 +1,339 @@ +import { Schema } from "effect"; +import { + GitHubAppClient, + GitHubApiError, + GitHubPermissionError, + GitHubUnavailableError, + GitHubRateLimitError, + GitHubUncertainError, +} from "../../../../packages/github/src/app-client.js"; +import { RunControlCommand, RunControlOutcome } from "../../../../packages/protocol/src/runs.js"; +import { unconfirmedRunCommand } from "../../../../packages/github/src/run-control.js"; +import { bindingAuthority } from "../authority.js"; +import type { MachineHandler } from "../context.js"; +import { CloudError } from "../errors.js"; +import { sha256Hex } from "../ids.js"; +import { repositoryAccess } from "./access.js"; +import { loadApp } from "./app.js"; +import { Receipts } from "./runs-receipts.js"; + +const Run = Schema.Struct({ + id: Schema.Int, + run_attempt: Schema.Int, + status: Schema.String, + conclusion: Schema.NullOr(Schema.String), + html_url: Schema.String, + repository: Schema.Struct({ id: Schema.Int }), +}); +const JobsPage = Schema.Struct({ + total_count: Schema.Int, + jobs: Schema.Array( + Schema.Struct({ id: Schema.optional(Schema.Int), conclusion: Schema.NullOr(Schema.String) }), + ), +}); +const DispatchDetails = Schema.Struct({ + workflow_run_id: Schema.Int.check(Schema.isGreaterThan(0)), + html_url: Schema.String, +}); +function sleep(delay: number, signal: AbortSignal) { + signal.throwIfAborted(); + return new Promise((resolve, reject) => { + const abort = () => { + clearTimeout(timer); + reject(signal.reason); + }; + const timer = setTimeout(() => { + signal.removeEventListener("abort", abort); + resolve(); + }, delay); + signal.addEventListener("abort", abort, { once: true }); + }); +} +function rejected(code: string, message: string, nextStep: string): RunControlOutcome { + return { state: "rejected", code, message, nextStep }; +} + +export const controlRun: MachineHandler<"/machine/runs/control"> = async (c, args) => { + let command: RunControlCommand; + try { + command = Schema.decodeUnknownSync(RunControlCommand, { onExcessProperty: "error" })( + args.command, + ); + } catch { + throw new CloudError("invalid_run_request", 409, { + message: "Invalid GitHub run command.", + nextStep: "Inspect the command schema, workflow, ref and input limits.", + }); + } + const authority = await bindingAuthority(c.env, c.machine, command.bindingId, false); + if (!authority.binding.enabled) throw new CloudError("repository_access_denied", 403); + if (!args.key.trim() || args.key.length > 200) throw new CloudError("invalid_run_request", 409); + const canonical = + command.type === "workflow.dispatch" + ? { + ...command, + ...(command.inputs + ? { + inputs: Object.fromEntries( + Object.entries(command.inputs).sort(([a], [b]) => a.localeCompare(b)), + ), + } + : {}), + } + : command; + const requestHash = await sha256Hex(JSON.stringify(canonical)); + const attemptScoped = + command.type === "run.rerun-failed" && command.expectedRunAttempt !== undefined; + const receipts = new Receipts(c, command.bindingId, args.key, requestHash); + const previous = attemptScoped ? await receipts.lookup() : null; + if (previous) return receipts.outcome(previous) ?? unconfirmedRunCommand(command); + let claimed = attemptScoped ? undefined : await receipts.claim(); + if (claimed && !claimed.fresh) + return receipts.outcome(claimed.receipt) ?? unconfirmedRunCommand(command); + let posted = false; + let responseReceived = false; + let acceptedOutcome: Extract | undefined; + let outcome: RunControlOutcome; + try { + const client = new GitHubAppClient(await loadApp(c.env)); + const access = await repositoryAccess(client, { + owner: authority.binding.repositoryOwner ?? authority.account.login, + repo: authority.binding.repositoryName, + repositoryId: authority.binding.repositoryId, + githubUserId: authority.account.githubId, + githubLogin: authority.account.login, + purpose: "actions-write", + ...(command.type === "run.cancel" ? { actionsOperation: "cancel" } : {}), + }); + if (access.installationId !== authority.binding.installationId) + throw new CloudError("installation_changed", 409, { + message: "The repository installation changed.", + nextStep: "Reconnect this repository binding before retrying.", + }); + const readRun = async (runId: number, signal?: AbortSignal) => { + const run = Schema.decodeUnknownSync(Run)( + await client.request( + access.token, + `${access.path}/actions/runs/${runId}`, + undefined, + signal, + ), + ); + if (run.id !== runId || run.repository.id !== access.repositoryId) + throw new CloudError("run_repository_mismatch", 409, { + message: "The run does not belong to this binding's repository.", + nextStep: "Select a run from the connected repository.", + }); + return run; + }; + const before = command.type !== "workflow.dispatch" ? await readRun(command.runId) : undefined; + if ( + before && + command.type === "run.cancel" && + !["queued", "in_progress", "waiting", "pending", "requested"].includes(before.status) + ) + throw new CloudError("run_not_active", 409, { + message: "This run is not active and cannot be cancelled.", + nextStep: "Select an active run.", + }); + if (before && command.type === "run.rerun-failed") { + if ( + command.expectedRunAttempt !== undefined && + before.run_attempt !== command.expectedRunAttempt + ) + throw new CloudError("run_attempt_changed", 409, { + message: "A different run attempt is now current.", + nextStep: "Inspect the current run on GitHub.", + }); + if (before.status !== "completed" && attemptScoped) + return rejected( + "run_not_ready", + "The job finished; other jobs in this run are still active.", + "Vectis will check this attempt again before requesting its retry.", + ); + if (before.status !== "completed") + throw new CloudError("run_not_rerunnable", 409, { + message: "The run is still active.", + nextStep: "Wait for the run to complete before rerunning failed jobs.", + }); + let failed = false; + for (let page = 1; page <= 10; page++) { + const jobs = Schema.decodeUnknownSync(JobsPage)( + await client.request( + access.token, + `${access.path}/actions/runs/${before.id}/attempts/${before.run_attempt}/jobs?per_page=100&page=${page}`, + ), + ); + if ( + command.infraJobIds && + jobs.jobs.some( + (job) => + ["failure", "timed_out"].includes(job.conclusion ?? "") && + (job.id === undefined || !command.infraJobIds?.includes(job.id)), + ) + ) + throw new CloudError("run_has_workflow_failures", 409, { + message: "Some failed jobs do not have a verified infrastructure failure.", + nextStep: "Inspect the workflow failures before manually rerunning jobs.", + }); + failed ||= jobs.jobs.some( + (job) => job.conclusion === "failure" || job.conclusion === "timed_out", + ); + if ((!command.infraJobIds && failed) || page * 100 >= jobs.total_count) break; + if (!jobs.jobs.length || page === 10) + throw new CloudError("run_jobs_unverified", 409, { + message: "Failed jobs could not be verified within the API limit.", + nextStep: "Inspect the run's jobs on GitHub before retrying.", + }); + } + if (!failed) + throw new CloudError("run_not_rerunnable", 409, { + message: "The run has no failed jobs.", + nextStep: "Select a completed run with failed jobs.", + }); + } + const current = await bindingAuthority(c.env, c.machine, command.bindingId, false); + if ( + !current.binding.enabled || + (attemptScoped && !current.binding.infraRerun) || + current.binding.repositoryId !== access.repositoryId || + current.binding.installationId !== access.installationId || + current.account.id !== authority.account.id + ) + throw new CloudError("repository_access_denied", 409, { + message: "Repository access changed before submission.", + nextStep: "Check the binding and GitHub App access before retrying.", + }); + claimed ??= await receipts.claim(); + if (!claimed.fresh) return receipts.outcome(claimed.receipt) ?? unconfirmedRunCommand(command); + posted = true; + if (command.type === "workflow.dispatch") { + // API 2026-03-10 always returns run details; return_run_details was removed. + const response = await client.post( + access.token, + `${access.path}/actions/workflows/${encodeURIComponent(String(command.workflow))}/dispatches`, + { ref: command.ref, ...(command.inputs ? { inputs: command.inputs } : {}) }, + ); + responseReceived = true; + if (response.status === 204) { + outcome = { + state: "accepted", + message: "GitHub accepted the workflow dispatch; run ID is unknown.", + }; + } else if (response.status === 200) { + const details = Schema.decodeUnknownSync(DispatchDetails)(response.body); + outcome = { + state: "accepted", + message: "GitHub returned the dispatched workflow run.", + runId: details.workflow_run_id, + htmlUrl: details.html_url, + }; + } else outcome = unconfirmedRunCommand(command); + } else { + if (!before) throw new Error("Missing run observation."); + const response = await client.post( + access.token, + `${access.path}/actions/runs/${command.runId}/${command.type === "run.cancel" ? "cancel" : "rerun-failed-jobs"}`, + {}, + "discard", + ); + responseReceived = true; + if (!(response.status >= 200 && response.status < 300)) { + outcome = unconfirmedRunCommand(command); + } else { + acceptedOutcome = { + state: "accepted", + message: + command.type === "run.cancel" + ? "GitHub accepted the cancellation; the run is still stopping." + : "GitHub accepted the rerun; the new attempt is not yet visible.", + ...(command.type === "run.cancel" + ? { cancellation: "requested" as const } + : { rerun: "requested" as const }), + runId: before.id, + runAttempt: before.run_attempt, + htmlUrl: before.html_url, + }; + const abort = new AbortController(); + const windowMs = command.type === "run.cancel" ? 20000 : 5000; + const deadline = setTimeout(() => abort.abort(), windowMs); + try { + let delay = 1000; + while (!abort.signal.aborted) { + try { + const after = await readRun(command.runId, abort.signal); + const observed = + command.type === "run.rerun-failed" + ? after.run_attempt > before.run_attempt + : after.status === "completed" || after.status === "cancelled"; + acceptedOutcome = { + ...acceptedOutcome, + runAttempt: after.run_attempt, + htmlUrl: after.html_url, + }; + if (observed) { + acceptedOutcome = { + ...acceptedOutcome, + message: + command.type === "run.rerun-failed" + ? "A newer GitHub run attempt was observed." + : "The run was observed stopped after the cancellation request.", + ...(command.type === "run.cancel" + ? { cancellation: "observed" as const } + : { rerun: "observed" as const }), + }; + break; + } + } catch { + // A failed observation does not undo GitHub's acceptance of the POST. + } + await sleep(delay, abort.signal); + delay = Math.min(delay * 2, 8000); + } + } finally { + clearTimeout(deadline); + abort.abort(); + } + outcome = acceptedOutcome; + } + } + } catch (error) { + if (acceptedOutcome) outcome = acceptedOutcome; + else if (responseReceived) outcome = unconfirmedRunCommand(command); + else if (error instanceof GitHubUncertainError) outcome = unconfirmedRunCommand(command); + else if (error instanceof GitHubUnavailableError || error instanceof GitHubRateLimitError) + outcome = rejected(error.code, error.message, error.nextStep); + else if (error instanceof GitHubPermissionError) + outcome = rejected(error.code, error.message, error.nextStep); + else if (error instanceof CloudError) + outcome = posted + ? unconfirmedRunCommand(command) + : rejected( + error.code, + error.message, + error.details.nextStep ?? "Check repository access and run state before retrying.", + ); + else if (error instanceof GitHubApiError && error.status >= 400 && error.status < 500) + outcome = rejected( + "github_run_request_rejected", + error.message, + "Inspect the run or workflow and current GitHub App access before retrying.", + ); + else + outcome = posted + ? unconfirmedRunCommand(command) + : rejected( + "run_preflight_failed", + "The run command was not submitted to GitHub.", + "Check repository access and run state before retrying with a new operation key.", + ); + } + if (!claimed) return outcome; + try { + await receipts.finish(outcome); + } catch (error) { + if (posted) return unconfirmedRunCommand(command); + throw error; + } + return outcome; +}; diff --git a/convex/githubHttp.ts b/apps/cloud/src/github/setup.ts similarity index 64% rename from convex/githubHttp.ts rename to apps/cloud/src/github/setup.ts index bb229136..58a9b3f9 100644 --- a/convex/githubHttp.ts +++ b/apps/cloud/src/github/setup.ts @@ -1,10 +1,18 @@ import { Schema } from "effect"; -import { httpAction } from "./_generated/server.js"; -import { internal } from "./_generated/api.js"; -import { appManifest } from "../packages/github/src/manifest.js"; -import { smallJson } from "./httpBody.js"; -import { githubApp, webhookUrl, webUrl } from "./site.js"; - +import type { RawHandler } from "../context.js"; +import type { Env } from "../env.js"; +import type { GitHubAppSetupRow } from "../db.js"; +import { readJson } from "../http.js"; +import { appManifest } from "../../../../packages/github/src/manifest.js"; +import { githubUserAgent } from "../../../../packages/github/src/api-request.js"; +import { appConfig, webhookUrl } from "./app.js"; +async function inspect(env: Env, digest: string) { + return env.DB.prepare( + "SELECT * FROM githubAppSetups WHERE stateDigest = ? AND consumed = 0 AND expiresAt > ?", + ) + .bind(digest, Date.now()) + .first(); +} const App = Schema.Struct({ id: Schema.Int, slug: Schema.NonEmptyString, @@ -29,17 +37,15 @@ async function stateDigest(request: Request) { digest: Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join(""), }; } -export const setup = httpAction(async (ctx, request) => { +export const appSetup: RawHandler = async ({ env }, request) => { const state = await stateDigest(request); - const owner = state - ? await ctx.runQuery(internal.githubAppSetup.inspect, { stateDigest: state.digest }) - : null; + const owner = state ? await inspect(env, state.digest) : null; if (!state || !owner) return new Response("This setup link is invalid or expired.", { status: 403 }); - const site = webUrl(); - const app = githubApp(); + const site = env.VECTIS_WEB_URL; + const app = appConfig(env); const manifest = appManifest(app.name, site, site, { public: app.public }); - manifest.hook_attributes.url = webhookUrl(); + manifest.hook_attributes.url = webhookUrl(env); manifest.redirect_url = `${site}/api/github/manifest/callback`; manifest.callback_urls = [`${site}/api/github/oauth/callback`]; const title = escaped(app.name); @@ -58,23 +64,25 @@ export const setup = httpAction(async (ctx, request) => { }, }, ); -}); -export const manifestCallback = httpAction(async (ctx, request) => { +}; +export const manifestCallback: RawHandler = async ({ env }, request) => { const state = await stateDigest(request); const code = new URL(request.url).searchParams.get("code"); if (!state || !code || !/^[A-Za-z0-9_-]{1,200}$/.test(code)) return new Response("Invalid callback.", { status: 400 }); - const expected = await ctx.runQuery(internal.githubAppSetup.inspect, { - stateDigest: state.digest, - }); + const expected = await inspect(env, state.digest); if (!expected) return new Response("This setup link is invalid or expired.", { status: 403 }); try { const response = await fetch( `https://api.github.com/app-manifests/${encodeURIComponent(code)}/conversions`, { method: "POST", - headers: { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2026-03-10" }, - redirect: "error", + headers: { + Accept: "application/vnd.github+json", + "User-Agent": githubUserAgent, + "X-GitHub-Api-Version": "2026-03-10", + }, + redirect: "manual", signal: AbortSignal.timeout(15000), }, ); @@ -85,21 +93,32 @@ export const manifestCallback = httpAction(async (ctx, request) => { { status: 502 }, ); } - const app = Schema.decodeUnknownSync(App)(await smallJson(response, 65536)); + const app = Schema.decodeUnknownSync(App)(await readJson(response, 65536)); if (app.owner.id !== expected.ownerId) return new Response("This app belongs to a different GitHub owner. It was not connected.", { status: 403, }); - await ctx.runMutation(internal.githubAppSetup.save, { - stateDigest: state.digest, - appId: app.id, - slug: app.slug, - ownerId: app.owner.id, - clientId: app.client_id, - privateKey: app.pem, - clientSecret: app.client_secret, - webhookSecret: app.webhook_secret, - }); + const saved = await env.DB.batch([ + env.DB.prepare( + `INSERT INTO githubApps (singleton, appId, slug, ownerId, clientId, privateKey, clientSecret, webhookSecret) SELECT 1, ?, ?, ?, ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM githubAppSetups WHERE stateDigest = ? AND consumed = 0 AND expiresAt > ? AND ownerId = ?)`, + ).bind( + app.id, + app.slug, + app.owner.id, + app.client_id, + app.pem, + app.client_secret, + app.webhook_secret, + state.digest, + Date.now(), + app.owner.id, + ), + env.DB.prepare( + "UPDATE githubAppSetups SET consumed = 1 WHERE stateDigest = ? AND changes() = 1", + ).bind(state.digest), + ]); + if (!saved[0]?.meta.changes) + return new Response("This setup link is invalid or expired.", { status: 403 }); return new Response("The GitHub App is registered. You can close this tab.", { headers: { "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" }, }); @@ -109,4 +128,4 @@ export const manifestCallback = httpAction(async (ctx, request) => { { status: 502, headers: { "Cache-Control": "no-store" } }, ); } -}); +}; diff --git a/apps/cloud/src/github/webhook.ts b/apps/cloud/src/github/webhook.ts new file mode 100644 index 00000000..6c689a50 --- /dev/null +++ b/apps/cloud/src/github/webhook.ts @@ -0,0 +1,121 @@ +import { Schema } from "effect"; +import type { RawHandler } from "../context.js"; +import { CloudError } from "../errors.js"; +import { readBody } from "../http.js"; +import { loadApp } from "./app.js"; +import { GitHubJob } from "../../../../packages/github/src/job.js"; +import { verifyWebhook } from "../../../../packages/github/src/webhook.js"; +import { GitHubAppClient } from "../../../../packages/github/src/app-client.js"; +import { readRunActors, withRunActors } from "../../../../packages/github/src/run-actors.js"; +import { storeJob, jobChanged } from "./job-store.js"; +import { restrictiveAuthority, accessFor } from "./jobs-private.js"; +import type { Env } from "../env.js"; +import type { GitHubAppRow } from "../db.js"; + +const Payload = Schema.Struct({ + action: Schema.optional(Schema.String), + installation: Schema.optional(Schema.Struct({ id: Schema.Int })), + repository: Schema.optional(Schema.Struct({ id: Schema.Int })), + workflow_job: Schema.optional(GitHubJob), +}); + +async function enrich( + env: Env, + app: GitHubAppRow, + authority: NonNullable>>, + job: GitHubJob, +) { + const client = new GitHubAppClient(app); + const access = await accessFor(client, authority); + const actors = await readRunActors( + (path) => client.request(access.token, access.path + path), + access.repositoryId, + job.run_id, + ); + const result = await storeJob( + env, + withRunActors(job, actors), + access.installationId, + access.repositoryId, + ); + if (result.changed) await jobChanged(env, result.row); +} + +export const githubWebhook: RawHandler = async (c, request) => { + const event = request.headers.get("x-github-event"); + const deliveryId = request.headers.get("x-github-delivery"); + if ( + !event || + !["ping", "workflow_job", "installation", "installation_repositories", "repository"].includes( + event, + ) || + !deliveryId || + !/^[a-zA-Z0-9-]{1,100}$/.test(deliveryId) + ) + return new Response("Invalid delivery", { status: 400 }); + let body; + try { + body = await readBody(request, 1024 * 1024); + } catch { + return new Response("Payload too large", { status: 413 }); + } + let app; + try { + app = await loadApp(c.env); + } catch (error) { + if (error instanceof CloudError && error.code === "github_app_unavailable") + return new Response("App setup is incomplete", { status: 503 }); + throw error; + } + if (!(await verifyWebhook(app.webhookSecret, body, request.headers.get("x-hub-signature-256")))) + return new Response("Invalid signature", { status: 401 }); + if (event === "ping") return Response.json({ accepted: true }); + let payload; + try { + payload = Schema.decodeUnknownSync(Payload)(JSON.parse(new TextDecoder().decode(body))); + } catch { + return new Response("Invalid payload", { status: 400 }); + } + const job = payload.workflow_job ? { ...payload.workflow_job } : undefined; + if (job) { + delete job.actor_id; + delete job.triggering_actor_id; + } + const installationId = payload.installation?.id ?? null; + const repositoryId = payload.repository?.id ?? null; + const delivery = { + deliveryId, + event, + action: payload.action ?? null, + installationId, + repositoryId, + jobId: job?.id ?? null, + receivedAt: Date.now(), + }; + let duplicate; + if (job && event === "workflow_job" && installationId && repositoryId) { + const result = await storeJob(c.env, job, installationId, repositoryId, delivery); + duplicate = result.duplicate; + if (result.changed) await jobChanged(c.env, result.row); + if (!duplicate) { + const authority = await restrictiveAuthority(c.env, installationId, repositoryId); + if (authority) c.ctx.waitUntil(enrich(c.env, app, authority, job)); + } + } else { + const result = + await c.env.DB.prepare(`INSERT INTO githubDeliveries (deliveryId,event,action,installationId,repositoryId,jobId,receivedAt) + VALUES (?,?,?,?,?,?,?) ON CONFLICT DO NOTHING`) + .bind( + deliveryId, + event, + delivery.action, + installationId, + repositoryId, + delivery.jobId, + delivery.receivedAt, + ) + .run(); + duplicate = !result.meta.changes; + } + return Response.json({ accepted: true, duplicate }, { status: 202 }); +}; diff --git a/apps/cloud/src/http.ts b/apps/cloud/src/http.ts new file mode 100644 index 00000000..3422a571 --- /dev/null +++ b/apps/cloud/src/http.ts @@ -0,0 +1,50 @@ +import { Schema } from "effect"; +import { CloudError } from "./errors.js"; + +export const noStore = { "Cache-Control": "no-store" } as const; + +export function json(value: unknown, status = 200, headers: HeadersInit = {}) { + return Response.json(value, { status, headers: { ...noStore, ...headers } }); +} + +export async function readBody(request: Pick, limit: number) { + if (!request.body) throw new CloudError("invalid_request", 400); + const reader = request.body.getReader(); + let length = 0; + const chunks: Uint8Array[] = []; + try { + while (true) { + const item = await reader.read(); + if (item.done) break; + length += item.value.length; + if (length > limit) throw new CloudError("request_too_large", 413); + chunks.push(item.value); + } + } finally { + await reader.cancel(); + } + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + return bytes; +} + +export async function readJson(request: Pick, limit: number): Promise { + try { + return JSON.parse(new TextDecoder().decode(await readBody(request, limit))); + } catch (error) { + if (error instanceof CloudError) throw error; + throw new CloudError("invalid_request", 400); + } +} + +export function decode(schema: Schema.Decoder, value: unknown): T { + try { + return Schema.decodeUnknownSync(schema, { onExcessProperty: "error" })(value); + } catch { + throw new CloudError("invalid_request", 400); + } +} diff --git a/apps/cloud/src/hub.ts b/apps/cloud/src/hub.ts new file mode 100644 index 00000000..577e3e02 --- /dev/null +++ b/apps/cloud/src/hub.ts @@ -0,0 +1,181 @@ +import { DurableObject } from "cloudflare:workers"; +import { Schema } from "effect"; +import { + EnvironmentSlots, + MachineStatus, + socketCloseCodes, +} from "../../../packages/protocol/src/cloud.js"; +import type { Env } from "./env.js"; +import { evaluateDemand } from "./runners/demand.js"; + +export interface HubPresence { + online: boolean; + lastSeenAt: number | null; + runnerIdle?: boolean; + runnerSlots?: number; +} +type Status = MachineStatus & { statusAt: number }; +type Attachment = { connectedAt: number }; +export class MachineHub extends DurableObject { + private running = false; + private rerun = false; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong")); + } + + override async fetch(request: Request): Promise { + const id = request.headers.get("X-Vectis-Machine"); + if (!id || request.headers.get("Upgrade")?.toLowerCase() !== "websocket") + return new Response("Invalid upgrade", { status: 400 }); + await this.ctx.storage.put("machineId", id); + await this.disconnect(socketCloseCodes.replaced); + const pair = new WebSocketPair(); + pair[1].serializeAttachment({ connectedAt: Date.now() } satisfies Attachment); + this.ctx.acceptWebSocket(pair[1]); + return new Response(null, { + status: 101, + webSocket: pair[0], + headers: { "Sec-WebSocket-Protocol": "vectis.v1" }, + }); + } + + private seen(socket: WebSocket): number { + const attachment: Attachment = socket.deserializeAttachment(); + return Math.max( + attachment.connectedAt, + this.ctx.getWebSocketAutoResponseTimestamp(socket)?.getTime() ?? 0, + ); + } + + async notify(): Promise { + for (const socket of this.ctx.getWebSockets()) + if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ t: "changed" })); + } + + async demand(): Promise { + if (this.running) { + this.rerun = true; + return; + } + this.running = true; + try { + do { + this.rerun = false; + const id = await this.ctx.storage.get("machineId"); + const status = await this.ctx.storage.get("status"); + if (id && status && (await this.presence()).online) + await evaluateDemand(this.env, id, status); + } while (this.rerun); + } finally { + this.running = false; + } + } + + async presence(): Promise { + let lastSeenAt = (await this.ctx.storage.get("lastSeenAt")) ?? null; + let online = false; + for (const socket of this.ctx.getWebSockets()) { + const seen = this.seen(socket); + lastSeenAt = Math.max(lastSeenAt ?? 0, seen); + if (socket.readyState !== WebSocket.OPEN) continue; + if (Date.now() - seen < 90000) online = true; + else { + socket.close(socketCloseCodes.stale); + await this.ctx.storage.put("lastSeenAt", lastSeenAt); + } + } + const status = await this.ctx.storage.get("status"); + return { + online, + lastSeenAt, + ...(status + ? { + runnerIdle: status.runnerIdle, + ...(status.runnerSlots === undefined ? {} : { runnerSlots: status.runnerSlots }), + } + : {}), + }; + } + + async disconnect(code: number): Promise { + let lastSeenAt = (await this.ctx.storage.get("lastSeenAt")) ?? null; + for (const socket of this.ctx.getWebSockets()) { + lastSeenAt = Math.max(lastSeenAt ?? 0, this.seen(socket)); + socket.close(code); + } + if (lastSeenAt !== null) await this.ctx.storage.put("lastSeenAt", lastSeenAt); + } + + async webSocketClose(socket: WebSocket): Promise { + const seen = this.seen(socket); + const previous = (await this.ctx.storage.get("lastSeenAt")) ?? 0; + await this.ctx.storage.put("lastSeenAt", Math.max(seen, previous)); + socket.close(); + } + + async webSocketError(socket: WebSocket): Promise { + await this.webSocketClose(socket); + } + + async webSocketMessage(socket: WebSocket, message: string | ArrayBuffer): Promise { + if (socket.readyState !== WebSocket.OPEN) return; + const statusAt = Date.now(); + let raw: unknown; + let status: MachineStatus; + try { + raw = JSON.parse(typeof message === "string" ? message : new TextDecoder().decode(message)); + if (raw && typeof raw === "object") { + if ( + ("runnerSlots" in raw && + (typeof raw.runnerSlots !== "number" || + !Number.isInteger(raw.runnerSlots) || + raw.runnerSlots < 0 || + raw.runnerSlots > 16)) || + ("environmentSlots" in raw && !Schema.is(EnvironmentSlots)(raw.environmentSlots)) + ) + throw new Error("invalid_runner_capacity"); + } + status = Schema.decodeUnknownSync(MachineStatus, { onExcessProperty: "error" })(raw); + if ( + new Set(status.environments.map((item) => item.id)).size !== status.environments.length || + status.environments.some((item) => !item.name.trim()) + ) + throw new Error("invalid_environment_inventory"); + if ( + status.environmentSlots && + new Set(status.environmentSlots.map((item) => item.environmentId)).size !== + status.environmentSlots.length + ) + throw new Error("invalid_runner_capacity"); + } catch (error) { + const seq = + raw && typeof raw === "object" && "seq" in raw && Number.isInteger(raw.seq) ? raw.seq : 0; + socket.send( + JSON.stringify({ + t: "status.rejected", + seq, + code: + error instanceof Error && error.message.startsWith("invalid_") + ? error.message + : "invalid_status", + }), + ); + return; + } + const id = await this.ctx.storage.get("machineId"); + if (!id) return; + const environmentsJson = JSON.stringify( + [...status.environments].sort((a, b) => a.id.localeCompare(b.id)), + ); + await this.env.DB.prepare( + "UPDATE machines SET paused = ?, environmentsJson = ? WHERE id = ? AND (paused IS NOT ? OR environmentsJson IS NOT ?)", + ) + .bind(status.paused ? 1 : 0, environmentsJson, id, status.paused ? 1 : 0, environmentsJson) + .run(); + await this.ctx.storage.put("status", { ...status, statusAt } satisfies Status); + socket.send(JSON.stringify({ t: "status.ack", seq: status.seq })); + await this.demand(); + } +} diff --git a/apps/cloud/src/ids.ts b/apps/cloud/src/ids.ts new file mode 100644 index 00000000..a7a172a0 --- /dev/null +++ b/apps/cloud/src/ids.ts @@ -0,0 +1,33 @@ +const alphabet = "abcdefghijklmnopqrstuvwxyz234567"; + +// 128 random bits as 26 lowercase base32 characters. Valid as a protocol Identifier, a controller +// bearer id and a runner name suffix, and never shaped like a local operation UUID. +export function newId() { + const bytes = crypto.getRandomValues(new Uint8Array(16)); + let bits = 0; + let value = 0; + let id = ""; + for (const byte of bytes) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5) { + id += alphabet[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + return id + alphabet[(value << (5 - bits)) & 31]; +} + +export async function sha256Hex(value: string) { + const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); + return Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +// Constant-time comparison of two equal-length hex digests. +export function sameDigest(left: string, right: string) { + if (left.length !== right.length) return false; + let difference = 0; + for (let index = 0; index < left.length; index++) + difference |= left.charCodeAt(index) ^ right.charCodeAt(index); + return difference === 0; +} diff --git a/apps/cloud/src/index.ts b/apps/cloud/src/index.ts new file mode 100644 index 00000000..36f065a2 --- /dev/null +++ b/apps/cloud/src/index.ts @@ -0,0 +1,191 @@ +import { + HumanApi, + MachineApi, + type HumanPath, + type MachinePath, +} from "../../../packages/protocol/src/cloud.js"; +import type { Env } from "./env.js"; +import { CloudError } from "./errors.js"; +import { decode, json, readJson } from "./http.js"; +import type { Context, HumanHandler, MachineHandler, RawHandler } from "./context.js"; +import { authenticateMachine, bearerCredential, socketCredential } from "./auth/machine.js"; +import { authenticateHuman } from "./auth/human.js"; +import { hubFor } from "./notify.js"; +import { runCron } from "./cron.js"; +import { sync } from "./sync.js"; +import { acknowledge } from "./operations.js"; +import { respond } from "./inspections.js"; +import { removeMachine } from "./machines.js"; +import { approvePairing, machinePairing } from "./pairings.js"; +import { + approveController, + controllerPairing, + controllerRequest, + revokeController, +} from "./controllers.js"; +import { clerkWebhook, overview, removeAccount } from "./account.js"; +import { + discardLink, + machineGitHubAccounts, + oauthBegin, + oauthCallback, + oauthConfirm, + unlinkAccount, +} from "./github/oauth.js"; +import { appSetup, manifestCallback } from "./github/setup.js"; +import { + connectRepository, + disableRepository, + disconnectRepository, + enableRepository, + repositoryReadiness, + setAccess, + setAutomatic, + setCacheVolume, + setCapacityMode, + setInfraRerun, +} from "./github/repositories.js"; +import { githubWebhook } from "./github/webhook.js"; +import { jobLogs, listJobs, refreshJob, scanJobs } from "./github/jobs.js"; +import { controlRun } from "./github/runs.js"; +import { analyzeMigration, publishMigration } from "./github/migrations.js"; +import { prepareRunner, reconcileRunner, releaseRunner, runnerStatus } from "./runners/leases.js"; +import { closeScaleSet, openScaleSet, refreshScaleSet } from "./runners/scale-sets.js"; + +export { MachineHub } from "./hub.js"; + +const machineRoutes: { [P in MachinePath]: MachineHandler

} = { + "/machine/sync": sync, + "/machine/operations/acknowledge": acknowledge, + "/machine/inspections/respond": respond, + "/machine/github/accounts": machineGitHubAccounts, + "/machine/repositories/connect": connectRepository, + "/machine/repositories/disconnect": disconnectRepository, + "/machine/repositories/readiness": repositoryReadiness, + "/machine/repositories/set-automatic": setAutomatic, + "/machine/repositories/set-infra-rerun": setInfraRerun, + "/machine/repositories/set-capacity-mode": setCapacityMode, + "/machine/repositories/set-cache-volume": setCacheVolume, + "/machine/repositories/set-access": setAccess, + "/machine/jobs/list": listJobs, + "/machine/jobs/scan": scanJobs, + "/machine/jobs/refresh": refreshJob, + "/machine/jobs/logs": jobLogs, + "/machine/runners/reconcile": reconcileRunner, + "/machine/runners/prepare": prepareRunner, + "/machine/runners/status": runnerStatus, + "/machine/runners/release": releaseRunner, + "/machine/scale-sets/open": openScaleSet, + "/machine/scale-sets/refresh": refreshScaleSet, + "/machine/scale-sets/close": closeScaleSet, + "/machine/runs/control": controlRun, + "/machine/migrations/analyze": analyzeMigration, + "/machine/migrations/publish": publishMigration, +}; + +const humanRoutes: { [P in HumanPath]: HumanHandler

} = { + "/account/remove": removeAccount, + "/pairings/approve": approvePairing, + "/controllers/approve": approveController, + "/controllers/revoke": revokeController, + "/machines/remove": removeMachine, + "/github/accounts/unlink": unlinkAccount, + "/github/links/discard": discardLink, + "/repositories/enable": enableRepository, + "/repositories/disable": disableRepository, +}; + +const rawRoutes: Record = { + "POST /machine/pairing": machinePairing, + "POST /controller/pairing": controllerPairing, + "POST /controller/request": controllerRequest, + "POST /github/webhook": githubWebhook, + "POST /clerk/webhook": clerkWebhook, + "GET /github/app/setup": appSetup, + "GET /github/manifest/callback": manifestCallback, + "GET /github/oauth/callback": oauthCallback, + "POST /github/oauth/begin": oauthBegin, + "POST /github/oauth/confirm": oauthConfirm, +}; + +const isMachinePath = (path: string): path is MachinePath => Object.hasOwn(MachineApi, path); +const isHumanPath = (path: string): path is HumanPath => Object.hasOwn(HumanApi, path); + +// Machine requests are small except job logs, migration reports and inspection responses. +const machineBodyLimit = 512 * 1024; +const humanBodyLimit = 16 * 1024; + +function corsHeaders(env: Env, request: Request): Record { + return request.headers.get("Origin") === env.VECTIS_WEB_URL + ? { + "Access-Control-Allow-Origin": env.VECTIS_WEB_URL, + "Access-Control-Allow-Headers": "Authorization, Content-Type", + "Access-Control-Allow-Methods": "GET, POST", + "Access-Control-Max-Age": "600", + Vary: "Origin", + } + : {}; +} + +function errorResponse(error: unknown) { + if (error instanceof CloudError) return json(error.body(), error.status); + console.error(error); + return json({ code: "cloud_unavailable" }, 500); +} + +async function machineSocket(c: Context, request: Request) { + if (request.headers.get("Upgrade") !== "websocket") + throw new CloudError("websocket_required", 426); + const machine = await authenticateMachine(c.env, socketCredential(request)); + const headers = new Headers(request.headers); + headers.set("X-Vectis-Machine", machine.id); + return hubFor(c.env, machine.id).fetch(new Request(request, { headers })); +} + +async function route(request: Request, env: Env, ctx: ExecutionContext): Promise { + const url = new URL(request.url); + const path = url.pathname; + const c: Context = { env, ctx }; + if (request.method === "GET" && path === "/machine/socket") return machineSocket(c, request); + if (request.method === "POST" && isMachinePath(path)) { + const machine = await authenticateMachine(env, bearerCredential(request)); + const input = decode(MachineApi[path].request, await readJson(request, machineBodyLimit)); + const handler = machineRoutes[path] as MachineHandler; + return json(await handler({ env, ctx, machine }, input as never)); + } + if (path === "/account/overview" || isHumanPath(path)) { + const cors = corsHeaders(env, request); + if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: cors }); + // Browsers send Origin on every cross-origin request; refuse any other origin outright. + if (request.headers.get("Origin") !== null && !cors["Access-Control-Allow-Origin"]) + throw new CloudError("origin_refused", 403); + const owner = await authenticateHuman(env, request); + if (request.method === "GET" && path === "/account/overview") + return json(await overview({ env, ctx, owner }), 200, cors); + if (request.method === "POST" && isHumanPath(path)) { + const input = decode(HumanApi[path].request, await readJson(request, humanBodyLimit)); + const handler = humanRoutes[path] as HumanHandler; + return json(await handler({ env, ctx, owner }, input as never), 200, cors); + } + } + const raw = rawRoutes[`${request.method} ${path}`]; + if (raw) return raw(c, request); + return json({ code: "not_found" }, 404); +} + +export default { + async fetch(request, env, ctx) { + try { + return await route(request, env, ctx); + } catch (error) { + // Error bodies carry CORS headers too, so the browser exposes them to /connect. + const response = errorResponse(error); + for (const [name, value] of Object.entries(corsHeaders(env, request))) + response.headers.set(name, value); + return response; + } + }, + async scheduled(controller, env, ctx) { + ctx.waitUntil(runCron(env, controller.cron, controller.scheduledTime)); + }, +} satisfies ExportedHandler; diff --git a/apps/cloud/src/inspections.ts b/apps/cloud/src/inspections.ts new file mode 100644 index 00000000..eb255971 --- /dev/null +++ b/apps/cloud/src/inspections.ts @@ -0,0 +1,71 @@ +import type { Env } from "./env.js"; +import type { LocalQuery } from "../../../packages/protocol/src/controller.js"; +import type { ControllerResponses } from "../../../packages/protocol/src/cloud.js"; +import type { MachineHandler } from "./context.js"; +import type { InspectionRow } from "./db.js"; +import { CloudError } from "./errors.js"; +import { newId } from "./ids.js"; +import { machinePresence, notifyMachine } from "./notify.js"; + +export const respond: MachineHandler<"/machine/inspections/respond"> = async ( + { env, machine }, + input, +) => { + if (new TextEncoder().encode(input.responseJson).byteLength > 262144) + throw new CloudError("query_result_too_large", 409); + const now = Date.now(); + await env.DB.prepare( + "UPDATE inspections SET pending = 0, responseJson = ? WHERE id = ? AND machineId = ? AND expiresAt > ? AND pending = 1", + ) + .bind(input.responseJson, input.id, machine.id, now) + .run(); + const row = await env.DB.prepare("SELECT * FROM inspections WHERE id = ?") + .bind(input.id) + .first(); + if (!row || row.machineId !== machine.id || row.expiresAt <= now) + throw new CloudError("query_expired", 409); + if (row.responseJson !== input.responseJson) throw new CloudError("query_response_conflict", 409); + return null; +}; + +export async function submitInspection( + env: Env, + owner: string, + machineId: string, + key: string, + query: LocalQuery, +): Promise { + const machine = await env.DB.prepare("SELECT owner FROM machines WHERE id = ?") + .bind(machineId) + .first<{ owner: string }>(); + if (machine?.owner !== owner) throw new CloudError("machine_unavailable", 409); + if (!(await machinePresence(env, machineId)).online) throw new CloudError("machine_offline", 409); + const id = newId(), + now = Date.now(), + queryJson = JSON.stringify(query); + await env.DB.prepare(`INSERT INTO inspections (id, owner, machineId, key, queryJson, expiresAt, pending) + SELECT ?, ?, ?, ?, ?, ?, 1 WHERE (SELECT COUNT(*) FROM inspections WHERE machineId = ? AND pending = 1 AND expiresAt > ?) < 10 + ON CONFLICT(owner, key) DO NOTHING`) + .bind(id, owner, machineId, key, queryJson, now + 60000, machineId, now) + .run(); + const row = await env.DB.prepare("SELECT * FROM inspections WHERE owner = ? AND key = ?") + .bind(owner, key) + .first(); + if (!row) throw new CloudError("query_queue_full", 409); + if (row.machineId !== machineId || row.queryJson !== queryJson) + throw new CloudError("idempotency_conflict", 409); + if (row.id === id) await notifyMachine(env, machineId); + return { queryId: row.id }; +} + +export async function getInspection( + env: Env, + owner: string, + id: string, +): Promise { + const row = await env.DB.prepare("SELECT * FROM inspections WHERE id = ? AND owner = ?") + .bind(id, owner) + .first(); + if (!row || row.expiresAt <= Date.now()) throw new CloudError("query_expired", 409); + return { pending: row.pending === 1, responseJson: row.responseJson }; +} diff --git a/apps/cloud/src/machines.ts b/apps/cloud/src/machines.ts new file mode 100644 index 00000000..11cab955 --- /dev/null +++ b/apps/cloud/src/machines.ts @@ -0,0 +1,54 @@ +import type { Env } from "./env.js"; +import { socketCloseCodes, type AccountOverview } from "../../../packages/protocol/src/cloud.js"; +import type { HumanHandler } from "./context.js"; +import { machineRecord, type MachineRow } from "./db.js"; +import { CloudError } from "./errors.js"; +import { disconnectMachine, machinePresence } from "./notify.js"; +import { markScaleSetsForCleanup } from "./runners/scale-sets.js"; + +export type AccountMachine = AccountOverview["machines"][number]; + +export async function listMachines(env: Env, owner: string): Promise { + const rows = await env.DB.prepare("SELECT * FROM machines WHERE owner = ? LIMIT 100") + .bind(owner) + .all(); + return Promise.all( + rows.results.map(async (row) => { + const machine = machineRecord(row); + const presence = await machinePresence(env, row.id).catch(() => ({ + online: false, + lastSeenAt: null, + })); + return { + id: row.id, + localId: row.localId, + name: row.name, + createdAt: row.createdAt, + environments: machine.environments, + paused: machine.paused ?? false, + ...presence, + }; + }), + ); +} + +export async function purgeMachine(env: Env, machineId: string): Promise { + const bindings = await env.DB.prepare("SELECT id FROM repositoryBindings WHERE machineId = ?") + .bind(machineId) + .all<{ id: string }>(); + await markScaleSetsForCleanup( + env, + bindings.results.map((row) => row.id), + ); + await disconnectMachine(env, machineId, socketCloseCodes.removed); + await env.DB.prepare("DELETE FROM machines WHERE id = ?").bind(machineId).run(); +} + +export const removeMachine: HumanHandler<"/machines/remove"> = async ({ env, owner }, { id }) => { + const machine = await env.DB.prepare("SELECT owner FROM machines WHERE id = ?") + .bind(id) + .first<{ owner: string }>(); + if (machine?.owner !== owner) throw new CloudError("machine_missing", 404); + await purgeMachine(env, id); + return null; +}; diff --git a/apps/cloud/src/notify.ts b/apps/cloud/src/notify.ts new file mode 100644 index 00000000..d666bd2d --- /dev/null +++ b/apps/cloud/src/notify.ts @@ -0,0 +1,32 @@ +import type { Env } from "./env.js"; +import type { HubPresence } from "./hub.js"; + +// The one place other modules reach a machine's hub. Hubs are named by machine id. +export const hubFor = (env: Env, machineId: string) => env.HUB.getByName(machineId); + +// Tells the machine to sync. Best effort: an offline machine syncs when it reconnects. +export async function notifyMachine(env: Env, machineId: string) { + try { + await hubFor(env, machineId).notify(); + } catch (error) { + console.error("notify failed", machineId, error); + } +} + +// Asks the machine's hub to evaluate runner demand. Hubs serialize evaluations per machine. +export async function requestDemand(env: Env, machineId: string) { + try { + await hubFor(env, machineId).demand(); + } catch (error) { + console.error("demand failed", machineId, error); + } +} + +export function machinePresence(env: Env, machineId: string): Promise { + return hubFor(env, machineId).presence(); +} + +// Closes the machine's sockets, for example with socketCloseCodes.removed after deletion. +export function disconnectMachine(env: Env, machineId: string, code: number) { + return hubFor(env, machineId).disconnect(code); +} diff --git a/apps/cloud/src/operations.ts b/apps/cloud/src/operations.ts new file mode 100644 index 00000000..d0b68630 --- /dev/null +++ b/apps/cloud/src/operations.ts @@ -0,0 +1,149 @@ +import type { Env } from "./env.js"; +import type { ControllerResponses } from "../../../packages/protocol/src/cloud.js"; +import { decodeCommand } from "../../../packages/protocol/src/index.js"; +import type { MachineHandler, Sweep } from "./context.js"; +import type { OperationRow } from "./db.js"; +import { CloudError } from "./errors.js"; +import { newId } from "./ids.js"; +import { machinePresence, notifyMachine, requestDemand } from "./notify.js"; + +const terminal = (phase: string) => + ["succeeded", "failed", "cancelled", "action_required"].includes(phase); +const allowed: Record = { + accepted: ["claimed"], + claimed: ["running", "failed", "action_required", "cancelled"], + running: ["succeeded", "failed", "action_required", "cancelled"], + succeeded: [], + failed: [], + action_required: [], + cancelled: [], +}; +export const acknowledge: MachineHandler<"/machine/operations/acknowledge"> = async ( + { env, machine }, + input, +) => { + for (;;) { + const row = await env.DB.prepare("SELECT * FROM operations WHERE id = ?") + .bind(input.id) + .first(); + if (!row || row.machineId !== machine.id) throw new CloudError("operation_missing", 404); + if (input.resultJson && input.resultJson.length > 16384) + throw new CloudError("result_too_large", 409); + if (row.phase === input.phase) { + if ((row.resultJson ?? undefined) !== input.resultJson) + throw new CloudError("acknowledgement_conflict", 409); + return null; + } + if (row.phase === "cancelled" && input.phase === "claimed") + throw new CloudError("operation_cancelled", 409); + if (!allowed[row.phase].includes(input.phase)) throw new CloudError("invalid_transition", 409); + const result = await env.DB.prepare( + "UPDATE operations SET phase = ?, updatedAt = ?, resultJson = ? WHERE id = ? AND phase = ?", + ) + .bind(input.phase, Date.now(), input.resultJson ?? row.resultJson, row.id, row.phase) + .run(); + if (!result.meta.changes) continue; + if (terminal(input.phase) && decodeCommand(JSON.parse(row.commandJson)).type === "runner.run") + await requestDemand(env, machine.id); + return null; + } +}; + +export async function submitOperation( + env: Env, + owner: string, + input: { machineId: string; key: string; commandJson: string }, +): Promise { + const machine = await env.DB.prepare("SELECT owner FROM machines WHERE id = ?") + .bind(input.machineId) + .first<{ owner: string }>(); + if (machine?.owner !== owner) throw new CloudError("machine_unavailable", 409); + if (!input.key || input.key.length > 200 || input.commandJson.length > 65536) + throw new CloudError("invalid_request", 409); + let commandJson: string; + try { + commandJson = JSON.stringify(decodeCommand(JSON.parse(input.commandJson))); + } catch { + throw new CloudError("invalid_command", 409); + } + const id = newId(), + now = Date.now(); + await env.DB.prepare(`INSERT INTO operations (id, owner, machineId, key, commandJson, phase, createdAt, updatedAt) + SELECT ?, ?, ?, ?, ?, 'accepted', ?, ? WHERE (SELECT COUNT(*) FROM operations WHERE machineId = ? AND phase IN ('accepted', 'claimed', 'running')) < 100 + ON CONFLICT(owner, key) DO NOTHING`) + .bind(id, owner, input.machineId, input.key, commandJson, now, now, input.machineId) + .run(); + const row = await env.DB.prepare("SELECT * FROM operations WHERE owner = ? AND key = ?") + .bind(owner, input.key) + .first(); + if (!row) throw new CloudError("queue_full", 409); + if (row.machineId !== input.machineId || row.commandJson !== commandJson) + throw new CloudError("idempotency_conflict", 409); + if (row.id === id) await notifyMachine(env, input.machineId); + return row.id; +} + +export async function cancelOperation(env: Env, owner: string, id: string): Promise { + const row = await env.DB.prepare("SELECT * FROM operations WHERE id = ? AND owner = ?") + .bind(id, owner) + .first(); + if (!row) throw new CloudError("operation_missing", 404); + if (!terminal(row.phase)) { + await env.DB.prepare( + `UPDATE operations SET cancelRequested = 1, updatedAt = ?, phase = CASE WHEN phase = 'accepted' THEN 'cancelled' ELSE phase END WHERE id = ? AND phase IN ('accepted', 'claimed', 'running')`, + ) + .bind(Date.now(), id) + .run(); + await notifyMachine(env, row.machineId); + } + return id; +} + +export async function getOperation( + env: Env, + owner: string, + id: string, +): Promise { + const row = await env.DB.prepare("SELECT * FROM operations WHERE id = ? AND owner = ?") + .bind(id, owner) + .first(); + if (!row) throw new CloudError("operation_missing", 404); + return { + id: row.id, + machineId: row.machineId, + key: row.key, + commandJson: row.commandJson, + phase: row.phase, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + ...(row.resultJson === null ? {} : { resultJson: row.resultJson }), + }; +} + +export const sweepOperations: Sweep = async (env, now) => { + const rows = await env.DB.prepare( + "SELECT * FROM operations INDEXED BY operations_accepted WHERE phase = 'accepted' AND createdAt <= ? AND json_extract(commandJson, '$.type') = 'runner.run' ORDER BY createdAt LIMIT 100", + ) + .bind(now - 120000) + .all(); + for (const row of rows.results) { + if (decodeCommand(JSON.parse(row.commandJson)).type !== "runner.run") continue; + const online = (await machinePresence(env, row.machineId).catch(() => ({ online: false }))) + .online; + if (online && row.createdAt + 600000 > now) continue; + const resultJson = JSON.stringify({ + status: "cancelled", + code: online ? "claim_timeout" : "machine_offline", + message: online + ? "The machine did not claim this runner request within ten minutes." + : "The machine went offline before it claimed this runner request.", + resultAvailableLocally: false, + }); + const result = await env.DB.prepare( + "UPDATE operations SET phase = 'cancelled', updatedAt = ?, resultJson = ? WHERE id = ? AND phase = 'accepted'", + ) + .bind(now, resultJson, row.id) + .run(); + if (result.meta.changes) await notifyMachine(env, row.machineId); + } +}; diff --git a/apps/cloud/src/pairings.ts b/apps/cloud/src/pairings.ts new file mode 100644 index 00000000..972f0677 --- /dev/null +++ b/apps/cloud/src/pairings.ts @@ -0,0 +1,100 @@ +import { PairingSecret, socketCloseCodes } from "../../../packages/protocol/src/cloud.js"; +import type { HumanHandler, RawHandler } from "./context.js"; +import { CloudError } from "./errors.js"; +import { decode, json, readJson } from "./http.js"; +import { newId, sha256Hex } from "./ids.js"; +import { disconnectMachine } from "./notify.js"; +import type { MachineRow, PairingRow } from "./db.js"; + +export const approvePairing: HumanHandler<"/pairings/approve"> = async ({ env, owner }, input) => { + const now = Date.now(); + if ( + !input.name.trim() || + !Number.isFinite(input.expiresAt) || + input.expiresAt <= now || + input.expiresAt > now + 600000 + ) + throw new CloudError("invalid_pairing"); + const previous = await env.DB.prepare("SELECT * FROM pairings WHERE requestDigest = ?") + .bind(input.requestDigest) + .first(); + if (previous) { + const machine = await env.DB.prepare( + "SELECT m.*, c.digest FROM machines m JOIN machineCredentials c ON c.machineId = m.id WHERE m.id = ?", + ) + .bind(previous.machineId) + .first(); + if ( + previous.owner !== owner || + previous.expiresAt !== input.expiresAt || + machine?.localId !== input.localId || + machine.name !== input.name || + machine.digest !== input.credentialDigest + ) + throw new CloudError("pairing_conflict"); + return { machineId: previous.machineId }; + } + const id = newId(); + const results = await env.DB.batch([ + env.DB.prepare("DELETE FROM pairings WHERE owner = ? AND expiresAt <= ?").bind(owner, now), + env.DB.prepare( + `INSERT INTO machines (id, owner, localId, name, createdAt) SELECT ?, ?, ?, ?, ? WHERE (SELECT COUNT(*) FROM machines WHERE owner = ?) < 100 AND (SELECT COUNT(*) FROM pairings WHERE owner = ?) < 10 AND NOT EXISTS (SELECT 1 FROM pairings WHERE requestDigest = ?) ON CONFLICT(owner, localId) DO NOTHING`, + ).bind(id, owner, input.localId, input.name, now, owner, owner, input.requestDigest), + env.DB.prepare( + `INSERT INTO pairings (requestDigest, owner, machineId, expiresAt) SELECT ?, ?, m.id, ? FROM machines m WHERE m.owner = ? AND m.localId = ? AND (SELECT COUNT(*) FROM pairings WHERE owner = ? AND expiresAt > ?) < 10 ON CONFLICT DO NOTHING`, + ).bind(input.requestDigest, owner, input.expiresAt, owner, input.localId, owner, now), + env.DB.prepare( + `INSERT INTO machineCredentials (machineId, digest, createdAt) SELECT machineId, ?, ? FROM pairings WHERE requestDigest = ? AND changes() = 1 ON CONFLICT(machineId) DO UPDATE SET digest = excluded.digest, createdAt = excluded.createdAt`, + ).bind(input.credentialDigest, now, input.requestDigest), + env.DB.prepare( + "UPDATE machines SET name = ? WHERE changes() = 1 AND id = (SELECT machineId FROM pairings WHERE requestDigest = ?)", + ).bind(input.name, input.requestDigest), + env.DB.prepare( + "DELETE FROM pairings WHERE changes() = 1 AND machineId = (SELECT machineId FROM pairings WHERE requestDigest = ?) AND requestDigest != ?", + ).bind(input.requestDigest, input.requestDigest), + ]); + if (!results[2]?.meta.changes) { + const raced = await env.DB.prepare("SELECT * FROM pairings WHERE requestDigest = ?") + .bind(input.requestDigest) + .first(); + if (raced) { + const current = await env.DB.prepare( + "SELECT m.*, c.digest FROM machines m JOIN machineCredentials c ON c.machineId = m.id WHERE m.id = ?", + ) + .bind(raced.machineId) + .first(); + if ( + raced.owner === owner && + raced.expiresAt === input.expiresAt && + current?.localId === input.localId && + current.name === input.name && + current.digest === input.credentialDigest + ) + return { machineId: raced.machineId }; + throw new CloudError("pairing_conflict"); + } + const count = await env.DB.prepare( + "SELECT COUNT(*) AS n FROM pairings WHERE owner = ? AND expiresAt > ?", + ) + .bind(owner, now) + .first("n"); + throw new CloudError(count !== null && count >= 10 ? "pairing_rate_limit" : "machine_limit"); + } + const machine = await env.DB.prepare( + "SELECT machineId AS id FROM pairings WHERE requestDigest = ?", + ) + .bind(input.requestDigest) + .first<{ id: string }>(); + if (!machine) throw new CloudError("pairing_conflict"); + await disconnectMachine(env, machine.id, socketCloseCodes.replaced); + return { machineId: machine.id }; +}; +export const machinePairing: RawHandler = async ({ env }, request) => { + const { secret } = decode(PairingSecret, await readJson(request, 4096)); + const row = await env.DB.prepare( + "SELECT p.machineId, m.localId FROM pairings p JOIN machines m ON m.id = p.machineId WHERE p.requestDigest = ? AND p.expiresAt > ?", + ) + .bind(await sha256Hex(secret), Date.now()) + .first<{ machineId: string; localId: string }>(); + return json(row ?? { state: "pending" }); +}; diff --git a/apps/cloud/src/retention.ts b/apps/cloud/src/retention.ts new file mode 100644 index 00000000..58e2e01e --- /dev/null +++ b/apps/cloud/src/retention.ts @@ -0,0 +1,31 @@ +import type { Sweep } from "./context.js"; +const day = 86400000; +const batchSize = 500; +export const sweepRetention: Sweep = async (env, now) => { + const rules: [string, string, number, string][] = [ + ["githubRedeliveryRequests", "requestedAt", 3 * day, ""], + ["githubDeliveries", "receivedAt", 30 * day, ""], + ["githubJobs", "updatedAt", 90 * day, ""], + ["operations", "updatedAt", 90 * day, " AND phase IN ('succeeded','failed','cancelled')"], + ["runnerLeases", "updatedAt", 30 * day, " AND phase = 'released'"], + ["runCommandReceipts", "updatedAt", 30 * day, ""], + ["controllers", "expiresAt", 30 * day, ""], + ["pairings", "expiresAt", 0, ""], + ["githubLinks", "expiresAt", 0, ""], + ["githubAppSetups", "expiresAt", 0, ""], + ["inspections", "expiresAt", 0, ""], + ["migrationPreviews", "expiresAt", 0, ""], + ]; + // Full batches repeat within the run, bounded so one sweep stays small; any rest waits a day. + let pending = rules; + for (let round = 0; round < 20 && pending.length; round++) { + const results = await env.DB.batch( + pending.map(([table, column, window, filter]) => + env.DB.prepare( + `DELETE FROM ${table} WHERE rowid IN (SELECT rowid FROM ${table} WHERE ${column} < ?${filter} ORDER BY ${column} LIMIT ${batchSize})`, + ).bind(now - window), + ), + ); + pending = pending.filter((_, index) => results[index]?.meta.changes === batchSize); + } +}; diff --git a/apps/cloud/src/runners/demand.ts b/apps/cloud/src/runners/demand.ts new file mode 100644 index 00000000..6c3835fb --- /dev/null +++ b/apps/cloud/src/runners/demand.ts @@ -0,0 +1,189 @@ +import type { Env } from "../env.js"; +import type { Sweep } from "../context.js"; +import { + bindingRecord, + machineRecord, + type BindingRow, + type DemandRow, + type JobRow, + type MachineRow, + type OperationRow, +} from "../db.js"; +import { newId } from "../ids.js"; +import { notifyMachine, requestDemand } from "../notify.js"; +import { planDemand, type DemandCapacity, type DemandQueue } from "./plan-demand.js"; + +export async function demandForRepository( + env: Env, + installationId: number, + repositoryId: number, +): Promise { + const rows = await env.DB.prepare( + "SELECT DISTINCT machineId FROM repositoryBindings WHERE installationId = ? AND repositoryId = ? AND enabled = 1 AND automatic = 1 AND capacityMode = 'demand' LIMIT 100", + ) + .bind(installationId, repositoryId) + .all<{ machineId: string }>(); + await Promise.all(rows.results.map((row) => requestDemand(env, row.machineId))); +} + +export const sweepDemand: Sweep = async (env) => { + const rows = + await env.DB.prepare(`SELECT DISTINCT b.machineId FROM githubJobs j INDEXED BY githubJobs_queued CROSS JOIN repositoryBindings b + ON b.installationId = j.installationId AND b.repositoryId = j.repositoryId + WHERE j.status = 'queued' AND b.enabled = 1 AND b.automatic = 1 AND b.capacityMode = 'demand' LIMIT 100`).all<{ + machineId: string; + }>(); + await Promise.all(rows.results.map((row) => requestDemand(env, row.machineId))); +}; + +export async function evaluateDemand( + env: Env, + machineId: string, + capacity: DemandCapacity, +): Promise { + if (capacity.runnerSlots === undefined && capacity.environmentSlots === undefined) return; + const machineRow = await env.DB.prepare("SELECT * FROM machines WHERE id = ?") + .bind(machineId) + .first(); + if (!machineRow || machineRow.paused !== 0) return; + const machine = machineRecord(machineRow); + const rows = + await env.DB.prepare(`SELECT b.* FROM repositoryBindings b JOIN githubAccounts a ON a.id = b.accountId AND a.owner = b.owner + WHERE b.machineId = ? AND b.owner = ? AND b.enabled = 1 AND b.automatic = 1 AND b.capacityMode = 'demand' LIMIT 100`) + .bind(machineId, machine.owner) + .all(); + const bindings = rows.results + .map(bindingRecord) + .filter((binding) => + machine.environments.some( + (item) => item.id === binding.environmentId && item.state === "ready", + ), + ); + if (!bindings.length) return; + const budget = Math.max(50, Math.floor(2000 / bindings.length)); + const reads = await env.DB.batch([ + ...bindings.map((binding) => + env.DB.prepare( + "SELECT * FROM githubJobs WHERE installationId = ? AND repositoryId = ? AND status = 'queued' ORDER BY updatedAt LIMIT ?", + ).bind(binding.installationId, binding.repositoryId, budget), + ), + ...["accepted", "claimed", "running"].map((phase) => + env.DB.prepare( + `SELECT o.*, b.environmentId AS reservedEnvironmentId FROM operations o + LEFT JOIN repositoryBindings b ON b.id = json_extract(o.commandJson, '$.bindingId') AND b.owner = o.owner AND b.machineId = o.machineId + WHERE o.machineId = ? AND o.phase = ? ORDER BY o.createdAt LIMIT 100`, + ).bind(machineId, phase), + ), + ...bindings.map((binding) => + env.DB.prepare(`SELECT d.*, o.id AS previousId, o.owner AS previousOwner, o.machineId AS previousMachineId, o.key AS previousKey, + o.commandJson AS previousCommandJson, o.phase AS previousPhase, o.createdAt AS previousCreatedAt, o.updatedAt AS previousUpdatedAt, + o.resultJson AS previousResultJson, o.cancelRequested AS previousCancelRequested + FROM runnerDemands d LEFT JOIN operations o ON o.id = d.operationId WHERE d.installationId = ? AND d.repositoryId = ? + AND d.jobId IN (SELECT jobId FROM githubJobs WHERE installationId = ? AND repositoryId = ? AND status = 'queued' ORDER BY updatedAt LIMIT ?)`).bind( + binding.installationId, + binding.repositoryId, + binding.installationId, + binding.repositoryId, + budget, + ), + ), + ]); + const queues: DemandQueue[] = bindings.map((binding, index) => ({ + binding, + environment: machine.environments.find((item) => item.id === binding.environmentId)!, + jobs: reads[index]!.results as unknown as JobRow[], + })); + type Prior = DemandRow & { + previousId: string | null; + previousOwner: string; + previousMachineId: string; + previousKey: string; + previousCommandJson: string; + previousPhase: OperationRow["phase"]; + previousCreatedAt: number; + previousUpdatedAt: number; + previousResultJson: string | null; + previousCancelRequested: OperationRow["cancelRequested"]; + }; + const demands = reads.slice(bindings.length + 3).flatMap((result) => + (result.results as unknown as Prior[]).map((row) => ({ + demand: row, + ...(row.previousId + ? { + operation: { + id: row.previousId, + owner: row.previousOwner, + machineId: row.previousMachineId, + key: row.previousKey, + commandJson: row.previousCommandJson, + phase: row.previousPhase, + createdAt: row.previousCreatedAt, + updatedAt: row.previousUpdatedAt, + resultJson: row.previousResultJson, + cancelRequested: row.previousCancelRequested, + }, + } + : {}), + })), + ); + const operations = reads + .slice(bindings.length, bindings.length + 3) + .flatMap( + (result) => + result.results as unknown as (OperationRow & { reservedEnvironmentId: string | null })[], + ); + const now = Date.now(); + for (const claim of planDemand({ + owner: machine.owner, + capacity, + queues, + demands, + operations, + now, + })) { + const { binding, job, previous, attempt, failures } = claim; + const operationId = newId(); + const result = await env.DB.batch([ + // A machine holds at most 100 active operations (see submitOperation); when it is full, + // neither the reservation nor the operation is written, so the job stays claimable. + env.DB.prepare(`INSERT INTO runnerDemands (installationId, repositoryId, jobId, owner, bindingId, operationId, attempt, failures, updatedAt) + SELECT ?, ?, ?, ?, ?, ?, ?, ?, ? WHERE (SELECT COUNT(*) FROM operations WHERE machineId = ? AND phase IN ('accepted', 'claimed', 'running')) < 100 + ON CONFLICT(installationId, repositoryId, jobId) DO UPDATE SET + owner = excluded.owner, bindingId = excluded.bindingId, operationId = excluded.operationId, attempt = excluded.attempt, + failures = excluded.failures, updatedAt = excluded.updatedAt WHERE runnerDemands.attempt = ? AND runnerDemands.operationId = ?`).bind( + job.installationId, + job.repositoryId, + job.jobId, + machine.owner, + binding.id, + operationId, + attempt, + failures, + now, + machineId, + previous?.attempt ?? 0, + previous?.operationId ?? "", + ), + env.DB.prepare(`INSERT INTO operations (id, owner, machineId, key, commandJson, phase, createdAt, updatedAt) + SELECT ?, ?, ?, ?, ?, 'accepted', ?, ? WHERE EXISTS (SELECT 1 FROM runnerDemands WHERE installationId = ? AND repositoryId = ? AND jobId = ? AND operationId = ?)`).bind( + operationId, + machine.owner, + machineId, + `automatic:${binding.installationId}:${binding.repositoryId}:${job.jobId}:${attempt}`, + JSON.stringify({ + type: "runner.run", + bindingId: binding.id, + jobId: job.jobId, + automatic: true, + }), + now, + now, + job.installationId, + job.repositoryId, + job.jobId, + operationId, + ), + ]); + if (result[1]!.meta.changes) await notifyMachine(env, machineId); + } +} diff --git a/apps/cloud/src/runners/leases-store.ts b/apps/cloud/src/runners/leases-store.ts new file mode 100644 index 00000000..09fd5a50 --- /dev/null +++ b/apps/cloud/src/runners/leases-store.ts @@ -0,0 +1,230 @@ +import { + jobAccess, + restrictiveAccess, + sameRunnerAccess, +} from "../../../../packages/github/src/access-policy.js"; +import { matchesRunnerLabels } from "../../../../packages/github/src/runner-labels.js"; +import { bindingAuthority } from "../authority.js"; +import { CloudError } from "../errors.js"; +import { newId } from "../ids.js"; +import { notifyMachine, requestDemand } from "../notify.js"; +import { + parseJson, + machineRecord, + type MachineRow, + type LeaseRow, + type JobRow, + type RunnerAccess, +} from "../db.js"; +import type { MachineContext } from "../context.js"; +import { assertScaleSetBinding, currentScaleSet } from "./scale-sets-store.js"; + +export type RunnerRequest = { + bindingId: string; + key: string; + jobId?: number | undefined; + automatic?: boolean | undefined; + scaleSet?: { generation: number } | undefined; +}; +export async function admission(c: MachineContext, args: Omit) { + const machine = await c.env.DB.prepare("SELECT * FROM machines WHERE id = ? AND owner = ?") + .bind(c.machine.id, c.machine.owner) + .first(); + if (!machine) throw new CloudError("repository_access_denied", 403); + const authority = await bindingAuthority(c.env, machineRecord(machine), args.bindingId, true); + const { binding, environment } = authority; + if (args.automatic && !binding.automatic) throw new CloudError("automatic_disabled", 409); + const policy = binding.access ?? { actors: "any" as const, approval: "automatic" as const }; + let base: RunnerAccess = { repositoryId: binding.repositoryId, ...policy }; + if (args.scaleSet) { + assertScaleSetBinding(binding); + if (args.jobId !== undefined) throw new CloudError("invalid_runner_request", 400); + const row = await currentScaleSet(c.env, binding, args.scaleSet.generation); + if (!["active", "refreshing"].includes(row.phase) || !row.sessionId || row.scaleSetId === null) + throw new CloudError("scale_set_session_missing", 409); + return { ...authority, access: base, cacheJob: null }; + } + if (args.automatic && binding.capacityMode === "scale_set") + throw new CloudError("scale_set_generation_required", 409); + if (args.jobId === undefined) { + if (restrictiveAccess(policy)) throw new CloudError("job_required", 409); + return { ...authority, access: base, cacheJob: null }; + } + if (!Number.isSafeInteger(args.jobId) || args.jobId <= 0) + throw new CloudError("invalid_job_id", 400); + const job = await c.env.DB.prepare( + "SELECT * FROM githubJobs WHERE installationId = ? AND repositoryId = ? AND jobId = ?", + ) + .bind(binding.installationId, binding.repositoryId, args.jobId) + .first(); + if (!job || job.status !== "queued") throw new CloudError("job_not_queued", 409); + if ( + !environment || + !matchesRunnerLabels(JSON.parse(job.labelsJson), environment.os, environment.id) + ) + throw new CloudError("runner_labels_mismatch", 409); + const denied = jobAccess(policy, { + actorId: job.actorId ?? undefined, + triggeringActorId: job.triggeringActorId ?? undefined, + runAttempt: job.runAttempt ?? undefined, + }); + if (denied && (denied !== "approval_required" || args.automatic)) + throw new CloudError(denied, 403); + if (policy.approval === "required") { + if (job.runAttempt === null) throw new CloudError("actor_unknown", 403); + base = { ...base, approvedRun: { runId: job.runId, runAttempt: job.runAttempt } }; + } + return { + ...authority, + access: base, + cacheJob: binding.cacheVolume?.enabled + ? { runId: job.runId, runAttempt: job.runAttempt ?? undefined } + : null, + }; +} +export function leaseRecord(row: LeaseRow) { + return { + ...row, + runnerId: row.runnerId ?? undefined, + access: parseJson(row.accessJson), + cacheRun: parseJson<{ runId: number; runAttempt: number }>(row.cacheRunJson), + cacheWrite: row.cacheWrite === 1, + }; +} +export async function owned(c: MachineContext, id: string) { + const row = await c.env.DB.prepare( + "SELECT * FROM runnerLeases WHERE id = ? AND machineId = ? AND owner = ?", + ) + .bind(id, c.machine.id, c.machine.owner) + .first(); + if (!row) throw new CloudError("runner_lease_missing", 404); + return leaseRecord(row); +} +export async function claim(c: MachineContext, args: RunnerRequest) { + const { binding, environment, access } = await admission(c, args); + if (!environment || !args.key.trim() || args.key.length > 200) + throw new CloudError("invalid_runner_request", 400); + const id = newId(); + const now = Date.now(); + const result = + await c.env.DB.prepare(`INSERT INTO runnerLeases (id, owner, machineId, bindingId, environmentId, os, environmentRevision, key, phase, createdAt, updatedAt, accessJson, jobId, automatic, scaleSetGeneration) + SELECT ?, ?, ?, ?, ?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ? WHERE (SELECT COUNT(*) FROM runnerLeases WHERE machineId = ? AND phase IN ('preparing','ready','action_required')) < 100 ON CONFLICT (machineId, key) DO NOTHING`) + .bind( + id, + c.machine.owner, + c.machine.id, + binding.id, + environment.id, + environment.os, + environment.revision ?? null, + args.key, + now, + now, + JSON.stringify(access), + args.jobId ?? null, + args.automatic ? 1 : 0, + args.scaleSet?.generation ?? null, + c.machine.id, + ) + .run(); + const row = await c.env.DB.prepare("SELECT * FROM runnerLeases WHERE machineId = ? AND key = ?") + .bind(c.machine.id, args.key) + .first(); + if (!row) + throw new CloudError("runner_lease_limit", 409, { + nextStep: "Release or reconcile this machine's existing runner leases.", + }); + if ( + row.owner !== c.machine.owner || + row.bindingId !== binding.id || + row.os !== environment.os || + (row.phase !== "released" && + (row.jobId !== (args.jobId ?? null) || + row.scaleSetGeneration !== (args.scaleSet?.generation ?? null) || + row.automatic !== (args.automatic ? 1 : 0) || + !sameRunnerAccess(parseJson(row.accessJson), access))) + ) + throw new CloudError("idempotency_conflict", 409); + if (result.meta.changes) await notifyMachine(c.env, c.machine.id); + return { fresh: row.id === id, lease: leaseRecord(row) }; +} +export async function attention(c: MachineContext, id: string, runnerId?: number) { + const result = await c.env.DB.prepare( + "UPDATE runnerLeases SET phase = 'action_required', encodedConfig = NULL, configExpiresAt = NULL, runnerId = COALESCE(?, runnerId), updatedAt = ? WHERE id = ? AND machineId = ? AND phase != 'released' AND (phase != 'action_required' OR (? IS NOT NULL AND runnerId IS NOT ?))", + ) + .bind(runnerId ?? null, Date.now(), id, c.machine.id, runnerId ?? null, runnerId ?? null) + .run(); + if (result.meta.changes) await notifyMachine(c.env, c.machine.id); +} +export async function ready( + c: MachineContext, + input: { + id: string; + runnerId: number; + encodedConfig: string; + cacheWrite?: boolean; + cacheWriteReason?: string; + cacheRun?: { runId: number; runAttempt: number }; + }, +) { + const lease = await owned(c, input.id); + if (lease.phase !== "preparing") throw new CloudError("runner_lease_conflict", 409); + const authority = await admission(c, { + bindingId: lease.bindingId, + jobId: lease.jobId ?? undefined, + automatic: lease.automatic === 1, + scaleSet: + lease.scaleSetGeneration === null ? undefined : { generation: lease.scaleSetGeneration }, + }); + if (!sameRunnerAccess(lease.access, authority.access)) + throw new CloudError("repository_access_changed", 409); + if (lease.environmentRevision !== (authority.environment?.revision ?? null)) + throw new CloudError("environment_changed", 409); + if ( + !Number.isSafeInteger(input.runnerId) || + input.runnerId <= 0 || + !/^[A-Za-z0-9+/=]{1,60000}$/.test(input.encodedConfig) + ) + throw new CloudError("invalid_runner_configuration", 502); + const now = Date.now(); + const result = await c.env.DB.prepare( + `UPDATE runnerLeases SET phase = 'ready', runnerId = ?, encodedConfig = ?, cacheWrite = ?, cacheWriteReason = ?, cacheRunJson = ?, configExpiresAt = ?, updatedAt = ? WHERE id = ? AND phase = 'preparing' + AND EXISTS (SELECT 1 FROM repositoryBindings b JOIN githubAccounts a ON a.id = b.accountId JOIN machines m ON m.id = b.machineId + WHERE b.id = runnerLeases.bindingId AND b.owner = ? AND a.owner = b.owner AND m.owner = b.owner AND b.enabled = 1 AND b.installationId = ? AND b.repositoryId = ? AND b.environmentId = ? AND b.accessJson IS ? AND b.automatic = ? AND b.capacityMode = ? AND m.environmentsJson = ?) + AND (? IS NULL OR EXISTS (SELECT 1 FROM runnerScaleSets s WHERE s.bindingId = runnerLeases.bindingId AND s.machineId = runnerLeases.machineId AND s.generation = ? AND s.phase IN ('active','refreshing') AND s.sessionId IS NOT NULL))`, + ) + .bind( + input.runnerId, + input.encodedConfig, + input.cacheWrite && input.cacheRun ? 1 : 0, + input.cacheWriteReason?.slice(0, 256) ?? null, + input.cacheRun ? JSON.stringify(input.cacheRun) : null, + now + 900000, + now, + input.id, + c.machine.owner, + authority.binding.installationId, + authority.binding.repositoryId, + authority.binding.environmentId, + authority.binding.access ? JSON.stringify(authority.binding.access) : null, + authority.binding.automatic ? 1 : 0, + authority.binding.capacityMode, + JSON.stringify(authority.machine.environments), + lease.scaleSetGeneration, + lease.scaleSetGeneration, + ) + .run(); + if (!result.meta.changes) throw new CloudError("runner_lease_conflict", 409); + await notifyMachine(c.env, c.machine.id); +} +export async function released(c: MachineContext, id: string) { + const result = await c.env.DB.prepare( + "UPDATE runnerLeases SET phase = 'released', encodedConfig = NULL, configExpiresAt = NULL, updatedAt = ? WHERE id = ? AND machineId = ? AND phase != 'released'", + ) + .bind(Date.now(), id, c.machine.id) + .run(); + if (result.meta.changes) { + await notifyMachine(c.env, c.machine.id); + await requestDemand(c.env, c.machine.id); + } +} diff --git a/apps/cloud/src/runners/leases.ts b/apps/cloud/src/runners/leases.ts new file mode 100644 index 00000000..471c6494 --- /dev/null +++ b/apps/cloud/src/runners/leases.ts @@ -0,0 +1,382 @@ +import { Schema } from "effect"; +import { readCacheWrite } from "../../../../packages/github/src/cache-write.js"; +import { runnerLabels } from "../../../../packages/github/src/runner-labels.js"; +import { GitHubApiError, GitHubAppClient } from "../../../../packages/github/src/app-client.js"; +import { CloudError } from "../errors.js"; +import { bindingAuthority } from "../authority.js"; +import { loadApp } from "../github/app.js"; +import { githubErrors, repositoryAccess } from "../github/access.js"; +import { newId } from "../ids.js"; +import { notifyMachine } from "../notify.js"; +import { + bindingRecord, + accountRecord, + type BindingRow, + type GitHubAccountRow, + type GitHubAppRow, + type LeaseRow, +} from "../db.js"; +import type { MachineHandler, Sweep } from "../context.js"; +import { admission, claim, owned, ready, attention, released } from "./leases-store.js"; +import { generateScaleSetJit } from "./scale-sets-github.js"; +const Runner = Schema.Struct({ id: Schema.Int, name: Schema.String }); +const RunnerState = Schema.Struct({ + ...Runner.fields, + busy: Schema.Boolean, + status: Schema.Literals(["online", "offline"]), +}); +const Registration = Schema.Struct({ runner: Runner, encoded_jit_config: Schema.NonEmptyString }); +const Pool = Schema.Struct({ total_count: Schema.Int, runners: Schema.Array(Runner) }); + +export const prepareRunner: MachineHandler<"/machine/runners/prepare"> = async (c, args) => { + if (!args.key.trim() || args.key.length > 200) + throw new CloudError("invalid_runner_request", 409); + const authority = await authorize(c, { + bindingId: args.bindingId, + preparing: true, + ...(args.jobId === undefined ? {} : { jobId: args.jobId }), + ...(args.automatic === undefined ? {} : { automatic: args.automatic }), + ...(args.scaleSet ? { scaleSet: args.scaleSet } : {}), + }); + const client = new GitHubAppClient(authority.app); + const access = await repositoryAccess(client, { + owner: authority.binding.repositoryOwner ?? authority.account.login, + repo: authority.binding.repositoryName, + repositoryId: authority.binding.repositoryId, + githubUserId: authority.account.githubId, + githubLogin: authority.account.login, + }); + if (access.installationId !== authority.binding.installationId) + throw new CloudError("installation_changed", 409, { + nextStep: "Reconnect this repository to its current GitHub App installation.", + }); + const { fresh, lease } = await claim(c, args); + if (!fresh) { + if ( + lease.phase === "ready" && + lease.encodedConfig && + lease.runnerId && + (lease.configExpiresAt ?? 0) > Date.now() + ) + return { + state: "ready", + id: lease.id, + runnerId: lease.runnerId, + encodedConfig: lease.encodedConfig, + cacheWrite: lease.cacheWrite ?? false, + ...(lease.cacheWriteReason ? { cacheWriteReason: lease.cacheWriteReason } : {}), + environmentId: lease.environmentId, + os: lease.os, + ...(lease.access + ? { + access: { + ...lease.access, + ...(lease.cacheRun ? { cacheRun: lease.cacheRun } : {}), + }, + } + : {}), + }; + if (lease.phase === "released") return { state: "released", id: lease.id }; + if (lease.phase === "preparing" && Date.now() - lease.updatedAt < 60000) + return { state: "pending", id: lease.id }; + await attention(c, lease.id); + return { state: "action_required", id: lease.id }; + } + const cacheJob = authority.cacheJob; + const cache = + !args.scaleSet && cacheJob + ? await (async () => { + try { + const jobs = await repositoryAccess(client, { + owner: authority.binding.repositoryOwner ?? authority.account.login, + repo: authority.binding.repositoryName, + repositoryId: authority.binding.repositoryId, + githubUserId: authority.account.githubId, + githubLogin: authority.account.login, + purpose: "jobs", + }); + if (jobs.installationId !== access.installationId) + return { cacheWrite: false, cacheWriteReason: "installation_changed" }; + return await readCacheWrite( + (path) => client.request(jobs.token, jobs.path + path), + authority.binding.repositoryId, + cacheJob.runId, + cacheJob.runAttempt, + ); + } catch { + return { cacheWrite: false, cacheWriteReason: "metadata_unavailable" }; + } + })() + : { + cacheWrite: false, + cacheWriteReason: args.scaleSet + ? "scale_set_run_unknown" + : authority.binding.cacheVolume?.enabled + ? "job_unknown" + : "cache_disabled", + }; + let runnerId: number | undefined; + try { + const scaleSetRegistration = args.scaleSet + ? await generateScaleSetJit( + c, + { + bindingId: args.bindingId, + generation: args.scaleSet.generation, + runnerName: `vectis-${lease.id}`, + }, + authority, + access, + ) + : undefined; + const registration = scaleSetRegistration + ? { + runner: scaleSetRegistration.runner, + encoded_jit_config: scaleSetRegistration.encodedJITConfig, + } + : Schema.decodeUnknownSync(Registration)( + await client.request(access.token, `${access.path}/actions/runners/generate-jitconfig`, { + name: `vectis-${lease.id}`, + runner_group_id: 1, + labels: runnerLabels(lease.os, lease.environmentId), + work_folder: "_work", + }), + ); + runnerId = registration.runner.id; + if (registration.runner.name !== `vectis-${lease.id}`) + throw new Error("Runner identity mismatch."); + if (args.scaleSet) { + const registered = Schema.decodeUnknownSync(Runner)( + await client.request(access.token, `${access.path}/actions/runners/${runnerId}`), + ); + if (registered.id !== runnerId || registered.name !== `vectis-${lease.id}`) + throw new Error("Runner identity mismatch."); + } + await ready(c, { + id: lease.id, + runnerId, + encodedConfig: registration.encoded_jit_config, + ...cache, + }); + return { + state: "ready", + id: lease.id, + runnerId, + encodedConfig: registration.encoded_jit_config, + cacheWrite: cache.cacheWrite, + ...(cache.cacheWriteReason ? { cacheWriteReason: cache.cacheWriteReason } : {}), + environmentId: lease.environmentId, + os: lease.os, + ...(lease.access + ? { access: { ...lease.access, ...(cache.cacheRun ? { cacheRun: cache.cacheRun } : {}) } } + : {}), + }; + } catch { + await attention(c, lease.id, runnerId); + return { state: "action_required", id: lease.id }; + } +}; + +export const releaseRunner: MachineHandler<"/machine/runners/release"> = async (c, args) => + githubErrors(async () => { + const lease = await owned(c, args.id); + if (lease.phase === "released") return { state: "released", id: lease.id }; + if (lease.phase === "preparing" && Date.now() - lease.updatedAt < 60000) + throw new CloudError("runner_lease_pending", 409, { + nextStep: "Wait for registration to finish before reconciling this lease.", + }); + const authority = await authorize(c, { + bindingId: lease.bindingId, + preparing: false, + }); + const client = new GitHubAppClient(authority.app); + const access = await repositoryAccess(client, { + owner: authority.binding.repositoryOwner ?? authority.account.login, + repo: authority.binding.repositoryName, + repositoryId: authority.binding.repositoryId, + githubUserId: authority.account.githubId, + githubLogin: authority.account.login, + purpose: "cleanup", + }); + const expectedName = `vectis-${lease.id}`; + let runnerId = lease.runnerId; + if (runnerId === undefined) { + let searched = false; + for (let page = 1; page <= 10; page++) { + const pool = Schema.decodeUnknownSync(Pool)( + await client.request( + access.token, + `${access.path}/actions/runners?per_page=100&page=${page}`, + ), + ); + const matches = pool.runners.filter((runner) => runner.name === expectedName); + if (matches.length > 1) throw new CloudError("runner_identity_conflict", 409); + if (matches[0]) { + runnerId = matches[0].id; + searched = true; + break; + } + if (page * 100 >= pool.total_count) { + searched = true; + break; + } + } + if (!searched) + throw new CloudError("runner_reconciliation_limit", 409, { + nextStep: "Inspect the repository runner pool before retrying cleanup.", + }); + } + if (runnerId !== undefined) { + const path = `${access.path}/actions/runners/${runnerId}`; + try { + const runner = Schema.decodeUnknownSync(Runner)(await client.request(access.token, path)); + if (runner.id !== runnerId || runner.name !== expectedName) + throw new CloudError("runner_identity_conflict", 409); + try { + await client.delete(access.token, path); + } catch (error) { + if (error instanceof GitHubApiError && error.status === 422) + throw new CloudError("runner_busy", 409, { + message: "GitHub still shows this runner on a job.", + nextStep: + "Vectis retries cleanup automatically after GitHub releases the job, usually within 15 minutes.", + }); + throw error; + } + } catch (error) { + if (!(error instanceof GitHubApiError && error.status === 404)) throw error; + } + } + await released(c, lease.id); + return { state: "released", id: lease.id }; + }); + +export const runnerStatus: MachineHandler<"/machine/runners/status"> = async (c, args) => + githubErrors(async () => { + const { lease, authority } = await statusAuthority(c, args.id); + if (lease.phase === "released") return { missing: true }; + if (lease.runnerId === undefined) throw new CloudError("runner_registration_incomplete", 409); + const client = new GitHubAppClient(authority.app); + const access = await repositoryAccess(client, { + owner: authority.binding.repositoryOwner ?? authority.account.login, + repo: authority.binding.repositoryName, + repositoryId: authority.binding.repositoryId, + githubUserId: authority.account.githubId, + githubLogin: authority.account.login, + purpose: "cleanup", + }); + try { + const runner = Schema.decodeUnknownSync(RunnerState)( + await client.request(access.token, `${access.path}/actions/runners/${lease.runnerId}`), + ); + if (runner.id !== lease.runnerId || runner.name !== `vectis-${lease.id}`) + throw new CloudError("runner_identity_conflict", 409); + return { busy: runner.busy, status: runner.status }; + } catch (error) { + if (error instanceof GitHubApiError && error.status === 404) return { missing: true }; + throw error; + } + }); + +async function authorize( + c: Parameters[0], + args: { + bindingId: string; + preparing: boolean; + jobId?: number; + automatic?: boolean; + scaleSet?: { generation: number }; + }, +) { + const authority = args.preparing + ? await admission(c, args) + : { ...(await bindingAuthority(c.env, c.machine, args.bindingId, false)), cacheJob: null }; + return { ...authority, app: await loadApp(c.env) }; +} +export const reconcileRunner: MachineHandler<"/machine/runners/reconcile"> = async (c, args) => { + const { binding, environment } = await bindingAuthority(c.env, c.machine, args.bindingId, false); + if (!args.key.trim() || args.key.length > 200 || binding.environmentId !== args.environmentId) + throw new CloudError("invalid_runner_request", 400); + let row = await c.env.DB.prepare("SELECT * FROM runnerLeases WHERE machineId = ? AND key = ?") + .bind(c.machine.id, args.key) + .first(); + if (!row) { + if (!environment) throw new CloudError("environment_unavailable", 409); + const now = Date.now(); + const result = await c.env.DB.prepare( + "INSERT INTO runnerLeases (id,owner,machineId,bindingId,environmentId,os,environmentRevision,key,phase,createdAt,updatedAt) VALUES (?,?,?,?,?,?,?,?,'released',?,?) ON CONFLICT (machineId,key) DO NOTHING", + ) + .bind( + newId(), + c.machine.owner, + c.machine.id, + binding.id, + environment.id, + environment.os, + environment.revision ?? null, + args.key, + now, + now, + ) + .run(); + row = await c.env.DB.prepare("SELECT * FROM runnerLeases WHERE machineId = ? AND key = ?") + .bind(c.machine.id, args.key) + .first(); + if (result.meta.changes) await notifyMachine(c.env, c.machine.id); + } + if ( + !row || + row.owner !== c.machine.owner || + row.bindingId !== binding.id || + row.environmentId !== args.environmentId + ) + throw new CloudError("runner_identity_mismatch", 409); + return { + id: row.id, + bindingId: row.bindingId, + environmentId: row.environmentId, + phase: row.phase, + }; +}; +export const sweepLeases: Sweep = async (env, now) => { + await env.DB.prepare( + "UPDATE runnerLeases SET encodedConfig = NULL, configExpiresAt = NULL WHERE id IN (SELECT id FROM runnerLeases WHERE configExpiresAt IS NOT NULL AND configExpiresAt <= ? ORDER BY configExpiresAt LIMIT 100)", + ) + .bind(now) + .run(); +}; + +async function statusAuthority(c: Parameters[0], id: string) { + const results = await c.env.DB.batch([ + c.env.DB.prepare( + "SELECT id, bindingId, phase, runnerId FROM runnerLeases WHERE id = ? AND machineId = ? AND owner = ?", + ).bind(id, c.machine.id, c.machine.owner), + c.env.DB.prepare( + "SELECT b.* FROM repositoryBindings b JOIN runnerLeases l ON l.bindingId = b.id WHERE l.id = ? AND l.machineId = ? AND l.owner = ?", + ).bind(id, c.machine.id, c.machine.owner), + c.env.DB.prepare( + "SELECT a.* FROM githubAccounts a JOIN repositoryBindings b ON b.accountId = a.id JOIN runnerLeases l ON l.bindingId = b.id WHERE l.id = ? AND l.machineId = ? AND l.owner = ?", + ).bind(id, c.machine.id, c.machine.owner), + c.env.DB.prepare("SELECT * FROM githubApps WHERE singleton = 1"), + ]); + const lease = results[0]?.results[0] as + | Pick + | undefined; + const binding = results[1]?.results[0] as BindingRow | undefined; + const account = results[2]?.results[0] as GitHubAccountRow | undefined; + const app = results[3]?.results[0] as GitHubAppRow | undefined; + if (!lease) throw new CloudError("runner_lease_missing", 404); + if ( + !binding || + binding.machineId !== c.machine.id || + binding.owner !== c.machine.owner || + !account || + account.owner !== c.machine.owner + ) + throw new CloudError("repository_access_denied", 403); + if (!app) throw new CloudError("github_app_unavailable", 503); + return { + lease: { ...lease, runnerId: lease.runnerId ?? undefined }, + authority: { binding: bindingRecord(binding), account: accountRecord(account), app }, + }; +} diff --git a/apps/cloud/src/runners/plan-demand.ts b/apps/cloud/src/runners/plan-demand.ts new file mode 100644 index 00000000..ec42dde4 --- /dev/null +++ b/apps/cloud/src/runners/plan-demand.ts @@ -0,0 +1,137 @@ +import { decodeCommand } from "../../../../packages/protocol/src/index.js"; +import type { EnvironmentSummary } from "../../../../packages/protocol/src/cloud.js"; +import { matchesRunnerLabels } from "../../../../packages/github/src/runner-labels.js"; +import { jobAccess } from "../../../../packages/github/src/access-policy.js"; +import type { Binding, DemandRow, JobRow, OperationRow } from "../db.js"; + +export interface DemandCapacity { + runnerSlots?: number | undefined; + environmentSlots?: readonly { environmentId: string; available: number }[] | undefined; + statusAt: number; +} +export interface DemandQueue { + binding: Binding; + environment: EnvironmentSummary; + jobs: readonly JobRow[]; +} +export interface PlannedDemand { + binding: Binding; + job: JobRow; + previous?: DemandRow; + attempt: number; + failures: number; +} + +function resultCode(operation: OperationRow) { + try { + const result: unknown = JSON.parse(operation.resultJson ?? "null"); + return result && typeof result === "object" && "code" in result ? result.code : undefined; + } catch { + return undefined; + } +} + +export function planDemand(input: { + owner: string; + capacity: DemandCapacity; + queues: readonly DemandQueue[]; + demands: readonly { demand: DemandRow; operation?: OperationRow }[]; + operations: readonly (OperationRow & { reservedEnvironmentId?: string | null })[]; + now: number; +}): PlannedDemand[] { + const { capacity, queues, now } = input; + const slots = + capacity.environmentSlots === undefined + ? undefined + : new Map(capacity.environmentSlots.map((item) => [item.environmentId, item.available])); + let free = capacity.runnerSlots ?? (slots ? Math.max(0, ...slots.values()) : 0); + const bindingsById = new Map(queues.map(({ binding }) => [binding.id, binding])); + const demandsByJob = new Map( + input.demands.map((entry) => [ + `${entry.demand.installationId}:${entry.demand.repositoryId}:${entry.demand.jobId}`, + entry, + ]), + ); + for (const operation of input.operations) { + const command = decodeCommand(JSON.parse(operation.commandJson)); + if ( + command.type !== "runner.run" || + !command.automatic || + !["accepted", "claimed", "running"].includes(operation.phase) || + (operation.phase === "running" && operation.updatedAt < capacity.statusAt) + ) + continue; + free--; + const environmentId = + operation.reservedEnvironmentId ?? bindingsById.get(command.bindingId)?.environmentId; + if (slots && environmentId) slots.set(environmentId, (slots.get(environmentId) ?? 0) - 1); + } + const budget = Math.max(50, Math.floor(2000 / Math.max(1, queues.length))); + const cursors = queues.map(() => 0); + const planned: PlannedDemand[] = []; + const claimed = new Set(); + while (free > 0) { + let scheduled = false; + for (const [index, queue] of queues.entries()) { + if (free <= 0) break; + const { binding, environment } = queue; + if (slots && (slots.get(binding.environmentId) ?? 0) <= 0) continue; + while (cursors[index]! < Math.min(budget, queue.jobs.length)) { + const job = queue.jobs[cursors[index]!]!; + cursors[index]!++; + const key = `${job.installationId}:${job.repositoryId}:${job.jobId}`; + if ( + claimed.has(key) || + jobAccess(binding.access, { + actorId: job.actorId ?? undefined, + triggeringActorId: job.triggeringActorId ?? undefined, + runAttempt: job.runAttempt ?? undefined, + }) !== undefined || + !matchesRunnerLabels(JSON.parse(job.labelsJson), environment.os, environment.id) + ) + continue; + const prior = demandsByJob.get(key); + const demand = prior?.demand, + previous = prior?.operation; + let failures = demand?.failures ?? 0; + if (demand) { + if (demand.owner !== input.owner || demand.attempt >= 10) continue; + if (!previous) { + if (demand.updatedAt > now - 600000) continue; + } else if (previous.phase === "failed" || previous.phase === "action_required") { + if ( + previous.phase === "failed" && + resultCode(previous) === "cache_volume_requires_guest_sudo" + ) + continue; + if (previous.updatedAt > now - (previous.phase === "failed" ? 60000 : 600000)) continue; + failures++; + } else if (previous.phase === "cancelled") { + if ( + !previous.cancelRequested && + ["machine_offline", "claim_timeout"].includes(String(resultCode(previous))) + ) { + if (previous.updatedAt > now - 60000) continue; + failures++; + } else if (job.updatedAt <= previous.createdAt) continue; + } else if (previous.phase !== "succeeded") continue; + if (failures >= 3) continue; + } + planned.push({ + binding, + job, + ...(demand ? { previous: demand } : {}), + attempt: (demand?.attempt ?? 0) + 1, + failures, + }); + claimed.add(key); + free--; + if (slots) slots.set(binding.environmentId, slots.get(binding.environmentId)! - 1); + scheduled = true; + break; + } + } + if (!scheduled) break; + } + return planned; +} diff --git a/apps/cloud/src/runners/scale-sets-github.ts b/apps/cloud/src/runners/scale-sets-github.ts new file mode 100644 index 00000000..700f9741 --- /dev/null +++ b/apps/cloud/src/runners/scale-sets-github.ts @@ -0,0 +1,300 @@ +import { CloudError } from "../errors.js"; +import type { MachineContext, MachineHandler, Sweep } from "../context.js"; +import type { Binding, GitHubAccount, ScaleSetRow } from "../db.js"; +import type { Env } from "../env.js"; +import { bindingAuthority } from "../authority.js"; +import { loadApp } from "../github/app.js"; +import { repositoryAccess } from "../github/access.js"; +import { + begin, + checkpoint, + failed, + assertScaleSetBinding, + currentScaleSet, +} from "./scale-sets-store.js"; +import type { ScaleSetGrant } from "../../../../packages/protocol/src/scale-sets.js"; +import { GitHubAppClient } from "../../../../packages/github/src/app-client.js"; +import { runnerLabels } from "../../../../packages/github/src/runner-labels.js"; +import { + bootstrapAdmin, + ScaleSetBootstrapHttpError, + tokenExpiration, +} from "../../../../packages/scaleset/src/bootstrap.js"; +import { + ScaleSetAdminClient, + ScaleSetHttpError, +} from "../../../../packages/scaleset/src/client.js"; +import type { Session } from "../../../../packages/scaleset/src/messages.js"; +type Authority = { binding: Binding; account: GitHubAccount }; +type Access = { token: string }; +// Admin credentials are short lived and stay inside this action invocation. +async function admin(authority: Authority, access: Access, signal: AbortSignal) { + const connection = await bootstrapAdmin( + { + owner: authority.binding.repositoryOwner ?? authority.account.login, + repo: authority.binding.repositoryName, + installationToken: access.token, + }, + fetch, + signal, + ); + return new ScaleSetAdminClient(connection.serviceUrl, connection.adminToken, fetch); +} + +async function connection( + c: MachineContext, + bindingId: string, + preparing: boolean, + signal: AbortSignal, +) { + const authority = await bindingAuthority(c.env, c.machine, bindingId, preparing); + if (preparing) assertScaleSetBinding(authority.binding); + const app = await loadApp(c.env); + const access = await repositoryAccess(new GitHubAppClient(app), { + owner: authority.binding.repositoryOwner ?? authority.account.login, + repo: authority.binding.repositoryName, + repositoryId: authority.binding.repositoryId, + installationId: authority.binding.installationId, + githubUserId: authority.account.githubId, + githubLogin: authority.account.login, + ...(preparing ? {} : { purpose: "cleanup" as const }), + }); + return { authority, admin: await admin(authority, access, signal) }; +} + +function grant(row: ScaleSetRow, scaleSetId: number, session: Session): ScaleSetGrant { + const exp = tokenExpiration(session.messageQueueAccessToken); + return { + bindingId: row.bindingId, + scaleSetId, + scaleSetName: row.name, + generation: row.generation, + sessionId: session.sessionId, + messageQueueUrl: session.messageQueueUrl, + messageQueueAccessToken: session.messageQueueAccessToken, + ...(exp === null ? {} : { queueTokenExpiresAt: exp * 1000 }), + statistics: session.statistics, + }; +} + +function scaleSetError(error: unknown): never { + if (error instanceof CloudError) throw error; + throw new CloudError("scale_set_unavailable", 502, { + message: "The GitHub scale set service could not complete the request.", + ...(error instanceof ScaleSetHttpError || error instanceof ScaleSetBootstrapHttpError + ? { status: error.status } + : {}), + }); +} + +async function ignoreMissing(operation: () => Promise) { + try { + await operation(); + } catch (error) { + if (!(error instanceof ScaleSetHttpError && error.status === 404)) throw error; + } +} + +function verifyLabels(actual: readonly { name: string }[], labels: readonly string[]) { + const actualLabels = new Set(actual.map((label) => label.name.toLowerCase())); + if ( + actualLabels.size !== labels.length || + !labels.every((label) => actualLabels.has(label.toLowerCase())) + ) + throw new CloudError("scale_set_labels_unsupported", 502, { + message: "GitHub did not retain the scale set's custom runner labels.", + nextStep: "Use demand capacity for this repository.", + }); +} + +export const openScaleSet: MachineHandler<"/machine/scale-sets/open"> = async (c, args) => { + const signal = AbortSignal.timeout(90000); + const row = await begin(c, { + ...args, + operation: "open", + }); + + let created: { admin: ScaleSetAdminClient; scaleSetId: number; sessionId: string } | undefined; + try { + const { authority, admin } = await connection(c, args.bindingId, true, signal); + const environment = authority.environment; + if (!environment) throw new CloudError("environment_unavailable", 409); + const group = await admin.defaultRunnerGroup(signal); + const labels = [...runnerLabels(environment.os, environment.id), row.name]; + const found = await admin.findScaleSet(group.id, row.name, signal); + if (found && row.scaleSetId !== null && found.id !== row.scaleSetId) + throw new CloudError("scale_set_identity_conflict", 409); + const set = found ?? (await admin.createScaleSet(group.id, row.name, labels, signal)); + await checkpoint(c, row, "opening", set.id); + const effective = await admin.getScaleSet(set.id, signal); + if (effective.name !== row.name || effective.runnerGroupId !== group.id) + throw new CloudError("scale_set_identity_conflict", 409); + verifyLabels(effective.labels, labels); + const oldSessionId = row.sessionId; + if (oldSessionId) await ignoreMissing(() => admin.deleteSession(set.id, oldSessionId, signal)); + const session = await admin + .createSession(set.id, `${row.machineId}:${row.generation}`, signal) + .catch((error: unknown) => { + if (error instanceof ScaleSetHttpError && error.status === 409) + throw new CloudError("scale_set_session_conflict", 502, { + message: "GitHub already has a session whose identity Vectis cannot reconcile.", + nextStep: + "Close the other scale set listener or wait for its session to expire, then retry.", + }); + throw error; + }); + created = { admin, scaleSetId: set.id, sessionId: session.sessionId }; + await checkpoint(c, row, "active", set.id, session.sessionId); + return grant(row, set.id, session); + } catch (error) { + if (created) { + const session = created; + await ignoreMissing(() => + session.admin.deleteSession(session.scaleSetId, session.sessionId), + ).catch(() => undefined); + } + await failed(c, row); + return scaleSetError(error); + } +}; + +export const refreshScaleSet: MachineHandler<"/machine/scale-sets/refresh"> = async (c, args) => { + const row = await begin(c, { + ...args, + operation: "refresh", + }); + + try { + if (row.scaleSetId === null || !row.sessionId) + throw new CloudError("scale_set_session_missing", 409); + const signal = AbortSignal.timeout(90000); + const { authority, admin } = await connection(c, args.bindingId, true, signal); + const environment = authority.environment; + if (!environment) throw new CloudError("environment_unavailable", 409); + const set = await admin.getScaleSet(row.scaleSetId, signal); + if (set.name !== row.name) throw new CloudError("scale_set_identity_conflict", 409); + verifyLabels(set.labels, [...runnerLabels(environment.os, environment.id), row.name]); + const session = await admin.refreshSession(row.scaleSetId, row.sessionId, signal); + if (session.sessionId !== row.sessionId) + throw new CloudError("scale_set_identity_conflict", 409); + await checkpoint(c, row, "active", undefined, row.sessionId); + return grant(row, row.scaleSetId, session); + } catch (error) { + await failed(c, row); + return scaleSetError(error); + } +}; + +export const closeScaleSet: MachineHandler<"/machine/scale-sets/close"> = async (c, args) => { + const row = await begin(c, { + ...args, + operation: "close", + }); + if (row.phase === "closed") return null; + + try { + const { scaleSetId, sessionId } = row; + if (scaleSetId !== null && sessionId) { + const signal = AbortSignal.timeout(90000); + const { admin } = await connection(c, args.bindingId, false, signal); + await ignoreMissing(() => admin.deleteSession(scaleSetId, sessionId, signal)); + } + await checkpoint(c, row, "closed"); + return null; + } catch (error) { + await failed(c, row); + return scaleSetError(error); + } +}; + +export async function generateScaleSetJit( + c: MachineContext, + input: { bindingId: string; generation: number; runnerName: string }, + authority: Authority, + access: Access, +) { + const { binding, environment } = await bindingAuthority(c.env, c.machine, input.bindingId, true); + assertScaleSetBinding(binding); + const row = await currentScaleSet(c.env, binding, input.generation); + if ( + !["active", "refreshing"].includes(row.phase) || + !row.sessionId || + row.scaleSetId === null || + !environment + ) + throw new CloudError("scale_set_session_missing", 409); + const signal = AbortSignal.timeout(90000); + const client = await admin(authority, access, signal); + const set = await client.getScaleSet(row.scaleSetId, signal); + if (set.name !== row.name) throw new CloudError("scale_set_identity_conflict", 409); + verifyLabels(set.labels, [...runnerLabels(environment.os, environment.id), row.name]); + return client.generateJitConfig(row.scaleSetId, input.runnerName, signal); +} +export async function markScaleSetsForCleanup(env: Env, bindingIds: string[]): Promise { + for (let offset = 0; offset < bindingIds.length; offset += 50) { + const ids = bindingIds.slice(offset, offset + 50); + await env.DB.prepare( + `UPDATE runnerScaleSets SET phase = 'deleting', generation = generation + 1, cleanupDueAt = ?, cleanupAttempt = 0, updatedAt = ? WHERE bindingId IN (${ids.map(() => "?").join(",")}) AND phase != 'deleting'`, + ) + .bind(Date.now(), Date.now(), ...ids) + .run(); + } +} +export const sweepScaleSets: Sweep = async (env, now) => { + // One row bounds cold GitHub token requests, including their retries. + const rows = await env.DB.prepare( + "SELECT * FROM runnerScaleSets WHERE phase = 'deleting' AND cleanupDueAt <= ? ORDER BY cleanupDueAt LIMIT 1", + ) + .bind(now) + .all(); + for (const row of rows.results) { + try { + const app = await loadApp(env); + const signal = AbortSignal.timeout(90000); + const access = await repositoryAccess(new GitHubAppClient(app), { + owner: row.repositoryOwner, + repo: row.repositoryName, + repositoryId: row.repositoryId, + installationId: row.installationId, + githubUserId: row.githubUserId, + githubLogin: row.githubLogin, + purpose: "cleanup", + }); + const connection = await bootstrapAdmin( + { owner: row.repositoryOwner, repo: row.repositoryName, installationToken: access.token }, + fetch, + signal, + ); + const client = new ScaleSetAdminClient(connection.serviceUrl, connection.adminToken, fetch); + const group = await client.defaultRunnerGroup(signal); + const set = await client.findScaleSet(group.id, row.name, signal); + if (set) { + if (row.scaleSetId !== null && row.scaleSetId !== set.id) + throw new CloudError("scale_set_identity_conflict", 409); + const sessionId = row.sessionId; + if (sessionId) await ignoreMissing(() => client.deleteSession(set.id, sessionId, signal)); + await ignoreMissing(() => client.deleteScaleSet(set.id, signal)); + } + await env.DB.prepare( + "DELETE FROM runnerScaleSets WHERE id = ? AND generation = ? AND phase = 'deleting'", + ) + .bind(row.id, row.generation) + .run(); + } catch { + const terminal = row.cleanupAttempt >= 7; + await env.DB.prepare( + "UPDATE runnerScaleSets SET phase = ?, cleanupAttempt = ?, cleanupDueAt = ?, updatedAt = ? WHERE id = ? AND generation = ? AND phase = 'deleting' AND cleanupAttempt = ?", + ) + .bind( + terminal ? "action_required" : "deleting", + row.cleanupAttempt + 1, + terminal ? null : now + Math.min(900000, 1000 * 5 ** row.cleanupAttempt), + now, + row.id, + row.generation, + row.cleanupAttempt, + ) + .run(); + } + } +}; diff --git a/apps/cloud/src/runners/scale-sets-store.ts b/apps/cloud/src/runners/scale-sets-store.ts new file mode 100644 index 00000000..7ff32d36 --- /dev/null +++ b/apps/cloud/src/runners/scale-sets-store.ts @@ -0,0 +1,191 @@ +import { restrictiveAccess } from "../../../../packages/github/src/access-policy.js"; +import { bindingAuthority } from "../authority.js"; +import { CloudError } from "../errors.js"; +import { newId } from "../ids.js"; +import { machineRecord, type MachineRow, type Binding, type ScaleSetRow } from "../db.js"; +import type { Env } from "../env.js"; +import type { MachineContext } from "../context.js"; +export function assertScaleSetBinding(binding: Binding) { + if (binding.capacityMode !== "scale_set") throw new CloudError("scale_set_disabled", 409); + if (!binding.enabled) throw new CloudError("repository_access_denied", 403); + if (!binding.automatic) throw new CloudError("automatic_disabled", 409); + if (restrictiveAccess(binding.access)) + throw new CloudError("scale_set_policy_unsupported", 409, { + message: "Scale set messages do not identify the workflow actor or approval status.", + nextStep: "Use demand capacity for actor restrictions or required approval.", + }); +} +export async function currentScaleSet(env: Env, binding: Binding, generation: number) { + const row = await env.DB.prepare( + "SELECT * FROM runnerScaleSets WHERE bindingId = ? AND machineId = ?", + ) + .bind(binding.id, binding.machineId) + .first(); + if ( + !Number.isSafeInteger(generation) || + generation <= 0 || + !row || + row.generation !== generation || + row.owner !== binding.owner || + row.installationId !== binding.installationId || + row.repositoryId !== binding.repositoryId + ) + throw new CloudError("scale_set_superseded", 409); + return row; +} +export async function begin( + c: MachineContext, + args: { bindingId: string; operation: "open" | "refresh" | "close"; generation?: number }, +): Promise { + const { binding, account } = await bindingAuthority( + c.env, + c.machine, + args.bindingId, + args.operation !== "close", + ); + const existing = await c.env.DB.prepare( + "SELECT * FROM runnerScaleSets WHERE bindingId = ? AND machineId = ?", + ) + .bind(binding.id, c.machine.id) + .first(); + const busy = + existing && + ["opening", "refreshing", "closing"].includes(existing.phase) && + existing.updatedAt > Date.now() - 120000; + if (args.operation !== "open") { + const row = await currentScaleSet(c.env, binding, args.generation ?? 0); + if (row.phase === "deleting") throw new CloudError("scale_set_superseded", 409); + if (args.operation === "refresh") assertScaleSetBinding(binding); + if (args.operation === "close" && row.phase === "closed") return row; + if ( + ["opening", "refreshing", "closing"].includes(row.phase) && + row.updatedAt > Date.now() - 120000 + ) + throw new CloudError("scale_set_pending", 409); + if ( + args.operation === "refresh" && + (!["active", "action_required"].includes(row.phase) || + !row.sessionId || + row.scaleSetId === null) + ) + throw new CloudError("scale_set_session_missing", 409); + const phase = args.operation === "refresh" ? "refreshing" : "closing"; + const now = Date.now(); + const changed = await c.env.DB.prepare( + "UPDATE runnerScaleSets SET phase = ?, updatedAt = ? WHERE id = ? AND generation = ? AND phase = ? AND updatedAt = ?", + ) + .bind(phase, now, row.id, row.generation, row.phase, row.updatedAt) + .run(); + if (!changed.meta.changes) throw new CloudError("scale_set_superseded", 409); + return { ...row, phase, updatedAt: now }; + } + assertScaleSetBinding(binding); + if (existing?.phase === "deleting") throw new CloudError("scale_set_cleanup_pending", 409); + if (busy) throw new CloudError("scale_set_pending", 409); + if ( + existing?.scaleSetId !== null && + existing && + (existing.installationId !== binding.installationId || + existing.repositoryId !== binding.repositoryId || + existing.owner !== binding.owner) + ) + throw new CloudError("installation_changed", 409); + const row: ScaleSetRow = { + id: existing?.id ?? newId(), + owner: c.machine.owner, + installationId: binding.installationId, + repositoryId: binding.repositoryId, + bindingId: binding.id, + machineId: c.machine.id, + repositoryOwner: binding.repositoryOwner ?? account.login, + repositoryName: binding.repositoryName, + githubUserId: account.githubId, + githubLogin: account.login, + name: `vectis-${binding.environmentId}-${c.machine.id.slice(0, 8)}`, + generation: (existing?.generation ?? 0) + 1, + phase: "opening", + updatedAt: Date.now(), + scaleSetId: existing?.scaleSetId ?? null, + sessionId: existing?.sessionId ?? null, + cleanupDueAt: null, + cleanupAttempt: 0, + }; + const fields = [ + "id", + "owner", + "installationId", + "repositoryId", + "bindingId", + "machineId", + "repositoryOwner", + "repositoryName", + "githubUserId", + "githubLogin", + "name", + "generation", + "phase", + "updatedAt", + ] as const; + const values = fields.map((key) => row[key]); + const result = existing + ? await c.env.DB.prepare( + `UPDATE runnerScaleSets SET ${fields.map((key) => `${key} = ?`).join(",")} WHERE id = ? AND generation = ? AND phase = ? AND updatedAt = ?`, + ) + .bind(...values, existing.id, existing.generation, existing.phase, existing.updatedAt) + .run() + : await c.env.DB.prepare( + `INSERT INTO runnerScaleSets (${fields.join(",")}) VALUES (${fields.map(() => "?").join(",")}) ON CONFLICT (bindingId,machineId) DO NOTHING`, + ) + .bind(...values) + .run(); + if (!result.meta.changes) throw new CloudError("scale_set_pending", 409); + return row; +} +export async function checkpoint( + c: MachineContext, + row: ScaleSetRow, + phase: "opening" | "active" | "closed", + scaleSetId?: number, + sessionId?: string, +) { + const machine = await c.env.DB.prepare("SELECT * FROM machines WHERE id = ? AND owner = ?") + .bind(c.machine.id, c.machine.owner) + .first(); + if (!machine) throw new CloudError("scale_set_superseded", 409); + const { binding } = await bindingAuthority( + c.env, + machineRecord(machine), + row.bindingId, + phase !== "closed", + ); + if (phase !== "closed") assertScaleSetBinding(binding); + await currentScaleSet(c.env, binding, row.generation); + const expected = + phase === "closed" + ? ["closing"] + : phase === "opening" + ? ["opening"] + : ["opening", "refreshing"]; + const result = await c.env.DB.prepare( + `UPDATE runnerScaleSets SET phase = ?, scaleSetId = COALESCE(?,scaleSetId), sessionId = CASE WHEN ? = 'opening' THEN sessionId ELSE ? END, updatedAt = ? WHERE id = ? AND generation = ? AND phase IN (${expected.map(() => "?").join(",")})`, + ) + .bind( + phase, + scaleSetId ?? null, + phase, + sessionId ?? null, + Date.now(), + row.id, + row.generation, + ...expected, + ) + .run(); + if (!result.meta.changes) throw new CloudError("scale_set_superseded", 409); +} +export async function failed(c: MachineContext, row: ScaleSetRow) { + await c.env.DB.prepare( + "UPDATE runnerScaleSets SET phase = 'action_required', updatedAt = ? WHERE id = ? AND generation = ? AND phase IN ('opening','refreshing','closing')", + ) + .bind(Date.now(), row.id, row.generation) + .run(); +} diff --git a/apps/cloud/src/runners/scale-sets.ts b/apps/cloud/src/runners/scale-sets.ts new file mode 100644 index 00000000..bc93847d --- /dev/null +++ b/apps/cloud/src/runners/scale-sets.ts @@ -0,0 +1,7 @@ +export { + openScaleSet, + refreshScaleSet, + closeScaleSet, + markScaleSetsForCleanup, + sweepScaleSets, +} from "./scale-sets-github.js"; diff --git a/apps/cloud/src/sync.ts b/apps/cloud/src/sync.ts new file mode 100644 index 00000000..3f7f6a18 --- /dev/null +++ b/apps/cloud/src/sync.ts @@ -0,0 +1,51 @@ +import type { MachineHandler } from "./context.js"; +import type { InspectionRow, OperationRow } from "./db.js"; +import { machineRepositories } from "./github/repositories.js"; +import { jobMarkers, leaseObservations } from "./github/jobs.js"; + +export const sync: MachineHandler<"/machine/sync"> = async ({ env, machine }, input) => { + const now = Date.now(); + const [reads, repositories, observations] = await Promise.all([ + env.DB.batch([ + ...["accepted", "claimed", "running"].map((phase) => + env.DB.prepare( + "SELECT * FROM operations WHERE machineId = ? AND phase = ? ORDER BY createdAt LIMIT 100", + ).bind(machine.id, phase), + ), + env.DB.prepare( + "SELECT id, queryJson FROM inspections WHERE machineId = ? AND pending = 1 AND expiresAt > ? LIMIT 20", + ).bind(machine.id, now), + ]), + machineRepositories(env, machine), + leaseObservations(env, machine, input.leaseIds), + ]); + const operations = reads + .slice(0, 3) + .flatMap((result) => result.results as unknown as OperationRow[]) + .sort((a, b) => a.createdAt - b.createdAt) + .map((row) => ({ + id: row.id, + commandJson: row.commandJson, + phase: row.phase, + cancelRequested: row.cancelRequested === 1, + createdAt: row.createdAt, + })); + const inspections = (reads[3]!.results as unknown as InspectionRow[]).map( + ({ id, queryJson }) => ({ id, queryJson }), + ); + return { + operations, + inspections, + repositories, + observations, + // The relay tracks job lists for the last 20 repositories in this order. + jobMarkers: await jobMarkers( + env, + repositories + .slice(-20) + .flatMap(({ id, installationId, repositoryId }) => + installationId === undefined ? [] : [{ id, installationId, repositoryId }], + ), + ), + }; +}; diff --git a/apps/cloud/src/test-support.ts b/apps/cloud/src/test-support.ts new file mode 100644 index 00000000..b1b0997d --- /dev/null +++ b/apps/cloud/src/test-support.ts @@ -0,0 +1,217 @@ +// Helpers for tests that run inside workerd (apps/cloud/test and src/**/*.test.ts). Never imported +// by production code. +import { env } from "cloudflare:workers"; +import { SELF } from "cloudflare:test"; +import { exportJWK, generateKeyPair, SignJWT } from "jose"; +import { vi } from "vitest"; +import type { EnvironmentSummary } from "../../../packages/protocol/src/cloud.js"; +import type { RepositoryAccess } from "../../../packages/protocol/src/repository-access.js"; +import { newId, sha256Hex } from "./ids.js"; + +export { env, SELF }; + +export const now = () => Date.now(); + +export function secret() { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + return btoa(String.fromCharCode(...bytes)) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/, ""); +} + +export const readyLinux = (id = "linux"): EnvironmentSummary => ({ + id, + name: "Ubuntu", + os: "linux", + cpu: 4, + memoryMiB: 8192, + state: "ready", +}); + +export async function seedMachine( + input: { + owner?: string; + localId?: string; + environments?: EnvironmentSummary[]; + paused?: boolean; + } = {}, +) { + const id = newId(); + const credential = secret(); + const owner = input.owner ?? "https://clerk.invalid|user_1"; + await env.DB.batch([ + env.DB.prepare( + "INSERT INTO machines (id, owner, localId, name, createdAt, environmentsJson, paused) VALUES (?, ?, ?, ?, ?, ?, ?)", + ).bind( + id, + owner, + input.localId ?? `host-${id.slice(0, 6)}`, + "Test Mac", + now(), + input.environments ? JSON.stringify(input.environments) : null, + input.paused === undefined ? null : input.paused ? 1 : 0, + ), + env.DB.prepare( + "INSERT INTO machineCredentials (machineId, digest, createdAt) VALUES (?, ?, ?)", + ).bind(id, await sha256Hex(credential), now()), + ]); + return { id, owner, bearer: `${id}.${credential}` }; +} + +export async function seedApp(webhookSecret = "webhook-secret") { + const pair = (await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + )) as CryptoKeyPair; + const privateKey = pair.privateKey; + const pkcs8 = new Uint8Array((await crypto.subtle.exportKey("pkcs8", privateKey)) as ArrayBuffer); + const pem = `-----BEGIN PRIVATE KEY-----\n${btoa(String.fromCharCode(...pkcs8)) + .match(/.{1,64}/g) + ?.join("\n")}\n-----END PRIVATE KEY-----\n`; + await env.DB.prepare( + "INSERT INTO githubApps (singleton, appId, slug, ownerId, clientId, privateKey, clientSecret, webhookSecret) VALUES (1, 1, 'vectis-test', 1, 'Iv1.test', ?, 'client-secret', ?)", + ) + .bind(pem, webhookSecret) + .run(); + return { webhookSecret }; +} + +export async function seedAccount( + owner: string, + input: { + githubId?: number; + login?: string; + installations?: { id: number; accountId: number; login: string }[]; + } = {}, +) { + const id = newId(); + const githubId = input.githubId ?? 100; + const login = input.login ?? "octo"; + await env.DB.prepare( + "INSERT INTO githubAccounts (id, owner, githubId, login, installationsJson, verifiedAt) VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind( + id, + owner, + githubId, + login, + JSON.stringify(input.installations ?? [{ id: 10, accountId: githubId, login }]), + now(), + ) + .run(); + return { id, githubId, login }; +} + +export async function seedBinding(input: { + owner: string; + accountId: string; + machineId: string; + repositoryId?: number; + repositoryName?: string; + repositoryOwner?: string; + installationId?: number; + environmentId?: string; + enabled?: boolean; + automatic?: boolean; + infraRerun?: boolean; + capacityMode?: "demand" | "scale_set"; + access?: RepositoryAccess; + cacheVolume?: { enabled: boolean; sizeGiB: number }; +}) { + const id = newId(); + await env.DB.prepare( + `INSERT INTO repositoryBindings (id, owner, accountId, machineId, repositoryId, repositoryName, + repositoryOwner, installationId, environmentId, enabled, infraRerun, automatic, capacityMode, + accessJson, cacheVolumeJson, verifiedAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + id, + input.owner, + input.accountId, + input.machineId, + input.repositoryId ?? 1000, + input.repositoryName ?? "repo", + input.repositoryOwner ?? null, + input.installationId ?? 10, + input.environmentId ?? "linux", + input.enabled === false ? 0 : 1, + input.infraRerun ? 1 : 0, + input.automatic ? 1 : 0, + input.capacityMode ?? "demand", + input.access ? JSON.stringify(input.access) : null, + input.cacheVolume ? JSON.stringify(input.cacheVolume) : null, + now(), + ) + .run(); + return { id }; +} + +// POSTs a machine API route with the machine's bearer. +export function machineRequest(path: string, bearer: string, body: unknown = {}) { + return SELF.fetch(`https://cloud.test${path}`, { + method: "POST", + headers: { Authorization: `Bearer ${bearer}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +// Signs Clerk-shaped session tokens for env.CLERK_ISSUER and serves the matching JWKS through the +// fetch stub, so human routes can be tested without Clerk. +let signer: Promise<{ key: CryptoKey; jwks: unknown }> | undefined; +async function clerkKeys() { + signer ??= (async () => { + const { privateKey, publicKey } = await generateKeyPair("RS256", { extractable: true }); + const jwk = { ...(await exportJWK(publicKey)), kid: "test", alg: "RS256", use: "sig" }; + return { key: privateKey, jwks: { keys: [jwk] } }; + })(); + return signer; +} +export async function humanToken(subject = "user_1", claims: Record = {}) { + const { key } = await clerkKeys(); + return new SignJWT({ azp: env.VECTIS_WEB_URL, ...claims }) + .setProtectedHeader({ alg: "RS256", kid: "test" }) + .setIssuer(env.CLERK_ISSUER) + .setSubject(subject) + .setIssuedAt() + .setExpirationTime("5m") + .sign(key); +} +export const ownerOf = (subject = "user_1") => `${env.CLERK_ISSUER}|${subject}`; + +export async function humanRequest( + path: string, + body?: unknown, + subject = "user_1", +): Promise { + return SELF.fetch(`https://cloud.test${path}`, { + method: body === undefined ? "GET" : "POST", + headers: { + Authorization: `Bearer ${await humanToken(subject)}`, + Origin: env.VECTIS_WEB_URL, + "Content-Type": "application/json", + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} + +// Replaces global fetch for the test. Clerk JWKS requests are answered automatically; everything +// else goes to `handler`, and unhandled requests fail the call loudly. +export function stubFetch( + handler: (request: Request) => Response | Promise | undefined = () => undefined, +) { + return vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const request = new Request(input, init); + if (request.url === `${env.CLERK_ISSUER}/.well-known/jwks.json`) + return Response.json((await clerkKeys()).jwks); + const response = await handler(request); + if (!response) throw new Error(`Unexpected fetch in test: ${request.method} ${request.url}`); + return response; + }); +} diff --git a/apps/cloud/test/account.test.ts b/apps/cloud/test/account.test.ts new file mode 100644 index 00000000..d2864a55 --- /dev/null +++ b/apps/cloud/test/account.test.ts @@ -0,0 +1,60 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { + env, + humanRequest, + ownerOf, + seedMachine, + seedAccount, + seedBinding, + stubFetch, +} from "../src/test-support.js"; +import { verifyClerkWebhook } from "../src/account.js"; +afterEach(() => vi.restoreAllMocks()); +it("purges only the signed-in owner and cascades repository state", async () => { + stubFetch(); + const own = await seedMachine({ owner: ownerOf() }); + const foreign = await seedMachine({ owner: ownerOf("other") }); + const account = await seedAccount(own.owner); + await seedBinding({ owner: own.owner, accountId: account.id, machineId: own.id }); + const overview = await humanRequest("/account/overview"); + expect(overview.status).toBe(200); + expect(await overview.json()).toMatchObject({ + machines: [{ id: own.id }], + github: { accounts: [{ id: account.id }] }, + }); + expect((await humanRequest("/account/remove", {})).status).toBe(200); + expect((await env.DB.prepare("SELECT id FROM machines").all()).results).toEqual([ + { id: foreign.id }, + ]); + expect(await env.DB.prepare("SELECT COUNT(*) AS n FROM repositoryBindings").first("n")).toBe(0); +}); +it("verifies the exact Svix body and rejects replay outside the time window", async () => { + const secret = "whsec_" + btoa("test-secret"); + const now = Date.now(), + timestamp = String(Math.floor(now / 1000)); + const body = new TextEncoder().encode('{"type":"user.deleted","data":{"id":"user_1"}}'); + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode("test-secret"), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = new Uint8Array( + await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(`message.${timestamp}.${new TextDecoder().decode(body)}`), + ), + ); + const headers = new Headers({ + "svix-id": "message", + "svix-timestamp": timestamp, + "svix-signature": `v1,${btoa(String.fromCharCode(...signature))}`, + }); + expect(await verifyClerkWebhook(secret, headers, body, now)).toBe(true); + expect(await verifyClerkWebhook(secret, headers, body, now + 301000)).toBe(false); + expect(await verifyClerkWebhook(secret, headers, new TextEncoder().encode("{}"), now)).toBe( + false, + ); +}); diff --git a/apps/cloud/test/controllers.test.ts b/apps/cloud/test/controllers.test.ts new file mode 100644 index 00000000..23792843 --- /dev/null +++ b/apps/cloud/test/controllers.test.ts @@ -0,0 +1,28 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { humanRequest, SELF, secret, stubFetch } from "../src/test-support.js"; +import { sha256Hex } from "../src/ids.js"; +afterEach(() => vi.restoreAllMocks()); +it("approves idempotently and immediately revokes controller authority", async () => { + stubFetch(); + const credential = secret(); + const input = { + requestDigest: "a".repeat(64), + credentialDigest: await sha256Hex(credential), + name: "CLI", + expiresAt: Date.now() + 60000, + }; + const response = await humanRequest("/controllers/approve", input); + const body = (await response.json()) as { controllerId: string }; + expect(response.status).toBe(200); + expect(await (await humanRequest("/controllers/approve", input)).json()).toEqual(body); + const call = (input: unknown) => + SELF.fetch("https://cloud.test/controller/request", { + method: "POST", + headers: { Authorization: `Bearer ${body.controllerId}.${credential}` }, + body: JSON.stringify(input), + }); + expect(await (await call({ type: "machines.list" })).json()).toEqual([]); + expect((await call({ type: "machines.list", extra: true })).status).toBe(409); + expect(await (await call({ type: "controller.revoke" })).json()).toEqual({ revoked: true }); + expect((await call({ type: "machines.list" })).status).toBe(401); +}); diff --git a/apps/cloud/test/demand.test.ts b/apps/cloud/test/demand.test.ts new file mode 100644 index 00000000..b3332fb3 --- /dev/null +++ b/apps/cloud/test/demand.test.ts @@ -0,0 +1,265 @@ +import { beforeEach, expect, it } from "vitest"; +import { + env, + seedMachine, + seedAccount, + seedBinding, + readyLinux, + SELF, +} from "../src/test-support.js"; +import { + bindingRecord, + type BindingRow, + type DemandRow, + type JobRow, + type OperationRow, +} from "../src/db.js"; +import { evaluateDemand, sweepDemand } from "../src/runners/demand.js"; +import { planDemand } from "../src/runners/plan-demand.js"; + +beforeEach(async () => { + await env.DB.batch([ + env.DB.prepare("DELETE FROM machines"), + env.DB.prepare("DELETE FROM githubAccounts"), + env.DB.prepare("DELETE FROM githubJobs"), + ]); +}); + +async function fixture() { + const machine = await seedMachine({ paused: false, environments: [readyLinux()] }); + const account = await seedAccount(machine.owner); + const binding = await seedBinding({ + owner: machine.owner, + machineId: machine.id, + accountId: account.id, + automatic: true, + }); + await env.DB.prepare(`INSERT INTO githubJobs (installationId, repositoryId, jobId, runId, name, status, labelsJson, updatedAt) + VALUES (10, 1000, 1, 2, 'Build', 'queued', '["vectis-linux"]', 1)`).run(); + const row = await env.DB.prepare("SELECT * FROM repositoryBindings WHERE id = ?") + .bind(binding.id) + .first(); + const job = await env.DB.prepare("SELECT * FROM githubJobs WHERE jobId = 1").first(); + return { machine, account, binding: bindingRecord(row!), job: job! }; +} + +it("atomically reserves a queued job across two competing machines", async () => { + const { machine, account } = await fixture(); + const second = await seedMachine({ + owner: machine.owner, + paused: false, + environments: [readyLinux()], + }); + await seedBinding({ + owner: machine.owner, + machineId: second.id, + accountId: account.id, + automatic: true, + }); + const capacity = { runnerSlots: 1, statusAt: Date.now() }; + await Promise.all([ + evaluateDemand(env, machine.id, capacity), + evaluateDemand(env, second.id, capacity), + ]); + expect(await env.DB.prepare("SELECT COUNT(*) AS n FROM operations").first("n")).toBe(1); + expect(await env.DB.prepare("SELECT COUNT(*) AS n FROM runnerDemands").first("n")).toBe(1); + await evaluateDemand(env, machine.id, capacity); + expect(await env.DB.prepare("SELECT COUNT(*) AS n FROM operations").first("n")).toBe(1); +}); + +it("coalesces concurrent hub evaluations without double claiming", async () => { + const { machine } = await fixture(); + const response = await SELF.fetch("https://cloud.test/machine/socket", { + headers: { + Upgrade: "websocket", + "Sec-WebSocket-Protocol": `vectis.v1, auth.${btoa(machine.bearer).replace(/=+$/, "")}`, + }, + }); + const socket = response.webSocket!; + socket.accept(); + const ack = new Promise((resolve) => + socket.addEventListener("message", () => resolve(), { once: true }), + ); + socket.send( + JSON.stringify({ + t: "status", + seq: 1, + paused: false, + runnerIdle: true, + runnerSlots: 2, + environments: [readyLinux()], + }), + ); + await ack; + await Promise.all([ + env.HUB.getByName(machine.id).demand(), + env.HUB.getByName(machine.id).demand(), + ]); + expect(await env.DB.prepare("SELECT COUNT(*) AS n FROM operations").first("n")).toBe(1); + socket.close(); +}); + +it("has no legacy idle fallback and respects environment capacity and reservations", async () => { + const { machine, binding, job } = await fixture(); + const queues = [{ binding, environment: readyLinux(), jobs: [job, { ...job, jobId: 2 }] }]; + const base = { owner: machine.owner, queues, demands: [], operations: [], now: 1000000 }; + expect(planDemand({ ...base, capacity: { statusAt: 100 } })).toHaveLength(0); + expect( + planDemand({ + ...base, + capacity: { + statusAt: 100, + runnerSlots: 2, + environmentSlots: [{ environmentId: "linux", available: 1 }], + }, + }), + ).toHaveLength(1); + const operation: OperationRow = { + id: "op", + owner: machine.owner, + machineId: machine.id, + key: "op", + commandJson: JSON.stringify({ type: "runner.run", bindingId: binding.id, automatic: true }), + phase: "claimed", + createdAt: 1, + updatedAt: 99, + resultJson: null, + cancelRequested: 0, + }; + expect( + planDemand({ ...base, operations: [operation], capacity: { statusAt: 100, runnerSlots: 1 } }), + ).toHaveLength(0); + expect( + planDemand({ + ...base, + operations: [ + { + ...operation, + commandJson: JSON.stringify({ + type: "runner.run", + bindingId: "disabled-binding", + automatic: true, + }), + reservedEnvironmentId: "linux", + }, + ], + capacity: { + statusAt: 100, + runnerSlots: 2, + environmentSlots: [{ environmentId: "linux", available: 1 }], + }, + }), + ).toHaveLength(0); + expect( + planDemand({ + ...base, + operations: [{ ...operation, phase: "running" }], + capacity: { statusAt: 100, runnerSlots: 1 }, + }), + ).toHaveLength(1); +}); + +it("preserves retry delays, attempt limits, failure limits, cache errors and cancellation rules", async () => { + const { machine, binding, job } = await fixture(); + const now = 1000000; + const demand: DemandRow = { + installationId: 10, + repositoryId: 1000, + jobId: 1, + owner: machine.owner, + bindingId: binding.id, + operationId: "op", + attempt: 1, + failures: 0, + updatedAt: now - 600001, + }; + const operation: OperationRow = { + id: "op", + owner: machine.owner, + machineId: machine.id, + key: "op", + commandJson: "{}", + phase: "failed", + createdAt: 0, + updatedAt: now - 60000, + resultJson: null, + cancelRequested: 0, + }; + const plan = (previous: OperationRow | undefined, changes: Partial = {}) => + planDemand({ + owner: machine.owner, + capacity: { statusAt: now, runnerSlots: 1 }, + queues: [{ binding, environment: readyLinux(), jobs: [job] }], + demands: [ + { demand: { ...demand, ...changes }, ...(previous ? { operation: previous } : {}) }, + ], + operations: [], + now, + }); + expect(plan(operation)[0]).toMatchObject({ attempt: 2, failures: 1 }); + expect(plan({ ...operation, updatedAt: now - 59999 })).toHaveLength(0); + expect(plan({ ...operation, phase: "action_required", updatedAt: now - 599999 })).toHaveLength(0); + expect(plan({ ...operation, phase: "action_required", updatedAt: now - 600000 })).toHaveLength(1); + expect(plan(operation, { attempt: 10 })).toHaveLength(0); + expect(plan(operation, { failures: 2 })).toHaveLength(0); + expect( + plan({ ...operation, resultJson: '{"code":"cache_volume_requires_guest_sudo"}' }), + ).toHaveLength(0); + expect(plan(undefined, { updatedAt: now - 599999 })).toHaveLength(0); + expect(plan(undefined)).toHaveLength(1); + expect(plan({ ...operation, phase: "cancelled", createdAt: 2 })).toHaveLength(0); + expect( + plan({ + ...operation, + phase: "cancelled", + createdAt: 2, + resultJson: '{"code":"machine_offline"}', + })[0], + ).toMatchObject({ failures: 1 }); + expect( + plan({ + ...operation, + phase: "cancelled", + createdAt: 2, + cancelRequested: 1, + resultJson: '{"code":"machine_offline"}', + }), + ).toHaveLength(0); +}); + +it("sweeps an empty queued-job index without creating work", async () => { + await sweepDemand(env, Date.now()); + expect(await env.DB.prepare("SELECT COUNT(*) AS n FROM operations").first("n")).toBe(0); +}); + +it("leaves a queued job claimable while the machine holds 100 active operations", async () => { + const { machine } = await fixture(); + const capacity = { runnerSlots: 1, statusAt: Date.now() }; + const now = Date.now(); + await env.DB.batch( + Array.from({ length: 100 }, (_, index) => + env.DB.prepare( + "INSERT INTO operations (id, owner, machineId, key, commandJson, phase, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, 'claimed', ?, ?)", + ).bind( + `busy${index}`, + machine.owner, + machine.id, + `busy-${index}`, + JSON.stringify({ type: "machine.pause", paused: true }), + now, + now, + ), + ), + ); + await evaluateDemand(env, machine.id, capacity); + expect(await env.DB.prepare("SELECT COUNT(*) AS n FROM runnerDemands").first("n")).toBe(0); + expect(await env.DB.prepare("SELECT COUNT(*) AS n FROM operations").first("n")).toBe(100); + await env.DB.prepare("DELETE FROM operations WHERE id = 'busy0'").run(); + await evaluateDemand(env, machine.id, capacity); + expect(await env.DB.prepare("SELECT COUNT(*) AS n FROM runnerDemands").first("n")).toBe(1); + expect( + await env.DB.prepare( + "SELECT COUNT(*) AS n FROM operations WHERE commandJson LIKE '%runner.run%'", + ).first("n"), + ).toBe(1); +}); diff --git a/apps/cloud/test/env.d.ts b/apps/cloud/test/env.d.ts new file mode 100644 index 00000000..dc677909 --- /dev/null +++ b/apps/cloud/test/env.d.ts @@ -0,0 +1,9 @@ +import type { Env as CloudEnv } from "../src/env.js"; + +declare global { + namespace Cloudflare { + interface Env extends CloudEnv { + TEST_MIGRATIONS: import("cloudflare:test").D1Migration[]; + } + } +} diff --git a/apps/cloud/test/github-identity.test.ts b/apps/cloud/test/github-identity.test.ts new file mode 100644 index 00000000..9b36e598 --- /dev/null +++ b/apps/cloud/test/github-identity.test.ts @@ -0,0 +1,38 @@ +import { expect, it } from "vitest"; +import { env, seedApp, seedAccount, ownerOf } from "../src/test-support.js"; +import { createLink, claimLink, finishLink, confirmLink } from "../src/github/identity-store.js"; +it("claims OAuth state once, binds confirmation to its browser and preserves global GitHub ownership", async () => { + await seedApp(); + const owner = ownerOf(); + await createLink(env, { owner, digest: "state", verifier: "verifier", browserDigest: "browser" }); + expect(await claimLink(env, { digest: "state", browserDigest: "foreign" })).toBeNull(); + const claim = await claimLink(env, { digest: "state", browserDigest: "browser" }); + expect(claim?.verifier).toBe("verifier"); + expect(await claimLink(env, { digest: "state", browserDigest: "browser" })).toBeNull(); + if (!claim) throw new Error("Missing claim"); + await finishLink(env, { + id: claim.id, + user: { id: 100, login: "octo", installations: [] }, + completionDigest: "completion", + }); + await expect( + confirmLink(env, { + id: claim.id, + owner, + browserDigest: "foreign", + completionDigest: "completion", + }), + ).rejects.toMatchObject({ code: "github_link_unavailable" }); + await seedAccount("other", { githubId: 100 }); + await expect( + confirmLink(env, { + id: claim.id, + owner, + browserDigest: "browser", + completionDigest: "completion", + }), + ).rejects.toMatchObject({ code: "github_account_already_linked" }); + expect( + await env.DB.prepare("SELECT owner FROM githubAccounts WHERE githubId = 100").first("owner"), + ).toBe("other"); +}); diff --git a/apps/cloud/test/hub.test.ts b/apps/cloud/test/hub.test.ts new file mode 100644 index 00000000..cf0e5d71 --- /dev/null +++ b/apps/cloud/test/hub.test.ts @@ -0,0 +1,105 @@ +import { expect, it } from "vitest"; +import { env, SELF, seedMachine, readyLinux } from "../src/test-support.js"; +import { machinePresence } from "../src/notify.js"; +import { purgeMachine } from "../src/machines.js"; + +async function connect(bearer: string) { + const encoded = btoa(bearer).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, ""); + const response = await SELF.fetch("https://cloud.test/machine/socket", { + headers: { Upgrade: "websocket", "Sec-WebSocket-Protocol": `vectis.v1, auth.${encoded}` }, + }); + expect(response.status).toBe(101); + expect(response.headers.get("Sec-WebSocket-Protocol")).toBe("vectis.v1"); + const socket = response.webSocket!; + socket.accept(); + return socket; +} +function message(socket: WebSocket) { + return new Promise((resolve) => + socket.addEventListener("message", (event) => resolve(String(event.data)), { once: true }), + ); +} +function closed(socket: WebSocket) { + return new Promise((resolve) => + socket.addEventListener("close", (event) => resolve(event.code), { once: true }), + ); +} + +it("echoes the protocol, automatically answers pings and reports presence", async () => { + const machine = await seedMachine(); + expect(await machinePresence(env, machine.id)).toMatchObject({ online: false, lastSeenAt: null }); + const socket = await connect(machine.bearer); + const pong = message(socket); + socket.send("ping"); + expect(await pong).toBe("pong"); + expect(await machinePresence(env, machine.id)).toMatchObject({ online: true }); + await env.HUB.getByName(machine.id).disconnect(4000); + expect((await machinePresence(env, machine.id)).online).toBe(false); + socket.close(); +}); + +it("replaces connections and closes the current socket when purged", async () => { + const machine = await seedMachine(); + const first = await connect(machine.bearer); + const replaced = closed(first); + const second = await connect(machine.bearer); + expect(await replaced).toBe(4002); + const removed = closed(second); + await purgeMachine(env, machine.id); + expect(await removed).toBe(4001); + expect( + await env.DB.prepare("SELECT id FROM machines WHERE id = ?").bind(machine.id).first(), + ).toBeNull(); +}); + +it("acknowledges valid status, rejects duplicates and writes only changed inventory", async () => { + const machine = await seedMachine(); + const socket = await connect(machine.bearer); + await env.DB.exec( + "CREATE TABLE statusWrites (id INTEGER); CREATE TRIGGER count_status_writes AFTER UPDATE ON machines BEGIN INSERT INTO statusWrites VALUES (1); END;", + ); + const pong = message(socket); + socket.send("ping"); + expect(await pong).toBe("pong"); + expect(await env.DB.prepare("SELECT COUNT(*) AS n FROM statusWrites").first("n")).toBe(0); + const status = { + t: "status", + seq: 1, + paused: false, + runnerIdle: true, + runnerSlots: 1, + environments: [readyLinux()], + }; + let received = message(socket); + socket.send(JSON.stringify(status)); + expect(JSON.parse(await received)).toEqual({ t: "status.ack", seq: 1 }); + received = message(socket); + socket.send(JSON.stringify({ ...status, seq: 2 })); + expect(JSON.parse(await received)).toEqual({ t: "status.ack", seq: 2 }); + expect(await env.DB.prepare("SELECT COUNT(*) AS n FROM statusWrites").first("n")).toBe(1); + received = message(socket); + socket.send(JSON.stringify({ ...status, seq: 3, environments: [readyLinux(), readyLinux()] })); + expect(JSON.parse(await received)).toEqual({ + t: "status.rejected", + seq: 3, + code: "invalid_environment_inventory", + }); + received = message(socket); + socket.send(JSON.stringify({ ...status, seq: 4, runnerSlots: -1 })); + expect(JSON.parse(await received)).toEqual({ + t: "status.rejected", + seq: 4, + code: "invalid_runner_capacity", + }); + received = message(socket); + socket.send("{"); + expect(JSON.parse(await received)).toMatchObject({ + t: "status.rejected", + code: "invalid_status", + }); + expect(await machinePresence(env, machine.id)).toMatchObject({ + runnerIdle: true, + runnerSlots: 1, + }); + socket.close(); +}); diff --git a/apps/cloud/test/inspections.test.ts b/apps/cloud/test/inspections.test.ts new file mode 100644 index 00000000..c34ef6f5 --- /dev/null +++ b/apps/cloud/test/inspections.test.ts @@ -0,0 +1,59 @@ +import { expect, it } from "vitest"; +import { env, SELF, seedMachine, machineRequest } from "../src/test-support.js"; +import { getInspection, submitInspection } from "../src/inspections.js"; + +it("requires an online owned machine, deduplicates queries and guards responses and expiry", async () => { + const machine = await seedMachine(); + const query = { name: "status" } as const; + await expect(submitInspection(env, machine.owner, machine.id, "q", query)).rejects.toMatchObject({ + code: "machine_offline", + }); + const response = await SELF.fetch("https://cloud.test/machine/socket", { + headers: { + Upgrade: "websocket", + "Sec-WebSocket-Protocol": `vectis.v1, auth.${btoa(machine.bearer).replace(/=+$/, "")}`, + }, + }); + const socket = response.webSocket!; + socket.accept(); + const ids = await Promise.all([ + submitInspection(env, machine.owner, machine.id, "q", query), + submitInspection(env, machine.owner, machine.id, "q", query), + ]); + expect(ids[0]).toEqual(ids[1]); + const id = ids[0]!.queryId; + await expect(getInspection(env, "other", id)).rejects.toMatchObject({ code: "query_expired" }); + expect(await getInspection(env, machine.owner, id)).toEqual({ + pending: true, + responseJson: null, + }); + expect( + ( + await machineRequest("/machine/inspections/respond", machine.bearer, { + id, + responseJson: "{}", + }) + ).status, + ).toBe(200); + expect( + ( + await machineRequest("/machine/inspections/respond", machine.bearer, { + id, + responseJson: "{}", + }) + ).status, + ).toBe(200); + expect( + await ( + await machineRequest("/machine/inspections/respond", machine.bearer, { + id, + responseJson: "different", + }) + ).json(), + ).toMatchObject({ code: "query_response_conflict" }); + await env.DB.prepare("UPDATE inspections SET expiresAt = 0 WHERE id = ?").bind(id).run(); + await expect(getInspection(env, machine.owner, id)).rejects.toMatchObject({ + code: "query_expired", + }); + socket.close(); +}); diff --git a/apps/cloud/test/job-store.test.ts b/apps/cloud/test/job-store.test.ts new file mode 100644 index 00000000..093129a9 --- /dev/null +++ b/apps/cloud/test/job-store.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, expect, it } from "vitest"; +import { env } from "../src/test-support.js"; +import { mergeJob, storeJob } from "../src/github/job-store.js"; +import type { GitHubJob } from "../../../packages/github/src/job.js"; + +const job: GitHubJob & { installationId: number; repositoryId: number; updatedAt: number } = { + installationId: 10, + repositoryId: 100, + updatedAt: 100, + id: 1, + run_id: 2, + run_attempt: 1, + name: "build", + status: "queued", + conclusion: null, + labels: ["self-hosted"], +}; +beforeEach(async () => { + await env.DB.batch([ + env.DB.prepare("DELETE FROM githubDeliveries"), + env.DB.prepare("DELETE FROM githubJobs"), + ]); +}); +it("keeps status monotonic and fills metadata on stale reports", () => { + const completed = mergeJob(null, { + ...job, + status: "completed", + conclusion: "success", + actor_id: 10, + }); + const stale = mergeJob(completed, { ...job, runner_id: 4, html_url: "https://github.test/job" }); + expect(stale).toMatchObject({ + status: "completed", + conclusion: "success", + actorId: 10, + runnerId: 4, + htmlUrl: "https://github.test/job", + }); + expect(stale.updatedAt).toBeGreaterThan(completed.updatedAt); + expect(mergeJob(stale, { ...job, status: "completed", conclusion: "success" })).toBe(stale); +}); +it("clears actors across attempts and treats requested as waiting", () => { + const previous = mergeJob(null, { ...job, actor_id: 10 }); + expect(mergeJob(previous, { ...job, run_attempt: 2, status: "requested" })).toMatchObject({ + actorId: null, + runAttempt: 2, + status: "waiting", + }); +}); +it("retries competing inserts without regressing completion", async () => { + await Promise.all([ + storeJob(env, job, 10, 100), + storeJob(env, { ...job, status: "completed", conclusion: "success" }, 10, 100), + ]); + expect(await env.DB.prepare("SELECT status FROM githubJobs").first()).toEqual({ + status: "completed", + }); +}); +it("commits a delivery and job together and ignores changed duplicate payloads", async () => { + const delivery = { + deliveryId: "same", + event: "workflow_job", + action: "queued", + repositoryId: 100, + installationId: 10, + jobId: 1, + receivedAt: Date.now(), + }; + const results = await Promise.all([ + storeJob(env, job, 10, 100, delivery), + storeJob(env, job, 10, 100, delivery), + ]); + expect(results.filter((result) => result.duplicate)).toHaveLength(1); + const before = await env.DB.prepare("SELECT * FROM githubJobs").first(); + expect((await storeJob(env, { ...job, status: "completed" }, 10, 100, delivery)).duplicate).toBe( + true, + ); + expect(await env.DB.prepare("SELECT * FROM githubJobs").first()).toEqual(before); +}); diff --git a/apps/cloud/test/jobs.test.ts b/apps/cloud/test/jobs.test.ts new file mode 100644 index 00000000..b5f8f4d0 --- /dev/null +++ b/apps/cloud/test/jobs.test.ts @@ -0,0 +1,276 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { + env, + seedMachine, + seedAccount, + seedBinding, + seedApp, + machineRequest, + stubFetch, +} from "../src/test-support.js"; +import { storeJob } from "../src/github/job-store.js"; +import { jobMarkers, leaseObservations } from "../src/github/jobs.js"; +import { bindingRecord, machineRecord, type BindingRow, type MachineRow } from "../src/db.js"; + +beforeEach(async () => { + await env.DB.batch([ + env.DB.prepare("DELETE FROM machines"), + env.DB.prepare("DELETE FROM githubAccounts"), + env.DB.prepare("DELETE FROM githubApps"), + env.DB.prepare("DELETE FROM githubJobs"), + ]); +}); +afterEach(() => vi.restoreAllMocks()); +async function fixture() { + const machine = await seedMachine(); + const account = await seedAccount(machine.owner); + const binding = await seedBinding({ + owner: machine.owner, + machineId: machine.id, + accountId: account.id, + }); + return { machine, account, binding }; +} +const job = { + id: 4, + run_id: 5, + name: "build", + status: "in_progress" as const, + conclusion: null, + labels: ["vectis-linux"], + runner_id: 6, + runner_name: "vectis-lease", +}; + +it("lists authorized jobs and rejects another machine or a disabled binding", async () => { + const { machine, binding } = await fixture(); + await storeJob(env, job, 10, 1000); + const response = await machineRequest("/machine/jobs/list", machine.bearer, { + bindingId: binding.id, + }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject([{ jobId: 4, status: "in_progress" }]); + const foreign = await seedMachine({ owner: "other" }); + expect( + (await machineRequest("/machine/jobs/list", foreign.bearer, { bindingId: binding.id })).status, + ).toBe(403); + await env.DB.prepare("UPDATE repositoryBindings SET enabled = 0 WHERE id = ?") + .bind(binding.id) + .run(); + expect( + (await machineRequest("/machine/jobs/list", machine.bearer, { bindingId: binding.id })).status, + ).toBe(403); +}); +it("returns null markers without jobs and uses newest job update for each repository", async () => { + const { binding } = await fixture(); + const row = await env.DB.prepare("SELECT * FROM repositoryBindings WHERE id = ?") + .bind(binding.id) + .first(); + if (!row) throw new Error("Missing binding"); + const bindings = [bindingRecord(row)]; + expect(await jobMarkers(env, bindings)).toEqual({ [binding.id]: null }); + const result = await storeJob(env, job, 10, 1000); + expect(await jobMarkers(env, bindings)).toEqual({ [binding.id]: result.row.updatedAt }); +}); +it("only reports unique matching jobs for the machine's leases", async () => { + const { machine, binding } = await fixture(); + const row = await env.DB.prepare("SELECT * FROM machines WHERE id = ?") + .bind(machine.id) + .first(); + if (!row) throw new Error("Missing machine"); + const target = machineRecord(row); + await env.DB.prepare(`INSERT INTO runnerLeases (id,owner,machineId,bindingId,environmentId,os,key,phase,createdAt,updatedAt,automatic,runnerId) + VALUES ('lease',?,?,?,'linux','linux','key','ready',1,1,0,6)`) + .bind(machine.owner, machine.id, binding.id) + .run(); + await storeJob(env, job, 10, 1000); + expect(await leaseObservations(env, target, ["lease", "lease"])).toMatchObject([ + { leaseId: "lease", job: { jobId: 4 } }, + ]); + const other = await seedMachine({ owner: "other" }); + const otherRow = await env.DB.prepare("SELECT * FROM machines WHERE id = ?") + .bind(other.id) + .first(); + if (!otherRow) throw new Error("Missing machine"); + expect(await leaseObservations(env, machineRecord(otherRow), ["lease"])).toEqual([]); + await storeJob(env, { ...job, id: 7, runner_id: 99 }, 10, 1000); + expect(await leaseObservations(env, target, ["lease"])).toEqual([]); + await expect( + leaseObservations( + env, + target, + Array.from({ length: 21 }, () => "lease"), + ), + ).rejects.toMatchObject({ code: "invalid_lease_ids" }); +}); +it("refreshes missing jobs through repository authority and reports an unknown job", async () => { + const { machine, binding } = await fixture(); + await seedApp(); + stubFetch((request) => { + const url = request.url; + if (url.endsWith("/installation")) + return Response.json({ + id: 10, + app_id: 1, + account: { id: 100, type: "User" }, + suspended_at: null, + }); + if (url.endsWith("/access_tokens")) return Response.json({ token: "test-read-only" }); + if (url.endsWith("/actions/jobs/4")) return Response.json(job); + if (url.endsWith("/actions/jobs/9")) return new Response(null, { status: 404 }); + if (url.endsWith("/repos/octo/repo")) + return Response.json({ id: 1000, private: true, owner: { id: 100 } }); + return undefined; + }); + const response = await machineRequest("/machine/jobs/refresh", machine.bearer, { + bindingId: binding.id, + jobId: 4, + }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ jobId: 4, status: "in_progress" }); + const missing = await machineRequest("/machine/jobs/refresh", machine.bearer, { + bindingId: binding.id, + jobId: 9, + }); + expect(await missing.json()).toMatchObject({ code: "job_not_found" }); +}); + +it("scans pending runs once and enriches jobs required by restrictive bindings", async () => { + const { machine, binding } = await fixture(); + await seedApp(); + await env.DB.prepare("UPDATE repositoryBindings SET accessJson = ? WHERE id = ?") + .bind(JSON.stringify({ actors: "any", approval: "required" }), binding.id) + .run(); + await storeJob(env, { ...job, id: 8, run_id: 9 }, 10, 1000); + const { GitHubAppClient } = await import("../../../packages/github/src/app-client.js"); + vi.spyOn(GitHubAppClient.prototype, "repositoryToken").mockResolvedValue({ + token: "token", + path: "/repos/octo/repo", + installationId: 10, + repositoryId: 1000, + }); + const request = vi + .spyOn(GitHubAppClient.prototype, "request") + .mockImplementation(async (_token, path) => { + if (path.includes("/actions/runs?")) return { total_count: 0, workflow_runs: [] }; + if (path.includes("/actions/runs/9/jobs?")) + return { total_count: 1, jobs: [{ ...job, id: 8, run_id: 9 }] }; + if (path.endsWith("/actions/runs/9")) + return { + id: 9, + run_attempt: 1, + repository: { id: 1000 }, + actor: { id: 100 }, + triggering_actor: { id: 101 }, + }; + throw new Error(`Unexpected request ${path}`); + }); + const result = await machineRequest("/machine/jobs/scan", machine.bearer, { + bindingId: binding.id, + }); + expect(result.status).toBe(200); + expect(await result.json()).toEqual({ runs: 1, jobs: 1, complete: true }); + expect(request).toHaveBeenCalledTimes(4); + expect(await env.DB.prepare("SELECT actorId, triggeringActorId FROM githubJobs").first()).toEqual( + { actorId: 100, triggeringActorId: 101 }, + ); +}); + +it("does not persist refreshed jobs after binding revocation", async () => { + const { machine, binding } = await fixture(); + await seedApp(); + const { GitHubAppClient } = await import("../../../packages/github/src/app-client.js"); + vi.spyOn(GitHubAppClient.prototype, "repositoryToken").mockResolvedValue({ + token: "token", + path: "/repos/octo/repo", + installationId: 10, + repositoryId: 1000, + }); + vi.spyOn(GitHubAppClient.prototype, "request").mockImplementation(async () => { + await env.DB.prepare("UPDATE repositoryBindings SET enabled = 0 WHERE id = ?") + .bind(binding.id) + .run(); + return job; + }); + expect( + ( + await machineRequest("/machine/jobs/refresh", machine.bearer, { + bindingId: binding.id, + jobId: 4, + }) + ).status, + ).toBe(403); + expect(await env.DB.prepare("SELECT * FROM githubJobs").first()).toBeNull(); +}); + +it("keeps unavailable running logs pending and rejects logs from another repository", async () => { + const { machine, binding } = await fixture(); + await seedApp(); + const { GitHubAppClient, GitHubApiError } = + await import("../../../packages/github/src/app-client.js"); + vi.spyOn(GitHubAppClient.prototype, "repositoryToken").mockResolvedValue({ + token: "token", + path: "/repos/octo/repo", + installationId: 10, + repositoryId: 1000, + }); + let repositoryId = 1000; + vi.spyOn(GitHubAppClient.prototype, "request").mockImplementation(async (_token, path) => + path.endsWith("/actions/jobs/4") ? job : { id: 5, repository: { id: repositoryId } }, + ); + const download = vi + .spyOn(GitHubAppClient.prototype, "downloadJobLog") + .mockRejectedValue(new GitHubApiError(404)); + const result = await machineRequest("/machine/jobs/logs", machine.bearer, { + bindingId: binding.id, + jobId: 4, + after: 20, + }); + expect(await result.json()).toEqual({ + text: "", + next: 20, + status: "in_progress", + complete: false, + truncated: false, + }); + repositoryId = 2000; + expect( + ( + await machineRequest("/machine/jobs/logs", machine.bearer, { + bindingId: binding.id, + jobId: 4, + }) + ).status, + ).toBe(403); + expect(download).toHaveBeenCalledTimes(1); +}); + +it("rechecks authorization after downloading logs", async () => { + const { machine, binding } = await fixture(); + await seedApp(); + const { GitHubAppClient, GitHubApiError } = + await import("../../../packages/github/src/app-client.js"); + vi.spyOn(GitHubAppClient.prototype, "repositoryToken").mockResolvedValue({ + token: "token", + path: "/repos/octo/repo", + installationId: 10, + repositoryId: 1000, + }); + vi.spyOn(GitHubAppClient.prototype, "request").mockImplementation(async (_token, path) => + path.endsWith("/actions/jobs/4") ? job : { id: 5, repository: { id: 1000 } }, + ); + vi.spyOn(GitHubAppClient.prototype, "downloadJobLog").mockImplementation(async () => { + await env.DB.prepare("UPDATE repositoryBindings SET enabled = 0 WHERE id = ?") + .bind(binding.id) + .run(); + throw new GitHubApiError(404); + }); + expect( + ( + await machineRequest("/machine/jobs/logs", machine.bearer, { + bindingId: binding.id, + jobId: 4, + }) + ).status, + ).toBe(403); +}); diff --git a/apps/cloud/test/leases.test.ts b/apps/cloud/test/leases.test.ts new file mode 100644 index 00000000..47876d83 --- /dev/null +++ b/apps/cloud/test/leases.test.ts @@ -0,0 +1,291 @@ +import worker from "../src/index.js"; +import { createExecutionContext, waitOnExecutionContext } from "cloudflare:test"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { Schema } from "effect"; +import { RunnerGrant } from "../../../packages/protocol/src/runners.js"; +import { + env, + seedMachine, + seedAccount, + seedBinding, + seedApp, + readyLinux, +} from "../src/test-support.js"; +import { sweepLeases } from "../src/runners/leases.js"; +import type { LeaseRow } from "../src/db.js"; +afterEach(() => vi.restoreAllMocks()); +async function fixture() { + const machine = await seedMachine({ environments: [readyLinux()] }); + const account = await seedAccount(machine.owner); + const binding = await seedBinding({ + owner: machine.owner, + accountId: account.id, + machineId: machine.id, + }); + await seedApp(); + let name = ""; + const state = { + lost: false, + busy: false, + wrong: false, + onRegistration: undefined as (() => Promise) | undefined, + }; + const fetch = stubFetch(async (request) => { + const url = new URL(request.url); + const path = url.pathname; + if (path.endsWith("/installation")) + return Response.json({ + id: 10, + app_id: 1, + account: { id: 100, type: "User" }, + suspended_at: null, + }); + if (path.endsWith("/access_tokens")) return Response.json({ token: "test-installation-token" }); + if (path === "/repos/octo/repo") + return Response.json({ id: 1000, private: true, owner: { id: 100 } }); + if (path.endsWith("/fork-pr-workflows-private-repos")) + return Response.json({ + run_workflows_from_fork_pull_requests: false, + require_approval_for_fork_pr_workflows: false, + }); + if (path.endsWith("/generate-jitconfig")) { + const body = (await request.json()) as { name: string }; + name = body.name; + await state.onRegistration?.(); + if (state.lost) throw new Error("Lost response"); + return Response.json({ runner: { id: 501, name }, encoded_jit_config: "c2VjcmV0" }); + } + if (path.endsWith("/actions/runners/501")) + return request.method === "DELETE" + ? state.busy + ? new Response(null, { status: 422 }) + : new Response(null, { status: 204 }) + : Response.json({ + id: 501, + name: state.wrong ? "foreign" : name, + busy: false, + status: "online", + }); + if (path.endsWith("/actions/runners")) + return Response.json({ total_count: 1, runners: [{ id: 501, name }] }); + throw new Error(`Unexpected test request ${request.url}`); + }); + const prepare = async (key = "request") => { + const response = await machineRequest("/machine/runners/prepare", machine.bearer, { + bindingId: binding.id, + key, + }); + expect(response.status).toBe(200); + return Schema.decodeUnknownSync(RunnerGrant)(await response.json()); + }; + return { machine, binding, state, fetch, prepare }; +} +it("deduplicates grants and release, and status polls do not write leases", async () => { + const f = await fixture(); + const first = await f.prepare(); + expect(first.state).toBe("ready"); + expect(await f.prepare()).toEqual(first); + const before = await env.DB.prepare("SELECT * FROM runnerLeases WHERE id = ?") + .bind(first.id) + .first(); + for (let i = 0; i < 2; i++) { + const status = await machineRequest("/machine/runners/status", f.machine.bearer, { + id: first.id, + }); + expect(await status.json()).toEqual({ busy: false, status: "online" }); + } + expect( + await env.DB.prepare("SELECT * FROM runnerLeases WHERE id = ?").bind(first.id).first(), + ).toEqual(before); + for (let i = 0; i < 2; i++) + expect( + await ( + await machineRequest("/machine/runners/release", f.machine.bearer, { id: first.id }) + ).json(), + ).toEqual({ state: "released", id: first.id }); + expect(await f.prepare()).toEqual({ state: "released", id: first.id }); + expect( + f.fetch.mock.calls.filter(([r]) => + String(r instanceof Request ? r.url : r).endsWith("/generate-jitconfig"), + ), + ).toHaveLength(1); + expect(f.fetch.mock.calls.filter(([, init]) => init?.method === "DELETE")).toHaveLength(1); +}); +it("fences missing requests and rejects foreign machines", async () => { + const f = await fixture(); + const request = { bindingId: f.binding.id, environmentId: "linux", key: "fenced" }; + const foreign = await seedMachine({ environments: [readyLinux()] }); + expect((await machineRequest("/machine/runners/reconcile", foreign.bearer, request)).status).toBe( + 403, + ); + const a = await machineRequest("/machine/runners/reconcile", f.machine.bearer, request); + const lease = await a.json(); + expect(lease).toMatchObject({ phase: "released" }); + expect( + await (await machineRequest("/machine/runners/reconcile", f.machine.bearer, request)).json(), + ).toEqual(lease); + expect(await f.prepare("fenced")).toMatchObject({ state: "released" }); + expect( + f.fetch.mock.calls.some(([r]) => + String(r instanceof Request ? r.url : r).endsWith("/generate-jitconfig"), + ), + ).toBe(false); +}); +it("does not retry registration after a lost response and discovers it for cleanup", async () => { + const f = await fixture(); + f.state.lost = true; + const lease = await f.prepare(); + expect(lease.state).toBe("action_required"); + expect(await f.prepare()).toEqual(lease); + expect( + (await machineRequest("/machine/runners/release", f.machine.bearer, { id: lease.id })).status, + ).toBe(200); +}); +it("withholds credentials when the binding is disabled during registration", async () => { + const f = await fixture(); + f.state.onRegistration = async () => { + await env.DB.prepare("UPDATE repositoryBindings SET enabled = 0 WHERE id = ?") + .bind(f.binding.id) + .run(); + }; + const grant = await f.prepare(); + expect(grant.state).toBe("action_required"); + const row = await env.DB.prepare("SELECT * FROM runnerLeases WHERE id = ?") + .bind(grant.id) + .first(); + expect(row?.encodedConfig).toBeNull(); + expect(row?.runnerId).toBe(501); + expect( + (await machineRequest("/machine/runners/release", f.machine.bearer, { id: grant.id })).status, + ).toBe(200); +}); +it("expires JIT credentials without changing live lease identity", async () => { + const f = await fixture(); + const grant = await f.prepare(); + await env.DB.prepare("UPDATE runnerLeases SET configExpiresAt = ? WHERE id = ?") + .bind(Date.now() - 1, grant.id) + .run(); + await sweepLeases(env, Date.now()); + const row = await env.DB.prepare("SELECT * FROM runnerLeases WHERE id = ?") + .bind(grant.id) + .first(); + expect(row).toMatchObject({ + phase: "ready", + runnerId: 501, + encodedConfig: null, + configExpiresAt: null, + }); + expect(await f.prepare()).toMatchObject({ state: "action_required" }); +}); +it("rejects cleanup of a mismatched runner and preserves busy runners", async () => { + const f = await fixture(); + const grant = await f.prepare(); + f.state.wrong = true; + expect( + await ( + await machineRequest("/machine/runners/release", f.machine.bearer, { id: grant.id }) + ).json(), + ).toMatchObject({ code: "runner_identity_conflict" }); + f.state.wrong = false; + f.state.busy = true; + expect( + await ( + await machineRequest("/machine/runners/release", f.machine.bearer, { id: grant.id }) + ).json(), + ).toMatchObject({ code: "runner_busy" }); + expect( + await env.DB.prepare("SELECT phase FROM runnerLeases WHERE id = ?").bind(grant.id).first(), + ).toEqual({ phase: "ready" }); +}); + +async function machineRequest(path: string, bearer: string, body: unknown) { + const ctx = createExecutionContext(); + const response = await worker.fetch( + new Request(`https://cloud.test${path}`, { + method: "POST", + headers: { Authorization: `Bearer ${bearer}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + env, + ctx, + ); + await waitOnExecutionContext(ctx); + return response; +} +beforeEach(async () => { + await env.DB.batch( + ["machines", "githubAccounts", "githubApps", "githubJobs", "runnerScaleSets"].map((table) => + env.DB.prepare(`DELETE FROM ${table}`), + ), + ); +}); + +function stubFetch(handler: (request: Request) => Promise | Response) { + return vi + .spyOn(globalThis, "fetch") + .mockImplementation(async (input, init) => handler(new Request(input, init))); +} + +it("serializes concurrent prepares with one durable registration", async () => { + const f = await fixture(); + const grants = await Promise.all([f.prepare(), f.prepare()]); + expect(grants[0]?.id).toBe(grants[1]?.id); + expect(grants.some((grant) => grant.state === "ready")).toBe(true); + expect( + f.fetch.mock.calls.filter(([r]) => + String(r instanceof Request ? r.url : r).endsWith("/generate-jitconfig"), + ), + ).toHaveLength(1); + expect(await env.DB.prepare("SELECT COUNT(*) AS n FROM runnerLeases").first("n")).toBe(1); +}); +it("withholds credentials if the environment revision changes during registration", async () => { + const f = await fixture(); + f.state.onRegistration = async () => { + await env.DB.prepare("UPDATE machines SET environmentsJson = ? WHERE id = ?") + .bind(JSON.stringify([{ ...readyLinux(), revision: "new-image" }]), f.machine.id) + .run(); + }; + expect(await f.prepare()).toMatchObject({ state: "action_required" }); + expect( + await env.DB.prepare("SELECT encodedConfig FROM runnerLeases").first("encodedConfig"), + ).toBeNull(); +}); +it("requires a matching queued job and explicit approval for restricted runners", async () => { + const f = await fixture(); + await env.DB.prepare("UPDATE repositoryBindings SET accessJson = ?, automatic = 1 WHERE id = ?") + .bind(JSON.stringify({ actors: { ids: [7] }, approval: "required" }), f.binding.id) + .run(); + const prepare = (extra: Record) => + machineRequest("/machine/runners/prepare", f.machine.bearer, { + bindingId: f.binding.id, + key: "restricted", + ...extra, + }); + expect(await (await prepare({})).json()).toMatchObject({ code: "job_required" }); + expect(await (await prepare({ jobId: 1 })).json()).toMatchObject({ code: "job_not_queued" }); + await env.DB.prepare( + `INSERT INTO githubJobs (installationId,repositoryId,jobId,runId,runAttempt,actorId,triggeringActorId,name,status,labelsJson,updatedAt) VALUES (10,1000,1,2,1,7,7,'Build','queued','["vectis-linux"]',1)`, + ).run(); + expect(await (await prepare({ jobId: 1, automatic: true })).json()).toMatchObject({ + code: "approval_required", + }); + expect(await (await prepare({ jobId: 1 })).json()).toMatchObject({ + state: "ready", + access: { approvedRun: { runId: 2, runAttempt: 1 } }, + }); + expect(await (await prepare({ jobId: 1, automatic: true })).json()).toMatchObject({ + code: "approval_required", + }); +}); +it("rejects foreign status requests before GitHub access", async () => { + const f = await fixture(); + const grant = await f.prepare(); + const foreign = await seedMachine(); + f.fetch.mockClear(); + expect( + await ( + await machineRequest("/machine/runners/status", foreign.bearer, { id: grant.id }) + ).json(), + ).toMatchObject({ code: "runner_lease_missing" }); + expect(f.fetch).not.toHaveBeenCalled(); +}); diff --git a/apps/cloud/test/migrations.test.ts b/apps/cloud/test/migrations.test.ts new file mode 100644 index 00000000..fef7bc04 --- /dev/null +++ b/apps/cloud/test/migrations.test.ts @@ -0,0 +1,335 @@ +import { createExecutionContext } from "cloudflare:test"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import worker from "../src/index.js"; +import { Schema } from "effect"; +import { MigrationAnalysis } from "../../../packages/protocol/src/migrations.js"; +import { + env, + seedMachine, + seedAccount, + seedBinding, + seedApp, + readyLinux, + stubFetch, +} from "../src/test-support.js"; +import { newId } from "../src/ids.js"; + +beforeEach(async () => { + await env.DB.batch( + ["machines", "githubAccounts", "githubApps", "githubJobs"].map((table) => + env.DB.prepare(`DELETE FROM ${table}`), + ), + ); +}); +afterEach(() => vi.restoreAllMocks()); +const sha = "a".repeat(40); +const revision = "a".repeat(64); +const before = + "on: push\njobs:\n build:\n runs-on: ubuntu-24.04-arm\n hosted:\n runs-on: macos-26\n"; +async function fixture() { + const machine = await seedMachine({ environments: [{ ...readyLinux(), revision }] }); + const account = await seedAccount(machine.owner); + const binding = await seedBinding({ + owner: machine.owner, + machineId: machine.id, + accountId: account.id, + }); + await seedApp(); + const state = { + truncated: false, + installationId: 10, + changedBase: false, + published: false, + beforeToken: undefined as (() => Promise) | undefined, + tokenStatus: 200, + }; + const posts: { path: string; body: unknown }[] = []; + stubFetch(async (request) => { + const path = new URL(request.url).pathname; + if (path.endsWith("/installation")) + return Response.json({ + id: state.installationId, + app_id: 1, + account: { id: 100, type: "User" }, + suspended_at: null, + }); + if (path.endsWith("/access_tokens")) { + await state.beforeToken?.(); + return state.tokenStatus === 200 + ? Response.json({ token: "migration-token" }) + : Response.json( + { message: "Resource not accessible by integration" }, + { status: state.tokenStatus }, + ); + } + if (path === "/repos/octo/repo") + return Response.json({ id: 1000, owner: { id: 100 }, private: true, default_branch: "main" }); + if (path.endsWith("/fork-pr-workflows-private-repos")) + return Response.json({ + run_workflows_from_fork_pull_requests: false, + require_approval_for_fork_pr_workflows: true, + }); + if (request.method === "POST") { + posts.push({ path, body: await request.json() }); + if (path.endsWith("/git/trees")) return Response.json({ sha: "e".repeat(40) }); + if (path.endsWith("/git/commits")) return Response.json({ sha: "f".repeat(40) }); + if (path.endsWith("/git/refs")) { + state.published = true; + return Response.json({}); + } + if (path.endsWith("/pulls")) + return Response.json({ + number: 12, + state: "open", + html_url: "https://github.com/octo/repo/pull/12", + head: { repo: { id: 1000 } }, + }); + } + if (path.endsWith("/pulls")) return Response.json([]); + if (path.endsWith("/git/ref/heads/main")) + return Response.json({ object: { sha: state.changedBase ? "b".repeat(40) : sha } }); + if (path.endsWith("/git/ref/heads/vectis/migrate-workflows")) + return state.published + ? Response.json({ object: { sha: "f".repeat(40) } }) + : new Response(null, { status: 404 }); + if (path.endsWith(`/git/commits/${"f".repeat(40)}`)) + return Response.json({ + tree: { sha: "e".repeat(40) }, + parents: [{ sha }], + message: `Migrate workflows to Vectis (${"e".repeat(40)})`, + }); + if (path.endsWith(`/git/commits/${sha}`)) + return Response.json({ tree: { sha }, parents: [], message: "base" }); + if (path.endsWith(`/git/trees/${sha}`)) + return Response.json({ + truncated: state.truncated, + tree: [{ path: ".github", type: "tree", mode: "040000", sha: "b".repeat(40) }], + }); + if (path.endsWith(`/git/trees/${"b".repeat(40)}`)) + return Response.json({ + truncated: false, + tree: [{ path: "workflows", type: "tree", mode: "040000", sha: "c".repeat(40) }], + }); + if (path.endsWith(`/git/trees/${"c".repeat(40)}`)) + return Response.json({ + truncated: false, + tree: [{ path: "ci.yml", type: "blob", mode: "100644", sha: "d".repeat(40) }], + }); + if (path.endsWith(`/git/blobs/${"d".repeat(40)}`)) + return Response.json({ + sha: "d".repeat(40), + encoding: "base64", + content: btoa(before), + size: before.length, + }); + throw new Error(`Unexpected GitHub request ${path}`); + }); + async function request(path: string, body: unknown, bearer = machine.bearer) { + return worker.fetch( + new Request(`https://cloud.test/machine/migrations/${path}`, { + method: "POST", + headers: { Authorization: `Bearer ${bearer}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + env, + createExecutionContext(), + ); + } + async function analyze() { + const response = await request("analyze", { bindingId: binding.id }); + expect(response.status).toBe(200); + return Schema.decodeUnknownSync(MigrationAnalysis)(await response.json()); + } + async function verify(phase = "released", runnerId = 6, environmentRevision = revision) { + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO runnerLeases (id, owner, machineId, bindingId, environmentId, os, key, environmentRevision, phase, runnerId, createdAt, updatedAt) VALUES (?, ?, ?, ?, 'linux', 'linux', 'verify', ?, ?, ?, 1, 1)`, + ).bind(newId(), machine.owner, machine.id, binding.id, environmentRevision, phase, runnerId), + env.DB.prepare( + `INSERT INTO githubJobs (installationId, repositoryId, jobId, runId, name, status, conclusion, labelsJson, runnerId, updatedAt) VALUES (10, 1000, 4, 5, 'test', 'completed', 'success', '[]', 6, 1)`, + ), + ]); + } + return { machine, binding, state, posts, request, analyze, verify }; +} +it("analyzes pinned workflows and retains job summaries", async () => { + const f = await fixture(); + const analysis = await f.analyze(); + expect(analysis.verified).toBe(false); + expect(analysis.files[0]?.after).toBe(before.replace("ubuntu-24.04-arm", "vectis-linux")); + const row = await env.DB.prepare("SELECT * FROM migrationPreviews WHERE id = ?") + .bind(analysis.previewId) + .first<{ reportJson: string; expiresAt: number; createdAt: number }>(); + expect(row?.expiresAt).toBe((row?.createdAt ?? 0) + 86400000); + expect(JSON.parse(row?.reportJson ?? "{}").files[0].jobs).toEqual(analysis.files[0]?.jobs); +}); +it("requires authentication and owned bindings", async () => { + const f = await fixture(); + expect((await f.request("analyze", { bindingId: f.binding.id }, "invalid")).status).toBe(401); + const other = await seedMachine(); + const response = await f.request("analyze", { bindingId: f.binding.id }, other.bearer); + expect(response.status).toBe(403); + expect(await response.json()).toMatchObject({ code: "repository_access_denied" }); +}); +it.each(["missing", "expired", "foreign", "environment"])( + "rejects %s previews", + async (scenario) => { + const f = await fixture(); + let previewId = newId(); + if (scenario !== "missing") previewId = (await f.analyze()).previewId; + if (scenario === "expired") + await env.DB.prepare("UPDATE migrationPreviews SET expiresAt = 1").run(); + if (scenario === "foreign") + await env.DB.prepare("UPDATE migrationPreviews SET owner = 'other'").run(); + if (scenario === "environment") + await env.DB.prepare("UPDATE migrationPreviews SET environmentRevision = 'other'").run(); + const response = await f.request("publish", { previewId }); + expect(await response.json()).toMatchObject({ + code: + scenario === "environment" + ? "migration_environment_changed" + : "migration_preview_unavailable", + }); + expect(f.posts).toHaveLength(0); + }, +); +it.each(["ready", "wrong-runner", "old-revision"])( + "refuses publication with %s runner evidence", + async (scenario) => { + const f = await fixture(); + const { previewId } = await f.analyze(); + await f.verify( + scenario === "ready" ? "ready" : "released", + scenario === "wrong-runner" ? 99 : 6, + scenario === "old-revision" ? "b".repeat(64) : revision, + ); + const response = await f.request("publish", { previewId }); + expect(await response.json()).toMatchObject({ code: "environment_not_verified" }); + expect(f.posts).toHaveLength(0); + }, +); +it("publishes changed workflows after a successful job and runner cleanup", async () => { + const f = await fixture(); + await f.verify(); + const analysis = await f.analyze(); + expect(analysis.verified).toBe(true); + const response = await f.request("publish", { previewId: analysis.previewId }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ status: "created", number: 12 }); + expect(f.posts.find((post) => post.path.endsWith("/pulls"))?.body).toMatchObject({ + draft: false, + base: "main", + }); +}); +it("rejects unchanged previews before GitHub publication", async () => { + const f = await fixture(); + await f.verify(); + const { previewId } = await f.analyze(); + const row = await env.DB.prepare("SELECT reportJson FROM migrationPreviews WHERE id = ?") + .bind(previewId) + .first<{ reportJson: string }>(); + const report = JSON.parse(row?.reportJson ?? "{}"); + report.files.forEach((file: { changed: boolean }) => { + file.changed = false; + }); + await env.DB.prepare("UPDATE migrationPreviews SET reportJson = ? WHERE id = ?") + .bind(JSON.stringify(report), previewId) + .run(); + expect(await (await f.request("publish", { previewId })).json()).toMatchObject({ + code: "migration_no_changes", + }); + expect(f.posts).toHaveLength(0); +}); +it("rechecks preview authority after minting publication credentials", async () => { + const f = await fixture(); + await f.verify(); + const { previewId } = await f.analyze(); + f.state.beforeToken = async () => { + await env.DB.prepare("UPDATE repositoryBindings SET enabled = 0").run(); + }; + expect(await (await f.request("publish", { previewId })).json()).toMatchObject({ + code: "repository_access_denied", + }); + expect(f.posts).toHaveLength(0); +}); +it("rejects changed installations", async () => { + const f = await fixture(); + f.state.installationId = 11; + expect(await (await f.request("analyze", { bindingId: f.binding.id })).json()).toMatchObject({ + code: "installation_changed", + }); +}); +it("preserves token permission recovery codes", async () => { + const f = await fixture(); + f.state.tokenStatus = 403; + expect(await (await f.request("analyze", { bindingId: f.binding.id })).json()).toMatchObject({ + code: "repository_access_denied", + }); +}); +it("refuses truncated inventories with a useful error", async () => { + const f = await fixture(); + f.state.truncated = true; + expect(await (await f.request("analyze", { bindingId: f.binding.id })).json()).toMatchObject({ + code: "workflow_inventory_incomplete", + }); +}); +it("refuses stale publication bases", async () => { + const f = await fixture(); + await f.verify(); + const { previewId } = await f.analyze(); + f.state.changedBase = true; + expect(await (await f.request("publish", { previewId })).json()).toMatchObject({ + code: "migration_stale", + }); + expect(f.posts).toHaveLength(0); +}); + +it("caps active previews while allowing expired previews to remain stored", async () => { + const f = await fixture(); + const { previewId } = await f.analyze(); + await env.DB.prepare(`WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM n WHERE x < 99) + INSERT INTO migrationPreviews (id, owner, machineId, bindingId, reportJson, environmentRevision, createdAt, expiresAt) + SELECT id || '-' || x, owner, machineId, bindingId, reportJson, environmentRevision, createdAt, expiresAt FROM migrationPreviews, n WHERE id = ?`) + .bind(previewId) + .run(); + expect(await (await f.request("analyze", { bindingId: f.binding.id })).json()).toMatchObject({ + code: "migration_preview_limit", + }); + await env.DB.prepare("UPDATE migrationPreviews SET expiresAt = 1").run(); + await f.analyze(); + expect(await env.DB.prepare("SELECT count(*) AS total FROM migrationPreviews").first()).toEqual({ + total: 101, + }); +}); +it("invalidates analysis when the prepared environment changes during GitHub access", async () => { + const f = await fixture(); + f.state.beforeToken = async () => { + await env.DB.prepare("UPDATE machines SET environmentsJson = ? WHERE id = ?") + .bind(JSON.stringify([{ ...readyLinux(), revision: "b".repeat(64) }]), f.machine.id) + .run(); + }; + expect(await (await f.request("analyze", { bindingId: f.binding.id })).json()).toMatchObject({ + code: "migration_environment_changed", + }); + expect(await env.DB.prepare("SELECT count(*) AS total FROM migrationPreviews").first()).toEqual({ + total: 0, + }); +}); +it("rechecks expiry immediately before publication", async () => { + const f = await fixture(); + await f.verify(); + const { previewId } = await f.analyze(); + f.state.beforeToken = async () => { + await env.DB.prepare("UPDATE migrationPreviews SET expiresAt = 1").run(); + }; + expect(await (await f.request("publish", { previewId })).json()).toMatchObject({ + code: "migration_preview_unavailable", + }); + expect(f.posts).toHaveLength(0); +}); +it("revoked machines cannot publish retained previews", async () => { + const f = await fixture(); + const { previewId } = await f.analyze(); + await env.DB.prepare("DELETE FROM machines WHERE id = ?").bind(f.machine.id).run(); + expect((await f.request("publish", { previewId })).status).toBe(401); +}); diff --git a/apps/cloud/test/operations.test.ts b/apps/cloud/test/operations.test.ts new file mode 100644 index 00000000..cd3469bf --- /dev/null +++ b/apps/cloud/test/operations.test.ts @@ -0,0 +1,112 @@ +import { expect, it } from "vitest"; +import { env, seedMachine, machineRequest } from "../src/test-support.js"; +import { + submitOperation, + cancelOperation, + getOperation, + sweepOperations, +} from "../src/operations.js"; + +it("creates once under concurrent submission and rejects conflicting keys and owners", async () => { + const machine = await seedMachine(); + const input = { + machineId: machine.id, + key: "one", + commandJson: JSON.stringify({ type: "runner.run", bindingId: "binding", automatic: true }), + }; + const ids = await Promise.all([ + submitOperation(env, machine.owner, input), + submitOperation(env, machine.owner, input), + ]); + expect(ids[0]).toBe(ids[1]); + await expect( + submitOperation(env, machine.owner, { + ...input, + commandJson: JSON.stringify({ type: "runner.run", bindingId: "other" }), + }), + ).rejects.toMatchObject({ code: "idempotency_conflict" }); + await expect(getOperation(env, "other", ids[0]!)).rejects.toMatchObject({ + code: "operation_missing", + }); + expect((await getOperation(env, machine.owner, ids[0]!)).id).toBe(ids[0]); +}); + +it("guards acknowledgements, repeated results, and cancellation", async () => { + const machine = await seedMachine(); + const id = await submitOperation(env, machine.owner, { + machineId: machine.id, + key: "ack", + commandJson: JSON.stringify({ type: "runner.run", bindingId: "binding" }), + }); + const ack = (phase: string, resultJson?: string) => + machineRequest("/machine/operations/acknowledge", machine.bearer, { + id, + phase, + ...(resultJson ? { resultJson } : {}), + }); + expect((await ack("running")).status).toBe(409); + const results = await Promise.all([ack("claimed"), ack("claimed")]); + expect(results.map((r) => r.status)).toEqual([200, 200]); + expect((await ack("running")).status).toBe(200); + expect((await ack("succeeded", "{}")).status).toBe(200); + expect((await ack("succeeded", "{}")).status).toBe(200); + expect(await (await ack("succeeded", '{"x":1}')).json()).toMatchObject({ + code: "acknowledgement_conflict", + }); + const cancelled = await submitOperation(env, machine.owner, { + machineId: machine.id, + key: "cancel", + commandJson: JSON.stringify({ type: "runner.run", bindingId: "binding" }), + }); + await cancelOperation(env, machine.owner, cancelled); + expect( + await ( + await machineRequest("/machine/operations/acknowledge", machine.bearer, { + id: cancelled, + phase: "claimed", + }) + ).json(), + ).toMatchObject({ code: "operation_cancelled" }); +}); + +it("expires unclaimed offline runner operations after two minutes", async () => { + const machine = await seedMachine(); + const id = await submitOperation(env, machine.owner, { + machineId: machine.id, + key: "expire", + commandJson: JSON.stringify({ type: "runner.run", bindingId: "binding" }), + }); + const row = await getOperation(env, machine.owner, id); + await sweepOperations(env, row.createdAt + 120001); + const expired = await getOperation(env, machine.owner, id); + expect(expired.phase).toBe("cancelled"); + expect(JSON.parse(expired.resultJson!)).toMatchObject({ code: "machine_offline" }); +}); + +// Sync returns at most 100 operations per phase, so a machine never holds more active ones than that. +it("refuses new operations while a machine has 100 active ones", async () => { + const machine = await seedMachine(); + const now = Date.now(); + await env.DB.batch( + Array.from({ length: 100 }, (_, index) => + env.DB.prepare( + "INSERT INTO operations (id, owner, machineId, key, commandJson, phase, createdAt, updatedAt) VALUES (?, ?, ?, ?, '{}', ?, ?, ?)", + ).bind( + `op${index}`, + machine.owner, + machine.id, + `key-${index}`, + index % 2 ? "claimed" : "running", + now, + now, + ), + ), + ); + await expect( + submitOperation(env, machine.owner, { + machineId: machine.id, + key: "one-more", + commandJson: JSON.stringify({ type: "machine.pause", paused: true }), + }), + ).rejects.toMatchObject({ code: "queue_full" }); +}); diff --git a/apps/cloud/test/pairings.test.ts b/apps/cloud/test/pairings.test.ts new file mode 100644 index 00000000..7604499d --- /dev/null +++ b/apps/cloud/test/pairings.test.ts @@ -0,0 +1,79 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { + env, + humanRequest, + machineRequest, + SELF, + seedMachine, + secret, + stubFetch, +} from "../src/test-support.js"; +import { sha256Hex } from "../src/ids.js"; +afterEach(() => vi.restoreAllMocks()); +it("re-pairs a Mac in place and revokes the previous bearer", async () => { + stubFetch(); + const machine = await seedMachine({ localId: "mac" }); + const credential = secret(), + request = secret(); + const input = { + requestDigest: await sha256Hex(request), + credentialDigest: await sha256Hex(credential), + localId: "mac", + name: "Recovered Mac", + expiresAt: Date.now() + 60000, + }; + const response = await humanRequest("/pairings/approve", input); + expect(await response.json()).toEqual({ machineId: machine.id }); + expect((await humanRequest("/pairings/approve", input)).status).toBe(200); + expect((await machineRequest("/machine/github/accounts", machine.bearer)).status).toBe(401); + expect( + (await machineRequest("/machine/github/accounts", `${machine.id}.${credential}`)).status, + ).toBe(200); + const resolved = await SELF.fetch("https://cloud.test/machine/pairing", { + method: "POST", + body: JSON.stringify({ secret: request }), + }); + expect(resolved.headers.get("Cache-Control")).toBe("no-store"); + expect(await resolved.json()).toEqual({ machineId: machine.id, localId: "mac" }); + expect(await env.DB.prepare("SELECT COUNT(*) AS n FROM machines").first("n")).toBe(1); +}); +it("rejects reused requests with different credentials and expired approvals", async () => { + stubFetch(); + const input = { + requestDigest: "a".repeat(64), + credentialDigest: "b".repeat(64), + localId: "mac", + name: "Mac", + expiresAt: Date.now() + 60000, + }; + expect((await humanRequest("/pairings/approve", input)).status).toBe(200); + expect( + await ( + await humanRequest("/pairings/approve", { ...input, credentialDigest: "c".repeat(64) }) + ).json(), + ).toMatchObject({ code: "pairing_conflict" }); + expect( + await (await humanRequest("/pairings/approve", { ...input, expiresAt: Date.now() - 1 })).json(), + ).toMatchObject({ code: "invalid_pairing" }); +}); +it("serializes duplicate approvals without creating extra machines or rotating a conflicting credential", async () => { + stubFetch(); + const input = { + requestDigest: "d".repeat(64), + credentialDigest: "e".repeat(64), + localId: "racing-mac", + name: "Mac", + expiresAt: Date.now() + 60000, + }; + const responses = await Promise.all([ + humanRequest("/pairings/approve", input), + humanRequest("/pairings/approve", input), + ]); + expect(responses.map((r) => r.status)).toEqual([200, 200]); + expect(await responses[0]?.json()).toEqual(await responses[1]?.json()); + expect( + await env.DB.prepare("SELECT COUNT(*) AS n FROM machines WHERE localId = 'racing-mac'").first( + "n", + ), + ).toBe(1); +}); diff --git a/apps/cloud/test/redelivery.test.ts b/apps/cloud/test/redelivery.test.ts new file mode 100644 index 00000000..cbb37564 --- /dev/null +++ b/apps/cloud/test/redelivery.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { env, seedApp, stubFetch } from "../src/test-support.js"; +import { sweepRedelivery } from "../src/github/redelivery.js"; +const now = Date.now(); +const row = (id: string, guid: string, status = 500, age = 120000) => ({ + id, + guid, + status_code: status, + event: "workflow_job", + delivered_at: new Date(now - age).toISOString(), +}); +beforeEach(async () => { + await env.DB.batch([ + env.DB.prepare("DELETE FROM githubApps"), + env.DB.prepare("DELETE FROM githubRedeliveryCheckpoint"), + env.DB.prepare("DELETE FROM githubRedeliveryRequests"), + ]); +}); +afterEach(() => vi.restoreAllMocks()); +it("reserves failed GUIDs once, preserves large IDs and advances only forward", async () => { + await seedApp(); + const posts: string[] = []; + stubFetch((request) => { + if (request.method === "POST") { + posts.push(request.url); + return new Response(null, { status: 202 }); + } + return new Response( + JSON.stringify([ + row("9007199254740993999", "failed"), + row("2", "success", 200), + row("3", "fresh", 500, 10000), + ]).replace('"9007199254740993999"', "9007199254740993999"), + ); + }); + await sweepRedelivery(env, now); + await sweepRedelivery(env, now); + expect(posts).toEqual([ + "https://api.github.com/app/hook/deliveries/9007199254740993999/attempts", + ]); + expect( + await env.DB.prepare("SELECT deliveredAt FROM githubRedeliveryCheckpoint").first(), + ).toEqual({ deliveredAt: now - 60000 }); +}); +it("leaves the checkpoint unchanged on an incomplete listing or failed POST", async () => { + await seedApp(); + const fetch = stubFetch(() => new Response(null, { status: 503 })); + await sweepRedelivery(env, now); + expect(await env.DB.prepare("SELECT * FROM githubRedeliveryCheckpoint").first()).toBeNull(); + fetch.mockRestore(); + stubFetch((request) => + request.method === "POST" + ? new Response(null, { status: 500 }) + : Response.json([row("1", "failed")]), + ); + await sweepRedelivery(env, now); + expect(await env.DB.prepare("SELECT * FROM githubRedeliveryCheckpoint").first()).toBeNull(); + expect(await env.DB.prepare("SELECT guid FROM githubRedeliveryRequests").first()).toEqual({ + guid: "failed", + }); +}); +it("bounds pagination and leaves the checkpoint unchanged when not complete", async () => { + await seedApp(); + const fetch = stubFetch(() => + Response.json([row("1", "failed")], { + headers: { link: '; rel="next"' }, + }), + ); + await sweepRedelivery(env, now); + expect(fetch).toHaveBeenCalledTimes(10); + expect(await env.DB.prepare("SELECT * FROM githubRedeliveryCheckpoint").first()).toBeNull(); +}); diff --git a/apps/cloud/test/repositories.test.ts b/apps/cloud/test/repositories.test.ts new file mode 100644 index 00000000..b2ed7ba0 --- /dev/null +++ b/apps/cloud/test/repositories.test.ts @@ -0,0 +1,113 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { + env, + seedMachine, + seedAccount, + seedBinding, + machineRequest, + readyLinux, +} from "../src/test-support.js"; +import { machineRecord, type MachineRow } from "../src/db.js"; +import { machineRepositories } from "../src/github/repositories.js"; +afterEach(() => vi.restoreAllMocks()); +it("keeps machine repositories ordered, enabled and owned; rejects foreign updates", async () => { + const m = await seedMachine({ environments: [readyLinux()] }); + const other = await seedMachine({ owner: "other" }); + const a = await seedAccount(m.owner); + const first = await seedBinding({ owner: m.owner, accountId: a.id, machineId: m.id }); + await seedBinding({ + owner: m.owner, + accountId: a.id, + machineId: m.id, + repositoryId: 2000, + enabled: false, + }); + const row = await env.DB.prepare("SELECT * FROM machines WHERE id = ?") + .bind(m.id) + .first(); + expect(row).not.toBeNull(); + if (!row) return; + expect(await machineRepositories(env, machineRecord(row))).toMatchObject([ + { id: first.id, repositoryName: "octo/repo" }, + ]); + expect( + ( + await machineRequest("/machine/repositories/set-automatic", other.bearer, { + bindingId: first.id, + enabled: true, + }) + ).status, + ).toBe(403); + expect( + ( + await machineRequest("/machine/repositories/set-automatic", m.bearer, { + bindingId: first.id, + enabled: true, + }) + ).status, + ).toBe(200); + expect( + ( + await machineRequest("/machine/repositories/set-access", m.bearer, { + bindingId: first.id, + actors: { ids: [1, 1] }, + approval: "required", + }) + ).status, + ).toBe(200); + expect( + await ( + await machineRequest("/machine/repositories/set-capacity-mode", m.bearer, { + bindingId: first.id, + capacityMode: "scale_set", + }) + ).json(), + ).toMatchObject({ code: "scale_set_policy_unsupported" }); +}); +it("rechecks the machine credential after GitHub verification before persisting a binding", async () => { + const { seedApp, stubFetch } = await import("../src/test-support.js"); + await seedApp(); + const m = await seedMachine({ owner: "connect-owner", environments: [readyLinux()] }); + const a = await seedAccount(m.owner, { githubId: 7, login: "owner" }); + let revoke = false; + stubFetch(async (request) => { + const url = new URL(request.url).pathname; + if (url.endsWith("/installation")) + return Response.json({ + id: 3, + app_id: 1, + account: { id: 7, type: "User" }, + suspended_at: null, + }); + if (url.endsWith("/access_tokens")) return Response.json({ token: "test-token" }); + if (url.endsWith("/permission")) return Response.json({ permission: "admin", user: { id: 7 } }); + if (url.endsWith("/fork-pr-workflows-private-repos")) + return Response.json({ + run_workflows_from_fork_pull_requests: false, + require_approval_for_fork_pr_workflows: false, + }); + if (revoke) + await env.DB.prepare("UPDATE machineCredentials SET digest = ? WHERE machineId = ?") + .bind("f".repeat(64), m.id) + .run(); + return Response.json({ id: 55, private: true, owner: { id: 7 } }); + }); + const input = { accountId: a.id, repositoryName: "sandbox", environmentId: "linux" }; + const first = await machineRequest("/machine/repositories/connect", m.bearer, input); + expect(first.status).toBe(200); + expect( + await (await machineRequest("/machine/repositories/connect", m.bearer, input)).json(), + ).toEqual(await first.json()); + revoke = true; + const refused = await machineRequest("/machine/repositories/connect", m.bearer, { + ...input, + environmentId: "linux", + repositoryName: "different", + }); + expect(refused.status).toBe(401); + expect( + await env.DB.prepare("SELECT COUNT(*) AS n FROM repositoryBindings WHERE machineId = ?") + .bind(m.id) + .first("n"), + ).toBe(1); +}); diff --git a/apps/cloud/test/retention.test.ts b/apps/cloud/test/retention.test.ts new file mode 100644 index 00000000..4b4e1d46 --- /dev/null +++ b/apps/cloud/test/retention.test.ts @@ -0,0 +1,33 @@ +import { expect, it } from "vitest"; +import { env, seedMachine } from "../src/test-support.js"; +import { sweepRetention } from "../src/retention.js"; +it("expires transient rows and retains active operations past the history window", async () => { + const m = await seedMachine(); + const now = Date.now(), + old = now - 100 * 86400000; + await env.DB.batch([ + env.DB.prepare("INSERT INTO pairings VALUES ('digest', ?, ?, ?)").bind(m.owner, m.id, now - 1), + env.DB.prepare( + "INSERT INTO operations (id, owner, machineId, key, commandJson, phase, createdAt, updatedAt) VALUES ('active', ?, ?, 'active', '{}', 'running', ?, ?), ('done', ?, ?, 'done', '{}', 'succeeded', ?, ?)", + ).bind(m.owner, m.id, old, old, m.owner, m.id, old, old), + ]); + await sweepRetention(env, now); + expect(await env.DB.prepare("SELECT COUNT(*) AS n FROM pairings").first("n")).toBe(0); + expect((await env.DB.prepare("SELECT id FROM operations").all()).results).toEqual([ + { id: "active" }, + ]); +}); + +it("keeps sweeping full batches until the backlog is cleared", async () => { + const now = Date.now(), + old = now - 40 * 86400000; + await env.DB.batch( + Array.from({ length: 1201 }, (_, index) => + env.DB.prepare( + "INSERT INTO githubDeliveries (deliveryId, event, receivedAt) VALUES (?, 'workflow_job', ?)", + ).bind(`old-${index}`, old), + ), + ); + await sweepRetention(env, now); + expect(await env.DB.prepare("SELECT COUNT(*) AS n FROM githubDeliveries").first("n")).toBe(0); +}); diff --git a/apps/cloud/test/routing.test.ts b/apps/cloud/test/routing.test.ts new file mode 100644 index 00000000..e2fc0f47 --- /dev/null +++ b/apps/cloud/test/routing.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + env, + humanRequest, + machineRequest, + SELF, + seedMachine, + stubFetch, +} from "../src/test-support.js"; + +afterEach(() => vi.restoreAllMocks()); + +describe("routing and authentication", () => { + it("answers unknown routes with a typed 404", async () => { + const response = await SELF.fetch("https://cloud.test/nope"); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ code: "not_found" }); + }); + + it("refuses machine routes without a valid credential", async () => { + const machine = await seedMachine(); + const missing = await SELF.fetch("https://cloud.test/machine/sync", { + method: "POST", + body: "{}", + }); + expect(missing.status).toBe(401); + expect(await missing.json()).toMatchObject({ code: "machine_unauthorized" }); + const wrong = await machineRequest("/machine/sync", `${machine.id}.${"x".repeat(43)}`, { + leaseIds: [], + }); + expect(wrong.status).toBe(401); + expect(await wrong.json()).toMatchObject({ code: "machine_revoked" }); + }); + + it("decodes machine bodies against the protocol before any handler runs", async () => { + const machine = await seedMachine(); + const invalid = await machineRequest("/machine/sync", machine.bearer, { leaseIds: "x" }); + expect(invalid.status).toBe(400); + const extra = await machineRequest("/machine/sync", machine.bearer, { leaseIds: [], x: 1 }); + expect(extra.status).toBe(400); + }); + + it("authenticates socket upgrades through the subprotocol", async () => { + const machine = await seedMachine(); + const anonymous = await SELF.fetch("https://cloud.test/machine/socket", { + headers: { Upgrade: "websocket", "Sec-WebSocket-Protocol": "vectis.v1" }, + }); + expect(anonymous.status).toBe(401); + const encoded = btoa(machine.bearer) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/, ""); + const forged = btoa(`${machine.id}.${"y".repeat(43)}`).replace(/=+$/, ""); + const refused = await SELF.fetch("https://cloud.test/machine/socket", { + headers: { Upgrade: "websocket", "Sec-WebSocket-Protocol": `vectis.v1, auth.${forged}` }, + }); + expect(refused.status).toBe(401); + expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it("verifies Clerk session tokens and the web origin for human routes", async () => { + stubFetch(); + const anonymous = await SELF.fetch("https://cloud.test/account/overview", { + headers: { Origin: env.VECTIS_WEB_URL }, + }); + expect(anonymous.status).toBe(401); + expect(anonymous.headers.get("Access-Control-Allow-Origin")).toBe(env.VECTIS_WEB_URL); + const foreign = await SELF.fetch("https://cloud.test/account/overview", { + headers: { Origin: "https://evil.test" }, + }); + expect(foreign.status).toBe(403); + const preflight = await SELF.fetch("https://cloud.test/pairings/approve", { + method: "OPTIONS", + headers: { Origin: env.VECTIS_WEB_URL }, + }); + expect(preflight.status).toBe(204); + const signedIn = await humanRequest("/account/overview"); + expect(signedIn.status).not.toBe(401); + }); +}); diff --git a/tests/convex/runControl.test.ts b/apps/cloud/test/runs.test.ts similarity index 53% rename from tests/convex/runControl.test.ts rename to apps/cloud/test/runs.test.ts index cd10a3e3..717a57f1 100644 --- a/tests/convex/runControl.test.ts +++ b/apps/cloud/test/runs.test.ts @@ -1,76 +1,33 @@ -import { generateKeyPairSync } from "node:crypto"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { convexTest } from "convex-test"; -import { afterEach, expect, test, vi } from "vitest"; -import schema from "../../convex/schema.js"; -import { api } from "../../convex/_generated/api.js"; -import { Service } from "../../apps/server/src/service.js"; -import { Store } from "../../apps/server/src/store.js"; -import { VmRuntime } from "../../packages/runner/src/runtime.js"; -import { - advanceRemoteOperation, - type Acknowledgement, -} from "../../packages/client/src/remote-operation.js"; -import type { RunControlCommand } from "../../packages/protocol/src/runs.js"; +import { createExecutionContext } from "cloudflare:test"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { Schema } from "effect"; +import worker from "../src/index.js"; +import { env, seedMachine, seedAccount, seedBinding, seedApp } from "../src/test-support.js"; +import { RunControlOutcome, type RunControlCommand } from "../../../packages/protocol/src/runs.js"; -const modules = { - "../../convex/repositoryBindings.ts": () => import("../../convex/repositoryBindings.js"), - "../../convex/githubRuns.ts": () => import("../../convex/githubRuns.js"), - "../../convex/runReceipts.ts": () => import("../../convex/runReceipts.js"), - "../../convex/runnerLeases.ts": () => import("../../convex/runnerLeases.js"), - "../../convex/operations.ts": () => import("../../convex/operations.js"), - "../../convex/_generated/server.js": () => import("../../convex/_generated/server.js"), -}; -const privateKey = generateKeyPairSync("rsa", { modulusLength: 2048 }) - .privateKey.export({ format: "pem", type: "pkcs8" }) - .toString(); +beforeEach(async () => { + await env.DB.batch( + ["machines", "githubAccounts", "githubApps", "githubJobs"].map((table) => + env.DB.prepare(`DELETE FROM ${table}`), + ), + ); +}); afterEach(() => { vi.useRealTimers(); - vi.unstubAllGlobals(); - vi.unstubAllEnvs(); + vi.restoreAllMocks(); }); async function fixture() { - vi.stubEnv("VECTIS_MACHINE_ISSUER", "https://machine.test"); - vi.stubEnv("CLERK_JWT_ISSUER_DOMAIN", "https://clerk.test"); - const t = convexTest(schema, modules); - const { machineId, bindingId } = await t.run(async (ctx) => { - const machineId = await ctx.db.insert("machines", { - owner: "owner", - localId: "local", - name: "Mac", - createdAt: 1, - }); - const accountId = await ctx.db.insert("githubAccounts", { - owner: "owner", - githubId: 7, - login: "owner", - installations: [], - verifiedAt: 1, - }); - const bindingId = await ctx.db.insert("repositoryBindings", { - owner: "owner", - machineId, - accountId, - repositoryId: 2, - repositoryName: "sandbox", - installationId: 3, - environmentId: "mac", - enabled: true, - verifiedAt: 1, - }); - await ctx.db.insert("githubApps", { - appId: 42, - slug: "test", - ownerId: 7, - clientId: "client", - privateKey, - clientSecret: "test", - webhookSecret: "test", - }); - return { machineId, bindingId }; + const machine = await seedMachine(); + const account = await seedAccount(machine.owner, { githubId: 7, login: "owner" }); + const binding = await seedBinding({ + owner: machine.owner, + accountId: account.id, + machineId: machine.id, + repositoryId: 2, + repositoryName: "sandbox", + installationId: 3, }); + await seedApp(); const run = { id: 5, run_attempt: 1, @@ -80,17 +37,17 @@ async function fixture() { repository: { id: 2 }, }; let failedJobs = true; - let beforePost: (() => Promise) | undefined; + let beforePost: (() => Promise) | undefined; let post: () => Promise = async () => { run.run_attempt++; run.status = "queued"; return new Response(null, { status: 201 }); }; - const fetch = vi.fn(async (url: string, init?: RequestInit) => { + const fetch = vi.fn(async (url: string, init?: RequestInit): Promise => { if (url.endsWith("/installation")) return Response.json({ id: 3, - app_id: 42, + app_id: 1, account: { id: 7, type: "User" }, suspended_at: null, }); @@ -103,8 +60,9 @@ async function fixture() { if (url === "https://api.github.com/repos/owner/sandbox") return Response.json({ id: 2, private: true, owner: { id: 7 } }); if (init?.method === "POST") { - const receipts = await t.run((ctx) => ctx.db.query("runCommandReceipts").collect()); - expect(receipts.some((receipt) => receipt.state === "submitted")).toBe(true); + expect( + await env.DB.prepare("SELECT * FROM runCommandReceipts WHERE state = 'submitted'").first(), + ).not.toBeNull(); await beforePost?.(); return post(); } @@ -116,21 +74,39 @@ async function fixture() { if (url.endsWith("/actions/runs/5")) return Response.json(run); throw new Error(`Unexpected request: ${url}`); }); - vi.stubGlobal("fetch", fetch); - const device = t.withIdentity({ - issuer: "https://machine.test", - subject: machineId, - credentialVersion: 0, - }); - const command = { type: "run.rerun-failed", bindingId, runId: 5 } as const; + vi.spyOn(globalThis, "fetch").mockImplementation((input, init) => fetch(String(input), init)); + const control = async (args: { key: string; command: RunControlCommand }) => { + const response = await worker.fetch( + new Request("https://cloud.test/machine/runs/control", { + method: "POST", + headers: { Authorization: `Bearer ${machine.bearer}`, "Content-Type": "application/json" }, + body: JSON.stringify(args), + }), + env, + createExecutionContext(), + ); + const body = await response.json(); + if (!response.ok) throw new Error(JSON.stringify(body)); + return Schema.decodeUnknownSync(RunControlOutcome)(body); + }; + const patch = async (values: Record) => { + await env.DB.prepare( + `UPDATE repositoryBindings SET ${Object.keys(values) + .map((k) => k + " = ?") + .join(", ")} WHERE id = ?`, + ) + .bind(...Object.values(values), binding.id) + .run(); + }; return { - t, - device, - bindingId, - machineId, - command, + machine, + machineId: machine.id, + bindingId: binding.id, + command: { type: "run.rerun-failed", bindingId: binding.id, runId: 5 } as const, run, fetch, + control, + patch, posts: () => fetch.mock.calls.filter( ([url, init]) => init?.method === "POST" && !url.endsWith("/access_tokens"), @@ -138,7 +114,7 @@ async function fixture() { setPost: (callback: () => Promise) => { post = callback; }, - setBeforePost: (callback: () => Promise) => { + setBeforePost: (callback: () => Promise) => { beforePost = callback; }, setFailedJobs: (value: boolean) => { @@ -146,10 +122,9 @@ async function fixture() { }, }; } - test("a newer attempt confirms rerun and a stored receipt prevents a second POST", async () => { const f = await fixture(); - const outcome = await f.device.action(api.githubRuns.control, { + const outcome = await f.control({ key: "rerun", command: f.command, }); @@ -160,33 +135,36 @@ test("a newer attempt confirms rerun and a stored receipt prevents a second POST runAttempt: 2, htmlUrl: f.run.html_url, }); - expect( - await f.device.action(api.githubRuns.control, { key: "rerun", command: f.command }), - ).toEqual(outcome); + expect(await f.control({ key: "rerun", command: f.command })).toEqual(outcome); expect(f.posts()).toHaveLength(1); expect( - JSON.stringify(await f.t.run((ctx) => ctx.db.query("runCommandReceipts").collect())), + JSON.stringify( + await env.DB.prepare("SELECT * FROM runCommandReceipts") + .all() + .then((r) => r.results), + ), ).not.toContain("test-actions-token"); - await expect( - f.device.action(api.githubRuns.control, { key: "rerun", command: { ...f.command, runId: 6 } }), - ).rejects.toThrow("idempotency_conflict"); + await expect(f.control({ key: "rerun", command: { ...f.command, runId: 6 } })).rejects.toThrow( + "idempotency_conflict", + ); }); test("another owner's binding is rejected before any GitHub request", async () => { const f = await fixture(); - await f.t.run((ctx) => ctx.db.patch("repositoryBindings", f.bindingId, { owner: "other" })); - await expect( - f.device.action(api.githubRuns.control, { key: "foreign", command: f.command }), - ).rejects.toThrow("repository_access_denied"); + await f.patch({ owner: "other" }); + await expect(f.control({ key: "foreign", command: f.command })).rejects.toThrow( + "repository_access_denied", + ); expect(f.fetch).not.toHaveBeenCalled(); }); test("a run from another repository is refused without POST", async () => { const f = await fixture(); f.run.repository.id = 99; - expect( - await f.device.action(api.githubRuns.control, { key: "wrong-repo", command: f.command }), - ).toMatchObject({ state: "rejected", code: "run_repository_mismatch" }); + expect(await f.control({ key: "wrong-repo", command: f.command })).toMatchObject({ + state: "rejected", + code: "run_repository_mismatch", + }); expect(f.posts()).toHaveLength(0); }); @@ -208,7 +186,7 @@ test.each([ if (failure === "network") throw new TypeError("Response lost after submission"); return new Response("Unavailable", { status: 503 }); }); - const outcome = await f.device.action(api.githubRuns.control, { + const outcome = await f.control({ key: "uncertain", command, }); @@ -217,11 +195,9 @@ test.each([ code: "run_command_unconfirmed", nextStep: expect.stringContaining("Check the run on GitHub"), }); - expect(await f.device.action(api.githubRuns.control, { key: "uncertain", command })).toEqual( - outcome, - ); + expect(await f.control({ key: "uncertain", command })).toEqual(outcome); expect(f.posts()).toHaveLength(1); - expect(await f.t.run((ctx) => ctx.db.query("runCommandReceipts").unique())).toMatchObject({ + expect(await env.DB.prepare("SELECT * FROM runCommandReceipts").first()).toMatchObject({ state: "uncertain", }); }, @@ -234,21 +210,22 @@ test("an in-flight receipt prevents concurrent POSTs and a crash receipt never r release = resolve; }); f.setBeforePost(() => waiting); - const first = f.device.action(api.githubRuns.control, { key: "concurrent", command: f.command }); + const first = f.control({ key: "concurrent", command: f.command }); await vi.waitFor(() => expect(f.posts()).toHaveLength(1)); - expect( - await f.device.action(api.githubRuns.control, { key: "concurrent", command: f.command }), - ).toMatchObject({ state: "uncertain", code: "run_command_unconfirmed" }); + expect(await f.control({ key: "concurrent", command: f.command })).toMatchObject({ + state: "uncertain", + code: "run_command_unconfirmed", + }); release(); expect(await first).toMatchObject({ state: "accepted" }); - const receipt = await f.t.run((ctx) => ctx.db.query("runCommandReceipts").unique()); + const receipt = await env.DB.prepare("SELECT * FROM runCommandReceipts").first(); if (!receipt) throw new Error("Missing receipt"); - await f.t.run((ctx) => - ctx.db.patch("runCommandReceipts", receipt._id, { state: "submitted", outcome: undefined }), - ); - expect( - await f.device.action(api.githubRuns.control, { key: "concurrent", command: f.command }), - ).toMatchObject({ state: "uncertain" }); + await env.DB.prepare( + "UPDATE runCommandReceipts SET state = 'submitted', outcomeJson = NULL", + ).run(); + expect(await f.control({ key: "concurrent", command: f.command })).toMatchObject({ + state: "uncertain", + }); expect(f.posts()).toHaveLength(1); }); @@ -260,9 +237,7 @@ test.each(["completed-cancel", "running-rerun", "no-failed-jobs"])( scenario === "completed-cancel" ? { ...f.command, type: "run.cancel" } : f.command; if (scenario === "running-rerun") f.run.status = "in_progress"; if (scenario === "no-failed-jobs") f.setFailedJobs(false); - expect( - await f.device.action(api.githubRuns.control, { key: "refused", command }), - ).toMatchObject({ + expect(await f.control({ key: "refused", command })).toMatchObject({ state: "rejected", code: scenario === "completed-cancel" ? "run_not_active" : "run_not_rerunnable", }); @@ -278,7 +253,7 @@ test.each(["completed", "cancelled"])( f.run.status = "in_progress"; f.setPost(async () => new Response(null, { status: 202 })); const command = { ...f.command, type: "run.cancel" } as const; - const pending = f.device.action(api.githubRuns.control, { key: "cancel", command }); + const pending = f.control({ key: "cancel", command }); await vi.waitFor(() => expect(f.fetch.mock.calls.filter(([url]) => url.endsWith("/actions/runs/5"))).toHaveLength(2), ); @@ -292,9 +267,7 @@ test.each(["completed", "cancelled"])( runId: 5, runAttempt: 1, }); - expect(await f.device.action(api.githubRuns.control, { key: "cancel", command })).toEqual( - outcome, - ); + expect(await f.control({ key: "cancel", command })).toEqual(outcome); expect(f.posts()).toHaveLength(1); expect(vi.getTimerCount()).toBe(0); }, @@ -311,7 +284,7 @@ test.each(["in_progress", "cancelling"])( return new Response(null, { status: 202 }); }); const command = { ...f.command, type: "run.cancel" } as const; - const pending = f.device.action(api.githubRuns.control, { key: "cancel-requested", command }); + const pending = f.control({ key: "cancel-requested", command }); await vi.waitFor(() => expect(f.fetch.mock.calls.filter(([url]) => url.endsWith("/actions/runs/5"))).toHaveLength(2), ); @@ -325,9 +298,7 @@ test.each(["in_progress", "cancelling"])( runAttempt: 1, }); expect(f.fetch.mock.calls.filter(([url]) => url.endsWith("/actions/runs/5"))).toHaveLength(6); - expect( - await f.device.action(api.githubRuns.control, { key: "cancel-requested", command }), - ).toEqual(outcome); + expect(await f.control({ key: "cancel-requested", command })).toEqual(outcome); expect(f.posts()).toHaveLength(1); expect(vi.getTimerCount()).toBe(0); }, @@ -342,7 +313,7 @@ test.each([200, 201, 202, 204])( f.run.status = "queued"; return new Response(null, { status }); }); - const pending = f.device.action(api.githubRuns.control, { + const pending = f.control({ key: "unchanged", command: f.command, }); @@ -352,9 +323,7 @@ test.each([200, 201, 202, 204])( await vi.advanceTimersByTimeAsync(5000); const outcome = await pending; expect(outcome).toMatchObject({ state: "accepted", rerun: "requested", runAttempt: 1 }); - expect( - await f.device.action(api.githubRuns.control, { key: "unchanged", command: f.command }), - ).toEqual(outcome); + expect(await f.control({ key: "unchanged", command: f.command })).toEqual(outcome); expect(f.posts()).toHaveLength(1); expect(vi.getTimerCount()).toBe(0); }, @@ -364,7 +333,7 @@ test("rerun acceptance observes a newer attempt after backoff", async () => { vi.useFakeTimers(); const f = await fixture(); f.setPost(async () => new Response(null, { status: 201 })); - const pending = f.device.action(api.githubRuns.control, { + const pending = f.control({ key: "delayed-rerun", command: f.command, }); @@ -399,7 +368,7 @@ test("a stalled post-acceptance read is aborted when the observation window ends } return original(url, init); }); - const pending = f.device.action(api.githubRuns.control, { + const pending = f.control({ key: "stalled-read", command: { ...f.command, type: "run.cancel" }, }); @@ -431,7 +400,7 @@ test.each([200, 204])( ref: "main", inputs: { target: "staging", version: "a=b" }, } as const; - const outcome = await f.device.action(api.githubRuns.control, { key: "dispatch", command }); + const outcome = await f.control({ key: "dispatch", command }); expect(outcome).toMatchObject({ state: "accepted" }); if (status === 200) expect(outcome).toMatchObject({ runId: 5, htmlUrl: f.run.html_url }); else expect(outcome).not.toHaveProperty("runId"); @@ -440,7 +409,7 @@ test.each([200, 204])( inputs: command.inputs, }); expect( - await f.device.action(api.githubRuns.control, { + await f.control({ key: "dispatch", command: { ...command, inputs: { version: "a=b", target: "staging" } }, }), @@ -455,9 +424,7 @@ test("permission denial is coded and an accepted POST stays requested despite un f.setPost(async () => Response.json({ message: "Resource not accessible by integration" }, { status: 403 }), ); - expect( - await f.device.action(api.githubRuns.control, { key: "permission", command: f.command }), - ).toMatchObject({ + expect(await f.control({ key: "permission", command: f.command })).toMatchObject({ state: "rejected", code: "github_app_permission_required", nextStep: expect.stringContaining("Approve the updated"), @@ -466,7 +433,7 @@ test("permission denial is coded and an accepted POST stays requested despite un f.run.repository.id = 99; return new Response(null, { status: 202 }); }); - const pending = f.device.action(api.githubRuns.control, { key: "read-lost", command: f.command }); + const pending = f.control({ key: "read-lost", command: f.command }); await vi.waitFor(() => expect(f.posts()).toHaveLength(2)); await vi.advanceTimersByTimeAsync(5000); expect(await pending).toMatchObject({ state: "accepted", rerun: "requested" }); @@ -479,141 +446,23 @@ test("binding access is rechecked immediately before POST", async () => { if (!original) throw new Error("Missing fetch"); f.fetch.mockImplementation(async (url, init) => { const response = await original(url, init); - if (url.includes("/jobs?")) - await f.t.run((ctx) => ctx.db.patch("repositoryBindings", f.bindingId, { enabled: false })); + if (url.includes("/jobs?")) await f.patch({ enabled: 0 }); return response; }); - expect( - await f.device.action(api.githubRuns.control, { key: "revoked", command: f.command }), - ).toMatchObject({ state: "rejected", code: "repository_access_denied" }); + expect(await f.control({ key: "revoked", command: f.command })).toMatchObject({ + state: "rejected", + code: "repository_access_denied", + }); expect(f.posts()).toHaveLength(0); }); -test.each(["rerun", "infra-toggle"])( - "%s remote replay survives lost responses and local database", - async (kind) => { - const f = await fixture(); - const home = await mkdtemp(join(tmpdir(), "vectis-run-replay-")); - let store = new Store(join(home, "state.sqlite")); - const broker = { - controlRun: (command: RunControlCommand, key: string, _signal: AbortSignal) => - f.device.action(api.githubRuns.control, { command, key }), - analyzeMigration: async () => { - throw new Error("unused"); - }, - publishMigration: async () => { - throw new Error("unused"); - }, - repositories: async () => [], - connectRepository: async () => "unused", - disconnectRepository: async () => {}, - setAutomatic: async () => {}, - setInfraRerun: async (bindingId: string, enabled: boolean) => { - await f.device.mutation(api.repositoryBindings.setInfraRerun, { bindingId, enabled }); - }, - scanJobs: async () => ({ runs: 0, jobs: 0, complete: true }), - refreshJob: async () => { - throw new Error("unused"); - }, - reconcileRunner: async () => { - throw new Error("unused"); - }, - runnerStatus: async () => ({ busy: false, status: "online" as const }), - jobLogs: async () => { - throw new Error("unused"); - }, - setAccess: async () => {}, - prepareRunner: async () => ({ state: "released" as const, id: "unused" }), - releaseRunner: async () => {}, - }; - let service = new Service(store, new VmRuntime({ home }), () => broker); - const owner = f.t.withIdentity({ - issuer: "https://clerk.test", - subject: "owner", - tokenIdentifier: "owner", - }); - const id = await owner.mutation(api.operations.submit, { - machineId: f.machineId, - key: "remote-rerun", - commandJson: JSON.stringify( - kind === "rerun" - ? f.command - : { type: "repository.infra-rerun", bindingId: f.bindingId, enabled: true }, - ), - }); - const pending = async () => { - const [operation] = await f.device.query(api.operations.pending, {}); - if (!operation) throw new Error("Missing remote operation"); - return operation; - }; - const acknowledge = (args: Acknowledgement) => - f.device.mutation(api.operations.acknowledge, args); - const local = { - submit: async (command: Parameters[1], key: string) => - service.submit(key, command), - operationByKey: async (key: string) => store.operationByKey(key), - }; - const signal = new AbortController().signal; - try { - await advanceRemoteOperation(await pending(), local, acknowledge, signal); - await expect( - advanceRemoteOperation( - await pending(), - { - ...local, - submit: async (command, key) => { - service.submit(key, command); - throw new Error("Local response lost"); - }, - }, - acknowledge, - signal, - ), - ).rejects.toThrow("Local response lost"); - await service.drain(); - await vi.waitFor(() => - expect( - store.snapshot().operations.find((operation) => operation.key === `remote:${id}`)?.status, - ).toBe("succeeded"), - ); - await service.close(); - store.close(); - await rm(join(home, "state.sqlite")); - store = new Store(join(home, "state.sqlite")); - service = new Service(store, new VmRuntime({ home }), () => broker); - await advanceRemoteOperation(await pending(), local, acknowledge, signal); - await service.drain(); - await vi.waitFor(() => expect(store.snapshot().operations[0]?.status).toBe("succeeded")); - await advanceRemoteOperation(await pending(), local, acknowledge, signal); - await advanceRemoteOperation(await pending(), local, acknowledge, signal); - expect((await owner.query(api.operations.get, { id })).phase).toBe("succeeded"); - expect(f.posts()).toHaveLength(kind === "rerun" ? 1 : 0); - if (kind === "infra-toggle") - expect( - (await f.t.run((ctx) => ctx.db.get("repositoryBindings", f.bindingId)))?.infraRerun, - ).toBe(true); - } finally { - await service.close(); - store.close(); - await rm(home, { recursive: true, force: true }); - } - }, -); - test("a binding on another machine rejects this machine before API access", async () => { const f = await fixture(); - await f.t.run(async (ctx) => { - const machineId = await ctx.db.insert("machines", { - owner: "owner", - localId: "other", - name: "Other", - createdAt: 1, - }); - await ctx.db.patch("repositoryBindings", f.bindingId, { machineId }); - }); - await expect( - f.device.action(api.githubRuns.control, { key: "other-machine", command: f.command }), - ).rejects.toThrow("repository_access_denied"); + const other = await seedMachine({ owner: f.machine.owner }); + await f.patch({ machineId: other.id }); + await expect(f.control({ key: "other-machine", command: f.command })).rejects.toThrow( + "repository_access_denied", + ); expect(f.fetch).not.toHaveBeenCalled(); }); @@ -631,14 +480,12 @@ test.each(["network", "malformed"])( workflow: 12, ref: "main", } as const; - const outcome = await f.device.action(api.githubRuns.control, { + const outcome = await f.control({ key: "dispatch-unknown", command, }); expect(outcome).toMatchObject({ state: "uncertain", code: "run_command_unconfirmed" }); - expect( - await f.device.action(api.githubRuns.control, { key: "dispatch-unknown", command }), - ).toEqual(outcome); + expect(await f.control({ key: "dispatch-unknown", command })).toEqual(outcome); expect(f.posts()).toHaveLength(1); }, ); @@ -652,9 +499,7 @@ test("token minting permission refusal crosses repositoryAccess with the same re ? Response.json({ message: "Resource not accessible by integration" }, { status: 403 }) : original(url, init), ); - expect( - await f.device.action(api.githubRuns.control, { key: "mint-denied", command: f.command }), - ).toMatchObject({ + expect(await f.control({ key: "mint-denied", command: f.command })).toMatchObject({ state: "rejected", code: "github_app_permission_required", nextStep: expect.stringContaining("Settings -> Applications"), @@ -664,25 +509,24 @@ test("token minting permission refusal crosses repositoryAccess with the same re test("a revoked machine cannot replay receipts or make a POST", async () => { const f = await fixture(); - await f.device.action(api.githubRuns.control, { key: "revoked-machine", command: f.command }); - await f.t.run((ctx) => ctx.db.delete("machines", f.machineId)); - await expect( - f.device.action(api.githubRuns.control, { key: "revoked-machine", command: f.command }), - ).rejects.toThrow("machine_revoked"); + await f.control({ key: "revoked-machine", command: f.command }); + await env.DB.prepare("DELETE FROM machines WHERE id = ?").bind(f.machineId).run(); + await expect(f.control({ key: "revoked-machine", command: f.command })).rejects.toThrow( + "machine_revoked", + ); expect(f.posts()).toHaveLength(1); }); test("revocation after POST leaves an unconfirmed receipt instead of claiming a persisted success", async () => { const f = await fixture(); f.setBeforePost(async () => { - await f.t.run((ctx) => ctx.db.delete("machines", f.machineId)); + await env.DB.prepare("DELETE FROM machines WHERE id = ?").bind(f.machineId).run(); }); - expect( - await f.device.action(api.githubRuns.control, { key: "finish-revoked", command: f.command }), - ).toMatchObject({ state: "uncertain", code: "run_command_unconfirmed" }); - expect(await f.t.run((ctx) => ctx.db.query("runCommandReceipts").unique())).toMatchObject({ - state: "submitted", + expect(await f.control({ key: "finish-revoked", command: f.command })).toMatchObject({ + state: "uncertain", + code: "run_command_unconfirmed", }); + expect(await env.DB.prepare("SELECT * FROM runCommandReceipts").first()).toBeNull(); expect(f.posts()).toHaveLength(1); }); @@ -695,7 +539,7 @@ test("202 cancellation acceptance does not depend on parsing the response body", return new Response("Accepted", { status: 202 }); }); expect( - await f.device.action(api.githubRuns.control, { + await f.control({ key: "accepted-body", command: { ...f.command, type: "run.cancel" }, }), @@ -705,32 +549,42 @@ test("202 cancellation acceptance does not depend on parsing the response body", test("attempt-scoped retries wait without reserving a POST, reject stale attempts and retain receipts", async () => { const f = await fixture(); - await f.t.run((ctx) => ctx.db.patch("repositoryBindings", f.bindingId, { infraRerun: true })); + await f.patch({ infraRerun: 1 }); const command = { ...f.command, expectedRunAttempt: 1, infraJobIds: [1] }; f.run.status = "in_progress"; - expect( - await f.device.action(api.githubRuns.control, { key: "infra-wait", command }), - ).toMatchObject({ state: "rejected", code: "run_not_ready" }); + expect(await f.control({ key: "infra-wait", command })).toMatchObject({ + state: "rejected", + code: "run_not_ready", + }); expect(f.posts()).toHaveLength(0); - expect(await f.t.run((ctx) => ctx.db.query("runCommandReceipts").collect())).toHaveLength(0); - f.run.status = "completed"; - expect( - await f.device.action(api.githubRuns.control, { key: "infra-wait", command }), - ).toMatchObject({ state: "accepted", rerun: "observed", runAttempt: 2 }); expect( - await f.device.action(api.githubRuns.control, { key: "infra-wait", command }), - ).toMatchObject({ state: "accepted", rerun: "observed", runAttempt: 2 }); + await env.DB.prepare("SELECT * FROM runCommandReceipts") + .all() + .then((r) => r.results), + ).toHaveLength(0); f.run.status = "completed"; - expect( - await f.device.action(api.githubRuns.control, { key: "infra-stale", command }), - ).toMatchObject({ state: "rejected", code: "run_attempt_changed" }); + expect(await f.control({ key: "infra-wait", command })).toMatchObject({ + state: "accepted", + rerun: "observed", + runAttempt: 2, + }); + expect(await f.control({ key: "infra-wait", command })).toMatchObject({ + state: "accepted", + rerun: "observed", + runAttempt: 2, + }); + f.run.status = "completed"; + expect(await f.control({ key: "infra-stale", command })).toMatchObject({ + state: "rejected", + code: "run_attempt_changed", + }); expect(f.posts()).toHaveLength(1); }); test("disabling infrastructure retry prevents a pending attempt-scoped POST", async () => { const f = await fixture(); expect( - await f.device.action(api.githubRuns.control, { + await f.control({ key: "infra-disabled", command: { ...f.command, expectedRunAttempt: 1 }, }), @@ -740,7 +594,7 @@ test("disabling infrastructure retry prevents a pending attempt-scoped POST", as test("automatic retries refuse a run with unrelated workflow failures", async () => { const f = await fixture(); - await f.t.run((ctx) => ctx.db.patch("repositoryBindings", f.bindingId, { infraRerun: true })); + await f.patch({ infraRerun: 1 }); const original = f.fetch.getMockImplementation(); if (!original) throw new Error("Missing fetch"); f.fetch.mockImplementation(async (url, init) => @@ -755,10 +609,28 @@ test("automatic retries refuse a run with unrelated workflow failures", async () : original(url, init), ); expect( - await f.device.action(api.githubRuns.control, { + await f.control({ key: "infra-mixed", command: { ...f.command, expectedRunAttempt: 1, infraJobIds: [1] }, }), ).toMatchObject({ state: "rejected", code: "run_has_workflow_failures" }); expect(f.posts()).toHaveLength(0); }); + +test.each([false, true])( + "simultaneous requests reserve only one POST (attempt-scoped: %s)", + async (attemptScoped) => { + const f = await fixture(); + if (attemptScoped) await f.patch({ infraRerun: 1 }); + const command = { + ...f.command, + ...(attemptScoped ? { expectedRunAttempt: 1, infraJobIds: [1] } : {}), + }; + const outcomes = await Promise.all([ + f.control({ key: "race", command }), + f.control({ key: "race", command }), + ]); + expect(f.posts()).toHaveLength(1); + expect(outcomes.some((outcome) => outcome.state === "accepted")).toBe(true); + }, +); diff --git a/apps/cloud/test/scale-sets.test.ts b/apps/cloud/test/scale-sets.test.ts new file mode 100644 index 00000000..4839be45 --- /dev/null +++ b/apps/cloud/test/scale-sets.test.ts @@ -0,0 +1,351 @@ +import worker from "../src/index.js"; +import { createExecutionContext, waitOnExecutionContext } from "cloudflare:test"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { Schema } from "effect"; +import { ScaleSetGrant } from "../../../packages/protocol/src/scale-sets.js"; +import { runnerLabels } from "../../../packages/github/src/runner-labels.js"; +import { + env, + seedMachine, + seedApp, + seedAccount, + seedBinding, + readyLinux, +} from "../src/test-support.js"; +import { markScaleSetsForCleanup, sweepScaleSets } from "../src/runners/scale-sets.js"; +import type { ScaleSetRow } from "../src/db.js"; +const serviceUrl = "https://actions.example.test/tenant/repository/"; +const jwt = (exp: number) => `header.${btoa(JSON.stringify({ exp }))}.signature`; +afterEach(() => vi.restoreAllMocks()); +async function fixture() { + const machine = await seedMachine({ environments: [readyLinux()] }); + const account = await seedAccount(machine.owner); + const binding = await seedBinding({ + owner: machine.owner, + accountId: account.id, + machineId: machine.id, + capacityMode: "scale_set", + automatic: true, + }); + await seedApp(); + let set: + | { + id: number; + name: string; + runnerGroupId: number; + labels: Array<{ name: string; type: string }>; + } + | undefined; + let sessionId: string | undefined; + let nextSession = 0; + let runnerName = ""; + const queueToken = jwt(Math.floor(Date.now() / 1000) + 3600); + const adminToken = jwt(Math.floor(Date.now() / 1000) + 600); + const state = { + conflict: false, + dropLabels: false, + failDelete: 0, + installationId: 10, + repositoryId: 1000, + onSession: undefined as (() => Promise) | undefined, + onJit: undefined as (() => Promise) | undefined, + mismatchedRunner: false, + wrongRepositoryRunner: false, + failRefresh: 0, + }; + const session = () => ({ + sessionId, + messageQueueUrl: "https://queue.example.test/messages", + messageQueueAccessToken: queueToken, + statistics: { totalAssignedJobs: 2 }, + }); + const fetchMock = stubFetch(async (request) => { + const input = request.url; + const init = { + method: request.method, + headers: request.headers, + redirect: request.redirect, + body: request.method === "POST" ? await request.text() : undefined, + }; + const url = new URL(input); + const path = url.pathname; + if (url.hostname === "api.github.com") { + if (path.endsWith("/installation")) + return Response.json({ + id: state.installationId, + app_id: 1, + account: { id: 100, type: "User" }, + suspended_at: null, + }); + if (path.endsWith("/access_tokens")) return Response.json({ token: "installation-secret" }); + if (path.endsWith("/fork-pr-workflows-private-repos")) + return Response.json({ + run_workflows_from_fork_pull_requests: false, + require_approval_for_fork_pr_workflows: false, + }); + if (path.endsWith("/registration-token")) + return Response.json({ token: "registration-secret" }, { status: 201 }); + if (path === "/actions/runner-registration") { + expect(new Headers(init?.headers).get("Authorization")).toBe( + "RemoteAuth registration-secret", + ); + return Response.json({ url: serviceUrl, token: adminToken }); + } + if (path.endsWith("/actions/runners/501")) + return init?.method === "DELETE" + ? new Response(null, { status: 204 }) + : Response.json({ + id: 501, + name: state.wrongRepositoryRunner ? "foreign" : runnerName, + status: "online", + busy: false, + }); + if (path === "/repos/octo/repo") + return Response.json({ id: state.repositoryId, private: true, owner: { id: 100 } }); + } + if (url.hostname !== "actions.example.test") + throw new Error(`Unexpected synthetic request ${url.href}`); + expect(new Headers(init?.headers).get("Authorization")).toBe(`Bearer ${adminToken}`); + expect(init?.redirect).toBe("manual"); + if (path.endsWith("/runnergroups/")) + return Response.json({ + count: 1, + value: [{ id: 42, name: "Default", isDefaultGroup: true }], + }); + if (path.endsWith("/runnerscalesets")) { + if (init?.method === "POST") { + const body: unknown = JSON.parse(String(init.body)); + if (!body || typeof body !== "object" || !("name" in body) || !("labels" in body)) + throw new Error("Invalid set request"); + const labels = runnerLabels("linux", "linux"); + expect(body.labels).toEqual( + [...labels, body.name].map((name) => ({ name, type: "System" })), + ); + set = { + id: 17, + name: String(body.name), + runnerGroupId: 42, + labels: [...labels, String(body.name)].map((name) => ({ name, type: "System" })), + }; + return Response.json(set); + } + return Response.json({ count: set ? 1 : 0, value: set ? [set] : [] }); + } + if (path.endsWith("/runnerscalesets/17")) { + if (init?.method === "DELETE") { + if (state.failDelete-- > 0) return new Response(null, { status: 503 }); + set = undefined; + sessionId = undefined; + return new Response(null, { status: 204 }); + } + return Response.json({ + ...set, + labels: state.dropLabels ? [{ name: set?.name, type: "System" }] : set?.labels, + }); + } + if (path.endsWith("/sessions")) { + if (state.conflict || sessionId) return new Response(null, { status: 409 }); + sessionId = `c6400b58-622b-497d-9c97-${String(++nextSession).padStart(12, "0")}`; + await state.onSession?.(); + return Response.json(session()); + } + if (path.includes("/sessions/")) { + if (path.split("/").at(-1) !== sessionId) return new Response(null, { status: 404 }); + if (init?.method === "DELETE") { + sessionId = undefined; + return new Response(null, { status: 204 }); + } + if (state.failRefresh-- > 0) + return new Response("queue-secret admin-secret", { status: 503 }); + return Response.json(session()); + } + if (path.endsWith("/generatejitconfig")) { + const body: { name: string; workFolder: string } = JSON.parse(String(init?.body)); + runnerName = body.name; + expect(body.workFolder).toBe("_work"); + await state.onJit?.(); + return Response.json({ + runner: { id: 501, name: state.mismatchedRunner ? "foreign" : runnerName }, + encodedJITConfig: "c2VjcmV0", + }); + } + throw new Error(`Unexpected synthetic request ${url.href}`); + }); + + const open = async () => { + const response = await machineRequest("/machine/scale-sets/open", machine.bearer, { + bindingId: binding.id, + }); + expect(response.status).toBe(200); + return Schema.decodeUnknownSync(ScaleSetGrant)(await response.json()); + }; + const rows = async () => + ( + await env.DB.prepare("SELECT * FROM runnerScaleSets WHERE bindingId = ?") + .bind(binding.id) + .all() + ).results; + return { machine, binding, state, open, rows, fetchMock }; +} +it("opens, refreshes and closes a listener while fencing old generations", async () => { + const f = await fixture(); + const first = await f.open(); + expect(first).toMatchObject({ scaleSetId: 17, generation: 1 }); + const args = { bindingId: f.binding.id, generation: first.generation }; + expect( + await (await machineRequest("/machine/scale-sets/refresh", f.machine.bearer, args)).json(), + ).toMatchObject({ sessionId: first.sessionId }); + for (let i = 0; i < 2; i++) + expect((await machineRequest("/machine/scale-sets/close", f.machine.bearer, args)).status).toBe( + 200, + ); + expect(await f.rows()).toMatchObject([{ phase: "closed", sessionId: null }]); + const next = await f.open(); + expect(next.generation).toBe(2); + expect( + await (await machineRequest("/machine/scale-sets/close", f.machine.bearer, args)).json(), + ).toMatchObject({ code: "scale_set_superseded" }); +}); +it("cleans up remote sets after their binding and machine are deleted", async () => { + const f = await fixture(); + await f.open(); + await markScaleSetsForCleanup(env, [f.binding.id]); + await env.DB.prepare("DELETE FROM machines WHERE id = ?").bind(f.machine.id).run(); + expect(await f.rows()).toMatchObject([{ phase: "deleting", cleanupAttempt: 0 }]); + await sweepScaleSets(env, Date.now()); + expect(await f.rows()).toEqual([]); +}); +it("bounds cleanup retries and leaves terminal failures for explicit retry", async () => { + const f = await fixture(); + await f.open(); + f.state.failDelete = 8; + await markScaleSetsForCleanup(env, [f.binding.id]); + let now = Date.now(); + for (let i = 0; i < 8; i++) { + await sweepScaleSets(env, now); + now += 900001; + } + expect(await f.rows()).toMatchObject([ + { phase: "action_required", cleanupDueAt: null, cleanupAttempt: 8 }, + ]); + await markScaleSetsForCleanup(env, [f.binding.id]); + await sweepScaleSets(env, now); + expect(await f.rows()).toEqual([]); +}); +it("records a session conflict without leaking queue or admin credentials", async () => { + const f = await fixture(); + f.state.conflict = true; + const response = await machineRequest("/machine/scale-sets/open", f.machine.bearer, { + bindingId: f.binding.id, + }); + expect(await response.json()).toMatchObject({ code: "scale_set_session_conflict" }); + expect(await f.rows()).toMatchObject([{ phase: "action_required", sessionId: null }]); +}); +it("withholds a session created after cleanup fences its generation", async () => { + const f = await fixture(); + f.state.onSession = () => markScaleSetsForCleanup(env, [f.binding.id]); + const response = await machineRequest("/machine/scale-sets/open", f.machine.bearer, { + bindingId: f.binding.id, + }); + expect(await response.json()).toMatchObject({ code: "scale_set_superseded" }); + expect(await f.rows()).toMatchObject([{ phase: "deleting", generation: 2 }]); +}); +it("refuses unsupported actor policies before contacting GitHub", async () => { + const f = await fixture(); + await env.DB.prepare("UPDATE repositoryBindings SET accessJson = ? WHERE id = ?") + .bind(JSON.stringify({ actors: "any", approval: "required" }), f.binding.id) + .run(); + expect( + await ( + await machineRequest("/machine/scale-sets/open", f.machine.bearer, { + bindingId: f.binding.id, + }) + ).json(), + ).toMatchObject({ code: "scale_set_policy_unsupported" }); + expect(f.fetchMock).not.toHaveBeenCalled(); +}); + +async function machineRequest(path: string, bearer: string, body: unknown) { + const ctx = createExecutionContext(); + const response = await worker.fetch( + new Request(`https://cloud.test${path}`, { + method: "POST", + headers: { Authorization: `Bearer ${bearer}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + env, + ctx, + ); + await waitOnExecutionContext(ctx); + return response; +} +beforeEach(async () => { + await env.DB.batch( + ["machines", "githubAccounts", "githubApps", "githubJobs", "runnerScaleSets"].map((table) => + env.DB.prepare(`DELETE FROM ${table}`), + ), + ); +}); + +function stubFetch(handler: (request: Request) => Promise | Response) { + return vi + .spyOn(globalThis, "fetch") + .mockImplementation(async (input, init) => handler(new Request(input, init))); +} + +it("registers scale set JIT runners only when repository identity agrees", async () => { + const f = await fixture(); + await f.open(); + f.state.wrongRepositoryRunner = true; + const response = await machineRequest("/machine/runners/prepare", f.machine.bearer, { + bindingId: f.binding.id, + key: "scale-runner", + scaleSet: { generation: 1 }, + }); + expect(await response.json()).toMatchObject({ state: "action_required" }); + expect( + await env.DB.prepare("SELECT encodedConfig FROM runnerLeases").first("encodedConfig"), + ).toBeNull(); +}); +it("rejects labels changed at GitHub before creating JIT credentials", async () => { + const f = await fixture(); + await f.open(); + f.state.dropLabels = true; + const response = await machineRequest("/machine/runners/prepare", f.machine.bearer, { + bindingId: f.binding.id, + key: "changed-labels", + scaleSet: { generation: 1 }, + }); + expect(await response.json()).toMatchObject({ state: "action_required" }); + expect( + f.fetchMock.mock.calls.some(([r]) => + String(r instanceof Request ? r.url : r).includes("/generatejitconfig"), + ), + ).toBe(false); +}); +it("lets disabled automatic capacity close its session while refusing refresh", async () => { + const f = await fixture(); + const grant = await f.open(); + await env.DB.prepare("UPDATE repositoryBindings SET automatic = 0 WHERE id = ?") + .bind(f.binding.id) + .run(); + const args = { bindingId: f.binding.id, generation: grant.generation }; + expect( + await (await machineRequest("/machine/scale-sets/refresh", f.machine.bearer, args)).json(), + ).toMatchObject({ code: "automatic_disabled" }); + expect((await machineRequest("/machine/scale-sets/close", f.machine.bearer, args)).status).toBe( + 200, + ); + expect(await f.rows()).toMatchObject([{ phase: "closed" }]); +}); + +it("permits only one concurrent listener open", async () => { + const f = await fixture(); + const responses = await Promise.all( + [1, 2].map(() => + machineRequest("/machine/scale-sets/open", f.machine.bearer, { bindingId: f.binding.id }), + ), + ); + expect(responses.map((response) => response.status).sort()).toEqual([200, 409]); + expect(await f.rows()).toMatchObject([{ generation: 1, phase: "active" }]); +}); diff --git a/apps/cloud/test/setup.test.ts b/apps/cloud/test/setup.test.ts new file mode 100644 index 00000000..5f140db5 --- /dev/null +++ b/apps/cloud/test/setup.test.ts @@ -0,0 +1,28 @@ +import { expect, it } from "vitest"; +import { env, SELF } from "../src/test-support.js"; +import { createExecutionContext } from "cloudflare:test"; +import { appSetup } from "../src/github/setup.js"; +import { sha256Hex } from "../src/ids.js"; +it("rejects expired setup state and builds a manifest with the direct Worker webhook", async () => { + const state = "a".repeat(64); + await env.DB.prepare( + "INSERT INTO githubAppSetups (stateDigest,ownerId,ownerLogin,organization,expiresAt) VALUES (?,1,'octo',0,?)", + ) + .bind(await sha256Hex(state), Date.now() + 60000) + .run(); + const web = "https://site.invalid"; + const cloud = "https://cloud.invalid"; + const page = await appSetup( + { + env: { ...env, VECTIS_WEB_URL: web, VECTIS_CLOUD_URL: cloud }, + ctx: createExecutionContext(), + }, + new Request(`https://cloud.test/github/app/setup?state=${state}`), + ); + expect(page.status).toBe(200); + const html = await page.text(); + expect(html).toContain(`${cloud}/github/webhook`); + expect(html).toContain(`${web}/api/github/oauth/callback`); + await env.DB.prepare("UPDATE githubAppSetups SET consumed = 1").run(); + expect((await SELF.fetch(`https://cloud.test/github/app/setup?state=${state}`)).status).toBe(403); +}); diff --git a/apps/cloud/test/setup.ts b/apps/cloud/test/setup.ts new file mode 100644 index 00000000..34141efc --- /dev/null +++ b/apps/cloud/test/setup.ts @@ -0,0 +1,4 @@ +import { env } from "cloudflare:workers"; +import { applyD1Migrations } from "cloudflare:test"; + +await applyD1Migrations(env.DB, env.TEST_MIGRATIONS); diff --git a/apps/cloud/test/sync.test.ts b/apps/cloud/test/sync.test.ts new file mode 100644 index 00000000..dffe4e18 --- /dev/null +++ b/apps/cloud/test/sync.test.ts @@ -0,0 +1,83 @@ +import { expect, it } from "vitest"; +import { env, machineRequest, seedAccount, seedBinding, seedMachine } from "../src/test-support.js"; +import { submitOperation, cancelOperation } from "../src/operations.js"; + +it("returns one snapshot with active operations in creation order and only live pending inspections", async () => { + const machine = await seedMachine(); + const first = await submitOperation(env, machine.owner, { + machineId: machine.id, + key: "first", + commandJson: JSON.stringify({ type: "runner.run", bindingId: "binding" }), + }); + const cancelled = await submitOperation(env, machine.owner, { + machineId: machine.id, + key: "cancelled", + commandJson: JSON.stringify({ type: "runner.run", bindingId: "binding" }), + }); + await cancelOperation(env, machine.owner, cancelled); + await env.DB.prepare( + "INSERT INTO inspections (id, owner, machineId, key, queryJson, expiresAt, pending) VALUES ('expired', ?, ?, 'expired', '{}', 0, 1)", + ) + .bind(machine.owner, machine.id) + .run(); + const response = await machineRequest("/machine/sync", machine.bearer, { leaseIds: [] }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + operations: [{ id: first, phase: "accepted", cancelRequested: false }], + inspections: [], + repositories: [], + observations: [], + jobMarkers: {}, + }); +}); + +it("reports job markers for exactly the repositories the relay tracks", async () => { + const machine = await seedMachine(); + const account = await seedAccount(machine.owner); + const ids: string[] = []; + for (let index = 0; index < 22; index++) + ids.push( + ( + await seedBinding({ + owner: machine.owner, + accountId: account.id, + machineId: machine.id, + repositoryId: 1000 + index, + }) + ).id, + ); + // Re-verifying the oldest binding must not move it into the tracked window. + await env.DB.prepare("UPDATE repositoryBindings SET verifiedAt = ? WHERE id = ?") + .bind(Date.now() + 60_000, ids[0]) + .run(); + const response = await machineRequest("/machine/sync", machine.bearer, { leaseIds: [] }); + const snapshot = (await response.json()) as { + repositories: { id: string }[]; + jobMarkers: Record; + }; + const tracked = snapshot.repositories.slice(-20).map(({ id }) => id); + expect(tracked).toEqual(ids.slice(2)); + expect(Object.keys(snapshot.jobMarkers).sort()).toEqual([...tracked].sort()); +}); + +it("returns every active operation when a machine is at the submission limit", async () => { + const machine = await seedMachine(); + const now = Date.now(); + await env.DB.batch( + Array.from({ length: 100 }, (_, index) => + env.DB.prepare( + "INSERT INTO operations (id, owner, machineId, key, commandJson, phase, createdAt, updatedAt) VALUES (?, ?, ?, ?, '{}', 'claimed', ?, ?)", + ).bind(`op${index}`, machine.owner, machine.id, `key-${index}`, now + index, now), + ), + ); + await expect( + submitOperation(env, machine.owner, { + machineId: machine.id, + key: "one-more", + commandJson: JSON.stringify({ type: "machine.pause", paused: true }), + }), + ).rejects.toMatchObject({ code: "queue_full" }); + const response = await machineRequest("/machine/sync", machine.bearer, { leaseIds: [] }); + const snapshot = (await response.json()) as { operations: { id: string }[] }; + expect(snapshot.operations).toHaveLength(100); +}); diff --git a/apps/cloud/test/webhook.test.ts b/apps/cloud/test/webhook.test.ts new file mode 100644 index 00000000..f9760f98 --- /dev/null +++ b/apps/cloud/test/webhook.test.ts @@ -0,0 +1,143 @@ +import { beforeEach, expect, it } from "vitest"; +import { env, SELF, seedApp } from "../src/test-support.js"; +import { githubWebhook } from "../src/github/webhook.js"; + +beforeEach(async () => { + await env.DB.batch([ + env.DB.prepare("DELETE FROM githubApps"), + env.DB.prepare("DELETE FROM githubDeliveries"), + env.DB.prepare("DELETE FROM githubJobs"), + ]); +}); +async function send(body: string, event = "workflow_job", signature?: string) { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode("webhook-secret"), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const digest = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body)); + const hex = [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + return SELF.fetch("https://cloud.test/github/webhook", { + method: "POST", + body, + headers: { + "x-github-event": event, + "x-github-delivery": "test-delivery", + "x-hub-signature-256": signature ?? `sha256=${hex}`, + }, + }); +} +it("checks headers, bounded bodies, setup, signature, and payload", async () => { + expect((await send("{}", "unknown")).status).toBe(400); + expect((await send("x".repeat(1024 * 1024 + 1))).status).toBe(413); + expect((await send("{}")).status).toBe(503); + await seedApp(); + expect((await send("{}", "workflow_job", "bad")).status).toBe(401); + expect((await send("invalid")).status).toBe(400); + expect((await send("{}", "ping")).status).toBe(200); +}); +it("deduplicates workflow jobs and strips unverified webhook actors", async () => { + await seedApp(); + const payload = JSON.stringify({ + installation: { id: 10 }, + repository: { id: 100 }, + workflow_job: { + id: 1, + run_id: 2, + name: "build", + status: "in_progress", + conclusion: null, + labels: [], + actor_id: 9, + triggering_actor_id: 8, + }, + }); + const first = await send(payload); + expect(first.status).toBe(202); + expect(await first.json()).toEqual({ accepted: true, duplicate: false }); + expect(await (await send(payload)).json()).toEqual({ accepted: true, duplicate: true }); + expect(await env.DB.prepare("SELECT actorId, triggeringActorId FROM githubJobs").first()).toEqual( + { actorId: null, triggeringActorId: null }, + ); +}); + +it("enriches restrictive bindings asynchronously using verified run actors", async () => { + await seedApp(); + const { seedMachine, seedAccount, seedBinding } = await import("../src/test-support.js"); + const { GitHubAppClient } = await import("../../../packages/github/src/app-client.js"); + const { vi } = await import("vitest"); + const machine = await seedMachine(); + const account = await seedAccount(machine.owner); + await seedBinding({ + owner: machine.owner, + accountId: account.id, + machineId: machine.id, + repositoryId: 100, + access: { actors: "any", approval: "required" }, + }); + const token = vi.spyOn(GitHubAppClient.prototype, "repositoryToken").mockResolvedValue({ + token: "token", + path: "/repos/octo/repo", + installationId: 10, + repositoryId: 100, + }); + const request = vi.spyOn(GitHubAppClient.prototype, "request").mockResolvedValue({ + id: 2, + run_attempt: 1, + repository: { id: 100 }, + actor: { id: 10 }, + triggering_actor: { id: 11 }, + }); + try { + const { createExecutionContext, waitOnExecutionContext } = await import("cloudflare:test"); + const ctx = createExecutionContext(); + const body = JSON.stringify({ + installation: { id: 10 }, + repository: { id: 100 }, + workflow_job: { + id: 3, + run_id: 2, + name: "build", + status: "in_progress", + conclusion: null, + labels: [], + }, + }); + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode("webhook-secret"), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const digest = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body)); + const hex = [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + await githubWebhook( + { env, ctx }, + new Request("https://cloud.test/github/webhook", { + method: "POST", + body, + headers: { + "x-github-event": "workflow_job", + "x-github-delivery": "enrich", + "x-hub-signature-256": `sha256=${hex}`, + }, + }), + ); + await waitOnExecutionContext(ctx); + expect( + await env.DB.prepare( + "SELECT actorId, triggeringActorId FROM githubJobs WHERE jobId = 3", + ).first(), + ).toEqual({ actorId: 10, triggeringActorId: 11 }); + } finally { + token.mockRestore(); + request.mockRestore(); + } +}); diff --git a/apps/cloud/tsconfig.json b/apps/cloud/tsconfig.json new file mode 100644 index 00000000..8030be9c --- /dev/null +++ b/apps/cloud/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": ["ES2023"], + "types": ["@cloudflare/vitest-plugin/types", "node"], + "noEmit": true + }, + "include": ["src/**/*.ts", "test/**/*.ts", "worker-configuration.d.ts"] +} diff --git a/apps/cloud/vitest.config.ts b/apps/cloud/vitest.config.ts new file mode 100644 index 00000000..f117a2d1 --- /dev/null +++ b/apps/cloud/vitest.config.ts @@ -0,0 +1,23 @@ +import { fileURLToPath } from "node:url"; +import { cloudflareTest, readD1Migrations } from "@cloudflare/vitest-plugin"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [ + cloudflareTest(async () => ({ + wrangler: { configPath: "./wrangler.jsonc" }, + miniflare: { + bindings: { + TEST_MIGRATIONS: await readD1Migrations( + fileURLToPath(new URL("migrations", import.meta.url)), + ), + }, + }, + })), + ], + test: { + name: "cloud", + include: ["test/**/*.test.ts", "src/**/*.test.ts"], + setupFiles: ["./test/setup.ts"], + }, +}); diff --git a/apps/cloud/worker-configuration.d.ts b/apps/cloud/worker-configuration.d.ts new file mode 100644 index 00000000..e357029a --- /dev/null +++ b/apps/cloud/worker-configuration.d.ts @@ -0,0 +1,16232 @@ +/* eslint-disable */ +// Runtime types generated with workerd@1.20261023.1 2026-09-15 nodejs_compat +// Begin runtime types +/*! ***************************************************************************** +Copyright (c) Cloudflare. All rights reserved. +Copyright (c) Microsoft Corporation. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. You may obtain a copy of the +License at http://www.apache.org/licenses/LICENSE-2.0 +THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED +WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE, +MERCHANTABLITY OR NON-INFRINGEMENT. +See the Apache Version 2.0 License for specific language governing permissions +and limitations under the License. +***************************************************************************** */ +/* eslint-disable */ +// noinspection JSUnusedGlobalSymbols +declare var onmessage: never; +/** + * The **`DOMException`** interface represents an abnormal event (called an exception) that occurs as a result of calling a method or accessing a property of a web API. This is how error conditions are described in web APIs. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException) + */ +declare class DOMException extends Error { + constructor(message?: string, name?: string); + /** + * The **`message`** read-only property of the DOMException interface returns a string representing a message or description associated with the given error name. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/message) + */ + readonly message: string; + /** + * The **`name`** read-only property of the DOMException interface returns a string that contains one of the strings associated with an error name. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/name) + */ + readonly name: string; + /** + * The **`code`** read-only property of the DOMException interface returns one of the legacy error code constants, or 0 if none match. + * @deprecated + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/code) + */ + readonly code: number; + static readonly INDEX_SIZE_ERR: number; + static readonly DOMSTRING_SIZE_ERR: number; + static readonly HIERARCHY_REQUEST_ERR: number; + static readonly WRONG_DOCUMENT_ERR: number; + static readonly INVALID_CHARACTER_ERR: number; + static readonly NO_DATA_ALLOWED_ERR: number; + static readonly NO_MODIFICATION_ALLOWED_ERR: number; + static readonly NOT_FOUND_ERR: number; + static readonly NOT_SUPPORTED_ERR: number; + static readonly INUSE_ATTRIBUTE_ERR: number; + static readonly INVALID_STATE_ERR: number; + static readonly SYNTAX_ERR: number; + static readonly INVALID_MODIFICATION_ERR: number; + static readonly NAMESPACE_ERR: number; + static readonly INVALID_ACCESS_ERR: number; + static readonly VALIDATION_ERR: number; + static readonly TYPE_MISMATCH_ERR: number; + static readonly SECURITY_ERR: number; + static readonly NETWORK_ERR: number; + static readonly ABORT_ERR: number; + static readonly URL_MISMATCH_ERR: number; + static readonly QUOTA_EXCEEDED_ERR: number; + static readonly TIMEOUT_ERR: number; + static readonly INVALID_NODE_TYPE_ERR: number; + static readonly DATA_CLONE_ERR: number; + get stack(): any; + set stack(value: any); +} +type WorkerGlobalScopeEventMap = { + fetch: FetchEvent; + scheduled: ScheduledEvent; + queue: QueueEvent; + unhandledrejection: PromiseRejectionEvent; + rejectionhandled: PromiseRejectionEvent; +}; +declare abstract class WorkerGlobalScope extends EventTarget { + EventTarget: typeof EventTarget; +} +/* The **`console`** object provides access to the debugging console (e.g., the Web console in Firefox). * + * The **`console`** object provides access to the debugging console (e.g., the Web console in Firefox). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console) + */ +interface Console { + "assert"(condition?: boolean, ...data: any[]): void; + /** + * The **`console.clear()`** static method clears the console if possible. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/clear_static) + */ + clear(): void; + /** + * The **`console.count()`** static method logs the number of times that this particular call to count() has been called. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/count_static) + */ + count(label?: string): void; + /** + * The **`console.countReset()`** static method resets counter used with console.count(). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/countReset_static) + */ + countReset(label?: string): void; + /** + * The **`console.debug()`** static method outputs a message to the console at the "debug" log level. The message is only displayed to the user if the console is configured to display debug output. In most cases, the log level is configured within the console UI. This log level might correspond to the Debug or Verbose log level. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/debug_static) + */ + debug(...data: any[]): void; + /** + * The **`console.dir()`** static method displays a list of the properties of the specified JavaScript object. In browser consoles, the output is presented as a hierarchical listing with disclosure triangles that let you see the contents of child objects. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dir_static) + */ + dir(item?: any, options?: any): void; + /** + * The **`console.dirxml()`** static method displays an interactive tree of the descendant elements of the specified XML/HTML element. If it is not possible to display as an element the JavaScript Object view is shown instead. The output is presented as a hierarchical listing of expandable nodes that let you see the contents of child nodes. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dirxml_static) + */ + dirxml(...data: any[]): void; + /** + * The **`console.error()`** static method outputs a message to the console at the "error" log level. The message is only displayed to the user if the console is configured to display error output. In most cases, the log level is configured within the console UI. The message may be formatted as an error, with red colors and call stack information. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/error_static) + */ + error(...data: any[]): void; + /** + * The **`console.group()`** static method creates a new inline group in the Web console log, causing any subsequent console messages to be indented by an additional level, until console.groupEnd() is called. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/group_static) + */ + group(...data: any[]): void; + /** + * The **`console.groupCollapsed()`** static method creates a new inline group in the console. Unlike console.group(), however, the new group is created collapsed. The user will need to use the disclosure button next to it to expand it, revealing the entries created in the group. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupCollapsed_static) + */ + groupCollapsed(...data: any[]): void; + /** + * The **`console.groupEnd()`** static method exits the current inline group in the console. See Using groups in the console in the console documentation for details and examples. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupEnd_static) + */ + groupEnd(): void; + /** + * The **`console.info()`** static method outputs a message to the console at the "info" log level. The message is only displayed to the user if the console is configured to display info output. In most cases, the log level is configured within the console UI. The message may receive special formatting, such as a small "i" icon next to it. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/info_static) + */ + info(...data: any[]): void; + /** + * The **`console.log()`** static method outputs a message to the console. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static) + */ + log(...data: any[]): void; + /** + * The **`console.table()`** static method displays tabular data as a table. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/table_static) + */ + table(tabularData?: any, properties?: string[]): void; + /** + * The **`console.time()`** static method starts a timer you can use to track how long an operation takes. You give each timer a unique name, and may have up to 10,000 timers running on a given page. When you call console.timeEnd() with the same name, the browser will output the time, in milliseconds, that elapsed since the timer was started. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/time_static) + */ + time(label?: string): void; + /** + * The **`console.timeEnd()`** static method stops a timer that was previously started by calling console.time(). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeEnd_static) + */ + timeEnd(label?: string): void; + /** + * The **`console.timeLog()`** static method logs the current value of a timer that was previously started by calling console.time(). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeLog_static) + */ + timeLog(label?: string, ...data: any[]): void; + /* The **`console.timeStamp()`** static method adds a single marker to the browser's Performance tool (Firefox bug 1387528, Chrome). This lets you correlate a point in your code with the other events recorded in the timeline, such as layout and paint events. */ + timeStamp(label?: string): void; + /** + * The **`console.trace()`** static method outputs a stack trace to the console. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/trace_static) + */ + trace(...data: any[]): void; + /** + * The **`console.warn()`** static method outputs a warning message to the console at the "warning" log level. The message is only displayed to the user if the console is configured to display warning output. In most cases, the log level is configured within the console UI. The message may receive special formatting, such as yellow colors and a warning icon. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/warn_static) + */ + warn(...data: any[]): void; +} +declare const console: Console; +type BufferSource = ArrayBufferView | ArrayBuffer; +type TypedArray = Int8Array | Uint8Array | Uint8ClampedArray | Int16Array | Uint16Array | Int32Array | Uint32Array | Float32Array | Float64Array | BigInt64Array | BigUint64Array; +declare namespace WebAssembly { + class CompileError extends Error { + constructor(message?: string); + } + class RuntimeError extends Error { + constructor(message?: string); + } + type ValueType = "anyfunc" | "externref" | "f32" | "f64" | "i32" | "i64" | "v128"; + interface GlobalDescriptor { + value: ValueType; + mutable?: boolean; + } + class Global { + constructor(descriptor: GlobalDescriptor, value?: any); + value: any; + valueOf(): any; + } + type ImportValue = ExportValue | number; + type ModuleImports = Record; + type Imports = Record; + type ExportValue = Function | Global | Memory | Table; + type Exports = Record; + class Instance { + constructor(module: Module, imports?: Imports); + readonly exports: Exports; + } + interface MemoryDescriptor { + initial: number; + maximum?: number; + shared?: boolean; + } + class Memory { + constructor(descriptor: MemoryDescriptor); + readonly buffer: ArrayBuffer; + grow(delta: number): number; + } + type ImportExportKind = "function" | "global" | "memory" | "table"; + interface ModuleExportDescriptor { + kind: ImportExportKind; + name: string; + } + interface ModuleImportDescriptor { + kind: ImportExportKind; + module: string; + name: string; + } + abstract class Module { + static customSections(module: Module, sectionName: string): ArrayBuffer[]; + static exports(module: Module): ModuleExportDescriptor[]; + static imports(module: Module): ModuleImportDescriptor[]; + } + type TableKind = "anyfunc" | "externref"; + interface TableDescriptor { + element: TableKind; + initial: number; + maximum?: number; + } + class Table { + constructor(descriptor: TableDescriptor, value?: any); + readonly length: number; + get(index: number): any; + grow(delta: number, value?: any): number; + set(index: number, value?: any): void; + } + function instantiate(module: Module, imports?: Imports): Promise; + function validate(bytes: BufferSource): boolean; +} +/** + * The **`ServiceWorkerGlobalScope`** interface of the Service Worker API represents the global execution context of a service worker. + * Available only in secure contexts. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ServiceWorkerGlobalScope) + */ +interface ServiceWorkerGlobalScope extends WorkerGlobalScope { + DOMException: typeof DOMException; + WorkerGlobalScope: typeof WorkerGlobalScope; + btoa(data: string): string; + atob(data: string): string; + setTimeout(callback: (...args: any[]) => void, msDelay?: number): number; + setTimeout(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number; + clearTimeout(timeoutId: number | null): void; + setInterval(callback: (...args: any[]) => void, msDelay?: number): number; + setInterval(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number; + clearInterval(timeoutId: number | null): void; + queueMicrotask(task: Function): void; + structuredClone(value: T, options?: StructuredSerializeOptions): T; + reportError(error: any): void; + fetch(input: RequestInfo | URL, init?: RequestInit): Promise; + self: ServiceWorkerGlobalScope; + crypto: Crypto; + caches: CacheStorage; + scheduler: Scheduler; + performance: Performance; + Cloudflare: Cloudflare; + readonly origin: string; + Event: typeof Event; + ExtendableEvent: typeof ExtendableEvent; + CustomEvent: typeof CustomEvent; + PromiseRejectionEvent: typeof PromiseRejectionEvent; + FetchEvent: typeof FetchEvent; + TailEvent: typeof TailEvent; + TraceEvent: typeof TailEvent; + ScheduledEvent: typeof ScheduledEvent; + MessageEvent: typeof MessageEvent; + CloseEvent: typeof CloseEvent; + ReadableStreamDefaultReader: typeof ReadableStreamDefaultReader; + ReadableStreamBYOBReader: typeof ReadableStreamBYOBReader; + ReadableStream: typeof ReadableStream; + WritableStream: typeof WritableStream; + WritableStreamDefaultWriter: typeof WritableStreamDefaultWriter; + TransformStream: typeof TransformStream; + ByteLengthQueuingStrategy: typeof ByteLengthQueuingStrategy; + CountQueuingStrategy: typeof CountQueuingStrategy; + ErrorEvent: typeof ErrorEvent; + MessageChannel: typeof MessageChannel; + MessagePort: typeof MessagePort; + EventSource: typeof EventSource; + ReadableStreamBYOBRequest: typeof ReadableStreamBYOBRequest; + ReadableStreamDefaultController: typeof ReadableStreamDefaultController; + ReadableByteStreamController: typeof ReadableByteStreamController; + WritableStreamDefaultController: typeof WritableStreamDefaultController; + TransformStreamDefaultController: typeof TransformStreamDefaultController; + CompressionStream: typeof CompressionStream; + DecompressionStream: typeof DecompressionStream; + TextEncoderStream: typeof TextEncoderStream; + TextDecoderStream: typeof TextDecoderStream; + Headers: typeof Headers; + Body: typeof Body; + Request: typeof Request; + Response: typeof Response; + WebSocket: typeof WebSocket; + WebSocketPair: typeof WebSocketPair; + WebSocketRequestResponsePair: typeof WebSocketRequestResponsePair; + AbortController: typeof AbortController; + AbortSignal: typeof AbortSignal; + TextDecoder: typeof TextDecoder; + TextEncoder: typeof TextEncoder; + navigator: Navigator; + Navigator: typeof Navigator; + URL: typeof URL; + URLSearchParams: typeof URLSearchParams; + URLPattern: typeof URLPattern; + Blob: typeof Blob; + File: typeof File; + FormData: typeof FormData; + Crypto: typeof Crypto; + SubtleCrypto: typeof SubtleCrypto; + CryptoKey: typeof CryptoKey; + CacheStorage: typeof CacheStorage; + Cache: typeof Cache; + FixedLengthStream: typeof FixedLengthStream; + IdentityTransformStream: typeof IdentityTransformStream; + HTMLRewriter: typeof HTMLRewriter; +} +declare function addEventListener(type: Type, handler: EventListenerOrEventListenerObject, options?: EventTargetAddEventListenerOptions | boolean): void; +declare function removeEventListener(type: Type, handler: EventListenerOrEventListenerObject, options?: EventTargetEventListenerOptions | boolean): void; +/** + * The **`dispatchEvent()`** method of the EventTarget sends an Event to the object, (synchronously) invoking the affected event listeners in the appropriate order. The normal event processing rules (including the capturing and optional bubbling phase) also apply to events dispatched manually with dispatchEvent(). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/dispatchEvent) + */ +declare function dispatchEvent(event: WorkerGlobalScopeEventMap[keyof WorkerGlobalScopeEventMap]): boolean; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/btoa) */ +declare function btoa(data: string): string; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/atob) */ +declare function atob(data: string): string; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setTimeout) */ +declare function setTimeout(callback: (...args: any[]) => void, msDelay?: number): number; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setTimeout) */ +declare function setTimeout(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/clearTimeout) */ +declare function clearTimeout(timeoutId: number | null): void; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setInterval) */ +declare function setInterval(callback: (...args: any[]) => void, msDelay?: number): number; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setInterval) */ +declare function setInterval(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/clearInterval) */ +declare function clearInterval(timeoutId: number | null): void; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/queueMicrotask) */ +declare function queueMicrotask(task: Function): void; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/structuredClone) */ +declare function structuredClone(value: T, options?: StructuredSerializeOptions): T; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/reportError) */ +declare function reportError(error: any): void; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/fetch) */ +declare function fetch(input: RequestInfo | URL, init?: RequestInit): Promise; +declare const self: ServiceWorkerGlobalScope; +/** +* The Web Crypto API provides a set of low-level functions for common cryptographic tasks. +* The Workers runtime implements the full surface of this API, but with some differences in +* the [supported algorithms](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/#supported-algorithms) +* compared to those implemented in most browsers. +* +* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/) +*/ +declare const crypto: Crypto; +/** +* The Cache API allows fine grained control of reading and writing from the Cloudflare global network cache. +* +* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/) +*/ +declare const caches: CacheStorage; +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/scheduler) */ +declare const scheduler: Scheduler; +/** +* The Workers runtime supports a subset of the Performance API, used to measure timing and performance, +* as well as timing of subrequests and other operations. +* +* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/) +*/ +declare const performance: Performance; +declare const Cloudflare: Cloudflare; +declare const origin: string; +declare const navigator: Navigator; +interface TestController { +} +interface ExecutionContext { + waitUntil(promise: Promise): void; + passThroughOnException(): void; + readonly exports: Cloudflare.Exports; + readonly props: Props; + cache?: CacheContext; + readonly access?: CloudflareAccessContext; + tracing: Tracing; + abort(reason?: any): void; +} +type ExportedHandlerFetchHandler = (request: Request>, env: Env, ctx: ExecutionContext) => Response | Promise; +type ExportedHandlerConnectHandler = (socket: Socket, env: Env, ctx: ExecutionContext) => void | Promise; +type ExportedHandlerTailHandler = (events: TraceItem[], env: Env, ctx: ExecutionContext) => void | Promise; +type ExportedHandlerTraceHandler = (traces: TraceItem[], env: Env, ctx: ExecutionContext) => void | Promise; +type ExportedHandlerTailStreamHandler = (event: TailStream.TailEvent, env: Env, ctx: ExecutionContext) => TailStream.TailEventHandlerType | undefined | Promise; +type ExportedHandlerScheduledHandler = (controller: ScheduledController, env: Env, ctx: ExecutionContext) => void | Promise; +type ExportedHandlerQueueHandler = (batch: MessageBatch, env: Env, ctx: ExecutionContext) => void | Promise; +type ExportedHandlerTestHandler = (controller: TestController, env: Env, ctx: ExecutionContext) => void | Promise; +interface ExportedHandler { + fetch?: ExportedHandlerFetchHandler; + connect?: ExportedHandlerConnectHandler; + tail?: ExportedHandlerTailHandler; + trace?: ExportedHandlerTraceHandler; + tailStream?: ExportedHandlerTailStreamHandler; + scheduled?: ExportedHandlerScheduledHandler; + test?: ExportedHandlerTestHandler; + email?: EmailExportedHandler; + queue?: ExportedHandlerQueueHandler; +} +interface StructuredSerializeOptions { + transfer?: any[]; +} +declare abstract class Navigator { + sendBeacon(url: string, body?: BodyInit): boolean; + readonly userAgent: string; + readonly hardwareConcurrency: number; + readonly platform: string; + readonly language: string; + readonly languages: string[]; +} +interface AlarmInvocationInfo { + readonly isRetry: boolean; + readonly retryCount: number; + readonly scheduledTime: number; +} +interface Cloudflare { + readonly compatibilityFlags: Record; +} +interface CachePurgeError { + code: number; + message: string; +} +interface CachePurgeResult { + success: boolean; + errors: CachePurgeError[]; +} +interface CachePurgeOptions { + tags?: string[]; + pathPrefixes?: string[]; + purgeEverything?: boolean; +} +interface CacheContext { + purge(options: CachePurgeOptions): Promise; + invalidate(options: CachePurgeOptions): Promise; +} +interface CloudflareAccessContext { + readonly aud: string; + getIdentity(): Promise; +} +declare abstract class ColoLocalActorNamespace { + get(actorId: string): Fetcher; +} +interface DurableObject { + fetch(request: Request): Response | Promise; + connect?(socket: Socket): void | Promise; + alarm?(alarmInfo?: AlarmInvocationInfo): void | Promise; + webSocketMessage?(ws: WebSocket, message: string | ArrayBuffer): void | Promise; + webSocketClose?(ws: WebSocket, code: number, reason: string, wasClean: boolean): void | Promise; + webSocketError?(ws: WebSocket, error: unknown): void | Promise; +} +type DurableObjectStub = Fetcher & { + readonly id: DurableObjectId; + readonly name?: string; +}; +interface DurableObjectId { + toString(): string; + equals(other: DurableObjectId): boolean; + readonly name?: string; + readonly jurisdiction?: string; +} +declare abstract class DurableObjectNamespace { + newUniqueId(options?: DurableObjectNamespaceNewUniqueIdOptions): DurableObjectId; + idFromName(name: string): DurableObjectId; + idFromString(id: string): DurableObjectId; + get(id: DurableObjectId, options?: DurableObjectNamespaceGetDurableObjectOptions): DurableObjectStub; + getByName(name: string, options?: DurableObjectNamespaceGetDurableObjectOptions): DurableObjectStub; + jurisdiction(jurisdiction: DurableObjectJurisdiction): DurableObjectNamespace; +} +type DurableObjectJurisdiction = "eu" | "fedramp" | "fedramp-high" | "us"; +interface DurableObjectNamespaceNewUniqueIdOptions { + jurisdiction?: DurableObjectJurisdiction; +} +type DurableObjectLocationHint = "wnam" | "enam" | "sam" | "weur" | "eeur" | "apac" | "apac-ne" | "apac-se" | "oc" | "afr" | "me"; +type DurableObjectRoutingMode = "primary-only"; +interface DurableObjectNamespaceGetDurableObjectOptions { + locationHint?: DurableObjectLocationHint; + routingMode?: DurableObjectRoutingMode; +} +interface DurableObjectClass<_T extends Rpc.DurableObjectBranded | undefined = undefined> { +} +interface DurableObjectState { + waitUntil(promise: Promise): void; + readonly exports: Cloudflare.Exports; + readonly props: Props; + readonly id: DurableObjectId; + readonly storage: DurableObjectStorage; + container?: Container; + facets: DurableObjectFacets; + blockConcurrencyWhile(callback: () => Promise): Promise; + acceptWebSocket(ws: WebSocket, tags?: string[]): void; + getWebSockets(tag?: string): WebSocket[]; + setWebSocketAutoResponse(maybeReqResp?: WebSocketRequestResponsePair): void; + getWebSocketAutoResponse(): WebSocketRequestResponsePair | null; + getWebSocketAutoResponseTimestamp(ws: WebSocket): Date | null; + setHibernatableWebSocketEventTimeout(timeoutMs?: number): void; + getHibernatableWebSocketEventTimeout(): number | null; + getTags(ws: WebSocket): string[]; + abort(reason?: string, options?: DurableObjectAbortOptions): void; +} +interface DurableObjectTransaction { + get(key: string, options?: DurableObjectGetOptions): Promise; + get(keys: string[], options?: DurableObjectGetOptions): Promise>; + list(options?: DurableObjectListOptions): Promise>; + put(key: string, value: T, options?: DurableObjectPutOptions): Promise; + put(entries: Record, options?: DurableObjectPutOptions): Promise; + delete(key: string, options?: DurableObjectPutOptions): Promise; + delete(keys: string[], options?: DurableObjectPutOptions): Promise; + rollback(): void; + getAlarm(options?: DurableObjectGetAlarmOptions): Promise; + setAlarm(scheduledTime: number | Date, options?: DurableObjectSetAlarmOptions): Promise; + deleteAlarm(options?: DurableObjectSetAlarmOptions): Promise; +} +interface DurableObjectStorage { + get(key: string, options?: DurableObjectGetOptions): Promise; + get(keys: string[], options?: DurableObjectGetOptions): Promise>; + list(options?: DurableObjectListOptions): Promise>; + put(key: string, value: T, options?: DurableObjectPutOptions): Promise; + put(entries: Record, options?: DurableObjectPutOptions): Promise; + delete(key: string, options?: DurableObjectPutOptions): Promise; + delete(keys: string[], options?: DurableObjectPutOptions): Promise; + deleteAll(options?: DurableObjectPutOptions): Promise; + transaction(closure: (txn: DurableObjectTransaction) => Promise): Promise; + getAlarm(options?: DurableObjectGetAlarmOptions): Promise; + setAlarm(scheduledTime: number | Date, options?: DurableObjectSetAlarmOptions): Promise; + deleteAlarm(options?: DurableObjectSetAlarmOptions): Promise; + sync(): Promise; + sql: SqlStorage; + kv: SyncKvStorage; + transactionSync(closure: () => T): T; + getCurrentBookmark(): Promise; + getBookmarkForTime(timestamp: number | Date): Promise; + onNextSessionRestoreBookmark(bookmark: string): Promise; +} +interface DurableObjectAbortOptions { + retryAlarm?: boolean; +} +interface DurableObjectListOptions { + start?: string; + startAfter?: string; + end?: string; + prefix?: string; + reverse?: boolean; + limit?: number; + allowConcurrency?: boolean; + noCache?: boolean; +} +interface DurableObjectGetOptions { + allowConcurrency?: boolean; + noCache?: boolean; +} +interface DurableObjectGetAlarmOptions { + allowConcurrency?: boolean; +} +interface DurableObjectPutOptions { + allowConcurrency?: boolean; + allowUnconfirmed?: boolean; + noCache?: boolean; +} +interface DurableObjectSetAlarmOptions { + allowConcurrency?: boolean; + allowUnconfirmed?: boolean; +} +declare class WebSocketRequestResponsePair { + constructor(request: string, response: string); + get request(): string; + get response(): string; +} +interface DurableObjectFacets { + get(name: string, getStartupOptions: () => FacetStartupOptions | Promise>): Fetcher; + abort(name: string, reason: any): void; + delete(name: string): void; + clone(src: string, dst: string): void; +} +interface FacetStartupOptions { + id?: DurableObjectId | string; + class: DurableObjectClass; +} +interface AnalyticsEngineDataset { + writeDataPoint(event?: AnalyticsEngineDataPoint): void; +} +interface AnalyticsEngineDataPoint { + indexes?: ((ArrayBuffer | string) | null)[]; + doubles?: number[]; + blobs?: ((ArrayBuffer | string) | null)[]; +} +/** + * The **`Event`** interface represents an event which takes place on an EventTarget. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event) + */ +declare class Event { + constructor(type: string, init?: EventInit); + /** + * The **`type`** read-only property of the Event interface returns a string containing the event's type. It is set when the event is constructed and is the name commonly used to refer to the specific event, such as click, load, or error. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/type) + */ + get type(): string; + /** + * The **`eventPhase`** read-only property of the Event interface indicates which phase of the event flow is currently being evaluated. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/eventPhase) + */ + get eventPhase(): number; + /** + * The read-only **`composed`** property of the Event interface returns a boolean value which indicates whether or not the event will propagate across the shadow DOM boundary into the standard DOM. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/composed) + */ + get composed(): boolean; + /** + * The **`bubbles`** read-only property of the Event interface indicates whether the event bubbles up through the DOM tree or not. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/bubbles) + */ + get bubbles(): boolean; + /** + * The **`cancelable`** read-only property of the Event interface indicates whether the event can be canceled, and therefore prevented as if the event never happened. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelable) + */ + get cancelable(): boolean; + /** + * The **`defaultPrevented`** read-only property of the Event interface returns a boolean value indicating whether or not the call to Event.preventDefault() canceled the event. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/defaultPrevented) + */ + get defaultPrevented(): boolean; + /** + * The Event property **`returnValue`** indicates whether the default action for this event has been prevented or not. + * @deprecated + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/returnValue) + */ + get returnValue(): boolean; + /** + * The **`currentTarget`** read-only property of the Event interface identifies the element to which the event handler has been attached. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/currentTarget) + */ + get currentTarget(): EventTarget | undefined; + /** + * The read-only **`target`** property of the Event interface is a reference to the object onto which the event was dispatched. It is different from Event.currentTarget when the event handler is called during the bubbling or capturing phase of the event. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/target) + */ + get target(): EventTarget | undefined; + /** + * The deprecated **`Event.srcElement`** is an alias for the Event.target property. Use Event.target instead. + * @deprecated + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/srcElement) + */ + get srcElement(): EventTarget | undefined; + /** + * The **`timeStamp`** read-only property of the Event interface returns the time (in milliseconds) at which the event was created. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/timeStamp) + */ + get timeStamp(): number; + /** + * The **`isTrusted`** read-only property of the Event interface is a boolean value that is true when the event was generated by the user agent (including via user actions and programmatic methods such as HTMLElement.focus()), and false when the event was dispatched via EventTarget.dispatchEvent(). The only exception is the click event, which initializes the isTrusted property to false in user agents. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/isTrusted) + */ + get isTrusted(): boolean; + /** + * The **`cancelBubble`** property of the Event interface is deprecated. Use Event.stopPropagation() instead. Setting its value to true before returning from an event handler prevents propagation of the event. In later implementations, setting this to false does nothing. See Browser compatibility for details. + * @deprecated + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelBubble) + */ + get cancelBubble(): boolean; + /** + * The **`cancelBubble`** property of the Event interface is deprecated. Use Event.stopPropagation() instead. Setting its value to true before returning from an event handler prevents propagation of the event. In later implementations, setting this to false does nothing. See Browser compatibility for details. + * @deprecated + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelBubble) + */ + set cancelBubble(value: boolean); + /** + * The **`stopImmediatePropagation()`** method of the Event interface prevents other listeners of the same event from being called. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/stopImmediatePropagation) + */ + stopImmediatePropagation(): void; + /** + * The **`preventDefault()`** method of the Event interface tells the user agent that the event is being explicitly handled, so its default action, such as page scrolling, link navigation, or pasting text, should not be taken. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/preventDefault) + */ + preventDefault(): void; + /** + * The **`stopPropagation()`** method of the Event interface prevents further propagation of the current event in the capturing and bubbling phases. It does not, however, prevent any default behaviors from occurring; for instance, clicks on links are still processed. If you want to stop those behaviors, see the preventDefault() method. It also does not prevent propagation to other event-handlers of the current element. If you want to stop those, see stopImmediatePropagation(). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/stopPropagation) + */ + stopPropagation(): void; + /** + * The **`composedPath()`** method of the Event interface returns the event's path which is an array of the objects on which listeners will be invoked. This does not include nodes in shadow trees if the shadow root was created with its ShadowRoot.mode closed. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/composedPath) + */ + composedPath(): EventTarget[]; + static readonly NONE: number; + static readonly CAPTURING_PHASE: number; + static readonly AT_TARGET: number; + static readonly BUBBLING_PHASE: number; +} +interface EventInit { + bubbles?: boolean; + cancelable?: boolean; + composed?: boolean; +} +type EventListener = (event: EventType) => void; +interface EventListenerObject { + handleEvent(event: EventType): void; +} +type EventListenerOrEventListenerObject = EventListener | EventListenerObject; +/** + * The **`EventTarget`** interface is implemented by objects that can receive events and may have listeners for them. In other words, any target of events implements the three methods associated with this interface. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget) + */ +declare class EventTarget = Record> { + constructor(); + /** + * The **`addEventListener()`** method of the EventTarget interface sets up a function that will be called whenever the specified event is delivered to the target. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/addEventListener) + */ + addEventListener(type: Type, handler: EventListenerOrEventListenerObject, options?: EventTargetAddEventListenerOptions | boolean): void; + /** + * The **`removeEventListener()`** method of the EventTarget interface removes an event listener previously registered with EventTarget.addEventListener() from the target. The event listener to be removed is identified using a combination of the event type, the event listener function itself, and various optional options that may affect the matching process; see Matching event listeners for removal. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/removeEventListener) + */ + removeEventListener(type: Type, handler: EventListenerOrEventListenerObject, options?: EventTargetEventListenerOptions | boolean): void; + /** + * The **`dispatchEvent()`** method of the EventTarget sends an Event to the object, (synchronously) invoking the affected event listeners in the appropriate order. The normal event processing rules (including the capturing and optional bubbling phase) also apply to events dispatched manually with dispatchEvent(). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/dispatchEvent) + */ + dispatchEvent(event: EventMap[keyof EventMap]): boolean; +} +interface EventTargetEventListenerOptions { + capture?: boolean; +} +interface EventTargetAddEventListenerOptions { + capture?: boolean; + passive?: boolean; + once?: boolean; + signal?: AbortSignal; +} +interface EventTargetHandlerObject { + handleEvent: (event: Event) => any | undefined; +} +/** + * The **`AbortController`** interface represents a controller object that allows you to abort one or more Web requests as and when desired. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController) + */ +declare class AbortController { + constructor(); + /** + * The **`signal`** read-only property of the AbortController interface returns an AbortSignal object instance, which can be used to communicate with/abort an asynchronous operation as desired. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController/signal) + */ + get signal(): AbortSignal; + /** + * The **`abort()`** method of the AbortController interface aborts an asynchronous operation before it has completed. This is able to abort fetch requests, the consumption of any response bodies, or streams. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController/abort) + */ + abort(reason?: any): void; +} +/** + * The **`AbortSignal`** interface represents a signal object that allows you to communicate with an asynchronous operation (such as a fetch request) and abort it if required via an AbortController object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal) + */ +declare abstract class AbortSignal extends EventTarget { + /** + * The **`AbortSignal.abort()`** static method returns an AbortSignal that is already set as aborted (and which does not trigger an abort event). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_static) + */ + static abort(reason?: any): AbortSignal; + /** + * The **`AbortSignal.timeout()`** static method returns an AbortSignal that will automatically abort after a specified time. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/timeout_static) + */ + static timeout(delay: number): AbortSignal; + /** + * The **`AbortSignal.any()`** static method takes an iterable of abort signals and returns an AbortSignal. The returned abort signal is aborted when any of the input iterable abort signals are aborted. The abort reason will be set to the reason of the first signal that is aborted. If any of the given abort signals are already aborted then so will be the returned AbortSignal. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/any_static) + */ + static any(signals: AbortSignal[]): AbortSignal; + /** + * The **`aborted`** read-only property returns a value that indicates whether the asynchronous operations the signal is communicating with are aborted (true) or not (false). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/aborted) + */ + get aborted(): boolean; + /** + * The **`reason`** read-only property returns a JavaScript value that indicates the abort reason. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/reason) + */ + get reason(): any; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_event) */ + get onabort(): any | null; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_event) */ + set onabort(value: any | null); + /** + * The **`throwIfAborted()`** method throws the signal's abort reason if the signal has been aborted; otherwise it does nothing. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/throwIfAborted) + */ + throwIfAborted(): void; +} +/** + * The **`Scheduler`** interface of the Prioritized Task Scheduling API provides methods for scheduling prioritized tasks. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Scheduler) + */ +interface Scheduler { + wait(delay: number, maybeOptions?: SchedulerWaitOptions): Promise; +} +interface SchedulerWaitOptions { + signal?: AbortSignal; +} +/** + * The **`ExtendableEvent`** interface extends the lifetime of the install and activate events dispatched on the global scope as part of the service worker lifecycle. This ensures that any functional events (like FetchEvent) are not dispatched until it upgrades database schemas and deletes the outdated cache entries. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ExtendableEvent) + */ +declare abstract class ExtendableEvent extends Event { + /** + * The **`ExtendableEvent.waitUntil()`** method tells the event dispatcher that work is ongoing. It can also be used to detect whether that work was successful. In service workers, waitUntil() tells the browser that work is ongoing until the promise settles, and it shouldn't terminate the service worker if it wants that work to complete. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ExtendableEvent/waitUntil) + */ + waitUntil(promise: Promise): void; +} +/** + * The **`CustomEvent`** interface can be used to attach custom data to an event generated by an application. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CustomEvent) + */ +declare class CustomEvent extends Event { + constructor(type: string, init?: CustomEventCustomEventInit); + /** + * The read-only **`detail`** property of the CustomEvent interface returns any data passed when initializing the event. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CustomEvent/detail) + */ + get detail(): T; +} +interface CustomEventCustomEventInit { + bubbles?: boolean; + cancelable?: boolean; + composed?: boolean; + detail?: any; +} +/** + * The **`Blob`** interface represents a blob, which is a file-like object of immutable, raw data; they can be read as text or binary data, or converted into a ReadableStream so its methods can be used for processing the data. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob) + */ +declare class Blob { + constructor(bits?: ((ArrayBuffer | ArrayBufferView) | string | Blob)[], options?: BlobOptions); + /** + * The **`size`** read-only property of the Blob interface returns the size of the Blob or File in bytes. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/size) + */ + get size(): number; + /** + * The **`type`** read-only property of the Blob interface returns the MIME type of the file. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/type) + */ + get type(): string; + /** + * The **`slice()`** method of the Blob interface creates and returns a new Blob object which contains data from a subset of the blob on which it's called. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/slice) + */ + slice(start?: number, end?: number, type?: string): Blob; + /** + * The **`arrayBuffer()`** method of the Blob interface returns a Promise that resolves with the contents of the blob as binary data contained in an ArrayBuffer. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/arrayBuffer) + */ + arrayBuffer(): Promise; + /** + * The **`bytes()`** method of the Blob interface returns a Promise that resolves with a Uint8Array containing the contents of the blob as an array of bytes. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/bytes) + */ + bytes(): Promise; + /** + * The **`text()`** method of the Blob interface returns a Promise that resolves with a string containing the contents of the blob, interpreted as UTF-8. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/text) + */ + text(): Promise; + /** + * The **`stream()`** method of the Blob interface returns a ReadableStream which upon reading returns the data contained within the Blob. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/stream) + */ + stream(): ReadableStream; +} +interface BlobOptions { + type?: string; +} +/** + * The **`File`** interface provides information about files and allows JavaScript in a web page to access their content. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/File) + */ +declare class File extends Blob { + constructor(bits: ((ArrayBuffer | ArrayBufferView) | string | Blob)[] | undefined, name: string, options?: FileOptions); + /** + * The **`name`** read-only property of the File interface returns the name of the file represented by a File object. For security reasons, the path is excluded from this property. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/name) + */ + get name(): string; + /** + * The **`lastModified`** read-only property of the File interface provides the last modified date of the file as the number of milliseconds since the Unix epoch (January 1, 1970 at midnight). Files without a known last modified date return the current date. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/lastModified) + */ + get lastModified(): number; +} +interface FileOptions { + type?: string; + lastModified?: number; +} +/** +* The Cache API allows fine grained control of reading and writing from the Cloudflare global network cache. +* +* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/) +*/ +declare abstract class CacheStorage { + /** + * The **`open()`** method of the CacheStorage interface returns a Promise that resolves to the Cache object matching the cacheName. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CacheStorage/open) + */ + open(cacheName: string): Promise; + readonly default: Cache; +} +/** +* The Cache API allows fine grained control of reading and writing from the Cloudflare global network cache. +* +* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/) +*/ +declare abstract class Cache { + /* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/#delete) */ + delete(request: RequestInfo | URL, options?: CacheQueryOptions): Promise; + /* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/#match) */ + match(request: RequestInfo | URL, options?: CacheQueryOptions): Promise; + /* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/#put) */ + put(request: RequestInfo | URL, response: Response): Promise; +} +interface CacheQueryOptions { + ignoreMethod?: boolean; +} +/** +* The Web Crypto API provides a set of low-level functions for common cryptographic tasks. +* The Workers runtime implements the full surface of this API, but with some differences in +* the [supported algorithms](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/#supported-algorithms) +* compared to those implemented in most browsers. +* +* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/) +*/ +declare abstract class Crypto { + /** + * The **`Crypto.subtle`** read-only property returns a SubtleCrypto which can then be used to perform low-level cryptographic operations. + * Available only in secure contexts. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/subtle) + */ + get subtle(): SubtleCrypto; + /** + * The **`Crypto.getRandomValues()`** method lets you get cryptographically strong random values. The array given as the parameter is filled with random numbers (random in its cryptographic meaning). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/getRandomValues) + */ + getRandomValues(buffer: T): T; + /** + * The **`randomUUID()`** method of the Crypto interface is used to generate a v4 UUID using a cryptographically secure random number generator. + * Available only in secure contexts. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/randomUUID) + */ + randomUUID(): string; + DigestStream: typeof DigestStream; +} +/** + * The **`SubtleCrypto`** interface of the Web Crypto API provides a number of low-level cryptographic functions. + * Available only in secure contexts. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto) + */ +declare abstract class SubtleCrypto { + /** + * The **`encrypt()`** method of the SubtleCrypto interface encrypts data. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/encrypt) + */ + encrypt(algorithm: string | SubtleCryptoEncryptAlgorithm, key: CryptoKey, plainText: ArrayBuffer | ArrayBufferView): Promise; + /** + * The **`decrypt()`** method of the SubtleCrypto interface decrypts some encrypted data. It takes as arguments a key to decrypt with, some optional extra parameters, and the data to decrypt (also known as "ciphertext"). It returns a Promise which will be fulfilled with the decrypted data (also known as "plaintext"). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/decrypt) + */ + decrypt(algorithm: string | SubtleCryptoEncryptAlgorithm, key: CryptoKey, cipherText: ArrayBuffer | ArrayBufferView): Promise; + /** + * The **`sign()`** method of the SubtleCrypto interface generates a digital signature. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/sign) + */ + sign(algorithm: string | SubtleCryptoSignAlgorithm, key: CryptoKey, data: ArrayBuffer | ArrayBufferView): Promise; + /** + * The **`verify()`** method of the SubtleCrypto interface verifies a digital signature. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/verify) + */ + verify(algorithm: string | SubtleCryptoSignAlgorithm, key: CryptoKey, signature: ArrayBuffer | ArrayBufferView, data: ArrayBuffer | ArrayBufferView): Promise; + /** + * The **`digest()`** method of the SubtleCrypto interface generates a digest of the given data, using the specified hash function. A digest is a short fixed-length value derived from some variable-length input. Cryptographic digests should exhibit collision-resistance, meaning that it's hard to come up with two different inputs that have the same digest value. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/digest) + */ + digest(algorithm: string | SubtleCryptoHashAlgorithm, data: ArrayBuffer | ArrayBufferView): Promise; + /** + * The **`generateKey()`** method of the SubtleCrypto interface is used to generate a new key (for symmetric algorithms) or key pair (for public-key algorithms). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/generateKey) + */ + generateKey(algorithm: string | SubtleCryptoGenerateKeyAlgorithm, extractable: boolean, keyUsages: string[]): Promise; + /** + * The **`deriveKey()`** method of the SubtleCrypto interface can be used to derive a secret key from a master key. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/deriveKey) + */ + deriveKey(algorithm: string | SubtleCryptoDeriveKeyAlgorithm, baseKey: CryptoKey, derivedKeyAlgorithm: string | SubtleCryptoImportKeyAlgorithm, extractable: boolean, keyUsages: string[]): Promise; + /** + * The **`deriveBits()`** method of the SubtleCrypto interface can be used to derive an array of bits from a base key. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/deriveBits) + */ + deriveBits(algorithm: string | SubtleCryptoDeriveKeyAlgorithm, baseKey: CryptoKey, length?: number | null): Promise; + /** + * The **`importKey()`** method of the SubtleCrypto interface imports a key: that is, it takes as input a key in an external, portable format and gives you a CryptoKey object that you can use in the Web Crypto API. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/importKey) + */ + importKey(format: string, keyData: (ArrayBuffer | ArrayBufferView) | JsonWebKey, algorithm: string | SubtleCryptoImportKeyAlgorithm, extractable: boolean, keyUsages: string[]): Promise; + /** + * The **`exportKey()`** method of the SubtleCrypto interface exports a key: that is, it takes as input a CryptoKey object and gives you the key in an external, portable format. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/exportKey) + */ + exportKey(format: string, key: CryptoKey): Promise; + /** + * The **`wrapKey()`** method of the SubtleCrypto interface "wraps" a key. This means that it exports the key in an external, portable format, then encrypts the exported key. Wrapping a key helps protect it in untrusted environments, such as inside an otherwise unprotected data store or in transmission over an unprotected network. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/wrapKey) + */ + wrapKey(format: string, key: CryptoKey, wrappingKey: CryptoKey, wrapAlgorithm: string | SubtleCryptoEncryptAlgorithm): Promise; + /** + * The **`unwrapKey()`** method of the SubtleCrypto interface "unwraps" a key. This means that it takes as its input a key that has been exported and then encrypted (also called "wrapped"). It decrypts the key and then imports it, returning a CryptoKey object that can be used in the Web Crypto API. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/unwrapKey) + */ + unwrapKey(format: string, wrappedKey: ArrayBuffer | ArrayBufferView, unwrappingKey: CryptoKey, unwrapAlgorithm: string | SubtleCryptoEncryptAlgorithm, unwrappedKeyAlgorithm: string | SubtleCryptoImportKeyAlgorithm, extractable: boolean, keyUsages: string[]): Promise; + timingSafeEqual(a: ArrayBuffer | ArrayBufferView, b: ArrayBuffer | ArrayBufferView): boolean; +} +/** + * The **`CryptoKey`** interface of the Web Crypto API represents a cryptographic key obtained from one of the SubtleCrypto methods generateKey(), deriveKey(), importKey(), or unwrapKey(). + * Available only in secure contexts. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey) + */ +declare abstract class CryptoKey { + /** + * The read-only **`type`** property of the CryptoKey interface indicates which kind of key is represented by the object. It can have the following values: + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/type) + */ + readonly type: string; + /** + * The read-only **`extractable`** property of the CryptoKey interface indicates whether or not the key may be extracted using SubtleCrypto.exportKey() or SubtleCrypto.wrapKey(). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/extractable) + */ + readonly extractable: boolean; + /** + * The read-only **`algorithm`** property of the CryptoKey interface returns an object describing the algorithm for which this key can be used, and any associated extra parameters. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/algorithm) + */ + readonly algorithm: CryptoKeyKeyAlgorithm | CryptoKeyAesKeyAlgorithm | CryptoKeyHmacKeyAlgorithm | CryptoKeyRsaKeyAlgorithm | CryptoKeyEllipticKeyAlgorithm | CryptoKeyArbitraryKeyAlgorithm; + /** + * The read-only **`usages`** property of the CryptoKey interface indicates what can be done with the key. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/usages) + */ + readonly usages: string[]; +} +interface CryptoKeyPair { + publicKey: CryptoKey; + privateKey: CryptoKey; +} +interface JsonWebKey { + kty: string; + use?: string; + key_ops?: string[]; + alg?: string; + ext?: boolean; + crv?: string; + x?: string; + y?: string; + d?: string; + n?: string; + e?: string; + p?: string; + q?: string; + dp?: string; + dq?: string; + qi?: string; + oth?: RsaOtherPrimesInfo[]; + k?: string; + pub?: string; + priv?: string; +} +interface RsaOtherPrimesInfo { + r?: string; + d?: string; + t?: string; +} +interface SubtleCryptoDeriveKeyAlgorithm { + name: string; + salt?: (ArrayBuffer | ArrayBufferView); + iterations?: number; + hash?: (string | SubtleCryptoHashAlgorithm); + $public?: CryptoKey; + info?: (ArrayBuffer | ArrayBufferView); +} +interface SubtleCryptoEncryptAlgorithm { + name: string; + iv?: (ArrayBuffer | ArrayBufferView); + additionalData?: (ArrayBuffer | ArrayBufferView); + tagLength?: number; + counter?: (ArrayBuffer | ArrayBufferView); + length?: number; + label?: (ArrayBuffer | ArrayBufferView); +} +interface SubtleCryptoGenerateKeyAlgorithm { + name: string; + hash?: (string | SubtleCryptoHashAlgorithm); + modulusLength?: number; + publicExponent?: (ArrayBuffer | ArrayBufferView); + length?: number; + namedCurve?: string; +} +interface SubtleCryptoHashAlgorithm { + name: string; +} +interface SubtleCryptoImportKeyAlgorithm { + name: string; + hash?: (string | SubtleCryptoHashAlgorithm); + length?: number; + namedCurve?: string; + compressed?: boolean; +} +interface SubtleCryptoSignAlgorithm { + name: string; + hash?: (string | SubtleCryptoHashAlgorithm); + dataLength?: number; + saltLength?: number; + context?: ArrayBuffer | ArrayBufferView; +} +interface CryptoKeyKeyAlgorithm { + name: string; +} +interface CryptoKeyAesKeyAlgorithm { + name: string; + length: number; +} +interface CryptoKeyHmacKeyAlgorithm { + name: string; + hash: CryptoKeyKeyAlgorithm; + length: number; +} +interface CryptoKeyRsaKeyAlgorithm { + name: string; + modulusLength: number; + publicExponent: ArrayBuffer | ArrayBufferView; + hash?: CryptoKeyKeyAlgorithm; +} +interface CryptoKeyEllipticKeyAlgorithm { + name: string; + namedCurve: string; +} +interface CryptoKeyArbitraryKeyAlgorithm { + name: string; + hash?: CryptoKeyKeyAlgorithm; + namedCurve?: string; + length?: number; +} +declare class DigestStream extends WritableStream { + constructor(algorithm: string | SubtleCryptoHashAlgorithm, options?: DigestStreamOptions); + readonly digest: Promise; + get bytesWritten(): number | bigint; +} +interface DigestStreamOptions { + toWellFormed?: boolean; +} +/** + * The **`TextDecoder`** interface represents a decoder for a specific text encoding, such as UTF-8, ISO-8859-2, or GBK. A decoder takes an array of bytes as input and returns a JavaScript string. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder) + */ +declare class TextDecoder { + constructor(label?: string, options?: TextDecoderConstructorOptions); + /** + * The **`TextDecoder.decode()`** method returns a string containing text decoded from the buffer passed as a parameter. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder/decode) + */ + decode(input?: (ArrayBuffer | ArrayBufferView), options?: TextDecoderDecodeOptions): string; + get encoding(): string; + get fatal(): boolean; + get ignoreBOM(): boolean; +} +/** + * The **`TextEncoder`** interface enables you to encode a JavaScript string using UTF-8. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder) + */ +declare class TextEncoder { + constructor(); + /** + * The **`TextEncoder.encode()`** method takes a string as input, and returns a Uint8Array containing the string encoded using UTF-8. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder/encode) + */ + encode(input?: string): Uint8Array; + /** + * The **`TextEncoder.encodeInto()`** method takes a string to encode and a destination Uint8Array to put resulting UTF-8 encoded text into, and returns an object indicating the progress of the encoding. This is potentially more performant than the encode() method — especially when the target buffer is a view into a Wasm heap. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder/encodeInto) + */ + encodeInto(input: string, buffer: Uint8Array): TextEncoderEncodeIntoResult; + get encoding(): string; +} +interface TextDecoderConstructorOptions { + fatal: boolean; + ignoreBOM: boolean; +} +interface TextDecoderDecodeOptions { + stream: boolean; +} +interface TextEncoderEncodeIntoResult { + read: number; + written: number; +} +/** + * The **`ErrorEvent`** interface represents events providing information related to errors in scripts or in files. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent) + */ +declare class ErrorEvent extends Event { + constructor(type: string, init?: ErrorEventErrorEventInit); + /** + * The **`filename`** read-only property of the ErrorEvent interface returns a string containing the name of the script file in which the error occurred. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/filename) + */ + get filename(): string; + /** + * The **`message`** read-only property of the ErrorEvent interface returns a string containing a human-readable error message describing the problem. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/message) + */ + get message(): string; + /** + * The **`lineno`** read-only property of the ErrorEvent interface returns an integer containing the line number of the script file on which the error occurred. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/lineno) + */ + get lineno(): number; + /** + * The **`colno`** read-only property of the ErrorEvent interface returns an integer containing the column number of the script file on which the error occurred. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/colno) + */ + get colno(): number; + /** + * The **`error`** read-only property of the ErrorEvent interface returns a JavaScript value, such as an Error or DOMException, representing the error associated with this event. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/error) + */ + get error(): any; +} +interface ErrorEventErrorEventInit { + bubbles?: boolean; + cancelable?: boolean; + composed?: boolean; + message?: string; + filename?: string; + lineno?: number; + colno?: number; + error?: any; +} +/** + * The **`MessageEvent`** interface represents a message received by a target object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent) + */ +declare class MessageEvent extends Event { + constructor(type: string, initializer?: MessageEventInit); + /** + * The **`data`** read-only property of the MessageEvent interface represents the data sent by the message emitter. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/data) + */ + readonly data: any; + /** + * The **`origin`** read-only property of the MessageEvent interface is a string representing the origin of the message emitter. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/origin) + */ + readonly origin: string | null; + /** + * The **`lastEventId`** read-only property of the MessageEvent interface is a string representing a unique ID for the event. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/lastEventId) + */ + readonly lastEventId: string; + /** + * The **`source`** read-only property of the MessageEvent interface is a MessageEventSource (which can be a WindowProxy, MessagePort, or ServiceWorker object) representing the message emitter. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/source) + */ + readonly source: MessagePort | null; + /** + * The **`ports`** read-only property of the MessageEvent interface is an array of MessagePort objects containing all MessagePort objects sent with the message, in order. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/ports) + */ + readonly ports: MessagePort[]; +} +interface MessageEventInit { + bubbles?: boolean; + cancelable?: boolean; + composed?: boolean; + data?: any; + origin?: string; + lastEventId?: string; + source?: (MessagePort | null); + ports?: MessagePort[]; +} +/** + * The **`PromiseRejectionEvent`** interface represents events which are sent to the global script context when JavaScript Promises are rejected. These events are particularly useful for telemetry and debugging purposes. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent) + */ +declare abstract class PromiseRejectionEvent extends Event { + /** + * The PromiseRejectionEvent interface's **`promise`** read-only property indicates the JavaScript Promise which was rejected. You can examine the event's PromiseRejectionEvent.reason property to learn why the promise was rejected. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent/promise) + */ + readonly promise: Promise; + /** + * The PromiseRejectionEvent **`reason`** read-only property is any JavaScript value or Object which provides the reason passed into Promise.reject(). This in theory provides information about why the promise was rejected. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent/reason) + */ + readonly reason: any; +} +/** + * The **`FormData`** interface provides a way to construct a set of key/value pairs representing form fields and their values, which can be sent using the fetch(), XMLHttpRequest.send() or navigator.sendBeacon() methods. It uses the same format a form would use if the encoding type were set to "multipart/form-data". + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData) + */ +declare class FormData { + constructor(); + /** + * The **`append()`** method of the FormData interface appends a new value onto an existing key inside a FormData object, or adds the key if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append) + */ + append(name: string, value: string | Blob): void; + /** + * The **`append()`** method of the FormData interface appends a new value onto an existing key inside a FormData object, or adds the key if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append) + */ + append(name: string, value: string): void; + /** + * The **`append()`** method of the FormData interface appends a new value onto an existing key inside a FormData object, or adds the key if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append) + */ + append(name: string, value: Blob, filename?: string): void; + /** + * The **`delete()`** method of the FormData interface deletes a key and its value(s) from a FormData object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/delete) + */ + delete(name: string): void; + /** + * The **`get()`** method of the FormData interface returns the first value associated with a given key from within a FormData object. If you expect multiple values and want all of them, use the getAll() method instead. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/get) + */ + get(name: string): (File | string) | null; + /** + * The **`getAll()`** method of the FormData interface returns all the values associated with a given key from within a FormData object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/getAll) + */ + getAll(name: string): (File | string)[]; + /** + * The **`has()`** method of the FormData interface returns whether a FormData object contains a certain key. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/has) + */ + has(name: string): boolean; + /** + * The **`set()`** method of the FormData interface sets a new value for an existing key inside a FormData object, or adds the key/value if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set) + */ + set(name: string, value: string | Blob): void; + /** + * The **`set()`** method of the FormData interface sets a new value for an existing key inside a FormData object, or adds the key/value if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set) + */ + set(name: string, value: string): void; + /** + * The **`set()`** method of the FormData interface sets a new value for an existing key inside a FormData object, or adds the key/value if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set) + */ + set(name: string, value: Blob, filename?: string): void; + entries(): IterableIterator<[ + key: string, + value: File | string + ]>; + keys(): IterableIterator; + values(): IterableIterator<(File | string)>; + forEach(callback: (this: This, value: File | string, key: string, parent: FormData) => void, thisArg?: This): void; + [Symbol.iterator](): IterableIterator<[ + key: string, + value: File | string + ]>; +} +interface ContentOptions { + html?: boolean; +} +declare class HTMLRewriter { + constructor(); + on(selector: string, handlers: HTMLRewriterElementContentHandlers): HTMLRewriter; + onDocument(handlers: HTMLRewriterDocumentContentHandlers): HTMLRewriter; + transform(response: Response): Response; +} +interface HTMLRewriterElementContentHandlers { + element?(element: Element): void | Promise; + comments?(comment: Comment): void | Promise; + text?(element: Text): void | Promise; +} +interface HTMLRewriterDocumentContentHandlers { + doctype?(doctype: Doctype): void | Promise; + comments?(comment: Comment): void | Promise; + text?(text: Text): void | Promise; + end?(end: DocumentEnd): void | Promise; +} +interface Doctype { + readonly name: string | null; + readonly publicId: string | null; + readonly systemId: string | null; +} +interface Element { + tagName: string; + readonly attributes: IterableIterator; + readonly removed: boolean; + readonly namespaceURI: string; + getAttribute(name: string): string | null; + hasAttribute(name: string): boolean; + setAttribute(name: string, value: string): Element; + removeAttribute(name: string): Element; + before(content: string | ReadableStream | Response, options?: ContentOptions): Element; + after(content: string | ReadableStream | Response, options?: ContentOptions): Element; + prepend(content: string | ReadableStream | Response, options?: ContentOptions): Element; + append(content: string | ReadableStream | Response, options?: ContentOptions): Element; + replace(content: string | ReadableStream | Response, options?: ContentOptions): Element; + remove(): Element; + removeAndKeepContent(): Element; + setInnerContent(content: string | ReadableStream | Response, options?: ContentOptions): Element; + onEndTag(handler: (tag: EndTag) => void | Promise): void; +} +interface EndTag { + name: string; + before(content: string | ReadableStream | Response, options?: ContentOptions): EndTag; + after(content: string | ReadableStream | Response, options?: ContentOptions): EndTag; + remove(): EndTag; +} +interface Comment { + text: string; + readonly removed: boolean; + before(content: string, options?: ContentOptions): Comment; + after(content: string, options?: ContentOptions): Comment; + replace(content: string, options?: ContentOptions): Comment; + remove(): Comment; +} +interface Text { + readonly text: string; + readonly lastInTextNode: boolean; + readonly removed: boolean; + before(content: string | ReadableStream | Response, options?: ContentOptions): Text; + after(content: string | ReadableStream | Response, options?: ContentOptions): Text; + replace(content: string | ReadableStream | Response, options?: ContentOptions): Text; + remove(): Text; +} +interface DocumentEnd { + append(content: string, options?: ContentOptions): DocumentEnd; +} +/** + * This is the event type for fetch events dispatched on the service worker global scope. It contains information about the fetch, including the request and how the receiver will treat the response. It provides the event.respondWith() method, which allows us to provide a response to this fetch. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent) + */ +declare abstract class FetchEvent extends ExtendableEvent { + /** + * The **`request`** read-only property of the FetchEvent interface returns the Request that triggered the event handler. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent/request) + */ + readonly request: Request; + /** + * The **`respondWith()`** method of FetchEvent prevents the browser's default fetch handling, and allows you to provide a promise for a Response yourself. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent/respondWith) + */ + respondWith(promise: Response | Promise): void; + passThroughOnException(): void; +} +type HeadersInit = Headers | Iterable> | Record; +/** + * The **`Headers`** interface of the Fetch API allows you to perform various actions on HTTP request and response headers. These actions include retrieving, setting, adding to, and removing headers from the list of the request's headers. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers) + */ +declare class Headers { + constructor(init?: HeadersInit); + /** + * The **`get()`** method of the Headers interface returns a byte string of all the values of a header within a Headers object with a given name. If the requested header doesn't exist in the Headers object, it returns null. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/get) + */ + get(name: string): string | null; + getAll(name: string): string[]; + /** + * The **`getSetCookie()`** method of the Headers interface returns an array containing the values of all Set-Cookie headers associated with a response. This allows Headers objects to handle having multiple Set-Cookie headers, which wasn't possible prior to its implementation. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/getSetCookie) + */ + getSetCookie(): string[]; + /** + * The **`has()`** method of the Headers interface returns a boolean stating whether a Headers object contains a certain header. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/has) + */ + has(name: string): boolean; + /** + * The **`set()`** method of the Headers interface sets a new value for an existing header inside a Headers object, or adds the header if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/set) + */ + set(name: string, value: string): void; + /** + * The **`append()`** method of the Headers interface appends a new value onto an existing header inside a Headers object, or adds the header if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/append) + */ + append(name: string, value: string): void; + /** + * The **`delete()`** method of the Headers interface deletes a header from the current Headers object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/delete) + */ + delete(name: string): void; + forEach(callback: (this: This, value: string, key: string, parent: Headers) => void, thisArg?: This): void; + entries(): IterableIterator<[ + key: string, + value: string + ]>; + keys(): IterableIterator; + values(): IterableIterator; + [Symbol.iterator](): IterableIterator<[ + key: string, + value: string + ]>; +} +type BodyInit = ReadableStream | string | ArrayBuffer | ArrayBufferView | Blob | URLSearchParams | FormData | Iterable | AsyncIterable; +declare abstract class Body { + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/body) */ + get body(): ReadableStream | null; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/bodyUsed) */ + get bodyUsed(): boolean; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/arrayBuffer) */ + arrayBuffer(): Promise; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/bytes) */ + bytes(): Promise; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/text) */ + text(): Promise; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/json) */ + json(): Promise; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/formData) */ + formData(): Promise; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/blob) */ + blob(): Promise; +} +/** + * The **`Response`** interface of the Fetch API represents the response to a request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response) + */ +declare var Response: { + prototype: Response; + new (body?: BodyInit | null, init?: ResponseInit): Response; + error(): Response; + redirect(url: string, status?: number): Response; + json(any: any, maybeInit?: (ResponseInit | Response)): Response; +}; +/** + * The **`Response`** interface of the Fetch API represents the response to a request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response) + */ +interface Response extends Body { + /** + * The **`clone()`** method of the Response interface creates a clone of a response object, identical in every way, but stored in a different variable. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/clone) + */ + clone(): Response; + /** + * The **`status`** read-only property of the Response interface contains the HTTP status codes of the response. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/status) + */ + status: number; + /** + * The **`statusText`** read-only property of the Response interface contains the status message corresponding to the HTTP status code in Response.status. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/statusText) + */ + statusText: string; + /** + * The **`headers`** read-only property of the Response interface contains the Headers object associated with the response. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/headers) + */ + headers: Headers; + /** + * The **`ok`** read-only property of the Response interface contains a Boolean stating whether the response was successful (status in the range 200-299) or not. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/ok) + */ + ok: boolean; + /** + * The **`redirected`** read-only property of the Response interface indicates whether or not the response is the result of a request you made which was redirected. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/redirected) + */ + redirected: boolean; + /** + * The **`url`** read-only property of the Response interface contains the URL of the response. The value of the url property will be the final URL obtained after any redirects. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/url) + */ + url: string; + webSocket: WebSocket | null; + cf: any | undefined; + /** + * The **`type`** read-only property of the Response interface contains the type of the response. The type determines whether scripts are able to access the response body and headers. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/type) + */ + type: "default" | "error"; +} +interface ResponseInit { + status?: number; + statusText?: string; + headers?: HeadersInit; + cf?: any; + webSocket?: (WebSocket | null); + encodeBody?: "automatic" | "manual"; +} +type RequestInfo> = Request | string; +/** + * The **`Request`** interface of the Fetch API represents a resource request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request) + */ +declare var Request: { + prototype: Request; + new >(input: RequestInfo | URL, init?: RequestInit): Request; +}; +/** + * The **`Request`** interface of the Fetch API represents a resource request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request) + */ +interface Request> extends Body { + /** + * The **`clone()`** method of the Request interface creates a copy of the current Request object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/clone) + */ + clone(): Request; + /** + * The **`method`** read-only property of the Request interface contains the request's method (GET, POST, etc.) + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/method) + */ + method: string; + /** + * The **`url`** read-only property of the Request interface contains the URL of the request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/url) + */ + url: string; + /** + * The **`headers`** read-only property of the Request interface contains the Headers object associated with the request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/headers) + */ + headers: Headers; + /** + * The **`redirect`** read-only property of the Request interface contains the mode for how redirects are handled. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/redirect) + */ + redirect: string; + fetcher: Fetcher | null; + /** + * The read-only **`signal`** property of the Request interface returns the AbortSignal associated with the request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/signal) + */ + signal: AbortSignal; + cf?: Cf; + /** + * The **`integrity`** read-only property of the Request interface contains the subresource integrity value of the request. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/integrity) + */ + integrity: string; + /** + * The **`keepalive`** read-only property of the Request interface contains the request's keepalive setting (true or false), which indicates whether the browser will keep the associated request alive if the page that initiated it is unloaded before the request is complete. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/keepalive) + */ + keepalive: boolean; + /** + * The **`cache`** read-only property of the Request interface contains the cache mode of the request. It controls how the request will interact with the browser's HTTP cache. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/cache) + */ + cache?: "no-store" | "no-cache"; +} +interface RequestInit { + /* A string to set request's method. */ + method?: string; + /* A Headers object, an object literal, or an array of two-item arrays to set request's headers. */ + headers?: HeadersInit; + /* A BodyInit object or null to set request's body. */ + body?: BodyInit | null; + /* A string indicating whether request follows redirects, results in an error upon encountering a redirect, or returns the redirect (in an opaque fashion). Sets request's redirect. */ + redirect?: string; + fetcher?: (Fetcher | null); + cf?: Cf; + /* A string indicating how the request will interact with the browser's cache to set request's cache. */ + cache?: "no-store" | "no-cache"; + /* A cryptographic hash of the resource to be fetched by request. Sets request's integrity. */ + integrity?: string; + /* An AbortSignal to set request's signal. */ + signal?: (AbortSignal | null); + encodeResponseBody?: "automatic" | "manual"; +} +type Service Rpc.WorkerEntrypointBranded) | Rpc.WorkerEntrypointBranded | ExportedHandler | undefined = undefined> = T extends new (...args: any[]) => Rpc.WorkerEntrypointBranded ? Fetcher> : T extends Rpc.WorkerEntrypointBranded ? Fetcher : T extends Exclude ? never : Fetcher; +type Fetcher = (T extends Rpc.EntrypointBranded ? Rpc.Provider : unknown) & { + fetch(input: RequestInfo | URL, init?: RequestInit): Promise; + connect(address: SocketAddress | string, options?: SocketOptions): Socket; +}; +interface KVNamespaceListKey { + name: Key; + expiration?: number; + metadata?: Metadata; +} +type KVNamespaceListResult = { + list_complete: false; + keys: KVNamespaceListKey[]; + cursor: string; + cacheStatus: string | null; +} | { + list_complete: true; + keys: KVNamespaceListKey[]; + cacheStatus: string | null; +}; +interface KVNamespace { + get(key: Key, options?: Partial>): Promise; + get(key: Key, type: "text"): Promise; + get(key: Key, type: "json"): Promise; + get(key: Key, type: "arrayBuffer"): Promise; + get(key: Key, type: "stream"): Promise; + get(key: Key, options?: KVNamespaceGetOptions<"text">): Promise; + get(key: Key, options?: KVNamespaceGetOptions<"json">): Promise; + get(key: Key, options?: KVNamespaceGetOptions<"arrayBuffer">): Promise; + get(key: Key, options?: KVNamespaceGetOptions<"stream">): Promise; + get(key: Array, type: "text"): Promise>; + get(key: Array, type: "json"): Promise>; + get(key: Array, options?: Partial>): Promise>; + get(key: Array, options?: KVNamespaceGetOptions<"text">): Promise>; + get(key: Array, options?: KVNamespaceGetOptions<"json">): Promise>; + list(options?: KVNamespaceListOptions): Promise>; + put(key: Key, value: string | ArrayBuffer | ArrayBufferView | ReadableStream, options?: KVNamespacePutOptions): Promise; + getWithMetadata(key: Key, options?: Partial>): Promise>; + getWithMetadata(key: Key, type: "text"): Promise>; + getWithMetadata(key: Key, type: "json"): Promise>; + getWithMetadata(key: Key, type: "arrayBuffer"): Promise>; + getWithMetadata(key: Key, type: "stream"): Promise>; + getWithMetadata(key: Key, options: KVNamespaceGetOptions<"text">): Promise>; + getWithMetadata(key: Key, options: KVNamespaceGetOptions<"json">): Promise>; + getWithMetadata(key: Key, options: KVNamespaceGetOptions<"arrayBuffer">): Promise>; + getWithMetadata(key: Key, options: KVNamespaceGetOptions<"stream">): Promise>; + getWithMetadata(key: Array, type: "text"): Promise>>; + getWithMetadata(key: Array, type: "json"): Promise>>; + getWithMetadata(key: Array, options?: Partial>): Promise>>; + getWithMetadata(key: Array, options?: KVNamespaceGetOptions<"text">): Promise>>; + getWithMetadata(key: Array, options?: KVNamespaceGetOptions<"json">): Promise>>; + delete(key: Key): Promise; +} +interface KVNamespaceListOptions { + limit?: number; + prefix?: (string | null); + cursor?: (string | null); +} +interface KVNamespaceGetOptions { + type: Type; + cacheTtl?: number; +} +interface KVNamespacePutOptions { + expiration?: number; + expirationTtl?: number; + metadata?: (any | null); +} +interface KVNamespaceGetWithMetadataResult { + value: Value | null; + metadata: Metadata | null; + cacheStatus: string | null; +} +type QueueContentType = "text" | "bytes" | "json" | "v8"; +interface Queue { + metrics(): Promise; + send(message: Body, options?: QueueSendOptions): Promise; + sendBatch(messages: Iterable>, options?: QueueSendBatchOptions): Promise; +} +interface QueueSendMetrics { + backlogCount: number; + backlogBytes: number; + oldestMessageTimestamp?: Date; +} +interface QueueSendMetadata { + metrics: QueueSendMetrics; +} +interface QueueSendResponse { + metadata: QueueSendMetadata; +} +interface QueueSendBatchMetrics { + backlogCount: number; + backlogBytes: number; + oldestMessageTimestamp?: Date; +} +interface QueueSendBatchMetadata { + metrics: QueueSendBatchMetrics; +} +interface QueueSendBatchResponse { + metadata: QueueSendBatchMetadata; +} +interface QueueSendOptions { + contentType?: QueueContentType; + delaySeconds?: number; +} +interface QueueSendBatchOptions { + delaySeconds?: number; +} +interface MessageSendRequest { + body: Body; + contentType?: QueueContentType; + delaySeconds?: number; +} +interface QueueMetrics { + backlogCount: number; + backlogBytes: number; + oldestMessageTimestamp?: Date; +} +interface MessageBatchMetrics { + backlogCount: number; + backlogBytes: number; + oldestMessageTimestamp?: Date; +} +interface MessageBatchMetadata { + metrics: MessageBatchMetrics; +} +interface QueueRetryOptions { + delaySeconds?: number; +} +interface Message { + readonly id: string; + readonly timestamp: Date; + readonly body: Body; + readonly attempts: number; + retry(options?: QueueRetryOptions): void; + ack(): void; +} +interface QueueEvent extends ExtendableEvent { + readonly messages: readonly Message[]; + readonly queue: string; + readonly metadata: MessageBatchMetadata; + retryAll(options?: QueueRetryOptions): void; + ackAll(): void; +} +interface MessageBatch { + readonly messages: readonly Message[]; + readonly queue: string; + readonly metadata: MessageBatchMetadata; + retryAll(options?: QueueRetryOptions): void; + ackAll(): void; +} +interface R2Error extends Error { + readonly name: string; + readonly code: number; + readonly message: string; + readonly action: string; + readonly stack: any; +} +interface R2ListOptions { + limit?: number; + prefix?: string; + cursor?: string; + delimiter?: string; + startAfter?: string; + include?: ("httpMetadata" | "customMetadata")[]; +} +interface R2Bucket { + head(key: string): Promise; + get(key: string, options: R2GetOptions & { + onlyIf: R2Conditional | Headers; + }): Promise; + get(key: string, options?: R2GetOptions): Promise; + put(key: string, value: ReadableStream | ArrayBuffer | ArrayBufferView | string | null | Blob, options?: R2PutOptions & { + onlyIf: R2Conditional | Headers; + }): Promise; + put(key: string, value: ReadableStream | ArrayBuffer | ArrayBufferView | string | null | Blob, options?: R2PutOptions): Promise; + createMultipartUpload(key: string, options?: R2MultipartOptions): Promise; + resumeMultipartUpload(key: string, uploadId: string): R2MultipartUpload; + delete(keys: string | string[]): Promise; + list(options?: R2ListOptions): Promise; +} +interface R2MultipartUpload { + readonly key: string; + readonly uploadId: string; + uploadPart(partNumber: number, value: ReadableStream | (ArrayBuffer | ArrayBufferView) | string | Blob, options?: R2UploadPartOptions): Promise; + abort(): Promise; + complete(uploadedParts: R2UploadedPart[]): Promise; +} +interface R2UploadedPart { + partNumber: number; + etag: string; +} +declare abstract class R2Object { + readonly key: string; + readonly version: string; + readonly size: number; + readonly etag: string; + readonly httpEtag: string; + readonly checksums: R2Checksums; + readonly uploaded: Date; + readonly httpMetadata?: R2HTTPMetadata; + readonly customMetadata?: Record; + readonly range?: R2Range; + readonly storageClass: string; + readonly ssecKeyMd5?: string; + writeHttpMetadata(headers: Headers): void; +} +interface R2ObjectBody extends R2Object { + get body(): ReadableStream; + get bodyUsed(): boolean; + arrayBuffer(): Promise; + bytes(): Promise; + text(): Promise; + json(): Promise; + blob(): Promise; +} +type R2Range = { + offset: number; + length?: number; +} | { + offset?: number; + length: number; +} | { + suffix: number; +}; +interface R2Conditional { + etagMatches?: string; + etagDoesNotMatch?: string; + uploadedBefore?: Date; + uploadedAfter?: Date; + secondsGranularity?: boolean; +} +interface R2GetOptions { + onlyIf?: (R2Conditional | Headers); + range?: (R2Range | Headers); + ssecKey?: (ArrayBuffer | string); +} +interface R2PutOptions { + onlyIf?: (R2Conditional | Headers); + httpMetadata?: (R2HTTPMetadata | Headers); + customMetadata?: Record; + md5?: ((ArrayBuffer | ArrayBufferView) | string); + sha1?: ((ArrayBuffer | ArrayBufferView) | string); + sha256?: ((ArrayBuffer | ArrayBufferView) | string); + sha384?: ((ArrayBuffer | ArrayBufferView) | string); + sha512?: ((ArrayBuffer | ArrayBufferView) | string); + storageClass?: string; + ssecKey?: (ArrayBuffer | string); +} +interface R2MultipartOptions { + httpMetadata?: (R2HTTPMetadata | Headers); + customMetadata?: Record; + storageClass?: string; + ssecKey?: (ArrayBuffer | string); +} +interface R2Checksums { + readonly md5?: ArrayBuffer; + readonly sha1?: ArrayBuffer; + readonly sha256?: ArrayBuffer; + readonly sha384?: ArrayBuffer; + readonly sha512?: ArrayBuffer; + toJSON(): R2StringChecksums; +} +interface R2StringChecksums { + md5?: string; + sha1?: string; + sha256?: string; + sha384?: string; + sha512?: string; +} +interface R2HTTPMetadata { + contentType?: string; + contentLanguage?: string; + contentDisposition?: string; + contentEncoding?: string; + cacheControl?: string; + cacheExpiry?: Date; +} +type R2Objects = { + objects: R2Object[]; + delimitedPrefixes: string[]; +} & ({ + truncated: true; + cursor: string; +} | { + truncated: false; +}); +interface R2UploadPartOptions { + ssecKey?: (ArrayBuffer | string); +} +declare abstract class ScheduledEvent extends ExtendableEvent { + readonly scheduledTime: number; + readonly cron: string; + noRetry(): void; +} +interface ScheduledController { + readonly scheduledTime: number; + readonly cron: string; + noRetry(): void; +} +interface QueuingStrategy { + highWaterMark?: (number | bigint); + size?: (chunk: T) => number | bigint; +} +interface UnderlyingSink { + type?: string; + start?: (controller: WritableStreamDefaultController) => void | Promise; + write?: (chunk: W, controller: WritableStreamDefaultController) => void | Promise; + abort?: (reason: any) => void | Promise; + close?: () => void | Promise; +} +interface UnderlyingByteSource { + type: "bytes"; + autoAllocateChunkSize?: number; + start?: (controller: ReadableByteStreamController) => void | Promise; + pull?: (controller: ReadableByteStreamController) => void | Promise; + cancel?: (reason: any) => void | Promise; +} +interface UnderlyingSource { + type?: "" | undefined; + start?: (controller: ReadableStreamDefaultController) => void | Promise; + pull?: (controller: ReadableStreamDefaultController) => void | Promise; + cancel?: (reason: any) => void | Promise; + expectedLength?: (number | bigint); +} +interface Transformer { + readableType?: string; + writableType?: string; + start?: (controller: TransformStreamDefaultController) => void | Promise; + transform?: (chunk: I, controller: TransformStreamDefaultController) => void | Promise; + flush?: (controller: TransformStreamDefaultController) => void | Promise; + cancel?: (reason: any) => void | Promise; + expectedLength?: number; +} +interface StreamPipeOptions { + preventAbort?: boolean; + preventCancel?: boolean; + /** + * Pipes this readable stream to a given writable stream destination. The way in which the piping process behaves under various error conditions can be customized with a number of passed options. It returns a promise that fulfills when the piping process completes successfully, or rejects if any errors were encountered. + * + * Piping a stream will lock it for the duration of the pipe, preventing any other consumer from acquiring a reader. + * + * Errors and closures of the source and destination streams propagate as follows: + * + * An error in this source readable stream will abort destination, unless preventAbort is truthy. The returned promise will be rejected with the source's error, or with any error that occurs during aborting the destination. + * + * An error in destination will cancel this source readable stream, unless preventCancel is truthy. The returned promise will be rejected with the destination's error, or with any error that occurs during canceling the source. + * + * When this source readable stream closes, destination will be closed, unless preventClose is truthy. The returned promise will be fulfilled once this process completes, unless an error is encountered while closing the destination, in which case it will be rejected with that error. + * + * If destination starts out closed or closing, this source readable stream will be canceled, unless preventCancel is true. The returned promise will be rejected with an error indicating piping to a closed stream failed, or with any error that occurs during canceling the source. + * + * The signal option can be set to an AbortSignal to allow aborting an ongoing pipe operation via the corresponding AbortController. In this case, this source readable stream will be canceled, and destination aborted, unless the respective options preventCancel or preventAbort are set. + */ + preventClose?: boolean; + signal?: AbortSignal; +} +type ReadableStreamReadResult = { + done: false; + value: R; +} | { + done: true; + value?: undefined; +}; +/** + * The **`ReadableStream`** interface of the Streams API represents a readable stream of byte data. The Fetch API offers a concrete instance of a ReadableStream through the body property of a Response object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream) + */ +interface ReadableStream { + /** + * The **`locked`** read-only property of the ReadableStream interface returns whether or not the readable stream is locked to a reader. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/locked) + */ + get locked(): boolean; + /** + * The **`cancel()`** method of the ReadableStream interface returns a Promise that resolves when the stream is canceled. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/cancel) + */ + cancel(reason?: any): Promise; + /** + * The **`getReader()`** method of the ReadableStream interface creates a reader and locks the stream to it. While the stream is locked, no other reader can be acquired until this one is released. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/getReader) + */ + getReader(): ReadableStreamDefaultReader; + /** + * The **`getReader()`** method of the ReadableStream interface creates a reader and locks the stream to it. While the stream is locked, no other reader can be acquired until this one is released. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/getReader) + */ + getReader(options: ReadableStreamGetReaderOptions): ReadableStreamBYOBReader; + /** + * The **`pipeThrough()`** method of the ReadableStream interface provides a chainable way of piping the current stream through a transform stream or any other writable/readable pair. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/pipeThrough) + */ + pipeThrough(transform: ReadableWritablePair, options?: StreamPipeOptions): ReadableStream; + /** + * The **`pipeTo()`** method of the ReadableStream interface pipes the current ReadableStream to a given WritableStream and returns a Promise that fulfills when the piping process completes successfully, or rejects if any errors were encountered. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/pipeTo) + */ + pipeTo(destination: WritableStream, options?: StreamPipeOptions): Promise; + /** + * The **`tee()`** method of the ReadableStream interface tees the current readable stream, returning a two-element array containing the two resulting branches as new ReadableStream instances. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/tee) + */ + tee(): [ + ReadableStream, + ReadableStream + ]; + values(options?: ReadableStreamValuesOptions): AsyncIterableIterator; + [Symbol.asyncIterator](options?: ReadableStreamValuesOptions): AsyncIterableIterator; +} +/** + * The **`ReadableStream`** interface of the Streams API represents a readable stream of byte data. The Fetch API offers a concrete instance of a ReadableStream through the body property of a Response object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream) + */ +declare const ReadableStream: { + prototype: ReadableStream; + new (underlyingSource: UnderlyingByteSource, strategy?: QueuingStrategy): ReadableStream; + new (underlyingSource?: UnderlyingSource, strategy?: QueuingStrategy): ReadableStream; +}; +/** + * The **`ReadableStreamDefaultReader`** interface of the Streams API represents a default reader that can be used to read stream data supplied from a network (such as a fetch request). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultReader) + */ +declare class ReadableStreamDefaultReader { + constructor(stream: ReadableStream); + get closed(): Promise; + cancel(reason?: any): Promise; + /** + * The **`read()`** method of the ReadableStreamDefaultReader interface returns a Promise providing access to the next chunk in the stream's internal queue. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultReader/read) + */ + read(): Promise>; + /** + * The **`releaseLock()`** method of the ReadableStreamDefaultReader interface releases the reader's lock on the stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultReader/releaseLock) + */ + releaseLock(): void; +} +/** + * The **`ReadableStreamBYOBReader`** interface of the Streams API defines a reader for a ReadableStream that supports zero-copy reading from an underlying byte source. It is used for efficient copying from underlying sources where the data is delivered as an "anonymous" sequence of bytes, such as files. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader) + */ +declare class ReadableStreamBYOBReader { + constructor(stream: ReadableStream); + get closed(): Promise; + cancel(reason?: any): Promise; + /** + * The **`read()`** method of the ReadableStreamBYOBReader interface is used to read data into a view on a user-supplied buffer from an associated readable byte stream. A request for data will be satisfied from the stream's internal queues if there is any data present. If the stream queues are empty, the request may be supplied as a zero-copy transfer from the underlying byte source. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader/read) + */ + read(view: T): Promise>; + /** + * The **`releaseLock()`** method of the ReadableStreamBYOBReader interface releases the reader's lock on the stream. After the lock is released, the reader is no longer active. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader/releaseLock) + */ + releaseLock(): void; + readAtLeast(minElements: number, view: T): Promise>; +} +interface ReadableStreamBYOBReaderReadableStreamBYOBReaderReadOptions { + min?: number; +} +interface ReadableStreamGetReaderOptions { + /** + * Creates a ReadableStreamBYOBReader and locks the stream to the new reader. + * + * This call behaves the same way as the no-argument variant, except that it only works on readable byte streams, i.e. streams which were constructed specifically with the ability to handle "bring your own buffer" reading. The returned BYOB reader provides the ability to directly read individual chunks from the stream via its read() method, into developer-supplied buffers, allowing more precise control over allocation. + */ + mode: "byob"; +} +/** + * The **`ReadableStreamBYOBRequest`** interface of the Streams API represents a "pull request" for data from an underlying source that will made as a zero-copy transfer to a consumer (bypassing the stream's internal queues). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBRequest) + */ +declare abstract class ReadableStreamBYOBRequest { + /** + * The **`view`** getter property of the ReadableStreamBYOBRequest interface returns the current view. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBRequest/view) + */ + get view(): Uint8Array | null; + /** + * The **`respond()`** method of the ReadableStreamBYOBRequest interface is used to signal to the associated readable byte stream that the specified number of bytes were written into the ReadableStreamBYOBRequest.view. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBRequest/respond) + */ + respond(bytesWritten: number): void; + /** + * The **`respondWithNewView()`** method of the ReadableStreamBYOBRequest interface specifies a new view that the consumer of the associated readable byte stream should write to instead of ReadableStreamBYOBRequest.view. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBRequest/respondWithNewView) + */ + respondWithNewView(view: ArrayBuffer | ArrayBufferView): void; + get atLeast(): number | null; +} +/** + * The **`ReadableStreamDefaultController`** interface of the Streams API represents a controller allowing control of a ReadableStream's state and internal queue. Default controllers are for streams that are not byte streams. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController) + */ +declare abstract class ReadableStreamDefaultController { + /** + * The **`desiredSize`** read-only property of the ReadableStreamDefaultController interface returns the desired size required to fill the stream's internal queue. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/desiredSize) + */ + get desiredSize(): number | null; + /** + * The **`close()`** method of the ReadableStreamDefaultController interface closes the associated stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/close) + */ + close(): void; + /** + * The **`enqueue()`** method of the ReadableStreamDefaultController interface enqueues a given chunk in the associated stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/enqueue) + */ + enqueue(chunk?: R): void; + /** + * The **`error()`** method of the ReadableStreamDefaultController interface causes any future interactions with the associated stream to error. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/error) + */ + error(reason: any): void; +} +/** + * The **`ReadableByteStreamController`** interface of the Streams API represents a controller for a readable byte stream. It allows control of the state and internal queue of a ReadableStream with an underlying byte source, and enables efficient zero-copy transfer of data from the underlying source to a consumer when the stream's internal queue is empty. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController) + */ +declare abstract class ReadableByteStreamController { + /** + * The **`byobRequest`** read-only property of the ReadableByteStreamController interface returns the current BYOB request, or null if there are no pending requests. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/byobRequest) + */ + get byobRequest(): ReadableStreamBYOBRequest | null; + /** + * The **`desiredSize`** read-only property of the ReadableByteStreamController interface returns the number of bytes required to fill the stream's internal queue to its "desired size". + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/desiredSize) + */ + get desiredSize(): number | null; + /** + * The **`close()`** method of the ReadableByteStreamController interface closes the associated stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/close) + */ + close(): void; + /** + * The **`enqueue()`** method of the ReadableByteStreamController interface enqueues a given chunk on the associated readable byte stream (the chunk is transferred into the stream's internal queues). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/enqueue) + */ + enqueue(chunk: ArrayBuffer | ArrayBufferView): void; + /** + * The **`error()`** method of the ReadableByteStreamController interface causes any future interactions with the associated stream to error with the specified reason. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/error) + */ + error(reason: any): void; +} +/** + * The **`WritableStreamDefaultController`** interface of the Streams API represents a controller allowing control of a WritableStream's state. When constructing a WritableStream, the underlying sink is given a corresponding WritableStreamDefaultController instance to manipulate. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultController) + */ +declare abstract class WritableStreamDefaultController { + /** + * The read-only **`signal`** property of the WritableStreamDefaultController interface returns the AbortSignal associated with the controller. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultController/signal) + */ + get signal(): AbortSignal; + /** + * The **`error()`** method of the WritableStreamDefaultController interface causes any future interactions with the associated stream to error. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultController/error) + */ + error(reason?: any): void; +} +/** + * The **`TransformStreamDefaultController`** interface of the Streams API provides methods to manipulate the associated ReadableStream and WritableStream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController) + */ +declare abstract class TransformStreamDefaultController { + /** + * The **`desiredSize`** read-only property of the TransformStreamDefaultController interface returns the desired size to fill the queue of the associated ReadableStream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController/desiredSize) + */ + get desiredSize(): number | null; + /** + * The **`enqueue()`** method of the TransformStreamDefaultController interface enqueues the given chunk in the readable side of the stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController/enqueue) + */ + enqueue(chunk?: O): void; + /** + * The **`error()`** method of the TransformStreamDefaultController interface errors both sides of the stream. Any further interactions with it will fail with the given error message, and any chunks in the queue will be discarded. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController/error) + */ + error(reason: any): void; + /** + * The **`terminate()`** method of the TransformStreamDefaultController interface closes the readable side and errors the writable side of the stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController/terminate) + */ + terminate(): void; +} +interface ReadableWritablePair { + readable: ReadableStream; + /** + * Provides a convenient, chainable way of piping this readable stream through a transform stream (or any other { writable, readable } pair). It simply pipes the stream into the writable side of the supplied pair, and returns the readable side for further use. + * + * Piping a stream will lock it for the duration of the pipe, preventing any other consumer from acquiring a reader. + */ + writable: WritableStream; +} +/** + * The **`WritableStream`** interface of the Streams API provides a standard abstraction for writing streaming data to a destination, known as a sink. This object comes with built-in backpressure and queuing. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream) + */ +declare class WritableStream { + constructor(underlyingSink?: UnderlyingSink, queuingStrategy?: QueuingStrategy); + /** + * The **`locked`** read-only property of the WritableStream interface returns a boolean indicating whether the WritableStream is locked to a writer. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/locked) + */ + get locked(): boolean; + /** + * The **`abort()`** method of the WritableStream interface aborts the stream, signaling that the producer can no longer successfully write to the stream and it is to be immediately moved to an error state, with any queued writes discarded. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/abort) + */ + abort(reason?: any): Promise; + /** + * The **`close()`** method of the WritableStream interface closes the associated stream. All chunks written before this method is called are sent before the returned promise is fulfilled. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/close) + */ + close(): Promise; + /** + * The **`getWriter()`** method of the WritableStream interface returns a new instance of WritableStreamDefaultWriter and locks the stream to that instance. While the stream is locked, no other writer can be acquired until this one is released. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/getWriter) + */ + getWriter(): WritableStreamDefaultWriter; +} +/** + * The **`WritableStreamDefaultWriter`** interface of the Streams API is the object returned by WritableStream.getWriter() and once created locks the writer to the WritableStream ensuring that no other streams can write to the underlying sink. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter) + */ +declare class WritableStreamDefaultWriter { + constructor(stream: WritableStream); + /** + * The **`closed`** read-only property of the WritableStreamDefaultWriter interface returns a Promise that fulfills if the stream becomes closed, or rejects if the stream errors or the writer's lock is released. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/closed) + */ + get closed(): Promise; + /** + * The **`ready`** read-only property of the WritableStreamDefaultWriter interface returns a Promise that resolves when the desired size of the stream's internal queue transitions from non-positive to positive, signaling that it is no longer applying backpressure. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/ready) + */ + get ready(): Promise; + /** + * The **`desiredSize`** read-only property of the WritableStreamDefaultWriter interface returns the desired size required to fill the stream's internal queue. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/desiredSize) + */ + get desiredSize(): number | null; + /** + * The **`abort()`** method of the WritableStreamDefaultWriter interface aborts the stream, signaling that the producer can no longer successfully write to the stream and it is to be immediately moved to an error state, with any queued writes discarded. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/abort) + */ + abort(reason?: any): Promise; + /** + * The **`close()`** method of the WritableStreamDefaultWriter interface closes the associated writable stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/close) + */ + close(): Promise; + /** + * The **`write()`** method of the WritableStreamDefaultWriter interface writes a passed chunk of data to a WritableStream and its underlying sink, then returns a Promise that resolves to indicate the success or failure of the write operation. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/write) + */ + write(chunk?: W): Promise; + /** + * The **`releaseLock()`** method of the WritableStreamDefaultWriter interface releases the writer's lock on the corresponding stream. After the lock is released, the writer is no longer active. If the associated stream is errored when the lock is released, the writer will appear errored in the same way from now on; otherwise, the writer will appear closed. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/releaseLock) + */ + releaseLock(): void; +} +/** + * The **`TransformStream`** interface of the Streams API represents a concrete implementation of the pipe chain transform stream concept. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStream) + */ +declare class TransformStream { + constructor(transformer?: Transformer, writableStrategy?: QueuingStrategy, readableStrategy?: QueuingStrategy); + /** + * The **`readable`** read-only property of the TransformStream interface returns the ReadableStream instance controlled by this TransformStream. This stream emits the transformed output data. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStream/readable) + */ + get readable(): ReadableStream; + /** + * The **`writable`** read-only property of the TransformStream interface returns the WritableStream instance controlled by this TransformStream. This stream accepts input data that will be transformed and emitted to the readable stream. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStream/writable) + */ + get writable(): WritableStream; +} +declare class FixedLengthStream extends IdentityTransformStream { + constructor(expectedLength: number | bigint, queuingStrategy?: IdentityTransformStreamQueuingStrategy); +} +declare class IdentityTransformStream extends TransformStream { + constructor(queuingStrategy?: IdentityTransformStreamQueuingStrategy); +} +interface IdentityTransformStreamQueuingStrategy { + highWaterMark?: (number | bigint); +} +interface ReadableStreamValuesOptions { + preventCancel?: boolean; +} +/** + * The **`CompressionStream`** interface of the Compression Streams API compresses a stream of data. It implements the same shape as a TransformStream, allowing it to be used in ReadableStream.pipeThrough() and similar methods. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CompressionStream) + */ +declare class CompressionStream extends TransformStream { + constructor(format: "gzip" | "deflate" | "deflate-raw"); +} +/** + * The **`DecompressionStream`** interface of the Compression Streams API decompresses a stream of data. It implements the same shape as a TransformStream, allowing it to be used in ReadableStream.pipeThrough() and similar methods. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DecompressionStream) + */ +declare class DecompressionStream extends TransformStream { + constructor(format: "gzip" | "deflate" | "deflate-raw"); +} +/** + * The **`TextEncoderStream`** interface of the Encoding API converts a stream of strings into bytes in the UTF-8 encoding. It is the streaming equivalent of TextEncoder. It implements the same shape as a TransformStream, allowing it to be used in ReadableStream.pipeThrough() and similar methods. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoderStream) + */ +declare class TextEncoderStream extends TransformStream { + constructor(); + get encoding(): string; +} +/** + * The **`TextDecoderStream`** interface of the Encoding API converts a stream of text in a binary encoding, such as UTF-8 etc., to a stream of strings. It is the streaming equivalent of TextDecoder. It implements the same shape as a TransformStream, allowing it to be used in ReadableStream.pipeThrough() and similar methods. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoderStream) + */ +declare class TextDecoderStream extends TransformStream { + constructor(label?: string, options?: TextDecoderStreamTextDecoderStreamInit); + get encoding(): string; + get fatal(): boolean; + get ignoreBOM(): boolean; +} +interface TextDecoderStreamTextDecoderStreamInit { + fatal?: boolean; + ignoreBOM?: boolean; +} +/** + * The **`ByteLengthQueuingStrategy`** interface of the Streams API provides a built-in byte length queuing strategy that can be used when constructing streams. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ByteLengthQueuingStrategy) + */ +declare class ByteLengthQueuingStrategy implements QueuingStrategy { + constructor(init: QueuingStrategyInit); + /** + * The read-only **`ByteLengthQueuingStrategy.highWaterMark`** property returns the total number of bytes that can be contained in the internal queue before backpressure is applied. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ByteLengthQueuingStrategy/highWaterMark) + */ + get highWaterMark(): number; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ByteLengthQueuingStrategy/size) */ + get size(): (chunk?: any) => number; +} +/** + * The **`CountQueuingStrategy`** interface of the Streams API provides a built-in chunk counting queuing strategy that can be used when constructing streams. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CountQueuingStrategy) + */ +declare class CountQueuingStrategy implements QueuingStrategy { + constructor(init: QueuingStrategyInit); + /** + * The read-only **`CountQueuingStrategy.highWaterMark`** property returns the total number of chunks that can be contained in the internal queue before backpressure is applied. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CountQueuingStrategy/highWaterMark) + */ + get highWaterMark(): number; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CountQueuingStrategy/size) */ + get size(): (chunk?: any) => number; +} +interface QueuingStrategyInit { + /** + * Creates a new ByteLengthQueuingStrategy with the provided high water mark. + * + * Note that the provided high water mark will not be validated ahead of time. Instead, if it is negative, NaN, or not a number, the resulting ByteLengthQueuingStrategy will cause the corresponding stream constructor to throw. + */ + highWaterMark: number; +} +interface TracePreviewInfo { + id: string; + slug: string; + name: string; +} +interface ScriptVersion { + id?: string; + tag?: string; + message?: string; +} +declare abstract class TailEvent extends ExtendableEvent { + readonly events: TraceItem[]; + readonly traces: TraceItem[]; +} +interface TraceItem { + readonly event: (TraceItemFetchEventInfo | TraceItemJsRpcEventInfo | TraceItemConnectEventInfo | TraceItemScheduledEventInfo | TraceItemAlarmEventInfo | TraceItemQueueEventInfo | TraceItemEmailEventInfo | TraceItemTailEventInfo | TraceItemCustomEventInfo | TraceItemHibernatableWebSocketEventInfo) | null; + readonly eventTimestamp: number | null; + readonly logs: TraceLog[]; + readonly exceptions: TraceException[]; + readonly diagnosticsChannelEvents: TraceDiagnosticChannelEvent[]; + readonly scriptName: string | null; + readonly entrypoint?: string; + readonly scriptVersion?: ScriptVersion; + readonly dispatchNamespace?: string; + readonly scriptTags?: string[]; + readonly tailAttributes?: Record; + readonly preview?: TracePreviewInfo; + readonly durableObjectId?: string; + readonly outcome: string; + readonly executionModel: string; + readonly truncated: boolean; + readonly cpuTime: number; + readonly wallTime: number; +} +interface TraceItemAlarmEventInfo { + readonly scheduledTime: Date; +} +interface TraceItemConnectEventInfo { +} +interface TraceItemCustomEventInfo { +} +interface TraceItemScheduledEventInfo { + readonly scheduledTime: number; + readonly cron: string; +} +interface TraceItemQueueEventInfo { + readonly queue: string; + readonly batchSize: number; +} +interface TraceItemEmailEventInfo { + readonly mailFrom: string; + readonly rcptTo: string; + readonly rawSize: number; +} +interface TraceItemTailEventInfo { + readonly consumedEvents: TraceItemTailEventInfoTailItem[]; +} +interface TraceItemTailEventInfoTailItem { + readonly scriptName: string | null; +} +interface TraceItemFetchEventInfo { + readonly response?: TraceItemFetchEventInfoResponse; + readonly request: TraceItemFetchEventInfoRequest; +} +interface TraceItemFetchEventInfoRequest { + readonly cf?: any; + readonly headers: Record; + readonly method: string; + readonly url: string; + getUnredacted(): TraceItemFetchEventInfoRequest; +} +interface TraceItemFetchEventInfoResponse { + readonly status: number; +} +interface TraceItemJsRpcEventInfo { + readonly rpcMethod: string; +} +interface TraceItemHibernatableWebSocketEventInfo { + readonly getWebSocketEvent: TraceItemHibernatableWebSocketEventInfoMessage | TraceItemHibernatableWebSocketEventInfoClose | TraceItemHibernatableWebSocketEventInfoError; +} +interface TraceItemHibernatableWebSocketEventInfoMessage { + readonly webSocketEventType: string; +} +interface TraceItemHibernatableWebSocketEventInfoClose { + readonly webSocketEventType: string; + readonly code: number; + readonly wasClean: boolean; +} +interface TraceItemHibernatableWebSocketEventInfoError { + readonly webSocketEventType: string; +} +interface TraceLog { + readonly timestamp: number; + readonly level: string; + readonly message: any; + readonly errorInfo?: (TraceLogErrorInfo | null)[]; +} +interface TraceLogErrorInfo { + name: string; + message: string; + stack?: string; +} +interface TraceException { + readonly timestamp: number; + readonly message: string; + readonly name: string; + readonly stack?: string; +} +interface TraceDiagnosticChannelEvent { + readonly timestamp: number; + readonly channel: string; + readonly message: any; +} +interface TraceMetrics { + readonly cpuTime: number; + readonly wallTime: number; +} +interface UnsafeTraceMetrics { + fromTrace(item: TraceItem): TraceMetrics; +} +/** + * The **`URL`** interface is used to parse, construct, normalize, and encode URLs. It works by providing properties which allow you to easily read and modify the components of a URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL) + */ +declare class URL { + constructor(url: string | URL, base?: string | URL); + /** + * The **`origin`** read-only property of the URL interface returns a string containing the Unicode serialization of the origin of the represented URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/origin) + */ + get origin(): string; + /** + * The **`href`** property of the URL interface is a string containing the whole URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/href) + */ + get href(): string; + /** + * The **`href`** property of the URL interface is a string containing the whole URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/href) + */ + set href(value: string); + /** + * The **`protocol`** property of the URL interface is a string containing the protocol or scheme of the URL, including the final ":". + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/protocol) + */ + get protocol(): string; + /** + * The **`protocol`** property of the URL interface is a string containing the protocol or scheme of the URL, including the final ":". + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/protocol) + */ + set protocol(value: string); + /** + * The **`username`** property of the URL interface is a string containing the username component of the URL. If the URL does not have a username, this property contains an empty string, "". + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/username) + */ + get username(): string; + /** + * The **`username`** property of the URL interface is a string containing the username component of the URL. If the URL does not have a username, this property contains an empty string, "". + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/username) + */ + set username(value: string); + /** + * The **`password`** property of the URL interface is a string containing the password component of the URL. If the URL does not have a password, this property contains an empty string, "". + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/password) + */ + get password(): string; + /** + * The **`password`** property of the URL interface is a string containing the password component of the URL. If the URL does not have a password, this property contains an empty string, "". + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/password) + */ + set password(value: string); + /** + * The **`host`** property of the URL interface is a string containing the host, which is the hostname, and then, if the port of the URL is nonempty, a ":", followed by the port of the URL. If the URL does not have a hostname, this property contains an empty string, "". + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/host) + */ + get host(): string; + /** + * The **`host`** property of the URL interface is a string containing the host, which is the hostname, and then, if the port of the URL is nonempty, a ":", followed by the port of the URL. If the URL does not have a hostname, this property contains an empty string, "". + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/host) + */ + set host(value: string); + /** + * The **`hostname`** property of the URL interface is a string containing either the domain name or IP address of the URL. If the URL does not have a hostname, this property contains an empty string, "". IPv4 and IPv6 addresses are normalized, such as stripping leading zeros, and domain names are converted to IDN. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hostname) + */ + get hostname(): string; + /** + * The **`hostname`** property of the URL interface is a string containing either the domain name or IP address of the URL. If the URL does not have a hostname, this property contains an empty string, "". IPv4 and IPv6 addresses are normalized, such as stripping leading zeros, and domain names are converted to IDN. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hostname) + */ + set hostname(value: string); + /** + * The **`port`** property of the URL interface is a string containing the port number of the URL. If the port is the default for the protocol (80 for ws: and http:, 443 for wss: and https:, and 21 for ftp:), this property contains an empty string, "". + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/port) + */ + get port(): string; + /** + * The **`port`** property of the URL interface is a string containing the port number of the URL. If the port is the default for the protocol (80 for ws: and http:, 443 for wss: and https:, and 21 for ftp:), this property contains an empty string, "". + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/port) + */ + set port(value: string); + /** + * The **`pathname`** property of the URL interface represents a location in a hierarchical structure. It is a string constructed from a list of path segments, each of which is prefixed by a / character. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/pathname) + */ + get pathname(): string; + /** + * The **`pathname`** property of the URL interface represents a location in a hierarchical structure. It is a string constructed from a list of path segments, each of which is prefixed by a / character. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/pathname) + */ + set pathname(value: string); + /** + * The **`search`** property of the URL interface is a search string, also called a query string, that is a string containing a "?" followed by the parameters of the URL. If the URL does not have a search query, this property contains an empty string, "". + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/search) + */ + get search(): string; + /** + * The **`search`** property of the URL interface is a search string, also called a query string, that is a string containing a "?" followed by the parameters of the URL. If the URL does not have a search query, this property contains an empty string, "". + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/search) + */ + set search(value: string); + /** + * The **`hash`** property of the URL interface is a string containing a "#" followed by the fragment identifier of the URL. If the URL does not have a fragment identifier, this property contains an empty string, "". + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hash) + */ + get hash(): string; + /** + * The **`hash`** property of the URL interface is a string containing a "#" followed by the fragment identifier of the URL. If the URL does not have a fragment identifier, this property contains an empty string, "". + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hash) + */ + set hash(value: string); + /** + * The **`searchParams`** read-only property of the URL interface returns a URLSearchParams object allowing access to the GET decoded query arguments contained in the URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/searchParams) + */ + get searchParams(): URLSearchParams; + /** + * The **`toJSON()`** method of the URL interface returns a string containing a serialized version of the URL, although in practice it seems to have the same effect as URL.toString(). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/toJSON) + */ + toJSON(): string; + /*function toString() { [native code] }*/ + toString(): string; + /** + * The **`URL.canParse()`** static method of the URL interface returns a boolean indicating whether or not an absolute URL, or a relative URL combined with a base URL, are parsable and valid. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/canParse_static) + */ + static canParse(url: string, base?: string): boolean; + /** + * The **`URL.parse()`** static method of the URL interface returns a newly created URL object representing the URL defined by the parameters. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/parse_static) + */ + static parse(url: string, base?: string): URL | null; + /** + * The **`createObjectURL()`** static method of the URL interface creates a string containing a blob URL pointing to the object given in the parameter. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/createObjectURL_static) + */ + static createObjectURL(object: File | Blob): string; + /** + * The **`revokeObjectURL()`** static method of the URL interface releases an existing object URL which was previously created by calling URL.createObjectURL(). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/revokeObjectURL_static) + */ + static revokeObjectURL(object_url: string): void; +} +/** + * The **`URLSearchParams`** interface defines utility methods to work with the query string of a URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams) + */ +declare class URLSearchParams { + constructor(init?: (Iterable> | Record | string)); + /** + * The **`size`** read-only property of the URLSearchParams interface indicates the total number of search parameter entries. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/size) + */ + get size(): number; + /** + * The **`append()`** method of the URLSearchParams interface appends a specified key/value pair as a new search parameter. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/append) + */ + append(name: string, value: string): void; + /** + * The **`delete()`** method of the URLSearchParams interface deletes specified parameters and their associated value(s) from the list of all search parameters. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/delete) + */ + delete(name: string, value?: string): void; + /** + * The **`get()`** method of the URLSearchParams interface returns the first value associated to the given search parameter. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/get) + */ + get(name: string): string | null; + /** + * The **`getAll()`** method of the URLSearchParams interface returns all the values associated with a given search parameter as an array. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/getAll) + */ + getAll(name: string): string[]; + /** + * The **`has()`** method of the URLSearchParams interface returns a boolean value that indicates whether the specified parameter is in the search parameters. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/has) + */ + has(name: string, value?: string): boolean; + /** + * The **`set()`** method of the URLSearchParams interface sets the value associated with a given search parameter to the given value. If there were several matching values, this method deletes the others. If the search parameter doesn't exist, this method creates it. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/set) + */ + set(name: string, value: string): void; + /** + * The **`URLSearchParams.sort()`** method sorts all key/value pairs contained in this object in place and returns undefined. Key/value pairs are sorted by the values of the UTF-16 code units of the keys. This method uses a stable sorting algorithm (i.e., the relative order between key/value pairs with equal keys will be preserved). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/sort) + */ + sort(): void; + entries(): IterableIterator<[ + key: string, + value: string + ]>; + keys(): IterableIterator; + values(): IterableIterator; + forEach(callback: (this: This, value: string, key: string, parent: URLSearchParams) => void, thisArg?: This): void; + /*function toString() { [native code] }*/ + toString(): string; + [Symbol.iterator](): IterableIterator<[ + key: string, + value: string + ]>; +} +/** + * The **`URLPattern`** interface of the URL Pattern API matches URLs or parts of URLs against a pattern. The pattern can contain capturing groups that extract parts of the matched URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern) + */ +declare class URLPattern { + constructor(input?: (string | URLPatternInit), baseURL?: (string | URLPatternOptions), patternOptions?: URLPatternOptions); + /** + * The **`protocol`** read-only property of the URLPattern interface is a string containing the pattern used to match the protocol part of a URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/protocol) + */ + get protocol(): string; + /** + * The **`username`** read-only property of the URLPattern interface is a string containing the pattern used to match the username part of a URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/username) + */ + get username(): string; + /** + * The **`password`** read-only property of the URLPattern interface is a string containing the pattern used to match the password part of a URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/password) + */ + get password(): string; + /** + * The **`hostname`** read-only property of the URLPattern interface is a string containing the pattern used to match the hostname part of a URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/hostname) + */ + get hostname(): string; + /** + * The **`port`** read-only property of the URLPattern interface is a string containing the pattern used to match the port part of a URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/port) + */ + get port(): string; + /** + * The **`pathname`** read-only property of the URLPattern interface is a string containing the pattern used to match the pathname part of a URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/pathname) + */ + get pathname(): string; + /** + * The **`search`** read-only property of the URLPattern interface is a string containing the pattern used to match the search part of a URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/search) + */ + get search(): string; + /** + * The **`hash`** read-only property of the URLPattern interface is a string containing the pattern used to match the fragment part of a URL. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/hash) + */ + get hash(): string; + /** + * The **`hasRegExpGroups`** read-only property of the URLPattern interface is a boolean indicating whether or not any of the URLPattern components contain regular expression capturing groups. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/hasRegExpGroups) + */ + get hasRegExpGroups(): boolean; + /** + * The **`test()`** method of the URLPattern interface takes a URL string or object of URL parts, and returns a boolean indicating if the given input matches the current pattern. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/test) + */ + test(input?: (string | URLPatternInit), baseURL?: string): boolean; + /** + * The **`exec()`** method of the URLPattern interface takes a URL or object of URL parts, and returns either an object containing the results of matching the URL to the pattern, or null if the URL does not match the pattern. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/exec) + */ + exec(input?: (string | URLPatternInit), baseURL?: string): URLPatternResult | null; +} +interface URLPatternInit { + protocol?: string; + username?: string; + password?: string; + hostname?: string; + port?: string; + pathname?: string; + search?: string; + hash?: string; + baseURL?: string; +} +interface URLPatternComponentResult { + input: string; + groups: Record; +} +interface URLPatternResult { + inputs: (string | URLPatternInit)[]; + protocol: URLPatternComponentResult; + username: URLPatternComponentResult; + password: URLPatternComponentResult; + hostname: URLPatternComponentResult; + port: URLPatternComponentResult; + pathname: URLPatternComponentResult; + search: URLPatternComponentResult; + hash: URLPatternComponentResult; +} +interface URLPatternOptions { + ignoreCase?: boolean; +} +/** + * A **`CloseEvent`** is sent to clients using WebSockets when the connection is closed. This is delivered to the listener indicated by the WebSocket object's onclose attribute. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent) + */ +declare class CloseEvent extends Event { + constructor(type: string, initializer?: CloseEventInit); + /** + * The **`code`** read-only property of the CloseEvent interface returns a WebSocket connection close code indicating the reason the connection was closed. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent/code) + */ + readonly code: number; + /** + * The **`reason`** read-only property of the CloseEvent interface returns the WebSocket connection close reason the server gave for closing the connection; that is, a concise human-readable prose explanation for the closure. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent/reason) + */ + readonly reason: string; + /** + * The **`wasClean`** read-only property of the CloseEvent interface returns true if the connection closed cleanly. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent/wasClean) + */ + readonly wasClean: boolean; +} +interface CloseEventInit { + bubbles?: boolean; + cancelable?: boolean; + composed?: boolean; + code?: number; + reason?: string; + wasClean?: boolean; +} +type WebSocketEventMap = { + close: CloseEvent; + message: MessageEvent; + open: Event; + error: ErrorEvent; +}; +/** + * The **`WebSocket`** object provides the API for creating and managing a WebSocket connection to a server, as well as for sending and receiving data on the connection. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket) + */ +declare var WebSocket: { + prototype: WebSocket; + new (url: string, protocols?: (string[] | string)): WebSocket; + readonly READY_STATE_CONNECTING: number; + readonly CONNECTING: number; + readonly READY_STATE_OPEN: number; + readonly OPEN: number; + readonly READY_STATE_CLOSING: number; + readonly CLOSING: number; + readonly READY_STATE_CLOSED: number; + readonly CLOSED: number; +}; +/** + * The **`WebSocket`** object provides the API for creating and managing a WebSocket connection to a server, as well as for sending and receiving data on the connection. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket) + */ +interface WebSocket extends EventTarget { + accept(options?: WebSocketAcceptOptions): void; + /** + * The **`WebSocket.send()`** method enqueues the specified data to be transmitted to the server over the WebSocket connection, increasing the value of bufferedAmount by the number of bytes needed to contain the data. If the data can't be sent (for example, because it needs to be buffered but the buffer is full), the socket is closed automatically. The browser will throw an exception if you call send() when the connection is in the CONNECTING state. If you call send() when the connection is in the CLOSING or CLOSED states, the browser will silently discard the data. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/send) + */ + send(message: (ArrayBuffer | ArrayBufferView) | string): void; + /** + * The **`WebSocket.close()`** method closes the WebSocket connection or connection attempt, if any. If the connection is already CLOSED, this method does nothing. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/close) + */ + close(code?: number, reason?: string): void; + serializeAttachment(attachment: any): void; + deserializeAttachment(): any | null; + /** + * The **`WebSocket.readyState`** read-only property returns the current state of the WebSocket connection. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/readyState) + */ + readyState: number; + /** + * The **`WebSocket.url`** read-only property returns the absolute URL of the WebSocket as resolved by the constructor. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/url) + */ + url: string | null; + /** + * The **`WebSocket.protocol`** read-only property returns the name of the sub-protocol the server selected; this will be one of the strings specified in the protocols parameter when creating the WebSocket object, or the empty string if no connection is established. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/protocol) + */ + protocol: string | null; + /** + * The **`WebSocket.extensions`** read-only property returns the extensions selected by the server. This is currently only the empty string or a list of extensions as negotiated by the connection. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/extensions) + */ + extensions: string | null; + /** + * The **`WebSocket.binaryType`** property controls the type of binary data being received over the WebSocket connection. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/binaryType) + */ + binaryType: "blob" | "arraybuffer"; +} +interface WebSocketAcceptOptions { + /** + * When set to `true`, receiving a server-initiated WebSocket Close frame will not + * automatically send a reciprocal Close frame, leaving the connection in a half-open + * state. This is useful for proxying scenarios where you need to coordinate closing + * both sides independently. Defaults to `false` when the + * `no_web_socket_half_open_by_default` compatibility flag is enabled. + */ + allowHalfOpen?: boolean; +} +declare const WebSocketPair: { + new (): { + 0: WebSocket; + 1: WebSocket; + }; +}; +interface SqlStorage { + exec>(query: string, ...bindings: any[]): SqlStorageCursor; + get databaseSize(): number; + Cursor: typeof SqlStorageCursor; + Statement: typeof SqlStorageStatement; +} +declare abstract class SqlStorageStatement { +} +type SqlStorageValue = ArrayBuffer | string | number | null; +declare abstract class SqlStorageCursor> { + next(): IteratorResult; + toArray(): T[]; + one(): T; + raw(): IterableIterator; + columnNames: string[]; + get rowsRead(): number; + get rowsWritten(): number; + [Symbol.iterator](): IterableIterator; +} +interface Socket { + get readable(): ReadableStream; + get writable(): WritableStream; + get closed(): Promise; + get opened(): Promise; + get upgraded(): boolean; + get secureTransport(): "on" | "off" | "starttls"; + close(): Promise; + startTls(options?: TlsOptions): Socket; +} +interface SocketOptions { + secureTransport?: string; + allowHalfOpen: boolean; + highWaterMark?: (number | bigint); +} +interface SocketAddress { + hostname: string; + port: number; +} +interface TlsOptions { + expectedServerHostname?: string; +} +interface SocketInfo { + remoteAddress?: string; + localAddress?: string; +} +/** + * The **`EventSource`** interface is web content's interface to server-sent events. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource) + */ +declare class EventSource extends EventTarget { + constructor(url: string, init?: EventSourceEventSourceInit); + /** + * The **`close()`** method of the EventSource interface closes the connection, if one is made, and sets the EventSource.readyState attribute to 2 (closed). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/close) + */ + close(): void; + /** + * The **`url`** read-only property of the EventSource interface returns a string representing the URL of the source. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/url) + */ + get url(): string; + /** + * The **`withCredentials`** read-only property of the EventSource interface returns a boolean value indicating whether the EventSource object was instantiated with CORS credentials set. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/withCredentials) + */ + get withCredentials(): boolean; + /** + * The **`readyState`** read-only property of the EventSource interface returns a number representing the state of the connection. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/readyState) + */ + get readyState(): number; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/open_event) */ + get onopen(): any | null; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/open_event) */ + set onopen(value: any | null); + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/message_event) */ + get onmessage(): any | null; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/message_event) */ + set onmessage(value: any | null); + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/error_event) */ + get onerror(): any | null; + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/error_event) */ + set onerror(value: any | null); + static readonly CONNECTING: number; + static readonly OPEN: number; + static readonly CLOSED: number; + static from(stream: ReadableStream): EventSource; +} +interface EventSourceEventSourceInit { + withCredentials?: boolean; + fetcher?: Fetcher; +} +interface ExecOutput { + readonly stdout: ArrayBuffer; + readonly stderr: ArrayBuffer; + readonly exitCode: number; +} +interface ContainerExecOptions { + cwd?: string; + env?: Record; + user?: string; + signal?: AbortSignal; + pty?: boolean | ContainerExecPtyOptions; + stdin?: ReadableStream | "pipe"; + stdout?: "pipe" | "ignore"; + stderr?: "pipe" | "ignore" | "combined"; +} +interface ContainerExecPtyOptions { + cols?: number; + rows?: number; +} +interface ExecProcess { + readonly stdin: WritableStream | null; + readonly stdout: ReadableStream | null; + readonly stderr: ReadableStream | null; + readonly pid: number; + readonly isPty: boolean; + readonly exitCode: Promise; + output(): Promise; + kill(signal?: number): void; + resize(cols: number, rows: number): void; +} +interface Container { + get running(): boolean; + get images(): Record; + start(options?: ContainerStartupOptions): void; + monitor(): Promise; + destroy(error?: any): Promise; + signal(signo: number): void; + getTcpPort(port: number): Fetcher; + setInactivityTimeout(durationMs: number | bigint): Promise; + interceptOutboundHttp(addr: string, binding: Fetcher): Promise; + interceptAllOutboundHttp(binding: Fetcher): Promise; + snapshotContainer(options?: ContainerSnapshotOptions): Promise; + interceptOutboundHttps(addr: string, binding: Fetcher): Promise; + exec(cmd: string[], options?: ContainerExecOptions): Promise; + inspect(): Promise; +} +interface ContainerDirectorySnapshot { + id: string; + size: number; + dir: string; + name?: string; +} +type ContainerDirectorySnapshotRestoreParams = { + snapshot: ContainerDirectorySnapshot; + mountPoint?: string; +} | { + snapshot?: undefined; + mountPoint: string; +}; +interface ContainerSnapshot { + id: string; + size: number; + name?: string; +} +interface ContainerSnapshotRestoreParams { + id: string; +} +interface ContainerSnapshotOptions { + name?: string; +} +type ContainerStartupOptions = { + entrypoint?: string[]; + enableInternet: boolean; + env?: Record; + instance?: "lite" | "basic" | "standard-1" | "standard-2" | "standard-3" | "standard-4" | ContainerStartResources; + labels?: Record; + directorySnapshots?: ContainerDirectorySnapshotRestoreParams[]; +} & ({ + image: string; + containerSnapshot?: never; +} | { + image?: never; + containerSnapshot?: ContainerSnapshotRestoreParams; +}); +interface ContainerInfo { + labels: Record; + image: string; +} +interface ContainerStartResources { + vcpu: number; + memoryMib: number; + diskMb: number; +} +/** + * The **`MessagePort`** interface of the Channel Messaging API represents one of the two ports of a MessageChannel, allowing messages to be sent from one port and listening out for them arriving at the other. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort) + */ +declare abstract class MessagePort extends EventTarget { + /** + * The **`postMessage()`** method of the MessagePort interface sends a message from the port, and optionally, transfers ownership of objects to other browsing contexts. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort/postMessage) + */ + postMessage(data?: any, options?: (any[] | MessagePortPostMessageOptions)): void; + /** + * The **`close()`** method of the MessagePort interface disconnects the port, so it is no longer active. This stops the flow of messages to that port. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort/close) + */ + close(): void; + /** + * The **`start()`** method of the MessagePort interface starts the sending of messages queued on the port. This method is only needed when using EventTarget.addEventListener; it is implied when using onmessage. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort/start) + */ + start(): void; + get onmessage(): any | null; + set onmessage(value: any | null); +} +/** + * The **`MessageChannel`** interface of the Channel Messaging API allows us to create a new message channel and send data through it via its two MessagePort properties. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageChannel) + */ +declare class MessageChannel { + constructor(); + /** + * The **`port1`** read-only property of the MessageChannel interface returns the first port of the message channel — the port attached to the context that originated the channel. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageChannel/port1) + */ + readonly port1: MessagePort; + /** + * The **`port2`** read-only property of the MessageChannel interface returns the second port of the message channel — the port attached to the context at the other end of the channel, which the message is initially sent to. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageChannel/port2) + */ + readonly port2: MessagePort; +} +interface MessagePortPostMessageOptions { + transfer?: any[]; +} +type LoopbackForExport Rpc.EntrypointBranded) | ExportedHandler | undefined = undefined> = T extends new (...args: any[]) => Rpc.WorkerEntrypointBranded ? LoopbackServiceStub> : T extends new (...args: any[]) => Rpc.DurableObjectBranded ? LoopbackDurableObjectClass> : T extends new (...args: any[]) => CloudflareWorkersModule.WorkflowEntrypoint ? Workflow : T extends ExportedHandler ? LoopbackServiceStub : undefined; +type LoopbackServiceStub = Fetcher & (T extends CloudflareWorkersModule.WorkerEntrypoint ? (opts: { + props?: Props; +}) => Fetcher : (opts: { + props?: any; +}) => Fetcher); +type LoopbackDurableObjectClass = DurableObjectClass & (T extends CloudflareWorkersModule.DurableObject ? (opts: { + props?: Props; +}) => DurableObjectClass : (opts: { + props?: any; +}) => DurableObjectClass); +interface LoopbackDurableObjectNamespace extends DurableObjectNamespace { +} +interface LoopbackColoLocalActorNamespace extends ColoLocalActorNamespace { +} +interface SyncKvStorage { + get(key: string): T | undefined; + list(options?: SyncKvListOptions): Iterable<[ + string, + T + ]>; + put(key: string, value: T): void; + delete(key: string): boolean; +} +interface SyncKvListOptions { + start?: string; + startAfter?: string; + end?: string; + prefix?: string; + reverse?: boolean; + limit?: number; +} +interface WorkerStub { + getEntrypoint(name?: string, options?: WorkerStubEntrypointOptions): Fetcher; + getDurableObjectClass(name?: string, options?: WorkerStubEntrypointOptions): DurableObjectClass; +} +interface WorkerStubEntrypointOptions { + props?: any; + limits?: workerdResourceLimits; +} +interface WorkerLoader { + get(name: string | null, getCode: () => WorkerLoaderWorkerCode | Promise): WorkerStub; + load(code: WorkerLoaderWorkerCode): WorkerStub; +} +interface WorkerLoaderModule { + js?: string; + cjs?: string; + text?: string; + data?: ArrayBuffer; + json?: any; + py?: string; + wasm?: ArrayBuffer | ArrayBufferView | WebAssembly.Module; +} +interface WorkerLoaderWorkerCode { + compatibilityDate: string; + compatibilityFlags?: string[]; + allowExperimental?: boolean; + limits?: workerdResourceLimits; + mainModule: string; + modules: Record; + env?: any; + globalOutbound?: (Fetcher | null); + tails?: Fetcher[]; + streamingTails?: Fetcher[]; +} +interface workerdResourceLimits { + cpuMs?: number; + subRequests?: number; +} +/** +* The Workers runtime supports a subset of the Performance API, used to measure timing and performance, +* as well as timing of subrequests and other operations. +* +* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/) +*/ +declare abstract class Performance { + /* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/#performancetimeorigin) */ + get timeOrigin(): number; + /* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/#performancenow) */ + now(): number; + /** + * The **`toJSON()`** method of the Performance interface is a serializer; it returns a JSON representation of the Performance object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Performance/toJSON) + */ + toJSON(): object; +} +interface Tracing { + enterSpan(name: string, callback: (span: Span, ...args: A) => T, ...args: A): T; + startActiveSpan(name: string, callback: (span: Span, ...args: A) => T, ...args: A): T; + startSpan(name: string): Span; + getActiveSpan(): Span | undefined; + Span: typeof Span; +} +declare abstract class Span { + get isTraced(): boolean; + spanContext(): TracingSpanContext; + setAttribute(key: string, value: boolean | number | string): this; + setAttributes(attributes: Record): this; + recordException(exception: string | { + code: string | number; + name?: string; + message?: string; + stack?: string; + } | { + code?: string | number; + name: string; + message?: string; + stack?: string; + } | { + code?: string | number; + name?: string; + message: string; + stack?: string; + }): void; + updateName(name: string): this; + setStatus(status: TracingSpanStatus): this; + end(): void; +} +type TracingSpanStatusCode = "unset" | "ok" | "error"; +interface TracingSpanStatus { + code: TracingSpanStatusCode; + message?: string; +} +interface TracingSpanContext { + traceId: string; + spanId: string; + traceFlags: number; +} +/** + * Represents the identity of a user authenticated via Cloudflare Access. + * This matches the result of calling /cdn-cgi/access/get-identity. + * + * The exact structure of the returned object depends on the identity provider + * configuration for the Access application. The fields below represent commonly + * available properties, but additional provider-specific fields may be present. + */ +interface CloudflareAccessIdentity extends Record { + /** The user's email address, if available from the identity provider. */ + email?: string; + /** The user's display name. */ + name?: string; + /** The user's unique identifier. */ + user_uuid?: string; + /** The Cloudflare account ID. */ + account_id?: string; + /** Login timestamp (Unix epoch seconds). */ + iat?: number; + /** The user's IP address at authentication time. */ + ip?: string; + /** Authentication methods used (e.g., "pwd"). */ + amr?: string[]; + /** Identity provider information. */ + idp?: { + id: string; + type: string; + }; + /** Geographic information about where the user authenticated. */ + geo?: { + country: string; + }; + /** Group memberships from the identity provider. */ + groups?: Array<{ + id: string; + name: string; + email?: string; + }>; + /** Device posture check results, keyed by check ID. */ + devicePosture?: Record; + /** True if the user connected via Cloudflare WARP. */ + is_warp?: boolean; + /** True if the user is authenticated via Cloudflare Gateway. */ + is_gateway?: boolean; +} +// ============================================================================ +// Agent Memory +// +// Public type surface for user Workers binding to an Agent Memory namespace. +// ============================================================================ +/** Memory type — every memory is classified into exactly one. */ +type AgentMemoryMemoryType = "fact" | "event" | "instruction" | "task"; +/** Search intensity for recall. */ +type AgentMemoryThinkingLevel = "low" | "medium" | "high"; +/** Response verbosity for recall. */ +type AgentMemoryResponseLength = "short" | "medium" | "long"; +/** A conversation message passed to ingest(). */ +interface AgentMemoryMessage { + role: "system" | "user" | "assistant"; + content: string; + /** Optional message timestamp. */ + timestamp?: Date; +} +/** Raw memory content passed to remember(). */ +interface AgentMemoryIncomingMemory { + /** Raw memory content. The service classifies and summarizes automatically. */ + content: string; + /** Optional session identifier to associate with this memory. */ + sessionId?: string | null | undefined; +} +/** A stored memory returned from remember(), get(), and delete(). */ +interface AgentMemoryMemory { + /** Memory ID. */ + id: string; + /** Memory type. */ + type: AgentMemoryMemoryType; + /** Text summary. */ + summary: string; + /** Memory text. */ + content: string; + /** Session that created this memory. */ + sessionId: string | null; + /** Memory creation time. */ + createdAt: Date; + /** Memory last-update time. */ + updatedAt: Date; +} +/** Single entry in a list() response. Same shape as Memory minus full content. */ +type AgentMemoryMemoryListEntry = Omit; +/** A scored memory candidate in a recall result. */ +interface AgentMemoryScoredCandidate { + /** Candidate ID. */ + id: string; + /** Text summary. */ + summary: string; + /** Session that created this candidate, when known. */ + sessionId: string | null; + /** Relevance score (higher is better). Comparable only within a single query. */ + score: number; +} +/** Options for the ingest() method. */ +interface AgentMemoryIngestOptions { + /** Session identifier to associate with memories created during ingestion. */ + sessionId?: string | null | undefined; +} +/** Options for the getSummary() method. */ +interface AgentMemoryGetSummaryOptions { + /** Session identifier to retrieve session summary for. */ + sessionId?: string | null | undefined; +} +/** Response from the getSummary() method. */ +interface AgentMemoryGetSummaryResponse { + /** Markdown summary. */ + summary: string; +} +/** + * Options for the recall() method. + * + * `referenceDate` accepts a Date object, an ISO-8601 date string + * (YYYY-MM-DD), or a full ISO-8601 datetime string. When provided, this + * date is used as "today" for resolving relative time references + * ("how many days ago", "last week") instead of the server's wall-clock time. + */ +interface AgentMemoryRecallOptions { + /** Recall intensity: "low" (default), "medium", or "high". */ + thinkingLevel?: AgentMemoryThinkingLevel; + /** Response verbosity: "short", "medium" (default), or "long". */ + responseLength?: AgentMemoryResponseLength; + /** Temporal anchor for date arithmetic. */ + referenceDate?: Date | string; +} +/** Response from the recall() method. */ +interface AgentMemoryRecallResult { + /** Number of memories retrieved. */ + count: number; + /** LLM-generated answer synthesizing the matching memories. */ + answer: string; + /** Matching memories ranked by relevance. */ + candidates: AgentMemoryScoredCandidate[]; +} +/** + * Options for the list() method. + * + * `cursor` is the opaque continuation token returned by the previous page; + * pass it back unchanged to fetch the next page. `sessionId` and `type` + * are exact-match filters; combining them is allowed. + */ +interface AgentMemoryListMemoriesOptions { + /** Maximum number of memories to return. Default 20, max 500. */ + limit?: number; + /** Opaque cursor from a previous page. */ + cursor?: string; + /** Exact-match session filter. */ + sessionId?: string; + /** Exact-match memory-type filter. */ + type?: AgentMemoryMemoryType; +} +/** Response from the list() method. */ +interface AgentMemoryListMemoriesResult { + memories: AgentMemoryMemoryListEntry[]; + /** Continuation cursor; absent when this page exhausted the result set. */ + cursor?: string; +} +/** + * A single Agent Memory profile, scoped to a profile name. + * + * Returned by {@link AgentMemoryNamespace.getProfile}. + */ +declare abstract class AgentMemoryProfile { + /** + * Retrieve a memory by ID. + * + * @param memoryId - ULID of the memory to retrieve. + * @throws if the memory does not exist. + */ + get(memoryId: string): Promise; + /** + * Delete a memory by ID. + * + * Removes the memory and any source messages linked by the memory's + * source message IDs. + * + * @param memoryId - ULID of the memory to delete. + * @throws if the memory does not exist. + */ + delete(memoryId: string): Promise; + /** + * Store a memory in this profile. The content is automatically classified, + * summarized, and indexed. + * + * @param memory - Raw memory content to persist. + */ + remember(memory: AgentMemoryIncomingMemory): Promise; + /** + * Extract memories from a conversation. + * + * @param messages - Conversation messages to extract memories from. + * @param options - Optional ingest options. + */ + ingest(messages: Iterable, options?: AgentMemoryIngestOptions): Promise; + /** + * Get a profile summary. + * + * @param options - Optional getSummary options. + */ + getSummary(options?: AgentMemoryGetSummaryOptions): Promise; + /** + * Recall memories in this profile. + * + * @param query - Recall query matched against memory content and keywords. + * @param options - Optional recall parameters. + * @returns Matching memories with relevance scores and a synthesized answer. + */ + recall(query: string, options?: AgentMemoryRecallOptions): Promise; + /** + * List active memories in this profile. + * + * Returns a paginated, filterable view of stored memories. Superseded + * versions are excluded. Use the returned `cursor` (when present) to + * fetch the next page. + * + * @param options - Optional pagination and filter options. + */ + list(options?: AgentMemoryListMemoriesOptions): Promise; + /** + * Soft-delete every memory and message in this profile that is tagged + * with `sessionId`. + * + * Idempotent: deleting a sessionId that has no rows is a no-op. + * + * @param sessionId - Session to delete. + */ + deleteSession(sessionId: string): Promise; +} +/** + * Namespace-level Agent Memory binding. + * + * Used as the type of an `env.MEMORY`-style binding backed by the Agent + * Memory product. + * + * @example + * ```ts + * export default { + * async fetch(_request: Request, env: Env): Promise { + * const profile = await env.MEMORY.getProfile("wrangler-e2e"); + * const summary = await profile.getSummary(); + * return Response.json(summary); + * }, + * }; + * ``` + */ +declare abstract class AgentMemoryNamespace { + /** + * Get a memory profile by name. Profiles are isolated by namespace and + * addressed by a compound key (namespaceId:profileName). + * + * @param profileName - Profile name (validated against naming rules). + * @returns RPC target for interacting with the profile. + */ + getProfile(profileName: string): Promise; + /** + * Soft-delete a profile and schedule deferred purge. Marks all + * memories and messages as deleted. + * + * @param profileName - Name of the profile to delete. + */ + deleteProfile(profileName: string): Promise; +} +// ============ AI Search Error Interfaces ============ +interface AiSearchInternalError extends Error { +} +interface AiSearchNotFoundError extends Error { +} +// ============ AI Search Common Types ============ +/** A single message in a conversation-style search or chat request. */ +type AiSearchMessage = { + role: 'system' | 'developer' | 'user' | 'assistant' | 'tool'; + content: string | null; +}; +/** + * Common shape for `ai_search_options` used by both single-instance and multi-instance requests. + * Contains retrieval, query rewrite, reranking, and cache sub-options. + */ +type AiSearchOptions = { + retrieval?: { + /** Which retrieval backend to use. Defaults to the instance's configured index_method. */ + retrieval_type?: 'vector' | 'keyword' | 'hybrid'; + /** Fusion method for combining vector + keyword results. */ + fusion_method?: 'max' | 'rrf'; + /** How keyword terms are combined: "and" = all terms must match, "or" = any term matches. */ + keyword_match_mode?: 'and' | 'or'; + /** Minimum similarity score (0-1) for a result to be included. Default 0.4. */ + match_threshold?: number; + /** Maximum number of results to return (1-50). Default 10. */ + max_num_results?: number; + /** Vectorize metadata filters applied to the search. */ + filters?: VectorizeVectorMetadataFilter; + /** Number of surrounding chunks to include for context (0-3). Default 0. */ + context_expansion?: number; + /** If true, return only item metadata without chunk text. */ + metadata_only?: boolean; + /** If true (default), return empty results on retrieval failure instead of throwing. */ + return_on_failure?: boolean; + /** Boost results by metadata field values. Max 3 entries. */ + boost_by?: Array<{ + field: string; + direction?: 'asc' | 'desc' | 'exists' | 'not_exists'; + }>; + [key: string]: unknown; + }; + query_rewrite?: { + enabled?: boolean; + model?: string; + rewrite_prompt?: string; + [key: string]: unknown; + }; + reranking?: { + enabled?: boolean; + model?: string; + /** Match threshold (0-1, default 0.4) */ + match_threshold?: number; + [key: string]: unknown; + }; + cache?: { + enabled?: boolean; + cache_threshold?: 'super_strict_match' | 'close_enough' | 'flexible_friend' | 'anything_goes'; + }; + [key: string]: unknown; +}; +// ============ AI Search Request Types ============ +/** + * Request body for single-instance search. + * Exactly one of `query` or `messages` must be provided. + */ +type AiSearchSearchRequest = { + /** Simple query string. */ + query: string; + messages?: never; + ai_search_options?: AiSearchOptions; +} | { + query?: never; + /** Conversation-style input. At least one user message with non-empty content is required. */ + messages: AiSearchMessage[]; + ai_search_options?: AiSearchOptions; +}; +type AiSearchChatCompletionsRequest = { + messages: AiSearchMessage[]; + model?: string; + stream?: boolean; + ai_search_options?: AiSearchOptions; + [key: string]: unknown; +}; +// ============ AI Search Multi-Instance Types (Namespace-Scoped) ============ +/** `ai_search_options` shape for multi-instance requests — requires `instance_ids`. */ +type AiSearchMultiSearchOptions = AiSearchOptions & { + /** Instance IDs to search across (1-10). */ + instance_ids: string[]; +}; +/** + * Request for searching across multiple instances within a namespace. + * `ai_search_options` is required and must include `instance_ids`. + * Exactly one of `query` or `messages` must be provided. + */ +type AiSearchMultiSearchRequest = { + /** Simple query string. */ + query: string; + messages?: never; + ai_search_options: AiSearchMultiSearchOptions; +} | { + query?: never; + /** Conversation-style input. */ + messages: AiSearchMessage[]; + ai_search_options: AiSearchMultiSearchOptions; +}; +/** A search result chunk tagged with the instance it originated from. */ +type AiSearchMultiSearchChunk = AiSearchSearchResponse['chunks'][number] & { + instance_id: string; +}; +/** Describes a per-instance error during a multi-instance operation. */ +type AiSearchMultiSearchError = { + instance_id: string; + message: string; +}; +/** Response from a multi-instance search, with chunks tagged by instance and optional partial-failure errors. */ +type AiSearchMultiSearchResponse = { + search_query: string; + chunks: AiSearchMultiSearchChunk[]; + errors?: AiSearchMultiSearchError[]; +}; +/** Request for chat completions across multiple instances within a namespace. `ai_search_options` is required and must include `instance_ids`. */ +type AiSearchMultiChatCompletionsRequest = Omit & { + ai_search_options: AiSearchMultiSearchOptions; +}; +/** Response from multi-instance chat completions, with chunks tagged by instance and optional partial-failure errors. */ +type AiSearchMultiChatCompletionsResponse = Omit & { + chunks: AiSearchMultiSearchChunk[]; + errors?: AiSearchMultiSearchError[]; +}; +// ============ AI Search Response Types ============ +type AiSearchSearchResponse = { + search_query: string; + chunks: Array<{ + id: string; + type: string; + /** Match score (0-1) */ + score: number; + text: string; + item: { + timestamp?: number; + key: string; + metadata?: Record; + }; + scoring_details?: { + /** Keyword match score (0-1) */ + keyword_score?: number; + /** Vector similarity score (0-1) */ + vector_score?: number; + /** Keyword rank position */ + keyword_rank?: number; + /** Vector rank position */ + vector_rank?: number; + /** Reranking model score */ + reranking_score?: number; + /** Fusion method used to combine results */ + fusion_method?: 'rrf' | 'max'; + [key: string]: unknown; + }; + }>; +}; +type AiSearchChatCompletionsResponse = { + id?: string; + object?: string; + model?: string; + choices: Array<{ + index?: number; + message: { + role: 'system' | 'developer' | 'user' | 'assistant' | 'tool'; + content: string | null; + [key: string]: unknown; + }; + [key: string]: unknown; + }>; + chunks: AiSearchSearchResponse['chunks']; + [key: string]: unknown; +}; +type AiSearchStatsResponse = { + queued?: number; + running?: number; + completed?: number; + error?: number; + skipped?: number; + outdated?: number; + last_activity?: string; + /** Storage engine statistics. */ + engine?: { + vectorize?: { + vectorsCount: number; + dimensions: number; + }; + r2?: { + payloadSizeBytes: number; + metadataSizeBytes: number; + objectCount: number; + }; + }; +}; +// ============ AI Search Instance Info Types ============ +type AiSearchInstanceInfo = { + id: string; + type?: 'r2' | 'web-crawler' | string; + source?: string; + source_params?: unknown; + paused?: boolean; + status?: string; + namespace?: string; + created_at?: string; + modified_at?: string; + token_id?: string; + ai_gateway_id?: string; + rewrite_query?: boolean; + reranking?: boolean; + embedding_model?: string; + ai_search_model?: string; + rewrite_model?: string; + reranking_model?: string; + /** @deprecated Use index_method instead. */ + hybrid_search_enabled?: boolean; + /** Controls which storage backends are active. */ + index_method?: { + vector?: boolean; + keyword?: boolean; + }; + /** Fusion method for combining vector and keyword results. */ + fusion_method?: 'max' | 'rrf'; + indexing_options?: { + keyword_tokenizer?: 'porter' | 'trigram'; + } | null; + retrieval_options?: { + keyword_match_mode?: 'and' | 'or'; + boost_by?: Array<{ + field: string; + direction?: 'asc' | 'desc' | 'exists' | 'not_exists'; + }>; + } | null; + chunk?: boolean; + chunk_size?: number; + chunk_overlap?: number; + score_threshold?: number; + max_num_results?: number; + cache?: boolean; + cache_threshold?: 'super_strict_match' | 'close_enough' | 'flexible_friend' | 'anything_goes'; + custom_metadata?: Array<{ + field_name: string; + data_type: 'text' | 'number' | 'boolean' | 'datetime'; + }>; + /** Sync interval in seconds. */ + sync_interval?: 3600 | 7200 | 14400 | 21600 | 43200 | 86400; + metadata?: Record; + [key: string]: unknown; +}; +/** Pagination, search, and ordering parameters for listing instances within a namespace. */ +type AiSearchListInstancesParams = { + page?: number; + per_page?: number; + /** Search instances by ID. */ + search?: string; + /** Field to sort by. */ + order_by?: 'created_at'; + /** Sort direction. */ + order_by_direction?: 'asc' | 'desc'; +}; +type AiSearchListResponse = { + result: AiSearchInstanceInfo[]; + result_info?: { + count: number; + page: number; + per_page: number; + total_count: number; + }; +}; +// ============ AI Search Config Types ============ +type AiSearchConfig = { + /** Instance ID (1-32 chars, pattern: ^[a-z0-9_]+(?:-[a-z0-9_]+)*$) */ + id: string; + /** Instance type. Omit to create with built-in storage. */ + type?: 'r2' | 'web-crawler' | string; + /** Source URL (required for web-crawler type). */ + source?: string; + source_params?: unknown; + /** Token ID (UUID format) */ + token_id?: string; + ai_gateway_id?: string; + /** Enable query rewriting (default false) */ + rewrite_query?: boolean; + /** Enable reranking (default false) */ + reranking?: boolean; + embedding_model?: string; + ai_search_model?: string; + rewrite_model?: string; + reranking_model?: string; + /** @deprecated Use index_method instead. */ + hybrid_search_enabled?: boolean; + /** Controls which storage backends are used during indexing. Defaults to vector-only. */ + index_method?: { + vector?: boolean; + keyword?: boolean; + }; + /** Fusion method for combining vector and keyword results. "rrf" = reciprocal rank fusion (default), "max" = maximum score. */ + fusion_method?: 'max' | 'rrf'; + indexing_options?: { + keyword_tokenizer?: 'porter' | 'trigram'; + } | null; + retrieval_options?: { + keyword_match_mode?: 'and' | 'or'; + boost_by?: Array<{ + field: string; + direction?: 'asc' | 'desc' | 'exists' | 'not_exists'; + }>; + } | null; + chunk?: boolean; + chunk_size?: number; + chunk_overlap?: number; + /** Minimum similarity score (0-1) for a result to be included. */ + score_threshold?: number; + max_num_results?: number; + cache?: boolean; + /** Similarity threshold for cache hits. Stricter = fewer cache hits but higher relevance. */ + cache_threshold?: 'super_strict_match' | 'close_enough' | 'flexible_friend' | 'anything_goes'; + custom_metadata?: Array<{ + field_name: string; + data_type: 'text' | 'number' | 'boolean' | 'datetime'; + }>; + namespace?: string; + /** Sync interval in seconds. 3600=1h, 7200=2h, 14400=4h, 21600=6h, 43200=12h, 86400=24h. */ + sync_interval?: 3600 | 7200 | 14400 | 21600 | 43200 | 86400; + metadata?: Record; + [key: string]: unknown; +}; +// ============ AI Search Item Types ============ +type AiSearchItemInfo = { + id: string; + key: string; + status: 'completed' | 'error' | 'skipped' | 'queued' | 'running' | 'outdated'; + next_action?: 'INDEX' | 'DELETE' | null; + error?: string; + checksum?: string; + namespace?: string; + chunks_count?: number | null; + file_size?: number | null; + source_id?: string | null; + last_seen_at?: string; + created_at?: string; + metadata?: Record; + [key: string]: unknown; +}; +type AiSearchItemContentResult = { + body: ReadableStream; + contentType: string; + filename: string; + size: number; +}; +type AiSearchUploadItemOptions = { + metadata?: Record; +}; +type AiSearchListItemsParams = { + page?: number; + per_page?: number; + /** Search items by key name. */ + search?: string; + /** Sort order for results. */ + sort_by?: 'status' | 'modified_at'; + /** Filter items by processing status. */ + status?: 'queued' | 'running' | 'completed' | 'error' | 'skipped' | 'outdated'; + /** Filter items by source (e.g. "builtin" or "web-crawler:https://example.com"). */ + source?: string; + /** JSON-encoded Vectorize filter for metadata filtering. */ + metadata_filter?: string; + /** Filter items by their unique ID. Returns at most one item. */ + item_id?: string; + /** + * Filter items by their exact key (object key / filename). Keys are unique + * per source, so combine with `source` to disambiguate across data sources. + */ + key?: string; +}; +type AiSearchListItemsResponse = { + result: AiSearchItemInfo[]; + result_info?: { + count: number; + page: number; + per_page: number; + total_count: number; + }; +}; +// ============ AI Search Item Logs Types ============ +type AiSearchItemLogsParams = { + /** Maximum number of log entries to return (1-100, default 50). */ + limit?: number; + /** Opaque cursor for pagination. Pass the `cursor` value from a previous response. */ + cursor?: string; +}; +type AiSearchItemLog = { + timestamp: string; + action: string; + message: string; + fileKey?: string; + chunkCount?: number; + processingTimeMs?: number; + errorType?: string; +}; +/** Paginated response for item processing logs (cursor-based). */ +type AiSearchItemLogsResponse = { + result: AiSearchItemLog[]; + result_info: { + count: number; + per_page: number; + cursor: string | null; + truncated: boolean; + }; +}; +// ============ AI Search Item Chunks Types ============ +type AiSearchItemChunksParams = { + /** Maximum number of chunks to return (1-100, default 20). */ + limit?: number; + /** Offset into the chunks list (default 0). */ + offset?: number; +}; +/** A single indexed chunk belonging to an item, including its text content and byte range. */ +type AiSearchItemChunk = { + id: string; + text: string; + start_byte: number; + end_byte: number; + item?: { + timestamp?: number; + key: string; + metadata?: Record; + }; +}; +/** Paginated response for item chunks (offset-based). */ +type AiSearchItemChunksResponse = { + result: AiSearchItemChunk[]; + result_info: { + count: number; + total: number; + limit: number; + offset: number; + }; +}; +// ============ AI Search Job Types ============ +type AiSearchJobInfo = { + id: string; + source: 'user' | 'schedule'; + description?: string; + last_seen_at?: string; + started_at?: string; + ended_at?: string; + end_reason?: string; +}; +type AiSearchJobLog = { + id: number; + message: string; + message_type: number; + created_at: number; +}; +type AiSearchCreateJobParams = { + description?: string; +}; +type AiSearchListJobsParams = { + page?: number; + per_page?: number; +}; +type AiSearchListJobsResponse = { + result: AiSearchJobInfo[]; + result_info?: { + count: number; + page: number; + per_page: number; + total_count: number; + }; +}; +type AiSearchJobLogsParams = { + page?: number; + per_page?: number; +}; +type AiSearchJobLogsResponse = { + result: AiSearchJobLog[]; + result_info?: { + count: number; + page: number; + per_page: number; + total_count: number; + }; +}; +// ============ AI Search Sub-Service Classes ============ +/** + * Single item service for an AI Search instance. + * Provides info, download, sync, logs, and chunks operations on a specific item. + */ +declare abstract class AiSearchItem { + /** Get metadata about this item. */ + info(): Promise; + /** + * Download the item's content. + * @returns Object with body stream, content type, filename, and size. + */ + download(): Promise; + /** + * Trigger re-indexing of this item. + * @returns The updated item info. + */ + sync(): Promise; + /** + * Retrieve processing logs for this item (cursor-based pagination). + * @param params Optional pagination parameters (limit, cursor). + * @returns Paginated log entries for this item. + */ + logs(params?: AiSearchItemLogsParams): Promise; + /** + * List indexed chunks for this item (offset-based pagination). + * @param params Optional pagination parameters (limit, offset). + * @returns Paginated chunk entries for this item. + */ + chunks(params?: AiSearchItemChunksParams): Promise; +} +/** + * Items collection service for an AI Search instance. + * Provides list, upload, and access to individual items. + */ +declare abstract class AiSearchItems { + /** List items in this instance. */ + list(params?: AiSearchListItemsParams): Promise; + /** + * Upload a file as an item. Behaves as an upsert: if an item with the same + * filename already exists, it is overwritten and re-indexed. + * @param name Filename for the uploaded item. + * @param content File content as a ReadableStream, Blob, or string. + * @param options Optional metadata to attach to the item. + * @returns The created item info. + */ + upload(name: string, content: ReadableStream | Blob | string, options?: AiSearchUploadItemOptions): Promise; + /** + * Upload a file and poll until processing completes. + * Behaves as an upsert: if an item with the same filename already exists, + * it is overwritten and re-indexed. + * @param name Filename for the uploaded item. + * @param content File content as a ReadableStream, Blob, or string. + * @param options Optional metadata and polling configuration. + * @returns The item info after processing completes (or timeout). + */ + uploadAndPoll(name: string, content: ReadableStream | Blob | string, options?: AiSearchUploadItemOptions & { + /** Polling interval in milliseconds (default 1000). */ + pollIntervalMs?: number; + /** Maximum time to wait in milliseconds (default 30000). */ + timeoutMs?: number; + }): Promise; + /** + * Get an item by ID. + * @param itemId The item identifier. + * @returns Item service for info, download, sync, logs, and chunks operations. + */ + get(itemId: string): AiSearchItem; + /** + * Delete an item from the instance. + * @param itemId The item identifier. + */ + delete(itemId: string): Promise; +} +/** + * Single job service for an AI Search instance. + * Provides info, logs, and cancel operations for a specific job. + */ +declare abstract class AiSearchJob { + /** Get metadata about this job. */ + info(): Promise; + /** Get logs for this job. */ + logs(params?: AiSearchJobLogsParams): Promise; + /** + * Cancel a running job. + * @returns The updated job info. + * @throws AiSearchNotFoundError if the job does not exist. + */ + cancel(): Promise; +} +/** + * Jobs collection service for an AI Search instance. + * Provides list, create, and access to individual jobs. + */ +declare abstract class AiSearchJobs { + /** List jobs for this instance. */ + list(params?: AiSearchListJobsParams): Promise; + /** + * Create a new indexing job. + * @param params Optional job parameters. + * @returns The created job info. + */ + create(params?: AiSearchCreateJobParams): Promise; + /** + * Get a job by ID. + * @param jobId The job identifier. + * @returns Job service for info, logs, and cancel operations. + */ + get(jobId: string): AiSearchJob; +} +// ============ AI Search Binding Classes ============ +/** + * Instance-level AI Search service. + * + * Used as: + * - The return type of `AiSearchNamespace.get(name)` (namespace binding) + * - The type of `env.BLOG_SEARCH` (single instance binding via `ai_search`) + * + * Provides search, chat, update, stats, items, and jobs operations. + * + * @example + * ```ts + * // Via namespace binding + * const instance = env.AI_SEARCH.get("blog"); + * const results = await instance.search({ + * query: "How does caching work?", + * }); + * + * // Via single instance binding + * const results = await env.BLOG_SEARCH.search({ + * messages: [{ role: "user", content: "How does caching work?" }], + * }); + * ``` + */ +declare abstract class AiSearchInstance { + /** + * Search the AI Search instance for relevant chunks. + * @param params Search request with query or messages and optional AI search options. + * @returns Search response with matching chunks and search query. + */ + search(params: AiSearchSearchRequest): Promise; + /** + * Generate chat completions with AI Search context (streaming). + * @param params Chat completions request with stream: true. + * @returns ReadableStream of server-sent events. + */ + chatCompletions(params: AiSearchChatCompletionsRequest & { + stream: true; + }): Promise; + /** + * Generate chat completions with AI Search context. + * @param params Chat completions request. + * @returns Chat completion response with choices and RAG chunks. + */ + chatCompletions(params: AiSearchChatCompletionsRequest): Promise; + /** + * Update the instance configuration. + * @param config Partial configuration to update. + * @returns Updated instance info. + */ + update(config: Partial): Promise; + /** Get metadata about this instance. */ + info(): Promise; + /** + * Get instance statistics (item count, indexing status, etc.). + * @returns Statistics with counts per status, last activity time, and engine details. + */ + stats(): Promise; + /** Items collection — list, upload, and manage items in this instance. */ + get items(): AiSearchItems; + /** Jobs collection — list, create, and inspect indexing jobs. */ + get jobs(): AiSearchJobs; +} +/** + * Namespace-level AI Search service. + * + * Used as the type of `env.AI_SEARCH` (namespace binding via `ai_search_namespaces`). + * Scoped to a single namespace. Provides dynamic instance access, creation, deletion, + * and multi-instance search/chat operations. + * + * @example + * ```ts + * // Access an instance within the namespace + * const blog = env.AI_SEARCH.get("blog"); + * const results = await blog.search({ query: "How does caching work?" }); + * + * // List all instances in the namespace + * const instances = await env.AI_SEARCH.list(); + * + * // Create a new instance with built-in storage + * const tenant = await env.AI_SEARCH.create({ id: "tenant-123" }); + * + * // Upload items into the instance + * await tenant.items.upload("doc.pdf", fileContent); + * + * // Search across multiple instances + * const multi = await env.AI_SEARCH.search({ + * query: "caching", + * ai_search_options: { instance_ids: ["blog", "docs"] }, + * }); + * + * // Delete an instance + * await env.AI_SEARCH.delete("tenant-123"); + * ``` + */ +declare abstract class AiSearchNamespace { + /** + * Get an instance by name within the bound namespace. + * @param name Instance name. + * @returns Instance service for search, chat, update, stats, items, and jobs. + */ + get(name: string): AiSearchInstance; + /** + * List instances in the bound namespace. + * @param params Optional pagination, search, and ordering parameters. + * @returns Array of instance metadata with pagination info. + */ + list(params?: AiSearchListInstancesParams): Promise; + /** + * Create a new instance within the bound namespace. + * @param config Instance configuration. Only `id` is required — omit `type` and `source` to create with built-in storage. + * @returns Instance service for the newly created instance. + * + * @example + * ```ts + * // Create with built-in storage (upload items manually) + * const instance = await env.AI_SEARCH.create({ id: "my-search" }); + * + * // Create with web crawler source + * const instance = await env.AI_SEARCH.create({ + * id: "docs-search", + * type: "web-crawler", + * source: "https://developers.cloudflare.com", + * }); + * ``` + */ + create(config: AiSearchConfig): Promise; + /** + * Delete an instance from the bound namespace. + * @param name Instance name to delete. + */ + delete(name: string): Promise; + /** + * Search across multiple instances within the bound namespace. + * Fans out to the specified instance_ids and merges results. + * @param params Search request with required `ai_search_options.instance_ids`. + * @returns Search response with chunks tagged by instance_id and optional partial-failure errors. + */ + search(params: AiSearchMultiSearchRequest): Promise; + /** + * Generate chat completions across multiple instances within the bound namespace (streaming). + * Fans out to the specified instance_ids, merges context, and generates a response. + * @param params Chat completions request with stream: true and required `ai_search_options.instance_ids`. + * @returns ReadableStream of server-sent events. + */ + chatCompletions(params: AiSearchMultiChatCompletionsRequest & { + stream: true; + }): Promise; + /** + * Generate chat completions across multiple instances within the bound namespace. + * Fans out to the specified instance_ids, merges context, and generates a response. + * @param params Chat completions request with required `ai_search_options.instance_ids`. + * @returns Chat completion response with choices, chunks tagged by instance_id, and optional partial-failure errors. + */ + chatCompletions(params: AiSearchMultiChatCompletionsRequest): Promise; +} +type AiImageClassificationInput = { + image: number[]; +}; +type AiImageClassificationOutput = { + score?: number; + label?: string; +}[]; +declare abstract class BaseAiImageClassification { + inputs: AiImageClassificationInput; + postProcessedOutputs: AiImageClassificationOutput; +} +type AiImageToTextInput = { + image: number[]; + prompt?: string; + max_tokens?: number; + temperature?: number; + top_p?: number; + top_k?: number; + seed?: number; + repetition_penalty?: number; + frequency_penalty?: number; + presence_penalty?: number; + raw?: boolean; + messages?: RoleScopedChatInput[]; +}; +type AiImageToTextOutput = { + description: string; +}; +declare abstract class BaseAiImageToText { + inputs: AiImageToTextInput; + postProcessedOutputs: AiImageToTextOutput; +} +type AiImageTextToTextInput = { + image: string; + prompt?: string; + max_tokens?: number; + temperature?: number; + ignore_eos?: boolean; + top_p?: number; + top_k?: number; + seed?: number; + repetition_penalty?: number; + frequency_penalty?: number; + presence_penalty?: number; + raw?: boolean; + messages?: RoleScopedChatInput[]; +}; +type AiImageTextToTextOutput = { + description: string; +}; +declare abstract class BaseAiImageTextToText { + inputs: AiImageTextToTextInput; + postProcessedOutputs: AiImageTextToTextOutput; +} +type AiMultimodalEmbeddingsInput = { + image: string; + text: string[]; +}; +type AiIMultimodalEmbeddingsOutput = { + data: number[][]; + shape: number[]; +}; +declare abstract class BaseAiMultimodalEmbeddings { + inputs: AiImageTextToTextInput; + postProcessedOutputs: AiImageTextToTextOutput; +} +type AiObjectDetectionInput = { + image: number[]; +}; +type AiObjectDetectionOutput = { + score?: number; + label?: string; +}[]; +declare abstract class BaseAiObjectDetection { + inputs: AiObjectDetectionInput; + postProcessedOutputs: AiObjectDetectionOutput; +} +type AiSentenceSimilarityInput = { + source: string; + sentences: string[]; +}; +type AiSentenceSimilarityOutput = number[]; +declare abstract class BaseAiSentenceSimilarity { + inputs: AiSentenceSimilarityInput; + postProcessedOutputs: AiSentenceSimilarityOutput; +} +type AiAutomaticSpeechRecognitionInput = { + audio: number[]; +}; +type AiAutomaticSpeechRecognitionOutput = { + text?: string; + words?: { + word: string; + start: number; + end: number; + }[]; + vtt?: string; +}; +declare abstract class BaseAiAutomaticSpeechRecognition { + inputs: AiAutomaticSpeechRecognitionInput; + postProcessedOutputs: AiAutomaticSpeechRecognitionOutput; +} +type AiSummarizationInput = { + input_text: string; + max_length?: number; +}; +type AiSummarizationOutput = { + summary: string; +}; +declare abstract class BaseAiSummarization { + inputs: AiSummarizationInput; + postProcessedOutputs: AiSummarizationOutput; +} +type AiTextClassificationInput = { + text: string; +}; +type AiTextClassificationOutput = { + score?: number; + label?: string; +}[]; +declare abstract class BaseAiTextClassification { + inputs: AiTextClassificationInput; + postProcessedOutputs: AiTextClassificationOutput; +} +type AiTextEmbeddingsInput = { + text: string | string[]; +}; +type AiTextEmbeddingsOutput = { + shape: number[]; + data: number[][]; +}; +declare abstract class BaseAiTextEmbeddings { + inputs: AiTextEmbeddingsInput; + postProcessedOutputs: AiTextEmbeddingsOutput; +} +type RoleScopedChatInput = { + role: "user" | "assistant" | "system" | "tool" | (string & NonNullable); + content: string; + name?: string; +}; +type AiTextGenerationToolLegacyInput = { + name: string; + description: string; + parameters?: { + type: "object" | (string & NonNullable); + properties: { + [key: string]: { + type: string; + description?: string; + }; + }; + required: string[]; + }; +}; +type AiTextGenerationToolInput = { + type: "function" | (string & NonNullable); + function: { + name: string; + description: string; + parameters?: { + type: "object" | (string & NonNullable); + properties: { + [key: string]: { + type: string; + description?: string; + }; + }; + required: string[]; + }; + }; +}; +type AiTextGenerationFunctionsInput = { + name: string; + code: string; +}; +type AiTextGenerationResponseFormat = { + type: string; + json_schema?: any; +}; +type AiTextGenerationInput = { + prompt?: string; + raw?: boolean; + stream?: boolean; + max_tokens?: number; + temperature?: number; + top_p?: number; + top_k?: number; + seed?: number; + repetition_penalty?: number; + frequency_penalty?: number; + presence_penalty?: number; + messages?: RoleScopedChatInput[]; + response_format?: AiTextGenerationResponseFormat; + tools?: AiTextGenerationToolInput[] | AiTextGenerationToolLegacyInput[] | (object & NonNullable); + functions?: AiTextGenerationFunctionsInput[]; +}; +type AiTextGenerationToolLegacyOutput = { + name: string; + arguments: unknown; +}; +type AiTextGenerationToolOutput = { + id: string; + type: "function"; + function: { + name: string; + arguments: string; + }; +}; +type UsageTags = { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; +}; +type AiTextGenerationOutput = { + response?: string; + tool_calls?: AiTextGenerationToolLegacyOutput[] & AiTextGenerationToolOutput[]; + usage?: UsageTags; +}; +declare abstract class BaseAiTextGeneration { + inputs: AiTextGenerationInput; + postProcessedOutputs: AiTextGenerationOutput; +} +type AiTextToSpeechInput = { + prompt: string; + lang?: string; +}; +type AiTextToSpeechOutput = Uint8Array | { + audio: string; +}; +declare abstract class BaseAiTextToSpeech { + inputs: AiTextToSpeechInput; + postProcessedOutputs: AiTextToSpeechOutput; +} +type AiTextToImageInput = { + prompt: string; + negative_prompt?: string; + height?: number; + width?: number; + image?: number[]; + image_b64?: string; + mask?: number[]; + num_steps?: number; + strength?: number; + guidance?: number; + seed?: number; +}; +type AiTextToImageOutput = ReadableStream; +declare abstract class BaseAiTextToImage { + inputs: AiTextToImageInput; + postProcessedOutputs: AiTextToImageOutput; +} +type AiTranslationInput = { + text: string; + target_lang: string; + source_lang?: string; +}; +type AiTranslationOutput = { + translated_text?: string; +}; +declare abstract class BaseAiTranslation { + inputs: AiTranslationInput; + postProcessedOutputs: AiTranslationOutput; +} +/** + * Workers AI support for OpenAI's Chat Completions API + */ +type ChatCompletionContentPartText = { + type: "text"; + text: string; +}; +type ChatCompletionContentPartImage = { + type: "image_url"; + image_url: { + url: string; + detail?: "auto" | "low" | "high"; + }; +}; +type ChatCompletionContentPartInputAudio = { + type: "input_audio"; + input_audio: { + /** Base64 encoded audio data. */ + data: string; + format: "wav" | "mp3"; + }; +}; +type ChatCompletionContentPartFile = { + type: "file"; + file: { + /** Base64 encoded file data. */ + file_data?: string; + /** The ID of an uploaded file. */ + file_id?: string; + filename?: string; + }; +}; +type ChatCompletionContentPartRefusal = { + type: "refusal"; + refusal: string; +}; +type ChatCompletionContentPart = ChatCompletionContentPartText | ChatCompletionContentPartImage | ChatCompletionContentPartInputAudio | ChatCompletionContentPartFile; +type FunctionDefinition = { + name: string; + description?: string; + parameters?: Record; + strict?: boolean | null; +}; +type ChatCompletionFunctionTool = { + type: "function"; + function: FunctionDefinition; +}; +type ChatCompletionCustomToolGrammarFormat = { + type: "grammar"; + grammar: { + definition: string; + syntax: "lark" | "regex"; + }; +}; +type ChatCompletionCustomToolTextFormat = { + type: "text"; +}; +type ChatCompletionCustomToolFormat = ChatCompletionCustomToolTextFormat | ChatCompletionCustomToolGrammarFormat; +type ChatCompletionCustomTool = { + type: "custom"; + custom: { + name: string; + description?: string; + format?: ChatCompletionCustomToolFormat; + }; +}; +type ChatCompletionTool = ChatCompletionFunctionTool | ChatCompletionCustomTool; +type ChatCompletionMessageFunctionToolCall = { + id: string; + type: "function"; + function: { + name: string; + /** JSON-encoded arguments string. */ + arguments: string; + }; +}; +type ChatCompletionMessageCustomToolCall = { + id: string; + type: "custom"; + custom: { + name: string; + input: string; + }; +}; +type ChatCompletionMessageToolCall = ChatCompletionMessageFunctionToolCall | ChatCompletionMessageCustomToolCall; +type ChatCompletionToolChoiceFunction = { + type: "function"; + function: { + name: string; + }; +}; +type ChatCompletionToolChoiceCustom = { + type: "custom"; + custom: { + name: string; + }; +}; +type ChatCompletionToolChoiceAllowedTools = { + type: "allowed_tools"; + allowed_tools: { + mode: "auto" | "required"; + tools: Array>; + }; +}; +type ChatCompletionToolChoiceOption = "none" | "auto" | "required" | ChatCompletionToolChoiceFunction | ChatCompletionToolChoiceCustom | ChatCompletionToolChoiceAllowedTools; +type DeveloperMessage = { + role: "developer"; + content: string | Array<{ + type: "text"; + text: string; + }>; + name?: string; +}; +type SystemMessage = { + role: "system"; + content: string | Array<{ + type: "text"; + text: string; + }>; + name?: string; +}; +/** + * Permissive merged content part used inside UserMessage arrays. + * + * Cabidela has a limitation where anyOf/oneOf with enum-based discrimination + * inside nested array items does not correctly match different branches for + * different array elements, so the schema uses a single merged object. + */ +type UserMessageContentPart = { + type: "text" | "image_url" | "input_audio" | "file"; + text?: string; + image_url?: { + url?: string; + detail?: "auto" | "low" | "high"; + }; + input_audio?: { + data?: string; + format?: "wav" | "mp3"; + }; + file?: { + file_data?: string; + file_id?: string; + filename?: string; + }; +}; +type UserMessage = { + role: "user"; + content: string | Array; + name?: string; +}; +type AssistantMessageContentPart = { + type: "text" | "refusal"; + text?: string; + refusal?: string; +}; +type AssistantMessage = { + role: "assistant"; + content?: string | null | Array; + refusal?: string | null; + name?: string; + audio?: { + id: string; + }; + tool_calls?: Array; + function_call?: { + name: string; + arguments: string; + }; +}; +type ToolMessage = { + role: "tool"; + content: string | Array<{ + type: "text"; + text: string; + }>; + tool_call_id: string; +}; +type FunctionMessage = { + role: "function"; + content: string; + name: string; +}; +type ChatCompletionMessageParam = DeveloperMessage | SystemMessage | UserMessage | AssistantMessage | ToolMessage | FunctionMessage; +type ChatCompletionsResponseFormatText = { + type: "text"; +}; +type ChatCompletionsResponseFormatJSONObject = { + type: "json_object"; +}; +type ResponseFormatJSONSchema = { + type: "json_schema"; + json_schema: { + name: string; + description?: string; + schema?: Record; + strict?: boolean | null; + }; +}; +type ResponseFormat = ChatCompletionsResponseFormatText | ChatCompletionsResponseFormatJSONObject | ResponseFormatJSONSchema; +type ChatCompletionsStreamOptions = { + include_usage?: boolean; + include_obfuscation?: boolean; +}; +type PredictionContent = { + type: "content"; + content: string | Array<{ + type: "text"; + text: string; + }>; +}; +type AudioParams = { + voice: string | { + id: string; + }; + format: "wav" | "aac" | "mp3" | "flac" | "opus" | "pcm16"; +}; +type WebSearchUserLocation = { + type: "approximate"; + approximate: { + city?: string; + country?: string; + region?: string; + timezone?: string; + }; +}; +type WebSearchOptions = { + search_context_size?: "low" | "medium" | "high"; + user_location?: WebSearchUserLocation; +}; +// Source of truth for per-model reasoning types: each model's `reasoning_effort` metadata +// in Workers AI ConfigAPI (supported efforts, aliases, defaults, and whether reasoning can +// be turned off). The Workers AI SDK type generator (cloudflare/ai/sdk, +// apps/worker-constellation-entry/scripts/build-types) turns it into the per-model +// `inputs` types below; the developer docs model schemas come from the same metadata. +type ChatTemplateKwargs = { + /** Whether to enable reasoning. Support and defaults depend on the model. */ + enable_thinking?: boolean; + /** If false, preserves reasoning context between turns. */ + clear_thinking?: boolean; +}; +/** Shared optional properties used by both Prompt and Messages input branches. */ +type ChatCompletionsCommonOptions = { + model?: string; + audio?: AudioParams; + frequency_penalty?: number | null; + logit_bias?: Record | null; + logprobs?: boolean | null; + top_logprobs?: number | null; + max_tokens?: number | null; + max_completion_tokens?: number | null; + metadata?: Record | null; + modalities?: Array<"text" | "audio"> | null; + n?: number | null; + parallel_tool_calls?: boolean; + prediction?: PredictionContent; + presence_penalty?: number | null; + /** Reasoning effort. Supported levels depend on the model. */ + reasoning_effort?: "low" | "medium" | "high" | null; + chat_template_kwargs?: ChatTemplateKwargs; + response_format?: ResponseFormat; + seed?: number | null; + service_tier?: "auto" | "default" | "flex" | "scale" | "priority" | null; + stop?: string | Array | null; + store?: boolean | null; + stream?: boolean | null; + stream_options?: ChatCompletionsStreamOptions; + temperature?: number | null; + tool_choice?: ChatCompletionToolChoiceOption; + tools?: Array; + top_p?: number | null; + user?: string; + web_search_options?: WebSearchOptions; + function_call?: "none" | "auto" | { + name: string; + }; + functions?: Array; +}; +type PromptTokensDetails = { + cached_tokens?: number; + audio_tokens?: number; +}; +type CompletionTokensDetails = { + reasoning_tokens?: number; + audio_tokens?: number; + accepted_prediction_tokens?: number; + rejected_prediction_tokens?: number; +}; +type CompletionUsage = { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + prompt_tokens_details?: PromptTokensDetails; + completion_tokens_details?: CompletionTokensDetails; +}; +type ChatCompletionTopLogprob = { + token: string; + logprob: number; + bytes: Array | null; +}; +type ChatCompletionTokenLogprob = { + token: string; + logprob: number; + bytes: Array | null; + top_logprobs: Array; +}; +type ChatCompletionAudio = { + id: string; + /** Base64 encoded audio bytes. */ + data: string; + expires_at: number; + transcript: string; +}; +type ChatCompletionUrlCitation = { + type: "url_citation"; + url_citation: { + url: string; + title: string; + start_index: number; + end_index: number; + }; +}; +type ChatCompletionResponseMessage = { + role: "assistant"; + content: string | null; + refusal: string | null; + annotations?: Array; + audio?: ChatCompletionAudio; + tool_calls?: Array; + function_call?: { + name: string; + arguments: string; + } | null; +}; +type ChatCompletionLogprobs = { + content: Array | null; + refusal?: Array | null; +}; +type ChatCompletionChoice = { + index: number; + message: ChatCompletionResponseMessage; + finish_reason: "stop" | "length" | "tool_calls" | "content_filter" | "function_call"; + logprobs: ChatCompletionLogprobs | null; +}; +type ChatCompletionsMessagesInput = { + messages: Array; +} & ChatCompletionsCommonOptions; +type ChatCompletionsOutput = { + id: string; + object: string; + created: number; + model: string; + choices: Array; + usage?: CompletionUsage; + system_fingerprint?: string | null; + service_tier?: "auto" | "default" | "flex" | "scale" | "priority" | null; +}; +/** + * Workers AI support for OpenAI's Responses API + * Reference: https://github.com/openai/openai-node/blob/master/src/resources/responses/responses.ts + * + * It's a stripped down version from its source. + * It currently supports basic function calling, json mode and accepts images as input. + * + * It does not include types for WebSearch, CodeInterpreter, FileInputs, MCP, CustomTools. + * We plan to add those incrementally as model + platform capabilities evolve. + */ +type ResponsesInput = { + background?: boolean | null; + conversation?: string | ResponseConversationParam | null; + include?: Array | null; + input?: string | ResponseInput; + instructions?: string | null; + max_output_tokens?: number | null; + parallel_tool_calls?: boolean | null; + previous_response_id?: string | null; + prompt_cache_key?: string; + reasoning?: Reasoning | null; + safety_identifier?: string; + service_tier?: "auto" | "default" | "flex" | "scale" | "priority" | null; + stream?: boolean | null; + stream_options?: StreamOptions | null; + temperature?: number | null; + text?: ResponseTextConfig; + tool_choice?: ToolChoiceOptions | ToolChoiceFunction; + tools?: Array; + top_p?: number | null; + truncation?: "auto" | "disabled" | null; +}; +type ResponsesOutput = { + id?: string; + created_at?: number; + output_text?: string; + error?: ResponseError | null; + incomplete_details?: ResponseIncompleteDetails | null; + instructions?: string | Array | null; + object?: "response"; + output?: Array; + parallel_tool_calls?: boolean; + temperature?: number | null; + tool_choice?: ToolChoiceOptions | ToolChoiceFunction; + tools?: Array; + top_p?: number | null; + max_output_tokens?: number | null; + previous_response_id?: string | null; + prompt?: ResponsePrompt | null; + reasoning?: Reasoning | null; + safety_identifier?: string; + service_tier?: "auto" | "default" | "flex" | "scale" | "priority" | null; + status?: ResponseStatus; + text?: ResponseTextConfig; + truncation?: "auto" | "disabled" | null; + usage?: ResponseUsage; +}; +type EasyInputMessage = { + content: string | ResponseInputMessageContentList; + role: "user" | "assistant" | "system" | "developer"; + type?: "message"; +}; +type ResponsesFunctionTool = { + name: string; + parameters: { + [key: string]: unknown; + } | null; + strict: boolean | null; + type: "function"; + description?: string | null; +}; +type ResponseIncompleteDetails = { + reason?: "max_output_tokens" | "content_filter"; +}; +type ResponsePrompt = { + id: string; + variables?: { + [key: string]: string | ResponseInputText | ResponseInputImage; + } | null; + version?: string | null; +}; +type Reasoning = { + effort?: ReasoningEffort | null; + generate_summary?: "auto" | "concise" | "detailed" | null; + summary?: "auto" | "concise" | "detailed" | null; +}; +type ResponseContent = ResponseInputText | ResponseInputImage | ResponseOutputText | ResponseOutputRefusal | ResponseContentReasoningText; +type ResponseContentReasoningText = { + text: string; + type: "reasoning_text"; +}; +type ResponseConversationParam = { + id: string; +}; +type ResponseCreatedEvent = { + response: Response; + sequence_number: number; + type: "response.created"; +}; +type ResponseCustomToolCallOutput = { + call_id: string; + output: string | Array; + type: "custom_tool_call_output"; + id?: string; +}; +type ResponseError = { + code: "server_error" | "rate_limit_exceeded" | "invalid_prompt" | "vector_store_timeout" | "invalid_image" | "invalid_image_format" | "invalid_base64_image" | "invalid_image_url" | "image_too_large" | "image_too_small" | "image_parse_error" | "image_content_policy_violation" | "invalid_image_mode" | "image_file_too_large" | "unsupported_image_media_type" | "empty_image_file" | "failed_to_download_image" | "image_file_not_found"; + message: string; +}; +type ResponseErrorEvent = { + code: string | null; + message: string; + param: string | null; + sequence_number: number; + type: "error"; +}; +type ResponseFailedEvent = { + response: Response; + sequence_number: number; + type: "response.failed"; +}; +type ResponseFormatText = { + type: "text"; +}; +type ResponseFormatJSONObject = { + type: "json_object"; +}; +type ResponseFormatTextConfig = ResponseFormatText | ResponseFormatTextJSONSchemaConfig | ResponseFormatJSONObject; +type ResponseFormatTextJSONSchemaConfig = { + name: string; + schema: { + [key: string]: unknown; + }; + type: "json_schema"; + description?: string; + strict?: boolean | null; +}; +type ResponseFunctionCallArgumentsDeltaEvent = { + delta: string; + item_id: string; + output_index: number; + sequence_number: number; + type: "response.function_call_arguments.delta"; +}; +type ResponseFunctionCallArgumentsDoneEvent = { + arguments: string; + item_id: string; + name: string; + output_index: number; + sequence_number: number; + type: "response.function_call_arguments.done"; +}; +type ResponseFunctionCallOutputItem = ResponseInputTextContent | ResponseInputImageContent; +type ResponseFunctionCallOutputItemList = Array; +type ResponseFunctionToolCall = { + arguments: string; + call_id: string; + name: string; + type: "function_call"; + id?: string; + status?: "in_progress" | "completed" | "incomplete"; +}; +interface ResponseFunctionToolCallItem extends ResponseFunctionToolCall { + id: string; +} +type ResponseFunctionToolCallOutputItem = { + id: string; + call_id: string; + output: string | Array; + type: "function_call_output"; + status?: "in_progress" | "completed" | "incomplete"; +}; +type ResponseIncludable = "message.input_image.image_url" | "message.output_text.logprobs"; +type ResponseIncompleteEvent = { + response: Response; + sequence_number: number; + type: "response.incomplete"; +}; +type ResponseInput = Array; +type ResponseInputContent = ResponseInputText | ResponseInputImage; +type ResponseInputImage = { + detail: "low" | "high" | "auto"; + type: "input_image"; + /** + * Base64 encoded image + */ + image_url?: string | null; +}; +type ResponseInputImageContent = { + type: "input_image"; + detail?: "low" | "high" | "auto" | null; + /** + * Base64 encoded image + */ + image_url?: string | null; +}; +type ResponseInputItem = EasyInputMessage | ResponseInputItemMessage | ResponseOutputMessage | ResponseFunctionToolCall | ResponseInputItemFunctionCallOutput | ResponseReasoningItem; +type ResponseInputItemFunctionCallOutput = { + call_id: string; + output: string | ResponseFunctionCallOutputItemList; + type: "function_call_output"; + id?: string | null; + status?: "in_progress" | "completed" | "incomplete" | null; +}; +type ResponseInputItemMessage = { + content: ResponseInputMessageContentList; + role: "user" | "system" | "developer"; + status?: "in_progress" | "completed" | "incomplete"; + type?: "message"; +}; +type ResponseInputMessageContentList = Array; +type ResponseInputMessageItem = { + id: string; + content: ResponseInputMessageContentList; + role: "user" | "system" | "developer"; + status?: "in_progress" | "completed" | "incomplete"; + type?: "message"; +}; +type ResponseInputText = { + text: string; + type: "input_text"; +}; +type ResponseInputTextContent = { + text: string; + type: "input_text"; +}; +type ResponseItem = ResponseInputMessageItem | ResponseOutputMessage | ResponseFunctionToolCallItem | ResponseFunctionToolCallOutputItem; +type ResponseOutputItem = ResponseOutputMessage | ResponseFunctionToolCall | ResponseReasoningItem; +type ResponseOutputItemAddedEvent = { + item: ResponseOutputItem; + output_index: number; + sequence_number: number; + type: "response.output_item.added"; +}; +type ResponseOutputItemDoneEvent = { + item: ResponseOutputItem; + output_index: number; + sequence_number: number; + type: "response.output_item.done"; +}; +type ResponseOutputMessage = { + id: string; + content: Array; + role: "assistant"; + status: "in_progress" | "completed" | "incomplete"; + type: "message"; +}; +type ResponseOutputRefusal = { + refusal: string; + type: "refusal"; +}; +type ResponseOutputText = { + text: string; + type: "output_text"; + logprobs?: Array; +}; +type ResponseReasoningItem = { + id: string; + summary: Array; + type: "reasoning"; + content?: Array; + encrypted_content?: string | null; + status?: "in_progress" | "completed" | "incomplete"; +}; +type ResponseReasoningSummaryItem = { + text: string; + type: "summary_text"; +}; +type ResponseReasoningContentItem = { + text: string; + type: "reasoning_text"; +}; +type ResponseReasoningTextDeltaEvent = { + content_index: number; + delta: string; + item_id: string; + output_index: number; + sequence_number: number; + type: "response.reasoning_text.delta"; +}; +type ResponseReasoningTextDoneEvent = { + content_index: number; + item_id: string; + output_index: number; + sequence_number: number; + text: string; + type: "response.reasoning_text.done"; +}; +type ResponseRefusalDeltaEvent = { + content_index: number; + delta: string; + item_id: string; + output_index: number; + sequence_number: number; + type: "response.refusal.delta"; +}; +type ResponseRefusalDoneEvent = { + content_index: number; + item_id: string; + output_index: number; + refusal: string; + sequence_number: number; + type: "response.refusal.done"; +}; +type ResponseStatus = "completed" | "failed" | "in_progress" | "cancelled" | "queued" | "incomplete"; +type ResponseStreamEvent = ResponseCompletedEvent | ResponseCreatedEvent | ResponseErrorEvent | ResponseFunctionCallArgumentsDeltaEvent | ResponseFunctionCallArgumentsDoneEvent | ResponseFailedEvent | ResponseIncompleteEvent | ResponseOutputItemAddedEvent | ResponseOutputItemDoneEvent | ResponseReasoningTextDeltaEvent | ResponseReasoningTextDoneEvent | ResponseRefusalDeltaEvent | ResponseRefusalDoneEvent | ResponseTextDeltaEvent | ResponseTextDoneEvent; +type ResponseCompletedEvent = { + response: Response; + sequence_number: number; + type: "response.completed"; +}; +type ResponseTextConfig = { + format?: ResponseFormatTextConfig; + verbosity?: "low" | "medium" | "high" | null; +}; +type ResponseTextDeltaEvent = { + content_index: number; + delta: string; + item_id: string; + logprobs: Array; + output_index: number; + sequence_number: number; + type: "response.output_text.delta"; +}; +type ResponseTextDoneEvent = { + content_index: number; + item_id: string; + logprobs: Array; + output_index: number; + sequence_number: number; + text: string; + type: "response.output_text.done"; +}; +type Logprob = { + token: string; + logprob: number; + top_logprobs?: Array; +}; +type TopLogprob = { + token?: string; + logprob?: number; +}; +type ResponseUsage = { + input_tokens: number; + output_tokens: number; + total_tokens: number; +}; +type Tool = ResponsesFunctionTool; +type ToolChoiceFunction = { + name: string; + type: "function"; +}; +type ToolChoiceOptions = "none"; +type ReasoningEffort = "minimal" | "low" | "medium" | "high" | null; +type StreamOptions = { + include_obfuscation?: boolean; +}; +/** Marks keys from T that aren't in U as optional never */ +type Without = { + [P in Exclude]?: never; +}; +/** Either T or U, but not both (mutually exclusive) */ +type XOR = (T & Without) | (U & Without); +type Ai_Cf_Baai_Bge_Base_En_V1_5_Input = { + text: string | string[]; + /** + * The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy. + */ + pooling?: "mean" | "cls"; +} | { + /** + * Batch of the embeddings requests to run using async-queue + */ + requests: { + text: string | string[]; + /** + * The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy. + */ + pooling?: "mean" | "cls"; + }[]; +}; +type Ai_Cf_Baai_Bge_Base_En_V1_5_Output = { + shape?: number[]; + /** + * Embeddings of the requested text values + */ + data?: number[][]; + /** + * The pooling method used in the embedding process. + */ + pooling?: "mean" | "cls"; +} | Ai_Cf_Baai_Bge_Base_En_V1_5_AsyncResponse; +interface Ai_Cf_Baai_Bge_Base_En_V1_5_AsyncResponse { + /** + * The async request id that can be used to obtain the results. + */ + request_id?: string; +} +declare abstract class Base_Ai_Cf_Baai_Bge_Base_En_V1_5 { + inputs: Ai_Cf_Baai_Bge_Base_En_V1_5_Input; + postProcessedOutputs: Ai_Cf_Baai_Bge_Base_En_V1_5_Output; +} +type Ai_Cf_Openai_Whisper_Input = string | { + /** + * An array of integers that represent the audio data constrained to 8-bit unsigned integer values + */ + audio: number[]; +}; +interface Ai_Cf_Openai_Whisper_Output { + /** + * The transcription + */ + text: string; + word_count?: number; + words?: { + word?: string; + /** + * The second this word begins in the recording + */ + start?: number; + /** + * The ending second when the word completes + */ + end?: number; + }[]; + vtt?: string; +} +declare abstract class Base_Ai_Cf_Openai_Whisper { + inputs: Ai_Cf_Openai_Whisper_Input; + postProcessedOutputs: Ai_Cf_Openai_Whisper_Output; +} +type Ai_Cf_Meta_M2M100_1_2B_Input = { + /** + * The text to be translated + */ + text: string; + /** + * The language code of the source text (e.g., 'en' for English). Defaults to 'en' if not specified + */ + source_lang?: string; + /** + * The language code to translate the text into (e.g., 'es' for Spanish) + */ + target_lang: string; +} | { + /** + * Batch of the embeddings requests to run using async-queue + */ + requests: { + /** + * The text to be translated + */ + text: string; + /** + * The language code of the source text (e.g., 'en' for English). Defaults to 'en' if not specified + */ + source_lang?: string; + /** + * The language code to translate the text into (e.g., 'es' for Spanish) + */ + target_lang: string; + }[]; +}; +type Ai_Cf_Meta_M2M100_1_2B_Output = { + /** + * The translated text in the target language + */ + translated_text?: string; +} | Ai_Cf_Meta_M2M100_1_2B_AsyncResponse; +interface Ai_Cf_Meta_M2M100_1_2B_AsyncResponse { + /** + * The async request id that can be used to obtain the results. + */ + request_id?: string; +} +declare abstract class Base_Ai_Cf_Meta_M2M100_1_2B { + inputs: Ai_Cf_Meta_M2M100_1_2B_Input; + postProcessedOutputs: Ai_Cf_Meta_M2M100_1_2B_Output; +} +type Ai_Cf_Baai_Bge_Small_En_V1_5_Input = { + text: string | string[]; + /** + * The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy. + */ + pooling?: "mean" | "cls"; +} | { + /** + * Batch of the embeddings requests to run using async-queue + */ + requests: { + text: string | string[]; + /** + * The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy. + */ + pooling?: "mean" | "cls"; + }[]; +}; +type Ai_Cf_Baai_Bge_Small_En_V1_5_Output = { + shape?: number[]; + /** + * Embeddings of the requested text values + */ + data?: number[][]; + /** + * The pooling method used in the embedding process. + */ + pooling?: "mean" | "cls"; +} | Ai_Cf_Baai_Bge_Small_En_V1_5_AsyncResponse; +interface Ai_Cf_Baai_Bge_Small_En_V1_5_AsyncResponse { + /** + * The async request id that can be used to obtain the results. + */ + request_id?: string; +} +declare abstract class Base_Ai_Cf_Baai_Bge_Small_En_V1_5 { + inputs: Ai_Cf_Baai_Bge_Small_En_V1_5_Input; + postProcessedOutputs: Ai_Cf_Baai_Bge_Small_En_V1_5_Output; +} +type Ai_Cf_Baai_Bge_Large_En_V1_5_Input = { + text: string | string[]; + /** + * The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy. + */ + pooling?: "mean" | "cls"; +} | { + /** + * Batch of the embeddings requests to run using async-queue + */ + requests: { + text: string | string[]; + /** + * The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy. + */ + pooling?: "mean" | "cls"; + }[]; +}; +type Ai_Cf_Baai_Bge_Large_En_V1_5_Output = { + shape?: number[]; + /** + * Embeddings of the requested text values + */ + data?: number[][]; + /** + * The pooling method used in the embedding process. + */ + pooling?: "mean" | "cls"; +} | Ai_Cf_Baai_Bge_Large_En_V1_5_AsyncResponse; +interface Ai_Cf_Baai_Bge_Large_En_V1_5_AsyncResponse { + /** + * The async request id that can be used to obtain the results. + */ + request_id?: string; +} +declare abstract class Base_Ai_Cf_Baai_Bge_Large_En_V1_5 { + inputs: Ai_Cf_Baai_Bge_Large_En_V1_5_Input; + postProcessedOutputs: Ai_Cf_Baai_Bge_Large_En_V1_5_Output; +} +type Ai_Cf_Unum_Uform_Gen2_Qwen_500M_Input = string | { + /** + * The input text prompt for the model to generate a response. + */ + prompt?: string; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * Controls the creativity of the AI's responses by adjusting how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; + image: number[] | (string & NonNullable); + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; +}; +interface Ai_Cf_Unum_Uform_Gen2_Qwen_500M_Output { + description?: string; +} +declare abstract class Base_Ai_Cf_Unum_Uform_Gen2_Qwen_500M { + inputs: Ai_Cf_Unum_Uform_Gen2_Qwen_500M_Input; + postProcessedOutputs: Ai_Cf_Unum_Uform_Gen2_Qwen_500M_Output; +} +type Ai_Cf_Openai_Whisper_Tiny_En_Input = string | { + /** + * An array of integers that represent the audio data constrained to 8-bit unsigned integer values + */ + audio: number[]; +}; +interface Ai_Cf_Openai_Whisper_Tiny_En_Output { + /** + * The transcription + */ + text: string; + word_count?: number; + words?: { + word?: string; + /** + * The second this word begins in the recording + */ + start?: number; + /** + * The ending second when the word completes + */ + end?: number; + }[]; + vtt?: string; +} +declare abstract class Base_Ai_Cf_Openai_Whisper_Tiny_En { + inputs: Ai_Cf_Openai_Whisper_Tiny_En_Input; + postProcessedOutputs: Ai_Cf_Openai_Whisper_Tiny_En_Output; +} +interface Ai_Cf_Openai_Whisper_Large_V3_Turbo_Input { + audio: string | { + body?: object; + contentType?: string; + }; + /** + * Supported tasks are 'translate' or 'transcribe'. + */ + task?: string; + /** + * The language of the audio being transcribed or translated. + */ + language?: string; + /** + * Preprocess the audio with a voice activity detection model. + */ + vad_filter?: boolean; + /** + * A text prompt to help provide context to the model on the contents of the audio. + */ + initial_prompt?: string; + /** + * The prefix appended to the beginning of the output of the transcription and can guide the transcription result. + */ + prefix?: string; + /** + * The number of beams to use in beam search decoding. Higher values may improve accuracy at the cost of speed. + */ + beam_size?: number; + /** + * Whether to condition on previous text during transcription. Setting to false may help prevent hallucination loops. + */ + condition_on_previous_text?: boolean; + /** + * Threshold for detecting no-speech segments. Segments with no-speech probability above this value are skipped. + */ + no_speech_threshold?: number; + /** + * Threshold for filtering out segments with high compression ratio, which often indicate repetitive or hallucinated text. + */ + compression_ratio_threshold?: number; + /** + * Threshold for filtering out segments with low average log probability, indicating low confidence. + */ + log_prob_threshold?: number; + /** + * Optional threshold (in seconds) to skip silent periods that may cause hallucinations. + */ + hallucination_silence_threshold?: number; +} +interface Ai_Cf_Openai_Whisper_Large_V3_Turbo_Output { + transcription_info?: { + /** + * The language of the audio being transcribed or translated. + */ + language?: string; + /** + * The confidence level or probability of the detected language being accurate, represented as a decimal between 0 and 1. + */ + language_probability?: number; + /** + * The total duration of the original audio file, in seconds. + */ + duration?: number; + /** + * The duration of the audio after applying Voice Activity Detection (VAD) to remove silent or irrelevant sections, in seconds. + */ + duration_after_vad?: number; + }; + /** + * The complete transcription of the audio. + */ + text: string; + /** + * The total number of words in the transcription. + */ + word_count?: number; + segments?: { + /** + * The starting time of the segment within the audio, in seconds. + */ + start?: number; + /** + * The ending time of the segment within the audio, in seconds. + */ + end?: number; + /** + * The transcription of the segment. + */ + text?: string; + /** + * The temperature used in the decoding process, controlling randomness in predictions. Lower values result in more deterministic outputs. + */ + temperature?: number; + /** + * The average log probability of the predictions for the words in this segment, indicating overall confidence. + */ + avg_logprob?: number; + /** + * The compression ratio of the input to the output, measuring how much the text was compressed during the transcription process. + */ + compression_ratio?: number; + /** + * The probability that the segment contains no speech, represented as a decimal between 0 and 1. + */ + no_speech_prob?: number; + words?: { + /** + * The individual word transcribed from the audio. + */ + word?: string; + /** + * The starting time of the word within the audio, in seconds. + */ + start?: number; + /** + * The ending time of the word within the audio, in seconds. + */ + end?: number; + }[]; + }[]; + /** + * The transcription in WebVTT format, which includes timing and text information for use in subtitles. + */ + vtt?: string; +} +declare abstract class Base_Ai_Cf_Openai_Whisper_Large_V3_Turbo { + inputs: Ai_Cf_Openai_Whisper_Large_V3_Turbo_Input; + postProcessedOutputs: Ai_Cf_Openai_Whisper_Large_V3_Turbo_Output; +} +type Ai_Cf_Baai_Bge_M3_Input = Ai_Cf_Baai_Bge_M3_Input_QueryAnd_Contexts | Ai_Cf_Baai_Bge_M3_Input_Embedding | { + /** + * Batch of the embeddings requests to run using async-queue + */ + requests: (Ai_Cf_Baai_Bge_M3_Input_QueryAnd_Contexts_1 | Ai_Cf_Baai_Bge_M3_Input_Embedding_1)[]; +}; +interface Ai_Cf_Baai_Bge_M3_Input_QueryAnd_Contexts { + /** + * A query you wish to perform against the provided contexts. If no query is provided the model with respond with embeddings for contexts + */ + query?: string; + /** + * List of provided contexts. Note that the index in this array is important, as the response will refer to it. + */ + contexts: { + /** + * One of the provided context content + */ + text?: string; + }[]; + /** + * When provided with too long context should the model error out or truncate the context to fit? + */ + truncate_inputs?: boolean; +} +interface Ai_Cf_Baai_Bge_M3_Input_Embedding { + text: string | string[]; + /** + * When provided with too long context should the model error out or truncate the context to fit? + */ + truncate_inputs?: boolean; +} +interface Ai_Cf_Baai_Bge_M3_Input_QueryAnd_Contexts_1 { + /** + * A query you wish to perform against the provided contexts. If no query is provided the model with respond with embeddings for contexts + */ + query?: string; + /** + * List of provided contexts. Note that the index in this array is important, as the response will refer to it. + */ + contexts: { + /** + * One of the provided context content + */ + text?: string; + }[]; + /** + * When provided with too long context should the model error out or truncate the context to fit? + */ + truncate_inputs?: boolean; +} +interface Ai_Cf_Baai_Bge_M3_Input_Embedding_1 { + text: string | string[]; + /** + * When provided with too long context should the model error out or truncate the context to fit? + */ + truncate_inputs?: boolean; +} +type Ai_Cf_Baai_Bge_M3_Output = Ai_Cf_Baai_Bge_M3_Output_Query | Ai_Cf_Baai_Bge_M3_Output_EmbeddingFor_Contexts | Ai_Cf_Baai_Bge_M3_Output_Embedding | Ai_Cf_Baai_Bge_M3_AsyncResponse; +interface Ai_Cf_Baai_Bge_M3_Output_Query { + response?: { + /** + * Index of the context in the request + */ + id?: number; + /** + * Score of the context under the index. + */ + score?: number; + }[]; +} +interface Ai_Cf_Baai_Bge_M3_Output_EmbeddingFor_Contexts { + response?: number[][]; + shape?: number[]; + /** + * The pooling method used in the embedding process. + */ + pooling?: "mean" | "cls"; +} +interface Ai_Cf_Baai_Bge_M3_Output_Embedding { + shape?: number[]; + /** + * Embeddings of the requested text values + */ + data?: number[][]; + /** + * The pooling method used in the embedding process. + */ + pooling?: "mean" | "cls"; +} +interface Ai_Cf_Baai_Bge_M3_AsyncResponse { + /** + * The async request id that can be used to obtain the results. + */ + request_id?: string; +} +declare abstract class Base_Ai_Cf_Baai_Bge_M3 { + inputs: Ai_Cf_Baai_Bge_M3_Input; + postProcessedOutputs: Ai_Cf_Baai_Bge_M3_Output; +} +interface Ai_Cf_Black_Forest_Labs_Flux_1_Schnell_Input { + /** + * A text description of the image you want to generate. + */ + prompt: string; + /** + * The number of diffusion steps; higher values can improve quality but take longer. + */ + steps?: number; +} +interface Ai_Cf_Black_Forest_Labs_Flux_1_Schnell_Output { + /** + * The generated image in Base64 format. + */ + image?: string; +} +declare abstract class Base_Ai_Cf_Black_Forest_Labs_Flux_1_Schnell { + inputs: Ai_Cf_Black_Forest_Labs_Flux_1_Schnell_Input; + postProcessedOutputs: Ai_Cf_Black_Forest_Labs_Flux_1_Schnell_Output; +} +type Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Input = Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Prompt | Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Messages; +interface Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + image?: number[] | (string & NonNullable); + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; + /** + * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model. + */ + lora?: string; +} +interface Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role?: string; + /** + * The tool call id. If you don't know what to put here you can fall back to 000000001 + */ + tool_call_id?: string; + content?: string | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }[] | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }; + }[]; + image?: number[] | (string & NonNullable); + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + /** + * If true, the response will be streamed back incrementally. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Controls the creativity of the AI's responses by adjusting how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +type Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Output = { + /** + * The generated text response from the model + */ + response?: string; + /** + * An array of tool calls requests made during the response generation + */ + tool_calls?: { + /** + * The arguments passed to be passed to the tool call request + */ + arguments?: object; + /** + * The name of the tool to be called + */ + name?: string; + }[]; +}; +declare abstract class Base_Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct { + inputs: Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Input; + postProcessedOutputs: Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Output; +} +type Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Input = Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Prompt | Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Messages | Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Async_Batch; +interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model. + */ + lora?: string; + response_format?: Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role: string; + content: string | { + /** + * Type of the content (text) + */ + type?: string; + /** + * Text content + */ + text?: string; + }[]; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + response_format?: Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode_1; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode_1 { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Async_Batch { + requests?: { + /** + * User-supplied reference. This field will be present in the response as well it can be used to reference the request and response. It's NOT validated to be unique. + */ + external_reference?: string; + /** + * Prompt for the text generation model + */ + prompt?: string; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; + response_format?: Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode_2; + }[]; +} +interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode_2 { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +type Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Output = { + /** + * The generated text response from the model + */ + response: string; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; + /** + * An array of tool calls requests made during the response generation + */ + tool_calls?: { + /** + * The arguments passed to be passed to the tool call request + */ + arguments?: object; + /** + * The name of the tool to be called + */ + name?: string; + }[]; +} | string | Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_AsyncResponse; +interface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_AsyncResponse { + /** + * The async request id that can be used to obtain the results. + */ + request_id?: string; +} +declare abstract class Base_Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast { + inputs: Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Input; + postProcessedOutputs: Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Output; +} +interface Ai_Cf_Meta_Llama_Guard_3_8B_Input { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender must alternate between 'user' and 'assistant'. + */ + role: "user" | "assistant"; + /** + * The content of the message as a string. + */ + content: string; + }[]; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Dictate the output format of the generated response. + */ + response_format?: { + /** + * Set to json_object to process and output generated text as JSON. + */ + type?: string; + }; +} +interface Ai_Cf_Meta_Llama_Guard_3_8B_Output { + response?: string | { + /** + * Whether the conversation is safe or not. + */ + safe?: boolean; + /** + * A list of what hazard categories predicted for the conversation, if the conversation is deemed unsafe. + */ + categories?: string[]; + }; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; +} +declare abstract class Base_Ai_Cf_Meta_Llama_Guard_3_8B { + inputs: Ai_Cf_Meta_Llama_Guard_3_8B_Input; + postProcessedOutputs: Ai_Cf_Meta_Llama_Guard_3_8B_Output; +} +interface Ai_Cf_Baai_Bge_Reranker_Base_Input { + /** + * A query you wish to perform against the provided contexts. + */ + /** + * Number of returned results starting with the best score. + */ + top_k?: number; + /** + * List of provided contexts. Note that the index in this array is important, as the response will refer to it. + */ + contexts: { + /** + * One of the provided context content + */ + text?: string; + }[]; +} +interface Ai_Cf_Baai_Bge_Reranker_Base_Output { + response?: { + /** + * Index of the context in the request + */ + id?: number; + /** + * Score of the context under the index. + */ + score?: number; + }[]; +} +declare abstract class Base_Ai_Cf_Baai_Bge_Reranker_Base { + inputs: Ai_Cf_Baai_Bge_Reranker_Base_Input; + postProcessedOutputs: Ai_Cf_Baai_Bge_Reranker_Base_Output; +} +type Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Input = Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Prompt | Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Messages; +interface Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model. + */ + lora?: string; + response_format?: Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_JSON_Mode; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_JSON_Mode { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +interface Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role: string; + /** + * The content of the message as a string. + */ + content: string; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + response_format?: Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_JSON_Mode_1; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_JSON_Mode_1 { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +type Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Output = { + /** + * The generated text response from the model + */ + response: string; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; + /** + * An array of tool calls requests made during the response generation + */ + tool_calls?: { + /** + * The arguments passed to be passed to the tool call request + */ + arguments?: object; + /** + * The name of the tool to be called + */ + name?: string; + }[]; +}; +declare abstract class Base_Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct { + inputs: Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Input; + postProcessedOutputs: Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Output; +} +type Ai_Cf_Qwen_Qwq_32B_Input = Ai_Cf_Qwen_Qwq_32B_Prompt | Ai_Cf_Qwen_Qwq_32B_Messages; +interface Ai_Cf_Qwen_Qwq_32B_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * JSON schema that should be fulfilled for the response. + */ + guided_json?: object; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Qwen_Qwq_32B_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role?: string; + /** + * The tool call id. If you don't know what to put here you can fall back to 000000001 + */ + tool_call_id?: string; + content?: string | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }[] | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + /** + * JSON schema that should be fufilled for the response. + */ + guided_json?: object; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +type Ai_Cf_Qwen_Qwq_32B_Output = { + /** + * The generated text response from the model + */ + response: string; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; + /** + * An array of tool calls requests made during the response generation + */ + tool_calls?: { + /** + * The arguments passed to be passed to the tool call request + */ + arguments?: object; + /** + * The name of the tool to be called + */ + name?: string; + }[]; +}; +declare abstract class Base_Ai_Cf_Qwen_Qwq_32B { + inputs: Ai_Cf_Qwen_Qwq_32B_Input; + postProcessedOutputs: Ai_Cf_Qwen_Qwq_32B_Output; +} +type Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Input = Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Prompt | Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Messages; +interface Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * JSON schema that should be fulfilled for the response. + */ + guided_json?: object; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role?: string; + /** + * The tool call id. Must be supplied for tool calls for Mistral-3. If you don't know what to put here you can fall back to 000000001 + */ + tool_call_id?: string; + content?: string | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }[] | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + /** + * JSON schema that should be fufilled for the response. + */ + guided_json?: object; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +type Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Output = { + /** + * The generated text response from the model + */ + response: string; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; + /** + * An array of tool calls requests made during the response generation + */ + tool_calls?: { + /** + * The arguments passed to be passed to the tool call request + */ + arguments?: object; + /** + * The name of the tool to be called + */ + name?: string; + }[]; +}; +declare abstract class Base_Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct { + inputs: Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Input; + postProcessedOutputs: Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Output; +} +type Ai_Cf_Google_Gemma_3_12B_It_Input = Ai_Cf_Google_Gemma_3_12B_It_Prompt | Ai_Cf_Google_Gemma_3_12B_It_Messages; +interface Ai_Cf_Google_Gemma_3_12B_It_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * JSON schema that should be fufilled for the response. + */ + guided_json?: object; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Google_Gemma_3_12B_It_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role?: string; + content?: string | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }[]; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + /** + * JSON schema that should be fufilled for the response. + */ + guided_json?: object; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +type Ai_Cf_Google_Gemma_3_12B_It_Output = { + /** + * The generated text response from the model + */ + response: string; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; + /** + * An array of tool calls requests made during the response generation + */ + tool_calls?: { + /** + * The arguments passed to be passed to the tool call request + */ + arguments?: object; + /** + * The name of the tool to be called + */ + name?: string; + }[]; +}; +declare abstract class Base_Ai_Cf_Google_Gemma_3_12B_It { + inputs: Ai_Cf_Google_Gemma_3_12B_It_Input; + postProcessedOutputs: Ai_Cf_Google_Gemma_3_12B_It_Output; +} +type Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Input = Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Prompt | Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Messages | Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Async_Batch; +interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * JSON schema that should be fulfilled for the response. + */ + guided_json?: object; + response_format?: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role?: string; + /** + * The tool call id. If you don't know what to put here you can fall back to 000000001 + */ + tool_call_id?: string; + content?: string | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }[] | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + response_format?: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode; + /** + * JSON schema that should be fufilled for the response. + */ + guided_json?: object; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Async_Batch { + requests: (Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Prompt_Inner | Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Messages_Inner)[]; +} +interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Prompt_Inner { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * JSON schema that should be fulfilled for the response. + */ + guided_json?: object; + response_format?: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Messages_Inner { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role?: string; + /** + * The tool call id. If you don't know what to put here you can fall back to 000000001 + */ + tool_call_id?: string; + content?: string | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }[] | { + /** + * Type of the content provided + */ + type?: string; + text?: string; + image_url?: { + /** + * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted + */ + url?: string; + }; + }; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + response_format?: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode; + /** + * JSON schema that should be fufilled for the response. + */ + guided_json?: object; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +type Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Output = { + /** + * The generated text response from the model + */ + response: string; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; + /** + * An array of tool calls requests made during the response generation + */ + tool_calls?: { + /** + * The tool call id. + */ + id?: string; + /** + * Specifies the type of tool (e.g., 'function'). + */ + type?: string; + /** + * Details of the function tool. + */ + function?: { + /** + * The name of the tool to be called + */ + name?: string; + /** + * The arguments passed to be passed to the tool call request + */ + arguments?: object; + }; + }[]; +}; +declare abstract class Base_Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct { + inputs: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Input; + postProcessedOutputs: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Output; +} +type Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Input = Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Prompt | Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Messages | Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Async_Batch; +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model. + */ + lora?: string; + response_format?: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role: string; + content: string | { + /** + * Type of the content (text) + */ + type?: string; + /** + * Text content + */ + text?: string; + }[]; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + response_format?: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_1; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_1 { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Async_Batch { + requests: (Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Prompt_1 | Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Messages_1)[]; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Prompt_1 { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model. + */ + lora?: string; + response_format?: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_2; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_2 { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Messages_1 { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role: string; + content: string | { + /** + * Type of the content (text) + */ + type?: string; + /** + * Text content + */ + text?: string; + }[]; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + response_format?: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_3; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_3 { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +type Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Output = Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Chat_Completion_Response | Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Text_Completion_Response | string | Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_AsyncResponse; +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Chat_Completion_Response { + /** + * Unique identifier for the completion + */ + id?: string; + /** + * Object type identifier + */ + object?: "chat.completion"; + /** + * Unix timestamp of when the completion was created + */ + created?: number; + /** + * Model used for the completion + */ + model?: string; + /** + * List of completion choices + */ + choices?: { + /** + * Index of the choice in the list + */ + index?: number; + /** + * The message generated by the model + */ + message?: { + /** + * Role of the message author + */ + role: string; + /** + * The content of the message + */ + content: string; + /** + * Internal reasoning content (if available) + */ + reasoning_content?: string; + /** + * Tool calls made by the assistant + */ + tool_calls?: { + /** + * Unique identifier for the tool call + */ + id: string; + /** + * Type of tool call + */ + type: "function"; + function: { + /** + * Name of the function to call + */ + name: string; + /** + * JSON string of arguments for the function + */ + arguments: string; + }; + }[]; + }; + /** + * Reason why the model stopped generating + */ + finish_reason?: string; + /** + * Stop reason (may be null) + */ + stop_reason?: string | null; + /** + * Log probabilities (if requested) + */ + logprobs?: {} | null; + }[]; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; + /** + * Log probabilities for the prompt (if requested) + */ + prompt_logprobs?: {} | null; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Text_Completion_Response { + /** + * Unique identifier for the completion + */ + id?: string; + /** + * Object type identifier + */ + object?: "text_completion"; + /** + * Unix timestamp of when the completion was created + */ + created?: number; + /** + * Model used for the completion + */ + model?: string; + /** + * List of completion choices + */ + choices?: { + /** + * Index of the choice in the list + */ + index: number; + /** + * The generated text completion + */ + text: string; + /** + * Reason why the model stopped generating + */ + finish_reason: string; + /** + * Stop reason (may be null) + */ + stop_reason?: string | null; + /** + * Log probabilities (if requested) + */ + logprobs?: {} | null; + /** + * Log probabilities for the prompt (if requested) + */ + prompt_logprobs?: {} | null; + }[]; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; +} +interface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_AsyncResponse { + /** + * The async request id that can be used to obtain the results. + */ + request_id?: string; +} +declare abstract class Base_Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8 { + inputs: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Input; + postProcessedOutputs: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Output; +} +interface Ai_Cf_Deepgram_Nova_3_Input { + audio: { + body: object; + contentType: string; + }; + /** + * Sets how the model will interpret strings submitted to the custom_topic param. When strict, the model will only return topics submitted using the custom_topic param. When extended, the model will return its own detected topics in addition to those submitted using the custom_topic param. + */ + custom_topic_mode?: "extended" | "strict"; + /** + * Custom topics you want the model to detect within your input audio or text if present Submit up to 100 + */ + custom_topic?: string; + /** + * Sets how the model will interpret intents submitted to the custom_intent param. When strict, the model will only return intents submitted using the custom_intent param. When extended, the model will return its own detected intents in addition those submitted using the custom_intents param + */ + custom_intent_mode?: "extended" | "strict"; + /** + * Custom intents you want the model to detect within your input audio if present + */ + custom_intent?: string; + /** + * Identifies and extracts key entities from content in submitted audio + */ + detect_entities?: boolean; + /** + * Identifies the dominant language spoken in submitted audio + */ + detect_language?: boolean; + /** + * Recognize speaker changes. Each word in the transcript will be assigned a speaker number starting at 0 + */ + diarize?: boolean; + /** + * Identify and extract key entities from content in submitted audio + */ + dictation?: boolean; + /** + * Specify the expected encoding of your submitted audio + */ + encoding?: "linear16" | "flac" | "mulaw" | "amr-nb" | "amr-wb" | "opus" | "speex" | "g729"; + /** + * Arbitrary key-value pairs that are attached to the API response for usage in downstream processing + */ + extra?: string; + /** + * Filler Words can help transcribe interruptions in your audio, like 'uh' and 'um' + */ + filler_words?: boolean; + /** + * Key term prompting can boost or suppress specialized terminology and brands. + */ + keyterm?: string; + /** + * Keywords can boost or suppress specialized terminology and brands. + */ + keywords?: string; + /** + * The BCP-47 language tag that hints at the primary spoken language. Depending on the Model and API endpoint you choose only certain languages are available. + */ + language?: string; + /** + * Spoken measurements will be converted to their corresponding abbreviations. + */ + measurements?: boolean; + /** + * Opts out requests from the Deepgram Model Improvement Program. Refer to our Docs for pricing impacts before setting this to true. https://dpgr.am/deepgram-mip. + */ + mip_opt_out?: boolean; + /** + * Mode of operation for the model representing broad area of topic that will be talked about in the supplied audio + */ + mode?: "general" | "medical" | "finance"; + /** + * Transcribe each audio channel independently. + */ + multichannel?: boolean; + /** + * Numerals converts numbers from written format to numerical format. + */ + numerals?: boolean; + /** + * Splits audio into paragraphs to improve transcript readability. + */ + paragraphs?: boolean; + /** + * Profanity Filter looks for recognized profanity and converts it to the nearest recognized non-profane word or removes it from the transcript completely. + */ + profanity_filter?: boolean; + /** + * Add punctuation and capitalization to the transcript. + */ + punctuate?: boolean; + /** + * Redaction removes sensitive information from your transcripts. + */ + redact?: string; + /** + * Search for terms or phrases in submitted audio and replaces them. + */ + replace?: string; + /** + * Search for terms or phrases in submitted audio. + */ + search?: string; + /** + * Recognizes the sentiment throughout a transcript or text. + */ + sentiment?: boolean; + /** + * Apply formatting to transcript output. When set to true, additional formatting will be applied to transcripts to improve readability. + */ + smart_format?: boolean; + /** + * Detect topics throughout a transcript or text. + */ + topics?: boolean; + /** + * Segments speech into meaningful semantic units. + */ + utterances?: boolean; + /** + * Seconds to wait before detecting a pause between words in submitted audio. + */ + utt_split?: number; + /** + * The number of channels in the submitted audio + */ + channels?: number; + /** + * Specifies whether the streaming endpoint should provide ongoing transcription updates as more audio is received. When set to true, the endpoint sends continuous updates, meaning transcription results may evolve over time. Note: Supported only for webosockets. + */ + interim_results?: boolean; + /** + * Indicates how long model will wait to detect whether a speaker has finished speaking or pauses for a significant period of time. When set to a value, the streaming endpoint immediately finalizes the transcription for the processed time range and returns the transcript with a speech_final parameter set to true. Can also be set to false to disable endpointing + */ + endpointing?: string; + /** + * Indicates that speech has started. You'll begin receiving Speech Started messages upon speech starting. Note: Supported only for webosockets. + */ + vad_events?: boolean; + /** + * Indicates how long model will wait to send an UtteranceEnd message after a word has been transcribed. Use with interim_results. Note: Supported only for webosockets. + */ + utterance_end_ms?: boolean; +} +interface Ai_Cf_Deepgram_Nova_3_Output { + results?: { + channels?: { + alternatives?: { + confidence?: number; + transcript?: string; + words?: { + confidence?: number; + end?: number; + start?: number; + word?: string; + }[]; + }[]; + }[]; + summary?: { + result?: string; + short?: string; + }; + sentiments?: { + segments?: { + text?: string; + start_word?: number; + end_word?: number; + sentiment?: string; + sentiment_score?: number; + }[]; + average?: { + sentiment?: string; + sentiment_score?: number; + }; + }; + }; +} +declare abstract class Base_Ai_Cf_Deepgram_Nova_3 { + inputs: Ai_Cf_Deepgram_Nova_3_Input; + postProcessedOutputs: Ai_Cf_Deepgram_Nova_3_Output; +} +interface Ai_Cf_Qwen_Qwen3_Embedding_0_6B_Input { + queries?: string | string[]; + /** + * Optional instruction for the task + */ + instruction?: string; + documents?: string | string[]; + text?: string | string[]; +} +interface Ai_Cf_Qwen_Qwen3_Embedding_0_6B_Output { + data?: number[][]; + shape?: number[]; +} +declare abstract class Base_Ai_Cf_Qwen_Qwen3_Embedding_0_6B { + inputs: Ai_Cf_Qwen_Qwen3_Embedding_0_6B_Input; + postProcessedOutputs: Ai_Cf_Qwen_Qwen3_Embedding_0_6B_Output; +} +type Ai_Cf_Pipecat_Ai_Smart_Turn_V2_Input = { + /** + * readable stream with audio data and content-type specified for that data + */ + audio: { + body: object; + contentType: string; + }; + /** + * type of data PCM data that's sent to the inference server as raw array + */ + dtype?: "uint8" | "float32" | "float64"; +} | { + /** + * base64 encoded audio data + */ + audio: string; + /** + * type of data PCM data that's sent to the inference server as raw array + */ + dtype?: "uint8" | "float32" | "float64"; +}; +interface Ai_Cf_Pipecat_Ai_Smart_Turn_V2_Output { + /** + * if true, end-of-turn was detected + */ + is_complete?: boolean; + /** + * probability of the end-of-turn detection + */ + probability?: number; +} +declare abstract class Base_Ai_Cf_Pipecat_Ai_Smart_Turn_V2 { + inputs: Ai_Cf_Pipecat_Ai_Smart_Turn_V2_Input; + postProcessedOutputs: Ai_Cf_Pipecat_Ai_Smart_Turn_V2_Output; +} +declare abstract class Base_Ai_Cf_Openai_Gpt_Oss_120B { + inputs: XOR & { + reasoning?: (Omit & { + /** + * Reasoning effort. Supported levels: low, medium, high. Reasoning cannot be disabled. + * + * @default "medium" + */ + effort?: "low" | "medium" | "high" | null; + }) | null; + }, Omit & { + /** + * Reasoning effort. Supported levels: low, medium, high. Reasoning cannot be disabled. + * + * @default "medium" + */ + reasoning_effort?: "low" | "medium" | "high" | null; + chat_template_kwargs?: Omit & { + /** + * Reasoning is always enabled for this model and cannot be disabled. + * + * @default true + */ + enable_thinking?: true; + }; + }>; + postProcessedOutputs: XOR; +} +declare abstract class Base_Ai_Cf_Openai_Gpt_Oss_20B { + inputs: XOR & { + reasoning?: (Omit & { + /** + * Reasoning effort. Supported levels: low, medium, high. Reasoning cannot be disabled. + * + * @default "medium" + */ + effort?: "low" | "medium" | "high" | null; + }) | null; + }, Omit & { + /** + * Reasoning effort. Supported levels: low, medium, high. Reasoning cannot be disabled. + * + * @default "medium" + */ + reasoning_effort?: "low" | "medium" | "high" | null; + chat_template_kwargs?: Omit & { + /** + * Reasoning is always enabled for this model and cannot be disabled. + * + * @default true + */ + enable_thinking?: true; + }; + }>; + postProcessedOutputs: XOR; +} +interface Ai_Cf_Leonardo_Phoenix_1_0_Input { + /** + * A text description of the image you want to generate. + */ + prompt: string; + /** + * Controls how closely the generated image should adhere to the prompt; higher values make the image more aligned with the prompt + */ + guidance?: number; + /** + * Random seed for reproducibility of the image generation + */ + seed?: number; + /** + * The height of the generated image in pixels + */ + height?: number; + /** + * The width of the generated image in pixels + */ + width?: number; + /** + * The number of diffusion steps; higher values can improve quality but take longer + */ + num_steps?: number; + /** + * Specify what to exclude from the generated images + */ + negative_prompt?: string; +} +/** + * The generated image in JPEG format + */ +type Ai_Cf_Leonardo_Phoenix_1_0_Output = string; +declare abstract class Base_Ai_Cf_Leonardo_Phoenix_1_0 { + inputs: Ai_Cf_Leonardo_Phoenix_1_0_Input; + postProcessedOutputs: Ai_Cf_Leonardo_Phoenix_1_0_Output; +} +interface Ai_Cf_Leonardo_Lucid_Origin_Input { + /** + * A text description of the image you want to generate. + */ + prompt: string; + /** + * Controls how closely the generated image should adhere to the prompt; higher values make the image more aligned with the prompt + */ + guidance?: number; + /** + * Random seed for reproducibility of the image generation + */ + seed?: number; + /** + * The height of the generated image in pixels + */ + height?: number; + /** + * The width of the generated image in pixels + */ + width?: number; + /** + * The number of diffusion steps; higher values can improve quality but take longer + */ + num_steps?: number; + /** + * The number of diffusion steps; higher values can improve quality but take longer + */ + steps?: number; +} +interface Ai_Cf_Leonardo_Lucid_Origin_Output { + /** + * The generated image in Base64 format. + */ + image?: string; +} +declare abstract class Base_Ai_Cf_Leonardo_Lucid_Origin { + inputs: Ai_Cf_Leonardo_Lucid_Origin_Input; + postProcessedOutputs: Ai_Cf_Leonardo_Lucid_Origin_Output; +} +interface Ai_Cf_Deepgram_Aura_1_Input { + /** + * Speaker used to produce the audio. + */ + speaker?: "angus" | "asteria" | "arcas" | "orion" | "orpheus" | "athena" | "luna" | "zeus" | "perseus" | "helios" | "hera" | "stella"; + /** + * Encoding of the output audio. + */ + encoding?: "linear16" | "flac" | "mulaw" | "alaw" | "mp3" | "opus" | "aac"; + /** + * Container specifies the file format wrapper for the output audio. The available options depend on the encoding type.. + */ + container?: "none" | "wav" | "ogg"; + /** + * The text content to be converted to speech + */ + text: string; + /** + * Sample Rate specifies the sample rate for the output audio. Based on the encoding, different sample rates are supported. For some encodings, the sample rate is not configurable + */ + sample_rate?: number; + /** + * The bitrate of the audio in bits per second. Choose from predefined ranges or specific values based on the encoding type. + */ + bit_rate?: number; +} +/** + * The generated audio in MP3 format + */ +type Ai_Cf_Deepgram_Aura_1_Output = string; +declare abstract class Base_Ai_Cf_Deepgram_Aura_1 { + inputs: Ai_Cf_Deepgram_Aura_1_Input; + postProcessedOutputs: Ai_Cf_Deepgram_Aura_1_Output; +} +interface Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B_Input { + /** + * Input text to translate. Can be a single string or a list of strings. + */ + text: string | string[]; + /** + * Target langauge to translate to + */ + target_language: "asm_Beng" | "awa_Deva" | "ben_Beng" | "bho_Deva" | "brx_Deva" | "doi_Deva" | "eng_Latn" | "gom_Deva" | "gon_Deva" | "guj_Gujr" | "hin_Deva" | "hne_Deva" | "kan_Knda" | "kas_Arab" | "kas_Deva" | "kha_Latn" | "lus_Latn" | "mag_Deva" | "mai_Deva" | "mal_Mlym" | "mar_Deva" | "mni_Beng" | "mni_Mtei" | "npi_Deva" | "ory_Orya" | "pan_Guru" | "san_Deva" | "sat_Olck" | "snd_Arab" | "snd_Deva" | "tam_Taml" | "tel_Telu" | "urd_Arab" | "unr_Deva"; +} +interface Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B_Output { + /** + * Translated texts + */ + translations: string[]; +} +declare abstract class Base_Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B { + inputs: Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B_Input; + postProcessedOutputs: Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B_Output; +} +type Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Input = Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Prompt | Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Messages | Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Async_Batch; +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Prompt { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model. + */ + lora?: string; + response_format?: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Messages { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role: string; + content: string | { + /** + * Type of the content (text) + */ + type?: string; + /** + * Text content + */ + text?: string; + }[]; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + response_format?: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_1; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_1 { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Async_Batch { + requests: (Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Prompt_1 | Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Messages_1)[]; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Prompt_1 { + /** + * The input text prompt for the model to generate a response. + */ + prompt: string; + /** + * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model. + */ + lora?: string; + response_format?: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_2; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_2 { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Messages_1 { + /** + * An array of message objects representing the conversation history. + */ + messages: { + /** + * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool'). + */ + role: string; + content: string | { + /** + * Type of the content (text) + */ + type?: string; + /** + * Text content + */ + text?: string; + }[]; + }[]; + functions?: { + name: string; + code: string; + }[]; + /** + * A list of tools available for the assistant to use. + */ + tools?: ({ + /** + * The name of the tool. More descriptive the better. + */ + name: string; + /** + * A brief description of what the tool does. + */ + description: string; + /** + * Schema defining the parameters accepted by the tool. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + } | { + /** + * Specifies the type of tool (e.g., 'function'). + */ + type: string; + /** + * Details of the function tool. + */ + function: { + /** + * The name of the function. + */ + name: string; + /** + * A brief description of what the function does. + */ + description: string; + /** + * Schema defining the parameters accepted by the function. + */ + parameters: { + /** + * The type of the parameters object (usually 'object'). + */ + type: string; + /** + * List of required parameter names. + */ + required?: string[]; + /** + * Definitions of each parameter. + */ + properties: { + [k: string]: { + /** + * The data type of the parameter. + */ + type: string; + /** + * A description of the expected parameter. + */ + description: string; + }; + }; + }; + }; + })[]; + response_format?: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_3; + /** + * If true, a chat template is not applied and you must adhere to the specific model's expected formatting. + */ + raw?: boolean; + /** + * If true, the response will be streamed back incrementally using SSE, Server Sent Events. + */ + stream?: boolean; + /** + * The maximum number of tokens to generate in the response. + */ + max_tokens?: number; + /** + * Controls the randomness of the output; higher values produce more random results. + */ + temperature?: number; + /** + * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses. + */ + top_p?: number; + /** + * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises. + */ + top_k?: number; + /** + * Random seed for reproducibility of the generation. + */ + seed?: number; + /** + * Penalty for repeated tokens; higher values discourage repetition. + */ + repetition_penalty?: number; + /** + * Decreases the likelihood of the model repeating the same lines verbatim. + */ + frequency_penalty?: number; + /** + * Increases the likelihood of the model introducing new topics. + */ + presence_penalty?: number; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_3 { + type?: "json_object" | "json_schema"; + json_schema?: unknown; +} +type Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Output = Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Chat_Completion_Response | Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Text_Completion_Response | string | Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_AsyncResponse; +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Chat_Completion_Response { + /** + * Unique identifier for the completion + */ + id?: string; + /** + * Object type identifier + */ + object?: "chat.completion"; + /** + * Unix timestamp of when the completion was created + */ + created?: number; + /** + * Model used for the completion + */ + model?: string; + /** + * List of completion choices + */ + choices?: { + /** + * Index of the choice in the list + */ + index?: number; + /** + * The message generated by the model + */ + message?: { + /** + * Role of the message author + */ + role: string; + /** + * The content of the message + */ + content: string; + /** + * Internal reasoning content (if available) + */ + reasoning_content?: string; + /** + * Tool calls made by the assistant + */ + tool_calls?: { + /** + * Unique identifier for the tool call + */ + id: string; + /** + * Type of tool call + */ + type: "function"; + function: { + /** + * Name of the function to call + */ + name: string; + /** + * JSON string of arguments for the function + */ + arguments: string; + }; + }[]; + }; + /** + * Reason why the model stopped generating + */ + finish_reason?: string; + /** + * Stop reason (may be null) + */ + stop_reason?: string | null; + /** + * Log probabilities (if requested) + */ + logprobs?: {} | null; + }[]; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; + /** + * Log probabilities for the prompt (if requested) + */ + prompt_logprobs?: {} | null; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Text_Completion_Response { + /** + * Unique identifier for the completion + */ + id?: string; + /** + * Object type identifier + */ + object?: "text_completion"; + /** + * Unix timestamp of when the completion was created + */ + created?: number; + /** + * Model used for the completion + */ + model?: string; + /** + * List of completion choices + */ + choices?: { + /** + * Index of the choice in the list + */ + index: number; + /** + * The generated text completion + */ + text: string; + /** + * Reason why the model stopped generating + */ + finish_reason: string; + /** + * Stop reason (may be null) + */ + stop_reason?: string | null; + /** + * Log probabilities (if requested) + */ + logprobs?: {} | null; + /** + * Log probabilities for the prompt (if requested) + */ + prompt_logprobs?: {} | null; + }[]; + /** + * Usage statistics for the inference request + */ + usage?: { + /** + * Total number of tokens in input + */ + prompt_tokens?: number; + /** + * Total number of tokens in output + */ + completion_tokens?: number; + /** + * Total number of input and output tokens + */ + total_tokens?: number; + }; +} +interface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_AsyncResponse { + /** + * The async request id that can be used to obtain the results. + */ + request_id?: string; +} +declare abstract class Base_Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It { + inputs: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Input; + postProcessedOutputs: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Output; +} +interface Ai_Cf_Pfnet_Plamo_Embedding_1B_Input { + /** + * Input text to embed. Can be a single string or a list of strings. + */ + text: string | string[]; +} +interface Ai_Cf_Pfnet_Plamo_Embedding_1B_Output { + /** + * Embedding vectors, where each vector is a list of floats. + */ + data: number[][]; + /** + * Shape of the embedding data as [number_of_embeddings, embedding_dimension]. + * + * @minItems 2 + * @maxItems 2 + */ + shape: [ + number, + number + ]; +} +declare abstract class Base_Ai_Cf_Pfnet_Plamo_Embedding_1B { + inputs: Ai_Cf_Pfnet_Plamo_Embedding_1B_Input; + postProcessedOutputs: Ai_Cf_Pfnet_Plamo_Embedding_1B_Output; +} +interface Ai_Cf_Deepgram_Flux_Input { + /** + * Encoding of the audio stream. Currently only supports raw signed little-endian 16-bit PCM. + */ + encoding: "linear16"; + /** + * Sample rate of the audio stream in Hz. + */ + sample_rate: string; + /** + * End-of-turn confidence required to fire an eager end-of-turn event. When set, enables EagerEndOfTurn and TurnResumed events. Valid Values 0.3 - 0.9. + */ + eager_eot_threshold?: string; + /** + * End-of-turn confidence required to finish a turn. Valid Values 0.5 - 0.9. + */ + eot_threshold?: string; + /** + * A turn will be finished when this much time has passed after speech, regardless of EOT confidence. + */ + eot_timeout_ms?: string; + /** + * Keyterm prompting can improve recognition of specialized terminology. Pass multiple keyterm query parameters to boost multiple keyterms. + */ + keyterm?: string; + /** + * Opts out requests from the Deepgram Model Improvement Program. Refer to Deepgram Docs for pricing impacts before setting this to true. https://dpgr.am/deepgram-mip + */ + mip_opt_out?: "true" | "false"; + /** + * Label your requests for the purpose of identification during usage reporting + */ + tag?: string; +} +/** + * Output will be returned as websocket messages. + */ +interface Ai_Cf_Deepgram_Flux_Output { + /** + * The unique identifier of the request (uuid) + */ + request_id?: string; + /** + * Starts at 0 and increments for each message the server sends to the client. + */ + sequence_id?: number; + /** + * The type of event being reported. + */ + event?: "Update" | "StartOfTurn" | "EagerEndOfTurn" | "TurnResumed" | "EndOfTurn"; + /** + * The index of the current turn + */ + turn_index?: number; + /** + * Start time in seconds of the audio range that was transcribed + */ + audio_window_start?: number; + /** + * End time in seconds of the audio range that was transcribed + */ + audio_window_end?: number; + /** + * Text that was said over the course of the current turn + */ + transcript?: string; + /** + * The words in the transcript + */ + words?: { + /** + * The individual punctuated, properly-cased word from the transcript + */ + word: string; + /** + * Confidence that this word was transcribed correctly + */ + confidence: number; + }[]; + /** + * Confidence that no more speech is coming in this turn + */ + end_of_turn_confidence?: number; +} +declare abstract class Base_Ai_Cf_Deepgram_Flux { + inputs: Ai_Cf_Deepgram_Flux_Input; + postProcessedOutputs: Ai_Cf_Deepgram_Flux_Output; +} +interface Ai_Cf_Deepgram_Aura_2_En_Input { + /** + * Speaker used to produce the audio. + */ + speaker?: "amalthea" | "andromeda" | "apollo" | "arcas" | "aries" | "asteria" | "athena" | "atlas" | "aurora" | "callista" | "cora" | "cordelia" | "delia" | "draco" | "electra" | "harmonia" | "helena" | "hera" | "hermes" | "hyperion" | "iris" | "janus" | "juno" | "jupiter" | "luna" | "mars" | "minerva" | "neptune" | "odysseus" | "ophelia" | "orion" | "orpheus" | "pandora" | "phoebe" | "pluto" | "saturn" | "thalia" | "theia" | "vesta" | "zeus"; + /** + * Encoding of the output audio. + */ + encoding?: "linear16" | "flac" | "mulaw" | "alaw" | "mp3" | "opus" | "aac"; + /** + * Container specifies the file format wrapper for the output audio. The available options depend on the encoding type.. + */ + container?: "none" | "wav" | "ogg"; + /** + * The text content to be converted to speech + */ + text: string; + /** + * Sample Rate specifies the sample rate for the output audio. Based on the encoding, different sample rates are supported. For some encodings, the sample rate is not configurable + */ + sample_rate?: number; + /** + * The bitrate of the audio in bits per second. Choose from predefined ranges or specific values based on the encoding type. + */ + bit_rate?: number; +} +/** + * The generated audio in MP3 format + */ +type Ai_Cf_Deepgram_Aura_2_En_Output = string; +declare abstract class Base_Ai_Cf_Deepgram_Aura_2_En { + inputs: Ai_Cf_Deepgram_Aura_2_En_Input; + postProcessedOutputs: Ai_Cf_Deepgram_Aura_2_En_Output; +} +interface Ai_Cf_Deepgram_Aura_2_Es_Input { + /** + * Speaker used to produce the audio. + */ + speaker?: "sirio" | "nestor" | "carina" | "celeste" | "alvaro" | "diana" | "aquila" | "selena" | "estrella" | "javier"; + /** + * Encoding of the output audio. + */ + encoding?: "linear16" | "flac" | "mulaw" | "alaw" | "mp3" | "opus" | "aac"; + /** + * Container specifies the file format wrapper for the output audio. The available options depend on the encoding type.. + */ + container?: "none" | "wav" | "ogg"; + /** + * The text content to be converted to speech + */ + text: string; + /** + * Sample Rate specifies the sample rate for the output audio. Based on the encoding, different sample rates are supported. For some encodings, the sample rate is not configurable + */ + sample_rate?: number; + /** + * The bitrate of the audio in bits per second. Choose from predefined ranges or specific values based on the encoding type. + */ + bit_rate?: number; +} +/** + * The generated audio in MP3 format + */ +type Ai_Cf_Deepgram_Aura_2_Es_Output = string; +declare abstract class Base_Ai_Cf_Deepgram_Aura_2_Es { + inputs: Ai_Cf_Deepgram_Aura_2_Es_Input; + postProcessedOutputs: Ai_Cf_Deepgram_Aura_2_Es_Output; +} +interface Ai_Cf_Black_Forest_Labs_Flux_2_Dev_Input { + multipart: { + body?: object; + contentType?: string; + }; +} +interface Ai_Cf_Black_Forest_Labs_Flux_2_Dev_Output { + /** + * Generated image as Base64 string. + */ + image?: string; +} +declare abstract class Base_Ai_Cf_Black_Forest_Labs_Flux_2_Dev { + inputs: Ai_Cf_Black_Forest_Labs_Flux_2_Dev_Input; + postProcessedOutputs: Ai_Cf_Black_Forest_Labs_Flux_2_Dev_Output; +} +interface Ai_Cf_Black_Forest_Labs_Flux_2_Klein_4B_Input { + multipart: { + body?: object; + contentType?: string; + }; +} +interface Ai_Cf_Black_Forest_Labs_Flux_2_Klein_4B_Output { + /** + * Generated image as Base64 string. + */ + image?: string; +} +declare abstract class Base_Ai_Cf_Black_Forest_Labs_Flux_2_Klein_4B { + inputs: Ai_Cf_Black_Forest_Labs_Flux_2_Klein_4B_Input; + postProcessedOutputs: Ai_Cf_Black_Forest_Labs_Flux_2_Klein_4B_Output; +} +interface Ai_Cf_Black_Forest_Labs_Flux_2_Klein_9B_Input { + multipart: { + body?: object; + contentType?: string; + }; +} +interface Ai_Cf_Black_Forest_Labs_Flux_2_Klein_9B_Output { + /** + * Generated image as Base64 string. + */ + image?: string; +} +declare abstract class Base_Ai_Cf_Black_Forest_Labs_Flux_2_Klein_9B { + inputs: Ai_Cf_Black_Forest_Labs_Flux_2_Klein_9B_Input; + postProcessedOutputs: Ai_Cf_Black_Forest_Labs_Flux_2_Klein_9B_Output; +} +declare abstract class Base_Ai_Cf_Zai_Org_Glm_4_7_Flash { + inputs: Omit & { + chat_template_kwargs?: Omit & { + /** + * Whether to enable reasoning for this model. This model has no reasoning effort levels. + * + * @default true + */ + enable_thinking?: boolean; + }; + }; + postProcessedOutputs: ChatCompletionsOutput; +} +declare abstract class Base_Ai_Cf_Moonshotai_Kimi_K2_5 { + inputs: ChatCompletionsInput; + postProcessedOutputs: ChatCompletionsOutput; +} +declare abstract class Base_Ai_Cf_Moonshotai_Kimi_K2_6 { + inputs: Omit & { + /** + * Reasoning effort. Supported levels: high, none. Compatibility aliases (accepted by the API, not by these types): low maps to high; medium maps to high; max maps to high. + * + * @default "high" + */ + reasoning_effort?: "high" | "none" | null; + chat_template_kwargs?: Omit & { + /** + * Whether to enable reasoning for this model. + * + * @default true + */ + enable_thinking?: boolean; + }; + }; + postProcessedOutputs: ChatCompletionsOutput; +} +type Ai_Cf_Nvidia_Nemotron_3_120B_A12B_Input = Omit & { + /** + * ID of the model to use (for example, '@cf/nvidia/nemotron-3-120b-a12b'). + */ + model?: ChatCompletionsInput["model"]; + /** + * The maximum number of tokens to generate. + */ + max_tokens?: ChatCompletionsInput["max_tokens"]; + /** + * Set of key-value pairs that can be attached to the object. + */ + metadata?: ChatCompletionsInput["metadata"]; + /** + * Output types requested from the model. + */ + modalities?: ChatCompletionsInput["modalities"]; + /** + * Nemotron chat-template controls for normal reasoning, low-effort reasoning, and non-reasoning responses. + */ + chat_template_kwargs?: { + /** + * Whether to enable reasoning. Reasoning is enabled by default. + */ + enable_thinking?: boolean; + /** + * When reasoning is enabled, use Nemotron's low-effort reasoning mode, which uses significantly fewer reasoning tokens. + */ + low_effort?: boolean; + /** + * For coding agent use, Nvidia suggests setting force_nonempty_content=true + */ + force_nonempty_content?: boolean; + }; + /** + * Whether to store the output for model distillation or evaluation. + * + * @default false + */ + store?: ChatCompletionsInput["store"]; +}; +declare abstract class Base_Ai_Cf_Nvidia_Nemotron_3_120B_A12B { + inputs: Ai_Cf_Nvidia_Nemotron_3_120B_A12B_Input; + postProcessedOutputs: ChatCompletionsOutput; +} +type Ai_Cf_Google_Gemma_4_26B_A4B_It_Input = Omit & { + chat_template_kwargs?: Omit & { + /** + * Whether to enable reasoning for this model. This model has no reasoning effort levels. + * + * @default true + */ + enable_thinking?: boolean; + }; +} & { + /** + * @default false + */ + skip_special_tokens?: boolean; +}; +declare abstract class Base_Ai_Cf_Google_Gemma_4_26B_A4B_It { + inputs: Ai_Cf_Google_Gemma_4_26B_A4B_It_Input; + postProcessedOutputs: ChatCompletionsOutput; +} +/** @deprecated Use Base_Ai_Cf_Google_Gemma_4_26B_A4B_It. */ +declare abstract class Base_Ai_Cf_Google_Gemma_4_26B_A4B_IT extends Base_Ai_Cf_Google_Gemma_4_26B_A4B_It { +} +type Ai_Cf_Nvidia_Nemotron_Speech_Streaming_En_0_6B_Input = { + /** + * readable stream with audio data and content-type specified for that data + */ + audio: { + body: object; + contentType: string; + }; +} | { + /** + * base64 encoded audio data + */ + audio: string; + encoding?: "wav" | "flac" | "ogg" | "linear16"; + sample_rate?: number; + channels?: number; +}; +interface Ai_Cf_Nvidia_Nemotron_Speech_Streaming_En_0_6B_Output { + text?: string; + duration?: number; +} +declare abstract class Base_Ai_Cf_Nvidia_Nemotron_Speech_Streaming_En_0_6B { + inputs: Ai_Cf_Nvidia_Nemotron_Speech_Streaming_En_0_6B_Input; + postProcessedOutputs: Ai_Cf_Nvidia_Nemotron_Speech_Streaming_En_0_6B_Output; +} +declare abstract class Base_Ai_Cf_Moonshotai_Kimi_K2_7_Code { + inputs: Omit & { + chat_template_kwargs?: Omit & { + /** + * Reasoning is always enabled for this model and cannot be disabled. This model has no reasoning effort levels. + * + * @default true + */ + enable_thinking?: true; + }; + }; + postProcessedOutputs: ChatCompletionsOutput; +} +declare abstract class Base_Ai_Cf_Zai_Org_Glm_5_2 { + inputs: Omit & { + /** + * Reasoning effort. Supported levels: max, high, none. Compatibility aliases (accepted by the API, not by these types): low maps to high; medium maps to high; xhigh maps to max; minimal maps to none. + * + * @default "max" + */ + reasoning_effort?: "max" | "high" | "none" | null; + chat_template_kwargs?: Omit & { + /** + * Whether to enable reasoning for this model. + * + * @default true + */ + enable_thinking?: boolean; + }; + }; + postProcessedOutputs: ChatCompletionsOutput; +} +interface Ai_Cf_Moondream_Moondream3_1_9B_A2B_Input { + /** + * Which Moondream skill to run. + */ + task?: "query" | "caption" | "point" | "detect"; + /** + * Input image as a public HTTPS URL or base64 data URI. Optional for `query`; required for `caption`, `point`, and `detect`. + */ + image?: string; + /** + * Question for the `query` task. + */ + question?: string; + /** + * Caption length for the `caption` task. + */ + caption_length?: "short" | "normal" | "long"; + /** + * Object phrase to locate for `point` and `detect` tasks (e.g. 'person wearing a red shirt'). + */ + target?: string; + /** + * Enable reasoning trace for the `query` task. + */ + reasoning?: boolean; + /** + * Sampling temperature. + */ + temperature?: number; + /** + * Top-p (nucleus) sampling. + */ + top_p?: number; + /** + * Max tokens to generate for `query` and `caption`. + */ + max_tokens?: number; + /** + * Max objects to return for `point` and `detect`. + */ + max_objects?: number; + /** + * Return incremental tokens for `query` and `caption`. `point` and `detect` do not support streaming. + */ + stream?: boolean; +} +interface Ai_Cf_Moondream_Moondream3_1_9B_A2B_Output { + /** + * Reason the generation finished. + */ + finish_reason: string; + metrics: { + /** + * Number of input tokens consumed. + */ + input_tokens: number; + /** + * Number of output tokens generated. + */ + output_tokens: number; + /** + * Prefill time in milliseconds. + */ + prefill_time_ms: number; + /** + * Decode time in milliseconds. + */ + decode_time_ms: number; + /** + * Time to first token in milliseconds. + */ + ttft_ms: number; + }; + /** + * Answer text for the `query` task. Null for other tasks. + */ + answer?: string; + /** + * Caption text for the `caption` task. Null for other tasks. + */ + caption?: string; + /** + * Located points for the `point` task. Null for other tasks. + */ + points?: { + /** + * X coordinate. + */ + x: number; + /** + * Y coordinate. + */ + y: number; + }[]; + /** + * Detected bounding boxes for the `detect` task. Null for other tasks. + */ + objects?: { + /** + * Minimum X coordinate. + */ + x_min: number; + /** + * Minimum Y coordinate. + */ + y_min: number; + /** + * Maximum X coordinate. + */ + x_max: number; + /** + * Maximum Y coordinate. + */ + y_max: number; + }[]; + /** + * Reasoning trace for the `query` task when reasoning=true. Null otherwise. + */ + reasoning?: { + /** + * Reasoning text. + */ + text: string; + /** + * Grounding information. + */ + grounding?: {}[]; + }; +} +declare abstract class Base_Ai_Cf_Moondream_Moondream3_1_9B_A2B { + inputs: Ai_Cf_Moondream_Moondream3_1_9B_A2B_Input; + postProcessedOutputs: Ai_Cf_Moondream_Moondream3_1_9B_A2B_Output; +} +declare abstract class Base_Ai_Cf_Deepseek_Ai_Deepseek_V4_Flash_0731 { + inputs: Omit & { + /** + * Reasoning effort. Supported levels: max, high, low, none. Compatibility aliases (accepted by the API, not by these types): minimal maps to low; medium maps to high; xhigh maps to high. + * + * @default "high" + */ + reasoning_effort?: "max" | "high" | "low" | "none" | null; + chat_template_kwargs?: Omit & { + /** + * Whether to enable reasoning for this model. + * + * @default true + */ + enable_thinking?: boolean; + }; + }; + postProcessedOutputs: ChatCompletionsOutput; +} +declare abstract class Base_Ai_Cf_Deepseek_Ai_Deepseek_V4_Pro_0813 { + inputs: Omit & { + /** + * Reasoning effort. Supported levels: max, high, low, none. Compatibility aliases (accepted by the API, not by these types): minimal maps to low; medium maps to high; xhigh maps to high. + * + * @default "high" + */ + reasoning_effort?: "max" | "high" | "low" | "none" | null; + chat_template_kwargs?: Omit & { + /** + * Whether to enable reasoning for this model. + * + * @default true + */ + enable_thinking?: boolean; + }; + }; + postProcessedOutputs: ChatCompletionsOutput; +} +declare abstract class Base_Ai_Cf_Qwen_Qwen3_8_27B { + inputs: Omit & { + /** + * Reasoning effort. Supported levels: low, medium, xhigh. + * + * @default "xhigh" + */ + reasoning_effort?: "low" | "medium" | "xhigh" | null; + chat_template_kwargs?: Omit & { + /** + * Whether to enable reasoning for this model. + * + * @default true + */ + enable_thinking?: boolean; + }; + }; + postProcessedOutputs: ChatCompletionsOutput; +} +declare abstract class Base_Ai_Cf_Zai_Org_Glm_5_3 { + inputs: Omit & { + /** + * Reasoning effort. Supported levels: max, high, low. Reasoning cannot be disabled. Compatibility aliases (accepted by the API, not by these types): none maps to max; minimal maps to max; medium maps to max; xhigh maps to max. + * + * @default "max" + */ + reasoning_effort?: "max" | "high" | "low" | null; + chat_template_kwargs?: Omit & { + /** + * Reasoning is always enabled for this model and cannot be disabled. + * + * @default true + */ + enable_thinking?: true; + }; + }; + postProcessedOutputs: ChatCompletionsOutput; +} +declare abstract class Base_Ai_Cf_Zai_Org_Glm_5_3_Flash { + inputs: Omit & { + /** + * Reasoning effort. Supported levels: max, high, low. Reasoning cannot be disabled. Compatibility aliases (accepted by the API, not by these types): none maps to max; minimal maps to max; medium maps to max; xhigh maps to max. + * + * @default "max" + */ + reasoning_effort?: "max" | "high" | "low" | null; + chat_template_kwargs?: Omit & { + /** + * Reasoning is always enabled for this model and cannot be disabled. + * + * @default true + */ + enable_thinking?: true; + }; + }; + postProcessedOutputs: ChatCompletionsOutput; +} +interface AiModels { + "@cf/huggingface/distilbert-sst-2-int8": BaseAiTextClassification; + "@cf/stabilityai/stable-diffusion-xl-base-1.0": BaseAiTextToImage; + "@cf/runwayml/stable-diffusion-v1-5-inpainting": BaseAiTextToImage; + "@cf/lykon/dreamshaper-8-lcm": BaseAiTextToImage; + "@cf/bytedance/stable-diffusion-xl-lightning": BaseAiTextToImage; + "@cf/myshell-ai/melotts": BaseAiTextToSpeech; + "@cf/google/embeddinggemma-300m": BaseAiTextEmbeddings; + "@cf/microsoft/resnet-50": BaseAiImageClassification; + "@cf/meta/llama-2-7b-chat-int8": BaseAiTextGeneration; + "@cf/mistral/mistral-7b-instruct-v0.1": BaseAiTextGeneration; + "@cf/meta/llama-2-7b-chat-fp16": BaseAiTextGeneration; + "@hf/thebloke/llama-2-13b-chat-awq": BaseAiTextGeneration; + "@hf/thebloke/mistral-7b-instruct-v0.1-awq": BaseAiTextGeneration; + "@hf/thebloke/zephyr-7b-beta-awq": BaseAiTextGeneration; + "@hf/thebloke/openhermes-2.5-mistral-7b-awq": BaseAiTextGeneration; + "@hf/thebloke/neural-chat-7b-v3-1-awq": BaseAiTextGeneration; + "@hf/thebloke/deepseek-coder-6.7b-base-awq": BaseAiTextGeneration; + "@hf/thebloke/deepseek-coder-6.7b-instruct-awq": BaseAiTextGeneration; + "@cf/deepseek-ai/deepseek-math-7b-instruct": BaseAiTextGeneration; + "@cf/defog/sqlcoder-7b-2": BaseAiTextGeneration; + "@cf/openchat/openchat-3.5-0106": BaseAiTextGeneration; + "@cf/tiiuae/falcon-7b-instruct": BaseAiTextGeneration; + "@cf/thebloke/discolm-german-7b-v1-awq": BaseAiTextGeneration; + "@cf/qwen/qwen1.5-0.5b-chat": BaseAiTextGeneration; + "@cf/qwen/qwen1.5-7b-chat-awq": BaseAiTextGeneration; + "@cf/qwen/qwen1.5-14b-chat-awq": BaseAiTextGeneration; + "@cf/tinyllama/tinyllama-1.1b-chat-v1.0": BaseAiTextGeneration; + "@cf/microsoft/phi-2": BaseAiTextGeneration; + "@cf/qwen/qwen1.5-1.8b-chat": BaseAiTextGeneration; + "@cf/mistral/mistral-7b-instruct-v0.2-lora": BaseAiTextGeneration; + "@hf/nousresearch/hermes-2-pro-mistral-7b": BaseAiTextGeneration; + "@hf/nexusflow/starling-lm-7b-beta": BaseAiTextGeneration; + "@hf/google/gemma-7b-it": BaseAiTextGeneration; + "@cf/meta-llama/llama-2-7b-chat-hf-lora": BaseAiTextGeneration; + "@cf/google/gemma-2b-it-lora": BaseAiTextGeneration; + "@cf/google/gemma-7b-it-lora": BaseAiTextGeneration; + "@hf/mistral/mistral-7b-instruct-v0.2": BaseAiTextGeneration; + "@cf/meta/llama-3-8b-instruct": BaseAiTextGeneration; + "@cf/fblgit/una-cybertron-7b-v2-bf16": BaseAiTextGeneration; + "@cf/meta/llama-3-8b-instruct-awq": BaseAiTextGeneration; + "@cf/meta/llama-3.1-8b-instruct-fp8": BaseAiTextGeneration; + "@cf/meta/llama-3.1-8b-instruct-awq": BaseAiTextGeneration; + "@cf/meta/llama-3.2-3b-instruct": BaseAiTextGeneration; + "@cf/meta/llama-3.2-1b-instruct": BaseAiTextGeneration; + "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b": BaseAiTextGeneration; + "@cf/ibm-granite/granite-4.0-h-micro": BaseAiTextGeneration; + "@cf/facebook/bart-large-cnn": BaseAiSummarization; + "@cf/llava-hf/llava-1.5-7b-hf": BaseAiImageToText; + "@cf/baai/bge-base-en-v1.5": Base_Ai_Cf_Baai_Bge_Base_En_V1_5; + "@cf/openai/whisper": Base_Ai_Cf_Openai_Whisper; + "@cf/meta/m2m100-1.2b": Base_Ai_Cf_Meta_M2M100_1_2B; + "@cf/baai/bge-small-en-v1.5": Base_Ai_Cf_Baai_Bge_Small_En_V1_5; + "@cf/baai/bge-large-en-v1.5": Base_Ai_Cf_Baai_Bge_Large_En_V1_5; + "@cf/unum/uform-gen2-qwen-500m": Base_Ai_Cf_Unum_Uform_Gen2_Qwen_500M; + "@cf/openai/whisper-tiny-en": Base_Ai_Cf_Openai_Whisper_Tiny_En; + "@cf/openai/whisper-large-v3-turbo": Base_Ai_Cf_Openai_Whisper_Large_V3_Turbo; + "@cf/baai/bge-m3": Base_Ai_Cf_Baai_Bge_M3; + "@cf/black-forest-labs/flux-1-schnell": Base_Ai_Cf_Black_Forest_Labs_Flux_1_Schnell; + "@cf/meta/llama-3.2-11b-vision-instruct": Base_Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct; + "@cf/meta/llama-3.3-70b-instruct-fp8-fast": Base_Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast; + "@cf/meta/llama-guard-3-8b": Base_Ai_Cf_Meta_Llama_Guard_3_8B; + "@cf/baai/bge-reranker-base": Base_Ai_Cf_Baai_Bge_Reranker_Base; + "@cf/qwen/qwen2.5-coder-32b-instruct": Base_Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct; + "@cf/qwen/qwq-32b": Base_Ai_Cf_Qwen_Qwq_32B; + "@cf/mistralai/mistral-small-3.1-24b-instruct": Base_Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct; + "@cf/google/gemma-3-12b-it": Base_Ai_Cf_Google_Gemma_3_12B_It; + "@cf/meta/llama-4-scout-17b-16e-instruct": Base_Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct; + "@cf/qwen/qwen3-30b-a3b-fp8": Base_Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8; + "@cf/deepgram/nova-3": Base_Ai_Cf_Deepgram_Nova_3; + "@cf/qwen/qwen3-embedding-0.6b": Base_Ai_Cf_Qwen_Qwen3_Embedding_0_6B; + "@cf/pipecat-ai/smart-turn-v2": Base_Ai_Cf_Pipecat_Ai_Smart_Turn_V2; + "@cf/openai/gpt-oss-120b": Base_Ai_Cf_Openai_Gpt_Oss_120B; + "@cf/openai/gpt-oss-20b": Base_Ai_Cf_Openai_Gpt_Oss_20B; + "@cf/leonardo/phoenix-1.0": Base_Ai_Cf_Leonardo_Phoenix_1_0; + "@cf/leonardo/lucid-origin": Base_Ai_Cf_Leonardo_Lucid_Origin; + "@cf/deepgram/aura-1": Base_Ai_Cf_Deepgram_Aura_1; + "@cf/ai4bharat/indictrans2-en-indic-1B": Base_Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B; + "@cf/aisingapore/gemma-sea-lion-v4-27b-it": Base_Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It; + "@cf/pfnet/plamo-embedding-1b": Base_Ai_Cf_Pfnet_Plamo_Embedding_1B; + "@cf/deepgram/flux": Base_Ai_Cf_Deepgram_Flux; + "@cf/deepgram/aura-2-en": Base_Ai_Cf_Deepgram_Aura_2_En; + "@cf/deepgram/aura-2-es": Base_Ai_Cf_Deepgram_Aura_2_Es; + "@cf/black-forest-labs/flux-2-dev": Base_Ai_Cf_Black_Forest_Labs_Flux_2_Dev; + "@cf/black-forest-labs/flux-2-klein-4b": Base_Ai_Cf_Black_Forest_Labs_Flux_2_Klein_4B; + "@cf/black-forest-labs/flux-2-klein-9b": Base_Ai_Cf_Black_Forest_Labs_Flux_2_Klein_9B; + "@cf/zai-org/glm-4.7-flash": Base_Ai_Cf_Zai_Org_Glm_4_7_Flash; + "@cf/moonshotai/kimi-k2.5": Base_Ai_Cf_Moonshotai_Kimi_K2_5; + "@cf/moonshotai/kimi-k2.6": Base_Ai_Cf_Moonshotai_Kimi_K2_6; + "@cf/nvidia/nemotron-3-120b-a12b": Base_Ai_Cf_Nvidia_Nemotron_3_120B_A12B; + "@cf/google/gemma-4-26b-a4b-it": Base_Ai_Cf_Google_Gemma_4_26B_A4B_It; + "@cf/nvidia/nemotron-speech-streaming-en-0.6b": Base_Ai_Cf_Nvidia_Nemotron_Speech_Streaming_En_0_6B; + "@cf/moonshotai/kimi-k2.7-code": Base_Ai_Cf_Moonshotai_Kimi_K2_7_Code; + "@cf/zai-org/glm-5.2": Base_Ai_Cf_Zai_Org_Glm_5_2; + "@cf/moondream/moondream3.1-9B-A2B": Base_Ai_Cf_Moondream_Moondream3_1_9B_A2B; + "@cf/deepseek-ai/deepseek-v4-flash-0731": Base_Ai_Cf_Deepseek_Ai_Deepseek_V4_Flash_0731; + "@cf/deepseek-ai/deepseek-v4-pro-0813": Base_Ai_Cf_Deepseek_Ai_Deepseek_V4_Pro_0813; + "@cf/qwen/qwen3.8-27b": Base_Ai_Cf_Qwen_Qwen3_8_27B; + "@cf/zai-org/glm-5.3": Base_Ai_Cf_Zai_Org_Glm_5_3; + "@cf/zai-org/glm-5.3-flash": Base_Ai_Cf_Zai_Org_Glm_5_3_Flash; +} +type AiOptions = { + /** + * Send requests as an asynchronous batch job, only works for supported models + * https://developers.cloudflare.com/workers-ai/features/batch-api + */ + queueRequest?: boolean; + /** + * Establish websocket connections, only works for supported models + */ + websocket?: boolean; + /** + * Tag your requests to group and view them in Cloudflare dashboard. + * + * Rules: + * Tags must only contain letters, numbers, and the symbols: : - . / @ + * Each tag can have maximum 50 characters. + * Maximum 5 tags are allowed each request. + * Duplicate tags will removed. + */ + tags?: string[]; + gateway?: GatewayOptions; + returnRawResponse?: boolean; + prefix?: string; + extraHeaders?: object; + signal?: AbortSignal; +}; +type AiModelsSearchParams = { + author?: string; + hide_experimental?: boolean; + page?: number; + per_page?: number; + search?: string; + source?: number; + task?: string; +}; +type AiModelsSearchObject = { + id: string; + source: number; + name: string; + description: string; + task: { + id: string; + name: string; + description: string; + }; + tags: string[]; + properties: { + property_id: string; + value: string; + }[]; +}; +type ChatCompletionsBase = ChatCompletionsMessagesInput; +type ChatCompletionsInput = ChatCompletionsMessagesInput; +interface InferenceUpstreamError extends Error { +} +interface AiInternalError extends Error { +} +type AiModelListType = Record; +type AiAsyncBatchResponse = { + request_id: string; +}; +type AiWebSearchRequest = { + /** AI Gateway configuration used for this request. */ + gatewayId: string; + /** Search query. */ + query: string; + /** Maximum number of results. Defaults to 10 and is capped at 20. */ + limit?: number; + /** Optional BYOK web-search provider configured on the gateway. */ + provider?: string; + /** Optional BYOK key alias. Defaults to `default`. */ + byokAlias?: string; +}; +declare abstract class Ai { + aiGatewayLogId: string | null; + gateway(gatewayId: string): AiGateway; + websearch(request: AiWebSearchRequest): Promise; + /** + * @deprecated Use the standalone `ai_search_namespaces` or `ai_search` Workers bindings instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ + aiSearch(): AiSearchNamespace; + /** + * @deprecated AutoRAG has been replaced by AI Search. + * Use the standalone `ai_search_namespaces` or `ai_search` Workers bindings instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + * + * @param autoragId Instance ID + */ + autorag(autoragId: string): AutoRAG; + // Batch request + run(model: Name, inputs: { + requests: AiModelList[Name]['inputs'][]; + }, options: AiOptions & { + queueRequest: true; + }): Promise; + // Raw response + run(model: Name, inputs: AiModelList[Name]['inputs'], options: AiOptions & { + returnRawResponse: true; + }): Promise; + // WebSocket + run(model: Name, inputs: AiModelList[Name]['inputs'], options: AiOptions & { + websocket: true; + }): Promise; + // Streaming + run(model: Name, inputs: AiModelList[Name]['inputs'] & { + stream: true; + }, options?: AiOptions): Promise; + // Normal (default) - known model + run(model: Name, inputs: AiModelList[Name]['inputs'], options?: AiOptions): Promise; + // Unknown model (fallback). + // + // The `Exclude<..., keyof AiModelList>` constraint forces TypeScript to + // route any model name that is a literal key of `AiModelList` to one of + // the known-model overloads above (so input/output mismatches surface as + // type errors rather than silently falling back to `Record`). + // Names that aren't in `AiModelList` — e.g. third-party gateway models + // like `"google/nano-banana"` — still hit this overload. + run(model: Model extends keyof AiModelList ? never : Model, inputs: Record, options?: AiOptions): Promise>; + models(params?: AiModelsSearchParams): Promise; + toMarkdown(): ToMarkdownService; + toMarkdown(files: MarkdownDocument[], options?: ConversionRequestOptions): Promise; + toMarkdown(files: MarkdownDocument, options?: ConversionRequestOptions): Promise; +} +type GatewayRetries = { + maxAttempts?: 1 | 2 | 3 | 4 | 5; + retryDelayMs?: number; + backoff?: 'constant' | 'linear' | 'exponential'; +}; +type GatewayOptions = { + id: string; + cacheKey?: string; + cacheTtl?: number; + skipCache?: boolean; + metadata?: Record; + collectLog?: boolean; + eventId?: string; + requestTimeoutMs?: number; + retries?: GatewayRetries; +}; +type UniversalGatewayOptions = Exclude & { + /** + ** @deprecated + */ + id?: string; +}; +type AiGatewayPatchLog = { + score?: number | null; + feedback?: -1 | 1 | null; + metadata?: Record | null; +}; +type AiGatewayLog = { + id: string; + provider: string; + model: string; + model_type?: string; + path: string; + duration: number; + request_type?: string; + request_content_type?: string; + status_code: number; + response_content_type?: string; + success: boolean; + cached: boolean; + tokens_in?: number; + tokens_out?: number; + metadata?: Record; + step?: number; + cost?: number; + custom_cost?: boolean; + request_size: number; + request_head?: string; + request_head_complete: boolean; + response_size: number; + response_head?: string; + response_head_complete: boolean; + created_at: Date; +}; +type AIGatewayProviders = 'workers-ai' | 'anthropic' | 'aws-bedrock' | 'azure-openai' | 'google-vertex-ai' | 'huggingface' | 'openai' | 'perplexity-ai' | 'replicate' | 'groq' | 'cohere' | 'google-ai-studio' | 'mistral' | 'grok' | 'openrouter' | 'deepseek' | 'cerebras' | 'cartesia' | 'elevenlabs' | 'adobe-firefly'; +type AIGatewayHeaders = { + 'cf-aig-metadata': Record | string; + 'cf-aig-custom-cost': { + per_token_in?: number; + per_token_out?: number; + } | { + total_cost?: number; + } | string; + 'cf-aig-cache-ttl': number | string; + 'cf-aig-skip-cache': boolean | string; + 'cf-aig-cache-key': string; + 'cf-aig-event-id': string; + 'cf-aig-request-timeout': number | string; + 'cf-aig-max-attempts': number | string; + 'cf-aig-retry-delay': number | string; + 'cf-aig-backoff': string; + 'cf-aig-collect-log': boolean | string; + Authorization: string; + 'Content-Type': string; + [key: string]: string | number | boolean | object; +}; +type AIGatewayUniversalRequest = { + provider: AIGatewayProviders | string; // eslint-disable-line + endpoint: string; + headers: Partial; + query: unknown; +}; +interface AiGatewayInternalError extends Error { +} +interface AiGatewayLogNotFound extends Error { +} +declare abstract class AiGateway { + patchLog(logId: string, data: AiGatewayPatchLog): Promise; + getLog(logId: string): Promise; + run(data: AIGatewayUniversalRequest | AIGatewayUniversalRequest[], options?: { + gateway?: UniversalGatewayOptions; + extraHeaders?: object; + signal?: AbortSignal; + }): Promise; + getUrl(provider?: AIGatewayProviders | string): Promise; // eslint-disable-line +} +/** A parameter accepted by an Analytics SQL query. */ +type AnalyticsSQLParameter = string | number | boolean | null; +/** An Analytics SQL query and its optional positional or named parameters. */ +interface AnalyticsSQLQuery { + query: string; + params?: readonly AnalyticsSQLParameter[] | Readonly>; +} +/** Execution statistics returned by Analytics SQL. */ +interface AnalyticsSQLStatistics { + elapsed_ms: number; + rows_read: number; + bytes_read: number; +} +/** The rows and execution statistics returned by an Analytics SQL query. */ +interface AnalyticsSQLResult = Record> { + data: T[]; + rows: number; + statistics: AnalyticsSQLStatistics; +} +/** An Analytics SQL binding. */ +interface AnalyticsSQLBinding { + query = Record>(request: AnalyticsSQLQuery): Promise>; +} +// Copyright (c) 2022-2025 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +/** + * Artifacts — Git-compatible file storage on Cloudflare Workers. + * + * Provides programmatic access to create, manage, and fork repositories, + * and to issue and revoke scoped access tokens. + */ +/** Information about a repository. */ +interface ArtifactsRepoInfo { + /** Unique repository ID. */ + id: string; + /** Repository name. */ + name: string; + /** Repository description, or null if not set. */ + description: string | null; + /** Default branch name (e.g. "main"). */ + defaultBranch: string; + /** ISO 8601 creation timestamp. */ + createdAt: string; + /** ISO 8601 last-updated timestamp. */ + updatedAt: string; + /** ISO 8601 timestamp of the last push, or null if never pushed. */ + lastPushAt: string | null; + /** Fork source (e.g. "github:owner/repo", "artifacts:namespace/repo"), or null if not a fork. */ + source: string | null; + /** Whether the repository is read-only. */ + readOnly: boolean; + /** HTTPS git remote URL. */ + remote: string; +} +/** Result of creating a repository — includes the initial access token. */ +interface ArtifactsCreateRepoResult { + /** Unique repository ID. */ + id: string; + /** Repository name. */ + name: string; + /** Repository description, or null if not set. */ + description: string | null; + /** Default branch name. */ + defaultBranch: string; + /** HTTPS git remote URL. */ + remote: string; + /** Plaintext access token (only returned at creation time). */ + token: string; +} +/** Paginated list of repositories. */ +interface ArtifactsRepoListResult { + /** Repositories in this page (without the `remote` field). */ + repos: Omit[]; + /** Total number of repositories in the namespace. */ + total: number; + /** Cursor for the next page, if there are more results. */ + cursor?: string; +} +/** Result of creating an access token. */ +interface ArtifactsCreateTokenResult { + /** Unique token ID. */ + id: string; + /** Plaintext token (only returned at creation time). */ + plaintext: string; + /** Token scope: "read" or "write". */ + scope: 'read' | 'write'; + /** ISO 8601 token expiry timestamp. */ + expiresAt: string; +} +/** Token metadata (no plaintext). */ +interface ArtifactsTokenInfo { + /** Unique token ID. */ + id: string; + /** Token scope: "read" or "write". */ + scope: 'read' | 'write'; + /** Token state: "active", "expired", or "revoked". */ + state: 'active' | 'expired' | 'revoked'; + /** ISO 8601 creation timestamp. */ + createdAt: string; + /** ISO 8601 expiry timestamp. */ + expiresAt: string; +} +/** Paginated list of tokens for a repository. */ +interface ArtifactsTokenListResult { + /** Tokens in this page. */ + tokens: ArtifactsTokenInfo[]; + /** Total number of tokens for the repository. */ + total: number; +} +/** + * Classification of a Git tree entry derived from its mode. + * + * `tree` is a directory, `blob` is a regular file, `symlink` is a symbolic link, + * `gitlink` is a submodule reference, and `exec` is an executable file. + */ +type ArtifactsTreeEntryType = 'tree' | 'blob' | 'symlink' | 'gitlink' | 'exec'; +/** An immediate child of a Git tree returned by {@link ArtifactsRepo.readTree}. */ +interface ArtifactsTreeEntry { + /** Name relative to the tree being read. */ + name: string; + /** Canonical Git mode, such as `100644` for a file or `40000` for a tree. */ + mode: string; + /** Lowercase, 40-character SHA-1 object ID. */ + hash: string; + /** Classification derived from `mode`. */ + type: ArtifactsTreeEntryType; +} +/** Decoded metadata returned by {@link ArtifactsRepo.readCommit} and {@link ArtifactsRepo.log}. */ +interface ArtifactsCommitMetadata { + /** Lowercase, 40-character SHA-1 commit ID. */ + hash: string; + /** Lowercase, 40-character SHA-1 ID of the commit's root tree. */ + treeHash: string; + /** Commit message with one trailing newline removed, if present. */ + message: string; + /** Author identity from the commit. */ + author: { + /** Author name. */ + name: string; + /** Author email address. */ + email: string; + }; + /** Committer identity from the commit. */ + committer: { + /** Committer name. */ + name: string; + /** Committer email address. */ + email: string; + }; + /** Parent commit IDs in Git order; empty for a root commit. */ + parents: string[]; + /** Author timestamp in Unix seconds. */ + authoredAt: number; + /** Committer timestamp in Unix seconds. */ + committedAt: number; +} +/** + * Repository capability returned by {@link Artifacts.get}. + * + * Metadata is available through {@link info}, not as properties on the capability. + * Methods may throw `ArtifactsError` with code `INTERNAL_ERROR` if an unexpected service error occurs. + * + * The capability is an RPC stub. Dispose of it when finished, for example with + * `using repo = await env.ARTIFACTS.get(name);`, to release it before the request ends. + */ +interface ArtifactsRepo extends Disposable { + /** + * Create an access token for this repo. + * @param scope Token scope: "write" (default) or "read". + * @param ttl Time-to-live in seconds (default 86400, min 60, max 31536000). + * @throws {ArtifactsError} with code `INVALID_TTL` if ttl is out of range. + */ + createToken(scope?: 'write' | 'read', ttl?: number): Promise; + /** List tokens for this repo (metadata only, no plaintext). */ + listTokens(): Promise; + /** + * Revoke a token by plaintext or ID. + * @param tokenOrId Plaintext token or token ID. + * @returns true if revoked, false if not found. + * @throws {ArtifactsError} with code `INVALID_INPUT` if tokenOrId is empty. + */ + revokeToken(tokenOrId: string): Promise; + /** + * Retrieve current repository metadata. Each call performs a fresh lookup. + * @returns Current public repository metadata. + * @throws {ArtifactsError} `NOT_FOUND` if the repository was deleted, or `INTERNAL_ERROR` on + * an unexpected lookup failure. + */ + info(): Promise; + /** + * Read the raw bytes of a Git blob by object ID. + * @param hash Lowercase, 40-character SHA-1 object ID. + * @returns An untyped {@link Blob}, or `null` if the object is missing or is not a Git blob. + * @throws {ArtifactsError} `INVALID_INPUT` for a malformed hash, `MEMORY_LIMIT` if the object + * cannot be buffered safely, or `INTERNAL_ERROR` on an unexpected read failure. + * @see {@link ArtifactsRepo.readFile} to resolve a file by ref and path. + */ + readBlob(hash: string): Promise; + /** + * Read the immediate children of a Git tree by object ID. + * @param hash Lowercase, 40-character SHA-1 tree ID. + * @returns Tree entries, or `null` if the object is missing. + * @throws {ArtifactsError} `INVALID_INPUT` for a malformed hash, or `INTERNAL_ERROR` if the + * object is not a valid tree or the read fails unexpectedly. + * @see {@link ArtifactsTreeEntry} + */ + readTree(hash: string): Promise; + /** + * Decode a Git commit by object ID. + * @param hash Lowercase, 40-character SHA-1 commit ID. + * @returns Commit metadata, or `null` if the object is missing. + * @throws {ArtifactsError} `INVALID_INPUT` for a malformed hash, or `INTERNAL_ERROR` if the + * object is not a valid commit or the read fails unexpectedly. + * @see {@link ArtifactsCommitMetadata} + */ + readCommit(hash: string): Promise; + /** + * Resolve a file from a branch, tag, or commit ID and return its bytes with a browser-safe + * content type in {@link Blob.type}. + * @param args File lookup options. + * @param args.ref Branch, tag, or commit ID to resolve. + * @param args.path Non-empty repository-relative path. + * @returns A MIME-typed {@link Blob}, or `null` if the ref or path cannot resolve to a file. + * @throws {ArtifactsError} `INVALID_INPUT` if either argument is empty, `MEMORY_LIMIT` if the + * file cannot be buffered safely, or `INTERNAL_ERROR` on an unexpected read failure. + */ + readFile(args: { + ref: string; + path: string; + }): Promise; + /** + * List commits along the first-parent chain, newest first. + * @param opts History options. All fields are optional. + * @param opts.ref Branch, tag, or commit ID; defaults to `HEAD`. + * @param opts.limit Maximum results; defaults to `50` and is capped at `1000`. + * @param opts.offset Number of matching commits to skip; defaults to `0`. + * @returns Commit metadata, or an empty array if the ref cannot be resolved. + * @throws {ArtifactsError} `INTERNAL_ERROR` on an unexpected traversal failure. + * @see {@link ArtifactsCommitMetadata} + */ + log(opts?: { + ref?: string; + limit?: number; + offset?: number; + }): Promise; + // ── Fork ── + /** + * Fork this repo to a new repo. + * @param name Target repository name. + * @param opts Optional: description, readOnly flag, defaultBranchOnly (default true). + * @throws {ArtifactsError} with code `INVALID_REPO_NAME` if name is invalid. + * @throws {ArtifactsError} with code `ALREADY_EXISTS` if the target repo already exists. + * @throws {ArtifactsError} with code `FORK_IN_PROGRESS` if a fork is already running. + */ + fork(name: string, opts?: { + description?: string; + readOnly?: boolean; + defaultBranchOnly?: boolean; + }): Promise; +} +// ── Error types ────────────────────────────────────────────────────────────── +/** + * Error codes returned by Artifacts binding operations. + * + * Each code maps to a numeric code available on `ArtifactsError.numericCode`. + */ +type ArtifactsErrorCode = 'ALREADY_EXISTS' | 'NOT_FOUND' | 'CREATE_IN_PROGRESS' | 'IMPORT_IN_PROGRESS' | 'FORK_IN_PROGRESS' | 'INVALID_INPUT' | 'INVALID_REPO_NAME' | 'INVALID_TTL' | 'INVALID_URL' | 'REMOTE_AUTH_REQUIRED' | 'UPSTREAM_UNAVAILABLE' | 'MEMORY_LIMIT' | 'INTERNAL_ERROR'; +/** + * Error thrown by Artifacts binding operations. + * + * Uses a string `.code` discriminator following the Cloudflare platform + * convention (StreamError, ImagesError, etc.). The `.numericCode` matches + * the REST API `errors[].code` values. + */ +interface ArtifactsError extends Error { + readonly name: 'ArtifactsError'; + /** String error code for programmatic matching. */ + readonly code: ArtifactsErrorCode; + /** Numeric error code matching the REST API. */ + readonly numericCode: number; +} +// ── Binding ────────────────────────────────────────────────────────────────── +/** + * Artifacts binding — namespace-level operations. + * + * Methods may throw `ArtifactsError` with code `INTERNAL_ERROR` if an unexpected service error occurs. + */ +interface Artifacts { + /** + * Create a new repository with an initial access token. + * @param name Repository name (alphanumeric, dots, hyphens, underscores). + * @param opts Optional: readOnly flag, description, default branch name. + * @returns Repo metadata with initial token. + * @throws {ArtifactsError} with code `INVALID_REPO_NAME` if name is invalid. + * @throws {ArtifactsError} with code `ALREADY_EXISTS` if the repo already exists. + */ + create(name: string, opts?: { + readOnly?: boolean; + description?: string; + setDefaultBranch?: string; + }): Promise; + /** + * Get a handle to an existing repository. + * @param name Repository name. + * @returns Repo handle. + * @throws {ArtifactsError} with code `NOT_FOUND` if the repo does not exist. + * @throws {ArtifactsError} with code `CREATE_IN_PROGRESS` if the repo is still being created. + * @throws {ArtifactsError} with code `IMPORT_IN_PROGRESS` if the repo is still importing. + * @throws {ArtifactsError} with code `FORK_IN_PROGRESS` if the repo is still forking. + */ + get(name: string): Promise; + /** + * Import a repository from an external git remote. + * @param params Source URL and optional branch/depth, plus target name and options. + * @returns Repo metadata with initial token. + * @throws {ArtifactsError} with code `INVALID_REPO_NAME` if the target name is invalid. + * @throws {ArtifactsError} with code `INVALID_INPUT` if the source URL is not valid HTTPS. + * @throws {ArtifactsError} with code `INVALID_URL` if the source URL does not point to a git repository. + * @throws {ArtifactsError} with code `REMOTE_AUTH_REQUIRED` if the remote requires authentication. + * @throws {ArtifactsError} with code `NOT_FOUND` if the remote repository does not exist. + * @throws {ArtifactsError} with code `UPSTREAM_UNAVAILABLE` if the remote cannot be reached. + * @throws {ArtifactsError} with code `MEMORY_LIMIT` if the import exceeds service memory limits. + * @throws {ArtifactsError} with code `ALREADY_EXISTS` if the target repo already exists. + */ + import(params: { + source: { + url: string; + branch?: string; + depth?: number; + }; + target: { + name: string; + opts?: { + description?: string; + readOnly?: boolean; + }; + }; + }): Promise; + /** + * List repositories with cursor-based pagination. + * @param opts Optional: limit (1–200, default 50), cursor for next page. + */ + list(opts?: { + limit?: number; + cursor?: string; + }): Promise; + /** + * Delete a repository and all associated tokens. + * @param name Repository name. + * @returns true if deleted, false if not found. + * @throws {ArtifactsError} with code `INVALID_REPO_NAME` if name is invalid. + */ + delete(name: string): Promise; +} +/** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ +interface AutoRAGInternalError extends Error { +} +/** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ +interface AutoRAGNotFoundError extends Error { +} +/** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ +interface AutoRAGUnauthorizedError extends Error { +} +/** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ +interface AutoRAGNameNotSetError extends Error { +} +type ComparisonFilter = { + key: string; + type: 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte'; + value: string | number | boolean; +}; +type CompoundFilter = { + type: 'and' | 'or'; + filters: ComparisonFilter[]; +}; +/** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ +type AutoRagSearchRequest = { + query: string; + filters?: CompoundFilter | ComparisonFilter; + max_num_results?: number; + ranking_options?: { + ranker?: string; + score_threshold?: number; + }; + reranking?: { + enabled?: boolean; + model?: string; + }; + rewrite_query?: boolean; +}; +/** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ +type AutoRagAiSearchRequest = AutoRagSearchRequest & { + stream?: boolean; + system_prompt?: string; +}; +/** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ +type AutoRagAiSearchRequestStreaming = Omit & { + stream: true; +}; +/** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ +type AutoRagSearchResponse = { + object: 'vector_store.search_results.page'; + search_query: string; + data: { + file_id: string; + filename: string; + score: number; + attributes: Record; + content: { + type: 'text'; + text: string; + }[]; + }[]; + has_more: boolean; + next_page: string | null; +}; +/** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ +type AutoRagListResponse = { + id: string; + enable: boolean; + type: string; + source: string; + vectorize_name: string; + paused: boolean; + status: string; +}[]; +/** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ +type AutoRagAiSearchResponse = AutoRagSearchResponse & { + response: string; +}; +/** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ +declare abstract class AutoRAG { + /** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ + list(): Promise; + /** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ + search(params: AutoRagSearchRequest): Promise; + /** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ + aiSearch(params: AutoRagAiSearchRequestStreaming): Promise; + /** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ + aiSearch(params: AutoRagAiSearchRequest): Promise; + /** + * @deprecated Use the standalone AI Search Workers binding instead. + * See https://developers.cloudflare.com/ai-search/usage/workers-binding/ + */ + aiSearch(params: AutoRagAiSearchRequest): Promise; +} +type BrowserRunLifecycleEvent = 'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2'; +type BrowserRunResourceType = 'document' | 'stylesheet' | 'image' | 'media' | 'font' | 'script' | 'texttrack' | 'xhr' | 'fetch' | 'prefetch' | 'eventsource' | 'websocket' | 'manifest' | 'signedexchange' | 'ping' | 'cspviolationreport' | 'preflight' | 'other'; +/** Options fields shared by all quick actions. */ +interface BrowserRunBaseOptions { + /** Adds `