From 7466f4db1ce8ea8172f85a4ce286e151aa0688c8 Mon Sep 17 00:00:00 2001 From: GlacierLuo <1090490148@qq.com> Date: Thu, 17 Sep 2026 22:28:12 +0800 Subject: [PATCH 01/28] feat(skill): document domains and GPT Live --- README.md | 7 ++ examples/openai-gpt-live-text.mjs | 128 ++++++++++++++++++++ skills/xapi/SKILL.md | 6 +- skills/xapi/guides/domains.md | 64 +++++++++- skills/xapi/guides/ws_gateway.md | 68 ++++++++++- src/tests/skill-live-services-guide.test.ts | 7 +- src/tests/skill-ws-gateway-guide.test.ts | 51 ++++++++ 7 files changed, 320 insertions(+), 11 deletions(-) create mode 100644 examples/openai-gpt-live-text.mjs create mode 100644 src/tests/skill-ws-gateway-guide.test.ts diff --git a/README.md b/README.md index 1ee80fc..75bf25d 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,13 @@ WebSocket client. Active SSE and raw downloads may run longer than 60 seconds, but abort after 60 seconds without data by default. Set `XAPI_TRANSFER_IDLE_TIMEOUT_MS` to change that idle timeout. +GPT Live is a WebSocket protocol and cannot be invoked with `xapi-to call`. +Read [the WebSocket Gateway guide](skills/xapi/guides/ws_gateway.md) and use a +real WebSocket client. The packaged +[`examples/openai-gpt-live-text.mjs`](examples/openai-gpt-live-text.mjs) +demonstrates `session.start`, managed Responses delegation, text events, and a +graceful `session.close` without placing an xAPI key in source or CLI arguments. + ### Async Task Commands Task helpers built on top of the `task.poll` capability. diff --git a/examples/openai-gpt-live-text.mjs b/examples/openai-gpt-live-text.mjs new file mode 100644 index 0000000..bad24e5 --- /dev/null +++ b/examples/openai-gpt-live-text.mjs @@ -0,0 +1,128 @@ +#!/usr/bin/env node + +// Minimal GPT Live control-path example through xAPI. It uses managed Responses +// delegation and text input so the session lifecycle is visible without audio +// capture. Install the transport in your application first: npm install ws + +import { randomUUID } from 'node:crypto'; + +const apiKey = process.env.XAPI_KEY || process.env.XAPI_API_KEY; +if (!apiKey) { + throw new Error('Set XAPI_KEY or XAPI_API_KEY in the process environment'); +} + +let WebSocket; +try { + ({ default: WebSocket } = await import('ws')); +} catch { + throw new Error('This example requires the ws package: npm install ws'); +} + +const prompt = process.argv.slice(2).join(' ').trim() || 'Say hello in one short sentence.'; +const prefix = randomUUID(); +const url = 'wss://openai-live.p.xapi.to/v1/live/sessions'; +const socket = new WebSocket(url, { + headers: { 'XAPI-Key': apiKey }, +}); + +const timeout = setTimeout(() => { + console.error('GPT Live example timed out'); + socket.terminate(); + process.exitCode = 1; +}, 60_000); + +let closeRequested = false; +let sessionClosedConfirmed = false; + +function send(event) { + socket.send(JSON.stringify(event)); +} + +function finish(error) { + clearTimeout(timeout); + if (error) { + console.error(error.message || error); + process.exitCode = 1; + } + if (socket.readyState === WebSocket.OPEN) socket.close(); +} + +socket.on('open', () => { + send({ + type: 'session.start', + event_id: `${prefix}-start`, + session: { + model: 'gpt-live-1', + instructions: 'Respond concisely.', + delegation: { type: 'responses' }, + store: false, + }, + }); +}); + +socket.on('message', (raw, isBinary) => { + if (isBinary) return finish(new Error('Unexpected binary GPT Live frame')); + + let event; + try { + event = JSON.parse(raw.toString()); + } catch { + return finish(new Error('GPT Live returned invalid JSON')); + } + + if (event.type === 'session.started') { + send({ + type: 'response.item.create', + event_id: `${prefix}-input`, + item: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: prompt }], + }, + }); + send({ type: 'response.create', event_id: `${prefix}-response` }); + return; + } + + if (event.type === 'response.event') { + const nested = event.event || {}; + if (nested.type === 'response.output_text.delta' && typeof nested.delta === 'string') { + process.stdout.write(nested.delta); + return; + } + if (nested.type === 'response.refusal.delta' && typeof nested.delta === 'string') { + process.stdout.write(nested.delta); + return; + } + if (nested.type === 'response.completed') { + process.stdout.write('\n'); + closeRequested = true; + send({ type: 'session.close', event_id: `${prefix}-close` }); + return; + } + if (nested.type === 'response.failed' || nested.type === 'response.incomplete') { + return finish(new Error(`Delegated response ended with ${nested.type}`)); + } + } + + if (event.type === 'error') { + return finish(new Error(event.error?.message || 'GPT Live session error')); + } + + if (event.type === 'session.closed') { + if (!closeRequested || event.reason !== 'close_requested') { + return finish(new Error(`Unexpected session close: ${event.reason || 'unknown'}`)); + } + sessionClosedConfirmed = true; + finish(); + } +}); + +socket.on('error', (error) => finish(new Error(`WebSocket error: ${error.message}`))); +socket.on('close', () => { + clearTimeout(timeout); + if (!sessionClosedConfirmed && process.exitCode !== 1) { + console.error('WebSocket closed before session.closed confirmation'); + process.exitCode = 1; + } +}); diff --git a/skills/xapi/SKILL.md b/skills/xapi/SKILL.md index c022120..23b6457 100644 --- a/skills/xapi/SKILL.md +++ b/skills/xapi/SKILL.md @@ -206,7 +206,9 @@ npx xapi-to task wait --interval 2s --timeout 10m For application integrations, read `guides/ai_gateway.md` before configuring an Anthropic/OpenAI-compatible client. Read `guides/ws_gateway.md` before opening a -persistent Realtime, ASR, TTS, interpretation, or podcast WebSocket session. +persistent GPT Live, Realtime, ASR, TTS, interpretation, or podcast WebSocket +session. GPT Live uses `/v1/live/sessions` and is not the Realtime protocol at +`/v1/realtime`. ## Input Format @@ -331,7 +333,7 @@ When the user's task involves these workflows, read the corresponding guide file - **`guides/domains.md`**, **`guides/blockpi.md`**, **`guides/binance_web3.md`** — domain purchase and DNS writes, BlockPI EVM JSON-RPC, and the official Binance Web3 API catalog; read the matching guide before any purchase, mutation, transaction build, signing, or broadcast - **`guides/ai.md`** — AI (人工智能): synchronous or SSE-streamed text, embeddings, asynchronous image/video generation with `task wait`, text-to-speech, and speech-to-text - **`guides/ai_gateway.md`** — xAPI AI Gateway: Claude Code and Anthropic/OpenAI SDK setup, model discovery, routing strategies, streaming, fallback, routing/billing headers, direct media endpoints, and known limitations -- **`guides/ws_gateway.md`** — xAPI WebSocket Gateway: OpenAI Realtime, streaming ASR/TTS, simultaneous interpretation, podcast generation, service/path routing, browser authentication, native binary protocols, limits, billing, close codes, and reconnects +- **`guides/ws_gateway.md`** — xAPI WebSocket Gateway: GPT Live, OpenAI Realtime, streaming ASR/TTS, simultaneous interpretation, podcast generation, service/path routing, browser authentication, native protocols, limits, billing, close codes, and reconnects - **`guides/sandbox.md`** — managed Sandbox compute: AI tool selection, one-shot and multi-step lifecycles, provider pinning, files, Cloudflare Web previews, suspension, GPU jobs, parallel agents, cleanup recovery, audit/history, and billing verification - **`guides/sms.md`** — SMS verification: buy virtual phone numbers, receive verification codes, finish/cancel orders (5SIM) - **`guides/provider.md`** — Provider management: create/update services, About/changelog, version lifecycle, metrics/events and request receipts, Skill upload/linking, rollback/delete, earnings transfer diff --git a/skills/xapi/guides/domains.md b/skills/xapi/guides/domains.md index 395c4d5..bccc1de 100644 --- a/skills/xapi/guides/domains.md +++ b/skills/xapi/guides/domains.md @@ -17,17 +17,21 @@ record change, and obtain explicit approval before either mutation. | `domain.check` | Check exact-domain availability | No | | `domain.price` | Read the current USD registration price | No | | `domain.register` | Register a domain | **Purchase** | +| `domain.registration.get` | Read an asynchronous registration task | No | | `domain.list` | List domains owned through xAPI | No | | `domain.get` | Inspect one domain by `domain_id` | No | | `dns.list` | List a domain's DNS records | No | | `dns.upsert` | Create or update a DNS record | **Write** | | `dns.delete` | Delete a DNS record | **Write** | +| `dns.dnssec.get` | Read desired, effective, and provider DNSSEC state | No | +| `dns.dnssec.set` | Enable or disable Cloudflare DNSSEC | **Write** | Fetch the live schemas before use: ```bash npx xapi-to get-batch domain.search domain.check domain.price domain.register \ - domain.list domain.get dns.list dns.upsert dns.delete + domain.registration.get domain.list domain.get dns.list dns.upsert dns.delete \ + dns.dnssec.get dns.dnssec.set ``` ## Search, check, and price @@ -82,8 +86,24 @@ npx xapi-to call domain.register --input '{ `max_price_usd` is a hard final-charge ceiling, not the expected price. `auto_renew` must currently remain `false`; renewal billing is not available. -Registration is non-refundable. Do not retry with a new key after an ambiguous -failure: first inspect `domain.list` to determine whether the purchase completed. +Registration is non-refundable. A successful submission can return a `task_id` +before the registrar has finished. Poll that exact task rather than repeating the +purchase: + +```bash +npx xapi-to call domain.registration.get \ + --input '{"task_id":""}' +``` + +Continue polling only while `status` is `pending` or `processing`. Treat +`succeeded`, `failed`, and `expired` as terminal. Preserve `phase`, `action`, +`error`, `final_cost`, and `confirmation_sent_to` when reporting the result; +some registrations can require external confirmation or manual review. + +Do not retry with a new idempotency key after an ambiguous response. If a +`task_id` was returned, read it with `domain.registration.get`. Otherwise reuse +the original key only for the exact same request, then inspect `domain.list` +before deciding whether another purchase attempt is safe. `domain.get` intentionally does not return the registrant contact. Treat that privacy boundary as expected rather than assuming registration lost the data. @@ -129,3 +149,41 @@ For every write, confirm the domain, record type/name/value, and stable record identifier. Reuse an idempotency key only for an identical retry; use a new key when any requested value changes. After a successful write, call `dns.list` again and verify the intended state instead of assuming propagation or success. + +## DNSSEC lifecycle + +DNSSEC is currently available only for domains whose authoritative provider is +Cloudflare. Read the current state before changing it: + +```bash +npx xapi-to call dns.dnssec.get \ + --input '{"domain_id":""}' +``` + +After explicit approval, request the desired state with one stable idempotency +key. Reuse that key only when retrying this exact domain and `enabled` value: + +```bash +npx xapi-to call dns.dnssec.set --input '{ + "domain_id":"", + "enabled":true, + "idempotency_key":"dnssec-enable-example-20260917" +}' +``` + +The mutation can finish its request while Cloudflare or the parent registry is +still reconciling. Interpret the response fields together: + +- `desired_enabled` is the requested target. +- `effective_enabled` is the state currently protecting DNS responses. +- `transition` is `enabling`, `disabling`, or `null` when settled. +- `provider_status` preserves the upstream lifecycle state. +- `action_required` means operator intervention is needed. + +`pending` and `pending-disabled` are transitions, not success. Poll +`dns.dnssec.get` until the state settles, becomes `error`, or the user's +deadline is reached. Do not report DNSSEC as enabled until +`effective_enabled=true` with no transition, and do not report it as disabled +until `effective_enabled=false` with no transition. Preserve DS metadata when +the caller needs to inspect delegation, but never invent or manually publish a +DS record unless the live response explicitly says operator action is required. diff --git a/skills/xapi/guides/ws_gateway.md b/skills/xapi/guides/ws_gateway.md index c5c28ff..cd802eb 100644 --- a/skills/xapi/guides/ws_gateway.md +++ b/skills/xapi/guides/ws_gateway.md @@ -1,6 +1,6 @@ # WebSocket Gateway Guide -Use xAPI's WebSocket Gateway for full-duplex, low-latency sessions such as OpenAI Realtime, streaming speech recognition, bidirectional text-to-speech, simultaneous interpretation, and podcast generation. +Use xAPI's WebSocket Gateway for full-duplex, low-latency sessions such as GPT Live, OpenAI Realtime, streaming speech recognition, bidirectional text-to-speech, simultaneous interpretation, and podcast generation. The WebSocket Gateway shares the public `ai.xapi.to` host with the HTTP AI Gateway, but it is a separate protocol surface. An HTTP request continues to use the AI Gateway; a valid WebSocket Upgrade request is routed to the WebSocket Gateway. @@ -9,6 +9,7 @@ The WebSocket Gateway shares the public `ai.xapi.to` host with the HTTP AI Gatew - [Choose the right interface](#choose-the-right-interface) - [Public URLs and routing](#public-urls-and-routing) - [Authentication](#authentication) +- [GPT Live example](#gpt-live-example) - [OpenAI Realtime example](#openai-realtime-example) - [Browser connections](#browser-connections) - [Native protocol endpoints](#native-protocol-endpoints) @@ -39,6 +40,7 @@ Current curated production paths include: | Path | Protocol | Typical use | |---|---|---| +| `/v1/live/sessions` on the GPT Live service host | OpenAI Live Sessions JSON events | GPT-Live 1 voice with Client or managed Responses delegation | | `/v1/realtime` | OpenAI Realtime GA JSON events | Realtime text and voice | | `/v1/asr` | Volcengine ASR binary frames | Streaming speech recognition | | `/v1/tts` | Doubao bidirectional TTS binary frames | Streaming text-to-speech | @@ -57,6 +59,15 @@ wss://.p.xapi.to/ This avoids shared-path ambiguity and is required when the desired service uses a provider-native protocol that is not selected by the unified path. Console Try-It and review workflows can also address an endpoint exactly with `?endpoint=`. +GPT Live currently uses the service-specific URL: + +```text +wss://openai-live.p.xapi.to/v1/live/sessions +``` + +Do not replace it with `/v1/realtime`. Live Sessions uses `session.start` and +`session.started`; Realtime uses a different session lifecycle and event model. + ## Authentication Use the same xAPI key as the CLI and HTTP Gateway. Server-side clients should send one of these handshake headers: @@ -78,6 +89,43 @@ The Gateway also accepts `?token=` or `?xapi-key=` for clien Authentication is checked before the WebSocket upgrade. Invalid handshakes therefore return an HTTP status instead of opening and immediately closing a socket. +## GPT Live example + +GPT Live is a provider-native JSON event protocol, not an Action `call` and not +OpenAI Realtime. The first client frame must be `session.start`. The production +endpoint locks the Live model to `gpt-live-1`, disables storage, and lets the +caller choose `client` or `responses` delegation once per connection. In +`responses` mode, the managed Responses model and its limits remain +server-controlled. + +The packaged `examples/openai-gpt-live-text.mjs` demonstrates the smallest +managed-Responses lifecycle: connect with a server-side xAPI key, send +`session.start`, wait for `session.started`, create a text item, request a +response, and finish with `session.close` after the nested response completes. +It intentionally omits microphone capture so the protocol boundary is clear. + +```bash +# Install the example's WebSocket transport in your application directory. +npm install ws + +# Supply XAPI_KEY through the process environment; never put it in source code +# or pass it as a command-line argument. +node examples/openai-gpt-live-text.mjs "Answer in one short sentence." +``` + +Voice clients use the same session lifecycle, then send base64 PCM chunks as +`session.input_audio.append` events and consume `session.output_audio.delta`. +Audio format, voice, interruption behavior, and the complete event schema must +follow the current Live Sessions contract. Do not copy Realtime +`conversation.item.create` or `input_audio_buffer.*` events into a Live session. + +With `client` delegation, the application must handle +`session.delegation.created`, run its own text-model request, and return the +result with `session.commentary.append`, then wait for +`session.commentary.appended`. Selecting `client` does not make xAPI run a +model on the application's behalf. Use `responses` when the managed backend is +desired. + ## OpenAI Realtime example The unified `/v1/realtime` route speaks the OpenAI Realtime GA JSON event protocol. It is native passthrough: send the same events you would send to the upstream Realtime API, but authenticate with the xAPI key. @@ -121,15 +169,26 @@ Do not send the retired `OpenAI-Beta: realtime=v1` header. Session settings, aud The browser `WebSocket` API cannot set arbitrary handshake headers. The Gateway accepts an xAPI key or temporary token through a subprotocol entry: ```javascript -const temporaryToken = await getTemporaryTokenFromYourBackend(); +const credential = await getEndpointBoundCredentialFromYourBackend(endpointId); +if (Date.now() >= new Date(credential.latestStartAt).getTime()) { + throw new Error("refresh the credential before opening a full new session"); +} const ws = new WebSocket( - "wss://ai.xapi.to/v1/realtime", - [`xapi-key.${temporaryToken}`], + "wss://openai-live.p.xapi.to/v1/live/sessions", + ["xapi-ws-v1", `xapi-key.${credential.token}`], ); ``` Never embed a long-lived xAPI key in frontend JavaScript. Use the authenticated xAPI Console Try-It flow or your backend to obtain a short-lived token, then pass only that token to the browser. The Console's `POST /api/keys/ws-token` flow mints a temporary token for a WebSocket endpoint; it requires a logged-in entity account and an endpoint ID, and is not authenticated with a normal xAPI key. +The credential is endpoint-bound and returns `expiresAt`, `latestStartAt`, and +`maxDurationSec`. It may be reused for reconnects only while the new connection +starts before `latestStartAt`; after that boundary, mint a fresh credential so +the full advertised session and final `session.close` exchange fit inside its +lifetime. If an endpoint declares public subprotocols, retain them and append +the `xapi-key.*` entry; otherwise use the non-secret `xapi-ws-v1` marker shown +above. Never log the secret subprotocol value. + If a browser integration must use `?token=`, use only a short-lived token and avoid logging the complete URL. ## Native protocol endpoints @@ -138,6 +197,7 @@ The Gateway forwards frames without translating the application protocol. The se | Adapter | Client frames | Important client requirement | |---|---|---| +| `openai-live` | UTF-8 JSON text | First frame is `session.start`; choose Client or managed Responses delegation once. | | `openai-realtime` | UTF-8 JSON text | Use OpenAI Realtime GA events. | | `volcengine-asr` | Binary | Send the Volcengine ASR header/config/audio frame sequence; PCM configuration must match the audio bytes. | | `doubao-realtime` | Binary | Use the Doubao end-to-end realtime dialogue protocol through its service host or exact endpoint. | diff --git a/src/tests/skill-live-services-guide.test.ts b/src/tests/skill-live-services-guide.test.ts index a890f85..7bf04e4 100644 --- a/src/tests/skill-live-services-guide.test.ts +++ b/src/tests/skill-live-services-guide.test.ts @@ -8,7 +8,8 @@ const binance = readFileSync(new URL('../../skills/xapi/guides/binance_web3.md', const DOMAIN_ACTIONS = [ 'domain.search', 'domain.check', 'domain.price', 'domain.register', - 'domain.list', 'domain.get', 'dns.list', 'dns.upsert', 'dns.delete', + 'domain.registration.get', 'domain.list', 'domain.get', 'dns.list', + 'dns.upsert', 'dns.delete', 'dns.dnssec.get', 'dns.dnssec.set', ]; const BLOCKPI_ACTIONS = [ @@ -110,8 +111,10 @@ describe('bundled xAPI live-service guides', () => { for (const safeguard of [ 'non-refundable', 'explicit approval', 'max_price_usd', 'idempotency key', 'record_id', 'record_index', 'domain.list', 'dns.list', + 'task_id', 'pending', 'processing', 'pending-disabled', + 'desired_enabled', 'effective_enabled', 'action_required', ]) expect(domains).toContain(safeguard); - expectValidJsonExamples(domains, 9); + expectValidJsonExamples(domains, 12); expectBalancedFences(domains); }); diff --git a/src/tests/skill-ws-gateway-guide.test.ts b/src/tests/skill-ws-gateway-guide.test.ts new file mode 100644 index 0000000..c1291ac --- /dev/null +++ b/src/tests/skill-ws-gateway-guide.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'bun:test'; +import { readFileSync } from 'node:fs'; + +const guide = readFileSync(new URL('../../skills/xapi/guides/ws_gateway.md', import.meta.url), 'utf8'); +const skill = readFileSync(new URL('../../skills/xapi/SKILL.md', import.meta.url), 'utf8'); +const readme = readFileSync(new URL('../../README.md', import.meta.url), 'utf8'); +const example = readFileSync(new URL('../../examples/openai-gpt-live-text.mjs', import.meta.url), 'utf8'); + +describe('bundled GPT Live guidance', () => { + it('keeps GPT Live distinct from OpenAI Realtime and Action calls', () => { + for (const required of [ + 'wss://openai-live.p.xapi.to/v1/live/sessions', + '`session.start`', + '`session.started`', + '`gpt-live-1`', + '`client`', + '`responses`', + '`session.delegation.created`', + '`session.commentary.append`', + '`session.commentary.appended`', + 'Do not replace it with `/v1/realtime`', + 'not an Action `call`', + ]) expect(guide).toContain(required); + expect(skill).toContain('GPT Live uses `/v1/live/sessions`'); + }); + + it('documents endpoint-bound browser credentials without query secrets', () => { + for (const required of [ + 'latestStartAt', 'maxDurationSec', 'endpoint-bound', + 'xapi-ws-v1', 'xapi-key.${credential.token}', + ]) expect(guide).toContain(required); + expect(guide).toContain('Never embed a long-lived xAPI key'); + }); + + it('ships a managed Responses example with the complete close lifecycle', () => { + for (const required of [ + "process.env.XAPI_KEY", "import('ws')", + "type: 'session.start'", "type: 'response.item.create'", + "type: 'response.create'", "type: 'session.close'", + "event.type === 'session.closed'", "delegation: { type: 'responses' }", + 'sessionClosedConfirmed', + ]) expect(example).toContain(required); + expect(example).not.toContain('?token='); + expect(example).not.toMatch(/sk-[A-Za-z0-9]/); + expect(readme).toContain('examples/openai-gpt-live-text.mjs'); + }); + + it('keeps Markdown fences balanced', () => { + expect((guide.match(/^```/gm) ?? []).length % 2).toBe(0); + }); +}); From 0b17d43b5f24536f6a4b50d29de65a4045b81369 Mon Sep 17 00:00:00 2001 From: 0xAA Date: Thu, 17 Sep 2026 23:07:40 +0800 Subject: [PATCH 02/28] feat(provider): import API contracts and wait for publication (#14) Co-authored-by: GlacierLuo <1090490148@qq.com> --- README.md | 108 ++++++++++ examples/provider/openapi.json | 34 +++ src/commands/provider-onboarding.ts | 242 +++++++++++++++++++++ src/commands/provider.ts | 31 ++- src/provider-client.ts | 80 +++++++ src/tests/provider-onboarding.test.ts | 296 ++++++++++++++++++++++++++ 6 files changed, 784 insertions(+), 7 deletions(-) create mode 100644 examples/provider/openapi.json create mode 100644 src/commands/provider-onboarding.ts create mode 100644 src/provider-client.ts create mode 100644 src/tests/provider-onboarding.test.ts diff --git a/README.md b/README.md index 75bf25d..b29b4fb 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,114 @@ xapi-to task wait 550e8400-e29b-41d4-a716-446655440000 # wait un xapi-to task wait 550e8400-e29b-41d4-a716-446655440000 --interval 1s --timeout 10m ``` +### Provider: Import → Configure → Submit → Wait + +`provider` manages APIs owned by your account. It uses `XAPI_API_HOST` +(default `api.xapi.to`) and the same saved or environment API key as other +commands. In the xAPI Console API Keys settings, grant `service:create`, +`service:read`, `service:update`, and `service:publish`. Legacy `allowRegister` +only grants creation, not the remaining lifecycle permissions. Missing +permissions return a nonzero exit with the required scope. + +Start from [examples/provider/openapi.json](examples/provider/openapi.json), +replace its upstream URL, service details, and endpoint contract, then run: + +```bash +# Inspect current rules; no API key required +xapi-to provider spec-rules --format pretty + +# Import a raw OpenAPI 3.0.3 JSON object (not a {openApiSpec: ...} envelope) +xapi-to provider import --file openapi.json > imported.json + +# Use the serviceId and revisionId from imported.json (jq is optional) +PROVIDER_SERVICE_ID=$(jq -er '.serviceId' imported.json) +PROVIDER_REVISION_ID=$(jq -er '.revisionId' imported.json) + +# Save version configuration to move the draft revision to SANDBOX. +# config.json can be {"description":"Initial release"} when the imported +# endpoints, authentication, and pricing are already complete. +xapi-to provider update "$PROVIDER_SERVICE_ID" \ + --revision "$PROVIDER_REVISION_ID" --file config.json + +# Submit the specified revision, then wait for the actual publication result +xapi-to provider submit "$PROVIDER_SERVICE_ID" \ + --revision "$PROVIDER_REVISION_ID" --changelog "Initial release" +xapi-to provider wait "$PROVIDER_SERVICE_ID" \ + --revision "$PROVIDER_REVISION_ID" --interval 2s --timeout 10m + +# Inspect owned services, configuration, version overview, or review reports +xapi-to provider list --format table +xapi-to provider get "$PROVIDER_SERVICE_ID" --format pretty +xapi-to provider versions "$PROVIDER_SERVICE_ID" --format pretty +xapi-to provider review "$PROVIDER_SERVICE_ID" --revision "$PROVIDER_REVISION_ID" +``` + +When scripting these steps, stop on nonzero exit (for example, use `set -e`). +Import returns the backend validation/preview plus `serviceId`, `revisionId`, +and `state`. An HTTP 201 with `success: false` is a validation failure and exits +nonzero; its structured validation errors are preserved. Registration creates +a new service each time. If a response is lost, inspect `provider list` before +retrying to avoid duplicate services. + +For an authenticated upstream, store credentials in a local JSON object such +as `{"Authorization":"Bearer YOUR_UPSTREAM_KEY"}` and pass +`--private-headers-file private-headers.json` to `provider import`. Keep this +file out of version control. `--file -` and `--private-headers-file -` accept +stdin, but only one input can consume stdin per command. Files must be JSON; +YAML and URL imports are not supported in this command group. + +`provider update --revision ` reads a version configuration object, using the backend +fields `description`, `baseUrl`, `baseUrls`, `authType`, `privateHeaders`, +`authConfig`, `openApiSpec`, `endpoints`, and `status`. Prefer structured +`privateHeaders` for upstream credentials. Endpoint fields include billing +configuration such as `billingType` and `costPerCall`. Update does not accept +a raw OpenAPI document; the nested backend field is `openApiSpec: {spec: ...}`. +Saving that field alone does not re-import endpoint definitions; configure +`endpoints` explicitly when changing the contract. + +- `--mode merge` (default) sends PATCH and preserves omitted fields/endpoints. + Existing endpoint edits require `id`, e.g. + `{"endpoints":[{"id":"ENDPOINT_ID","costPerCall":"0.002"}]}`. +- `--allow-new-endpoints` explicitly permits ID-less merge entries to create + endpoints. Repeating such a merge can create duplicates. +- `--mode replace` sends PUT. If `endpoints` is provided, it replaces the + endpoint list; include every endpoint you intend to keep. Omitted fields + otherwise follow backend PUT semantics. Use full configuration for replacement. + +`get` retains its existing service response; `--version v1.0` selects the +configuration returned by the backend. Find endpoint IDs in +`currentVersion.endpoints`; `provider versions` returns working revision IDs +in `majors[].working.id`. For an already-published API, use the existing +`provider revision start ` command to create a working revision. +`provider update` without `--revision` continues to update service metadata +and rate limits. Existing `version update`, `publish`, and positional `review` +commands remain available. The `submit` and `review --revision` forms are +additional onboarding commands. + +Updates to `IN_REVIEW`, `PUBLISHED`, or `SUSPENDED` revisions return a conflict; +the backend enforces this check under a transaction lock. When updating +`privateHeaders`, send the complete desired map: it replaces the old map and +rebuilds the derived authentication configuration. An empty map without an +explicit `authConfig` clears those credentials. Omitting both fields preserves them. + +`submit` returns `{serviceId, revisionId, submission}`. A successful submission +does not guarantee publication. `wait` checks the requested revision, succeeds +only for `PUBLISHED`, and outputs the review report with `success` and `reason`. +`--changelog` is limited to 2,000 characters by both the CLI and backend. +Rejection, a draft/sandbox/suspended revision, or a legacy manual-review hold +exit nonzero. Pending review continues until publication, the timeout (default +10 minutes), or optional `--max-attempts`. Timeout and attempt-limit results +include the last received report; polling can be resumed with the same IDs. +The deadline also bounds in-flight HTTP requests and retry delays. + +Reads retry transient errors; writes are never automatically retried. The CLI +redacts credential fields and known credential values from provider output. +Redacted reads are for inspection and must not be submitted unchanged as +configuration. After an ambiguous write failure, use `list`, `get`, or `review` +to inspect the result before repeating the operation. No npm release is implied +by a local source checkout; use `bun run src/index.ts provider ...` or build and +run `node dist/index.js provider ...` while testing unreleased changes. + ### Sandbox Commands Sandbox commands provide an AI-friendly cloud computer lifecycle. The fastest diff --git a/examples/provider/openapi.json b/examples/provider/openapi.json new file mode 100644 index 0000000..5ddf5bb --- /dev/null +++ b/examples/provider/openapi.json @@ -0,0 +1,34 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Example Provider API", + "version": "1.0.0", + "description": "Replace this example with your own upstream API contract.", + "x-xapi-category": "Public-Utils", + "x-xapi-host": "example-provider-api" + }, + "servers": [{ "url": "https://upstream.example.com" }], + "paths": { + "/status": { + "get": { + "summary": "Get status", + "description": "Return the upstream service status.", + "x-xapi-billing": { "type": "PER_CALL", "costPerCall": 0.001 }, + "responses": { + "200": { + "description": "Service status", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { "status": { "type": "string" } } + }, + "example": { "status": "ok" } + } + } + } + } + } + } + } +} diff --git a/src/commands/provider-onboarding.ts b/src/commands/provider-onboarding.ts new file mode 100644 index 0000000..59a6632 --- /dev/null +++ b/src/commands/provider-onboarding.ts @@ -0,0 +1,242 @@ +import { readFile } from 'node:fs/promises'; +import { getConfig, requireApiKey } from '../config.ts'; +import { HttpError, isRetryableRequestError } from '../client.ts'; +import { output, err, type OutputFormat } from '../format.ts'; +import { providerRequest, object, redactProvider, collectProviderSecrets, type ProviderObject } from '../provider-client.ts'; + +export const PROVIDER_ONBOARDING_HELP = `xapi-to provider - Import and publish your API services + +COMMANDS + spec-rules Read current OpenAPI rules (public) + import --file openapi.json Create a DRAFT service from OpenAPI JSON + --private-headers-file Upstream credentials as a JSON object + update --revision --file config.json + --mode merge|replace PATCH merge (default) or PUT replacement + --allow-new-endpoints Allow merge entries without IDs to create endpoints + submit --revision + --changelog Submit for review; this alone is not publication + review --revision + Read latest review and previous attempts + wait --revision + --interval Poll interval (default: 2s; ms/s/m/h) + --timeout Overall deadline (default: 10m; ms/s/m/h) + --max-attempts Optional cap, including transient failures + +COMMON FLAGS + --format json|pretty|table + --help + +Files must contain JSON objects; --file - reads stdin. Import reads a raw +OpenAPI spec; update reads version configuration (not a raw OpenAPI spec). +Merge updates to existing endpoints require their IDs. Use --mode replace +to replace the endpoint list, or --allow-new-endpoints to intentionally add. +Saving a draft configuration moves it to SANDBOX, ready for submit. + +PERMISSIONS + import: service:create (legacy allowRegister also accepted) + list/get/review/wait: service:read; update: service:update + submit: service:publish. Grant scopes in the xAPI Console API Keys settings. + +wait succeeds only for PUBLISHED. Rejection, unpublished terminal states, +manual review, invalid responses, and timeouts exit nonzero with details. +Writes are not retried automatically. Credentials are redacted from output. +`; + +const FLAGS: Record = { + 'spec-rules': [], import: ['file', 'private-headers-file'], + update: ['revision', 'file', 'mode', 'allow-new-endpoints'], + submit: ['revision', 'changelog'], review: ['revision'], + wait: ['revision', 'interval', 'timeout', 'max-attempts'], +}; +const SCOPES: Record = { + import: 'service:create', + update: 'service:update', submit: 'service:publish', review: 'service:read', wait: 'service:read', +}; + +function flag(flags: Record, name: string, required = false): string | undefined { + const value = flags[name]; + if ((required && value === undefined) || value === '' || value === 'true') { + throw new Error(`--${name} requires a value`); + } + return value; +} + +function duration(raw: string, name: string): number { + const match = /^(\d+)(ms|s|m|h)?$/.exec(raw); + const units: Record = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }; + const ms = match ? Number(match[1]) * units[match[2] || 'ms'] : NaN; + if (!Number.isSafeInteger(ms) || ms <= 0 || ms > 2_147_483_647) { + throw new Error(`--${name} must be a positive duration (ms/s/m/h), at most 2147483647ms`); + } + return ms; +} + +async function jsonFile(path: string): Promise { + let text: string; + try { + if (path === '-') { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk)); + text = Buffer.concat(chunks).toString('utf8'); + } else text = await readFile(path, 'utf8'); + } catch { throw new Error('Could not read JSON input file'); } + let value: unknown; + try { value = JSON.parse(text); } + // JSON.parse error messages can quote input credentials. + catch { throw new Error('Input must be valid JSON (JSON objects only; YAML is not supported)'); } + const result = object(value); + if (!result) throw new Error('Input must be a JSON object'); + return result; +} + +function segment(value: string): string { + const normalized = value.trim(); + if (!normalized || normalized === '.' || normalized === '..') { + throw new Error('Invalid service or revision ID'); + } + return encodeURIComponent(normalized); +} + +export async function providerOnboarding(args: string[], flags: Record): Promise { + const [command, ...rest] = args; + if (flags.help || !command) { console.log(PROVIDER_ONBOARDING_HELP); return; } + const secrets: string[] = []; + const emit = (value: unknown) => output(redactProvider(value, secrets), flags.format as OutputFormat | undefined); + try { + if (!Object.hasOwn(FLAGS, command)) throw new Error(`Unknown provider command: ${command}`); + for (const key of Object.keys(flags)) { + if (!['format', ...FLAGS[command]].includes(key)) throw new Error(`Unknown flag for provider ${command}: --${key}`); + } + const needsService = !['spec-rules', 'import'].includes(command); + if (rest.length !== (needsService ? 1 : 0)) throw new Error(`provider ${command} expects ${needsService ? 'one service ID' : 'no positional arguments'}`); + const serviceId = rest[0]; + const base = serviceId ? `services/${segment(serviceId)}` : 'services'; + const revisionId = flag(flags, 'revision', ['update', 'submit', 'review', 'wait'].includes(command)); + const revisionPath = revisionId ? `${base}/revisions/${segment(revisionId)}` : ''; + const cfg = getConfig(); + if (cfg.apiKey) secrets.push(cfg.apiKey); + if (command !== 'spec-rules') requireApiKey(cfg); + const read = (path: string) => providerRequest(path, cfg.apiKey); + + if (command === 'spec-rules') { emit(await providerRequest('spec-rules', undefined)); return; } + if (command === 'import') { + const file = flag(flags, 'file', true)!; + const headersFile = flag(flags, 'private-headers-file'); + if (file === '-' && headersFile === '-') throw new Error('Only one input may read stdin'); + const spec = await jsonFile(file); + const body: ProviderObject = { openApiSpec: spec }; + if (headersFile) { + body.privateHeaders = await jsonFile(headersFile); + if (Object.values(body.privateHeaders as ProviderObject).some(v => typeof v !== 'string')) { + throw new Error('Private header values must be strings'); + } + } + secrets.push(...collectProviderSecrets(body)); + const result = await providerRequest('register-api-service', cfg.apiKey, 'POST', body); + if (result?.success === false) { emit(result); process.exitCode = 1; return; } + const service = object(result?.apiService); + if (result?.success !== true || typeof service?.id !== 'string') { + throw new Error('Unexpected import response; creation may have succeeded. Check provider list before retrying'); + } + const active = object(service.activeVersion); + const revision = active ?? (Array.isArray(service.versions) ? object(service.versions[0]) : undefined); + emit({ ...result, serviceId: service.id, revisionId: revision?.id ?? service.activeVersionId ?? null, + state: revision?.state ?? service.status ?? null }); + return; + } + if (command === 'update') { + const mode = flag(flags, 'mode') ?? 'merge'; + if (!['merge', 'replace'].includes(mode)) throw new Error('--mode must be merge or replace'); + if (flags['allow-new-endpoints'] !== undefined && flags['allow-new-endpoints'] !== 'true') { + throw new Error('--allow-new-endpoints is a boolean flag'); + } + const body = await jsonFile(flag(flags, 'file', true)!); + secrets.push(...collectProviderSecrets(body)); + if ('openapi' in body) throw new Error('update expects version configuration, not a raw OpenAPI spec'); + if (body.endpoints !== undefined) { + if (!Array.isArray(body.endpoints) || body.endpoints.some(ep => !object(ep))) throw new Error('endpoints must be an array of objects'); + if (mode === 'merge' && !flags['allow-new-endpoints'] && body.endpoints.some(ep => typeof ep.id !== 'string' || !ep.id.trim())) { + throw new Error('Merge endpoints require IDs. Use --mode replace for a full list, or --allow-new-endpoints to intentionally create endpoints'); + } + } + const revision = await providerRequest(`${base}/versions/${segment(revisionId!)}`, cfg.apiKey, mode === 'merge' ? 'PATCH' : 'PUT', body); + emit({ serviceId, revisionId, state: revision?.state ?? null, revision }); + return; + } + if (command === 'submit') { + const changelog = flag(flags, 'changelog'); + if (changelog !== undefined && changelog.length > 2000) { + throw new Error('--changelog must be at most 2000 characters'); + } + const submission = await providerRequest(`${revisionPath}/submit`, cfg.apiKey, 'POST', changelog ? { changelog } : {}); + emit({ serviceId, revisionId, submission }); + return; + } + if (command === 'review') { emit(await read(`${revisionPath}/review`)); return; } + + const intervalMs = duration(flag(flags, 'interval') ?? '2s', 'interval'); + const timeoutMs = duration(flag(flags, 'timeout') ?? '10m', 'timeout'); + const attemptsFlag = flag(flags, 'max-attempts'); + const maxAttempts = attemptsFlag === undefined ? Infinity : Number(attemptsFlag); + if (attemptsFlag !== undefined && (!/^\d+$/.test(attemptsFlag) || !Number.isSafeInteger(maxAttempts) || maxAttempts <= 0)) { + throw new Error('--max-attempts must be a positive integer'); + } + const deadline = Date.now() + timeoutMs; + let attempts = 0; + let last: ProviderObject | undefined; + while (true) { + if (Date.now() >= deadline) { + emit({ serviceId, revisionId, success: false, reason: 'timeout', attempts, last }); + process.exitCode = 1; return; + } + let delay = intervalMs; + let report: ProviderObject | undefined; + let received = false; + attempts++; + try { + report = await providerRequest(`${revisionPath}/review`, cfg.apiKey, 'GET', undefined, Math.max(1, deadline - Date.now()), 0); + received = true; + } catch (e) { + if (!isRetryableRequestError(e)) throw e; + if (e instanceof HttpError && e.retryAfterMs !== undefined) delay = Math.max(intervalMs, e.retryAfterMs); + } + if (Date.now() >= deadline) continue; + if (received) { + const revision = object(report?.revision); + const state = revision?.state; + if (revision?.id !== revisionId || !['DRAFT', 'SANDBOX', 'IN_REVIEW', 'PUBLISHED', 'SUSPENDED'].includes(String(state))) { + throw new Error('Invalid review response: expected requested revision ID and known state'); + } + last = report; + const review = object(report?.review); + const manual = review?.outcome === 'pending_human' || review?.status === 'PENDING_HUMAN'; + const rejected = review?.outcome === 'rejected' || ['REJECTED', 'AUTO_FAILED'].includes(String(review?.status)); + if (state === 'PUBLISHED' || state !== 'IN_REVIEW' || manual || rejected) { + const success = state === 'PUBLISHED'; + emit({ ...report, serviceId, revisionId, success, reason: success ? 'published' : manual ? 'manual_review_required' : rejected ? 'rejected' : 'not_published' }); + if (!success) process.exitCode = 1; + return; + } + } + if (attempts >= maxAttempts) { + emit({ serviceId, revisionId, success: false, reason: 'max_attempts', attempts, last }); + process.exitCode = 1; return; + } + await new Promise(resolve => setTimeout(resolve, Math.min(delay, Math.max(0, deadline - Date.now())))); + } + } catch (e) { + let message: string; + if (e instanceof HttpError) { + // Server errors may echo request bodies (including truncated credentials). + message = `HTTP ${e.status}`; + if (e.status === 403) message += `: requires ${SCOPES[command]}; check key permissions, service ownership, and IP restrictions in the xAPI Console`; + else if (e.status === 401) message += ': invalid or expired API key'; + else if (e.status === 400) message += ': request rejected; check spec-rules, configuration, and revision state'; + else if (e.status === 409 && /"code"\s*:\s*"REVISION_NOT_EDITABLE"/.test(e.message)) { + message += ': revision is not editable. Only DRAFT or SANDBOX can be updated; use a working revision for changes to a published API'; + } + if (['import', 'update', 'submit'].includes(command)) message += '. No automatic retry was made; inspect provider list/get/review before retrying'; + } else message = e instanceof SyntaxError ? 'Invalid JSON response from provider API' : e instanceof Error ? e.message : 'Unknown error'; + err(`provider ${command} failed`, redactProvider(message, secrets)); + } +} diff --git a/src/commands/provider.ts b/src/commands/provider.ts index 5469b97..8a1bc64 100644 --- a/src/commands/provider.ts +++ b/src/commands/provider.ts @@ -2,7 +2,9 @@ import { mkdir, open, readFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; -import { apiKeyApiRequest } from '../client.ts'; +import { apiKeyApiRequest, HttpError } from '../client.ts'; +import { providerOnboarding, PROVIDER_ONBOARDING_HELP } from './provider-onboarding.ts'; +import { redactProvider } from '../provider-client.ts'; import { getConfig, requireApiKey, @@ -16,6 +18,11 @@ const BASE = '/api/api-services/agent'; export const PROVIDER_HELP = `xapi-to provider - Manage provider services and their content USAGE + xapi-to provider spec-rules + xapi-to provider import --file [--private-headers-file ] + xapi-to provider update --revision --file + xapi-to provider submit --revision [--changelog ] + xapi-to provider wait --revision [--interval 2s] [--timeout 10m] xapi-to provider list xapi-to provider get [--version ] xapi-to provider create --file [rate-limit flags] @@ -159,11 +166,13 @@ async function textOption( async function readJsonObject(path: string, flagName = '--file'): Promise> { if (!path || path === 'true') err(`${flagName} requires a JSON file path or - for stdin`); + const text = await readText(path, flagName); let parsed: unknown; try { - parsed = JSON.parse(await readText(path, flagName)); - } catch (error: any) { - err(`invalid JSON from ${flagName}`, error.message); + parsed = JSON.parse(text); + } catch { + // Runtime JSON errors can quote input fragments, including credentials. + err(`invalid JSON from ${flagName}`, 'Input must be valid JSON.'); } if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { err(`${flagName} must contain a JSON object`); @@ -215,9 +224,12 @@ async function writeExclusive(path: string, content: string, force: boolean) { export async function provider(args: string[], flags: Record) { if (flags.help || args.length === 0) { - console.log(PROVIDER_HELP); + console.log(PROVIDER_HELP + "\n" + PROVIDER_ONBOARDING_HELP); return; } + const onboardingCommand = ['spec-rules', 'import', 'submit', 'wait'].includes(args[0]); + const revisionAlias = ['update', 'review'].includes(args[0]) && flags.revision !== undefined; + if (onboardingCommand || revisionAlias) return providerOnboarding(args, flags); const cfg = getConfig(); requireApiKey(cfg); const apiKey = cfg.apiKey!; @@ -407,8 +419,13 @@ export async function provider(args: string[], flags: Record) { err(`unknown provider command: ${command}`, 'Run "xapi-to provider --help".'); } - output(result, flags.format as any); + output(redactProvider(result, [apiKey]), flags.format as any); } catch (error: any) { - err('provider request failed', error.message); + // Provider error bodies can echo submitted configuration or credentials. + // Preserve local errors, but never print an HTTP response body verbatim. + const message = error instanceof HttpError + ? `HTTP ${error.status}` + : String(redactProvider(error instanceof Error ? error.message : 'Unknown error', [apiKey])); + err('provider request failed', message); } } diff --git a/src/provider-client.ts b/src/provider-client.ts new file mode 100644 index 0000000..4133a8d --- /dev/null +++ b/src/provider-client.ts @@ -0,0 +1,80 @@ +import { request } from './client.ts'; +import { scheme, XAPI_API_HOST } from './config.ts'; + +export type ProviderObject = Record; + +export function providerRequest( + path: string, + apiKey: string | undefined, + method: 'GET' | 'POST' | 'PATCH' | 'PUT' = 'GET', + body?: ProviderObject, + timeoutMs = 30_000, + retries = method === 'GET' ? 2 : 0, +): Promise { + return request(`${scheme(XAPI_API_HOST)}://${XAPI_API_HOST}/api/api-services/agent/${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + ...(apiKey ? { 'XAPI-KEY': apiKey } : {}), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }, Math.min(timeoutMs, 30_000), retries); +} + +export function object(value: unknown): ProviderObject | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as ProviderObject : undefined; +} + +// Owner reads can contain upstream credentials, even when write responses are scrubbed. +const SECRET_FIELD = /^(authConfig|privateHeaders|authorization|proxy-authorization|api[-_]?key|xapi[-_]?key|x-api-key|access[-_]?token|refresh[-_]?token|client[-_]?secret|password|secret|token|cookie|set-cookie)$/i; + +// These fields contain API definitions, not credential maps. A property named +// "token" in a schema must retain its type/description and must not make the +// word "string" a secret everywhere else in the response. +const CONTRACT_FIELD = new Set([ + 'openApiSpec', 'bodySchema', 'schema', 'schemas', 'properties', + 'definitions', '$defs', 'params', 'pathParams', 'responses', 'securitySchemes', +]); + +function isContract(key: string, value: unknown, parent?: string): boolean { + if (CONTRACT_FIELD.has(key)) return true; + const obj = object(value); + if (typeof obj?.openapi === 'string') return true; + // Endpoint headers may be parameter definitions or literal header values. + return parent === 'headers' && !!obj && ('type' in obj || 'schema' in obj || '$ref' in obj); +} + +export function collectProviderSecrets(value: unknown): string[] { + const secrets: string[] = []; + function visit(item: unknown, sensitive = false, contract = false, parent?: string) { + if (typeof item === 'string' && sensitive && item) secrets.push(item); + else if (Array.isArray(item)) item.forEach(v => visit(v, sensitive, contract, parent)); + else if (object(item)) { + for (const [key, val] of Object.entries(item as ProviderObject)) { + const definition = !sensitive && (contract || isContract(key, val, parent)); + visit(val, sensitive || (!definition && SECRET_FIELD.test(key)), definition, key); + } + } + } + visit(value); + return secrets; +} + +export function redactProvider(value: unknown, knownSecrets: string[] = []): unknown { + const secrets = [...new Set([...knownSecrets, ...collectProviderSecrets(value)])] + .filter(Boolean).sort((a, b) => b.length - a.length); + function visit(item: unknown, contract = false, parent?: string): unknown { + if (typeof item === 'string') { + return secrets.reduce((text, secret) => text.split(secret).join('[REDACTED]'), item); + } + if (Array.isArray(item)) return item.map(val => visit(val, contract, parent)); + if (object(item)) return Object.fromEntries(Object.entries(item as ProviderObject) + .map(([key, val]) => { + const definition = contract || isContract(key, val, parent); + return [key, !definition && SECRET_FIELD.test(key) ? '[REDACTED]' : visit(val, definition, key)]; + })); + return item; + } + return visit(value); +} diff --git a/src/tests/provider-onboarding.test.ts b/src/tests/provider-onboarding.test.ts new file mode 100644 index 0000000..bc679ab --- /dev/null +++ b/src/tests/provider-onboarding.test.ts @@ -0,0 +1,296 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import * as config from '../config.ts'; +import * as format from '../format.ts'; +import { provider } from '../commands/provider.ts'; +import { providerRequest, redactProvider } from '../provider-client.ts'; + +const json = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status }); +const report = (state: string, review: unknown = null) => ({ revision: { id: 'rev-1', state }, review }); + +describe('provider lifecycle commands', () => { + let dir: string; + let fetchSpy: ReturnType; + let outputSpy: ReturnType; + let errSpy: ReturnType; + let configSpy: ReturnType; + let oldExit: typeof process.exitCode; + let oldRetry: string | undefined; + const calls: Array<{ url: string; method: string; headers: Headers; body: any; signal?: AbortSignal }> = []; + let respond: (call: typeof calls[number]) => Response | Promise; + const file = async (value: unknown, name = 'input.json') => { + const path = join(dir, name); + await writeFile(path, JSON.stringify(value)); + return path; + }; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'xapi-provider-')); + calls.length = 0; + oldExit = process.exitCode; + process.exitCode = 0; + oldRetry = process.env.XAPI_RETRY_BASE_MS; + process.env.XAPI_RETRY_BASE_MS = '1'; + respond = () => json({}); + configSpy = spyOn(config, 'getConfig').mockReturnValue({ actionHost: 'action.xapi.to', apiKey: 'sk-cli-secret' }); + outputSpy = spyOn(format, 'output').mockImplementation(() => {}); + errSpy = spyOn(format, 'err').mockImplementation((() => { throw new Error('cli error'); }) as any); + fetchSpy = spyOn(globalThis, 'fetch').mockImplementation((async (url: any, options: RequestInit) => { + const call = { url: String(url), method: options.method!, headers: new Headers(options.headers), + body: options.body ? JSON.parse(String(options.body)) : undefined, signal: options.signal ?? undefined }; + calls.push(call); + expect(options.redirect).toBe('manual'); + return respond(call); + }) as any); + }); + + afterEach(async () => { + fetchSpy.mockRestore(); outputSpy.mockRestore(); errSpy.mockRestore(); configSpy.mockRestore(); + process.exitCode = oldExit; + if (oldRetry === undefined) delete process.env.XAPI_RETRY_BASE_MS; + else process.env.XAPI_RETRY_BASE_MS = oldRetry; + await rm(dir, { recursive: true, force: true }); + }); + + it('completes import, configuration, submission, and polling on scoped management routes', async () => { + let polls = 0; + respond = call => { + expect(call.headers.get('XAPI-KEY')).toBe('sk-cli-secret'); + expect(call.url).toStartWith('https://api.xapi.to/api/api-services/agent/'); + if (call.url.endsWith('register-api-service')) return json({ success: true, + apiService: { id: 'svc-1', activeVersionId: 'rev-1', activeVersion: { id: 'rev-1', state: 'DRAFT' } }, validation: { warnings: [] } }, 201); + if (call.method === 'PATCH') return json({ id: 'rev-1', state: 'SANDBOX', ...call.body }); + if (call.url.endsWith('/submit')) return json({ state: 'IN_REVIEW', willReview: true }); + return json(report(++polls === 1 ? 'IN_REVIEW' : 'PUBLISHED')); + }; + const spec = { openapi: '3.0.3', info: { title: 'Example', version: '1.0' }, paths: {} }; + await provider(['import'], { file: await file(spec), 'private-headers-file': await file({ Authorization: 'Bearer upstream-secret' }, 'headers.json') }); + expect(calls[0].body).toEqual({ openApiSpec: spec, privateHeaders: { Authorization: 'Bearer upstream-secret' } }); + expect(outputSpy.mock.calls[0][0]).toMatchObject({ serviceId: 'svc-1', revisionId: 'rev-1', state: 'DRAFT' }); + await provider(['update', 'svc-1'], { revision: 'rev-1', file: await file({ privateHeaders: { Authorization: 'Bearer upstream-secret' } }) }); + expect(calls[1].method).toBe('PATCH'); + expect(calls[1].url).toEndWith('/services/svc-1/versions/rev-1'); + expect(JSON.stringify(outputSpy.mock.calls)).not.toContain('upstream-secret'); + await provider(['submit', 'svc-1'], { revision: 'rev-1', changelog: 'Initial release' }); + expect(calls[2].body).toEqual({ changelog: 'Initial release' }); + await provider(['wait', 'svc-1'], { revision: 'rev-1', interval: '1ms', timeout: '1s' }); + expect(outputSpy.mock.calls.at(-1)?.[0]).toMatchObject({ success: true, reason: 'published' }); + expect(process.exitCode).toBe(0); + }); + + it('treats HTTP 201 application validation failures as failures with structured diagnostics', async () => { + respond = () => json({ success: false, validation: { errors: [{ field: 'openapi', message: 'Must be 3.0.3' }] } }, 201); + await provider(['import'], { file: await file({ openapi: '2.0' }) }); + expect(process.exitCode).toBe(1); + expect(outputSpy.mock.calls[0][0]).toMatchObject({ success: false, validation: { errors: [{ field: 'openapi', message: 'Must be 3.0.3' }] } }); + expect(calls.length).toBe(1); + }); + + it('rejects malformed and non-object files without exposing input or making requests', async () => { + const path = join(dir, 'bad.json'); + await writeFile(path, '{"privateHeaders": "upstream-secret"'); + await expect(provider(['import'], { file: path })).rejects.toThrow('cli error'); + expect(JSON.stringify(errSpy.mock.calls)).not.toContain('upstream-secret'); + await expect(provider(['import'], { file: await file([]) })).rejects.toThrow('cli error'); + expect(calls).toHaveLength(0); + }); + + it('guards endpoint merges while allowing explicit additions and full replacements', async () => { + respond = () => json({ state: 'SANDBOX' }); + const path = await file({ endpoints: [{ name: 'search', method: 'GET', path: '/search' }] }); + await expect(provider(['update', 'svc'], { file: path, revision: 'rev-1' })).rejects.toThrow('cli error'); + expect(calls).toHaveLength(0); + await provider(['update', 'svc'], { file: path, revision: 'rev-1', mode: 'replace' }); + expect(calls[0].method).toBe('PUT'); + await provider(['update', 'svc'], { file: path, revision: 'rev-1', 'allow-new-endpoints': 'true' }); + expect(calls[1].method).toBe('PATCH'); + await provider(['update', 'svc'], { file: await file({ endpoints: [{ id: 'ep-1', costPerCall: '0.002' }] }), revision: 'rev-1' }); + expect(calls[2].body.endpoints[0].id).toBe('ep-1'); + }); + + it('gets owner configuration and overview with credentials redacted', async () => { + respond = call => call.url.endsWith('version-overview') ? json({ currentMajor: 1, majors: [] }) + : json({ id: 'svc', activeVersion: { id: 'rev-1', version: 'v1.0' }, + currentVersion: { id: 'rev-1', authConfig: 'raw-secret', privateHeaders: { Custom: 'custom-secret' }, + endpoints: [{ id: 'ep-1', bodySchema: { type: 'object', properties: { token: { type: 'string' }, name: { type: 'string' } } } }] }, + description: 'raw-secret custom-secret' }); + await provider(['get', 'svc'], { version: 'v1.0' }); + expect(calls[0].url).toEndWith('/services/svc?version=v1.0'); + expect(calls).toHaveLength(1); + expect(outputSpy.mock.calls[0][0]).toMatchObject({ id: 'svc', currentVersion: { id: 'rev-1' } }); + expect(JSON.stringify(outputSpy.mock.calls)).not.toContain('raw-secret'); + expect(JSON.stringify(outputSpy.mock.calls)).not.toContain('custom-secret'); + expect(outputSpy.mock.calls[0][0].currentVersion.endpoints).toEqual([ + { id: 'ep-1', bodySchema: { type: 'object', properties: { token: { type: 'string' }, name: { type: 'string' } } } }, + ]); + }); + + it('uses public rules without credentials and owner list with scoped key', async () => { + await provider(['spec-rules'], {}); + expect(calls[0].headers.has('XAPI-KEY')).toBe(false); + await provider(['list'], {}); + expect(calls[1].url).toEndWith('/agent/services'); + expect(calls[1].headers.get('XAPI-KEY')).toBe('sk-cli-secret'); + }); + + it('never retries import, update, or submit, and does not print server-echoed secrets', async () => { + respond = () => new Response('upstream-secret sk-cli-secret truncated-upstream', { status: 503 }); + const path = await file({}); + for (const [args, flags] of [ + [['import'], { file: path }], + [['update', 'svc'], { revision: 'rev-1', file: path }], + [['submit', 'svc'], { revision: 'rev-1' }], + ] as [string[], Record][]) { + await expect(provider(args, flags)).rejects.toThrow('cli error'); + } + expect(calls).toHaveLength(3); + expect(JSON.stringify(errSpy.mock.calls)).not.toContain('secret'); + expect(errSpy.mock.calls[0][1]).toContain('No automatic retry'); + }); + + it('does not print server-echoed secrets from existing provider commands', async () => { + respond = () => new Response('sk-cli-secret upstream-private-value', { status: 400 }); + await expect(provider(['create'], { file: await file({ + name: 'Example', + privateHeaders: { Authorization: 'upstream-private-value' }, + }) })).rejects.toThrow('cli error'); + expect(calls).toHaveLength(1); + expect(errSpy).toHaveBeenCalledWith('provider request failed', 'HTTP 400'); + expect(JSON.stringify(errSpy.mock.calls)).not.toContain('upstream-private-value'); + expect(JSON.stringify(errSpy.mock.calls)).not.toContain('sk-cli-secret'); + }); + + it('does not expose malformed JSON fragments from existing provider commands', async () => { + const path = join(dir, 'legacy-bad.json'); + await writeFile(path, '{"privateHeaders":{"Authorization":"upstream-private-value"}'); + await expect(provider(['create'], { file: path })).rejects.toThrow('cli error'); + expect(calls).toHaveLength(0); + expect(errSpy).toHaveBeenCalledWith('provider request failed', 'cli error'); + expect(JSON.stringify(errSpy.mock.calls)).not.toContain('upstream-private-value'); + }); + + it('reports scope requirements on 403 and fails immediately', async () => { + respond = () => new Response('private error body', { status: 403 }); + await expect(provider(['submit', 'svc'], { revision: 'rev-1' })).rejects.toThrow('cli error'); + expect(errSpy.mock.calls[0][1]).toContain('service:publish'); + expect(calls).toHaveLength(1); + }); + + it('explains the backend immutable-revision conflict without echoing server input', async () => { + respond = () => json({ code: 'REVISION_NOT_EDITABLE', message: 'private-secret', state: 'PUBLISHED' }, 409); + await expect(provider(['update', 'svc'], { revision: 'rev-1', file: await file({ description: 'changed' }) })).rejects.toThrow('cli error'); + expect(errSpy.mock.calls[0][1]).toContain('Only DRAFT or SANDBOX'); + expect(errSpy.mock.calls[0][1]).not.toContain('private-secret'); + expect(calls).toHaveLength(1); + }); + + it('does not accept approval as publication, and waits through approved IN_REVIEW', async () => { + respond = () => json(report(calls.length === 1 ? 'IN_REVIEW' : 'PUBLISHED', { status: 'APPROVED', outcome: 'passed' })); + await provider(['wait', 'svc'], { revision: 'rev-1', interval: '1ms' }); + expect(calls).toHaveLength(2); + expect(outputSpy.mock.calls[0][0]).toMatchObject({ success: true }); + }); + + for (const [state, review, reason] of [ + ['SANDBOX', { status: 'AUTO_FAILED', outcome: 'rejected' }, 'rejected'], + ['IN_REVIEW', { status: 'PENDING_HUMAN', outcome: 'pending_human' }, 'manual_review_required'], + ['DRAFT', null, 'not_published'], ['SUSPENDED', null, 'not_published'], + ] as const) { + it(`returns nonzero for ${state}/${reason}`, async () => { + respond = () => json(report(state, review)); + await provider(['wait', 'svc'], { revision: 'rev-1' }); + expect(process.exitCode).toBe(1); + expect(outputSpy.mock.calls[0][0]).toMatchObject({ success: false, reason }); + expect(calls).toHaveLength(1); + }); + } + + it('recovers from transient poll errors within the attempt cap', async () => { + respond = () => calls.length === 1 ? new Response('busy', { status: 503 }) : json(report('PUBLISHED')); + await provider(['wait', 'svc'], { revision: 'rev-1', interval: '1ms', 'max-attempts': '2' }); + expect(calls).toHaveLength(2); + expect(process.exitCode).toBe(0); + }); + + it('caps transient errors and honors Retry-After without exceeding the deadline', async () => { + respond = () => new Response('busy', { status: 429, headers: { 'Retry-After': '120' } }); + const start = Date.now(); + await provider(['wait', 'svc'], { revision: 'rev-1', interval: '1ms', timeout: '30ms' }); + expect(Date.now() - start).toBeLessThan(1000); + expect(calls).toHaveLength(1); + expect(outputSpy.mock.calls[0][0]).toMatchObject({ reason: 'timeout', success: false }); + calls.length = 0; + await provider(['wait', 'svc'], { revision: 'rev-1', 'max-attempts': '1' }); + expect(calls).toHaveLength(1); + expect(outputSpy.mock.calls.at(-1)?.[0]).toMatchObject({ reason: 'max_attempts' }); + }); + + it('aborts an in-flight poll at the overall timeout', async () => { + respond = call => new Promise((_, reject) => call.signal!.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')))); + const start = Date.now(); + await provider(['wait', 'svc'], { revision: 'rev-1', timeout: '30ms' }); + expect(Date.now() - start).toBeLessThan(1000); + expect(calls).toHaveLength(1); + expect(outputSpy.mock.calls[0][0]).toMatchObject({ reason: 'timeout' }); + }); + + it('rejects empty, mismatched, and unknown-state review responses', async () => { + for (const value of [null, { revision: { id: 'different', state: 'PUBLISHED' } }, report('NEW_STATE')]) { + respond = () => value === null ? new Response(null, { status: 204 }) : json(value); + await expect(provider(['wait', 'svc'], { revision: 'rev-1' })).rejects.toThrow('cli error'); + } + expect(calls).toHaveLength(3); + }); + + it('validates command flags and IDs before HTTP requests', async () => { + for (const [args, flags] of [ + [['wait', 'svc'], { revision: 'rev-1', timeout: '0s' }], + [['wait', 'svc'], { revision: 'rev-1', interval: 'true' }], + [['wait', 'svc'], { revision: 'rev-1', 'max-attempts': '0' }], + [['submit', 'svc'], {}], [['wait', '..'], { revision: 'rev-1' }], + [['submit', ' '], { revision: 'rev-1' }], + [['submit', 'svc'], { revision: 'rev-1', changelog: 'x'.repeat(2001) }], + [['import'], { file: 'true' }], [['update', 'svc'], { revision: 'rev-1', mode: 'typo' }], + ] as [string[], Record][]) await expect(provider(args, flags)).rejects.toThrow('cli error'); + expect(calls).toHaveLength(0); + }); + + it('refuses redirects and does not forward credentials', async () => { + respond = () => new Response(null, { status: 302, headers: { location: 'https://evil.example' } }); + await expect(providerRequest('services', 'key')).rejects.toThrow('refusing to follow redirect'); + expect(calls).toHaveLength(1); + }); + + it('redacts nested secret fields and known credential echoes', () => { + const value = redactProvider({ versions: [{ privateHeaders: { 'X-Custom': 'value-secret' }, authConfig: 'cipher-secret' }], + message: 'value-secret cipher-secret sk-cli-secret', headers: { Authorization: 'Bearer abc' } }, ['sk-cli-secret']); + expect(JSON.stringify(value)).not.toContain('secret'); + expect(JSON.stringify(value)).not.toContain('Bearer abc'); + }); + + it('preserves token/password/auth schema definitions and unrelated types', () => { + const properties = { token: { type: 'string', description: 'Authentication token' }, password: { type: 'string' }, authConfig: { type: 'object' } }; + const definitions = { + bodySchema: { type: 'object', properties }, + params: { token: { type: 'string' } }, + pathParams: { secret: { type: 'string' } }, + responses: [{ status: 200, schema: { type: 'object', properties } }], + headers: { Authorization: { type: 'string', description: 'Caller authorization' } }, + openApiSpec: { openapi: '3.0.3', components: { schemas: { Credentials: { type: 'object', properties } } } }, + description: 'string object Authentication token', + }; + expect(redactProvider(definitions)).toEqual(definitions); + }); + + it('still redacts real credentials and their echoes inside preserved schemas', () => { + const value = redactProvider({ + privateHeaders: { 'X-Custom': 'actual-upstream-key' }, + headers: { Authorization: 'Bearer another-key' }, + bodySchema: { properties: { token: { type: 'string', example: 'actual-upstream-key' } } }, + }); + expect(value).toEqual({ privateHeaders: '[REDACTED]', headers: { Authorization: '[REDACTED]' }, + bodySchema: { properties: { token: { type: 'string', example: '[REDACTED]' } } } }); + }); +}); From 101ac720a2f852d3b05c3e8a186273f1c146d994 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 23:22:16 +0800 Subject: [PATCH 03/28] chore(main): release 0.1.22 (#13) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 16 ++++++++++++++++ package.json | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 5520dc3..2f62a69 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.1.21" + ".": "0.1.22" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 16d8205..ba1adc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [0.1.22](https://github.com/xapi-labs/xapi-cli/compare/v0.1.21...v0.1.22) (2026-09-17) + + +### Features + +* **provider:** import API contracts and wait for publication ([#14](https://github.com/xapi-labs/xapi-cli/issues/14)) ([0b17d43](https://github.com/xapi-labs/xapi-cli/commit/0b17d43b5f24536f6a4b50d29de65a4045b81369)) +* **provider:** manage per-user service rate limits ([49c974b](https://github.com/xapi-labs/xapi-cli/commit/49c974b82d42decb6fb55c7034f8cd562b2c9d03)) +* **skill:** add domain and Web3 service guides ([86de0d1](https://github.com/xapi-labs/xapi-cli/commit/86de0d11f89a4a9d52fa1d407541150d71addefe)) +* **skill:** document domains and GPT Live ([7466f4d](https://github.com/xapi-labs/xapi-cli/commit/7466f4db1ce8ea8172f85a4ce286e151aa0688c8)) + + +### Bug Fixes + +* **oauth:** enforce hard polling deadlines ([86e6828](https://github.com/xapi-labs/xapi-cli/commit/86e6828c411df85acbb6ad471951d38b31693590)) +* **skill:** harden live service guidance ([137e8ab](https://github.com/xapi-labs/xapi-cli/commit/137e8ab7b32febe176e2f619d6c472655bbcb244)) + ## [0.1.21](https://github.com/xapi-labs/xapi-cli/compare/v0.1.20...v0.1.21) (2026-08-28) diff --git a/package.json b/package.json index 4d24a4d..c750658 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "xapi-to", - "version": "0.1.21", + "version": "0.1.22", "description": "Agent-friendly CLI for xapi - discover and call capabilities and APIs", "type": "module", "bin": { From e0029701152c9ad73277bef9e0750284e03c884d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E9=9B=84=E5=91=80?= <47734376+dxiongya@users.noreply.github.com> Date: Sun, 20 Sep 2026 12:38:09 +0800 Subject: [PATCH 04/28] feat(workers): add managed project deployment workflows --- README.md | 370 +++- bun.lock | 23 +- package.json | 9 +- schemas/worker-project.v1.schema.json | 179 ++ skills/xapi-workers/SKILL.md | 31 + skills/xapi-workers/agents/openai.yaml | 4 + skills/xapi-workers/references/billing.md | 50 + skills/xapi-workers/references/deployment.md | 80 + skills/xapi-workers/references/domains.md | 81 + skills/xapi-workers/references/lifecycle.md | 44 + skills/xapi-workers/references/resources.md | 50 + skills/xapi/SKILL.md | 40 +- skills/xapi/guides/workers.md | 639 +++++++ src/commands/workers.ts | 1636 +++++++++++++++++ src/index.ts | 10 + src/tests/skill-workers-guide.test.ts | 104 ++ src/tests/workers-artifact.test.ts | 147 ++ src/tests/workers-billing-ledger.test.ts | 59 + src/tests/workers-billing-output.test.ts | 270 +++ src/tests/workers-client.test.ts | 392 ++++ src/tests/workers-deployment-state.test.ts | 79 + src/tests/workers-domain-bind.test.ts | 155 ++ src/tests/workers-help.test.ts | 27 + src/tests/workers-init.test.ts | 372 ++++ src/tests/workers-logs.test.ts | 190 ++ src/tests/workers-metering-output.test.ts | 26 + src/tests/workers-native-bundle.test.ts | 57 + src/tests/workers-plan-output.test.ts | 138 ++ src/tests/workers-plan.test.ts | 465 +++++ src/tests/workers-project-resources.test.ts | 444 +++++ src/tests/workers-project.test.ts | 276 +++ src/tests/workers-promote.test.ts | 397 ++++ src/tests/workers-push-output.test.ts | 82 + src/tests/workers-push.test.ts | 693 +++++++ src/tests/workers-rollback.test.ts | 254 +++ src/tests/workers-wrangler-import.test.ts | 244 +++ src/workers-artifact.ts | 616 +++++++ src/workers-billing-ledger.ts | 53 + src/workers-billing-output.ts | 320 ++++ src/workers-client.ts | 626 +++++++ src/workers-deployment-state.ts | 48 + src/workers-domain-bind.ts | 212 +++ src/workers-framework-init.ts | 350 ++++ src/workers-init.ts | 343 ++++ src/workers-logs.ts | 293 +++ src/workers-metering-output.ts | 42 + src/workers-plan-output.ts | 274 +++ src/workers-plan.ts | 780 ++++++++ src/workers-project-resources.ts | 529 ++++++ src/workers-project.ts | 355 ++++ src/workers-promote.ts | 528 ++++++ src/workers-push-output.ts | 63 + src/workers-push.ts | 1021 ++++++++++ src/workers-resource-state.ts | 103 ++ src/workers-rollback.ts | 412 +++++ src/workers-templates.ts | 332 ++++ src/workers-wrangler-import.ts | 869 +++++++++ templates/agent/files/src/index.ts | 39 + templates/agent/template.json | 14 + templates/chat/files/src/index.ts | 40 + templates/chat/template.json | 14 + templates/persistent-agent/files/TEMPLATE.md | 41 + .../files/migrations/0001_init.sql | 9 + .../persistent-agent/files/scripts/smoke.mjs | 35 + templates/persistent-agent/files/src/index.ts | 610 ++++++ templates/persistent-agent/template.json | 26 + templates/webhook/files/src/index.ts | 16 + templates/webhook/template.json | 14 + templates/worker/files/src/index.ts | 16 + templates/worker/template.json | 14 + 70 files changed, 17119 insertions(+), 55 deletions(-) create mode 100644 schemas/worker-project.v1.schema.json create mode 100644 skills/xapi-workers/SKILL.md create mode 100644 skills/xapi-workers/agents/openai.yaml create mode 100644 skills/xapi-workers/references/billing.md create mode 100644 skills/xapi-workers/references/deployment.md create mode 100644 skills/xapi-workers/references/domains.md create mode 100644 skills/xapi-workers/references/lifecycle.md create mode 100644 skills/xapi-workers/references/resources.md create mode 100644 skills/xapi/guides/workers.md create mode 100644 src/commands/workers.ts create mode 100644 src/tests/skill-workers-guide.test.ts create mode 100644 src/tests/workers-artifact.test.ts create mode 100644 src/tests/workers-billing-ledger.test.ts create mode 100644 src/tests/workers-billing-output.test.ts create mode 100644 src/tests/workers-client.test.ts create mode 100644 src/tests/workers-deployment-state.test.ts create mode 100644 src/tests/workers-domain-bind.test.ts create mode 100644 src/tests/workers-help.test.ts create mode 100644 src/tests/workers-init.test.ts create mode 100644 src/tests/workers-logs.test.ts create mode 100644 src/tests/workers-metering-output.test.ts create mode 100644 src/tests/workers-native-bundle.test.ts create mode 100644 src/tests/workers-plan-output.test.ts create mode 100644 src/tests/workers-plan.test.ts create mode 100644 src/tests/workers-project-resources.test.ts create mode 100644 src/tests/workers-project.test.ts create mode 100644 src/tests/workers-promote.test.ts create mode 100644 src/tests/workers-push-output.test.ts create mode 100644 src/tests/workers-push.test.ts create mode 100644 src/tests/workers-rollback.test.ts create mode 100644 src/tests/workers-wrangler-import.test.ts create mode 100644 src/workers-artifact.ts create mode 100644 src/workers-billing-ledger.ts create mode 100644 src/workers-billing-output.ts create mode 100644 src/workers-client.ts create mode 100644 src/workers-deployment-state.ts create mode 100644 src/workers-domain-bind.ts create mode 100644 src/workers-framework-init.ts create mode 100644 src/workers-init.ts create mode 100644 src/workers-logs.ts create mode 100644 src/workers-metering-output.ts create mode 100644 src/workers-plan-output.ts create mode 100644 src/workers-plan.ts create mode 100644 src/workers-project-resources.ts create mode 100644 src/workers-project.ts create mode 100644 src/workers-promote.ts create mode 100644 src/workers-push-output.ts create mode 100644 src/workers-push.ts create mode 100644 src/workers-resource-state.ts create mode 100644 src/workers-rollback.ts create mode 100644 src/workers-templates.ts create mode 100644 src/workers-wrangler-import.ts create mode 100644 templates/agent/files/src/index.ts create mode 100644 templates/agent/template.json create mode 100644 templates/chat/files/src/index.ts create mode 100644 templates/chat/template.json create mode 100644 templates/persistent-agent/files/TEMPLATE.md create mode 100644 templates/persistent-agent/files/migrations/0001_init.sql create mode 100644 templates/persistent-agent/files/scripts/smoke.mjs create mode 100644 templates/persistent-agent/files/src/index.ts create mode 100644 templates/persistent-agent/template.json create mode 100644 templates/webhook/files/src/index.ts create mode 100644 templates/webhook/template.json create mode 100644 templates/worker/files/src/index.ts create mode 100644 templates/worker/template.json diff --git a/README.md b/README.md index b29b4fb..d4039d2 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,12 @@ AI services through this CLI. Then just ask — "what's the price of BTC" — and it takes it from there. Set up a key first; see [Quick Start](#quick-start). +Workers projects can also install the standalone [`xapi-workers` skill](skills/xapi-workers/SKILL.md), covering deployment, all six managed resource types, complete consumption queries and cleanup: + +```bash +npx skills add xapi-labs/xapi-cli --skill xapi-workers +``` + ## Quick Start ```bash @@ -422,6 +428,296 @@ XAPI_MODEL=deepseek-v4-pro \ npm run example:sandbox:openai ``` +### Hosted Workers Commands + +Inspect delayed storage collection separately from the financial ledger: + +```bash +xapi-to workers metering --env preview +xapi-to workers metering --env preview --json +``` + +Human output lists each resource's UTC collection window, source status, sample +count, last sample and retry times. Missing samples are not zero usage; observed +samples are not final settlement. An empty or truncated list does not prove +complete history. This source read is independent of the billing snapshot. + +`workers` manages continuously addressable JavaScript applications on +xAPI-hosted Cloudflare Workers for Platforms. +It is separate from `sandbox`: use Sandbox for arbitrary shell/build/GPU work, +and Workers for HTTP, WebSocket, Webhook, Cron, and persistent Agent entrypoints. + +```bash +# New project: build, create/update resources, deploy preview, then promote the +# exact tested Artifact to production. +xapi workers templates +xapi workers init my-agent --template persistent-agent +cd my-agent +# Review the plan before resources are created. +xapi workers plan --env preview +xapi workers push --env preview +xapi workers promote --to production + +# Existing Cloudflare Worker: Wrangler remains the source of runtime config. +cd existing-worker +xapi workers init --from-wrangler ./wrangler.jsonc +xapi workers plan --env preview +xapi workers push --env preview + +# Code rollback never rolls back KV/D1/R2/DO/Queue/Workflow data or Secrets. +xapi workers rollback --env production --to previous + +# Follow Tail Worker logs and correlate one request or deployment. +xapi workers logs --env production --tail --since 10m +xapi workers logs --env production --request-id +``` + +Choose `init` based on the starting point: + +| Starting point | Command | +| --- | --- | +| New Worker | `xapi workers init my-agent --template persistent-agent` | +| Existing React, Vite, Vue, or static Next.js package | `cd app && xapi workers init` | +| Existing Worker with Wrangler | `xapi workers init --from-wrangler ./wrangler.jsonc` | +| Next.js SSR | Initialize vinext first, then import its generated Wrangler config | + +Existing browser applications can be initialized in place. Detection reads +`package.json` and preserves the application's existing `dev`, `build`, and +test scripts: + +```bash +cd existing-web-app +xapi workers init +npm install +npm run xapi:build +xapi workers plan --env preview +xapi workers push --env preview +``` + +The initializer adds `xapi:build`, `xapi:worker:build`, and +`xapi:worker:dev`, plus a small `xapi-worker/index.ts`, `wrangler.jsonc`, and +`xapi.worker.json`. `xapi:worker:dev` is only a package script around Wrangler; +there is no separate xAPI local runtime. Use `--framework react|vite|vue|next` +only when automatic package detection is ambiguous. `init` is a one-time +adapter setup, not a synchronization command; after it creates +`xapi.worker.json`, use resource commands and `plan` to manage state. + +Next.js with `output: 'export'` is treated as static assets. SSR Next.js must +first create a Workers-compatible bundle with vinext (`npx vinext check`, then +`npx vinext init`) and import its generated Wrangler configuration. The CLI +refuses to misclassify an SSR application as a static SPA. + +Web projects can declare their browser build separately from Worker modules. +The CLI preserves supported Wrangler `assets` settings and uploads the files +through xAPI as Cloudflare native static assets: + +```json +{ + "assets": { + "directory": "dist/client", + "binding": "ASSETS", + "notFoundHandling": "single-page-application", + "runWorkerFirst": ["/api/*"] + } +} +``` + +`workers plan` shows whether the selected environment has a dedicated hostname. +When `webAppReady` is false, production promotion asks you to review the base +path, root-relative routes, and OAuth callbacks without blocking applications +that deliberately support path-prefix hosting. The current JSON Artifact +transport accepts 12 MiB of decoded Worker modules and static assets per +deployment. + +Templates are versioned packages shipped with the CLI, not remote code fetched +during `init`. `persistent-agent` includes buildable source plus KV, D1, R2, +Durable Object, Queue, and Workflow declarations. `push` provisions the +environment-specific resources and returns their binding state; secret values +remain a separate operation: + +```bash +export APP_TOKEN='replace-with-an-incoming-request-token' +export MODEL_KEY='replace-with-an-ai.xapi.to-key' +xapi workers secrets set APP_TOKEN --env preview --from-env APP_TOKEN +xapi workers secrets set MODEL_KEY --env preview --from-env MODEL_KEY +``` + +The project workflow works without Git. `xapi.worker.json` may be committed, +but Secret values must stay in environment variables or the encrypted Secret +store. `push` never silently deletes extra stateful resources or Secrets. + +For project-managed resources, `xapi.worker.json` is the Git-tracked desired +state and xAPI is live state. `plan` always fetches live state; the CLI keeps no +third cached copy. + +| Intent | Project command | Effect | +| --- | --- | --- | +| Declare a new resource | `resources add` | Adds desired state only. | +| Adjust an existing declaration | `resources update` | Replaces the complete declaration locally; `plan` decides whether live state can follow. | +| Adopt live-only resources | `resources pull` | Reads live state and merges portable fields locally. | +| Stop declaring a resource | `resources remove` | Removes desired state only; the live resource remains billable. | +| Delete resource data | `resources destroy --yes` | Removes the one-environment declaration and requests live deletion. | +| Reconcile | `workers plan` | Reads and compares current live state without mutation. | + +Add desired resources before applying them: + +```bash +xapi workers resources add --env both --type kv --binding CACHE +xapi workers resources add --env both --type d1 --binding DB \ + --location apac --read-replication auto +xapi workers resources add --env both --type r2 --binding FILES --location apac +xapi workers resources add --env both --type do --binding ROOM --class-name Room +xapi workers resources add --env both --type queue --binding JOBS +xapi workers resources add --env both --type workflow --binding PIPELINE + +# Replace the complete desired declaration before it has been provisioned. +xapi workers resources update --env preview --type d1 --binding DB \ + --location weur --read-replication disabled + +xapi workers plan --env preview +xapi workers push --env preview +``` + +`resources add` changes only `xapi.worker.json`; `plan` shows the resulting +provider operations and `push` applies them. Repeating an identical add is a +no-op, while reusing a binding for another type is rejected. `--env both` +declares the same binding independently for preview and production; it does not +make both environments share one physical resource. + +`push` creates missing preview resources only after its full plan passes. +`promote` performs the same production preflight and, after confirmation, +creates missing production declarations before activating the exact tested +preview Artifact. A budget mismatch, missing Secret, incompatible binding, or +undeclared production resource blocks the command before any resource or +deployment write. If creation requires an accepted freeze quote, pass its exact +version with `--retention-price-version`. + +`resources update` requires the resource type because it replaces the complete +portable declaration. Before the project is linked, it can correct any local +declaration. After linking, it rejects type and Durable Object class changes. +Changing a live location or D1 replication mode is +reported as `BLOCKED`: the current xAPI API has no in-place resource update, so +create a new binding, migrate data, and switch the application explicitly. + +Adopt supported live resources that are missing locally with an explicit pull: + +```bash +xapi workers plan --env preview +xapi workers resources pull --env preview +git diff -- xapi.worker.json +xapi workers plan --env preview +``` + +`pull` performs an additive, all-or-nothing merge. It preserves local-only +declarations, writes no provider IDs, deletes nothing, and rejects unhealthy, +unsupported, duplicate, or conflicting remote bindings. `--env both` reads and +merges preview and production independently. + +Use `resources remove --env ... --binding ...` only when the live resource must +remain. `plan` then marks it `MANUAL`, and `resources pull` can adopt it again. +To delete data, back it up first and run: + +```bash +xapi workers resources destroy --env preview --binding FILES --yes +``` + +`destroy` accepts one environment at a time, removes the local declaration +before requesting deletion, and reports deletion as requested until the live +resource disappears. If the request fails, the live resource remains visible +and `resources pull` restores the declaration. `resources list/create/delete + ...` remain low-level recovery primitives and do not update project +files. + +Deployment identity includes the code Artifact, remote resource identities, +Secret versions, environment bindings and compatibility settings. Changing only +resources or compatibility settings therefore deploys again; repeating an +unchanged push reuses the current activation. Older deployments without this +configuration fingerprint require one deployment to establish the baseline. + +Removing a resource from `xapi.worker.json` does **not** destroy it: `plan` +reports `MANUAL`, and the resource remains billable. `resources destroy` is the +project-aware destructive operation. Preserve a backup before using it and wait +until `resources list` no longer returns the binding. A successful deployment +alone is not proof of deletion or final billing settlement. + +In CI, +set `XAPI_KEY` and `XAPI_API_HOST` explicitly, use a Key restricted to the target +Worker, and pass `--non-interactive`; safety preflights are still enforced: + +```bash +export XAPI_API_HOST=test.xapi.to +export XAPI_KEY="$CI_XAPI_KEY" +xapi workers plan --env preview --format json +xapi workers push --env preview --non-interactive +xapi workers promote --to production --non-interactive +``` + +The lower-level commands remain available for diagnosis and custom automation: + +```bash +# API keys need workers:read / workers:write scopes. +xapi-to workers provider-status +xapi-to workers capabilities --format table +xapi-to workers bindings --format table + +# Both environment budgets are explicit ($0.10-$100/day). +xapi-to workers create \ + --name "Daily research agent" \ + --slug daily-research-agent \ + --template agent \ + --preview-budget 0.25 \ + --production-budget 2 + +# Upload one bundled ES module, or use --file dist/ --main worker.js for code splitting. +xapi-to workers upload \ + --file dist/worker.mjs \ + --idempotency-key artifact-v1 + +xapi-to workers deploy \ + --artifact \ + --env preview \ + --idempotency-key preview-v1 + +# Secrets are encrypted at rest; prefer reading them from a local env variable. +MODEL_KEY='...' xapi-to workers secrets set MODEL_KEY \ + --env preview --from-env MODEL_KEY +xapi-to workers resources list --env preview --format table +xapi-to workers secrets list --env preview --format table + +# Persistent schedules remain Worker-scoped rather than project resource declarations. +# Queue includes an xAPI-managed consumer. Send a local route envelope from +# Worker code; delivery is at least once, so make /tasks/run idempotent: +# await env.TASK_QUEUE.send({ +# path: '/tasks/run', method: 'POST', body: { taskId: 'task_123' } +# }); +xapi-to workers schedules create \ + --name heartbeat --cron "*/15 * * * *" --timezone UTC \ + --env preview --path /cron --method POST + +# Optional: build source in an ephemeral xAPI Sandbox. A successful result +# contains artifactId, which is deployed exactly like an upload. +xapi-to workers build \ + --project . \ + --entrypoint src/index.ts \ + --command "npm install --ignore-scripts && npm run build" \ + --output dist/worker.mjs \ + --idempotency-key build-v1 + +xapi-to workers get --format pretty +xapi-to workers audit --format table +xapi-to workers invocations --env production --format table +xapi-to workers logs --env production --format table +xapi-to workers usage --env production --format pretty +xapi-to workers billing-status --format pretty +xapi-to workers domains list --format table +xapi-to workers budget production --daily-usd 3 +xapi-to workers delete --yes +``` + +Set `XAPI_API_HOST=test.xapi.to` for the test control plane. Mutating requests +are not retried automatically; when a deployment result is uncertain, inspect +the Worker and retry with the same idempotency key. + ### OAuth Bind third-party OAuth accounts (e.g. Twitter) to your API key. @@ -544,17 +840,17 @@ xapi-to list --format table # human-readable table ## Environment Variables -| Variable | Description | -|---|---| -| `XAPI_KEY` | API key (overrides config file) | -| `XAPI_API_KEY` | Compatible API key alias (overrides config file; lower priority than `XAPI_KEY`) | -| `XAPI_SANDBOX_KEY` | Sandbox-only credential for OpenAI SandboxAgent examples/tests | -| `XAPI_AI_KEY` | AI Gateway credential for OpenAI-compatible model calls | -| `XAPI_ACTION_HOST` | Action service host (default: `action.xapi.to`) | -| `XAPI_API_HOST` | Auth/account service host (default: `api.xapi.to`) | -| `XAPI_SANDBOX_HOST` | Sandbox gateway host (default: `sandbox.xapi.to`) | -| `XAPI_OUTPUT` | Default output format (`json`\|`pretty`\|`table`) | -| `XAPI_TRANSFER_IDLE_TIMEOUT_MS` | SSE/download idle timeout in milliseconds (default: `60000`) | +| Variable | Description | +| ------------------------------- | -------------------------------------------------------------------------------- | +| `XAPI_KEY` | API key (overrides config file) | +| `XAPI_API_KEY` | Compatible API key alias (overrides config file; lower priority than `XAPI_KEY`) | +| `XAPI_SANDBOX_KEY` | Sandbox-only credential for OpenAI SandboxAgent examples/tests | +| `XAPI_AI_KEY` | AI Gateway credential for OpenAI-compatible model calls | +| `XAPI_ACTION_HOST` | Action service host (default: `action.xapi.to`) | +| `XAPI_API_HOST` | Auth/account service host (default: `api.xapi.to`) | +| `XAPI_SANDBOX_HOST` | Sandbox gateway host (default: `sandbox.xapi.to`) | +| `XAPI_OUTPUT` | Default output format (`json`\|`pretty`\|`table`) | +| `XAPI_TRANSFER_IDLE_TIMEOUT_MS` | SSE/download idle timeout in milliseconds (default: `60000`) | Config is stored at `~/.xapi/config.json`. @@ -564,28 +860,28 @@ This is a small quick-reference subset, not the complete or permanently fixed catalog. Use `xapi-to list --source capability`, `search`, and `get` for the current IDs and schemas. -| ID | Description | -|---|---| -| `twitter.tweet_detail` | Get tweet details and replies | -| `twitter.user_by_screen_name` | Get user profile by username | -| `twitter.user_tweets` | Get tweets from a user | -| `twitter.user_tweets_and_replies` | Get tweets and replies from a user | -| `twitter.user_media` | Get media posts from a user | -| `twitter.following` | Get user following list | -| `twitter.followers` | Get user followers | -| `twitter.retweeters` | Get tweet retweeters | -| `twitter.search` | Search tweets | -| `ai.text.chat.fast` | Fast AI chat completion | -| `ai.text.chat.reasoning` | Advanced reasoning chat | -| `ai.text.chat.auto` | Model-selected chat with provider fallback | -| `ai.text.summarize` | Summarize long text | -| `ai.text.rewrite` | Rewrite text with different styles | -| `ai.embedding.generate` | Generate vector embeddings | -| `web.search` | Web search | -| `web.search.realtime` | Realtime web search with time filters | -| `web.search.news` | News search | -| `crypto.token.price` | Crypto token price and changes | -| `crypto.token.metadata` | Crypto token metadata | +| ID | Description | +| --------------------------------- | ------------------------------------------ | +| `twitter.tweet_detail` | Get tweet details and replies | +| `twitter.user_by_screen_name` | Get user profile by username | +| `twitter.user_tweets` | Get tweets from a user | +| `twitter.user_tweets_and_replies` | Get tweets and replies from a user | +| `twitter.user_media` | Get media posts from a user | +| `twitter.following` | Get user following list | +| `twitter.followers` | Get user followers | +| `twitter.retweeters` | Get tweet retweeters | +| `twitter.search` | Search tweets | +| `ai.text.chat.fast` | Fast AI chat completion | +| `ai.text.chat.reasoning` | Advanced reasoning chat | +| `ai.text.chat.auto` | Model-selected chat with provider fallback | +| `ai.text.summarize` | Summarize long text | +| `ai.text.rewrite` | Rewrite text with different styles | +| `ai.embedding.generate` | Generate vector embeddings | +| `web.search` | Web search | +| `web.search.realtime` | Realtime web search with time filters | +| `web.search.news` | News search | +| `crypto.token.price` | Crypto token price and changes | +| `crypto.token.metadata` | Crypto token metadata | ## Security @@ -596,3 +892,11 @@ current IDs and schemas. ## License MIT + +### Native framework deployment bundles + +Framework output can be exported with Wrangler's `deploy --dry-run --outfile +dist/app.worker.bundle` and published through `xapi workers push`. The CLI +retains native module names/types/bytes and separately publishes static Assets. +See [the Workers guide](skills/xapi/guides/workers.md#framework-builds-publish-wranglers-complete-bundle) +for configuration, supported metadata and current transport boundaries. diff --git a/bun.lock b/bun.lock index 019a36f..06bfe01 100644 --- a/bun.lock +++ b/bun.lock @@ -6,10 +6,15 @@ "name": "xapi-to", "dependencies": { "@openai/agents": "0.15.0", + "acorn": "^8.18.0", + "busboy": "1.6.0", + "jsonc-parser": "^3.3.1", + "smol-toml": "^1.8.0", "zod": "^4.0.0", }, "devDependencies": { "@types/bun": "^1.3.9", + "@types/busboy": "1.5.4", "@types/node": "^18", "tsup": "^8.5.1", "typescript": "^6.0.3", @@ -141,13 +146,15 @@ "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + "@types/busboy": ["@types/busboy@1.5.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], - "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], @@ -155,6 +162,8 @@ "bundle-require": ["bundle-require@5.1.0", "", { "dependencies": { "load-tsconfig": "^0.2.3" }, "peerDependencies": { "esbuild": ">=0.18" } }, "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA=="], + "busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="], + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], @@ -187,6 +196,8 @@ "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], @@ -231,8 +242,12 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "smol-toml": ["smol-toml@1.8.0", "", {}, "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ=="], + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + "streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="], + "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], @@ -261,10 +276,16 @@ "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@types/busboy/@types/node": ["@types/node@25.3.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="], + "@types/ws/@types/node": ["@types/node@25.3.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="], "bun-types/@types/node": ["@types/node@25.3.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="], + "mlly/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "@types/busboy/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "@types/ws/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], diff --git a/package.json b/package.json index c750658..0083e63 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,9 @@ "src/openai-sandbox-client.ts", "src/sandbox-client.ts", "README.md", - "skills" + "schemas", + "skills", + "templates" ], "license": "MIT", "homepage": "https://xapi.to", @@ -49,10 +51,15 @@ }, "dependencies": { "@openai/agents": "0.15.0", + "acorn": "^8.18.0", + "busboy": "1.6.0", + "jsonc-parser": "^3.3.1", + "smol-toml": "^1.8.0", "zod": "^4.0.0" }, "devDependencies": { "@types/bun": "^1.3.9", + "@types/busboy": "1.5.4", "@types/node": "^18", "tsup": "^8.5.1", "typescript": "^6.0.3" diff --git a/schemas/worker-project.v1.schema.json b/schemas/worker-project.v1.schema.json new file mode 100644 index 0000000..9b3ecf2 --- /dev/null +++ b/schemas/worker-project.v1.schema.json @@ -0,0 +1,179 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://xapi.to/schemas/worker-project.v1.json", + "title": "xAPI Worker project", + "type": "object", + "additionalProperties": false, + "required": ["version", "worker", "wrangler", "build", "environments"], + "properties": { + "$schema": { + "const": "https://xapi.to/schemas/worker-project.v1.json" + }, + "version": { "const": 1 }, + "workerId": { "type": "string", "format": "uuid" }, + "worker": { + "type": "object", + "additionalProperties": false, + "required": ["name", "slug"], + "properties": { + "name": { "type": "string", "minLength": 2, "maxLength": 80 }, + "slug": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{1,47}[a-z0-9]$" + }, + "description": { "type": "string", "maxLength": 500 }, + "template": { "enum": ["worker", "agent"], "default": "worker" } + } + }, + "wrangler": { "$ref": "#/$defs/projectPath" }, + "build": { + "type": "object", + "additionalProperties": false, + "required": ["command", "output"], + "properties": { + "command": { "type": "string", "minLength": 1, "maxLength": 1000 }, + "output": { "$ref": "#/$defs/projectPath" }, + "main": { + "description": "Entrypoint relative to build.output when output is a directory", + "$ref": "#/$defs/projectPath" + } + } + }, + "assets": { + "type": "object", + "additionalProperties": false, + "required": ["directory"], + "properties": { + "directory": { "$ref": "#/$defs/projectPath" }, + "binding": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]{0,63}$" + }, + "htmlHandling": { + "enum": [ + "auto-trailing-slash", + "force-trailing-slash", + "drop-trailing-slash", + "none" + ] + }, + "notFoundHandling": { + "enum": ["none", "404-page", "single-page-application"] + }, + "runWorkerFirst": { + "oneOf": [ + { "type": "boolean" }, + { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "pattern": "^!?/" + } + } + ] + } + } + }, + "environments": { + "type": "object", + "additionalProperties": false, + "required": ["preview", "production"], + "properties": { + "preview": { "$ref": "#/$defs/environment" }, + "production": { "$ref": "#/$defs/environment" } + } + } + }, + "$defs": { + "projectPath": { + "type": "string", + "minLength": 1, + "maxLength": 240, + "pattern": "^(?!/)(?![A-Za-z]:[\\\\/])(?!.*(?:^|/)\\.\\.(?:/|$))[^\\\\\\u0000]+$" + }, + "environment": { + "type": "object", + "additionalProperties": false, + "required": ["dailyBudgetUsd"], + "properties": { + "dailyBudgetUsd": { "type": "number", "minimum": 0.1, "maximum": 100 }, + "healthCheck": { + "type": "string", + "pattern": "^/(?!/)[^\\s]*$", + "maxLength": 500, + "default": "/health" + }, + "resources": { + "type": "array", + "maxItems": 100, + "default": [], + "items": { "$ref": "#/$defs/resource" } + }, + "secrets": { + "type": "array", + "maxItems": 100, + "uniqueItems": true, + "default": [], + "items": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]{0,63}$" + } + } + } + }, + "resource": { + "type": "object", + "additionalProperties": false, + "required": ["type", "bindingName"], + "properties": { + "type": { + "enum": [ + "kv_namespace", + "d1_database", + "r2_bucket", + "durable_object", + "queue", + "workflow" + ] + }, + "bindingName": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]{0,63}$" + }, + "className": { + "type": "string", + "pattern": "^[A-Za-z_$][A-Za-z0-9_$]{0,127}$" + }, + "location": { + "enum": ["wnam", "enam", "weur", "eeur", "apac", "oc"] + }, + "readReplication": { "enum": ["auto", "disabled"] } + }, + "allOf": [ + { + "if": { "properties": { "type": { "const": "durable_object" } } }, + "then": { "required": ["className"] }, + "else": { "not": { "required": ["className"] } } + }, + { + "if": { "required": ["location"] }, + "then": { + "properties": { + "type": { "enum": ["d1_database", "r2_bucket"] } + } + } + }, + { + "if": { "required": ["readReplication"] }, + "then": { + "properties": { "type": { "const": "d1_database" } } + } + } + ] + } + } +} diff --git a/skills/xapi-workers/SKILL.md b/skills/xapi-workers/SKILL.md new file mode 100644 index 0000000..9d4df10 --- /dev/null +++ b/skills/xapi-workers/SKILL.md @@ -0,0 +1,31 @@ +--- +name: xapi-workers +description: Deploy, operate, and verify applications on xAPI-managed Cloudflare Workers for Platforms through the xAPI CLI. Use for Workers projects, preview/production deployment, KV, D1, R2, Durable Objects, Queues, Workflows, schedules, runtime logs, consumption queries, billing reconciliation, retention, recovery, and test-resource cleanup. Includes real resource acceptance and cost evidence; does not administer a Cloudflare account directly. +--- + +# xAPI Workers for Platforms + +Use the `xapi` CLI (`xapi-to` is the same executable). Verify `xapi workers --help` before using it; an older installation may lack these commands. Do not silently replace managed deployment with Wrangler direct deployment. + +## Start with scope + +- Identify the control-plane host, Worker ID, and **preview or production** from the project and `workers get`. Test control plane and preview environment are separate choices. +- Authentication precedence: `XAPI_KEY`, `XAPI_API_KEY`, then `~/.xapi/config.json`. Keys need `workers:read` and, for changes, `workers:write`, plus access to the target Worker. A scoped-out Worker can return 404. +- Production API host is `api.xapi.to`; testing uses `XAPI_API_HOST=api.test.xapi.to` (host only). Load secrets from the user's existing secure environment. Never print keys, include them in code/artifacts, or send the xAPI key to a public Worker URL or Cloudflare. Runtime application authentication is separate. +- Start with `workers get `, `workers capabilities`, and `workers resources list --env `. Read-only inspection needs no extra approval. Use existing user authorization for changes; don't expand cleanup from a test environment to production. + +## Load the relevant workflow + +- **Build/deploy/import/CI:** read [deployment.md](references/deployment.md). +- **Use and verify resources:** read [resources.md](references/resources.md). +- **How much did it cost?** Read [billing.md](references/billing.md) before answering, collecting, or reconciling consumption. +- **Pause/recover/delete/refund:** read [lifecycle.md](references/lifecycle.md) before lifecycle mutations. +- **Buy or bind an xdomain domain:** read [domains.md](references/domains.md). Use the combined CLI command; do not manually create a CNAME to the Dispatcher or expose Cloudflare zone IDs. + +## Evidence and completion + +Follow a full round: execute real business operations → collect failures → confirm reproduction and affected paths → fix together → rerun affected real operations. Unit tests supplement this; label them separately from live acceptance. + +`ACTIVE` deployment, a provisioned resource, an accepted asynchronous job, and a passing HTTP health check prove different things. Verify the intended business result and persisted state. Preserve sanitized evidence: host, environment, IDs, times, request/job IDs, statuses, snapshot and ledger IDs, exact decimal amounts, and unresolved gaps. Exclude tokens, cookies, passwords, and customer file contents. + +For incomplete observations report **unknown**, not zero or success. Keep deployment completion, resource behavior, xAPI consumption, storage-day finalization, provider invoice reconciliation, and physical cleanup as separate verdicts. Report only verified outcomes and the next concrete unresolved check. diff --git a/skills/xapi-workers/agents/openai.yaml b/skills/xapi-workers/agents/openai.yaml new file mode 100644 index 0000000..c9afa7f --- /dev/null +++ b/skills/xapi-workers/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "xAPI Workers for Platforms" + short_description: "Deploy Workers, verify resources, and reconcile usage" + default_prompt: "Use $xapi-workers to deploy and verify this project, then check its resource usage and charges." diff --git a/skills/xapi-workers/references/billing.md b/skills/xapi-workers/references/billing.md new file mode 100644 index 0000000..3348118 --- /dev/null +++ b/skills/xapi-workers/references/billing.md @@ -0,0 +1,50 @@ +# Consumption and reconciliation + +## Read current evidence + +Set the correct control-plane host first. Every query below targets one environment: + +```sh +xapi workers billing prices --env preview --json +xapi workers billing overview --env preview --json +xapi workers billing ledger --env preview --all --json > ledger.json +xapi workers metering --env preview --json +xapi workers billing lifecycle --env preview --json +xapi workers retention show --env preview --format json +``` + +`billing-status` is platform configuration status, not an individual consumption bill. `workers usage` is a different diagnostic; it is not a replacement for complete ledger evidence. `billing prices` is the live xAPI price book: record its version and units, do not hard-code past acceptance prices. + +`metering` is a bounded diagnostic and may return truncated facts. There is no `metering --all` command. Use supported billing usage ranges/filters and the complete ledger for reconciliation; if raw facts remain truncated, request platform-operator evidence through an available authorized interface and mark coverage incomplete. Do not invent a pagination endpoint. + +## One consistent snapshot + +`ledger --all` pins the first returned `snapshotTime`, preserves filters and follows all pages (up to 100 pages, normally 100 entries/page). It fails on mixed scope/snapshot, duplicate IDs, repeated/missing cursors, or the page limit rather than returning an apparently complete partial bill. `pagination.complete` means pagination completed, **not** that provider metering is complete. `--all` cannot be combined with `--cursor`. + +Responses may have different `snapshotId` values per page/query; fix `snapshotTime` and scope, not equality of IDs. `pagination.pageSnapshotIds` retains the page IDs for tracing. Read `ledger.json.snapshotTime`, then query: + +```sh +xapi workers billing overview --env preview --snapshot-time --json +xapi workers billing usage --env preview --snapshot-time --from --to --json +``` + +Ledger/overview cover the snapshot's UTC billing day, not all history. Respect returned `rangeMetadata` and freshness. For historical days select the required supported snapshot; do not assume old snapshots remain queryable forever. For a ledger over the CLI page limit, manually page `--limit 100 --cursor --snapshot-time `, preserving `--metric`/`--resource-id`; verify unique IDs and `hasMore: false`. Never decode or synthesize cursors. + +Compare only the same host, Worker, environment, UTC day and filter scope. A resource-filtered ledger cannot equal a whole-environment overview. Keep decimal strings exact; use decimal arithmetic or integer minor units at the API's declared precision, not binary floating point. Preserve negative adjustments and entry funding classifications. For quantities, follow `adjustsEntryId` revision chains and metric semantics; do not sum successive cumulative observations as independent operations. For money, retain signed deltas. Preserve each entry's historical price version rather than repricing old usage with today's book. Sum comparable customer charges against `customerAccruedUsd`, and ledger net against `ledgerNetUsd`; don't count freeze/release transfers as consumption. If classification is missing, report the funding split unknown. + +## Interpret money correctly + +- `customerAccruedUsd`: net accrued xAPI customer charges. This is not the final Cloudflare invoice. +- `platformRiskUsd`: platform-funded amount; don't add it to customer spending. +- `settledUsd`: compatibility field; do not infer final provider settlement from the name. +- Frozen retention funds are still the user's money. Freeze/release and request reservations are not new consumption charges. +- `null`, absent observations, PARTIAL/INDETERMINATE, gaps and collector errors mean incomplete evidence. `$0` only means a measured/reported zero within that scope. +- Storage is capacity over time. A write event isn't a complete storage-day bill. Verify collector coverage, applicable date/price, day-finalization status and late-data adjustment/idempotency; merely waiting 24 hours proves nothing if collection or sealing is disabled. +- Unknown R2 actions need explicit classification. Never default all unknown operations to free or Class A. A user-approved provisional xAPI waiver for one named operation does not establish Cloudflare's official price, and does not cover Queue deliveries triggered by notifications or other API actions. +- Provider free allowances, subscriptions and shared infrastructure costs require separate provider evidence. A zero account invoice under a free allowance does not prove an operation is intrinsically free. + +## Real cost experiment + +Capture baseline snapshot and full ledger. Perform a bounded, named business operation; record request/job/object IDs and UTC times. Poll collection within a bounded window and capture a second full ledger plus same-snapshot overview. Identify new and adjusted entries, rather than subtracting rounded UI totals. Account for background jobs, retries, asynchronous CPU and delayed storage facts. If crossing UTC midnight, reconcile each day separately. + +Report: observed operation counts; customer charge; platform-funded cost; reservation changes; unknown/missing metrics; whether storage day and provider invoice are finalized. Attach sanitized raw responses. Don't claim an exact per-operation price when background traffic or delayed attribution prevents it. diff --git a/skills/xapi-workers/references/deployment.md b/skills/xapi-workers/references/deployment.md new file mode 100644 index 0000000..a6ec10b --- /dev/null +++ b/skills/xapi-workers/references/deployment.md @@ -0,0 +1,80 @@ +# Deployment and CI + +## Project workflow + +```sh +export XAPI_API_HOST=api.test.xapi.to +xapi workers templates +xapi workers init my-service --template persistent-agent +cd my-service +xapi workers plan --env preview +``` + +Choose the API host explicitly: `api.test.xapi.to` operates test-platform +resources; `api.xapi.to` operates production-platform resources. `--env preview` +selects a project's preview environment on that host, not the test API. A +production acceptance deployment can therefore use `api.xapi.to` with +`--env preview`. Do not silently fall back to a saved test key or host. + +For an existing project use `xapi workers init --from-wrangler ./wrangler.jsonc` (TOML also supported). Generated framework configs may live below the project root, for example `dist/server/wrangler.json`; run the command from the package directory so xAPI writes `xapi.worker.json` beside `package.json` and resolves generated asset paths back to that root. Read its import report; do not auto-accept unsupported settings. xAPI creates environment-specific resources; do not copy another Cloudflare account's IDs. + +`xapi.worker.json` holds desired xAPI state and Worker ID; Wrangler holds entrypoint, compatibility and binding declarations. The persistent-agent template declares all six managed resource types so it can demonstrate the complete platform, but an ordinary application should declare only the resources its business logic uses. Do not add unrelated bindings merely to complete an acceptance checklist. Test the full resource matrix in a separate disposable Worker or environment, then clean up only that isolated test state. Install/build according to the generated project instructions. Inspect plans for missing permissions, prices, secrets, budget, and policy requirements. + +```sh +xapi workers push --env preview +xapi workers get --format json +xapi workers secrets set APP_TOKEN --env preview --from-env APP_TOKEN +xapi workers logs --env preview --since 10m +``` + +If provisioning requires an accepted retention quote, follow lifecycle.md and pass its exact `--retention-price-version VERSION`; do not invent a version. Supply secrets when the Worker exists and rerun the unchanged project command if a missing secret blocked deployment. Never report a blocked preflight as successful deployment. + +Use the Node and package-manager version required by the application before `plan` or `push`; the CLI runs the configured build command unchanged. If the project declares `engines.node`, activate a compatible runtime first. A build-runtime failure is an application build failure and must occur before any deployment write; rerun the same push only after correcting the local runtime. + +For SSR frameworks that generate a complete Wrangler bundle, preserve that native bundle rather than uploading source files one at a time. For vinext/Next.js, a typical build command is: + +```sh +npm run build +npx wrangler deploy --dry-run \ + --config dist/server/wrangler.json \ + --outfile dist/app.worker.bundle +``` + +Set `build.output` to the generated `.worker.bundle`, omit `build.main`, and set `assets.directory` to the generated client directory. `--dry-run` only creates the local Cloudflare upload artifact; `xapi workers push` remains the only publisher. The import report must show every unmapped Wrangler field; never split a framework application into per-file API uploads to work around an import problem. + +Use the environment's returned `publicUrl` for actual requests. A custom-domain URL needs verified DNS/TLS readiness; do not construct a hostname or infer readiness from the organization name. Use application authentication, never the control-plane key, on this URL. + +The dispatcher reserves and strips incoming `x-xapi-*` headers. Use an +application-owned header such as `x-my-app-token`, or normal application +authentication, for your own probes and APIs. Do not disable this filtering to +make a probe pass. Also inspect returned state: `domains retry` can return a +domain record with `status: ERROR`; command completion does not certify DNS, +TLS, root-relative assets or OAuth callback readiness. + +After deployment reaches ACTIVE, run health plus business persistence and asynchronous completion checks in resources.md. Review data/schema compatibility before promoting the same preview artifact: + +```sh +xapi workers promote --to production +xapi workers rollback --env production --to previous +# Or an explicit deployment visible in the selected environment: +xapi workers rollback --env production --deployment +``` + +Rollback restores code and compatibility settings, not data, schema, Secret values, schedules, or Queue/Workflow state. Preview and production each have their own current deployment; an old deployment reference is not proof an environment is still serving. + +## CI runner + +Use the project's installed/pinned CLI, lockfile installation, and a scoped secret `XAPI_KEY`. Keep `XAPI_API_HOST` explicit and separate test/production credentials. CLI deployment does not require SSH into an API server or a Cloudflare account token. + +Run plan, build/push, active-status and business checks in order. `--non-interactive` suppresses prompts; it does not accept retention policy or bypass preflight: + +```sh +xapi workers plan --env preview --format json +xapi workers push --env preview --non-interactive +``` + +Promote in the already authorized release job after preview acceptance. Follow repository AGENTS.md and branch/PR rules; do not infer release authorization from a successful preview push. On uncertain results inspect deployments/logs and retry unchanged inputs so stable idempotency keys can recover the same operation. Do not change IDs or clear deletion flags to force deployment through. + +A `worker_control_*` conflict is a server rollout or environment-enrollment failure, not a hint to bypass xAPI with Wrangler. Preserve the existing deployment and resources, record the exact error code, inspect `workers audit`, and have the platform operator restore a compatible control-plane configuration before retrying the unchanged deployment. + +For explicit artifact operations: `workers upload --file dist/worker.mjs --idempotency-key `, then `workers deploy --artifact --env preview --idempotency-key `. Reuse a key only for identical inputs. `workers build` is an optional managed Sandbox build, not a requirement for deploying locally built code. diff --git a/skills/xapi-workers/references/domains.md b/skills/xapi-workers/references/domains.md new file mode 100644 index 0000000..9f80601 --- /dev/null +++ b/skills/xapi-workers/references/domains.md @@ -0,0 +1,81 @@ +# xdomain + xAPI Workers domains + +Use this workflow when a domain managed through xAPI Domains must serve one xAPI Worker environment. The supported first version requires the domain's Cloudflare zone and Workers for Platforms to belong to the same platform Cloudflare account. + +## What the combined command does + +`xapi workers domains attach` is the public operation. It: + +1. Fetches the `domain.get` schema, then confirms the `xdomain` domain belongs to the current xAPI Key. +2. Requests a short-lived Workers DNS ownership challenge bound to the current account, Worker, environment, and exact hostname. +3. Fetches the `dns.upsert` schema and writes a temporary TXT record through xdomain. +4. Lets the Workers control plane verify that TXT record, resolve the Cloudflare zone, publish the exact hostname-to-environment route in platform edge state, and create a native Cloudflare Workers Custom Domain for the shared Dispatcher. +5. Fetches the `dns.delete` schema and removes the temporary TXT record after the binding is accepted. + +Cloudflare owns the final DNS record, certificate, TLS renewal, and request routing. Do not add a competing A, AAAA, or CNAME record. The runtime request path resolves the exact hostname from platform edge state; it does not call xdomain, the xAPI control plane, or a customer database. + +## Attach + +Inspect the Worker and domain before changing anything: + +```bash +xapi workers get +xapi get domain.get +xapi call domain.get --input '{"domain_id":""}' +``` + +The target environment must already have an active deployment. Bind the apex: + +```bash +xapi workers domains attach \ + --env preview \ + --xdomain-domain-id \ + --subdomain @ +``` + +Or bind one hostname such as `kanby.example.com`: + +```bash +xapi workers domains attach \ + --env preview \ + --xdomain-domain-id \ + --subdomain kanby +``` + +Use `--env production` only after the production environment has been deployed and explicitly selected. One exact hostname maps to one environment. Preview and production need different hostnames. + +The command waits up to two minutes for DNS ownership by default; use `--timeout 5m` for slower propagation. A cleanup warning means the Worker binding was accepted but the temporary TXT could not be removed automatically. Inspect it with `xapi get dns.list` followed by `xapi call dns.list` before deleting the exact record. + +## Verify and operate + +```bash +xapi workers domains list +xapi workers domains retry +``` + +`PROVISIONING` means Cloudflare has not yet completed TLS or the reserved Dispatcher readiness probe. `ACTIVE` means TLS and exact environment routing both passed. It does not prove the application's own business routes; test those separately. + +Detach only a customer custom domain: + +```bash +xapi workers domains detach --yes +``` + +Platform-generated hostnames follow the environment lifecycle and cannot be detached independently. +After detach, the exact hostname has a short reuse cooldown while stale edge +route state expires. Wait until the API's `reusableAt` time before binding that +hostname again; do not bypass this by editing DNS manually. + +## Buying a domain + +Domain registration is non-refundable. Before `domain.register`, always fetch the schemas for `domain.check`, `domain.price`, and `domain.register`; show the exact domain, first-period billable price, renewal information when available, maximum accepted charge, registration period, and the registrant contact data that will be sent to the registrar. Obtain explicit confirmation immediately before the purchase. Never invent missing contact fields. + +Registration and Worker binding are separate operations. A completed purchase does not deploy or expose a Worker, and a deployed Worker does not authorize a domain purchase. + +## Safety boundaries + +- Never send the xAPI Key to the custom hostname or a tenant Worker. +- Never accept a client-supplied Cloudflare zone ID as proof of ownership. +- Never reuse one DNS challenge for another account, Worker, environment, or hostname. +- Do not replace the combined command with direct Wrangler, Cloudflare dashboard, or private provider calls during xAPI acceptance. +- Do not claim support for a domain whose authoritative zone is outside the configured Workers for Platforms account; that needs a future Custom Hostnames for SaaS flow. diff --git a/skills/xapi-workers/references/lifecycle.md b/skills/xapi-workers/references/lifecycle.md new file mode 100644 index 0000000..d6e9062 --- /dev/null +++ b/skills/xapi-workers/references/lifecycle.md @@ -0,0 +1,44 @@ +# Retention, recovery and cleanup + +Read current state and policy before choosing an action: + +```sh +xapi workers retention show --env preview --format json +xapi workers retention quote --env preview --type WORKER --format json +xapi workers billing lifecycle --env preview --json +``` + +A quote is not policy acceptance. If already authorized, accept the returned exact version: + +```sh +xapi workers retention accept --env preview --price-version --yes +``` + +Explain material automatic-deletion terms when they require a new user decision; do not request approval again when that policy and scope are already authorized. Provision with the same accepted version where required. Different resource types can have separate quotes; inspect the API response instead of copying an old price version. + +```sh +xapi workers retention pause --env preview +xapi workers retention resume --env preview +xapi workers retention keep-paused --env preview +``` + +Use actions permitted by the current lifecycle. Manual pause, low balance and pending deletion are distinct. A deposit doesn't prove reserve replenishment or automatic resumption. Retention-v3 can start cleanup at the reserve cleanup threshold; an estimate in hours is not necessarily a fixed expiry. Read actual deadlines and reserve budget. A 409 or PENDING_DELETION needs inspection of blockers and operation history, not a forced redeploy or edited database flag. + +For crash recovery, distinguish slow live ownership from an expired lease or exited process. Do not kill shared services to reproduce a failure. Use an isolated authorized test process/environment. Record deployment ID, lease/recovery state, delete intent and reserve changes; verify no script is recreated after deletion wins. + +## End a test without deleting production + +1. Inventory the test Worker/environment, resources, bindings, active jobs/schedules and test objects. Pause producers and schedules; preserve user data. + Schedule list/pause commands take Worker/schedule IDs, not `--env`; verify each returned schedule's environment before pausing it. Save production deployment/resource and business-health baselines for the post-cleanup comparison. +2. Remove only authorized disposable objects and handle in-flight jobs. Nonempty R2 buckets may block deletion. Use the project's authenticated application routes or supported resource interfaces for object/job cleanup; discover those routes in the project before use. This CLI does not provide a generic object purge or job-drain command. Missing cleanup or provider-proof access is an explicit remaining step, not permission to bypass xAPI with platform credentials. +3. Request **environment** deletion for an isolated preview test: + + ```sh + xapi workers retention delete --env preview --yes + ``` + + `workers delete --yes` is whole-Worker deletion; use only when the whole project is disposable and authorized. +4. Poll lifecycle, resources and public reachability to a terminal outcome. An accepted request, deleted UI badge or retained historical deployment reference is not physical-destruction proof. If provider proof is unavailable to this key, report that limitation; don't treat an API visibility 404 alone as proof. +5. Query final retention/ledger evidence: unused reserve released, actual retention/cleanup charges accounted for, pending final metering and late adjustments identified. Verify a repeat read/recovery does not cause a second refund or resource resurrection. + +Natural expiry refund is a separate test: use an isolated disposable environment and observe its actual policy trigger/deadline. Manual deletion cannot substitute for it. Keep billing evidence after cleanup and list residual resources or pending refunds explicitly. diff --git a/skills/xapi-workers/references/resources.md b/skills/xapi-workers/references/resources.md new file mode 100644 index 0000000..9a846d5 --- /dev/null +++ b/skills/xapi-workers/references/resources.md @@ -0,0 +1,50 @@ +# Resources and real acceptance + +Run `workers capabilities` and `workers resources list --env preview --format json`. Permissions, availability, and price configuration are independent: provisioned alone does not mean priced or exercised. + +Keep resource declarations driven by application behavior. R2, D1, KV, Durable Objects, Queues, and Workflows are independent bindings; none must be added or deleted just because another resource is used. When the goal is to verify every platform resource, use a separate disposable acceptance Worker so those checks cannot change a real application's storage or lifecycle. + +Prefer declarations plus plan/push. For granular provisioning: + +```sh +xapi workers resources create --env preview --type kv --binding PREFERENCES +xapi workers resources create --env preview --type d1 --binding DB +xapi workers resources create --env preview --type r2 --binding FILES +xapi workers resources create --env preview --type do --binding COORDINATOR --class-name Coordinator +xapi workers resources create --env preview --type queue --binding JOBS +xapi workers resources create --env preview --type workflow --binding PIPELINE +``` + +Supply the explicitly accepted retention price version when required. Redeploy after binding changes. Use `env.`; no provider API/S3 credentials belong in application code. Initialize D1 schema through the application's migration mechanism; creation doesn't create tables. + +| Resource | Real business check | Evidence | +|---|---|---| +| KV | Save and read a preference; respect eventual consistency | Key, before/after value, timestamps | +| D1 | Create/update/query a business record across separate requests | Record ID and stored result | +| R2 | Upload/download/list/delete a test attachment | Object key, bytes, checksum; only test data deleted | +| Durable Object | Read/write through the same object ID across requests | Object ID and durable result; test alarms separately if used | +| Queue | Enqueue a task and observe its persisted outcome | Stable task ID and consumer result, including retry idempotence | +| Workflow | Start and poll the instance to terminal state | Instance ID, final status and durable result | +| Schedule | Trigger an immediate run and inspect run history | Schedule/run ID and resulting business change | + +Queue uses a managed consumer that routes an envelope to the same Worker environment: + +```js +await env.JOBS.send({ path: "/tasks/report", method: "POST", body: { taskId } }); +const job = await env.PIPELINE.create({ params: { + path: "/tasks/report", method: "POST", body: { taskId } +} }); +const state = await (await env.PIPELINE.get(job.id)).status(); +``` + +Use local absolute paths and idempotent task IDs. Queue delivery can repeat; a successful enqueue isn't task completion. Workflow instance creation isn't completion either: poll to `complete`, `errored`, or `terminated`, with a bounded deadline. Don't expose a public administrative test route without application authentication. + +```sh +xapi workers schedules create --env preview --name daily-report --cron '0 8 * * *' --timezone Asia/Shanghai --path /tasks/report --method POST --body '{"source":"schedule"}' +xapi workers schedules list --format json +xapi workers schedules run +xapi workers schedules runs --format json +xapi workers logs --env preview --request-id +``` + +An immediate run doesn't prove future cron firing. Pause test schedules when finished. Log each resource separately as not connected / connected / exercised / result verified / metering observed / reconciled. Test data must not overwrite existing user content. Correlate operation times and IDs with billing.md; unrelated background traffic can also generate charges. diff --git a/skills/xapi/SKILL.md b/skills/xapi/SKILL.md index 23b6457..9000aaf 100644 --- a/skills/xapi/SKILL.md +++ b/skills/xapi/SKILL.md @@ -1,7 +1,8 @@ --- name: xapi -description: Access real-time external data and managed cloud sandboxes via the xapi CLI — Twitter/X, social platforms, domain purchase and DNS, normalized crypto, BlockPI RPC, Binance Web3 API, web/news search, AI generation, SMS verification, and auditable ephemeral compute. Configure the xAPI AI or WebSocket Gateways, or use sandbox run for automatic quote/create/execute/cleanup. Use when the user mentions xapi, external services, or sandbox compute. -metadata: {"openclaw":{"emoji":"x","requires":{"anyBins":["npx"]},"primaryEnv":"XAPI_KEY"}} +description: Access real-time external data, managed cloud sandboxes, and hosted Workers via the xapi CLI — Twitter/X, social platforms, domain purchase and DNS, normalized crypto, BlockPI RPC, Binance Web3 API, web/news search, AI generation, SMS verification, and auditable compute. Configure xAPI AI or WebSocket Gateways, run ephemeral Sandbox jobs, or deploy JavaScript and persistent Agents to xAPI Workers. +metadata: + { "openclaw": { "emoji": "x", "requires": { "anyBins": ["npx"] }, "primaryEnv": "XAPI_KEY" } } --- # xapi CLI Skill @@ -23,21 +24,16 @@ Before calling any API, you need an API key: ```bash # Register a new account (apiKey is saved automatically) npx xapi-to register - # Replace an already-saved file key only when intentionally creating a new account npx xapi-to register --force - # Register with an inviter's referral code (server-side referral and promotion terms may change) # please replace xapito to your actual referral code npx xapi-to register --referral-code xapito npx xapi-to register xapito # positional shorthand - # Or set an existing key npx xapi-to config set apiKey= - # Safer for shared terminals: paste the key on stdin, then press Ctrl-D npx xapi-to config set apiKey=- - # Verify connectivity npx xapi-to config health ``` @@ -63,15 +59,18 @@ xapi offers two types of APIs under a unified interface: Both types use the same discovery and call workflow. Use `--source capability` or `--source api` on commands that expose source filtering. ## Managed Sandbox Compute -Read `guides/sandbox.md` before creating a billable instance. For a one-shot -command, prefer `sandbox run`; it quotes, applies a price ceiling, waits, -executes, and terminates in `finally`: + +Read `guides/sandbox.md` before creating a billable instance. For a one-shot command, prefer `sandbox run`; it quotes, applies a price ceiling, waits, executes, and terminates in `finally`: + ```bash npx xapi-to sandbox run --command 'python3 -c "print(6 * 7)"' ``` -Use granular commands only for multi-step work. Keep the instance ID, terminate -in cleanup, and verify terminal state/cost afterward. Do not use `--keep` unless -the user explicitly wants a reusable, continuing-to-bill instance. + +Use granular commands only for multi-step work. Keep the instance ID, terminate in cleanup, and verify terminal state/cost afterward. Do not use `--keep` unless the user explicitly wants a reusable, continuing-to-bill instance. + +## Hosted Workers + +Read `guides/workers.md` before creating, importing, planning, pushing, promoting, rolling back, attaching Cloudflare resources, scheduling tasks, or inspecting logs. Workers are continuously addressable JavaScript applications; Sandbox is ephemeral arbitrary compute. Prefer the project workflow: `workers init`, `workers plan --env preview`, `workers push --env preview`, then `workers promote --to production`. `init` has distinct new-project, existing frontend, Wrangler import, and Next.js SSR adapter paths; select the matching path from the guide instead of repeatedly regenerating project files. Use `xapi.worker.json` as managed-resource desired state, let `plan` compare live state, and use `workers resources pull` only to adopt healthy remote-only resources. Git is optional. `push` builds and uploads an immutable Artifact, including separately declared native static assets, uses stable recovery keys, and never silently deletes stateful resources or Secrets. For web applications, inspect `webAppReady`: path-prefix-aware applications can use fallback routing, while root-relative routes and OAuth callbacks need a dedicated hostname. An optional platform-owned ephemeral Sandbox build can produce the same Artifact type. Rollback restores code and compatibility settings, never KV/D1/R2/DO/Queue/Workflow/schedule data or Secret values. Run the provider capability check before provisioning so missing permissions such as D1 Edit are reported precisely. KV, D1, R2, Durable Object, Queue, Workflow, Secret, schedule, managed-domain, observability, and billing data are environment- or Worker-scoped; never assume preview and production share state. Queue messages use the documented route envelope, are delivered at least once, and require an idempotent target route. Only `ACTIVE` means deployment succeeded. ## Usage Workflow @@ -230,13 +229,13 @@ Use `--code ` with `get` or `call` to generate ready-to-use code snippet Supported targets and aliases: -| Target | Aliases | Default library | Variants | -|--------|---------|----------------|----------| -| `curl` | — | curl | — | -| `python` | `py` | requests | `python.requests`, `python.httpx`, `py.requests`, `py.httpx` | -| `javascript` | `js` | fetch | `javascript.fetch`, `javascript.axios`, `js.fetch`, `js.axios` | -| `typescript` | `ts` | fetch | `typescript.fetch`, `ts.fetch` | -| `go` | — | net/http | — | +| Target | Aliases | Default library | Variants | +| ------------ | ------- | --------------- | -------------------------------------------------------------- | +| `curl` | — | curl | — | +| `python` | `py` | requests | `python.requests`, `python.httpx`, `py.requests`, `py.httpx` | +| `javascript` | `js` | fetch | `javascript.fetch`, `javascript.axios`, `js.fetch`, `js.axios` | +| `typescript` | `ts` | fetch | `typescript.fetch`, `ts.fetch` | +| `go` | — | net/http | — | ```bash # Generate a curl command from API schema (template with empty values) @@ -335,6 +334,7 @@ When the user's task involves these workflows, read the corresponding guide file - **`guides/ai_gateway.md`** — xAPI AI Gateway: Claude Code and Anthropic/OpenAI SDK setup, model discovery, routing strategies, streaming, fallback, routing/billing headers, direct media endpoints, and known limitations - **`guides/ws_gateway.md`** — xAPI WebSocket Gateway: GPT Live, OpenAI Realtime, streaming ASR/TTS, simultaneous interpretation, podcast generation, service/path routing, browser authentication, native protocols, limits, billing, close codes, and reconnects - **`guides/sandbox.md`** — managed Sandbox compute: AI tool selection, one-shot and multi-step lifecycles, provider pinning, files, Cloudflare Web previews, suspension, GPU jobs, parallel agents, cleanup recovery, audit/history, and billing verification +- **`guides/workers.md`** — xAPI-hosted Cloudflare Workers: new-project and Wrangler import flows, plan/push/promote/rollback, no-Git and CI operation, Worker vs Sandbox selection, managed KV/D1/R2/DO/Queue/Workflow and Secrets, persistent schedules, managed DNS/TLS, Tail observability, usage settlement and hard budget/balance guards, Artifact deployment, API-key instance visibility, audit, and deletion - **`guides/sms.md`** — SMS verification: buy virtual phone numbers, receive verification codes, finish/cancel orders (5SIM) - **`guides/provider.md`** — Provider management: create/update services, About/changelog, version lifecycle, metrics/events and request receipts, Skill upload/linking, rollback/delete, earnings transfer diff --git a/skills/xapi/guides/workers.md b/skills/xapi/guides/workers.md new file mode 100644 index 0000000..3ef9565 --- /dev/null +++ b/skills/xapi/guides/workers.md @@ -0,0 +1,639 @@ +# xAPI Hosted Workers + +Use this guide when the user wants to deploy an API, Webhook, Chat endpoint, scheduled JavaScript task, or persistent Agent to xAPI-managed Cloudflare Workers for Platforms. + +## Choose Worker or Sandbox + +- Use `workers` for continuously addressable HTTP/WebSocket applications, Webhooks, scheduled tasks, and persistent Agent entrypoints. The control plane currently manages KV, D1, R2, Durable Objects, Queues, Workflows, Secrets, and persistent schedules. Run `workers capabilities` before provisioning because the configured Cloudflare token may not have every required resource permission. +- Use `sandbox` for arbitrary shell commands, builds, browsers, GPU work, or short-lived isolated jobs. +- A Worker may dispatch heavy work to Sandbox. Do not keep a Sandbox alive merely to act as an HTTP service when a Worker fits. + +## Authentication and test routing + +Keys need `workers:read` for reads and `workers:write` for mutations. The CLI reads `XAPI_KEY`, then `XAPI_API_KEY`, then `~/.xapi/config.json`. + +The account owner can additionally restrict each key to all account Workers, +only Workers created by that key, or own plus selected Workers. Configure this +in Console → API Keys → Permissions. Every CLI subcommand respects the same +server-side instance boundary; an out-of-scope Worker is returned as `404`. + +Production uses `api.xapi.to`. Select the test control plane explicitly: + +```bash +export XAPI_API_HOST=api.test.xapi.to +``` + +Do not send the key directly to Cloudflare or any non-xAPI host. xAPI owns the Cloudflare account and API token. + +## Prefer the project workflow + +For a normal application or Agent, use the project commands instead of manually +passing Worker IDs, Artifact IDs, and idempotency keys. `xapi.worker.json` stores +only xAPI-specific desired state and the remote `workerId`; Wrangler remains the +source of truth for the entrypoint, compatibility settings, and static assets. +Managed KV, D1, R2, Durable Object, Queue, and Workflow declarations belong in +`xapi.worker.json`. The file contains no credential and may be committed. + +Choose the `init` form from the project you actually have: + +| Starting point | Command | What `init` does | +| --- | --- | --- | +| Empty directory / new service | `xapi workers init my-agent --template persistent-agent` | Creates a complete Worker package from a versioned local template. | +| Existing React, Vite, Vue, or static Next.js package | `cd app && xapi workers init` | Detects the framework, keeps existing scripts, and adds only the xAPI adapter, Wrangler config, desired-state file, and xAPI package scripts. | +| Existing Worker with Wrangler | `xapi workers init --from-wrangler ./wrangler.jsonc` | Imports supported settings after showing what is managed, ignored, or must be re-entered. | +| Next.js SSR | Run `npx vinext check`, `npx vinext init`, then `xapi workers init --from-wrangler ` | Uses the framework adapter's complete Worker bundle instead of treating SSR as static files. | + +For a new service: + +```bash +xapi workers templates +xapi workers init my-agent --template persistent-agent +cd my-agent +xapi workers plan --env preview +xapi workers push --env preview +``` + +For an existing browser application without Wrangler, initialize the package in +place. Detection reads `package.json`; it supports Vite React, Vite Vue, plain +Vite, Create React App, Vue CLI, and static Next.js. It does not replace the +application's `dev`, `build`, or test scripts: + +```bash +cd existing-web-app +xapi workers init +npm install +npm run xapi:build +xapi workers plan --env preview +xapi workers push --env preview +``` + +The added files are `xapi.worker.json`, `wrangler.jsonc`, and +`xapi-worker/index.ts`. The added package scripts are `xapi:build`, +`xapi:worker:build`, and `xapi:worker:dev`. Review the generated diff before +installing dependencies. Re-running `init` is not a synchronization command; +once `xapi.worker.json` exists, manage it with the project and resource commands. + +Use `--framework react|vite|vue|next` only for ambiguous package metadata. +Static Next.js requires `output: 'export'`. For Next.js SSR, do not generate a +generic SPA Worker: run `npx vinext check` and `npx vinext init`, then import the +generated Wrangler configuration. Local development remains a package concern +(`xapi:worker:dev` runs Wrangler); there is no separate `workers dev` command. + +The templates are versioned files packaged with the CLI, so `init` neither +downloads nor executes remote code. The `persistent-agent` starter declares KV, +D1, R2, one Durable Object, one Queue, and one Workflow in both environments, +plus the Secret names `APP_TOKEN` and `MODEL_KEY`. `plan` shows the exact desired +changes; `push` creates environment-specific resources and binds their provider +IDs without writing those IDs into application source. Set secret values after +the Worker ID exists: + +That broad resource set demonstrates the complete platform; it is not the +default architecture for every application. Keep only resources used by the +application's business logic. Do not add or couple independent bindings merely +to complete an acceptance checklist. Verify the full resource matrix in a +separate disposable Worker or environment so cleanup cannot alter application +data. + +```bash +xapi workers secrets set APP_TOKEN --env preview --from-env APP_TOKEN +xapi workers secrets set MODEL_KEY --env preview --from-env MODEL_KEY +``` + +The generated `TEMPLATE.md` explains the `/chat`, `/state`, `/queue`, +`/workflow`, and `/cron` routes and includes a remote smoke test. Users own and +edit `src/index.ts`; the template is only the initial project snapshot. + +Import an existing Cloudflare Worker without reusing its Cloudflare account ID, +resource IDs, routes, or secrets: + +```bash +cd existing-worker +xapi workers init --from-wrangler ./wrangler.jsonc +xapi workers plan --env preview +xapi workers push --env preview +``` + +`init --from-wrangler` also accepts `wrangler.toml`. It classifies settings as +`SUPPORTED`, `MANAGED`, `REENTER`, `IGNORED`, or `UNSUPPORTED`; it refuses to +write a partial project unless the user explicitly accepts the report with +`--accept-partial`. + +The project workflow does not require Git. Git repository, branch, and commit +are optional provenance, not authentication and not a deployment prerequisite. +It runs the configured build, creates the remote Worker when `workerId` is +absent, safely creates or updates declared resources, uploads one immutable +Artifact, deploys preview, waits for the active state, and runs the configured +health check. `push` never deletes an extra stateful resource or Secret; `plan` +marks such drift `MANUAL` for explicit handling. + +## Resource state without drift + +There are only two resource states: + +- `xapi.worker.json` is the desired state that belongs in Git. It contains + binding names and portable options, never Cloudflare or xAPI resource IDs. +- xAPI is the live state. `plan` reads it every time and compares it with the + selected environment in `xapi.worker.json`; there is no cached state file. + +Choose the command by intent: + +| Intent | Command | State changed | +| --- | --- | --- | +| Create a declaration | `resources add` | Local desired state only | +| Adjust a declaration | `resources update` | Local desired state only | +| Adopt live-only resources | `resources pull` | Live read, then safe local merge | +| Stop declaring a resource | `resources remove` | Local desired state only | +| Delete resource data | `resources destroy --yes` | Local desired state and one live environment | +| Check convergence | `workers plan` | None | + +Use this normal flow to add a resource: + +```bash +xapi workers resources add --env both --type kv --binding CACHE +xapi workers resources add --env both --type d1 --binding DB --location apac +xapi workers resources add --env both --type r2 --binding FILES --location apac +xapi workers resources add --env both --type do --binding ROOM --class-name Room +xapi workers resources add --env both --type queue --binding JOBS +xapi workers resources add --env both --type workflow --binding PIPELINE +xapi workers resources update --env preview --type d1 --binding DB \ + --location weur --read-replication disabled +xapi workers plan --env preview +xapi workers push --env preview +``` + +`--env both` creates matching declarations, not shared storage. `resources add` +is idempotent and rejects conflicting binding reuse. `resources update` +replaces the complete declaration. Before linking it can correct any local +field; after linking it rejects type and Durable Object class changes. A live +location or D1 replication change is +`BLOCKED` because the current API cannot update a managed resource in place; +create another binding and migrate data instead. + +If a live resource was created before this file, or an older CLI changed only +the control plane, adopt it explicitly: + +```bash +xapi workers plan --env preview +xapi workers resources pull --env preview +git diff -- xapi.worker.json +xapi workers plan --env preview +``` + +`pull` is an additive, all-or-nothing merge. It imports only supported healthy +resources, preserves pending local declarations, never writes provider IDs, +never deletes anything, and refuses to overwrite a binding whose type, +Durable Object class, location, or D1 replication differs. `--env both` reads +the environments independently because their physical resources are separate. + +`resources remove` changes desired state only. The following `plan` shows the +live resource as `MANUAL`; keep it with `resources pull`, or back it up and use +the project-aware destructive command: + +```bash +xapi workers resources destroy --env preview --binding FILES --yes +``` + +`destroy` accepts one environment, removes the declaration before requesting +live deletion, and reports a deletion request rather than claiming immediate +physical destruction. If the request fails, the live resource remains and +`resources pull` restores desired state before retrying. + +`resources list/create/delete ...` are recovery and debugging +primitives. They mutate or inspect live state without updating +`xapi.worker.json`; do not use `create` as the normal project workflow. + +`build.output` may point to one bundled JavaScript module or to a directory of +Cloudflare code modules. A directory requires `build.main`, relative to that +directory, so CI and local runs select the same entrypoint: + +```json +{ + "build": { + "command": "npm run build", + "output": "dist", + "main": "worker.js" + } +} +``` + +Directory Artifacts include `.js`, `.mjs`, `.wasm`, `.txt`, and `.bin` modules +in one versioned upload. Relative imports must resolve inside the directory; +package imports must be bundled by the build. The CLI normalizes and hashes the +complete Artifact before `plan` or `push`, so both commands compare identical +bytes. Existing single-file project configurations remain valid. + +### Framework builds: publish Wrangler's complete bundle + +For a framework that produces a generated Wrangler configuration (for example +vinext), use that configuration to produce the native upload bundle: + +```bash +npm run build +npx wrangler deploy --dry-run --config dist/server/wrangler.json --outfile dist/app.worker.bundle +``` + +Point the project build output to `dist/app.worker.bundle`; omit `build.main`. +Set `assets.directory` to the framework's client output (for example +`dist/client`). Then use `xapi workers plan --env preview` and +`xapi workers push --env preview`. The build command should run both commands +above. `--dry-run` creates a local artifact; it does not publish outside xAPI. + +The CLI reads multipart module names, bytes, MIME types and `main_module` from +Wrangler instead of guessing the output directory's contents. It does not +rename chunks or rewrite imports. Assets are packaged with the artifact and +published using CF's asset upload session before the script is activated. +Compatibility date/flags must match the project's Wrangler configuration. +D1/R2/KV binding names must match declared xAPI resources; native account IDs +and resource IDs are not reused. Secrets are set separately through xAPI. +The artifact also preserves `observability.enabled`. + +This adapter currently supports the explicitly mapped metadata above, not every +Wrangler setting. Unmapped metadata fails before artifact upload rather than +being silently discarded. Cron triggers are separate from the upload bundle +and must be configured through xAPI schedules. The granular `workers upload` +command is artifact-only; use the project `push` workflow for coordinated +compatibility, resource, secret and asset handling. + +Current xAPI transport limits remain 200 modules / 10 MiB decoded modules and +12 MiB decoded modules plus assets. These are xAPI limits, not a statement of +CF's full native capacity. If exceeded, report the unsupported deployment; +never split a project into unrelated deployments or edit framework output to +work around the limit. + +A `PATH_FALLBACK` URL is not a root-hosted Web application URL. Do not rewrite +application routes or configure GitHub callbacks against an invented host. +Use the environment's reported routing state and verify a real reachable +`publicOrigin` with empty `publicBasePath` for a root-hosted acceptance test. + +After real preview validation, promote the exact active preview Artifact without +rebuilding it: + +```bash +xapi workers promote --to production +``` + +Production promotion first reads the complete production state. Missing +declared resources appear as `CREATE` and are created only after every budget, +Secret, compatibility, and extra-resource check passes and the user confirms. +Any blocked or undeclared production resource stops the command before all +writes. Use `--retention-price-version ` when a new resource +requires an accepted freeze quote. Promotion then activates the exact preview +Artifact and verifies health before reporting success. To restore code: + +```bash +xapi workers rollback --env production --to previous +# Or select one visible historical deployment: +xapi workers rollback --env production --deployment +``` + +Rollback restores the selected code Artifact and compatibility settings only. +It does **not** restore or migrate KV, D1, R2, Durable Objects, Queues, Workflows, +schedule state, or Secret values. Inspect application data compatibility before +confirming production rollback. + +For CI, provide a least-privilege Key scoped to the selected Worker and keep +both host and key explicit. Non-interactive mode removes prompts but does not +bypass `BLOCKED` plan items or production preflights: + +```bash +export XAPI_API_HOST=api.test.xapi.to +export XAPI_KEY="$CI_XAPI_KEY" +xapi workers plan --env preview --format json +xapi workers push --env preview --non-interactive +xapi workers promote --to production --non-interactive +``` + +When an operation result is uncertain, rerun the same project command. Its +stable idempotency keys and state lookup recover the existing operation rather +than publishing a duplicate. Do not change project inputs merely to force a +retry. + +Follow runtime output after deployment: + +```bash +xapi workers logs --env preview --tail --since 10m +xapi workers logs --env preview --request-id +xapi workers logs --env preview --deployment +``` + +## Advanced: granular control-plane commands + +Use the commands below for platform diagnosis, explicit resource operations, or +custom automation that cannot use `xapi.worker.json`. They are the primitives +used by the project workflow, not the recommended first-time deployment path. + +### Create with explicit budgets + +The account needs at least $5 available balance. This is a creation guard, not a prepayment. Both environment budgets are required and must be between $0.10 and $100 per day. + +```bash +npx xapi-to workers create \ + --name "Daily research agent" \ + --slug daily-research-agent \ + --template agent \ + --preview-budget 0.25 \ + --production-budget 2 \ + --format pretty +``` + +Save the returned Worker ID. Preview and production have separate script names, hostnames, budgets, bindings, and state resources. +Each environment also returns `publicUrl`. Use that field for calls: xAPI may point it at the shared dispatcher while a custom hostname is waiting for DNS and TLS. `dispatchUrl` is immediately routable through the platform Worker; `customDomainUrl` is the intended dedicated hostname and must not be presented as ready until its domain status is verified. + +### Produce an Artifact, then deploy + +Deployments consume an immutable xAPI Artifact, never a mutable directory and never a running Sandbox. The normal path is to build locally or in CI and upload either one bundled ES module or one code-module directory. Sandbox is an optional build provider. + +The user's API key authenticates only xAPI control-plane requests. It is never embedded in a bundle, written into a build Sandbox, or sent directly to Cloudflare. Do not put runtime secrets in source; add them later as encrypted Secret bindings. + +```js +// src/index.ts +export default { + async fetch(_request, env) { + return Response.json({ ok: true, ai: env.XAPI_AI_BASE_URL }); + }, +}; +``` + +Build locally or in CI. A single bundled UTF-8 ES module must be at most 1 MiB +and include its runtime dependencies: + +```bash +npm run build +npx xapi-to workers upload \ + --file dist/worker.mjs \ + --idempotency-key artifact-2026-08-21 +``` + +For code splitting, upload the output directory and name its entrypoint. The +directory may contain at most 200 supported modules and 10 MiB of decoded +module content. All modules are sent together in one xAPI Artifact request: + +```bash +npx xapi-to workers upload \ + --file dist/ \ + --main worker.js \ + --idempotency-key artifact-2026-08-21 +``` + +This directory format is for Worker code modules. For a web application, keep +HTML, CSS, images, and fonts in a separate build directory and declare it in +`xapi.worker.json`. `workers push` packages those files into the immutable xAPI +Artifact and the platform completes Cloudflare's native static-assets upload: + +```json +{ + "build": { "command": "npm run build", "output": "dist/worker" }, + "assets": { + "directory": "dist/client", + "binding": "ASSETS", + "htmlHandling": "auto-trailing-slash", + "notFoundHandling": "single-page-application", + "runWorkerFirst": ["/api/*"] + } +} +``` + +Wrangler imports preserve supported `assets` settings. Cloudflare permits up to +25 MiB per asset and 100,000 assets per version. Asset content stays separate +from Worker modules and is never silently dropped. The current xAPI JSON +Artifact transport accepts at most 12 MiB of decoded modules and assets in one +deployment; split larger sites before upload until the multipart Artifact +transport is available. + +Save the returned Artifact `id`, then deploy that exact Artifact to preview: + +```bash +npx xapi-to workers deploy \ + --artifact \ + --env preview \ + --compatibility-date 2026-08-21 \ + --idempotency-key release-candidate-1 +``` + +After deployment, read the environment `publicUrl` instead of constructing a hostname. +For a web application, `workers plan` reports whether that environment has a +dedicated hostname. Preview path fallback remains useful for API and diagnostic +Workers. A path-prefix-aware application can also use it in production; +root-relative browser URLs and OAuth callbacks require `webAppReady: true`. +Promotion surfaces this as a manual review instead of blocking compatible apps. + +```bash +npx xapi-to workers get --format pretty +curl "/health" +``` + +### Optional Sandbox build + +`workers build` uploads a bounded project snapshot. The control plane creates an ephemeral Sandbox with platform credentials, writes the project, executes the explicit command, stores the output as the same immutable Artifact type, and requests termination in `finally`. + +The CLI skips `.git`, `.xapi`, `node_modules`, `dist`, credential directories (`.ssh`, `.aws`, `.gnupg`, `.docker`), `.env*`, package-manager credential files, private-key extensions, and common SSH private-key names. + +The optional server-side Sandbox builder currently requires `--output` to +identify one bundled ES module produced by the command: + +```bash +npx xapi-to workers build \ + --project . \ + --entrypoint src/index.ts \ + --command "npm install --ignore-scripts --no-audit --no-fund && npm run build" \ + --output dist/worker.mjs \ + --idempotency-key source-2026-08-21 +``` + +The result must have `status: SUCCEEDED`. Save its `artifactId`, not the build `id`, and deploy it with `--artifact`. + +Reuse an upload key only for identical normalized Artifact bytes. Reuse a build key only for the exact same source snapshot and parameters. Reuse a deployment key only for the same Artifact and environment. A successful deployment returns `status: ACTIVE`; `DEPLOYING` is not completion and `FAILED` must be surfaced with its error. + +After real preview validation, deploy the identical file to production with a new stable key: + +```bash +npx xapi-to workers deploy \ + --artifact \ + --env production \ + --idempotency-key production-2026-08-21 +``` + +Workers for Platforms switches User Worker uploads all at once. Treat preview validation as a release gate; do not imply gradual rollout. + +## Low-level resource recovery + +Managed resources belong to one Worker environment. Preview and production use +separate physical resources even when binding names match. Normal projects use +the commands in “Resource state without drift”; the commands below are for +recovery and custom control-plane automation and do not update +`xapi.worker.json`. + +```bash +# Inspect the active provider permissions first. A failed item names the exact +# Cloudflare permission that the platform operator must add. +npx xapi-to workers capabilities --format pretty + +# Recovery-only direct live creation +npx xapi-to workers resources create \ + --env preview --type kv --binding STATE + +npx xapi-to workers resources list \ + --env preview --format table +``` + +After a direct low-level create or delete, run `resources pull` or reconcile the +project declaration manually before the next deployment. Treat an `ERROR` +resource as unavailable and surface its provider error. + +xAPI Queue creation includes the producer binding, an isolated Cloudflare Queue, +and an xAPI-managed consumer. The User Worker sends a route envelope; the +managed consumer delivers it back to the same Worker environment over an +internal Cloudflare Service Binding: + +```js +await env.TASK_QUEUE.send({ + path: "/tasks/summarize", + method: "POST", + body: { taskId: "task_123", objectKey: "uploads/report.pdf" }, +}); +``` + +`path` must be a local absolute path. Supported methods are `GET`, `POST`, +`PUT`, `PATCH`, and `DELETE`; an omitted path uses `/__xapi/queue`. Delivery is +at least once: a successful `2xx` response is acknowledged, while a failure is +retried. Tell users to make the target route idempotent by stable task ID and +to pass resource identifiers instead of secrets in the message body. + +Workflow creation installs a managed Workflow host. User code starts an +instance with `env.AGENT_WORKFLOW.create({ params: { path, method, body } })`, +saves the returned ID, and calls +`(await env.AGENT_WORKFLOW.get(id)).status()` until `complete`, `errored`, or +`terminated`. A returned instance ID means accepted, not completed. + +```js +export default { + async fetch(request, env) { + const url = new URL(request.url); + if (url.pathname === "/tasks/summarize") { + const task = await request.json(); + // Check task.taskId before executing so Queue retries are harmless. + return Response.json({ completed: true, taskId: task.taskId }); + } + await env.STATE.put("last-request", new Date().toISOString()); + const rows = await env.DB.prepare("SELECT id, title FROM tasks").all(); + await env.FILES.put("latest.json", JSON.stringify(rows.results)); + return Response.json({ ok: true, tasks: rows.results }); + }, +}; +``` + +D1 tables still require an application migration or explicit initialization query. Resource creation does not invent the user's schema. R2 buckets must be empty before Cloudflare allows deletion. + +Do not treat resource creation alone as validation. After redeploying, perform +a write/read round trip for a Durable Object, send a Queue message and observe +the target route's durable result, and start a Workflow then poll it to a +terminal state. + +## Run persistent schedules + +xAPI stores schedules in the control plane rather than inside a short-lived Sandbox. It evaluates the cron expression in the declared IANA timezone, leases each run to prevent duplicate execution across replicas, retries failures up to the configured limit, and keeps run history. + +```bash +npx xapi-to workers schedules create \ + --name "refresh research digest" \ + --cron "0 */6 * * *" \ + --timezone Asia/Shanghai \ + --env production \ + --path /tasks/refresh \ + --method POST \ + --body '{"source":"scheduled"}' + +npx xapi-to workers schedules list --format table +npx xapi-to workers schedules run +npx xapi-to workers schedules runs --format table +npx xapi-to workers schedules pause +npx xapi-to workers schedules resume +``` + +An immediate run exercises the same lease, retry, audit, budget, and Worker route as a cron run. Use it as the release check before enabling production schedules. A schedule is persistent metadata; it does not mean a Worker process stays alive between requests. + +### Encrypted Secrets + +Prefer `--from-env` so plaintext does not appear in shell history. The control plane encrypts the value at rest and public reads expose only binding name, version, and timestamps. When a script is already active, rotation is applied immediately; otherwise it is applied during the next deployment. + +```bash +export MODEL_KEY='...' +npx xapi-to workers secrets set MODEL_KEY \ + --env preview --from-env MODEL_KEY +npx xapi-to workers secrets list --env preview +unset MODEL_KEY +``` + +Never print the value to verify it. User code can report only whether a secret is configured. Delete explicitly when no longer needed: + +```bash +npx xapi-to workers secrets delete MODEL_KEY \ + --env preview --yes +``` + +## Consumption queries + +For the dedicated deployment and cost-reconciliation workflow, install the bundled `xapi-workers` skill. Query `workers billing overview --env preview --json` and `workers billing ledger --env preview --all --json`. The latter follows all pages at one snapshot and fails instead of silently truncating; its completion flag only describes pagination. Reuse its `snapshotTime` with overview `--snapshot-time` before comparing totals. Customer accrued charges, platform-funded amounts and frozen reserves are distinct; missing observations are unknown, not zero. A snapshot covers its UTC billing day, not all history. + +## Inspect and manage + +```bash +npx xapi-to workers list --format table +npx xapi-to workers get --format pretty +npx xapi-to workers audit --format table +npx xapi-to workers invocations --env preview --format table +npx xapi-to workers logs --env preview --format table +npx xapi-to workers usage --env preview --format pretty +npx xapi-to workers billing-status --format pretty +npx xapi-to workers domains list --format table +npx xapi-to workers artifacts --format table +npx xapi-to workers builds --format table +npx xapi-to workers bindings --format table +npx xapi-to workers resources list --env preview --format table +npx xapi-to workers secrets list --env preview --format table +npx xapi-to workers provider-status +npx xapi-to workers capabilities --format table +npx xapi-to workers artifact-provider-status +npx xapi-to workers build-provider-status +npx xapi-to workers budget preview --daily-usd 0.50 +``` + +`invocations` shows request metadata and aggregate performance. `logs` reads Tail Worker console messages, exceptions, and traces; request/response bodies, headers, and query strings are deliberately excluded. `usage` shows authorization reservations, actual Tail-settled CPU charges, refunds, and the Cloudflare GraphQL reconciliation gap. + +The dispatcher preauthorizes the maximum per-request charge before executing user code. It rejects exhausted account/API-key balances and environment daily budgets before dispatch; Tail telemetry settles actual CPU and refunds the unused reservation. If billing authorization is unavailable while enforcement is enabled, execution fails closed. + +Each environment receives an exact managed hostname. Use `publicUrl` immediately; it falls back to the shared dispatcher until the dedicated hostname reaches `ACTIVE`. The control plane attaches a Cloudflare Worker Custom Domain, waits for DNS and TLS, and detaches it during Worker deletion. On `ERROR`, inspect the recorded reason and retry explicitly: + +```bash +npx xapi-to workers domains list --format pretty +npx xapi-to workers domains retry +``` + +Bindings are risk-tiered. A catalog entry describes product policy; `workers capabilities` is the live Cloudflare token preflight. A failed D1 item, for example, must identify `D1 Edit` and block D1 creation while leaving unrelated resources usable. + +## Delete safely + +Only delete when the user asked for it. The CLI requires explicit confirmation: + +```bash +npx xapi-to workers delete --yes +``` + +The backend preflights managed resources (for example, R2 must be empty), deletes active upstream scripts and resources, then marks the Worker soft-deleted. Records have a 30-day retention window. A partial upstream failure leaves the Worker in `DELETING`; report the error and do not say the Worker is deleted or active. + +## D1 and R2 data location + +Choose the expected primary data-access region when a project creates D1 or R2. The Worker code itself remains globally deployed on Cloudflare's edge network. + +```json +{ + "type": "d1_database", + "bindingName": "DB", + "location": "apac", + "readReplication": "disabled" +} +``` + +Supported location hints are `wnam`, `enam`, `weur`, `eeur`, `apac`, and `oc`. `readReplication` is D1-only and accepts `auto` or `disabled`. Omitting these fields preserves the existing compatible behavior. + +Location is creation-time placement. Changing it on an existing binding is blocked because Cloudflare cannot move an existing D1 database or R2 bucket in place. Create a new binding, migrate and verify the data, switch the application binding, and retain the old resource for rollback before deleting it. diff --git a/src/commands/workers.ts b/src/commands/workers.ts new file mode 100644 index 0000000..ae4cbc4 --- /dev/null +++ b/src/commands/workers.ts @@ -0,0 +1,1636 @@ +import { randomUUID } from "node:crypto"; +import { readdir, readFile, stat } from "node:fs/promises"; +import { relative, resolve, sep } from "node:path"; +import { XAPI_ACTION_HOST, XAPI_API_HOST, getConfig, requireApiKey } from "../config.ts"; +import { err, output, type OutputFormat } from "../format.ts"; +import * as client from "../workers-client.ts"; +import { + initWorkerProject, + type WorkerStarterTemplate, +} from "../workers-init.ts"; +import { listWorkerTemplates } from "../workers-templates.ts"; +import { importWranglerProject } from "../workers-wrangler-import.ts"; +import { createWorkerPlan } from "../workers-plan.ts"; +import { + formatWorkerPlan, + useHumanWorkerPlanOutput, +} from "../workers-plan-output.ts"; +import { pushWorkerProject, WorkerPushError } from "../workers-push.ts"; +import { + formatWorkerPushResult, + useHumanWorkerPushOutput, +} from "../workers-push-output.ts"; +import { promoteWorkerProject } from "../workers-promote.ts"; +import { rollbackWorkerProject } from "../workers-rollback.ts"; +import { readWorkerLogs, tailWorkerLogs } from "../workers-logs.ts"; +import { + addProjectResource, + destroyProjectResource, + pullProjectResources, + removeProjectResource, + updateProjectResource, +} from "../workers-project-resources.ts"; +import { collectWorkerBillingLedger } from "../workers-billing-ledger.ts"; +import { formatWorkerMetering } from "../workers-metering-output.ts"; +import { + loadWorkerArtifactInput, + WorkerArtifactError, +} from "../workers-artifact.ts"; +import { + printWorkerBillingResponse, + workerBillingOutputMode, +} from "../workers-billing-output.ts"; +import { bindXdomainWorker } from "../workers-domain-bind.ts"; + +export const WORKERS_HELP = `xapi-to workers - Deploy and manage xAPI-hosted Cloudflare Workers + +USAGE + xapi-to workers [args] [flags] + +COMMANDS + templates + init [directory] --template TEMPLATE + plan --env preview|production + push --env preview + promote --to production [--artifact ARTIFACT_ID] + rollback --env preview|production (--to previous | --deployment DEPLOYMENT_ID) + list + get + create --name NAME --slug SLUG --preview-budget USD --production-budget USD + upload --file dist/index.mjs|dist/ [--main worker.js] + artifacts + build --project . --entrypoint src/index.ts --command "npm run build" + builds + deploy --artifact ARTIFACT_ID --env preview|production + budget --daily-usd USD + audit + invocations --env preview|production + logs --env preview|production [--tail] [--since 10m] + usage [--env preview|production] + metering --env preview|production [--json] + billing-status + billing ledger --env ENV [--all] [--snapshot-time ISO] [--json] + retention show|quote|accept|pause|resume|keep-paused|delete --env ENV + billing prices|overview|usage|ledger|forecast|risk|lifecycle --env preview|production + domains list + domains attach --env ENV --xdomain-domain-id ID [--subdomain @] + domains detach --yes + domains retry + schedules list + schedules create --name NAME --cron "*/15 * * * *" --env preview --path /cron + schedules runs + schedules run + schedules pause|resume + schedules delete --yes + bindings + resources add --env preview|production|both --type TYPE --binding NAME + resources update --env preview|production|both --type TYPE --binding NAME + resources pull --env preview|production|both + resources remove --env preview|production|both --binding NAME + resources destroy --env preview|production --binding NAME --yes + secrets list --env preview|production + secrets set --env ENV --from-env VARIABLE + secrets delete --env ENV --yes + provider-status + capabilities + artifact-provider-status + build-provider-status + delete --yes + +CREATE FLAGS + --template worker|agent Official starter type (default: worker) + --description TEXT + --preview-budget 0.10..100 Explicit preview daily budget + --production-budget 0.10..100 Explicit production daily budget + +INIT FLAGS + --template TEMPLATE worker|agent|chat|webhook|persistent-agent + --from-wrangler PATH Import an existing wrangler.jsonc or wrangler.toml + --accept-partial Write only after explicitly accepting unsupported fields + --name NAME Worker display name + --slug SLUG Stable lowercase Worker slug + --preview-budget 0.10..100 Default: 0.25 + --production-budget 0.10..100 Default: 2 + --force Overwrite template-managed files only + --framework auto|react|vite|vue|next Override existing package detection + +PLAN FLAGS + --env preview|production Environment to compare (required) + --config PATH Explicit xapi.worker.json path + Interactive terminals show a review view by default; use --format json for CI + +PUSH FLAGS + --env preview Required; production uses workers promote + --config PATH Explicit xapi.worker.json path + --non-interactive CI mode; never bypasses BLOCKED checks + --retention-price-version VERSION Explicit accepted freeze quote; does not auto-accept policy + +PROMOTE FLAGS + --to production Required explicit production target + --artifact ARTIFACT_ID ACTIVE preview Artifact (default: latest) + --config PATH Explicit xapi.worker.json path + --non-interactive CI mode after all production preflights pass + --retention-price-version VERSION Explicit accepted freeze quote for new production resources + +ROLLBACK FLAGS + --env preview|production Environment whose code will be rolled back + --to previous Select the latest different successful version + --deployment DEPLOYMENT_ID Select an explicit historical deployment + --config PATH Explicit xapi.worker.json path + --non-interactive Explicit CI confirmation for production + +LOG FLAGS + --tail Poll continuously; Ctrl-C stops cleanly + --since 30s|10m|2h Include only recent log events + --level debug|info|log|warn|error Filter by exact log level + --request-id ID Filter one xAPI request trace + --deployment DEPLOYMENT_ID Filter by the derived deployment timeline + +BILLING FLAGS + --env preview|production Environment to inspect (required) + --json Emit the public API schema unchanged + --snapshot-time ISO Reuse one coherent Backend snapshot + --from ISO --to ISO Usage range on five-minute boundaries + --metric NAME --resource-id ID Filter usage or ledger + --cursor OPAQUE --limit 1..100 Page ledger without decoding its cursor + +UPLOAD FLAGS + --file PATH Single ES module or code-module directory (required) + --main PATH Entrypoint relative to --file when it is a directory + --idempotency-key KEY Stable retry key (generated when omitted) + +OPTIONAL SANDBOX BUILD FLAGS + --project PATH Project directory (default: current directory) + --entrypoint PATH Source entrypoint inside the project (required) + --command COMMAND Sandbox build command (required) + --output PATH Bundled ES module path (default: dist/index.mjs) + --idempotency-key KEY Stable retry key (generated when omitted) + +DEPLOY FLAGS + --artifact ARTIFACT_ID Immutable uploaded or built artifact (required) + --env preview|production Target environment (default: preview) + --compatibility-date YYYY-MM-DD + --compatibility-flags a,b + --idempotency-key KEY Stable retry key (generated when omitted) + +RESOURCE FLAGS + --env preview|production|both Project resource environment + --config PATH Explicit xapi.worker.json path + --type kv|d1|r2|do|queue|workflow + --class-name NAME Exported class for a Durable Object + --location REGION D1/R2 placement: wnam|enam|weur|eeur|apac|oc + --read-replication MODE D1 replicas: auto|disabled + --binding NAME Uppercase env binding, for example STATE or FILES + --yes Required for physical resource destruction + +ADVANCED REMOTE RESOURCE COMMANDS + These recovery/debug commands mutate live state without updating xapi.worker.json. + resources list --env preview|production + resources create --env ENV --type TYPE --binding NAME + resources delete --env ENV --yes + +SECRET FLAGS + --from-env VARIABLE Read value from a local environment variable + --value VALUE Direct value (prefer --from-env to avoid shell history) + +AUTHORIZATION + API keys need workers:read for reads and workers:write for mutations. + XAPI_KEY overrides XAPI_API_KEY and ~/.xapi/config.json. + +EXAMPLES + xapi-to workers templates + xapi-to workers init my-agent --template persistent-agent + xapi-to workers init . --framework vite + xapi-to workers init --from-wrangler ./wrangler.jsonc + xapi-to workers plan --env preview --format json + xapi-to workers push --env preview + xapi-to workers promote --to production + xapi-to workers rollback --env production --to previous + xapi-to workers create --name "Daily agent" --slug daily-agent \ + --preview-budget 0.25 --production-budget 2 + xapi-to workers upload --file dist/index.mjs + xapi-to workers upload --file dist/ --main worker.js + xapi-to workers deploy --artifact --env preview + xapi-to workers billing overview --env production + xapi-to workers billing usage --env production --json + xapi-to workers build --entrypoint src/index.ts --command "npm run build" + xapi-to workers resources add --env both --type d1 --binding DB + xapi-to workers resources update --env preview --type d1 --binding DB --location weur + xapi-to workers resources pull --env preview + DEEPSEEK_KEY=... xapi-to workers secrets set MODEL_KEY \ + --env preview --from-env DEEPSEEK_KEY + xapi-to workers list --format table +`; + +export const WORKERS_INIT_HELP = `xapi-to workers init - Initialize an xAPI Worker project + +USAGE + xapi-to workers init [directory] [flags] + +STARTING POINTS + New Worker + xapi workers init my-agent --template persistent-agent + + Existing React, Vite, Vue, or static Next.js package + cd app && xapi workers init + Detection preserves existing dev, build, and test scripts. + + Existing Worker with Wrangler + xapi workers init --from-wrangler ./wrangler.jsonc + + Next.js SSR + Run vinext check/init first, then import its generated Wrangler config. + +WRITES + Existing frontends gain xapi.worker.json, wrangler.jsonc, + xapi-worker/index.ts, and xapi:* package scripts. Re-running init is not a + resource synchronization operation. + +FLAGS + --template worker|agent|chat|webhook|persistent-agent + --framework auto|react|vite|vue|next + --from-wrangler PATH + --accept-partial + --name NAME + --slug SLUG + --preview-budget USD + --production-budget USD + --force +`; + +export const WORKERS_RESOURCES_HELP = `xapi-to workers resources - Reconcile managed Worker resources + +STATE MODEL + xapi.worker.json is desired state. xAPI is live state. workers plan reads and + compares both; the CLI keeps no third cached state file. + +PROJECT COMMANDS + add Declare a new resource locally; plan then push/promote. + update Replace one complete existing declaration locally; linked resource + type and Durable Object class cannot change in place. + pull Adopt supported healthy live-only resources into desired state. + remove Stop declaring a resource; live data remains and may keep billing. + destroy Remove one environment declaration and request live data deletion; + requires --yes and a prior backup. + +USAGE + xapi workers resources add --env preview|production|both --type TYPE --binding NAME + xapi workers resources update --env preview|production|both --type TYPE --binding NAME + xapi workers resources pull --env preview|production|both + xapi workers resources remove --env preview|production|both --binding NAME + xapi workers resources destroy --env preview|production --binding NAME --yes + +RESOURCE FLAGS + --type kv|d1|r2|do|queue|workflow + --class-name NAME + --location wnam|enam|weur|eeur|apac|oc + --read-replication auto|disabled + --config PATH + +SAFE FLOW + xapi workers resources add --env preview --type d1 --binding DB --location apac + xapi workers plan --env preview + xapi workers push --env preview + + Live location and D1 replication changes cannot be updated in place. Create a + new binding, migrate data, and switch explicitly when plan reports BLOCKED. + +ADVANCED LIVE-ONLY COMMANDS + list/create/delete operate on live state without updating the + project file. Use them only for recovery or custom control-plane automation. +`; + +const COMMON_FLAGS = new Set(["help", "format"]); + +function options() { + const cfg = getConfig(); + requireApiKey(cfg); + return { apiHost: XAPI_API_HOST, apiKey: cfg.apiKey! }; +} + +function printWorkerPlan( + plan: Awaited>, + flagFormat?: string, +) { + if ( + useHumanWorkerPlanOutput({ + flagFormat, + envFormat: process.env.XAPI_OUTPUT, + stdoutIsTTY: process.stdout.isTTY, + }) + ) { + console.log(formatWorkerPlan(plan)); + return; + } + output(plan, flagFormat as OutputFormat | undefined); +} + +function printWorkerPushResult( + result: Awaited>, + flagFormat?: string, +) { + if ( + useHumanWorkerPushOutput({ + flagFormat, + envFormat: process.env.XAPI_OUTPUT, + stdoutIsTTY: process.stdout.isTTY, + }) + ) { + console.log(formatWorkerPushResult(result)); + return; + } + output(result, flagFormat as OutputFormat | undefined); +} + +function required(value: string | undefined, flag: string): string { + if (!value || value === "true") err(`${flag} is required`); + return value; +} + +function budget(value: string | undefined, flag: string): number { + const amount = Number(required(value, flag)); + if (!Number.isFinite(amount) || amount < 0.1 || amount > 100) { + err(`${flag} must be between 0.10 and 100`); + } + return amount; +} + +function durationMs(value: string | undefined, fallback: number): number { + if (!value) return fallback; + const match = /^(\d+)(ms|s|m)$/.exec(value); + if (!match) err("--timeout must use ms, s, or m, for example 120s"); + const amount = Number(match![1]); + const scale = match![2] === "ms" ? 1 : match![2] === "s" ? 1_000 : 60_000; + const result = amount * scale; + if (!Number.isSafeInteger(result) || result < 1_000 || result > 10 * 60_000) { + err("--timeout must be between 1s and 10m"); + } + return result; +} + +function assertFlags( + flags: Record, + allowed: readonly string[] = [], +): void { + const valid = new Set([...COMMON_FLAGS, ...allowed]); + const unknown = Object.keys(flags).filter((flag) => !valid.has(flag)); + if (unknown.length) { + err( + `unknown workers flag: ${unknown.map((flag) => `--${flag}`).join(", ")}`, + { + validFlags: [...valid].sort().map((flag) => `--${flag}`), + }, + ); + } +} + +function oneId(args: string[], usage: string): string { + if (args.length !== 1) err(usage); + return args[0]; +} + +function environment(value: string | undefined): string { + const result = required(value, "--env"); + if (!["preview", "production"].includes(result)) { + err("--env must be preview or production"); + } + return result; +} + +const IGNORED_DIRECTORIES = new Set([ + ".aws", + ".docker", + ".git", + ".gnupg", + ".ssh", + ".xapi", + "dist", + "node_modules", +]); +const SECRET_FILE = + /^(?:\.env(?:\..*)?|\.git-credentials|\.netrc|\.npmrc|\.yarnrc(?:\..*)?|.*\.(?:pem|key|p12|pfx)|id_(?:rsa|ecdsa|ed25519))$/i; + +async function projectFiles(project: string) { + const files: Array<{ + path: string; + content: string; + encoding: "utf8" | "base64"; + }> = []; + let total = 0; + async function walk(directory: string): Promise { + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (entry.isSymbolicLink()) continue; + if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name)) continue; + if (SECRET_FILE.test(entry.name)) continue; + const absolute = resolve(directory, entry.name); + if (entry.isDirectory()) { + await walk(absolute); + continue; + } + if (!entry.isFile()) continue; + const info = await stat(absolute); + if (info.size > 1_000_000) err(`source file exceeds 1 MB: ${absolute}`); + const buffer = await readFile(absolute); + total += buffer.length; + if (total > 2 * 1024 * 1024) + err("Worker project exceeds the 2 MB source limit"); + if (files.length >= 200) err("Worker project exceeds the 200 file limit"); + const path = relative(project, absolute).split(sep).join("/"); + const binary = buffer.includes(0); + files.push({ + path, + content: binary ? buffer.toString("base64") : buffer.toString("utf8"), + encoding: binary ? "base64" : "utf8", + }); + } + } + await walk(project); + return files; +} + +export async function workersCommand( + args: string[], + flags: Record, +): Promise { + if (flags.help) { + if (args[0] === "init") { + console.log(WORKERS_INIT_HELP); + return; + } + if (args[0] === "resources") { + console.log(WORKERS_RESOURCES_HELP); + return; + } + console.log(WORKERS_HELP); + return; + } + if (args.length === 0) { + console.log(WORKERS_HELP); + return; + } + const [command, ...rest] = args; + switch (command) { + case "templates": { + assertFlags(flags); + if (rest.length) err("usage: xapi-to workers templates"); + output(listWorkerTemplates()); + return; + } + case "init": { + assertFlags(flags, [ + "template", + "from-wrangler", + "accept-partial", + "name", + "slug", + "preview-budget", + "production-budget", + "force", + "framework", + ]); + if (rest.length > 1) { + err("usage: xapi-to workers init [directory] [flags]"); + } + if (Object.hasOwn(flags, "from-wrangler")) { + if (!flags["from-wrangler"] || flags["from-wrangler"] === "true") { + err("--from-wrangler requires a .jsonc, .json, or .toml path"); + } + if (rest.length || flags.template || flags.name || flags.slug || flags.framework) { + err( + "--from-wrangler cannot be combined with a target directory, --template, --name, --slug, or --framework", + ); + } + if (flags["accept-partial"] && flags["accept-partial"] !== "true") { + err("--accept-partial does not accept a value"); + } + if (flags.force && flags.force !== "true") { + err("--force does not accept a value"); + } + let result; + try { + result = importWranglerProject({ + wranglerPath: flags["from-wrangler"], + acceptPartial: flags["accept-partial"] === "true", + force: flags.force === "true", + previewDailyBudgetUsd: flags["preview-budget"] + ? budget(flags["preview-budget"], "--preview-budget") + : 0.25, + productionDailyBudgetUsd: flags["production-budget"] + ? budget(flags["production-budget"], "--production-budget") + : 2, + }); + } catch (error) { + err( + error instanceof Error + ? error.message + : "Unable to import Wrangler project", + ); + } + output(result); + if (!result.wrote) { + err( + "Wrangler import contains unsupported fields; no files were written", + ); + } + return; + } + if (flags["accept-partial"]) { + err("--accept-partial is only valid with --from-wrangler"); + } + if (flags.framework === "true" || flags.framework === "") { + err("--framework requires auto, react, vite, vue, or next"); + } + const template = (flags.template || "worker") as WorkerStarterTemplate; + if (flags.force && flags.force !== "true") { + err("--force does not accept a value"); + } + try { + output( + initWorkerProject({ + target: rest[0] || ".", + template, + name: flags.name === "true" ? undefined : flags.name, + slug: flags.slug === "true" ? undefined : flags.slug, + previewDailyBudgetUsd: flags["preview-budget"] + ? budget(flags["preview-budget"], "--preview-budget") + : 0.25, + productionDailyBudgetUsd: flags["production-budget"] + ? budget(flags["production-budget"], "--production-budget") + : 2, + force: flags.force === "true", + framework: flags.framework === "true" ? undefined : flags.framework, + }), + ); + } catch (error) { + err( + error instanceof Error + ? error.message + : "Unable to initialize Worker project", + ); + } + return; + } + case "plan": { + assertFlags(flags, ["env", "config"]); + if (rest.length) err("usage: xapi-to workers plan --env ENV"); + if (flags.config === "true" || flags.config === "") { + err("--config requires a path"); + } + const plan = await createWorkerPlan({ + environment: environment(flags.env) as "preview" | "production", + configPath: flags.config, + clientOptions: options(), + }); + printWorkerPlan(plan, flags.format); + return; + } + case "retention": { + assertFlags(flags, ["env", "type", "price-version", "yes"]); + const [action, id, ...extra] = rest; + if (!id || extra.length || !["show", "quote", "accept", "pause", "resume", "keep-paused", "delete"].includes(action)) err("usage: workers retention show|quote|accept|pause|resume|keep-paused|delete --env ENV"); + const env = environment(flags.env); + if (["accept", "delete"].includes(action) && flags.yes !== "true") err("--yes is required to accept automatic reserve-exhaustion deletion or delete this environment"); + const result = await client.workerRetention(options(), id, env, + action === "quote" ? `/quote/${encodeURIComponent(flags.type || "WORKER")}` : action === "accept" ? "/accept" : action === "show" ? "" : "/actions", + action === "accept" ? { policyVersion: "retention-v3", automaticDeletionAccepted: true, priceVersion: required(flags["price-version"], "--price-version") } : ["show", "quote"].includes(action) ? undefined : { action }); + if (flags.format === "json") output(result); + else { + const state = result.lifecycle || result; + console.log(`Worker ${id} · ${env}\nState: ${state.state || (result.enabled === false ? "retention disabled" : "quote / policy")}`); + for (const [label, key] of [["Available balance", "availableBalanceUsd"], ["Freeze quote", "freezeUsd"], ["Available after freeze (estimate)", "availableAfterFreezeUsd"], ["Reserve target", "targetUsd"], ["Reserve remaining", "remainingUsd"], ["Reserve consumed", "consumedUsd"], ["Reserve released", "releasedUsd"]]) { + if (result[key] != null) console.log(`${label}: $${result[key]}`); + } + if (result.priceVersion) console.log(`Price version: ${result.priceVersion}`); + if (result.retentionHours) console.log(`Retention: ${result.retentionHours} hours. Automatic deletion at expiry.`); + if (result.estimateBasisHours) console.log(`Freeze estimate basis: ${result.estimateBasisHours} hours (not a fixed retention period).`); + if (result.policyVersion === "retention-v3" || state.retentionPolicyVersion === "retention-v3") { + console.log("Manual recovery only. Deposits do not replenish reserve or resume execution. Cleanup starts when this environment reserve reaches its cleanup allowance, even if the account has available funds."); + if (result.reserveBudget) console.log(`Remaining for retention: $${result.reserveBudget.retentionSpendableUsd ?? "unknown"}; cleanup allowance: $${result.reserveBudget.cleanupReserveUsd ?? "unknown"}`); + } + if (state.pauseReason) console.log(`Pause reason: ${state.pauseReason}`); + if (result.fundingSource) console.log(`Funding: ${result.fundingSource}`); + if (state.graceDeadlineAt) console.log(`Deletion deadline: ${state.graceDeadlineAt}`); + console.log("Frozen funds remain yours; freezing is not a consumption charge. Use --format json for full evidence."); + } + return; + } + case "push": { + assertFlags(flags, ["env", "config", "non-interactive", "retention-price-version"]); + if (flags["retention-price-version"] === "true" || flags["retention-price-version"] === "") { + err("--retention-price-version requires the explicitly accepted quote version"); + } + if (rest.length) err("usage: xapi-to workers push --env preview"); + if (flags.config === "true" || flags.config === "") { + err("--config requires a path"); + } + if (flags["non-interactive"] && flags["non-interactive"] !== "true") { + err("--non-interactive does not accept a value"); + } + const selectedEnvironment = environment(flags.env); + if (selectedEnvironment !== "preview") { + err( + "workers push only accepts --env preview; use workers promote for production", + ); + } + const nonInteractive = flags["non-interactive"] === "true"; + try { + const result = await pushWorkerProject({ + environment: "preview", + configPath: flags.config, + clientOptions: options(), + nonInteractive, + retentionPriceVersion: flags["retention-price-version"], + onPlan: nonInteractive + ? undefined + : (plan) => printWorkerPlan(plan, flags.format), + }); + printWorkerPushResult(result, flags.format); + } catch (error) { + if (error instanceof WorkerPushError) { + err(error.message, error.recovery); + } + err(error instanceof Error ? error.message : "Preview push failed"); + } + return; + } + case "promote": { + assertFlags(flags, [ + "to", + "artifact", + "config", + "non-interactive", + "retention-price-version", + ]); + if (rest.length) err("usage: xapi-to workers promote --to production"); + if (flags.to !== "production") { + err("workers promote requires --to production"); + } + if (flags.artifact === "true" || flags.artifact === "") { + err("--artifact requires an Artifact ID"); + } + if (flags.config === "true" || flags.config === "") { + err("--config requires a path"); + } + if (flags["non-interactive"] && flags["non-interactive"] !== "true") { + err("--non-interactive does not accept a value"); + } + if ( + flags["retention-price-version"] === "true" || + flags["retention-price-version"] === "" + ) { + err("--retention-price-version requires the explicitly accepted quote version"); + } + const nonInteractive = flags["non-interactive"] === "true"; + try { + output( + await promoteWorkerProject({ + to: "production", + artifactId: flags.artifact, + configPath: flags.config, + clientOptions: options(), + nonInteractive, + retentionPriceVersion: flags["retention-price-version"], + onPlan: nonInteractive ? undefined : (plan) => output(plan), + }), + ); + } catch (error) { + if (error instanceof WorkerPushError) { + err(error.message, error.recovery); + } + err( + error instanceof Error + ? error.message + : "Production promotion failed", + ); + } + return; + } + case "rollback": { + assertFlags(flags, [ + "env", + "to", + "deployment", + "config", + "non-interactive", + ]); + if (rest.length) { + err( + "usage: xapi-to workers rollback --env ENV (--to previous | --deployment DEPLOYMENT_ID)", + ); + } + const selectedEnvironment = environment(flags.env) as + | "preview" + | "production"; + if (flags.to && flags.to !== "previous") { + err("--to currently supports only previous"); + } + if (flags.deployment === "true" || flags.deployment === "") { + err("--deployment requires a Deployment ID"); + } + if ((flags.to === "previous") === !!flags.deployment) { + err( + "choose exactly one of --to previous or --deployment DEPLOYMENT_ID", + ); + } + if (flags.config === "true" || flags.config === "") { + err("--config requires a path"); + } + if (flags["non-interactive"] && flags["non-interactive"] !== "true") { + err("--non-interactive does not accept a value"); + } + const nonInteractive = flags["non-interactive"] === "true"; + try { + output( + await rollbackWorkerProject({ + environment: selectedEnvironment, + to: flags.to === "previous" ? "previous" : undefined, + deploymentId: flags.deployment, + configPath: flags.config, + clientOptions: options(), + nonInteractive, + onPlan: nonInteractive ? undefined : (plan) => output(plan), + }), + ); + } catch (error) { + if (error instanceof WorkerPushError) { + err(error.message, error.recovery); + } + err(error instanceof Error ? error.message : "Worker rollback failed"); + } + return; + } + case "list": + assertFlags(flags); + if (rest.length) err("usage: xapi-to workers list"); + output(await client.listWorkers(options())); + return; + case "get": + assertFlags(flags); + output( + await client.getWorker( + options(), + oneId(rest, "usage: xapi-to workers get "), + ), + ); + return; + case "create": { + assertFlags(flags, [ + "name", + "slug", + "description", + "template", + "preview-budget", + "production-budget", + ]); + if (rest.length) err("usage: xapi-to workers create [flags]"); + const template = flags.template || "worker"; + if (!["worker", "agent"].includes(template)) + err("--template must be worker or agent"); + output( + await client.createWorker(options(), { + name: required(flags.name, "--name"), + slug: required(flags.slug, "--slug"), + description: flags.description, + template, + previewDailyBudgetUsd: budget( + flags["preview-budget"], + "--preview-budget", + ), + productionDailyBudgetUsd: budget( + flags["production-budget"], + "--production-budget", + ), + }), + ); + return; + } + case "upload": { + assertFlags(flags, ["file", "main", "idempotency-key"]); + const id = oneId( + rest, + "usage: xapi-to workers upload --file PATH", + ); + if (flags.main === "true" || flags.main === "") { + err("--main requires a path relative to the output directory"); + } + let artifact; + try { + artifact = await loadWorkerArtifactInput( + resolve(required(flags.file, "--file")), + flags.main, + ); + } catch (error) { + if (error instanceof WorkerArtifactError) err(error.message); + throw error; + } + output( + await client.uploadWorkerArtifact(options(), id, { + ...artifact.upload, + idempotencyKey: flags["idempotency-key"] || randomUUID(), + }), + ); + return; + } + case "artifacts": + assertFlags(flags); + output( + await client.listWorkerArtifacts( + options(), + oneId(rest, "usage: xapi-to workers artifacts "), + ), + ); + return; + case "build": { + assertFlags(flags, [ + "project", + "entrypoint", + "command", + "output", + "idempotency-key", + ]); + const id = oneId( + rest, + "usage: xapi-to workers build --entrypoint PATH --command COMMAND", + ); + const project = resolve(flags.project || "."); + const entrypoint = required(flags.entrypoint, "--entrypoint").replace( + /^\.\//, + "", + ); + const files = await projectFiles(project).catch((error) => { + err(`cannot read Worker project: ${project}`, error.message); + }); + output( + await client.createWorkerBuild(options(), id, { + files, + entrypoint, + buildCommand: required(flags.command, "--command"), + outputPath: (flags.output || "dist/index.mjs").replace(/^\.\//, ""), + idempotencyKey: flags["idempotency-key"] || randomUUID(), + }), + ); + return; + } + case "builds": + assertFlags(flags); + output( + await client.listWorkerBuilds( + options(), + oneId(rest, "usage: xapi-to workers builds "), + ), + ); + return; + case "deploy": { + assertFlags(flags, [ + "artifact", + "env", + "compatibility-date", + "compatibility-flags", + "idempotency-key", + "retention-price-version", + ]); + const id = oneId( + rest, + "usage: xapi-to workers deploy --artifact ARTIFACT_ID [--env preview]", + ); + const environment = flags.env || "preview"; + if (!["preview", "production"].includes(environment)) + err("--env must be preview or production"); + output( + await client.deployWorker(options(), id, { + environment, + artifactId: required(flags.artifact, "--artifact"), + retentionPriceVersion: flags["retention-price-version"], + idempotencyKey: flags["idempotency-key"] || randomUUID(), + compatibilityDate: flags["compatibility-date"], + compatibilityFlags: flags["compatibility-flags"] + ?.split(",") + .map((item) => item.trim()) + .filter(Boolean), + }), + ); + return; + } + case "budget": { + assertFlags(flags, ["daily-usd"]); + if (rest.length !== 2) + err( + "usage: xapi-to workers budget --daily-usd USD", + ); + if (!["preview", "production"].includes(rest[1])) + err("environment must be preview or production"); + output( + await client.updateWorkerBudget( + options(), + rest[0], + rest[1], + budget(flags["daily-usd"], "--daily-usd"), + ), + ); + return; + } + case "audit": + assertFlags(flags); + output( + await client.workerAuditLogs( + options(), + oneId(rest, "usage: xapi-to workers audit "), + ), + ); + return; + case "invocations": + assertFlags(flags, ["env"]); + output( + await client.workerInvocationLogs( + options(), + oneId( + rest, + "usage: xapi-to workers invocations --env ENV", + ), + environment(flags.env), + ), + ); + return; + case "logs": { + assertFlags(flags, [ + "env", + "tail", + "since", + "level", + "request-id", + "deployment", + ]); + const id = oneId( + rest, + "usage: xapi-to workers logs --env ENV [--tail] [filters]", + ); + const selectedEnvironment = environment(flags.env) as + | "preview" + | "production"; + if (flags.tail && flags.tail !== "true") { + err("--tail does not accept a value"); + } + for (const flag of ["since", "level", "request-id", "deployment"]) { + if (flags[flag] === "true" || flags[flag] === "") { + err(`--${flag} requires a value`); + } + } + const query = { + workerId: id, + environment: selectedEnvironment, + clientOptions: options(), + since: flags.since, + level: flags.level, + requestId: flags["request-id"], + deploymentId: flags.deployment, + }; + if (flags.tail === "true") { + const controller = new AbortController(); + const stop = () => controller.abort(); + process.once("SIGINT", stop); + try { + await tailWorkerLogs({ + ...query, + signal: controller.signal, + onBatch: (batch) => output(batch), + onTransientError: () => + console.error( + JSON.stringify({ + warning: "Worker log poll failed; retrying", + }), + ), + }); + } finally { + process.removeListener("SIGINT", stop); + } + } else { + output(await readWorkerLogs(query)); + } + return; + } + case "usage": + assertFlags(flags, ["env"]); + output( + await client.workerUsage( + options(), + oneId(rest, "usage: xapi-to workers usage [--env ENV]"), + flags.env ? environment(flags.env) : undefined, + ), + ); + return; + case "billing-status": + assertFlags(flags); + if (rest.length) err("usage: xapi-to workers billing-status"); + output(await client.workerBillingStatus(options())); + return; + case "metering": { + assertFlags(flags, ["env", "json"]); + let mode; + try { mode = workerBillingOutputMode(flags); } + catch (error) { err(error instanceof Error ? error.message : String(error)); } + const response = await client.workerMeteredUsage(options(), oneId(rest, "usage: xapi-to workers metering --env ENV [--json]"), environment(flags.env)); + if (mode === "json") output(response); + else console.log(formatWorkerMetering(response)); + return; + } + case "billing": { + const [kindValue, ...billingArgs] = rest; + const kinds = [ + "prices", + "overview", + "usage", + "ledger", + "forecast", + "risk", + "lifecycle", + ] as const; + if (!kinds.includes(kindValue as (typeof kinds)[number])) { + err( + "usage: xapi-to workers billing prices|overview|usage|ledger|forecast|risk|lifecycle --env ENV", + ); + } + const kind = kindValue as (typeof kinds)[number]; + const allowed = ["env", "json"]; + if (kind === "overview") allowed.push("snapshot-time"); + if (kind === "usage") { + allowed.push("snapshot-time", "from", "to", "metric", "resource-id"); + } + if (kind === "ledger") { + allowed.push( + "snapshot-time", + "all", + "cursor", + "limit", + "metric", + "resource-id", + ); + } + assertFlags(flags, allowed); + let mode; + try { + mode = workerBillingOutputMode(flags); + } catch (error) { + err(error instanceof Error ? error.message : String(error)); + } + if (flags.limit) { + const limit = Number(flags.limit); + if (!Number.isInteger(limit) || limit < 1 || limit > 100) { + err("--limit must be an integer from 1 to 100"); + } + } + for (const flag of [ + "snapshot-time", + "from", + "to", + "metric", + "resource-id", + "cursor", + ]) { + if (flags[flag] === "true") err(`--${flag} requires a value`); + } + if (Object.hasOwn(flags, "all") && flags.all !== "true") { + err("--all does not accept a value"); + } + if (flags.all && flags.cursor) err("--all cannot be combined with --cursor"); + const workerId = oneId( + billingArgs, + `usage: xapi-to workers billing ${kind} --env ENV`, + ); + const env = environment(flags.env); + const query: client.WorkerBillingQuery = { + snapshotTime: flags["snapshot-time"], + from: flags.from, + to: flags.to, + metric: flags.metric, + resourceId: flags["resource-id"], + cursor: flags.cursor, + limit: flags.limit, + }; + const fetchPage = (pageQuery: client.WorkerBillingQuery) => + client.workerBillingQuery(options(), workerId, env, kind, pageQuery); + const response = flags.all + ? await collectWorkerBillingLedger(fetchPage, query) + : await fetchPage(query); + printWorkerBillingResponse(kind, response, mode); + return; + } + case "domains": { + const [action, ...domainArgs] = rest; + if (action === "list") { + assertFlags(flags); + output( + await client.listWorkerDomains( + options(), + oneId( + domainArgs, + "usage: xapi-to workers domains list ", + ), + ), + ); + return; + } + if (action === "attach") { + assertFlags(flags, ["env", "xdomain-domain-id", "subdomain", "timeout"]); + const workerId = oneId( + domainArgs, + "usage: xapi-to workers domains attach --env ENV --xdomain-domain-id ID [--subdomain @]", + ); + const cfg = getConfig(); + requireApiKey(cfg); + output( + await bindXdomainWorker({ + workerOptions: options(), + actionOptions: { + actionHost: cfg.actionHost || XAPI_ACTION_HOST, + apiKey: cfg.apiKey!, + }, + workerId, + environment: environment(flags.env) as "preview" | "production", + domainId: required(flags["xdomain-domain-id"], "--xdomain-domain-id"), + subdomain: flags.subdomain || "@", + waitMs: durationMs(flags.timeout, 120_000), + }), + ); + return; + } + if (action === "detach") { + assertFlags(flags, ["yes"]); + if (domainArgs.length !== 2) { + err("usage: xapi-to workers domains detach --yes"); + } + if (flags.yes !== "true") { + err("refusing to detach a custom domain without --yes"); + } + output( + await client.deleteWorkerDomain( + options(), + domainArgs[0], + domainArgs[1], + ), + ); + return; + } + if (action === "retry") { + assertFlags(flags); + if (domainArgs.length !== 2) { + err("usage: xapi-to workers domains retry "); + } + output( + await client.retryWorkerDomain( + options(), + domainArgs[0], + domainArgs[1], + ), + ); + return; + } + err("usage: xapi-to workers domains ..."); + } + case "schedules": { + const [action, ...scheduleArgs] = rest; + if (action === "list") { + assertFlags(flags); + output( + await client.listWorkerSchedules( + options(), + oneId( + scheduleArgs, + "usage: xapi-to workers schedules list ", + ), + ), + ); + return; + } + if (action === "create") { + assertFlags(flags, [ + "name", + "cron", + "env", + "path", + "timezone", + "method", + "body", + "timeout-ms", + "max-retries", + ]); + const id = oneId( + scheduleArgs, + "usage: xapi-to workers schedules create [flags]", + ); + let body: Record | undefined; + if (flags.body) { + try { + body = JSON.parse(flags.body); + } catch { + err("--body must be valid JSON"); + } + } + output( + await client.createWorkerSchedule(options(), id, { + name: required(flags.name, "--name"), + cron: required(flags.cron, "--cron"), + environment: environment(flags.env) as "preview" | "production", + path: required(flags.path, "--path"), + timezone: flags.timezone || "UTC", + method: (flags.method || "POST").toUpperCase(), + body, + timeoutMs: flags["timeout-ms"] + ? Number(flags["timeout-ms"]) + : 30000, + maxRetries: flags["max-retries"] ? Number(flags["max-retries"]) : 2, + }), + ); + return; + } + if (action === "runs") { + assertFlags(flags); + if (scheduleArgs.length !== 2) + err( + "usage: xapi-to workers schedules runs ", + ); + output( + await client.workerScheduleRuns( + options(), + scheduleArgs[0], + scheduleArgs[1], + ), + ); + return; + } + if (action === "run") { + assertFlags(flags); + if (scheduleArgs.length !== 2) + err("usage: xapi-to workers schedules run "); + output( + await client.runWorkerScheduleNow( + options(), + scheduleArgs[0], + scheduleArgs[1], + ), + ); + return; + } + if (action === "pause" || action === "resume") { + assertFlags(flags); + if (scheduleArgs.length !== 2) + err( + `usage: xapi-to workers schedules ${action} `, + ); + output( + await client.updateWorkerSchedule( + options(), + scheduleArgs[0], + scheduleArgs[1], + { enabled: action === "resume" }, + ), + ); + return; + } + if (action === "delete") { + assertFlags(flags, ["yes"]); + if (scheduleArgs.length !== 2) + err( + "usage: xapi-to workers schedules delete --yes", + ); + if (flags.yes !== "true") + err("refusing to delete a schedule without --yes"); + output( + await client.deleteWorkerSchedule( + options(), + scheduleArgs[0], + scheduleArgs[1], + ), + ); + return; + } + err( + "usage: xapi-to workers schedules list|create|runs|pause|resume|delete ...", + ); + } + case "bindings": + assertFlags(flags); + if (rest.length) err("usage: xapi-to workers bindings"); + output(await client.workerBindingCatalog(options())); + return; + case "resources": { + const [action, ...resourceArgs] = rest; + if (action === "destroy") { + assertFlags(flags, ["env", "config", "binding", "yes"]); + if (resourceArgs.length) { + err("usage: xapi-to workers resources destroy --env preview|production --binding NAME --yes"); + } + if (flags.yes !== "true") { + err("refusing to destroy a managed resource without --yes"); + } + output( + await destroyProjectResource({ + configPath: flags.config, + environment: environment(flags.env) as "preview" | "production", + bindingName: required(flags.binding, "--binding"), + clientOptions: options(), + }), + ); + return; + } + if (action === "pull") { + assertFlags(flags, ["env", "config"]); + if (resourceArgs.length) { + err("usage: xapi-to workers resources pull --env preview|production|both"); + } + const selected = required(flags.env, "--env"); + if (!["preview", "production", "both"].includes(selected)) { + err("--env must be preview, production, or both"); + } + const environments = (selected === "both" + ? ["preview", "production"] + : [selected]) as Array<"preview" | "production">; + output( + await pullProjectResources({ + configPath: flags.config, + environments, + clientOptions: options(), + }), + ); + return; + } + if (action === "add" || action === "update" || action === "remove") { + assertFlags(flags, [ + "env", + "config", + "type", + "binding", + "class-name", + "location", + "read-replication", + ]); + if (resourceArgs.length) { + err(`usage: xapi-to workers resources ${action} --env preview|production|both --binding NAME`); + } + const selected = required(flags.env, "--env"); + if (!["preview", "production", "both"].includes(selected)) { + err("--env must be preview, production, or both"); + } + const environments = (selected === "both" + ? ["preview", "production"] + : [selected]) as Array<"preview" | "production">; + const bindingName = required(flags.binding, "--binding"); + if (action === "remove") { + if (flags.type || flags["class-name"] || flags.location || flags["read-replication"]) { + err("resources remove accepts only --env, --binding, and --config"); + } + output( + removeProjectResource({ + configPath: flags.config, + environments, + bindingName, + }), + ); + return; + } + const type = required(flags.type, "--type"); + const typeMap: Record = { + kv: "kv_namespace", + d1: "d1_database", + r2: "r2_bucket", + do: "durable_object", + queue: "queue", + workflow: "workflow", + }; + if (!typeMap[type]) err("--type must be kv, d1, r2, do, queue, or workflow"); + const edit = action === "add" ? addProjectResource : updateProjectResource; + output( + edit({ + configPath: flags.config, + environments, + resource: { + type: typeMap[type], + bindingName, + ...(flags["class-name"] + ? { className: flags["class-name"] } + : {}), + ...(flags.location + ? { + location: flags.location as + | "wnam" + | "enam" + | "weur" + | "eeur" + | "apac" + | "oc", + } + : {}), + ...(flags["read-replication"] + ? { + readReplication: flags["read-replication"] as + | "auto" + | "disabled", + } + : {}), + }, + }), + ); + return; + } + if (action === "list") { + assertFlags(flags, ["env"]); + output( + await client.listWorkerResources( + options(), + oneId( + resourceArgs, + "usage: xapi-to workers resources list --env ENV", + ), + environment(flags.env), + ), + ); + return; + } + if (action === "create") { + assertFlags(flags, ["env", "type", "binding", "class-name", "location", "read-replication", "retention-price-version"]); + const id = oneId( + resourceArgs, + "usage: xapi-to workers resources create --env ENV --type kv|d1|r2|do|queue|workflow --binding NAME", + ); + const type = required(flags.type, "--type"); + const typeMap: Record = { + kv: "kv_namespace", + d1: "d1_database", + r2: "r2_bucket", + do: "durable_object", + queue: "queue", + workflow: "workflow", + }; + if (!typeMap[type]) + err("--type must be kv, d1, r2, do, queue, or workflow"); + const className = + type === "do" + ? required(flags["class-name"], "--class-name") + : undefined; + const location = flags.location; + if ( + location && + !["wnam", "enam", "weur", "eeur", "apac", "oc"].includes(location) + ) { + err("--location must be wnam, enam, weur, eeur, apac, or oc"); + } + if (location && type !== "d1" && type !== "r2") { + err("--location is only valid with --type d1 or r2"); + } + const readReplication = flags["read-replication"]; + if ( + readReplication && + readReplication !== "auto" && + readReplication !== "disabled" + ) { + err("--read-replication must be auto or disabled"); + } + if (readReplication && type !== "d1") { + err("--read-replication is only valid with --type d1"); + } + output( + await client.createWorkerResource( + options(), + id, + environment(flags.env), + { + type: typeMap[type], + retentionPriceVersion: flags["retention-price-version"], + bindingName: required(flags.binding, "--binding"), + ...(className ? { className } : {}), + ...(location ? { location } : {}), + ...(readReplication ? { readReplication } : {}), + }, + ), + ); + return; + } + if (action === "delete") { + assertFlags(flags, ["env", "yes"]); + if (resourceArgs.length !== 2) { + err( + "usage: xapi-to workers resources delete --env ENV --yes", + ); + } + if (flags.yes !== "true") { + err("refusing to delete a managed resource without --yes"); + } + output( + await client.deleteWorkerResource( + options(), + resourceArgs[0], + environment(flags.env), + resourceArgs[1], + ), + ); + return; + } + err("usage: xapi-to workers resources ..."); + } + case "secrets": { + const [action, ...secretArgs] = rest; + if (action === "list") { + assertFlags(flags, ["env"]); + output( + await client.listWorkerSecrets( + options(), + oneId( + secretArgs, + "usage: xapi-to workers secrets list --env ENV", + ), + environment(flags.env), + ), + ); + return; + } + if (action === "set") { + assertFlags(flags, ["env", "from-env", "value"]); + if (secretArgs.length !== 2) { + err( + "usage: xapi-to workers secrets set --env ENV --from-env VARIABLE", + ); + } + if (flags["from-env"] && flags.value) { + err("use either --from-env or --value, not both"); + } + const value = flags["from-env"] + ? process.env[flags["from-env"]] + : flags.value; + if (value === undefined || value === "true" || value === "") { + err( + flags["from-env"] + ? `environment variable ${flags["from-env"]} is empty or missing` + : "provide --from-env VARIABLE (recommended) or --value VALUE", + ); + } + output( + await client.putWorkerSecret( + options(), + secretArgs[0], + environment(flags.env), + secretArgs[1], + value, + ), + ); + return; + } + if (action === "delete") { + assertFlags(flags, ["env", "yes"]); + if (secretArgs.length !== 2) { + err( + "usage: xapi-to workers secrets delete --env ENV --yes", + ); + } + if (flags.yes !== "true") { + err("refusing to delete a secret without --yes"); + } + output( + await client.deleteWorkerSecret( + options(), + secretArgs[0], + environment(flags.env), + secretArgs[1], + ), + ); + return; + } + err("usage: xapi-to workers secrets ..."); + } + case "provider-status": + assertFlags(flags); + if (rest.length) err("usage: xapi-to workers provider-status"); + output(await client.workerProviderStatus(options())); + return; + case "capabilities": + assertFlags(flags); + if (rest.length) err("usage: xapi-to workers capabilities"); + output(await client.workerProviderCapabilities(options())); + return; + case "artifact-provider-status": + assertFlags(flags); + if (rest.length) err("usage: xapi-to workers artifact-provider-status"); + output(await client.workerArtifactProviderStatus(options())); + return; + case "build-provider-status": + assertFlags(flags); + if (rest.length) err("usage: xapi-to workers build-provider-status"); + output(await client.workerBuildProviderStatus(options())); + return; + case "delete": + assertFlags(flags, ["yes"]); + if (flags.yes !== "true") + err("refusing to delete without --yes", { + retention: "soft-deleted for 30 days", + }); + output( + await client.deleteWorker( + options(), + oneId(rest, "usage: xapi-to workers delete --yes"), + ), + ); + return; + default: + err(`unknown workers command: ${command}`, { + hint: "run xapi-to workers --help", + }); + } +} diff --git a/src/index.ts b/src/index.ts index a4ffe61..bed337c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ * xapi-to get-batch [id ...] * xapi-to call --input '{"k":"v"}' [--code curl|py|js|ts|go] * xapi-to sandbox run --command + * xapi-to workers list * * xapi-to config show * xapi-to config set apiKey= @@ -41,6 +42,7 @@ import * as taskCmds from './commands/task.ts'; import * as sandboxCmds from './commands/sandbox.ts'; import * as providerCmds from './commands/provider.ts'; import * as skillCmds from './commands/skill.ts'; +import * as workersCmds from './commands/workers.ts'; const { OAUTH_HELP } = oauthCmds; import { parseArgs } from './args.ts'; @@ -100,6 +102,10 @@ COMMANDS spec|submit|status|wait Run "xapi-to skill --help" for local directory and GitHub workflows + workers Deploy and manage hosted Cloudflare Workers + list|get|create|deploy|budget|audit|bindings|provider-status|delete + Run "xapi-to workers --help" for budgets, environments, and deploy flags + oauth bind [--provider twitter] Bind Twitter OAuth to your API key oauth status List current OAuth bindings oauth unbind Remove an OAuth binding @@ -155,6 +161,7 @@ EXAMPLES xapi-to task poll 550e8400-e29b-41d4-a716-446655440000 xapi-to task wait 550e8400-e29b-41d4-a716-446655440000 --interval 2s --timeout 10m xapi-to sandbox run --command 'python3 -c "print(6*7)"' + xapi-to workers list --format table xapi-to categories xapi-to services --format table xapi-to config set apiKey=xapi_abc123 @@ -252,6 +259,9 @@ async function main() { break; } + case 'workers': + return workersCmds.workersCommand(rest, flags); + // ── OAuth commands ── case 'oauth': { if (flags.help || rest.length === 0) { diff --git a/src/tests/skill-workers-guide.test.ts b/src/tests/skill-workers-guide.test.ts new file mode 100644 index 0000000..a776d82 --- /dev/null +++ b/src/tests/skill-workers-guide.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'bun:test'; +import { readFileSync } from 'node:fs'; + +const skill = readFileSync( + new URL('../../skills/xapi/SKILL.md', import.meta.url), + 'utf8', +); +const guide = readFileSync( + new URL('../../skills/xapi/guides/workers.md', import.meta.url), + 'utf8', +); +const standaloneDeployment = readFileSync( + new URL('../../skills/xapi-workers/references/deployment.md', import.meta.url), + 'utf8', +); +const standaloneResources = readFileSync( + new URL('../../skills/xapi-workers/references/resources.md', import.meta.url), + 'utf8', +); +const dedicatedSkill = readFileSync( + new URL('../../skills/xapi-workers/SKILL.md', import.meta.url), + 'utf8', +); +const domainGuide = readFileSync( + new URL('../../skills/xapi-workers/references/domains.md', import.meta.url), + 'utf8', +); + +describe('bundled xAPI Workers skill guide', () => { + it('routes hosted Worker tasks to the progressively loaded guide', () => { + expect(skill).toContain('Read `guides/workers.md`'); + expect(skill).toContain('`workers init`'); + expect(skill).toContain('`workers promote --to production`'); + }); + + it('combines xdomain ownership with native Worker Custom Domains safely', () => { + expect(dedicatedSkill).toContain('[domains.md](references/domains.md)'); + expect(domainGuide).toContain('xapi workers domains attach'); + expect(domainGuide).toContain('domain.get'); + expect(domainGuide).toContain('dns.upsert'); + expect(domainGuide).toContain('dns.delete'); + expect(domainGuide).toContain('temporary TXT'); + expect(domainGuide).toContain('Cloudflare owns the final DNS record'); + expect(domainGuide).toContain('non-refundable'); + expect(domainGuide).toContain('Never invent missing contact fields'); + expect(domainGuide).not.toContain('wrangler deploy'); + }); + + it('prefers project deployment and covers import, CI, recovery, and rollback boundaries', () => { + expect(guide).toContain('xapi workers templates'); + expect(guide).toContain('xapi workers init my-agent --template persistent-agent'); + expect(guide).toContain('init --from-wrangler ./wrangler.jsonc'); + expect(guide).toContain('xapi workers plan --env preview'); + expect(guide).toContain('xapi workers push --env preview'); + expect(guide).toContain('xapi workers promote --to production'); + expect(guide).toContain('xapi workers rollback --env production'); + expect(guide).toContain('The project workflow does not require Git'); + expect(guide).toContain('--non-interactive'); + expect(guide).toContain('never deletes an extra stateful resource or Secret'); + expect(guide).toContain('does **not** restore or migrate KV'); + expect(guide).toContain('XAPI_API_HOST=api.test.xapi.to'); + }); + + it('uses Artifact upload as the default and Sandbox only as an option', () => { + expect(guide).toContain('workers upload '); + expect(guide).toContain('workers deploy '); + expect(guide).toContain('--artifact '); + expect(guide).toContain('### Optional Sandbox build'); + expect(guide).toContain('"main": "worker.js"'); + expect(guide).toContain('--file dist/'); + expect(guide).toContain('--main worker.js'); + expect(guide).toContain("native static-assets upload"); + expect(guide).toContain('"directory": "dist/client"'); + expect(guide).toContain('`webAppReady: true`'); + expect(guide).not.toContain('--build '); + }); + + it('preserves key isolation, idempotency, budgets, and terminal status rules', () => { + expect(guide).toContain('never embedded in a bundle'); + expect(guide).toContain('between $0.10 and $100 per day'); + expect(guide).toContain('Reuse an upload key only for identical normalized Artifact bytes'); + expect(guide).toContain('`status: ACTIVE`'); + }); + + it('covers persistent agents, live capability diagnostics, billing, logs, and domains', () => { + expect(guide).toContain('workers capabilities'); + expect(guide).toContain('--type do'); + expect(guide).toContain('--type queue'); + expect(guide).toContain('--type workflow'); + expect(guide).toContain('workers schedules create'); + expect(guide).toContain('Tail Worker console messages'); + expect(guide).toContain('fails closed'); + expect(guide).toContain('workers domains retry'); + expect(guide).toContain('D1 Edit'); + }); + + it('keeps application resources independent from full-matrix acceptance', () => { + for (const text of [guide, standaloneDeployment, standaloneResources]) { + expect(text).toMatch(/separate disposable (?:acceptance )?Worker/); + expect(text).toContain('application'); + } + expect(standaloneResources).toContain('independent bindings'); + }); +}); diff --git a/src/tests/workers-artifact.test.ts b/src/tests/workers-artifact.test.ts new file mode 100644 index 0000000..abc3a7a --- /dev/null +++ b/src/tests/workers-artifact.test.ts @@ -0,0 +1,147 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + loadWorkerArtifact, + WorkerArtifactError, +} from "../workers-artifact.ts"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +function directory(): string { + const root = mkdtempSync(join(tmpdir(), "xapi-worker-artifact-")); + roots.push(root); + return root; +} + +describe("Worker Artifact loader", () => { + test("keeps the legacy single-module request and raw-byte hash", () => { + const root = directory(); + const source = "export default { fetch() { return new Response('ok') } };"; + const path = join(root, "worker.mjs"); + writeFileSync(path, source); + + const artifact = loadWorkerArtifact(path); + + expect(artifact.kind).toBe("module"); + expect(artifact.upload).toEqual({ moduleCode: source }); + expect(artifact.contentSha256).toBe( + createHash("sha256").update(source).digest("hex"), + ); + }); + + test("builds a deterministic multi-module request and server-compatible hash", () => { + const root = directory(); + mkdirSync(join(root, "chunks")); + writeFileSync( + join(root, "worker.js"), + 'import { answer } from "./chunks/answer.js"; import data from "./data.bin"; export default { fetch() { return Response.json({ answer, size: data.byteLength }) } };', + ); + writeFileSync(join(root, "chunks/answer.js"), "export const answer = 42;"); + writeFileSync(join(root, "data.bin"), Buffer.from([0, 1, 2, 255])); + + const artifact = loadWorkerArtifact(root, "worker.js"); + expect(artifact.kind).toBe("bundle"); + if (!("bundle" in artifact.upload)) throw new Error("expected bundle"); + expect(artifact.upload.bundle.modules.map((item) => item.path)).toEqual([ + "chunks/answer.js", + "data.bin", + "worker.js", + ]); + expect(artifact.upload.bundle.modules[1]).toEqual( + expect.objectContaining({ + path: "data.bin", + encoding: "base64", + content: "AAEC/w==", + contentType: "application/octet-stream", + }), + ); + const stored = Buffer.from( + JSON.stringify({ + version: 1, + mainModule: "worker.js", + modules: artifact.upload.bundle.modules.map((module) => ({ + path: module.path, + contentBase64: + module.encoding === "base64" + ? module.content + : Buffer.from(module.content, "utf8").toString("base64"), + contentType: module.contentType, + })), + }), + "utf8", + ); + expect(artifact.contentSha256).toBe( + createHash("sha256").update(stored).digest("hex"), + ); + expect(artifact.sizeBytes).toBe(stored.length); + }); + + test("requires an explicit directory entrypoint", () => { + const root = directory(); + writeFileSync(join(root, "worker.js"), "export default {};"); + expect(() => loadWorkerArtifact(root)).toThrow(WorkerArtifactError); + expect(() => loadWorkerArtifact(root)).toThrow("--main"); + }); + + test("packages native static assets with MIME types and routing settings", () => { + const root = directory(); + const worker = join(root, "worker.mjs"); + const assets = join(root, "public"); + mkdirSync(assets); + writeFileSync(worker, "export default { fetch() { return new Response('api') } };"); + writeFileSync(join(assets, "index.html"), "

hello

"); + writeFileSync(join(assets, "logo.png"), Buffer.from([137, 80, 78, 71])); + + const artifact = loadWorkerArtifact(worker, undefined, { + directory: assets, + binding: "ASSETS", + notFoundHandling: "single-page-application", + runWorkerFirst: ["/api/*"], + }); + + expect(artifact.kind).toBe("bundle"); + if (!("bundle" in artifact.upload)) throw new Error("expected bundle"); + expect(artifact.upload.bundle.assets).toEqual({ + binding: "ASSETS", + config: { + notFoundHandling: "single-page-application", + runWorkerFirst: ["/api/*"], + }, + files: [ + expect.objectContaining({ path: "/index.html", contentType: "text/html" }), + expect.objectContaining({ path: "/logo.png", contentType: "image/png" }), + ], + }); + }); + + test("rejects missing relative modules and static website assets", () => { + const root = directory(); + writeFileSync( + join(root, "worker.js"), + 'import "./missing.js"; export default {};', + ); + expect(() => loadWorkerArtifact(root, "worker.js")).toThrow( + "imports that are not in the Artifact", + ); + + writeFileSync(join(root, "missing.js"), "export {};"); + writeFileSync(join(root, "index.html"), "

site

"); + expect(() => loadWorkerArtifact(root, "worker.js")).toThrow( + "static-assets workflow", + ); + }); +}); diff --git a/src/tests/workers-billing-ledger.test.ts b/src/tests/workers-billing-ledger.test.ts new file mode 100644 index 0000000..0f611f3 --- /dev/null +++ b/src/tests/workers-billing-ledger.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "bun:test"; +import { collectWorkerBillingLedger } from "../workers-billing-ledger.ts"; +import type { WorkerBillingQuery } from "../workers-client.ts"; + +const snapshotTime = "2026-09-11T12:38:46.837Z"; +const page = (id: string, hasMore = false, nextCursor: string | null = null) => ({ + workerId: "worker", environment: "preview", snapshotTime, dataQuality: "PARTIAL", + data: { entries: [{ id, amountUsd: "-0.00000001" }], hasMore, nextCursor }, +}); + +describe("complete billing ledger", () => { + it("pins the first snapshot, preserves filters and exact amounts without upgrading quality", async () => { + const calls: WorkerBillingQuery[] = []; + const result = await collectWorkerBillingLedger(async query => { + calls.push(query); + return calls.length === 1 ? page("one", true, "opaque+/=") : page("two"); + }, { metric: "WORKER_CPU_MS", resourceId: "resource" }); + expect(calls).toEqual([ + { metric: "WORKER_CPU_MS", resourceId: "resource", limit: "100", snapshotTime: undefined, cursor: undefined }, + { metric: "WORKER_CPU_MS", resourceId: "resource", limit: "100", snapshotTime, cursor: "opaque+/=" }, + ]); + expect(result.data.entries.map((e: any) => e.amountUsd)).toEqual(["-0.00000001", "-0.00000001"]); + expect(result.data.hasMore).toBe(false); + expect(result.pagination).toEqual({ pages: 2, entries: 2, complete: true, pageSnapshotIds: [null, null] }); + expect(result.dataQuality).toBe("PARTIAL"); + }); + + it("accepts equivalent ISO timestamp representations and pins the returned form", async () => { + const result = await collectWorkerBillingLedger(async () => page("one"), { snapshotTime: "2026-09-11T20:38:46.837+08:00" }); + expect(result.snapshotTime).toBe(snapshotTime); + }); + + it("handles an empty first page", async () => { + const result = await collectWorkerBillingLedger(async () => ({ ...page(""), data: { entries: [], hasMore: false, nextCursor: null } })); + expect(result.pagination.entries).toBe(0); + }); + + it.each([ + ["snapshot changed", { ...page("two"), snapshotTime: "2026-09-12T00:00:00Z" }, "snapshot changed"], + ["scope changed", { ...page("two"), environment: "production" }, "scope changed"], + ["duplicate entry", page("one"), "duplicate ledger entry"], + ["missing cursor", page("two", true), "Missing or repeated ledger cursor"], + ["repeated cursor", page("two", true, "cursor"), "Missing or repeated ledger cursor"], + ["invalid page", {}, "Invalid ledger page"], + ])("rejects %s instead of emitting partial success", async (_label, second, error) => { + let calls = 0; + await expect(collectWorkerBillingLedger(async () => ++calls === 1 ? page("one", true, "cursor") : second)).rejects.toThrow(error as string); + }); + + it("bounds pages and propagates API failures", async () => { + await expect(collectWorkerBillingLedger(async () => page("one", true, "cursor"), {}, 1)).rejects.toThrow("exceeds 1 pages"); + await expect(collectWorkerBillingLedger(async () => { throw new Error("HTTP 503"); })).rejects.toThrow("HTTP 503"); + }); + + it("rejects a starting cursor and mismatching explicit snapshot", async () => { + await expect(collectWorkerBillingLedger(async () => page("one"), { cursor: "cursor" })).rejects.toThrow("cannot be combined"); + await expect(collectWorkerBillingLedger(async () => page("one"), { snapshotTime: "other" })).rejects.toThrow("snapshot changed"); + }); +}); diff --git a/src/tests/workers-billing-output.test.ts b/src/tests/workers-billing-output.test.ts new file mode 100644 index 0000000..7ad75a8 --- /dev/null +++ b/src/tests/workers-billing-output.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, it } from "bun:test"; +import { + formatWorkerBillingOutput, + formatWorkerBillingResponse, + workerBillingOutputMode, +} from "../workers-billing-output.ts"; + +const envelope = { + schemaVersion: 1, + snapshotId: "snapshot-1", + snapshotTime: "2026-09-02T12:00:00.000Z", + completeThrough: "2026-09-02T11:55:00.000Z", + dataQuality: "COMPLETE", + workerId: "worker-1", + environment: "production", +} as const; + +describe("Worker billing output selection", () => { + it("defaults to human output independently of TTY state", () => { + expect(workerBillingOutputMode({})).toBe("human"); + expect(workerBillingOutputMode({ format: "table" })).toBe("human"); + expect(workerBillingOutputMode({ format: "pretty" })).toBe("human"); + }); + + it("supports both explicit JSON forms and rejects conflicts deterministically", () => { + expect(workerBillingOutputMode({ json: "true" })).toBe("json"); + expect(workerBillingOutputMode({ format: "json" })).toBe("json"); + expect(() => + workerBillingOutputMode({ json: "true", format: "table" }), + ).toThrow("--json cannot be combined with --format"); + expect(() => workerBillingOutputMode({ json: "false" })).toThrow( + "--json does not accept a value", + ); + }); + + it("emits the API object unchanged in JSON mode", () => { + const fixture = { + ...envelope, + completeThrough: null, + dataQuality: "PARTIAL", + data: { + currency: "USD", + entries: [{ amountUsd: "-0.000000300000000001" }], + hasMore: true, + nextCursor: "opaque+/= cursor.do-not-decode", + }, + }; + + expect( + JSON.parse(formatWorkerBillingOutput("ledger", fixture, "json")), + ).toEqual(fixture); + }); +}); + +describe("Worker billing human output", () => { + it("matches the overview golden without converting or recomputing money", () => { + const rendered = formatWorkerBillingResponse("overview", { + ...envelope, + data: { + lifecycleState: "LOW_BALANCE", + dailyBudgetUsd: "10000000000000000000.00000001", + budgetRemainingUsd: "9999999999999999998.750000309999999999", + settledUsd: "1.25000000", + reservedUsd: null, + estimatedUsd: "-0.000000300000000001", + exposureUsd: "1.249999700000000001", + safetyReserveUsd: null, + ledgerNetUsd: "1.25000000", + ledgerEntries: 2, + providerOutage: false, + reasonCodes: ["LOW_BALANCE"], + resourceTotals: [ + { + kind: "worker_runtime", + resourceCount: 1, + metrics: ["WORKER_REQUEST"], + settledLedgerEntries: 2, + settledUsd: "1.25000000", + reservedUsd: null, + estimatedUsd: "0", + exposureUsd: null, + dataQuality: "INDETERMINATE", + reasonCodes: ["ESTIMATED_TOTAL_UNKNOWN"], + }, + ], + }, + }); + + expect(rendered).toBe(`xAPI Worker Billing · Overview +──────────────────────────────────────────────────────────────────────────────────────── + Worker worker-1 + Environment production + Snapshot snapshot-1 + Snapshot time 2026-09-02T12:00:00.000Z + Data quality COMPLETE + Complete through 2026-09-02T11:55:00.000Z + + Lifecycle LOW_BALANCE + Daily budget $10000000000000000000.00000001 + Budget remaining $9999999999999999998.750000309999999999 + Settled $1.25000000 + Reserved — + Estimated $-0.000000300000000001 + Exposure $1.249999700000000001 + Safety reserve — + Ledger net $1.25000000 + Ledger entries 2 + Provider outage false + Reasons LOW_BALANCE + +Resource totals +${' Resource Count Settled Reserved Estimated Exposure Quality Reasons'.padEnd(107)} + ────────────── ───── ─────────── ──────── ───────── ──────── ───────────── ─────────────────────── + worker_runtime 1 $1.25000000 — $0 — INDETERMINATE ESTIMATED_TOTAL_UNKNOWN +────────────────────────────────────────────────────────────────────────────────────────`); + expect(rendered).not.toContain("1e+"); + }); + + it("keeps unattributed managed-resource estimates unknown", () => { + const rendered = formatWorkerBillingResponse("overview", { + ...envelope, + data: { + budgetRemainingUsd: null, + resourceTotals: [ + { + kind: "kv", + resourceCount: 1, + settledUsd: "0.0002", + reservedUsd: "0", + estimatedUsd: null, + exposureUsd: null, + dataQuality: "INDETERMINATE", + reasonCodes: ["ESTIMATED_ATTRIBUTION_NOT_PERSISTED"], + }, + ], + }, + }); + + expect(rendered).toContain("Budget remaining —"); + expect(rendered).toContain("$0.0002"); + expect(rendered).toContain("ESTIMATED_ATTRIBUTION_NOT_PERSISTED"); + expect(rendered).not.toContain("$null"); + }); + + it("renders explicit gaps, nulls, and incomplete freshness prominently", () => { + const rendered = formatWorkerBillingResponse("usage", { + ...envelope, + completeThrough: null, + dataQuality: "PARTIAL", + data: { + from: "2026-09-02T11:00:00.000Z", + to: "2026-09-02T12:00:00.000Z", + bucketCount: 12, + gapCount: 1, + partialCount: 1, + metric: null, + resourceId: null, + buckets: [ + { + start: "2026-09-02T11:00:00.000Z", + status: "COMPLETE", + facts: [{ metric: "WORKER_REQUEST", quantity: "7" }], + }, + { + start: "2026-09-02T11:05:00.000Z", + status: "GAP", + facts: [], + }, + ], + }, + }); + + expect(rendered).toContain("Data quality PARTIAL"); + expect(rendered).toContain("Complete through —"); + expect(rendered).toContain( + "! PARTIAL: provider data may be delayed or incomplete", + ); + expect(rendered).toContain("! GAP"); + expect(rendered).toContain("Metric —"); + expect(rendered).not.toContain("$0"); + }); + + it("renders all eight persisted lifecycle states without assumptions", () => { + for (const state of [ + "ACTIVE", + "LOW_BALANCE", + "SUSPENDING", + "SUSPENDED_GRACE", + "RESUMING", + "PENDING_DELETION", + "DELETING", + "DELETED", + ]) { + const rendered = formatWorkerBillingResponse("lifecycle", { + ...envelope, + data: { + state, + reasonCode: null, + phase: null, + completedSteps: 0, + totalSteps: 0, + nextStep: null, + blockerCodes: [], + suspendedAt: null, + graceDeadlineAt: null, + deletionEarliestAt: null, + deletedAt: null, + retentionCostUsd: null, + finalMeteringStatus: null, + r2DispositionStatus: null, + legalHold: false, + paymentInFlight: false, + approvalStatus: "NOT_REQUIRED", + availableActions: [], + }, + }); + expect(rendered).toContain(`State ${state}`); + expect(rendered).toContain("Reason —"); + expect(rendered).toContain("Retention cost —"); + } + }); + + it.each([ + ["prices", { version: null, effectiveFrom: null, rates: [] }], + ["ledger", { entries: [], hasMore: false, nextCursor: null }], + [ + "forecast", + { + spendableUsd: null, + burnRate1hUsd: null, + burnRate24hUsd: null, + burnRateUsdPerHour: null, + timeToZeroHours: null, + safetyReserveUsd: null, + providerDelayReserveUsd: null, + retentionStorageReserveUsd: null, + asyncShutdownReserveUsd: null, + providerOutage: null, + }, + ], + [ + "risk", + { accountRisk: null, environmentExposure: null, apiKeyExposure: null }, + ], + ] as const)("renders the %s family safely", (kind, data) => { + const rendered = formatWorkerBillingResponse(kind, { ...envelope, data }); + expect(rendered).toContain(`xAPI Worker Billing`); + expect(rendered).toContain("Data quality COMPLETE"); + expect(rendered).not.toContain("undefined"); + }); +}); + +it("distinguishes current customer accrual from platform funding without interpreting legacy totals", () => { + const rendered = formatWorkerBillingResponse("overview", { ...envelope, data: { + customerAccruedUsd: "0.00060795", platformRiskUsd: "0.00000001", + settledUsd: "0.00060796", fundingBreakdownComplete: true, + settlementMeaning: "NET_ACCRUAL_NOT_FINAL_PROVIDER_INVOICE", + resourceTotals: [{ kind: "r2", customerAccruedUsd: "0.00021096", settledUsd: "9.99" }], + } }); + expect(rendered).toContain("Customer accrued $0.00060795"); + expect(rendered).toContain("Platform funded $0.00000001"); + expect(rendered).toContain("NET_ACCRUAL_NOT_FINAL_PROVIDER_INVOICE"); + expect(rendered).toContain("$0.00021096"); + expect(rendered).not.toContain("$9.99"); + const unknown = formatWorkerBillingResponse("overview", { ...envelope, data: { + customerAccruedUsd: null, settledUsd: "9.99", + } }); + expect(unknown).toContain("Customer accrued —"); + expect(unknown).not.toContain("$9.99"); +}); diff --git a/src/tests/workers-client.test.ts b/src/tests/workers-client.test.ts new file mode 100644 index 0000000..965a914 --- /dev/null +++ b/src/tests/workers-client.test.ts @@ -0,0 +1,392 @@ +import { afterEach, describe, expect, it, spyOn } from "bun:test"; +import { + createWorker, + createWorkerBuild, + createWorkerResource, + createWorkerSchedule, + deployWorker, + putWorkerSecret, + listWorkers, + runWorkerScheduleNow, + listWorkerDomains, + createWorkerDomainChallenge, + attachWorkerDomain, + deleteWorkerDomain, + retryWorkerDomain, + rollbackWorker, + workerBillingStatus, + workerBillingQuery, + workerInvocationLogs, + workerProviderCapabilities, + workerRuntimeLogs, + workerUsage, + workerMeteredUsage, + uploadWorkerArtifact, +} from "../workers-client.ts"; + +const options = { apiHost: "test.xapi.to", apiKey: "sk-test-value" }; +let fetchSpy: ReturnType | undefined; + +afterEach(() => fetchSpy?.mockRestore()); + +describe("workers client", () => { + it("reads scoped source windows with the original xAPI authentication", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ storageCollection: { items: [] } }), { status: 200, headers: { "content-type": "application/json" } })) as any; + await workerMeteredUsage(options, "worker/1", "preview"); + const [url, init] = fetchSpy.mock.calls[0] as any[]; + expect(url).toBe("https://test.xapi.to/api/v1/workers/worker%2F1/environments/preview/metered-usage"); + expect(init.headers["XAPI-KEY"]).toBe("sk-test-value"); + expect(init.redirect).toBe("manual"); + }); + it("lists Workers through the versioned test API with the scoped key header", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify([{ id: "worker-1" }]), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) as any; + + await expect(listWorkers(options)).resolves.toEqual([{ id: "worker-1" }]); + const [url, init] = fetchSpy.mock.calls[0] as any[]; + expect(url).toBe("https://test.xapi.to/api/v1/workers"); + expect(init.headers["XAPI-KEY"]).toBe("sk-test-value"); + expect(init.redirect).toBe("manual"); + }); + + it("sends create input as JSON without automatic write retries", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ id: "worker-2" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) as any; + const body = { + name: "Agent", + slug: "agent", + previewDailyBudgetUsd: 0.25, + productionDailyBudgetUsd: 2, + }; + await createWorker(options, body); + const [, init] = fetchSpy.mock.calls[0] as any[]; + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body)).toEqual(body); + }); + + it("targets the requested Worker deployment endpoint", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ status: "ACTIVE" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) as any; + await deployWorker(options, "worker/id", { + environment: "preview", + artifactId: "artifact-1", + idempotencyKey: "showcase-v1", + }); + expect(fetchSpy.mock.calls[0][0]).toBe( + "https://test.xapi.to/api/v1/workers/worker%2Fid/deployments", + ); + }); + + it("targets the environment-scoped rollback endpoint with a stable retry key", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ status: "ACTIVE" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) as any; + await rollbackWorker(options, "worker/id", "production", { + deploymentId: "deployment-1", + idempotencyKey: "rollback-key-1", + }); + const [target, init] = fetchSpy.mock.calls[0] as any[]; + expect(target).toBe( + "https://test.xapi.to/api/v1/workers/worker%2Fid/environments/production/rollback", + ); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body)).toEqual({ + deploymentId: "deployment-1", + idempotencyKey: "rollback-key-1", + }); + }); + + it("uploads a bundled module as an immutable artifact", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ id: "artifact-1" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) as any; + await uploadWorkerArtifact(options, "worker/id", { + moduleCode: "export default {}", + idempotencyKey: "showcase-upload-v1", + }); + const [target, init] = fetchSpy.mock.calls[0] as any[]; + expect(target).toBe( + "https://test.xapi.to/api/v1/workers/worker%2Fid/artifacts", + ); + expect(JSON.parse(init.body).idempotencyKey).toBe("showcase-upload-v1"); + }); + + it("uploads a multi-module bundle in one immutable artifact request", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ id: "artifact-2" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) as any; + const bundle = { + version: 1 as const, + mainModule: "worker.js", + modules: [ + { + path: "worker.js", + content: 'import "./chunk.js"; export default {};', + encoding: "utf8" as const, + contentType: "application/javascript+module" as const, + }, + { + path: "chunk.js", + content: "export {};", + encoding: "utf8" as const, + contentType: "application/javascript+module" as const, + }, + ], + }; + await uploadWorkerArtifact(options, "worker/id", { + bundle, + idempotencyKey: "showcase-bundle-v1", + }); + const [, init] = fetchSpy.mock.calls[0] as any[]; + expect(JSON.parse(init.body)).toEqual({ + bundle, + idempotencyKey: "showcase-bundle-v1", + }); + }); + + it("uses the server-side build endpoint with an extended timeout", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ id: "build-1", status: "SUCCEEDED" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) as any; + await createWorkerBuild(options, "worker/id", { + files: [ + { + path: "src/index.ts", + content: "export default {}", + encoding: "utf8", + }, + ], + entrypoint: "src/index.ts", + buildCommand: "npm run build", + outputPath: "dist/index.mjs", + idempotencyKey: "showcase-build-v1", + }); + expect(fetchSpy.mock.calls[0][0]).toBe( + "https://test.xapi.to/api/v1/workers/worker%2Fid/builds", + ); + }); + + it("creates an environment-isolated managed resource", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ id: "resource-1", status: "ACTIVE" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) as any; + await createWorkerResource(options, "worker/id", "production", { + type: "kv_namespace", + bindingName: "STATE", + }); + const [target, init] = fetchSpy.mock.calls[0] as any[]; + expect(target).toBe( + "https://test.xapi.to/api/v1/workers/worker%2Fid/environments/production/resources", + ); + expect(JSON.parse(init.body)).toEqual({ + type: "kv_namespace", + bindingName: "STATE", + }); + }); + + it("sends a secret only in the JSON request body", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ bindingName: "MODEL_KEY", version: 1 }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) as any; + await putWorkerSecret( + options, + "worker/id", + "preview", + "MODEL_KEY", + "private-value", + ); + const [target, init] = fetchSpy.mock.calls[0] as any[]; + expect(target).toBe( + "https://test.xapi.to/api/v1/workers/worker%2Fid/environments/preview/secrets/MODEL_KEY", + ); + expect(target).not.toContain("private-value"); + expect(JSON.parse(init.body)).toEqual({ value: "private-value" }); + }); + + it("reads environment-isolated invocation metadata", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ items: [], contentCaptured: false }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) as any; + await workerInvocationLogs(options, "worker/id", "preview"); + expect(fetchSpy.mock.calls[0][0]).toBe( + "https://test.xapi.to/api/v1/workers/worker%2Fid/environments/preview/invocations", + ); + }); + + it("reads runtime telemetry, usage, domains, and provider capabilities", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockImplementation( + (async () => + new Response(JSON.stringify({ items: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as any, + ) as any; + + await workerRuntimeLogs(options, "worker/id", "production"); + await workerUsage(options, "worker/id", "production"); + await workerUsage(options, "worker/id"); + await workerBillingStatus(options); + await listWorkerDomains(options, "worker/id"); + await retryWorkerDomain(options, "worker/id", "domain/id"); + await workerProviderCapabilities(options); + + expect(fetchSpy.mock.calls.map((call: any[]) => call[0])).toEqual([ + "https://test.xapi.to/api/v1/workers/worker%2Fid/environments/production/runtime-logs", + "https://test.xapi.to/api/v1/workers/worker%2Fid/environments/production/usage", + "https://test.xapi.to/api/v1/workers/worker%2Fid/usage", + "https://test.xapi.to/api/v1/workers/billing/status", + "https://test.xapi.to/api/v1/workers/worker%2Fid/domains", + "https://test.xapi.to/api/v1/workers/worker%2Fid/domains/domain%2Fid/retry", + "https://test.xapi.to/api/v1/workers/provider/capabilities", + ]); + expect((fetchSpy.mock.calls[5][1] as RequestInit).method).toBe("POST"); + }); + + it("creates, attaches, and removes a DNS-verified custom domain", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockImplementation( + (async () => + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as any, + ) as any; + + await createWorkerDomainChallenge( + options, + "worker/id", + "preview", + "kanby.example.com", + ); + await attachWorkerDomain(options, "worker/id", "signed.challenge"); + await deleteWorkerDomain(options, "worker/id", "domain/id"); + + expect(fetchSpy.mock.calls.map((call: any[]) => call[0])).toEqual([ + "https://test.xapi.to/api/v1/workers/worker%2Fid/domains/challenges", + "https://test.xapi.to/api/v1/workers/worker%2Fid/domains", + "https://test.xapi.to/api/v1/workers/worker%2Fid/domains/domain%2Fid", + ]); + expect(JSON.parse((fetchSpy.mock.calls[0][1] as RequestInit).body as string)).toEqual({ + environment: "preview", + hostname: "kanby.example.com", + }); + expect((fetchSpy.mock.calls[2][1] as RequestInit).method).toBe("DELETE"); + }); + + it("targets every environment billing family and preserves an opaque ledger cursor", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockImplementation( + (async () => + new Response(JSON.stringify({ schemaVersion: 1, data: {} }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as any, + ) as any; + + for (const kind of [ + "prices", + "overview", + "usage", + "ledger", + "forecast", + "risk", + "lifecycle", + ] as const) { + await workerBillingQuery(options, "worker/id", "production", kind); + } + await workerBillingQuery(options, "worker/id", "preview", "ledger", { + snapshotTime: "2026-09-02T12:00:00.000Z", + cursor: "opaque+/= cursor.do-not-decode", + limit: "25", + metric: "WORKER_REQUEST", + resourceId: "resource/id", + }); + + expect( + fetchSpy.mock.calls.slice(0, 7).map((call: any[]) => call[0]), + ).toEqual( + [ + "prices", + "overview", + "usage", + "ledger", + "forecast", + "risk", + "lifecycle", + ].map( + (kind) => + `https://test.xapi.to/api/v1/workers/worker%2Fid/environments/production/billing/${kind}`, + ), + ); + const ledgerUrl = new URL(fetchSpy.mock.calls[7][0] as string); + expect(ledgerUrl.pathname).toBe( + "/api/v1/workers/worker%2Fid/environments/preview/billing/ledger", + ); + expect(Object.fromEntries(ledgerUrl.searchParams)).toEqual({ + snapshotTime: "2026-09-02T12:00:00.000Z", + cursor: "opaque+/= cursor.do-not-decode", + limit: "25", + metric: "WORKER_REQUEST", + resourceId: "resource/id", + }); + }); + + it("creates and immediately runs a persistent Worker schedule", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockImplementation( + (async () => + new Response(JSON.stringify({ id: "run-1", status: "SUCCEEDED" }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as unknown as typeof fetch, + ) as any; + await createWorkerSchedule(options, "worker/id", { + name: "heartbeat", + environment: "PREVIEW", + cron: "*/15 * * * *", + timezone: "UTC", + method: "POST", + path: "/cron", + }); + expect(fetchSpy.mock.calls[0][0]).toBe( + "https://test.xapi.to/api/v1/workers/worker%2Fid/schedules", + ); + expect((fetchSpy.mock.calls[0][1] as RequestInit).method).toBe("POST"); + + await runWorkerScheduleNow(options, "worker/id", "schedule/id"); + expect(fetchSpy.mock.calls[1][0]).toBe( + "https://test.xapi.to/api/v1/workers/worker%2Fid/schedules/schedule%2Fid/run", + ); + expect((fetchSpy.mock.calls[1][1] as RequestInit).method).toBe("POST"); + }); +}); diff --git a/src/tests/workers-deployment-state.test.ts b/src/tests/workers-deployment-state.test.ts new file mode 100644 index 0000000..d08f18b --- /dev/null +++ b/src/tests/workers-deployment-state.test.ts @@ -0,0 +1,79 @@ +import { expect, test } from "bun:test"; +import { ensureActiveDeployment } from "../workers-push.ts"; +import { deploymentPrefix } from "../workers-deployment-state.ts"; +import { RequestTimeoutError } from "../client.ts"; + +function platform() { + const environment = { id: "env", name: "PREVIEW", activeDeploymentId: null as string | null, bindings: [] }; + const resources: Record[] = []; + const secrets: Record[] = []; + const deployments: Record[] = []; + let failOnce = false; + const api = { + getWorker: async () => ({ id: "worker", environments: [environment], deployments }), + listWorkerResources: async () => resources, + listWorkerSecrets: async () => secrets, + deployWorker: async (_options: unknown, _id: string, input: Record) => { + const d = { ...input, id: `d${deployments.length}`, environmentId: "env", status: "ACTIVE" }; + deployments.push(d); + environment.activeDeploymentId = d.id; + if (failOnce) { failOnce = false; throw new RequestTimeoutError(1000); } + return d; + }, + }; + const run = (date = "2026-09-07", artifact = "same-artifact") => ensureActiveDeployment(api, { apiHost: "localhost:3148", apiKey: "test" }, "worker", artifact, + "preview", { compatibilityDate: date, compatibilityFlags: [] }, async () => {}); + return { run, resources, secrets, deployments, environment, uncertain: () => { failOnce = true; } }; +} + +test("same code: add, replace and explicitly remove bindings deploy; unchanged repeats do not", async () => { + const p = platform(); + const first = await p.run(); + expect((await p.run()).deployment.id).toBe(first.deployment.id); + p.resources.push({ id: "resource", bindingName: "STATE", type: "KV_NAMESPACE", status: "ACTIVE", providerResourceId: "kv1" }); + const added = await p.run(); + expect(added.deployment.id).not.toBe(first.deployment.id); + expect((await p.run()).deployment.id).toBe(added.deployment.id); + p.resources[0].providerResourceId = "kv2"; + const replaced = await p.run(); + expect(replaced.deployment.id).not.toBe(added.deployment.id); + p.resources.length = 0; + const removed = await p.run(); + expect(removed.deployment.id).not.toBe(first.deployment.id); + expect((await p.run()).deployment.id).toBe(removed.deployment.id); + expect(p.deployments).toHaveLength(4); +}); + +test("compatibility and secret versions change the deployment, not the Artifact", async () => { + const p = platform(); + await p.run(); + await p.run("2026-09-06"); + p.secrets.push({ bindingName: "TOKEN", version: 1 }); + await p.run("2026-09-06"); + p.secrets[0].version = 2; + await p.run("2026-09-06"); + await p.run("2026-09-06"); + expect(p.deployments).toHaveLength(4); + expect(new Set(p.deployments.map(d => d.artifactId)).size).toBe(1); +}); + +test("historical ACTIVE artifact is not mistaken for the current activation; lost response reconciles", async () => { + const p = platform(); + await p.run(); + await p.run("2026-09-07", "other-artifact"); + p.uncertain(); + const restored = await p.run(); + expect(restored.deployment.id).toBe("d2"); + expect((await p.run()).deployment.id).toBe("d2"); + expect(p.deployments).toHaveLength(3); + expect(restored.idempotencyKey.length).toBeLessThanOrEqual(128); +}); + +test("fingerprint ignores polling noise and resource order, but includes environment bindings", () => { + const fingerprint = (resources: Record[], bindings: unknown[] = []) => + deploymentPrefix("worker", "preview", "artifact", {}, { bindings }, resources, []); + const a = { id: "1", bindingName: "A", type: "D1_DATABASE", status: "ACTIVE", config: { file_size: 10 } }; + const b = { id: "2", bindingName: "B", type: "KV_NAMESPACE", status: "ACTIVE" }; + expect(fingerprint([a, b])).toBe(fingerprint([b, { ...a, updatedAt: "later", config: { file_size: 20 } }])); + expect(fingerprint([a])).not.toBe(fingerprint([a], [{ type: "plain_text", name: "MODE", text: "new" }])); +}); diff --git a/src/tests/workers-domain-bind.test.ts b/src/tests/workers-domain-bind.test.ts new file mode 100644 index 0000000..921287c --- /dev/null +++ b/src/tests/workers-domain-bind.test.ts @@ -0,0 +1,155 @@ +import { afterEach, describe, expect, it, spyOn } from "bun:test"; +import { bindXdomainWorker } from "../workers-domain-bind.ts"; + +let fetchSpy: ReturnType | undefined; + +afterEach(() => fetchSpy?.mockRestore()); + +describe("xdomain + Workers binding", () => { + it("gets every action schema, proves ownership, attaches, and cleans the TXT", async () => { + const actionIds: string[] = []; + const calls: string[] = []; + fetchSpy = spyOn(globalThis, "fetch").mockImplementation( + (async (target: string | URL | Request, init?: RequestInit) => { + const url = String(target); + calls.push(url); + if (url.includes("/v1/actions/") && (!init?.method || init.method === "GET")) { + actionIds.push(decodeURIComponent(url.split("/").pop() || "")); + return new Response(JSON.stringify([{ id: actionIds.at(-1) }]), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (url.endsWith("/v1/actions/execute")) { + const body = JSON.parse(String(init?.body)); + if (body.action_id === "domain.get") { + return Response.json({ data: { domainName: "example.com" } }); + } + if (body.action_id === "dns.upsert") { + return Response.json({ + data: { + records: [ + { + record_id: "txt-1", + modified_on: "2026-09-20T00:00:00Z", + type: "TXT", + subdomain: "_xapi-worker-challenge.kanby", + value: "xapi-worker-domain=signed.challenge", + }, + ], + }, + }); + } + if (body.action_id === "dns.delete") { + return Response.json({ data: { deleted_record_id: "txt-1" } }); + } + } + if (url.endsWith("/api/v1/workers/worker-1/domains/challenges")) { + return Response.json({ + challengeToken: "signed.challenge", + expiresAt: "2026-09-20T00:10:00Z", + hostname: "kanby.example.com", + environment: "preview", + dns: { + type: "TXT", + name: "_xapi-worker-challenge.kanby.example.com", + value: "xapi-worker-domain=signed.challenge", + ttl: 60, + }, + }); + } + if (url.endsWith("/api/v1/workers/worker-1/domains")) { + return Response.json({ id: "worker-domain-1", status: "PROVISIONING" }); + } + return new Response("unexpected", { status: 500 }); + }) as any, + ); + + const result = await bindXdomainWorker({ + workerOptions: { apiHost: "api.test.xapi.to", apiKey: "sk-test" }, + actionOptions: { actionHost: "action.xapi.to", apiKey: "sk-test" }, + workerId: "worker-1", + environment: "preview", + domainId: "domain-1", + subdomain: "kanby", + waitMs: 1_000, + }); + + expect(actionIds).toEqual([ + "domain.get", + "dns.upsert", + "dns.delete", + ]); + expect(result).toEqual( + expect.objectContaining({ + hostname: "kanby.example.com", + url: "https://kanby.example.com", + challengeCleanup: { deleted: true, recordId: "txt-1" }, + }), + ); + expect(calls.some((url) => url.includes("api.test.xapi.to"))).toBe(true); + expect(calls.some((url) => url.includes("action.xapi.to"))).toBe(true); + }); + + it("removes the temporary ownership TXT when attach fails", async () => { + const actions: string[] = []; + fetchSpy = spyOn(globalThis, "fetch").mockImplementation( + (async (target: string | URL | Request, init?: RequestInit) => { + const url = String(target); + if (url.includes("/v1/actions/") && (!init?.method || init.method === "GET")) { + return Response.json([{ id: decodeURIComponent(url.split("/").pop() || "") }]); + } + if (url.endsWith("/v1/actions/execute")) { + const body = JSON.parse(String(init?.body)); + actions.push(body.action_id); + if (body.action_id === "domain.get") { + return Response.json({ data: { domainName: "example.com" } }); + } + if (body.action_id === "dns.upsert") { + return Response.json({ + data: { + records: [ + { + record_id: "txt-failed-attach", + type: "TXT", + subdomain: "_xapi-worker-challenge.kanby", + value: "xapi-worker-domain=signed.challenge", + }, + ], + }, + }); + } + if (body.action_id === "dns.delete") { + return Response.json({ data: { deleted_record_id: "txt-failed-attach" } }); + } + } + if (url.endsWith("/api/v1/workers/worker-1/domains/challenges")) { + return Response.json({ + challengeToken: "signed.challenge", + dns: { value: "xapi-worker-domain=signed.challenge" }, + }); + } + if (url.endsWith("/api/v1/workers/worker-1/domains")) { + return Response.json( + { message: "domain is already attached" }, + { status: 409 }, + ); + } + return new Response("unexpected", { status: 500 }); + }) as any, + ); + + await expect( + bindXdomainWorker({ + workerOptions: { apiHost: "api.test.xapi.to", apiKey: "sk-test" }, + actionOptions: { actionHost: "action.xapi.to", apiKey: "sk-test" }, + workerId: "worker-1", + environment: "preview", + domainId: "domain-1", + subdomain: "kanby", + waitMs: 0, + }), + ).rejects.toThrow(); + expect(actions).toEqual(["domain.get", "dns.upsert", "dns.delete"]); + }); +}); diff --git a/src/tests/workers-help.test.ts b/src/tests/workers-help.test.ts new file mode 100644 index 0000000..872cb7a --- /dev/null +++ b/src/tests/workers-help.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; +import { + WORKERS_INIT_HELP, + WORKERS_RESOURCES_HELP, +} from "../commands/workers.ts"; + +describe("Workers focused help", () => { + test("init help separates new, frontend, Wrangler, and Next SSR paths", () => { + expect(WORKERS_INIT_HELP).toContain("New Worker"); + expect(WORKERS_INIT_HELP).toContain("Existing React, Vite, Vue"); + expect(WORKERS_INIT_HELP).toContain("Existing Worker with Wrangler"); + expect(WORKERS_INIT_HELP).toContain("Next.js SSR"); + expect(WORKERS_INIT_HELP).toContain("Re-running init is not a"); + }); + + test("resource help distinguishes every project state transition", () => { + expect(WORKERS_RESOURCES_HELP).toContain( + "xapi.worker.json is desired state", + ); + for (const command of ["add", "update", "pull", "remove", "destroy"]) { + expect(WORKERS_RESOURCES_HELP).toContain(` ${command}`); + } + expect(WORKERS_RESOURCES_HELP).toContain("requires --yes"); + expect(WORKERS_RESOURCES_HELP).toContain("without updating the"); + expect(WORKERS_RESOURCES_HELP).toContain("cannot be updated in place"); + }); +}); diff --git a/src/tests/workers-init.test.ts b/src/tests/workers-init.test.ts new file mode 100644 index 0000000..9d5a2ff --- /dev/null +++ b/src/tests/workers-init.test.ts @@ -0,0 +1,372 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import ts from "typescript"; +import { initWorkerProject } from "../workers-init.ts"; +import { loadWorkerProject } from "../workers-project.ts"; +import { listWorkerTemplates, loadWorkerTemplate } from "../workers-templates.ts"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) + rmSync(root, { recursive: true, force: true }); +}); + +function workspace() { + const root = realpathSync(mkdtempSync(join(tmpdir(), "xapi-worker-init-"))); + roots.push(root); + return root; +} + +describe("workers init", () => { + for (const template of ["chat", "agent"] as const) { + test(`${template} rejects JSON null and non-string messages without invoking AI`, async () => { + const result = initWorkerProject({ cwd: workspace(), target: `invalid-${template}`, template }); + const build = await Bun.build({ entrypoints: [join(result.rootDir, "src/index.ts")], outdir: join(result.rootDir, "dist"), format: "esm", target: "browser" }); + const module = await import(build.outputs[0].path); + const originalFetch = globalThis.fetch; + let calls = 0; + globalThis.fetch = Object.assign(async () => { calls++; throw new Error("Unexpected AI request"); }, { preconnect: originalFetch.preconnect }); + try { + for (const input of [null, [], { message: 42 }, { message: " " }]) { + const response = await module.default.fetch(new Request("https://example.test/chat", { method: "POST", body: JSON.stringify(input) }), { MODEL_KEY: "test" }); + expect(response.status).toBe(400); + } + expect(calls).toBe(0); + } finally { globalThis.fetch = originalFetch; } + }); + } + for (const template of listWorkerTemplates().map((item) => item.id)) { + test(`creates a buildable ${template} project without Git or network access`, async () => { + const cwd = workspace(); + const result = initWorkerProject({ + cwd, + target: `demo-${template}`, + template, + compatibilityDate: "2026-08-26", + }); + expect(existsSync(join(result.rootDir, ".git"))).toBe(false); + const project = loadWorkerProject(result.rootDir); + expect(project.config.worker.slug).toBe(`demo-${template}`); + expect(project.config.worker.template).toBe( + loadWorkerTemplate(template).productTemplate, + ); + const sourcePath = join(result.rootDir, "src/index.ts"); + const source = readFileSync(sourcePath, "utf8"); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + }, + reportDiagnostics: true, + }); + expect(transpiled.diagnostics || []).toHaveLength(0); + expect(transpiled.outputText).toContain("export default"); + const program = ts.createProgram([sourcePath], { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + lib: ["lib.es2022.d.ts", "lib.webworker.d.ts"], + strict: true, + noEmit: true, + skipLibCheck: true, + }); + expect(ts.getPreEmitDiagnostics(program)).toHaveLength(0); + const build = await Bun.build({ + entrypoints: [sourcePath], + outdir: join(result.rootDir, "dist"), + format: "esm", + target: "browser", + }); + expect(build.success).toBe(true); + expect(build.outputs).toHaveLength(1); + expect(readFileSync(build.outputs[0].path, "utf8")).toContain( + "as default", + ); + }); + } + + test("persistent-agent declares managed resources, secrets, and support files", () => { + const cwd = workspace(); + const result = initWorkerProject({ + cwd, + target: "persistent-demo", + template: "persistent-agent", + compatibilityDate: "2026-08-26", + }); + const project = loadWorkerProject(result.rootDir); + expect(project.config.environments.preview.resources.map((item) => item.type)).toEqual([ + "kv_namespace", + "d1_database", + "r2_bucket", + "durable_object", + "queue", + "workflow", + ]); + expect(project.config.environments.preview.secrets).toEqual([ + "APP_TOKEN", + "MODEL_KEY", + ]); + expect(existsSync(join(result.rootDir, "migrations/0001_init.sql"))).toBe(true); + expect(existsSync(join(result.rootDir, "scripts/smoke.mjs"))).toBe(true); + const packageJson = JSON.parse(readFileSync(join(result.rootDir, "package.json"), "utf8")); + expect(packageJson.scripts["test:remote"]).toBe("node scripts/smoke.mjs"); + }); + + test("chat requests a streaming OpenAI-compatible completion", () => { + const cwd = workspace(); + const result = initWorkerProject({ + cwd, + target: "streaming-chat", + template: "chat", + compatibilityDate: "2026-08-26", + }); + const source = readFileSync(join(result.rootDir, "src/index.ts"), "utf8"); + expect(source).toContain('stream: true'); + expect(source).toContain('new Response(upstream.body'); + }); + + test("persistent-agent public home renders and ships parseable workbench JavaScript", async () => { + const cwd = workspace(); + const result = initWorkerProject({ + cwd, + target: "persistent-ui", + template: "persistent-agent", + compatibilityDate: "2026-08-26", + }); + const build = await Bun.build({ + entrypoints: [join(result.rootDir, "src/index.ts")], + outdir: join(result.rootDir, "dist"), + format: "esm", + target: "browser", + }); + expect(build.success).toBe(true); + const worker = await import(`${build.outputs[0].path}?test=${Date.now()}`); + const response = await worker.default.fetch( + new Request("https://persistent-ui-preview.example.test/"), + {}, + ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("text/html"); + const html = await response.text(); + expect(html).toContain("My Agent is running."); + expect(html).toContain("application access token, not your XAPI_KEY"); + const script = html.match(/ + +`, + { + headers: { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + "content-security-policy": "default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'", + "referrer-policy": "no-referrer", + "x-content-type-options": "nosniff", + }, + }, + ); +} + +function sameSecret(actual: string, expected: string): boolean { + if (actual.length !== expected.length) return false; + let different = 0; + for (let index = 0; index < actual.length; index += 1) { + different |= actual.charCodeAt(index) ^ expected.charCodeAt(index); + } + return different === 0; +} + +function authorized(request: Request, token?: string): boolean { + if (!token) return false; + return sameSecret( + request.headers.get("authorization") || "", + `Bearer ${token}`, + ); +} + +async function hmac(token: string, payload: unknown): Promise { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(token), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signed = new Uint8Array( + await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(JSON.stringify(payload)), + ), + ); + let binary = ""; + for (const byte of signed) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +async function verifiedEnvelope( + token: string | undefined, + envelope: { payload?: unknown; signature?: string } | undefined, +): Promise { + if (!token || !envelope?.signature || envelope.payload === undefined) { + return false; + } + return sameSecret(envelope.signature, await hmac(token, envelope.payload)); +} + +async function body(request: Request): Promise { + const input: unknown = await request.json().catch(() => ({})); + return (input && typeof input === "object" && !Array.isArray(input) ? input : {}) as T; +} + +async function record(env: Env, kind: string, payload: unknown): Promise { + await env.DB.prepare( + "INSERT INTO agent_events (id, kind, payload, created_at) VALUES (?, ?, ?, ?)", + ) + .bind(crypto.randomUUID(), kind, JSON.stringify(payload), new Date().toISOString()) + .run(); +} + +export class AgentState { + constructor( + private readonly state: DurableObjectState, + private readonly _env: Env, + ) {} + + async fetch(request: Request): Promise { + if (request.method === "GET") { + return json( + (await this.state.storage.get("session")) || { + messages: [], + updatedAt: new Date(0).toISOString(), + }, + ); + } + if (request.method === "PUT") { + const next = await body(request); + await this.state.storage.put("session", next); + return json(next); + } + return json({ error: "method_not_allowed" }, 405); + } +} + +function sessionStub(env: Env, session: string): DurableObjectStub { + return env.AGENT_STATE.get(env.AGENT_STATE.idFromName(session)); +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + if (url.pathname === "/" && request.method === "GET") { + return home(); + } + if (url.pathname === "/health") { + return json({ ok: true, project: "{{PROJECT_SLUG}}", template: "persistent-agent" }); + } + const trigger = request.headers.get("x-xapi-trigger"); + const internalRoute = + url.pathname === "/queue-consume" || url.pathname === "/workflow-step"; + const internalPayload = internalRoute + ? await body<{ payload?: unknown; signature?: string }>(request.clone()) + : undefined; + const internalAuthorized = await verifiedEnvelope( + env.APP_TOKEN, + internalPayload, + ); + const cronAuthorized = url.pathname === "/cron" && trigger === "cron"; + if ( + !authorized(request, env.APP_TOKEN) && + !internalAuthorized && + !cronAuthorized + ) { + return json({ error: env.APP_TOKEN ? "unauthorized" : "APP_TOKEN_not_configured" }, env.APP_TOKEN ? 401 : 503); + } + + if (url.pathname === "/setup" && request.method === "POST") { + await env.DB.exec( + "CREATE TABLE IF NOT EXISTS agent_events (id TEXT PRIMARY KEY, kind TEXT NOT NULL, payload TEXT NOT NULL, created_at TEXT NOT NULL)", + ); + await Promise.all([ + env.CACHE.put("setup", new Date().toISOString()), + env.FILES.put("setup.json", JSON.stringify({ project: "{{PROJECT_SLUG}}", ok: true })), + ]); + await record(env, "setup", { project: "{{PROJECT_SLUG}}" }); + return json({ ok: true, resources: ["KV", "D1", "R2"] }); + } + + if (url.pathname === "/state" && request.method === "GET") { + const session = url.searchParams.get("session") || "default"; + return sessionStub(env, session).fetch(new Request("https://agent-state.local/")); + } + + if (url.pathname === "/chat" && request.method === "POST") { + if (!env.MODEL_KEY) return json({ error: "MODEL_KEY_not_configured" }, 503); + const input = await body<{ message?: string; session?: string }>(request); + if (!input.message) return json({ error: "message_required" }, 400); + const session = input.session || "default"; + const stub = sessionStub(env, session); + const previous = (await (await stub.fetch(new Request("https://agent-state.local/"))).json()) as SessionState; + const messages = [...previous.messages, { role: "user" as const, content: input.message }].slice(-20); + const aiBaseUrl = + env.XAPI_AI_BASE_URL && env.XAPI_AI_BASE_URL !== "https://ai.xapi.to/v1" + ? env.XAPI_AI_BASE_URL + : "https://ai.xapi.to/cost/v1"; + const upstream = await fetch(aiBaseUrl + "/chat/completions", { + method: "POST", + headers: { authorization: `Bearer ${env.MODEL_KEY}`, "content-type": "application/json" }, + body: JSON.stringify({ model: "deepseek-v4-pro", messages, stream: true }), + }); + if (!upstream.ok) { + const result = (await upstream.json().catch(() => ({}))) as Record; + return json({ error: "model_request_failed", upstream: result }, upstream.status); + } + const contentType = upstream.headers.get("content-type") || ""; + if (!contentType.includes("text/event-stream") || !upstream.body) { + const result = (await upstream.json().catch(() => ({}))) as { + choices?: Array<{ message?: { content?: string } }>; + }; + const answer = result.choices?.[0]?.message?.content || ""; + const next: SessionState = { + messages: [...messages, { role: "assistant" as const, content: answer }].slice(-20), + updatedAt: new Date().toISOString(), + }; + await stub.fetch(new Request("https://agent-state.local/", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(next), + })); + await record(env, "chat", { session, messageCount: next.messages.length, streamed: false }); + return json({ session, answer }); + } + const reader = upstream.body.getReader(); + const stream = new TransformStream(); + const writer = stream.writable.getWriter(); + const decoder = new TextDecoder(); + let buffer = ""; + let answer = ""; + const consume = (eventText: string): void => { + const data = eventText + .split(/\r?\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trim()) + .join("\n"); + if (!data || data === "[DONE]") return; + const chunk = JSON.parse(data) as { + choices?: Array<{ delta?: { content?: string } }>; + }; + const delta = chunk.choices?.[0]?.delta?.content; + if (typeof delta === "string") answer += delta; + }; + void (async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const events = buffer.split(/\r?\n\r?\n/); + buffer = events.pop() || ""; + events.forEach(consume); + await writer.write(value); + } + buffer += decoder.decode(); + if (buffer.trim()) consume(buffer); + const next: SessionState = { + messages: [...messages, { role: "assistant" as const, content: answer }].slice(-20), + updatedAt: new Date().toISOString(), + }; + await stub.fetch(new Request("https://agent-state.local/", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(next), + })); + await record(env, "chat", { session, messageCount: next.messages.length, streamed: true }); + await writer.close(); + } catch (error) { + await writer.abort(error); + } + })(); + return new Response(stream.readable, { + status: 200, + headers: { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-store", + "x-accel-buffering": "no", + }, + }); + } + + if (url.pathname === "/queue" && request.method === "POST") { + const task = await body>(request); + const taskId = crypto.randomUUID(); + const payload = { taskId, task }; + await env.TASK_QUEUE.send({ + path: "/queue-consume", + body: { payload, signature: await hmac(env.APP_TOKEN!, payload) }, + }); + return json({ accepted: true, taskId }, 202); + } + + if (url.pathname === "/queue-consume" && request.method === "POST") { + const envelope = await body<{ + payload?: { taskId?: string; task?: unknown }; + }>(request); + await record(env, "queue", envelope.payload || {}); + return json({ processed: true }); + } + + if (url.pathname === "/workflow" && request.method === "POST") { + const task = await body>(request); + const payload = { task }; + const run = await env.AGENT_WORKFLOW.create({ + id: crypto.randomUUID(), + params: { + path: "/workflow-step", + body: { payload, signature: await hmac(env.APP_TOKEN!, payload) }, + }, + }); + return json({ accepted: true, workflowRunId: run.id }, 202); + } + + if (url.pathname === "/workflow-step" && request.method === "POST") { + const envelope = await body<{ payload?: { task?: unknown } }>(request); + await record(env, "workflow", envelope.payload || {}); + return json({ completed: true }); + } + + if (url.pathname === "/cron" && request.method === "POST") { + if (!env.APP_TOKEN) return json({ error: "APP_TOKEN_not_configured" }, 503); + const taskId = crypto.randomUUID(); + const payload = { + taskId, + task: { kind: "cron", at: new Date().toISOString() }, + }; + await env.TASK_QUEUE.send({ + path: "/queue-consume", + body: { + payload, + signature: await hmac(env.APP_TOKEN, payload), + }, + }); + return json({ accepted: true, taskId }, 202); + } + + return json({ error: "not_found" }, 404); + }, +}; diff --git a/templates/persistent-agent/template.json b/templates/persistent-agent/template.json new file mode 100644 index 0000000..3a1efb8 --- /dev/null +++ b/templates/persistent-agent/template.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "id": "persistent-agent", + "version": "1.0.0", + "name": "Persistent DeepSeek Agent", + "description": "Production-oriented Agent with Durable Object state, KV, D1, R2, Queue, Workflow, cron routes, authentication, and a remote smoke test.", + "productTemplate": "agent", + "defaultResources": [ + { "type": "kv_namespace", "bindingName": "CACHE" }, + { "type": "d1_database", "bindingName": "DB" }, + { "type": "r2_bucket", "bindingName": "FILES" }, + { "type": "durable_object", "bindingName": "AGENT_STATE", "className": "AgentState" }, + { "type": "queue", "bindingName": "TASK_QUEUE" }, + { "type": "workflow", "bindingName": "AGENT_WORKFLOW" } + ], + "defaultSecrets": ["APP_TOKEN", "MODEL_KEY"], + "packageScripts": { + "test:remote": "node scripts/smoke.mjs" + }, + "files": [ + { "source": "src/index.ts", "target": "src/index.ts" }, + { "source": "migrations/0001_init.sql", "target": "migrations/0001_init.sql" }, + { "source": "scripts/smoke.mjs", "target": "scripts/smoke.mjs" }, + { "source": "TEMPLATE.md", "target": "TEMPLATE.md" } + ] +} diff --git a/templates/webhook/files/src/index.ts b/templates/webhook/files/src/index.ts new file mode 100644 index 0000000..7279444 --- /dev/null +++ b/templates/webhook/files/src/index.ts @@ -0,0 +1,16 @@ +interface Env {} + +export default { + async fetch(request: Request, _env: Env): Promise { + const url = new URL(request.url); + if (url.pathname === "/health") return Response.json({ ok: true }); + if (url.pathname !== "/webhook" || request.method !== "POST") { + return Response.json({ error: "not_found" }, { status: 404 }); + } + const event = await request.json().catch(() => null); + return Response.json( + { accepted: true, eventId: crypto.randomUUID(), event }, + { status: 202 }, + ); + }, +}; diff --git a/templates/webhook/template.json b/templates/webhook/template.json new file mode 100644 index 0000000..34a6d41 --- /dev/null +++ b/templates/webhook/template.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "webhook", + "version": "1.0.0", + "name": "Webhook Receiver", + "description": "Small webhook receiver that validates the route and acknowledges JSON events.", + "productTemplate": "worker", + "defaultResources": [], + "defaultSecrets": [], + "packageScripts": {}, + "files": [ + { "source": "src/index.ts", "target": "src/index.ts" } + ] +} diff --git a/templates/worker/files/src/index.ts b/templates/worker/files/src/index.ts new file mode 100644 index 0000000..3de1b9a --- /dev/null +++ b/templates/worker/files/src/index.ts @@ -0,0 +1,16 @@ +interface Env { + XAPI_AI_BASE_URL: string; +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + if (url.pathname === "/health") { + return Response.json({ ok: true, runtime: "xAPI Workers", project: "{{PROJECT_SLUG}}" }); + } + return Response.json({ + message: "Hello from {{PROJECT_NAME}}", + aiBaseUrl: env.XAPI_AI_BASE_URL, + }); + }, +}; diff --git a/templates/worker/template.json b/templates/worker/template.json new file mode 100644 index 0000000..5aad84d --- /dev/null +++ b/templates/worker/template.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "worker", + "version": "1.0.0", + "name": "HTTP Worker", + "description": "Minimal HTTP Worker with a health endpoint and xAPI AI base URL binding.", + "productTemplate": "worker", + "defaultResources": [], + "defaultSecrets": [], + "packageScripts": {}, + "files": [ + { "source": "src/index.ts", "target": "src/index.ts" } + ] +} From fe1551a3b302cef584154b61f3c45da10267a353 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E9=9B=84=E5=91=80?= <47734376+dxiongya@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:27:14 +0800 Subject: [PATCH 05/28] feat(skill): bundle CLI-native provider workflows (#29) --- README.md | 8 + skills/xapi-provider/SKILL.md | 184 +++++++++++++++++ .../examples/01-per-call-minimal.json | 21 ++ .../examples/02-per-token-chat.json | 37 ++++ .../examples/03-per-token-embeddings.json | 30 +++ .../examples/04-per-resource-search.json | 33 ++++ .../examples/05-streaming-sse.json | 27 +++ .../examples/06-streaming-ndjson.json | 30 +++ .../examples/07-full-featured.json | 63 ++++++ .../examples/08-lifecycle-demo.sh | 23 +++ .../reference/ai-spec-and-models.md | 48 +++++ skills/xapi-provider/reference/auth-types.md | 62 ++++++ .../xapi-provider/reference/billing-modes.md | 88 +++++++++ .../reference/free-api-access.md | 36 ++++ .../reference/lifecycle-management.md | 185 ++++++++++++++++++ .../reference/oauth-user-context.md | 31 +++ .../reference/onboarding-openai-relay.md | 34 ++++ .../reference/validation-checklist.md | 57 ++++++ .../reference/websocket-endpoints.md | 75 +++++++ skills/xapi-provider/templates/ai-model.json | 36 ++++ skills/xapi-provider/templates/minimal.json | 23 +++ src/tests/xapi-provider-skill.test.ts | 73 +++++++ 22 files changed, 1204 insertions(+) create mode 100644 skills/xapi-provider/SKILL.md create mode 100644 skills/xapi-provider/examples/01-per-call-minimal.json create mode 100644 skills/xapi-provider/examples/02-per-token-chat.json create mode 100644 skills/xapi-provider/examples/03-per-token-embeddings.json create mode 100644 skills/xapi-provider/examples/04-per-resource-search.json create mode 100644 skills/xapi-provider/examples/05-streaming-sse.json create mode 100644 skills/xapi-provider/examples/06-streaming-ndjson.json create mode 100644 skills/xapi-provider/examples/07-full-featured.json create mode 100755 skills/xapi-provider/examples/08-lifecycle-demo.sh create mode 100644 skills/xapi-provider/reference/ai-spec-and-models.md create mode 100644 skills/xapi-provider/reference/auth-types.md create mode 100644 skills/xapi-provider/reference/billing-modes.md create mode 100644 skills/xapi-provider/reference/free-api-access.md create mode 100644 skills/xapi-provider/reference/lifecycle-management.md create mode 100644 skills/xapi-provider/reference/oauth-user-context.md create mode 100644 skills/xapi-provider/reference/onboarding-openai-relay.md create mode 100644 skills/xapi-provider/reference/validation-checklist.md create mode 100644 skills/xapi-provider/reference/websocket-endpoints.md create mode 100644 skills/xapi-provider/templates/ai-model.json create mode 100644 skills/xapi-provider/templates/minimal.json create mode 100644 src/tests/xapi-provider-skill.test.ts diff --git a/README.md b/README.md index d4039d2..9029533 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,14 @@ AI services through this CLI. Then just ask — "what's the price of BTC" — and it takes it from there. Set up a key first; see [Quick Start](#quick-start). +Providers can install the CLI-native [`xapi-provider` skill](skills/xapi-provider/SKILL.md), +which covers service registration, billing and WebSocket configuration, +revision publishing, observability, earnings, and linked usage Skills: + +```bash +npx skills add xapi-labs/xapi-cli --skill xapi-provider +``` + Workers projects can also install the standalone [`xapi-workers` skill](skills/xapi-workers/SKILL.md), covering deployment, all six managed resource types, complete consumption queries and cleanup: ```bash diff --git a/skills/xapi-provider/SKILL.md b/skills/xapi-provider/SKILL.md new file mode 100644 index 0000000..c90cd20 --- /dev/null +++ b/skills/xapi-provider/SKILL.md @@ -0,0 +1,184 @@ +--- +name: xapi-provider +description: Register and operate xAPI provider services through the xapi-to CLI. Use when creating an HTTP, streaming, AI, or WebSocket service; configuring upstream authentication and billing; managing revisions, publishing, rollback, metrics, earnings, or a linked usage Skill. All xAPI management actions use CLI commands with a scoped XAPI key. +--- + +# xAPI Provider CLI + +Use `npx xapi-to` for every xAPI management action. Do not call xAPI management +HTTP endpoints directly. Put service contracts and upstream credentials in JSON +files and pass them with `--file`; this avoids secrets and large payloads in shell +history. + +## Start here + +Check the installed CLI before building a payload: + +```bash +npx xapi-to provider --help +npx xapi-to skill --help +``` + +The CLI reads the key from saved config, `XAPI_KEY`, or `XAPI_API_KEY`. Never +search the filesystem for a credential or reuse a key found in a fixture. If no +key is configured, ask the user to configure one without echoing it: + +```bash +read -rsp 'xAPI key: ' XAPI_KEY_INPUT +printf '\n' +printf '%s\n' "$XAPI_KEY_INPUT" | npx xapi-to config set apiKey=- +unset XAPI_KEY_INPUT +``` + +Treat upstream values in `privateHeaders` the same way: receive them from the +user, store them only in the requested contract file, and never print them in a +summary. If a command returns a missing-scope error, stop and report the exact +scope. Do not try unrelated keys. + +## Choose the workflow + +- New service or contract shape: read + [lifecycle management](reference/lifecycle-management.md), then start from a + file in [`templates/`](templates/). +- Upstream authentication: read [authentication](reference/auth-types.md). +- Fixed, token, resource, or zero-price billing: read + [billing](reference/billing-modes.md) and + [free access](reference/free-api-access.md). +- AI models or token accounting: read + [AI services](reference/ai-spec-and-models.md). +- WebSocket endpoints: read + [WebSocket endpoints](reference/websocket-endpoints.md). +- OAuth user-context endpoints: read + [OAuth user context](reference/oauth-user-context.md). +- Relay or aggregator onboarding: read + [relay onboarding](reference/onboarding-openai-relay.md). +- Before any create or publish: apply the + [validation checklist](reference/validation-checklist.md). + +## Standard lifecycle + +Prepare `service.json`, then create and inspect the draft: + +```bash +npx xapi-to provider create --file ./service.json +npx xapi-to provider list +npx xapi-to provider get +npx xapi-to provider versions +``` + +Use the returned version ID for contract changes. PATCH merges supplied fields; +`--replace` sends a full replacement: + +```bash +npx xapi-to provider version update \ + --file ./contract.patch.json +npx xapi-to provider diff +``` + +Submit only after inspecting the diff. Publishing can change live behavior and +the CLI deliberately does not retry an ambiguous write: + +```bash +npx xapi-to provider publish \ + --changelog-file ./CHANGELOG.md +npx xapi-to provider review +npx xapi-to provider versions +``` + +For a published major, create a working revision before editing it. Create a new +major only when the public contract needs a major-version boundary: + +```bash +npx xapi-to provider revision start +npx xapi-to provider major create +``` + +## Marketplace content and usage Skill + +Update service-card content separately from the version contract: + +```bash +npx xapi-to provider update \ + --description "Short marketplace summary" \ + --about-file ./ABOUT.md \ + --website https://example.com +``` + +Generate, submit, wait for, link, and fingerprint a service-specific usage +Skill entirely through the CLI: + +```bash +npx xapi-to provider skill scaffold \ + --output ./my-service/SKILL.md +npx xapi-to skill submit --dir ./my-service +npx xapi-to skill wait --timeout 10m +npx xapi-to provider skill link +npx xapi-to provider skill fingerprint \ + --skill-version-id +npx xapi-to provider skill context +``` + +The scaffold command refuses to overwrite an existing file unless `--force` is +explicit. Link only a Skill owned by the same provider. If `skill context` +reports contract drift, update and resubmit the Skill before recording a new +fingerprint. Use `provider skill unlink ` to remove the association. + +## Observe, recover, and earn + +```bash +npx xapi-to provider metrics --days 30 +npx xapi-to provider metrics --days 7 +npx xapi-to provider events --limit 50 +npx xapi-to provider events --after '' --limit 50 +npx xapi-to usage wait --timeout 1m +``` + +Pass event cursors back unchanged. Inspect the target before changing live +routing: + +```bash +npx xapi-to provider rollback \ + --revision \ + --reason-file ./ROLLBACK_REASON.md +npx xapi-to provider default-major +npx xapi-to provider deprecate +npx xapi-to provider restore +``` + +Do not automatically retry an ambiguous rollback response. Read `versions` and +`review` first. Deletion requires both `service:delete` and the explicit service +name or ID: + +```bash +npx xapi-to provider delete --confirm +``` + +Provider earnings use the same configured key: + +```bash +npx xapi-to earnings +npx xapi-to earnings list --status SETTLED +npx xapi-to earnings transfer 1 --idempotency-key +``` + +Transfer is one-way. Confirm the amount and settled balance first. Retry only +with the same idempotency key and amount. + +## Scope map + +| CLI operation | Required scope | +|---|---| +| `provider create` | `service:create` | +| `provider list/get/versions/review/diff`, `provider skill context/scaffold` | `service:read` | +| `provider update`, `provider version update`, Skill link/unlink/fingerprint | `service:update` | +| `provider major create`, `provider revision start` | `version:create` | +| `provider publish` | `service:publish` | +| rollback/default-major/deprecate/restore | `service:rollback` | +| metrics/events | `observability:read` | +| `skill spec/status/wait` | `skill:read` | +| `skill submit` | `skill:submit` | +| earnings summary/list or transfer | `earnings:read` / `earnings:transfer` | +| `provider delete` | `service:delete` | + +Scope and ownership are independent. A key with a scope cannot manage another +provider's service or Skill. diff --git a/skills/xapi-provider/examples/01-per-call-minimal.json b/skills/xapi-provider/examples/01-per-call-minimal.json new file mode 100644 index 0000000..7171f3e --- /dev/null +++ b/skills/xapi-provider/examples/01-per-call-minimal.json @@ -0,0 +1,21 @@ +{ + "name": "Health Check API", + "description": "Simple status endpoint with fixed per-call billing.", + "category": "Public-Utils", + "host": "health-check-demo", + "baseUrl": "https://api.example.com", + "authType": "HEADER", + "privateHeaders": { "X-API-Key": "replace-with-upstream-secret" }, + "accessMode": "PROXY", + "isPublic": true, + "endpoints": [ + { + "name": "Get health status", + "description": "Returns upstream health and version information.", + "method": "GET", + "path": "/health", + "billingType": "PER_CALL", + "costPerCall": 0.001 + } + ] +} diff --git a/skills/xapi-provider/examples/02-per-token-chat.json b/skills/xapi-provider/examples/02-per-token-chat.json new file mode 100644 index 0000000..0bb371a --- /dev/null +++ b/skills/xapi-provider/examples/02-per-token-chat.json @@ -0,0 +1,37 @@ +{ + "name": "Multi-Model Chat API", + "description": "OpenAI-compatible chat with model-specific token pricing.", + "category": "AI-Models", + "host": "chat-multi-model", + "baseUrl": "https://api.example.com", + "authType": "BEARER", + "privateHeaders": { "Authorization": "replace-with-upstream-token" }, + "accessMode": "PROXY", + "isPublic": true, + "endpoints": [ + { + "name": "Create chat completion", + "method": "POST", + "path": "/v1/chat/completions", + "billingType": "PER_TOKEN", + "servedModels": ["model-a", "model-b"], + "tokenPricing": { + "model-a": { + "inputPricePerToken": 0.000001, + "outputPricePerToken": 0.000003 + }, + "model-b": { + "inputPricePerToken": 0.000002, + "outputPricePerToken": 0.000006 + }, + "default": { + "inputPricePerToken": 0.000001, + "outputPricePerToken": 0.000003 + } + }, + "estimatedMaxTokens": 8192, + "supportsStreaming": true, + "streamFormat": "sse" + } + ] +} diff --git a/skills/xapi-provider/examples/03-per-token-embeddings.json b/skills/xapi-provider/examples/03-per-token-embeddings.json new file mode 100644 index 0000000..8b2ce25 --- /dev/null +++ b/skills/xapi-provider/examples/03-per-token-embeddings.json @@ -0,0 +1,30 @@ +{ + "name": "Embeddings API", + "description": "Text embeddings with input-token billing.", + "category": "AI-Models", + "host": "embeddings-demo", + "baseUrl": "https://api.example.com", + "authType": "BEARER", + "privateHeaders": { "Authorization": "replace-with-upstream-token" }, + "accessMode": "PROXY", + "endpoints": [ + { + "name": "Generate embeddings", + "method": "POST", + "path": "/v1/embeddings", + "billingType": "PER_TOKEN", + "servedModels": ["embedding-model"], + "tokenPricing": { + "embedding-model": { + "inputPricePerToken": 0.0000001, + "outputPricePerToken": 0 + }, + "default": { + "inputPricePerToken": 0.0000001, + "outputPricePerToken": 0 + } + }, + "estimatedMaxTokens": 8191 + } + ] +} diff --git a/skills/xapi-provider/examples/04-per-resource-search.json b/skills/xapi-provider/examples/04-per-resource-search.json new file mode 100644 index 0000000..bbdb6b6 --- /dev/null +++ b/skills/xapi-provider/examples/04-per-resource-search.json @@ -0,0 +1,33 @@ +{ + "name": "Web Search API", + "description": "Web search billed by the number of results.", + "category": "Data-Analysis", + "host": "web-search-demo", + "baseUrl": "https://search.example.com", + "authType": "HEADER", + "privateHeaders": { "X-Subscription-Token": "replace-with-upstream-secret" }, + "accessMode": "PROXY", + "endpoints": [ + { + "name": "Search the web", + "method": "GET", + "path": "/v1/search", + "params": { + "q": { + "required": true, + "description": "Search keywords", + "schema": { "type": "string" } + }, + "limit": { + "required": false, + "description": "Maximum results", + "schema": { "type": "integer", "default": 10, "minimum": 1, "maximum": 100 } + } + }, + "billingType": "PER_RESOURCE", + "costPerResource": 0.005, + "resourceCountJsonPath": "data.items", + "estimatedMaxResources": 100 + } + ] +} diff --git a/skills/xapi-provider/examples/05-streaming-sse.json b/skills/xapi-provider/examples/05-streaming-sse.json new file mode 100644 index 0000000..1d3f062 --- /dev/null +++ b/skills/xapi-provider/examples/05-streaming-sse.json @@ -0,0 +1,27 @@ +{ + "name": "Streaming Chat API", + "description": "SSE chat with token billing and final usage.", + "category": "AI-Models", + "host": "chat-streaming-sse", + "baseUrl": "https://api.example.com", + "authType": "BEARER", + "privateHeaders": { "Authorization": "replace-with-upstream-token" }, + "accessMode": "PROXY", + "endpoints": [ + { + "name": "Stream chat completion", + "method": "POST", + "path": "/v1/chat/completions", + "billingType": "PER_TOKEN", + "tokenPricing": { + "default": { + "inputPricePerToken": 0.000001, + "outputPricePerToken": 0.000003 + } + }, + "estimatedMaxTokens": 4096, + "supportsStreaming": true, + "streamFormat": "sse" + } + ] +} diff --git a/skills/xapi-provider/examples/06-streaming-ndjson.json b/skills/xapi-provider/examples/06-streaming-ndjson.json new file mode 100644 index 0000000..fecb22f --- /dev/null +++ b/skills/xapi-provider/examples/06-streaming-ndjson.json @@ -0,0 +1,30 @@ +{ + "name": "File Chunker API", + "description": "NDJSON file processing billed by chunks generated.", + "category": "Data-Analysis", + "host": "file-chunker", + "baseUrl": "https://files.example.com", + "authType": "HEADER", + "privateHeaders": { "X-API-Key": "replace-with-upstream-secret" }, + "accessMode": "PROXY", + "endpoints": [ + { + "name": "Stream file chunks", + "method": "POST", + "path": "/v1/files/:fileId/chunks", + "pathParams": { + "fileId": { + "required": true, + "description": "File identifier", + "schema": { "type": "string" } + } + }, + "billingType": "PER_RESOURCE", + "costPerResource": 0.02, + "resourceCountJsonPath": "chunks_generated", + "estimatedMaxResources": 50, + "supportsStreaming": true, + "streamFormat": "ndjson" + } + ] +} diff --git a/skills/xapi-provider/examples/07-full-featured.json b/skills/xapi-provider/examples/07-full-featured.json new file mode 100644 index 0000000..fc1292f --- /dev/null +++ b/skills/xapi-provider/examples/07-full-featured.json @@ -0,0 +1,63 @@ +{ + "name": "Provider Contract Demo", + "description": "HTTP and WebSocket endpoints with fixed, token, and resource billing.", + "category": "AI-Models", + "host": "provider-contract-demo", + "baseUrl": "https://api.example.com", + "authType": "HEADER", + "privateHeaders": { "X-API-Key": "replace-with-upstream-secret" }, + "accessMode": "PROXY", + "isPublic": true, + "enableDetailedLogs": true, + "endpoints": [ + { + "name": "Health", + "method": "GET", + "path": "/health", + "billingType": "PER_CALL", + "costPerCall": 0 + }, + { + "name": "Chat", + "method": "POST", + "path": "/v1/chat/completions", + "billingType": "PER_TOKEN", + "tokenPricing": { + "default": { + "inputPricePerToken": 0.000001, + "outputPricePerToken": 0.000003 + } + }, + "estimatedMaxTokens": 4096, + "supportsStreaming": true, + "streamFormat": "sse" + }, + { + "name": "Search", + "method": "GET", + "path": "/search", + "billingType": "PER_RESOURCE", + "costPerResource": 0.005, + "resourceCountJsonPath": "data.items", + "estimatedMaxResources": 100 + }, + { + "name": "Realtime session", + "method": "GET", + "path": "/v1/realtime", + "protocol": "WEBSOCKET", + "wsConfig": { + "upstreamUrl": "wss://realtime.example.com/v1/session", + "adapter": "openai-realtime", + "maxDurationSec": 900, + "maxConnPerKey": 2 + }, + "wsBilling": { + "kind": "realtime-token", + "inputTokenUsd": 0.000005, + "outputTokenUsd": 0.00002, + "holdUsd": 0.05 + } + } + ] +} diff --git a/skills/xapi-provider/examples/08-lifecycle-demo.sh b/skills/xapi-provider/examples/08-lifecycle-demo.sh new file mode 100755 index 0000000..c38603b --- /dev/null +++ b/skills/xapi-provider/examples/08-lifecycle-demo.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "usage: $0 " >&2 + exit 2 +fi + +SERVICE_FILE=$1 + +# This example intentionally stops after creation. Read IDs from the output and +# inspect them before performing publication or live-routing mutations. +npx xapi-to provider create --file "$SERVICE_FILE" +npx xapi-to provider list + +cat <<'NEXT' +Continue with the IDs returned above: + npx xapi-to provider get + npx xapi-to provider versions + npx xapi-to provider diff + npx xapi-to provider publish --changelog-file ./CHANGELOG.md + npx xapi-to provider review +NEXT diff --git a/skills/xapi-provider/reference/ai-spec-and-models.md b/skills/xapi-provider/reference/ai-spec-and-models.md new file mode 100644 index 0000000..60b2b20 --- /dev/null +++ b/skills/xapi-provider/reference/ai-spec-and-models.md @@ -0,0 +1,48 @@ +# AI services and model pricing + +Create AI services with the same provider CLI workflow as other services. The +endpoint path helps xAPI identify standard interfaces such as OpenAI chat, +Responses, embeddings, images, Claude messages, and Gemini. Keep standard paths +when the upstream is compatible; non-standard paths usually require explicit +`tokenJsonPaths`. + +Before writing the contract, establish: + +1. the upstream public base URL and authentication type; +2. the supported model names; +3. input and output USD price per token for each model; +4. the largest allowed request/output so `estimatedMaxTokens` is realistic; +5. whether streaming responses contain final usage. + +Use [`templates/ai-model.json`](../templates/ai-model.json) as a starting point: + +```bash +npx xapi-to provider create --file ./ai-service.json +npx xapi-to provider versions +``` + +For multi-model services, include a `default` price and optional +`servedModels`. Do not invent pricing. If the upstream has tiered, cached, +reasoning, audio, image, or tool-call pricing, represent it in `tokenPricing` +instead of flattening it without the user's agreement. + +For SSE, request upstream usage in the terminal event when its protocol +supports that option. For NDJSON, place usage in the last JSON line. If final +usage cannot be extracted, the platform may charge the configured maximum. + +Use a version patch for model or pricing updates: + +```bash +npx xapi-to provider revision start +npx xapi-to provider version update \ + --file ./models.patch.json +npx xapi-to provider diff +npx xapi-to provider publish \ + --changelog "Update model catalog and pricing" +``` + +After a canary call, use its request ID to wait for finalized accounting: + +```bash +npx xapi-to usage wait --timeout 1m +``` diff --git a/skills/xapi-provider/reference/auth-types.md b/skills/xapi-provider/reference/auth-types.md new file mode 100644 index 0000000..d08291e --- /dev/null +++ b/skills/xapi-provider/reference/auth-types.md @@ -0,0 +1,62 @@ +# Upstream authentication + +Authentication belongs in the JSON passed to `provider create` or `provider +version update`. Never put a real credential in public schema content, endpoint +docs, the changelog, or a command argument. + +| Upstream expects | `authType` | `privateHeaders` | +|---|---|---| +| no authentication | `NONE` | omit | +| custom header | `HEADER` | `{ "X-API-Key": "secret" }` | +| bearer token | `BEARER` | `{ "Authorization": "secret" }` | +| query credential | `QUERY` | `{ "appid": "secret" }` | + +For bearer auth, supply the raw token or an `Authorization` value; the platform +normalizes the `Bearer` prefix. For HEADER and QUERY auth, every map entry is +injected upstream, so the keys must be the exact header or parameter names. + +Create example: + +```json +{ + "name": "Private Search", + "baseUrl": "https://search.example.com", + "authType": "HEADER", + "privateHeaders": { + "X-API-Key": "replace-with-upstream-secret", + "X-Tenant-Id": "tenant-42" + }, + "endpoints": [ + { + "name": "Search", + "method": "GET", + "path": "/search", + "billingType": "PER_CALL", + "costPerCall": 0.01 + } + ] +} +``` + +```bash +npx xapi-to provider create --file ./service.json +``` + +To rotate credentials, start a working revision if the current one is +published, then write only the authentication fields to a private patch file: + +```json +{ + "authType": "BEARER", + "privateHeaders": { "Authorization": "replace-with-new-token" } +} +``` + +```bash +npx xapi-to provider revision start +npx xapi-to provider version update \ + --file ./auth.patch.json +``` + +Reads intentionally scrub credential values. Confirm the auth type and run a +canary call; do not expect a read command to return the secret. diff --git a/skills/xapi-provider/reference/billing-modes.md b/skills/xapi-provider/reference/billing-modes.md new file mode 100644 index 0000000..03258e9 --- /dev/null +++ b/skills/xapi-provider/reference/billing-modes.md @@ -0,0 +1,88 @@ +# Endpoint billing + +Set billing on each object in `endpoints`. The CLI sends these fields as part of +`provider create --file` or `provider version update --file`. + +## Fixed price: `PER_CALL` + +```json +{ + "name": "Lookup", + "method": "GET", + "path": "/lookup", + "billingType": "PER_CALL", + "costPerCall": 0.001 +} +``` + +Use this when every successful invocation has the same price. A price of `0` is +valid; read [free access](free-api-access.md) before publishing a free endpoint. + +## Token usage: `PER_TOKEN` + +```json +{ + "name": "Chat completion", + "method": "POST", + "path": "/v1/chat/completions", + "billingType": "PER_TOKEN", + "tokenPricing": { + "model-a": { + "inputPricePerToken": 0.000001, + "outputPricePerToken": 0.000003 + }, + "default": { + "inputPricePerToken": 0.000001, + "outputPricePerToken": 0.000003 + } + }, + "estimatedMaxTokens": 4096, + "supportsStreaming": true, + "streamFormat": "sse" +} +``` + +Always obtain pricing from the user or an authoritative upstream source. Keep a +`default` entry for unknown model names. Standard AI paths are detected by the +platform; use `tokenJsonPaths` only for a non-standard request or response: + +```json +{ + "tokenJsonPaths": { + "requestModel": "model", + "model": "model", + "inputTokens": "usage.prompt_tokens", + "outputTokens": "usage.completion_tokens" + } +} +``` + +`estimatedMaxTokens` controls the pre-charge hold. Choose a realistic maximum; +an excessive value can reject affordable calls, while a value below actual use +cannot cover the final charge. + +## Returned items: `PER_RESOURCE` + +```json +{ + "name": "Search", + "method": "GET", + "path": "/search", + "billingType": "PER_RESOURCE", + "costPerResource": 0.005, + "resourceCountJsonPath": "data.items", + "estimatedMaxResources": 100 +} +``` + +The count path may resolve to an array, whose length is charged, or a numeric +count. For streamed NDJSON/SSE, the final structured chunk must expose the +count. The estimated maximum is the pre-charge cap. + +After changing prices, inspect the diff before publication: + +```bash +npx xapi-to provider version update \ + --file ./pricing.patch.json +npx xapi-to provider diff +``` diff --git a/skills/xapi-provider/reference/free-api-access.md b/skills/xapi-provider/reference/free-api-access.md new file mode 100644 index 0000000..ad4cdac --- /dev/null +++ b/skills/xapi-provider/reference/free-api-access.md @@ -0,0 +1,36 @@ +# Zero-price endpoints + +An endpoint with `PER_CALL` and `costPerCall: 0` is free, but callers still need +a registered xAPI key. Authentication preserves usage attribution, provider +metrics, rate limiting, and abuse controls. + +```json +{ + "endpoints": [ + { + "id": "existing-endpoint-id", + "billingType": "PER_CALL", + "costPerCall": 0 + } + ] +} +``` + +```bash +npx xapi-to provider version update \ + --file ./free-endpoint.patch.json +``` + +Before publishing, verify the service remains `PROXY` if it needs xAPI usage +tracking or service rate limits. After publication, use a registered canary key +with zero spendable balance, then inspect provider metrics and the finalized +receipt: + +```bash +npx xapi-to usage wait --timeout 1m +npx xapi-to provider metrics --days 1 +npx xapi-to provider events --limit 20 +``` + +Do not describe a zero-price endpoint as anonymous. Keep normal endpoint, +per-user, and WebSocket connection limits even when no balance hold is needed. diff --git a/skills/xapi-provider/reference/lifecycle-management.md b/skills/xapi-provider/reference/lifecycle-management.md new file mode 100644 index 0000000..1b27a12 --- /dev/null +++ b/skills/xapi-provider/reference/lifecycle-management.md @@ -0,0 +1,185 @@ +# CLI lifecycle and payload contract + +The provider CLI manages a service through scoped `XAPI-KEY` routes internally. +Use the commands here instead of constructing management requests yourself. + +## Service and revision states + +A newly created service starts with a `DRAFT` v1 revision. Saving a valid +contract moves it to `SANDBOX`; publishing moves it to `IN_REVIEW`. A passing +review produces an immutable `PUBLISHED` revision. A failed review returns the +revision to `SANDBOX`; an uncertain review may wait for human review. A service +can also become `SUSPENDED` after publication. + +Use these reads to determine current state and IDs: + +```bash +npx xapi-to provider list +npx xapi-to provider get +npx xapi-to provider versions +npx xapi-to provider review +npx xapi-to provider diff +``` + +Do not infer version IDs from version labels. Use the IDs returned by `create`, +`versions`, or `revision start`. + +## Create payload + +`provider create --file` accepts a JSON object. `name` and `authType` are +required. A complete HTTP example is: + +```json +{ + "name": "Weather API", + "description": "Forecasts and current conditions", + "category": "Data-Analysis", + "host": "weather-provider", + "baseUrl": "https://weather.example.com/v1", + "authType": "HEADER", + "privateHeaders": { "X-API-Key": "replace-with-upstream-secret" }, + "accessMode": "PROXY", + "isPublic": true, + "endpoints": [ + { + "name": "Current weather", + "method": "GET", + "path": "/weather/:city", + "pathParams": { + "city": { + "required": true, + "description": "City name", + "schema": { "type": "string" } + } + }, + "billingType": "PER_CALL", + "costPerCall": 0.001 + } + ] +} +``` + +`baseUrl` and every `wsConfig.upstreamUrl` must be public URLs. Use `baseUrls` +for named upstream environments and prefix an endpoint path with `{{name}}` to +select one. For example, `"path": "{{staging}}/weather"` requires a `staging` +entry in `baseUrls`. + +Run: + +```bash +npx xapi-to provider create --file ./service.json +``` + +The output contains the service and initial version/revision IDs. Keep those +IDs for later commands. + +## Service metadata versus version contract + +`provider update` changes marketplace metadata, the linked Skill, or service +rate limiting. It does not change endpoints, upstream URLs, authentication, or +billing. + +```bash +npx xapi-to provider update \ + --name "Weather API" \ + --description-file ./DESCRIPTION.txt \ + --about-file ./ABOUT.md \ + --website https://example.com \ + --logo-url https://example.com/logo.png \ + --category Data-Analysis +``` + +Use `--clear-about` or `--clear-website` to clear those fields. For structured +metadata such as a request limit, use a file: + +```json +{ "rateLimitConfig": { "requests": 100, "periodSeconds": 60 } } +``` + +```bash +npx xapi-to provider update --file ./service-metadata.json +``` + +The limit is shared by all keys owned by one user for this service. It applies +only to `PROXY` services. Set `rateLimitConfig` to `null` to disable it. + +## Version updates + +Use a partial file for the default PATCH behavior: + +```json +{ + "baseUrl": "https://weather.example.com/v2", + "endpoints": [ + { + "id": "existing-endpoint-id", + "costPerCall": 0.002 + } + ] +} +``` + +```bash +npx xapi-to provider version update \ + --file ./contract.patch.json +``` + +Include an endpoint `id` when updating an existing endpoint. Omitting it means +new endpoint. Use `--replace` only with a complete version contract; it sends a +PUT and replaces omitted contract data: + +```bash +npx xapi-to provider version update \ + --file ./contract.full.json --replace +``` + +Published revisions are immutable. Start a working revision for the existing +major, or create a new major: + +```bash +npx xapi-to provider revision start +npx xapi-to provider major create +``` + +## Publish and review + +Before publishing, the revision needs a valid public upstream and at least one +endpoint. Inspect the diff, submit, then inspect review state: + +```bash +npx xapi-to provider diff +npx xapi-to provider publish \ + --changelog-file ./CHANGELOG.md +npx xapi-to provider review +npx xapi-to provider versions +``` + +The changelog is public provider-authored release information. Do not put +credentials, internal logs, or review-worker output in it. If publish returns an +ambiguous transport failure, read state before deciding whether to submit again. + +## Rollback, major routing, and removal + +Rollback targets an existing published revision in one major: + +```bash +npx xapi-to provider rollback \ + --revision \ + --reason "Restore known-good behavior" +``` + +Changing the default major and taking a major out of or back into routing are +separate operations: + +```bash +npx xapi-to provider default-major +npx xapi-to provider deprecate +npx xapi-to provider restore +``` + +Read `versions` after an ambiguous write. Delete only when the user intends to +remove the service and has identified it by name or ID: + +```bash +npx xapi-to provider delete --confirm +``` diff --git a/skills/xapi-provider/reference/oauth-user-context.md b/skills/xapi-provider/reference/oauth-user-context.md new file mode 100644 index 0000000..5a16339 --- /dev/null +++ b/skills/xapi-provider/reference/oauth-user-context.md @@ -0,0 +1,31 @@ +# OAuth user-context endpoints + +Some upstream operations act as the end user, such as posting to a social +account or reading private messages. An application-level HEADER, BEARER, or +QUERY secret is insufficient for those operations. + +The provider CLI can carry `userOAuthProviderId` on an endpoint contract, but +the backend permits that field only for authorized providers. Obtain the +correct provider ID through the platform's approved OAuth setup; never guess an +ID or substitute a provider credential. + +```json +{ + "endpoints": [ + { + "id": "existing-endpoint-id", + "userOAuthProviderId": "approved-provider-uuid" + } + ] +} +``` + +```bash +npx xapi-to provider version update \ + --file ./oauth.patch.json +``` + +If the command reports that only platform administrators can configure OAuth, +stop and tell the user the service contract is ready but the endpoint needs an +administrator to bind the approved OAuth provider. Do not remove the +user-context requirement to make review pass. diff --git a/skills/xapi-provider/reference/onboarding-openai-relay.md b/skills/xapi-provider/reference/onboarding-openai-relay.md new file mode 100644 index 0000000..3f81c63 --- /dev/null +++ b/skills/xapi-provider/reference/onboarding-openai-relay.md @@ -0,0 +1,34 @@ +# OpenAI-compatible relay onboarding + +Use this workflow when the upstream is a New API, One API, or another +OpenAI-compatible relay with a model catalog. + +First obtain the relay's public base URL, authentication value, model list, and +authoritative pricing from the user or the relay's documented discovery +interface. Do not copy stale pricing from examples and do not probe an +undocumented administrative endpoint. + +Build one CLI create file containing: + +- `authType: "BEARER"` and the secret in `privateHeaders`; +- the public relay URL in `baseUrl`; +- standard paths such as `/v1/chat/completions`, `/v1/responses`, and + `/v1/embeddings` only when the relay supports them; +- one `PER_TOKEN` endpoint contract per supported interface; +- the upstream model names in `servedModels` and their prices in + `tokenPricing`, including `default`; +- streaming fields only for interfaces that return final usage correctly. + +Start from [`../templates/ai-model.json`](../templates/ai-model.json), then run: + +```bash +npx xapi-to provider create --file ./relay-service.json +npx xapi-to provider get +npx xapi-to provider versions +npx xapi-to provider diff 1 +``` + +If the relay quotes ratios or internal quota units, preserve the source values +and conversion assumptions while calculating USD/token. Ask the user to confirm +the resulting prices before publishing. A later model-catalog refresh is a +version update, followed by diff, publish, and review commands. diff --git a/skills/xapi-provider/reference/validation-checklist.md b/skills/xapi-provider/reference/validation-checklist.md new file mode 100644 index 0000000..6e2e203 --- /dev/null +++ b/skills/xapi-provider/reference/validation-checklist.md @@ -0,0 +1,57 @@ +# CLI provider validation checklist + +Apply this checklist to the JSON file before `provider create`, to a full file +before `provider version update --replace`, and again before publication. + +## Service + +- `name` is non-empty and `authType` is one of `NONE`, `HEADER`, `BEARER`, or + `QUERY`. +- `host`, if supplied, normalizes to a lowercase DNS label and is not a reserved + platform name. +- `category` uses a platform category such as `Public-Utils`, `AI-Models`, + `Social`, `Data-Analysis`, or `Crypto`. +- `accessMode` is `PROXY` or `DIRECT`. Use `PROXY` for gateway billing, + observability, or service rate limiting. +- `baseUrl` and named `baseUrls` are public HTTP(S) URLs. A pure WebSocket + service may omit `baseUrl`. +- Real upstream secrets appear only in `privateHeaders`. + +## Endpoints + +- Every new endpoint has `name`, `method`, and `path`. +- Existing endpoint patches include `id`; otherwise the backend treats the item + as new. +- Path parameters use `:name` and have matching `pathParams` descriptions. +- Billing fields match the chosen `billingType`; pre-charge estimates are + realistic. +- Streaming endpoints set both `supportsStreaming` and `streamFormat`. +- WebSocket endpoints set `protocol: "WEBSOCKET"`, a supported adapter, a + public `wsConfig.upstreamUrl`, and a valid `wsBilling` object. +- OAuth user-context endpoints retain their approved `userOAuthProviderId`. + +## Before create or update + +Parse the file locally and inspect the exact command: + +```bash +node -e 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"))' ./service.json +npx xapi-to provider --help +``` + +Then run the relevant write once. If a non-idempotent write has an ambiguous +transport result, use `provider get`, `versions`, `diff`, or `review` to resolve +state before retrying. + +## Before publish + +```bash +npx xapi-to provider get +npx xapi-to provider versions +npx xapi-to provider diff +``` + +Confirm there is at least one endpoint, required upstream URLs are present, +prices and auth types are correct, no secrets appear in public metadata, and +the changelog describes the public contract change. After publish, inspect both +`review` and `versions` before reporting success. diff --git a/skills/xapi-provider/reference/websocket-endpoints.md b/skills/xapi-provider/reference/websocket-endpoints.md new file mode 100644 index 0000000..9c74263 --- /dev/null +++ b/skills/xapi-provider/reference/websocket-endpoints.md @@ -0,0 +1,75 @@ +# WebSocket endpoints through the CLI + +WebSocket contracts use `protocol`, `wsConfig`, and `wsBilling` inside an +endpoint object. Create or update them through provider CLI files. + +```json +{ + "name": "Realtime speech", + "authType": "BEARER", + "privateHeaders": { "Authorization": "replace-with-upstream-token" }, + "endpoints": [ + { + "name": "Realtime session", + "method": "GET", + "path": "/v1/realtime", + "protocol": "WEBSOCKET", + "wsConfig": { + "upstreamUrl": "wss://realtime.example.com/v1/session", + "adapter": "openai-realtime", + "idleTimeoutSec": 120, + "maxDurationSec": 900, + "maxConnPerKey": 2 + }, + "wsBilling": { + "kind": "realtime-token", + "inputTokenUsd": 0.000005, + "outputTokenUsd": 0.00002, + "holdUsd": 0.05 + } + } + ] +} +``` + +```bash +npx xapi-to provider create --file ./websocket-service.json +``` + +`wsConfig.upstreamUrl` must be a public `ws://` or `wss://` URL. `adapter` is a +lowercase platform-supported adapter ID. Optional connection controls include +`clientPath`, `subprotocols`, `heartbeatSec`, `idleTimeoutSec`, +`maxFrameBytes`, `maxDurationSec`, and `maxConnPerKey`. + +Billing options are: + +- `duration` with `perMinuteUsd`; +- `realtime-token` with mixed input/output prices or modality-specific prices; +- `per-char` with `perCharUsd`. + +Set `holdUsd` explicitly for realtime-token workloads. For duration billing, +the platform can derive a hold from maximum duration and the per-minute price. +Zero-priced WebSockets still keep connection and rate limits. + +For an existing endpoint, inspect the service to get its endpoint ID and include +that ID in the patch: + +```json +{ + "endpoints": [ + { + "id": "existing-endpoint-id", + "wsConfig": { "maxConnPerKey": 4 } + } + ] +} +``` + +```bash +npx xapi-to provider version update \ + --file ./websocket.patch.json +``` + +Pure WebSocket services may omit `baseUrl`, but every WebSocket endpoint needs +its own `wsConfig.upstreamUrl`. Inspect the diff and review result before +claiming the route is live. diff --git a/skills/xapi-provider/templates/ai-model.json b/skills/xapi-provider/templates/ai-model.json new file mode 100644 index 0000000..ededb85 --- /dev/null +++ b/skills/xapi-provider/templates/ai-model.json @@ -0,0 +1,36 @@ +{ + "name": "<>", + "description": "<>", + "category": "AI-Models", + "host": "<>", + "baseUrl": "<>", + "authType": "BEARER", + "privateHeaders": { + "Authorization": "<>" + }, + "accessMode": "PROXY", + "isPublic": true, + "endpoints": [ + { + "name": "Chat completions", + "description": "OpenAI-compatible chat completion", + "method": "POST", + "path": "/v1/chat/completions", + "billingType": "PER_TOKEN", + "servedModels": ["<>"], + "tokenPricing": { + "<>": { + "inputPricePerToken": 0.000001, + "outputPricePerToken": 0.000003 + }, + "default": { + "inputPricePerToken": 0.000001, + "outputPricePerToken": 0.000003 + } + }, + "estimatedMaxTokens": 4096, + "supportsStreaming": true, + "streamFormat": "sse" + } + ] +} diff --git a/skills/xapi-provider/templates/minimal.json b/skills/xapi-provider/templates/minimal.json new file mode 100644 index 0000000..0f41074 --- /dev/null +++ b/skills/xapi-provider/templates/minimal.json @@ -0,0 +1,23 @@ +{ + "name": "<>", + "description": "<>", + "category": "Public-Utils", + "host": "<>", + "baseUrl": "<>", + "authType": "HEADER", + "privateHeaders": { + "<>": "<>" + }, + "accessMode": "PROXY", + "isPublic": true, + "endpoints": [ + { + "name": "<>", + "description": "<>", + "method": "GET", + "path": "<>", + "billingType": "PER_CALL", + "costPerCall": 0.001 + } + ] +} diff --git a/src/tests/xapi-provider-skill.test.ts b/src/tests/xapi-provider-skill.test.ts new file mode 100644 index 0000000..90c8116 --- /dev/null +++ b/src/tests/xapi-provider-skill.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'bun:test'; +import { readFileSync, readdirSync } from 'node:fs'; +import { extname, join } from 'node:path'; + +const root = join(import.meta.dir, '..', '..'); +const skillRoot = join(root, 'skills', 'xapi-provider'); +const skill = readFileSync(join(skillRoot, 'SKILL.md'), 'utf8'); + +function filesUnder(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + return entry.isDirectory() ? filesUnder(path) : [path]; + }); +} + +describe('bundled xapi-provider skill', () => { + it('covers every provider CLI operation family', () => { + for (const command of [ + 'provider create', + 'provider list', + 'provider get', + 'provider update', + 'provider versions', + 'provider version update', + 'provider major create', + 'provider revision start', + 'provider publish', + 'provider rollback', + 'provider default-major', + 'provider deprecate', + 'provider restore', + 'provider review', + 'provider diff', + 'provider metrics', + 'provider events', + 'provider skill context', + 'provider skill scaffold', + 'provider skill link', + 'provider skill unlink', + 'provider skill fingerprint', + 'provider delete', + ]) { + expect(skill).toContain(command); + } + }); + + it('uses the CLI for xAPI provider management', () => { + const instructionalFiles = filesUnder(skillRoot).filter((path) => + ['.md', '.sh'].includes(extname(path)), + ); + for (const path of instructionalFiles) { + const content = readFileSync(path, 'utf8'); + expect(content).not.toMatch(/\bcurl\b/); + expect(content).not.toContain('register-api-service'); + expect(content).not.toMatch(/(?:POST|GET|PUT|PATCH|DELETE) \/api\//); + } + }); + + it('ships CLI create payloads as valid JSON objects', () => { + const payloads = [ + ...filesUnder(join(skillRoot, 'templates')), + ...filesUnder(join(skillRoot, 'examples')), + ].filter((path) => extname(path) === '.json'); + + for (const path of payloads) { + const payload = JSON.parse(readFileSync(path, 'utf8')); + expect(payload).toBeInstanceOf(Object); + expect(typeof payload.name).toBe('string'); + expect(['NONE', 'HEADER', 'BEARER', 'QUERY']).toContain(payload.authType); + expect(Array.isArray(payload.endpoints)).toBe(true); + } + }); +}); From 0f0ec1b498a012f35e6b5133fde2ec4c346c9ad6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E9=9B=84=E5=91=80?= <47734376+dxiongya@users.noreply.github.com> Date: Sun, 20 Sep 2026 23:28:55 +0800 Subject: [PATCH 06/28] feat(workers): add safe secret management workflows (#32) --- skills/xapi-workers/SKILL.md | 1 + skills/xapi-workers/references/deployment.md | 2 +- skills/xapi-workers/references/secrets.md | 46 ++++++++++ src/commands/workers.ts | 90 ++++++++++++++++---- src/tests/workers-client.test.ts | 17 ++++ src/tests/workers-secrets.test.ts | 15 ++++ src/workers-client.ts | 30 +++++++ 7 files changed, 182 insertions(+), 19 deletions(-) create mode 100644 skills/xapi-workers/references/secrets.md create mode 100644 src/tests/workers-secrets.test.ts diff --git a/skills/xapi-workers/SKILL.md b/skills/xapi-workers/SKILL.md index 9d4df10..425676d 100644 --- a/skills/xapi-workers/SKILL.md +++ b/skills/xapi-workers/SKILL.md @@ -18,6 +18,7 @@ Use the `xapi` CLI (`xapi-to` is the same executable). Verify `xapi workers --he - **Build/deploy/import/CI:** read [deployment.md](references/deployment.md). - **Use and verify resources:** read [resources.md](references/resources.md). +- **Configure runtime credentials:** read [secrets.md](references/secrets.md). Values go to the environment's Cloudflare User Worker binding; never ask xAPI to reveal them. - **How much did it cost?** Read [billing.md](references/billing.md) before answering, collecting, or reconciling consumption. - **Pause/recover/delete/refund:** read [lifecycle.md](references/lifecycle.md) before lifecycle mutations. - **Buy or bind an xdomain domain:** read [domains.md](references/domains.md). Use the combined CLI command; do not manually create a CNAME to the Dispatcher or expose Cloudflare zone IDs. diff --git a/skills/xapi-workers/references/deployment.md b/skills/xapi-workers/references/deployment.md index a6ec10b..91ddc65 100644 --- a/skills/xapi-workers/references/deployment.md +++ b/skills/xapi-workers/references/deployment.md @@ -27,7 +27,7 @@ xapi workers secrets set APP_TOKEN --env preview --from-env APP_TOKE xapi workers logs --env preview --since 10m ``` -If provisioning requires an accepted retention quote, follow lifecycle.md and pass its exact `--retention-price-version VERSION`; do not invent a version. Supply secrets when the Worker exists and rerun the unchanged project command if a missing secret blocked deployment. Never report a blocked preflight as successful deployment. +If provisioning requires an accepted retention quote, follow lifecycle.md and pass its exact `--retention-price-version VERSION`; do not invent a version. Configure credentials with [secrets.md](secrets.md); xAPI never needs a code deployment to retain or replay their values. Never report a blocked preflight as successful deployment. Use the Node and package-manager version required by the application before `plan` or `push`; the CLI runs the configured build command unchanged. If the project declares `engines.node`, activate a compatible runtime first. A build-runtime failure is an application build failure and must occur before any deployment write; rerun the same push only after correcting the local runtime. diff --git a/skills/xapi-workers/references/secrets.md b/skills/xapi-workers/references/secrets.md new file mode 100644 index 0000000..a477847 --- /dev/null +++ b/skills/xapi-workers/references/secrets.md @@ -0,0 +1,46 @@ +# Worker Secrets + +Cloudflare User Worker secret bindings are the only long-term store for values. xAPI authenticates the caller, resolves the owned account/namespace/script from the Worker environment, forwards the value once, and stores name/status/audit metadata only. There is no reveal or export command. + +Preview and production are separate. Configure each environment explicitly; never copy a preview credential into production without the user requesting that exact rotation. + +## Safe input + +Prefer an existing process environment: + +```sh +MODEL_KEY='...' xapi workers secrets set MODEL_KEY \ + --env preview --from-env MODEL_KEY +``` + +Use stdin when a password manager emits the value: + +```sh +password-manager read MODEL_KEY | \ + xapi workers secrets set MODEL_KEY --env preview --stdin +``` + +Apply a local env file in one Cloudflare provider version: + +```sh +xapi workers secrets apply --env preview --env-file .env.worker +``` + +The env file must remain outside source control. The CLI reads it in memory and creates no plaintext temporary file. Do not pass values as command arguments, echo them, include them in JSON output, or enable HTTP debug/body logging. + +## Verification and uncertain results + +```sh +xapi workers secrets list --env preview +xapi workers secrets status --env preview +``` + +`list` returns xAPI metadata. `status` performs a read-only name comparison against Cloudflare and still never returns values. If a write times out, treat its result as unknown and run `status`; do not automatically replay a captured value. If the intended value cannot be proven, obtain or generate a fresh credential and rotate it. + +Code deploy, promotion, rollback, pause, and resume preserve provider secrets with Cloudflare native binding inheritance. They do not read values from xAPI or copy values between environments. A missing required binding should block activation rather than opening a route with incomplete runtime configuration. + +Delete only with explicit authorization: + +```sh +xapi workers secrets delete OLD_KEY --env preview --yes +``` diff --git a/src/commands/workers.ts b/src/commands/workers.ts index ae4cbc4..b5edd09 100644 --- a/src/commands/workers.ts +++ b/src/commands/workers.ts @@ -89,7 +89,9 @@ COMMANDS resources remove --env preview|production|both --binding NAME resources destroy --env preview|production --binding NAME --yes secrets list --env preview|production - secrets set --env ENV --from-env VARIABLE + secrets status --env preview|production + secrets set --env ENV (--from-env VARIABLE | --stdin | --env-file .env) + secrets apply --env ENV --env-file .env [--delete OLD_NAME,OTHER_NAME] secrets delete --env ENV --yes provider-status capabilities @@ -191,7 +193,9 @@ ADVANCED REMOTE RESOURCE COMMANDS SECRET FLAGS --from-env VARIABLE Read value from a local environment variable - --value VALUE Direct value (prefer --from-env to avoid shell history) + --stdin Read one value from stdin without shell history + --env-file PATH Read one named value or apply all entries in memory + --delete NAME,NAME Delete names in the same batch apply AUTHORIZATION API keys need workers:read for reads and workers:write for mutations. @@ -397,6 +401,48 @@ function environment(value: string | undefined): string { return result; } +export function parseWorkerSecretEnv(source:string):Record { + const values:Record={}; + for(const [index,raw] of source.split(/\r?\n/).entries()) { + const line=raw.trim(); + if(!line||line.startsWith('#'))continue; + const normalized=line.startsWith('export ')?line.slice(7).trim():line; + const separator=normalized.indexOf('='); + if(separator<=0)err(`invalid secret env entry on line ${index+1}`); + const name=normalized.slice(0,separator).trim(); + if(!/^[A-Z][A-Z0-9_]{0,63}$/.test(name))err(`invalid secret name on line ${index+1}`); + let value=normalized.slice(separator+1).trim(); + if((value.startsWith('"')&&value.endsWith('"'))||(value.startsWith("'")&&value.endsWith("'")))value=value.slice(1,-1); + if(!value)err(`secret ${name} is empty`); + values[name]=value; + } + return values; +} + +async function readSecretStdin() { + let value=''; + for await(const chunk of process.stdin)value+=chunk.toString(); + value=value.replace(/[\r\n]+$/,''); + if(!value)err('stdin secret value is empty'); + return value; +} + +async function secretValue(flags:Record,bindingName:string) { + const sources=['from-env','stdin','env-file'].filter(flag=>flags[flag]!==undefined); + if(sources.length!==1)err('choose exactly one of --from-env, --stdin, or --env-file'); + if(flags['from-env']) { + const value=process.env[flags['from-env']]; + if(!value)err(`environment variable ${flags['from-env']} is empty or missing`); + return value; + } + if(flags.stdin==='true')return readSecretStdin(); + if(!flags['env-file']||flags['env-file']==='true')err('--env-file requires a path'); + const values=parseWorkerSecretEnv(await readFile(resolve(flags['env-file']),'utf8')); + const key=flags['from-env']||bindingName; + if(!values[key])err(`secret ${key} is empty or missing from ${flags['env-file']}`); + return values[key]; +} + const IGNORED_DIRECTORIES = new Set([ ".aws", ".docker", @@ -1542,26 +1588,19 @@ export async function workersCommand( ); return; } + if (action === "status") { + assertFlags(flags,["env"]); + output(await client.workerSecretProviderStatus(options(),oneId(secretArgs,"usage: xapi-to workers secrets status --env ENV"),environment(flags.env))); + return; + } if (action === "set") { - assertFlags(flags, ["env", "from-env", "value"]); + assertFlags(flags, ["env", "from-env", "stdin", "env-file"]); if (secretArgs.length !== 2) { err( - "usage: xapi-to workers secrets set --env ENV --from-env VARIABLE", - ); - } - if (flags["from-env"] && flags.value) { - err("use either --from-env or --value, not both"); - } - const value = flags["from-env"] - ? process.env[flags["from-env"]] - : flags.value; - if (value === undefined || value === "true" || value === "") { - err( - flags["from-env"] - ? `environment variable ${flags["from-env"]} is empty or missing` - : "provide --from-env VARIABLE (recommended) or --value VALUE", + "usage: xapi-to workers secrets set --env ENV (--from-env VARIABLE | --stdin | --env-file .env)", ); } + const value=await secretValue(flags,secretArgs[1]); output( await client.putWorkerSecret( options(), @@ -1573,6 +1612,21 @@ export async function workersCommand( ); return; } + if(action==="apply") { + assertFlags(flags,["env","env-file","delete"]); + const id=oneId(secretArgs,"usage: xapi-to workers secrets apply --env ENV --env-file .env [--delete NAME,NAME]"); + if(!flags['env-file']||flags['env-file']==='true')err('--env-file requires a path'); + const values=parseWorkerSecretEnv(await readFile(resolve(flags['env-file']),'utf8')); + const deletes=(flags.delete&&flags.delete!=='true'?flags.delete.split(',').map(name=>name.trim()).filter(Boolean):[]); + if(!Object.keys(values).length&&!deletes.length)err('secret apply input is empty'); + const duplicates=deletes.filter(name=>Object.hasOwn(values,name)); + if(duplicates.length)err(`cannot set and delete the same secret: ${duplicates.join(', ')}`); + output(await client.applyWorkerSecrets(options(),id,environment(flags.env),[ + ...Object.entries(values).map(([name,value])=>({name,value})), + ...deletes.map(name=>({name,delete:true})), + ])); + return; + } if (action === "delete") { assertFlags(flags, ["env", "yes"]); if (secretArgs.length !== 2) { @@ -1593,7 +1647,7 @@ export async function workersCommand( ); return; } - err("usage: xapi-to workers secrets ..."); + err("usage: xapi-to workers secrets ..."); } case "provider-status": assertFlags(flags); diff --git a/src/tests/workers-client.test.ts b/src/tests/workers-client.test.ts index 965a914..c24c2d0 100644 --- a/src/tests/workers-client.test.ts +++ b/src/tests/workers-client.test.ts @@ -11,6 +11,7 @@ import { listWorkerDomains, createWorkerDomainChallenge, attachWorkerDomain, + applyWorkerSecrets, deleteWorkerDomain, retryWorkerDomain, rollbackWorker, @@ -18,6 +19,7 @@ import { workerBillingQuery, workerInvocationLogs, workerProviderCapabilities, + workerSecretProviderStatus, workerRuntimeLogs, workerUsage, workerMeteredUsage, @@ -30,6 +32,21 @@ let fetchSpy: ReturnType | undefined; afterEach(() => fetchSpy?.mockRestore()); describe("workers client", () => { + it("applies secret mutations without retrying or exposing values in the URL", async () => { + fetchSpy = spyOn(globalThis,"fetch").mockResolvedValue(new Response(JSON.stringify({status:"ACTIVE"}),{status:200,headers:{"content-type":"application/json"}})) as any; + await applyWorkerSecrets(options,"worker/1","preview",[{name:"MODEL_KEY",value:"private-value"},{name:"OLD_KEY",delete:true}]); + const [target,init]=fetchSpy.mock.calls[0] as any[]; + expect(target).toBe("https://test.xapi.to/api/v1/workers/worker%2F1/environments/preview/secrets"); + expect(init.method).toBe("PATCH"); + expect(JSON.parse(init.body)).toEqual({secrets:[{name:"MODEL_KEY",value:"private-value"},{name:"OLD_KEY",delete:true}]}); + expect(target).not.toContain("private-value"); + }); + + it("reads provider secret status without values", async () => { + fetchSpy = spyOn(globalThis,"fetch").mockResolvedValue(new Response(JSON.stringify({secrets:[{bindingName:"MODEL_KEY",providerPresent:true}]}),{status:200,headers:{"content-type":"application/json"}})) as any; + await workerSecretProviderStatus(options,"worker/1","production"); + expect(fetchSpy.mock.calls[0][0]).toBe("https://test.xapi.to/api/v1/workers/worker%2F1/environments/production/secrets/provider-status"); + }); it("reads scoped source windows with the original xAPI authentication", async () => { fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ storageCollection: { items: [] } }), { status: 200, headers: { "content-type": "application/json" } })) as any; await workerMeteredUsage(options, "worker/1", "preview"); diff --git a/src/tests/workers-secrets.test.ts b/src/tests/workers-secrets.test.ts new file mode 100644 index 0000000..0cbed47 --- /dev/null +++ b/src/tests/workers-secrets.test.ts @@ -0,0 +1,15 @@ +import {describe,expect,it} from 'bun:test'; +import {parseWorkerSecretEnv,WORKERS_HELP} from '../commands/workers.ts'; + +describe('Workers secret input',()=>{ + it('parses local env content in memory without expanding or printing values',()=>{ + expect(parseWorkerSecretEnv("MODEL_KEY='private-value'\n# ignored\nOTHER=literal-$MODEL_KEY\n")).toEqual({MODEL_KEY:'private-value',OTHER:'literal-$MODEL_KEY'}); + }); + + it('documents environment, stdin, batch apply and provider status workflows',()=>{ + expect(WORKERS_HELP).toContain('--from-env VARIABLE | --stdin | --env-file .env'); + expect(WORKERS_HELP).toContain('secrets apply'); + expect(WORKERS_HELP).toContain('secrets status'); + expect(WORKERS_HELP).not.toContain('--value VALUE'); + }); +}); diff --git a/src/workers-client.ts b/src/workers-client.ts index d61bd16..d2fc3b0 100644 --- a/src/workers-client.ts +++ b/src/workers-client.ts @@ -609,6 +609,36 @@ export function putWorkerSecret( ); } +export function applyWorkerSecrets( + options: WorkersClientOptions, + id: string, + environment: string, + secrets: Array<{ name: string; value?: string; delete?: boolean }>, +) { + return request( + url(options,`/${encodeURIComponent(id)}/environments/${encodeURIComponent(environment)}/secrets`), + { + method: "PATCH", + headers: headers(options,true), + body: JSON.stringify({secrets}), + }, + 60_000, + ); +} + +export function workerSecretProviderStatus( + options: WorkersClientOptions, + id: string, + environment: string, +) { + return request( + url(options,`/${encodeURIComponent(id)}/environments/${encodeURIComponent(environment)}/secrets/provider-status`), + {headers:headers(options)}, + 30_000, + 2, + ); +} + export function deleteWorkerSecret( options: WorkersClientOptions, id: string, From 74148a48351772a54457fca143d0eaa87f73db7b Mon Sep 17 00:00:00 2001 From: daxiongya Date: Mon, 21 Sep 2026 09:28:35 +0800 Subject: [PATCH 07/28] fix(workers): preserve native deployment intent during import --- ...ers-deployment-test-findings-2026-09-21.md | 67 +++++++ skills/xapi-workers/SKILL.md | 2 +- skills/xapi/guides/workers.md | 18 ++ src/commands/workers.ts | 20 ++ src/tests/workers-help.test.ts | 3 + src/tests/workers-wrangler-import.test.ts | 115 +++++++++++- src/workers-wrangler-import.ts | 174 +++++++++++++++++- 7 files changed, 385 insertions(+), 14 deletions(-) create mode 100644 docs/workers-deployment-test-findings-2026-09-21.md diff --git a/docs/workers-deployment-test-findings-2026-09-21.md b/docs/workers-deployment-test-findings-2026-09-21.md new file mode 100644 index 0000000..d8fcab7 --- /dev/null +++ b/docs/workers-deployment-test-findings-2026-09-21.md @@ -0,0 +1,67 @@ +# Workers open-source deployment findings — 2026-09-21 + +## Scope + +This record covers two real preview deployments: + +- a Next.js 16 application adapted with Vinext and packaged as Wrangler's + native multipart bundle; +- a Vite SPA with a Worker-first `/api/*` route and xAPI AI calls. + +Both applications were published through xAPI Workers. Wrangler was used only +for local framework packaging where needed. + +## Existing platform work that must not be duplicated + +xapi-backend PRs #248 and #250 already provide the backend contract used by +these deployments: + +- complete code-module and static-asset Artifacts; +- multipart upload with bounded memory and integrity verification; +- native Smart Placement metadata; +- D1/R2 default resource location; +- web-application completeness checks before provider or financial effects. + +#250 is the clean promotion replay of #248, not a second implementation. This +CLI branch must remain complementary to that backend work. + +## Confirmed CLI gaps addressed here + +1. Generated Wrangler metadata such as `configPath`, `userConfigPath`, and + `definedEnvironments` was reported as unsupported even though it is build + provenance, not Worker runtime state. +2. Wrangler `vars` were combined with Secret names. Public values must not be + copied, leaked into reports, or silently converted into Secrets. +3. Wrangler imports always generated `npm run build` and + `dist/worker.mjs`, even when a framework package already declared a native + `build:worker` command and `--outfile` bundle. +4. The repository may contain Workers commands before the currently published + npm package. Skills need to detect and report that release mismatch. + +## Deferred backend work + +The following should be implemented only after the native deployment candidate +has completed its normal dev → staging → main promotion: + +- first-class desired state and API mutations for Cloudflare `plain_text` + bindings; +- one correlation ID spanning Dispatcher/User Worker logs and downstream xAPI + AI usage records; +- structured runtime error classes that distinguish Worker, xAPI gateway, + provider, authentication, and response-schema failures. + +Until plain-text bindings exist, the importer fails closed on non-empty +Wrangler `vars`. `--accept-partial` records an explicit user decision but still +does not copy their values. + +## Items confirmed outside platform scope + +- model output that omitted application-specific JSON fields; +- selecting Gemini or DeepSeek instead of native Jev; +- drone hover/landing control behavior; +- browser-cookie quotas; +- absence of KV, D1, R2, Queue, Durable Object, or Workflow when the application + does not require them. + +These are application design or integration concerns and must not be fixed by +restricting the Workers platform. diff --git a/skills/xapi-workers/SKILL.md b/skills/xapi-workers/SKILL.md index 425676d..57f4523 100644 --- a/skills/xapi-workers/SKILL.md +++ b/skills/xapi-workers/SKILL.md @@ -5,7 +5,7 @@ description: Deploy, operate, and verify applications on xAPI-managed Cloudflare # xAPI Workers for Platforms -Use the `xapi` CLI (`xapi-to` is the same executable). Verify `xapi workers --help` before using it; an older installation may lack these commands. Do not silently replace managed deployment with Wrangler direct deployment. +Use the `xapi` CLI (`xapi-to` is the same executable). Verify `xapi workers --help` before using it; an older published installation may lack these commands even when the repository already contains them. Stop and report the version mismatch instead of silently replacing managed deployment with Wrangler direct deployment. Wrangler `deploy --dry-run --outfile` is allowed only as a local framework packaging step; the resulting Artifact must still be published with xAPI. ## Start with scope diff --git a/skills/xapi/guides/workers.md b/skills/xapi/guides/workers.md index 3ef9565..79c843d 100644 --- a/skills/xapi/guides/workers.md +++ b/skills/xapi/guides/workers.md @@ -118,6 +118,13 @@ xapi workers push --env preview write a partial project unless the user explicitly accepts the report with `--accept-partial`. +Wrangler `vars` are public plain-text bindings. The importer never copies their +values and never silently converts them into encrypted Secrets. A non-empty +`vars` block is reported as `UNSUPPORTED` until xAPI desired state has an +explicit plain-text binding workflow. Move only genuinely sensitive values to +`secrets`, set them with `workers secrets set`, and keep public values out of +the generated project until the binding is supported. + The project workflow does not require Git. Git repository, branch, and commit are optional provenance, not authentication and not a deployment prerequisite. It runs the configured build, creates the remote Worker when `workerId` is @@ -232,6 +239,17 @@ npm run build npx wrangler deploy --dry-run --config dist/server/wrangler.json --outfile dist/app.worker.bundle ``` +When `package.json` contains a framework `build:worker` script with Wrangler's +`--outfile`, `init --from-wrangler` infers both the command and `.bundle` path. +Review the generated `xapi.worker.json`. If the framework uses a custom script, +provide the values during import instead of editing an ambiguous default: + +```bash +xapi workers init --from-wrangler dist/server/wrangler.json \ + --build-command "pnpm run package:worker" \ + --build-output dist/app.worker.bundle +``` + Point the project build output to `dist/app.worker.bundle`; omit `build.main`. Set `assets.directory` to the framework's client output (for example `dist/client`). Then use `xapi workers plan --env preview` and diff --git a/src/commands/workers.ts b/src/commands/workers.ts index b5edd09..f1800dd 100644 --- a/src/commands/workers.ts +++ b/src/commands/workers.ts @@ -109,6 +109,9 @@ INIT FLAGS --template TEMPLATE worker|agent|chat|webhook|persistent-agent --from-wrangler PATH Import an existing wrangler.jsonc or wrangler.toml --accept-partial Write only after explicitly accepting unsupported fields + --build-command COMMAND Override the imported project build command + --build-output PATH Override the deployable bundle/module path + --build-main PATH Entrypoint inside a build-output directory --name NAME Worker display name --slug SLUG Stable lowercase Worker slug --preview-budget 0.10..100 Default: 0.25 @@ -255,6 +258,9 @@ FLAGS --framework auto|react|vite|vue|next --from-wrangler PATH --accept-partial + --build-command COMMAND + --build-output PATH + --build-main PATH --name NAME --slug SLUG --preview-budget USD @@ -527,6 +533,9 @@ export async function workersCommand( "template", "from-wrangler", "accept-partial", + "build-command", + "build-output", + "build-main", "name", "slug", "preview-budget", @@ -552,12 +561,20 @@ export async function workersCommand( if (flags.force && flags.force !== "true") { err("--force does not accept a value"); } + for (const flag of ["build-command", "build-output", "build-main"]) { + if (flags[flag] === "true" || flags[flag] === "") { + err(`--${flag} requires a value`); + } + } let result; try { result = importWranglerProject({ wranglerPath: flags["from-wrangler"], acceptPartial: flags["accept-partial"] === "true", force: flags.force === "true", + buildCommand: flags["build-command"], + buildOutput: flags["build-output"], + buildMain: flags["build-main"], previewDailyBudgetUsd: flags["preview-budget"] ? budget(flags["preview-budget"], "--preview-budget") : 0.25, @@ -583,6 +600,9 @@ export async function workersCommand( if (flags["accept-partial"]) { err("--accept-partial is only valid with --from-wrangler"); } + for (const flag of ["build-command", "build-output", "build-main"]) { + if (flags[flag]) err(`--${flag} is only valid with --from-wrangler`); + } if (flags.framework === "true" || flags.framework === "") { err("--framework requires auto, react, vite, vue, or next"); } diff --git a/src/tests/workers-help.test.ts b/src/tests/workers-help.test.ts index 872cb7a..5d8bcf0 100644 --- a/src/tests/workers-help.test.ts +++ b/src/tests/workers-help.test.ts @@ -10,6 +10,9 @@ describe("Workers focused help", () => { expect(WORKERS_INIT_HELP).toContain("Existing React, Vite, Vue"); expect(WORKERS_INIT_HELP).toContain("Existing Worker with Wrangler"); expect(WORKERS_INIT_HELP).toContain("Next.js SSR"); + expect(WORKERS_INIT_HELP).toContain("--build-command"); + expect(WORKERS_INIT_HELP).toContain("--build-output"); + expect(WORKERS_INIT_HELP).toContain("--build-main"); expect(WORKERS_INIT_HELP).toContain("Re-running init is not a"); }); diff --git a/src/tests/workers-wrangler-import.test.ts b/src/tests/workers-wrangler-import.test.ts index b79fbb1..77fef29 100644 --- a/src/tests/workers-wrangler-import.test.ts +++ b/src/tests/workers-wrangler-import.test.ts @@ -76,7 +76,8 @@ describe("Wrangler project import", () => { ); expect(blocked.report.entries).toContainEqual( expect.objectContaining({ - category: "REENTER", + category: "UNSUPPORTED", + path: "vars.MODEL_KEY", bindingName: "MODEL_KEY", }), ); @@ -97,7 +98,7 @@ describe("Wrangler project import", () => { (resource) => resource.bindingName, ), ).toEqual(["AGENT", "DB", "EVENTS", "FILES", "FLOW", "STATE"]); - expect(project.config.environments.preview.secrets).toEqual(["MODEL_KEY"]); + expect(project.config.environments.preview.secrets).toEqual([]); expect(project.config.assets).toEqual({ directory: "dist/client", binding: "ASSETS", @@ -133,9 +134,21 @@ binding = "DB" database_id = "old-d1-id" `; writeFileSync(path, original); + const blocked = importWranglerProject({ + cwd: root, + wranglerPath: "wrangler.toml", + }); + expect(blocked.wrote).toBe(false); + expect(blocked.report.entries).toContainEqual( + expect.objectContaining({ + category: "UNSUPPORTED", + path: "env.preview.vars.MODEL_KEY", + }), + ); const result = importWranglerProject({ cwd: root, wranglerPath: "wrangler.toml", + acceptPartial: true, }); expect(result.wrote).toBe(true); expect(result.report.format).toBe("toml"); @@ -144,7 +157,7 @@ database_id = "old-d1-id" expect(project.config.environments.preview.resources).toEqual([ { type: "kv_namespace", bindingName: "STATE" }, ]); - expect(project.config.environments.preview.secrets).toEqual(["MODEL_KEY"]); + expect(project.config.environments.preview.secrets).toEqual([]); expect( project.config.environments.production.resources.map( (resource) => resource.bindingName, @@ -200,7 +213,14 @@ database_id = "old-d1-id" const root = workspace(); writeFileSync( join(root, "package.json"), - JSON.stringify({ name: "framework-app", scripts: { build: "vinext build" } }), + JSON.stringify({ + name: "framework-app", + scripts: { + build: "next build", + "build:worker": + "vinext build && wrangler deploy --dry-run --config dist/server/wrangler.json --outfile dist/app.worker.bundle", + }, + }), ); const serverDir = join(root, "dist", "server"); const clientDir = join(root, "dist", "client"); @@ -210,7 +230,10 @@ database_id = "old-d1-id" writeFileSync( path, JSON.stringify({ + configPath: join(root, "wrangler.jsonc"), + userConfigPath: join(root, "wrangler.jsonc"), topLevelName: "framework-app", + definedEnvironments: [], name: "framework-app", main: "index.js", compatibility_date: "2026-09-10", @@ -239,6 +262,90 @@ database_id = "old-d1-id" const project = loadWorkerProject(root); expect(project.config.wrangler).toBe("dist/server/wrangler.json"); expect(project.config.assets).toEqual({ directory: "dist/client" }); + expect(project.config.build).toEqual({ + command: "npm run build:worker", + output: "dist/app.worker.bundle", + }); + expect(result.report.entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ category: "IGNORED", path: "configPath" }), + expect.objectContaining({ category: "IGNORED", path: "userConfigPath" }), + expect.objectContaining({ category: "SUPPORTED", path: "build" }), + ]), + ); expect(existsSync(join(serverDir, "xapi.worker.json"))).toBe(false); }); + + test("keeps declared secrets distinct from public Wrangler vars", () => { + const root = workspace(); + const path = join(root, "wrangler.jsonc"); + writeFileSync( + path, + JSON.stringify({ + name: "binding-types", + main: "dist/worker.mjs", + vars: { PUBLIC_MODE: "public-visible-value" }, + secrets: ["PRIVATE_TOKEN"], + }), + ); + + const blocked = importWranglerProject({ cwd: root, wranglerPath: path }); + expect(blocked.wrote).toBe(false); + expect(JSON.stringify(blocked.report)).not.toContain("public-visible-value"); + + importWranglerProject({ + cwd: root, + wranglerPath: path, + acceptPartial: true, + }); + const project = loadWorkerProject(root); + expect(project.config.environments.preview.secrets).toEqual([ + "PRIVATE_TOKEN", + ]); + expect(project.config.environments.preview.secrets).not.toContain( + "PUBLIC_MODE", + ); + }); + + test("accepts explicit build overrides for generated framework artifacts", () => { + const root = workspace(); + const path = join(root, "wrangler.jsonc"); + writeFileSync(path, JSON.stringify({ name: "custom-build", main: "src/index.ts" })); + const result = importWranglerProject({ + cwd: root, + wranglerPath: path, + buildCommand: "pnpm run package:worker", + buildOutput: ".worker/output", + buildMain: "index.js", + }); + expect(result.config?.build).toEqual({ + command: "pnpm run package:worker", + output: ".worker/output", + main: "index.js", + }); + }); + + test("keeps a full frontend build when it invokes the Worker sub-build", () => { + const root = workspace(); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ + scripts: { + build: "vite build && npm run build:worker", + "build:worker": + "esbuild worker/index.ts --bundle --outfile=dist-worker/worker.js", + }, + }), + ); + const path = join(root, "wrangler.jsonc"); + writeFileSync( + path, + JSON.stringify({ name: "full-spa", main: "dist-worker/worker.js" }), + ); + const result = importWranglerProject({ cwd: root, wranglerPath: path }); + expect(result.config?.build).toEqual({ + command: "npm run build", + output: "dist-worker/worker.js", + }); + }); }); diff --git a/src/workers-wrangler-import.ts b/src/workers-wrangler-import.ts index 4d79a23..96dad0a 100644 --- a/src/workers-wrangler-import.ts +++ b/src/workers-wrangler-import.ts @@ -53,6 +53,9 @@ export interface ImportWranglerProjectOptions { wranglerPath: string; acceptPartial?: boolean; force?: boolean; + buildCommand?: string; + buildOutput?: string; + buildMain?: string; previewDailyBudgetUsd?: number; productionDailyBudgetUsd?: number; } @@ -98,7 +101,8 @@ const MANAGED_TOP_LEVEL = new Set([ "queues", "workflows", ]); -const REENTER_TOP_LEVEL = new Set(["vars", "secrets", "secrets_store_secrets"]); +const REENTER_TOP_LEVEL = new Set(["secrets", "secrets_store_secrets"]); +const PUBLIC_VARIABLE_TOP_LEVEL = new Set(["vars"]); const IGNORED_TOP_LEVEL = new Set([ "$schema", "account_id", @@ -130,6 +134,9 @@ const IGNORED_TOP_LEVEL = new Set([ // have already been applied to the emitted Worker bundle. They are not // control-plane settings and do not need an xAPI desired-state mapping. "topLevelName", + "configPath", + "userConfigPath", + "definedEnvironments", "jsx_factory", "jsx_fragment", "python_modules", @@ -490,6 +497,24 @@ function resourceList( }); } +function publicVariables( + config: UnknownRecord, + prefix: string, + environment: "preview" | "production", + entries: WranglerCompatibilityEntry[], +): void { + const vars = record(config.vars); + for (const name of Object.keys(vars || {}).sort()) { + compatibilityEntry( + entries, + "UNSUPPORTED", + `${prefix}vars.${name}`, + "Plain-text variables are not copied or converted into Secrets. Remove this var from Wrangler and declare a Secret explicitly only when the value is sensitive", + { environment, bindingName: name }, + ); + } +} + function secretNames( config: UnknownRecord, prefix: string, @@ -497,8 +522,6 @@ function secretNames( entries: WranglerCompatibilityEntry[], ): string[] { const candidates = new Set(); - const vars = record(config.vars); - for (const name of Object.keys(vars || {})) candidates.add(name); if (Array.isArray(config.secrets)) { for (const value of config.secrets) { if (typeof value === "string") candidates.add(value); @@ -558,7 +581,10 @@ function inspectTopLevel( ); } else if (MANAGED_TOP_LEVEL.has(key)) { // Individual binding entries carry the actionable report. - } else if (REENTER_TOP_LEVEL.has(key)) { + } else if ( + REENTER_TOP_LEVEL.has(key) || + PUBLIC_VARIABLE_TOP_LEVEL.has(key) + ) { // Secret/variable names are reported per environment without values. } else if (IGNORED_TOP_LEVEL.has(key)) { compatibilityEntry( @@ -601,7 +627,11 @@ function inspectTopLevel( "Retained through the referenced Wrangler environment configuration", { environment: name }, ); - } else if (MANAGED_TOP_LEVEL.has(key) || REENTER_TOP_LEVEL.has(key)) { + } else if ( + MANAGED_TOP_LEVEL.has(key) || + REENTER_TOP_LEVEL.has(key) || + PUBLIC_VARIABLE_TOP_LEVEL.has(key) + ) { // Actionable binding and secret entries are reported separately. } else if (IGNORED_TOP_LEVEL.has(key) || key === "name") { compatibilityEntry( @@ -676,6 +706,112 @@ function summary( return result; } +function packageManager(rootDir: string): string { + if (existsSync(resolve(rootDir, "pnpm-lock.yaml"))) return "pnpm"; + if ( + existsSync(resolve(rootDir, "bun.lock")) || + existsSync(resolve(rootDir, "bun.lockb")) + ) + return "bun"; + if (existsSync(resolve(rootDir, "yarn.lock"))) return "yarn"; + return "npm"; +} + +function packageScripts(rootDir: string): Record { + const path = resolve(rootDir, "package.json"); + if (!existsSync(path)) return {}; + try { + const packageJson = record(JSON.parse(readFileSync(path, "utf8"))); + const scripts = record(packageJson?.scripts); + return Object.fromEntries( + Object.entries(scripts || {}).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + } catch { + return {}; + } +} + +function workerBuildScript( + scripts: Record, +): string | undefined { + if (scripts["xapi:build"]) return "xapi:build"; + if ( + scripts.build && + /(?:^|\s)(?:npm|pnpm|yarn|bun)\s+run\s+build:worker(?:\s|$)/.test( + scripts.build, + ) + ) { + return "build"; + } + if ( + scripts["build:worker"] && + /(?:--outfile|\bvinext\b|\bwrangler\b)/.test(scripts["build:worker"]) + ) { + return "build:worker"; + } + return scripts.build ? "build" : undefined; +} + +function outfileFromScript(script: string | undefined): string | undefined { + if (!script) return undefined; + const match = script.match( + /(?:^|\s)--outfile(?:=|\s+)(?:"([^"]+)"|'([^']+)'|([^\s]+))/, + ); + return match?.[1] || match?.[2] || match?.[3]; +} + +function portableProjectPath( + rootDir: string, + baseDir: string, + value: string | undefined, +): string | undefined { + if (!value || value.includes("\0")) return undefined; + const path = relative(rootDir, resolve(baseDir, value)).split(sep).join("/"); + if (!path || path === ".." || path.startsWith("../")) return undefined; + return path; +} + +function inferredBuild( + options: ImportWranglerProjectOptions, + rootDir: string, + sourceDir: string, + wrangler: UnknownRecord, +): { command: string; output: string; main?: string; inferred: boolean } { + const scripts = packageScripts(rootDir); + const manager = packageManager(rootDir); + const workerScript = workerBuildScript(scripts); + const inferredCommand = workerScript + ? `${manager} run ${workerScript}` + : undefined; + const scriptOutput = portableProjectPath( + rootDir, + rootDir, + outfileFromScript(workerScript ? scripts[workerScript] : undefined), + ); + const wranglerMain = + typeof wrangler.main === "string" ? wrangler.main.trim() : undefined; + const mainOutput = + wranglerMain && + /\.(?:m?js)$/.test(wranglerMain) && + (sourceDir === rootDir || wranglerMain.includes("/")) + ? portableProjectPath(rootDir, sourceDir, wranglerMain) + : undefined; + const output = options.buildOutput || scriptOutput || mainOutput; + return { + command: options.buildCommand || inferredCommand || "npm run build", + output: output || "dist/worker.mjs", + ...(options.buildMain ? { main: options.buildMain } : {}), + inferred: Boolean( + options.buildCommand || + options.buildOutput || + options.buildMain || + (inferredCommand && output), + ), + }; +} + export function importWranglerProject( options: ImportWranglerProjectOptions, ): ImportWranglerProjectResult { @@ -720,6 +856,18 @@ export function importWranglerProject( "production", entries, ); + publicVariables( + desired.preview.config, + desired.preview.prefix, + "preview", + entries, + ); + publicVariables( + desired.production.config, + desired.production.prefix, + "production", + entries, + ); const previewSecrets = secretNames( desired.preview.config, desired.preview.prefix, @@ -732,11 +880,14 @@ export function importWranglerProject( "production", entries, ); + const build = inferredBuild(options, rootDir, sourceDir, wrangler); compatibilityEntry( entries, - "REENTER", - "build.output", - "Verify the generated bundle path; Wrangler source main is not necessarily the build output", + build.inferred ? "SUPPORTED" : "REENTER", + "build", + build.inferred + ? `Build inferred as ${build.command} → ${build.output}${build.main ? ` (main: ${build.main})` : ""}` + : "Verify build.command and build.output; Wrangler source main is not necessarily the deployable build output", ); const sortedEntries = stableEntries(entries); @@ -787,7 +938,11 @@ export function importWranglerProject( template: "worker" as const, }, wrangler: wranglerPath, - build: { command: "npm run build", output: "dist/worker.mjs" }, + build: { + command: build.command, + output: build.output, + ...(build.main ? { main: build.main } : {}), + }, ...(assets ? { assets } : {}), environments: { preview: { @@ -825,6 +980,7 @@ export function importWranglerProject( nextSteps: [ `Review ${WORKER_PROJECT_CONFIG_FILE}`, "Set every REENTER secret with xapi workers secrets set", + "Resolve every reported Wrangler var as a public binding or an explicit Secret; xAPI never converts it automatically", "xapi workers plan --env preview", ], }; From 555dcb4ff88d1633bf23d6709a56abb30f187104 Mon Sep 17 00:00:00 2001 From: daxiongya Date: Mon, 21 Sep 2026 09:35:54 +0800 Subject: [PATCH 08/28] fix(workers): clarify project deployment commands --- ...ers-deployment-test-findings-2026-09-21.md | 3 +++ skills/xapi-workers/references/deployment.md | 17 +++++++++++++ src/commands/workers.ts | 25 ++++++++++++++----- src/tests/workers-help.test.ts | 10 ++++++++ 4 files changed, 49 insertions(+), 6 deletions(-) diff --git a/docs/workers-deployment-test-findings-2026-09-21.md b/docs/workers-deployment-test-findings-2026-09-21.md index d8fcab7..d099f6b 100644 --- a/docs/workers-deployment-test-findings-2026-09-21.md +++ b/docs/workers-deployment-test-findings-2026-09-21.md @@ -37,6 +37,9 @@ CLI branch must remain complementary to that backend work. `build:worker` command and `--outfile` bundle. 4. The repository may contain Workers commands before the currently published npm package. Skills need to detect and report that release mismatch. +5. Project commands and low-level Artifact primitives appeared in one flat help + list. Help now makes `plan → push → promote` the normal path and labels + `build`, `upload`, and `deploy` as custom-CI or recovery operations. ## Deferred backend work diff --git a/skills/xapi-workers/references/deployment.md b/skills/xapi-workers/references/deployment.md index 91ddc65..6efb156 100644 --- a/skills/xapi-workers/references/deployment.md +++ b/skills/xapi-workers/references/deployment.md @@ -2,6 +2,23 @@ ## Project workflow +Use one command layer for one task. For normal application deployment, stay in +the project workflow: + +| Intent | Command | Writes live state | +| --- | --- | --- | +| Compare local desired state with xAPI | `workers plan --env ENV` | No | +| Build, reconcile and deploy preview | `workers push --env preview` | Yes | +| Release the accepted preview Artifact | `workers promote --to production` | Yes | +| Restore an earlier active version | `workers rollback --env ENV ...` | Yes | + +There is no `workers inspect` command. Use `workers get`, `workers plan`, +`workers resources list`, and `workers logs` for read-only inspection. +`workers build`, `upload`, and `deploy` are lower-level Artifact primitives for +custom CI and recovery. A managed `build` only produces an Artifact; `deploy` +only activates an existing Artifact. Neither replaces project convergence by +`push`. + ```sh export XAPI_API_HOST=api.test.xapi.to xapi workers templates diff --git a/src/commands/workers.ts b/src/commands/workers.ts index f1800dd..413db9f 100644 --- a/src/commands/workers.ts +++ b/src/commands/workers.ts @@ -47,27 +47,33 @@ export const WORKERS_HELP = `xapi-to workers - Deploy and manage xAPI-hosted Clo USAGE xapi-to workers [args] [flags] -COMMANDS +NORMAL PROJECT WORKFLOW (recommended) templates init [directory] --template TEMPLATE plan --env preview|production push --env preview promote --to production [--artifact ARTIFACT_ID] rollback --env preview|production (--to previous | --deployment DEPLOYMENT_ID) + +INSPECTION AND OPERATIONS list get + audit + invocations --env preview|production + logs --env preview|production [--tail] [--since 10m] + usage [--env preview|production] + metering --env preview|production [--json] + +ADVANCED ARTIFACT PRIMITIVES (custom CI and recovery only) create --name NAME --slug SLUG --preview-budget USD --production-budget USD upload --file dist/index.mjs|dist/ [--main worker.js] artifacts build --project . --entrypoint src/index.ts --command "npm run build" builds deploy --artifact ARTIFACT_ID --env preview|production + +RESOURCES, BILLING, AND LIFECYCLE budget --daily-usd USD - audit - invocations --env preview|production - logs --env preview|production [--tail] [--since 10m] - usage [--env preview|production] - metering --env preview|production [--json] billing-status billing ledger --env ENV [--all] [--snapshot-time ISO] [--json] retention show|quote|accept|pause|resume|keep-paused|delete --env ENV @@ -99,6 +105,13 @@ COMMANDS build-provider-status delete --yes +CHOOSING A WORKFLOW + Normal application: init -> plan -> push -> promote + Read-only review: get + plan + resources list + logs + workers build creates an Artifact in a managed Sandbox; it does not deploy. + workers deploy activates an existing Artifact; it does not build or converge project state. + There is no workers inspect command; use the read-only commands above. + CREATE FLAGS --template worker|agent Official starter type (default: worker) --description TEXT diff --git a/src/tests/workers-help.test.ts b/src/tests/workers-help.test.ts index 5d8bcf0..c1cd17e 100644 --- a/src/tests/workers-help.test.ts +++ b/src/tests/workers-help.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { + WORKERS_HELP, WORKERS_INIT_HELP, WORKERS_RESOURCES_HELP, } from "../commands/workers.ts"; @@ -27,4 +28,13 @@ describe("Workers focused help", () => { expect(WORKERS_RESOURCES_HELP).toContain("without updating the"); expect(WORKERS_RESOURCES_HELP).toContain("cannot be updated in place"); }); + + test("top-level help separates the normal project flow from primitives", () => { + expect(WORKERS_HELP).toContain("NORMAL PROJECT WORKFLOW (recommended)"); + expect(WORKERS_HELP).toContain("ADVANCED ARTIFACT PRIMITIVES"); + expect(WORKERS_HELP).toContain("init -> plan -> push -> promote"); + expect(WORKERS_HELP).toContain("build creates an Artifact"); + expect(WORKERS_HELP).toContain("deploy activates an existing Artifact"); + expect(WORKERS_HELP).toContain("There is no workers inspect command"); + }); }); From f5e7245e7bc8b01f62a1a9c2669ea1dbf9c48c9d Mon Sep 17 00:00:00 2001 From: daxiongya Date: Mon, 21 Sep 2026 09:44:48 +0800 Subject: [PATCH 09/28] feat(workers): add read-only environment inspection --- ...ers-deployment-test-findings-2026-09-21.md | 3 + skills/xapi-workers/SKILL.md | 2 +- skills/xapi-workers/references/deployment.md | 10 +- skills/xapi/guides/workers.md | 7 + src/commands/workers.ts | 66 +++- src/tests/workers-help.test.ts | 3 +- src/tests/workers-inspect.test.ts | 110 +++++++ src/workers-inspect-output.ts | 67 ++++ src/workers-inspect.ts | 296 ++++++++++++++++++ 9 files changed, 558 insertions(+), 6 deletions(-) create mode 100644 src/tests/workers-inspect.test.ts create mode 100644 src/workers-inspect-output.ts create mode 100644 src/workers-inspect.ts diff --git a/docs/workers-deployment-test-findings-2026-09-21.md b/docs/workers-deployment-test-findings-2026-09-21.md index d099f6b..4825b04 100644 --- a/docs/workers-deployment-test-findings-2026-09-21.md +++ b/docs/workers-deployment-test-findings-2026-09-21.md @@ -40,6 +40,9 @@ CLI branch must remain complementary to that backend work. 5. Project commands and low-level Artifact primitives appeared in one flat help list. Help now makes `plan → push → promote` the normal path and labels `build`, `upload`, and `deploy` as custom-CI or recovery operations. +6. Runtime inspection required several separate commands. `workers inspect` + now provides one read-only report while preserving failed sources as + `UNKNOWN` and excluding Secret values. ## Deferred backend work diff --git a/skills/xapi-workers/SKILL.md b/skills/xapi-workers/SKILL.md index 57f4523..c2c282b 100644 --- a/skills/xapi-workers/SKILL.md +++ b/skills/xapi-workers/SKILL.md @@ -12,7 +12,7 @@ Use the `xapi` CLI (`xapi-to` is the same executable). Verify `xapi workers --he - Identify the control-plane host, Worker ID, and **preview or production** from the project and `workers get`. Test control plane and preview environment are separate choices. - Authentication precedence: `XAPI_KEY`, `XAPI_API_KEY`, then `~/.xapi/config.json`. Keys need `workers:read` and, for changes, `workers:write`, plus access to the target Worker. A scoped-out Worker can return 404. - Production API host is `api.xapi.to`; testing uses `XAPI_API_HOST=api.test.xapi.to` (host only). Load secrets from the user's existing secure environment. Never print keys, include them in code/artifacts, or send the xAPI key to a public Worker URL or Cloudflare. Runtime application authentication is separate. -- Start with `workers get `, `workers capabilities`, and `workers resources list --env `. Read-only inspection needs no extra approval. Use existing user authorization for changes; don't expand cleanup from a test environment to production. +- Start with `workers inspect [worker-id] --env ` and `workers capabilities`. Use `workers plan --env ` when a local project is available and desired-state drift matters. Read-only inspection needs no extra approval. Use existing user authorization for changes; don't expand cleanup from a test environment to production. ## Load the relevant workflow diff --git a/skills/xapi-workers/references/deployment.md b/skills/xapi-workers/references/deployment.md index 6efb156..f6ccf1b 100644 --- a/skills/xapi-workers/references/deployment.md +++ b/skills/xapi-workers/references/deployment.md @@ -7,13 +7,19 @@ the project workflow: | Intent | Command | Writes live state | | --- | --- | --- | +| Inspect one running environment | `workers inspect [worker-id] --env ENV` | No | | Compare local desired state with xAPI | `workers plan --env ENV` | No | | Build, reconcile and deploy preview | `workers push --env preview` | Yes | | Release the accepted preview Artifact | `workers promote --to production` | Yes | | Restore an earlier active version | `workers rollback --env ENV ...` | Yes | -There is no `workers inspect` command. Use `workers get`, `workers plan`, -`workers resources list`, and `workers logs` for read-only inspection. +`workers inspect` accepts an explicit Worker ID or resolves it from the current +`xapi.worker.json`. It combines Worker, environment, active Artifact and +Deployment, routing, resource, Secret metadata, domain, and billing freshness +reads into one report. Optional read failures stay `UNKNOWN`, never zero or +success. It never reads Secret values and performs no health request that might +trigger application behavior. Use `workers plan` separately when comparing +local desired state with xAPI. `workers build`, `upload`, and `deploy` are lower-level Artifact primitives for custom CI and recovery. A managed `build` only produces an Artifact; `deploy` only activates an existing Artifact. Neither replaces project convergence by diff --git a/skills/xapi/guides/workers.md b/skills/xapi/guides/workers.md index 79c843d..e758289 100644 --- a/skills/xapi/guides/workers.md +++ b/skills/xapi/guides/workers.md @@ -34,6 +34,13 @@ source of truth for the entrypoint, compatibility settings, and static assets. Managed KV, D1, R2, Durable Object, Queue, and Workflow declarations belong in `xapi.worker.json`. The file contains no credential and may be committed. +Use `xapi workers inspect --env preview` for one read-only operational view of +the linked Worker. It reports the active environment, routing, Artifact, +Deployment, resource and Secret metadata, domains, and billing freshness. +Unavailable sources remain `UNKNOWN`. Use `plan` for desired-state comparison; +`inspect` never builds, deploys, probes application routes, or reads Secret +values. + Choose the `init` form from the project you actually have: | Starting point | Command | What `init` does | diff --git a/src/commands/workers.ts b/src/commands/workers.ts index 413db9f..d876877 100644 --- a/src/commands/workers.ts +++ b/src/commands/workers.ts @@ -41,6 +41,12 @@ import { workerBillingOutputMode, } from "../workers-billing-output.ts"; import { bindXdomainWorker } from "../workers-domain-bind.ts"; +import { inspectWorker } from "../workers-inspect.ts"; +import { + formatWorkerInspection, + useHumanWorkerInspectionOutput, +} from "../workers-inspect-output.ts"; +import { loadWorkerProject } from "../workers-project.ts"; export const WORKERS_HELP = `xapi-to workers - Deploy and manage xAPI-hosted Cloudflare Workers @@ -58,6 +64,7 @@ NORMAL PROJECT WORKFLOW (recommended) INSPECTION AND OPERATIONS list get + inspect [worker-id] --env preview|production audit invocations --env preview|production logs --env preview|production [--tail] [--since 10m] @@ -107,10 +114,14 @@ RESOURCES, BILLING, AND LIFECYCLE CHOOSING A WORKFLOW Normal application: init -> plan -> push -> promote - Read-only review: get + plan + resources list + logs + Read-only review: inspect; use plan when comparing local desired state workers build creates an Artifact in a managed Sandbox; it does not deploy. workers deploy activates an existing Artifact; it does not build or converge project state. - There is no workers inspect command; use the read-only commands above. + +INSPECT FLAGS + --env preview|production Environment to inspect (required) + --config PATH Locate workerId from xapi.worker.json + --format json Emit the complete machine-readable report CREATE FLAGS --template worker|agent Official starter type (default: worker) @@ -852,6 +863,57 @@ export async function workersCommand( ), ); return; + case "inspect": { + assertFlags(flags, ["env", "config"]); + if (rest.length > 1) { + err("usage: xapi-to workers inspect [worker-id] --env ENV"); + } + if (flags.config === "true" || flags.config === "") { + err("--config requires a path"); + } + const selectedEnvironment = environment(flags.env) as + | "preview" + | "production"; + let workerId: string | undefined = rest[0]; + if (!workerId) { + try { + const project = loadWorkerProject(process.cwd(), flags.config); + workerId = project.config.workerId; + } catch (error) { + err( + error instanceof Error + ? error.message + : "Unable to load Worker project", + ); + } + if (!workerId) { + err( + "Worker project is not linked yet; pass a Worker ID or run workers push first", + ); + } + } + try { + const report = await inspectWorker({ + workerId, + environment: selectedEnvironment, + clientOptions: options(), + }); + if ( + useHumanWorkerInspectionOutput({ + flagFormat: flags.format, + envFormat: process.env.XAPI_OUTPUT, + stdoutIsTTY: process.stdout.isTTY, + }) + ) { + console.log(formatWorkerInspection(report)); + } else { + output(report, flags.format as OutputFormat | undefined); + } + } catch (error) { + err(error instanceof Error ? error.message : "Worker inspection failed"); + } + return; + } case "create": { assertFlags(flags, [ "name", diff --git a/src/tests/workers-help.test.ts b/src/tests/workers-help.test.ts index c1cd17e..7aab88b 100644 --- a/src/tests/workers-help.test.ts +++ b/src/tests/workers-help.test.ts @@ -33,8 +33,9 @@ describe("Workers focused help", () => { expect(WORKERS_HELP).toContain("NORMAL PROJECT WORKFLOW (recommended)"); expect(WORKERS_HELP).toContain("ADVANCED ARTIFACT PRIMITIVES"); expect(WORKERS_HELP).toContain("init -> plan -> push -> promote"); + expect(WORKERS_HELP).toContain("inspect [worker-id]"); expect(WORKERS_HELP).toContain("build creates an Artifact"); expect(WORKERS_HELP).toContain("deploy activates an existing Artifact"); - expect(WORKERS_HELP).toContain("There is no workers inspect command"); + expect(WORKERS_HELP).toContain("Read-only review: inspect"); }); }); diff --git a/src/tests/workers-inspect.test.ts b/src/tests/workers-inspect.test.ts new file mode 100644 index 0000000..84766fe --- /dev/null +++ b/src/tests/workers-inspect.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "bun:test"; +import { + type WorkerInspectClient, + inspectWorker, +} from "../workers-inspect.ts"; +import { formatWorkerInspection } from "../workers-inspect-output.ts"; + +const clientOptions = { apiHost: "api.xapi.to", apiKey: "hidden-key" }; + +function client( + overrides: Partial = {}, +): WorkerInspectClient { + return { + getWorker: async () => ({ + id: "worker-1", + name: "Jev Autopilot", + slug: "jev-autopilot", + status: "ACTIVE", + environments: [ + { + id: "environment-preview", + name: "PREVIEW", + status: "ACTIVE", + publicUrl: "https://jev-preview.xapi.men", + routingMode: "CUSTOM_DOMAIN", + webAppReady: true, + activeDeploymentId: "deployment-1", + }, + ], + deployments: [ + { + id: "deployment-1", + environmentId: "environment-preview", + artifactId: "artifact-1", + status: "ACTIVE", + }, + ], + artifacts: [ + { id: "artifact-1", contentSha256: "abc", sizeBytes: 42 }, + ], + }), + listWorkerResources: async () => [ + { id: "resource-1", type: "R2_BUCKET", bindingName: "FILES", status: "ACTIVE" }, + ], + listWorkerSecrets: async () => [ + { bindingName: "XAPI_KEY", version: 2, secretValue: "must-not-leak" }, + ], + listWorkerDomains: async () => [ + { id: "domain-1", environmentId: "environment-preview", hostname: "jev-preview.xapi.men", status: "ACTIVE" }, + { id: "domain-2", environmentId: "environment-production", hostname: "jev.xapi.men", status: "ACTIVE" }, + ], + workerBillingQuery: async () => ({ + snapshotId: "snapshot-1", + snapshotTime: "2026-09-21T01:00:00.000Z", + completeThrough: "2026-09-21T00:55:00.000Z", + dataQuality: "COMPLETE", + data: { lifecycleState: "ACTIVE", budgetRemainingUsd: "9.50" }, + }), + ...overrides, + }; +} + +describe("workers inspect", () => { + test("aggregates a read-only environment report without secret values", async () => { + const report = await inspectWorker({ + workerId: "worker-1", + environment: "preview", + clientOptions, + client: client(), + }); + + expect(report.mode).toBe("READ_ONLY"); + expect(report.environment.publicUrl).toBe("https://jev-preview.xapi.men"); + expect(report.deployment?.id).toBe("deployment-1"); + expect(report.artifact?.id).toBe("artifact-1"); + expect(report.resources.items).toHaveLength(1); + expect(report.secrets.items).toEqual([ + { bindingName: "XAPI_KEY", version: 2 }, + ]); + expect(report.domains.items).toHaveLength(1); + expect(report.billing.summary?.dataQuality).toBe("COMPLETE"); + expect(JSON.stringify(report)).not.toContain("must-not-leak"); + expect(JSON.stringify(report)).not.toContain("hidden-key"); + expect(formatWorkerInspection(report)).toContain("READ ONLY"); + }); + + test("keeps optional read failures unknown instead of claiming zero", async () => { + const unavailable = async () => { + throw new Error("provider response that should not be surfaced"); + }; + const report = await inspectWorker({ + workerId: "worker-1", + environment: "preview", + clientOptions, + client: client({ + listWorkerResources: unavailable, + listWorkerSecrets: unavailable, + listWorkerDomains: unavailable, + workerBillingQuery: unavailable, + }), + }); + + expect(report.resources).toEqual({ status: "UNKNOWN", items: [] }); + expect(report.secrets).toEqual({ status: "UNKNOWN", items: [] }); + expect(report.domains).toEqual({ status: "UNKNOWN", items: [] }); + expect(report.billing).toEqual({ status: "UNKNOWN" }); + expect(report.diagnostics.filter((item) => item.status === "UNKNOWN")).toHaveLength(4); + expect(JSON.stringify(report)).not.toContain("provider response"); + }); +}); diff --git a/src/workers-inspect-output.ts b/src/workers-inspect-output.ts new file mode 100644 index 0000000..73d0381 --- /dev/null +++ b/src/workers-inspect-output.ts @@ -0,0 +1,67 @@ +import type { WorkerInspection } from "./workers-inspect.ts"; + +const RULE = "─".repeat(72); + +function value(value: unknown): string { + if (value === undefined || value === null || value === "") return "—"; + return Array.isArray(value) ? value.join(", ") || "—" : String(value); +} + +function row(label: string, item: unknown): string { + return ` ${label.padEnd(22)} ${value(item)}`; +} + +export function formatWorkerInspection(report: WorkerInspection): string { + const lines = [ + "xAPI Worker Inspection · READ ONLY", + RULE, + row("Control plane", report.controlPlane), + row("Worker", `${value(report.worker.name)} (${value(report.worker.id)})`), + row("Worker status", report.worker.status), + row("Environment", report.environment.name), + row("Environment status", report.environment.status), + row("Public URL", report.environment.publicUrl), + row("Routing mode", report.environment.routingMode), + row("Web app ready", report.environment.webAppReady), + row("Active deployment", report.deployment?.id), + row("Artifact", report.artifact?.id), + row( + "Resources", + report.resources.status === "AVAILABLE" + ? report.resources.items.length + : "unknown", + ), + row( + "Secrets configured", + report.secrets.status === "AVAILABLE" + ? report.secrets.items.length + : "unknown", + ), + row( + "Domains", + report.domains.status === "AVAILABLE" ? report.domains.items.length : "unknown", + ), + row("Billing quality", report.billing.summary?.dataQuality), + row("Billing through", report.billing.summary?.completeThrough), + "", + "Diagnostics", + ...report.diagnostics.map( + (item) => ` ${item.status.padEnd(7)} ${item.check.padEnd(20)} ${item.message}`, + ), + "", + "Next steps", + ...report.nextSteps.map((command) => ` ${command}`), + RULE, + ]; + return lines.join("\n"); +} + +export function useHumanWorkerInspectionOutput(options: { + flagFormat?: string; + envFormat?: string; + stdoutIsTTY?: boolean; +}): boolean { + const explicit = options.flagFormat || options.envFormat; + return explicit === "table" || explicit === "pretty" || (!explicit && options.stdoutIsTTY === true); +} + diff --git a/src/workers-inspect.ts b/src/workers-inspect.ts new file mode 100644 index 0000000..95bddd1 --- /dev/null +++ b/src/workers-inspect.ts @@ -0,0 +1,296 @@ +import type { WorkersClientOptions } from "./workers-client.ts"; +import * as workersClient from "./workers-client.ts"; + +type UnknownRecord = Record; + +export interface WorkerInspectClient { + getWorker(options: WorkersClientOptions, id: string): Promise; + listWorkerResources( + options: WorkersClientOptions, + id: string, + environment: string, + ): Promise; + listWorkerSecrets( + options: WorkersClientOptions, + id: string, + environment: string, + ): Promise; + listWorkerDomains(options: WorkersClientOptions, id: string): Promise; + workerBillingQuery( + options: WorkersClientOptions, + id: string, + environment: string, + kind: "overview", + ): Promise; +} + +export interface WorkerInspection { + schemaVersion: 1; + mode: "READ_ONLY"; + controlPlane: string; + worker: UnknownRecord; + environment: UnknownRecord; + deployment?: UnknownRecord; + artifact?: UnknownRecord; + resources: { status: "AVAILABLE" | "UNKNOWN"; items: UnknownRecord[] }; + secrets: { status: "AVAILABLE" | "UNKNOWN"; items: UnknownRecord[] }; + domains: { status: "AVAILABLE" | "UNKNOWN"; items: UnknownRecord[] }; + billing: { status: "AVAILABLE" | "UNKNOWN"; summary?: UnknownRecord }; + diagnostics: Array<{ + status: "PASS" | "WARN" | "UNKNOWN"; + check: string; + message: string; + }>; + nextSteps: string[]; +} + +function record(value: unknown): UnknownRecord | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as UnknownRecord) + : undefined; +} + +function items(value: unknown): UnknownRecord[] { + const source = Array.isArray(value) + ? value + : Array.isArray(record(value)?.items) + ? (record(value)?.items as unknown[]) + : []; + return source.map(record).filter((item): item is UnknownRecord => !!item); +} + +function text(value: unknown): string | undefined { + return typeof value === "string" && value ? value : undefined; +} + +function selectedFields( + source: UnknownRecord | undefined, + names: string[], +): UnknownRecord { + const result: UnknownRecord = {}; + for (const name of names) { + if (source?.[name] !== undefined) result[name] = source[name]; + } + return result; +} + +function settledItems( + result: PromiseSettledResult, + fields: string[], +): { status: "AVAILABLE" | "UNKNOWN"; items: UnknownRecord[] } { + if (result.status === "rejected") return { status: "UNKNOWN", items: [] }; + return { + status: "AVAILABLE", + items: items(result.value).map((item) => selectedFields(item, fields)), + }; +} + +export async function inspectWorker(options: { + workerId: string; + environment: "preview" | "production"; + clientOptions: WorkersClientOptions; + client?: WorkerInspectClient; +}): Promise { + const api = options.client || workersClient; + const workerValue = await api.getWorker(options.clientOptions, options.workerId); + const worker = record(workerValue); + if (!worker || text(worker.id) !== options.workerId) { + throw new Error("xAPI returned an invalid Worker inspection response"); + } + + const environment = items(worker.environments).find( + (item) => text(item.name)?.toLowerCase() === options.environment, + ); + if (!environment) { + throw new Error(`Worker is missing its ${options.environment} environment`); + } + + const [resourceResult, secretResult, domainResult, billingResult] = + await Promise.allSettled([ + api.listWorkerResources( + options.clientOptions, + options.workerId, + options.environment, + ), + api.listWorkerSecrets( + options.clientOptions, + options.workerId, + options.environment, + ), + api.listWorkerDomains(options.clientOptions, options.workerId), + api.workerBillingQuery( + options.clientOptions, + options.workerId, + options.environment, + "overview", + ), + ]); + + const resources = settledItems(resourceResult, [ + "id", + "type", + "bindingName", + "status", + "requestedLocation", + "effectiveLocation", + "readReplication", + ]); + const secrets = settledItems(secretResult, [ + "bindingName", + "version", + "status", + "updatedAt", + ]); + const environmentId = text(environment.id); + const domains = settledItems(domainResult, [ + "id", + "environment", + "environmentId", + "hostname", + "status", + "url", + "errorCode", + ]); + domains.items = domains.items.filter( + (item) => + (text(item.environmentId) + ? text(item.environmentId) === environmentId + : !text(item.environment) || + text(item.environment)?.toLowerCase() === options.environment), + ); + + const activeDeploymentId = text(environment.activeDeploymentId); + const deployments = items(worker.deployments); + const deployment = + deployments.find((item) => text(item.id) === activeDeploymentId) || + deployments.find( + (item) => + text(item.environmentId) === environmentId && + text(item.status)?.toUpperCase() === "ACTIVE", + ); + const artifact = items(worker.artifacts).find( + (item) => text(item.id) === text(deployment?.artifactId), + ); + const environmentStatus = + text(environment.status) || + (text(deployment?.status)?.toUpperCase() === "ACTIVE" ? "ACTIVE" : undefined); + + const billingEnvelope = + billingResult.status === "fulfilled" ? record(billingResult.value) : undefined; + const billingData = record(billingEnvelope?.data); + const billing = billingEnvelope + ? { + status: "AVAILABLE" as const, + summary: { + ...selectedFields(billingEnvelope, [ + "snapshotId", + "snapshotTime", + "completeThrough", + "dataQuality", + ]), + ...selectedFields(billingData, [ + "lifecycleState", + "dailyBudgetUsd", + "budgetRemainingUsd", + "settledUsd", + "estimatedUsd", + "exposureUsd", + "providerOutage", + "reasonCodes", + ]), + }, + } + : { status: "UNKNOWN" as const }; + + const diagnostics: WorkerInspection["diagnostics"] = []; + diagnostics.push({ + status: environmentStatus?.toUpperCase() === "ACTIVE" ? "PASS" : "UNKNOWN", + check: "environment", + message: `Environment status is ${environmentStatus || "unknown"}`, + }); + diagnostics.push({ + status: deployment ? "PASS" : "WARN", + check: "deployment", + message: deployment + ? `Active deployment ${text(deployment.id) || "is present"}` + : "No active deployment was found", + }); + for (const [check, result] of [ + ["resources", resources], + ["secrets", secrets], + ["domains", domains], + ["billing", billing], + ] as const) { + diagnostics.push({ + status: result.status === "AVAILABLE" ? "PASS" : "UNKNOWN", + check, + message: + result.status === "AVAILABLE" + ? `${check} metadata is available` + : `${check} metadata could not be read`, + }); + } + const billingQuality = text(billing.summary?.dataQuality); + if (billing.status === "AVAILABLE" && billingQuality !== "COMPLETE") { + diagnostics.push({ + status: billingQuality ? "WARN" : "UNKNOWN", + check: "billing_freshness", + message: `Billing data quality is ${billingQuality || "unknown"}`, + }); + } + + const environmentSummary = selectedFields(environment, [ + "id", + "name", + "status", + "dailyBudgetUsd", + "publicUrl", + "dispatchUrl", + "customDomainUrl", + "routingMode", + "webAppReady", + "activeDeploymentId", + ]); + if (!environmentSummary.status && environmentStatus) { + environmentSummary.status = environmentStatus; + } + + return { + schemaVersion: 1, + mode: "READ_ONLY", + controlPlane: options.clientOptions.apiHost, + worker: selectedFields(worker, ["id", "name", "slug", "status"]), + environment: environmentSummary, + ...(deployment + ? { + deployment: selectedFields(deployment, [ + "id", + "status", + "artifactId", + "createdAt", + "activatedAt", + ]), + } + : {}), + ...(artifact + ? { + artifact: selectedFields(artifact, [ + "id", + "contentSha256", + "sizeBytes", + "createdAt", + ]), + } + : {}), + resources, + secrets, + domains, + billing, + diagnostics, + nextSteps: [ + `xapi workers plan --env ${options.environment}`, + `xapi workers logs ${options.workerId} --env ${options.environment} --since 10m`, + `xapi workers billing overview ${options.workerId} --env ${options.environment}`, + ], + }; +} From 45aeac24d2081b1496bc882fa27a313ab9f21250 Mon Sep 17 00:00:00 2001 From: daxiongya Date: Mon, 21 Sep 2026 10:04:50 +0800 Subject: [PATCH 10/28] feat(workers): make deployment plans exact --- ...ers-deployment-test-findings-2026-09-21.md | 21 +++ skills/xapi-workers/SKILL.md | 2 +- skills/xapi-workers/references/deployment.md | 16 +- skills/xapi/SKILL.md | 2 +- skills/xapi/guides/workers.md | 16 +- src/commands/workers.ts | 32 +++- src/tests/workers-plan-output.test.ts | 24 ++- src/tests/workers-plan.test.ts | 61 ++++++- src/tests/workers-push-output.test.ts | 24 +++ src/tests/workers-push.test.ts | 53 +++++- src/workers-plan-output.ts | 27 ++- src/workers-plan.ts | 151 +++++++++++++++- src/workers-project-build.ts | 149 ++++++++++++++++ src/workers-push-output.ts | 5 +- src/workers-push.ts | 162 ++++++------------ 15 files changed, 608 insertions(+), 137 deletions(-) create mode 100644 src/workers-project-build.ts diff --git a/docs/workers-deployment-test-findings-2026-09-21.md b/docs/workers-deployment-test-findings-2026-09-21.md index 4825b04..38eddef 100644 --- a/docs/workers-deployment-test-findings-2026-09-21.md +++ b/docs/workers-deployment-test-findings-2026-09-21.md @@ -43,6 +43,27 @@ CLI branch must remain complementary to that backend work. 6. Runtime inspection required several separate commands. `workers inspect` now provides one read-only report while preserving failed sources as `UNKNOWN` and excluding Secret values. +7. Preview push could create the Worker, budget, or managed resources before a + failing application build. `plan` and `push` now share one local preparation + path: build, validate the complete native Artifact, calculate the live diff + and cost-impact evidence, then allow remote writes. Push returns a read-only + inspection after the ACTIVE deployment and public health check. +8. A real Jev `workers plan --format json` exposed build progress on stdout, + corrupting the machine-readable plan even though the build succeeded. Build + stdout/stderr now remain visible on stderr; stdout is reserved for the CLI + result contract. + +## Command boundary after the deployment tests + +- `workers inspect` answers what is running now. It needs no local build and + never evaluates application routes. +- `workers plan` answers what the next deployment will change. It creates local + build output, validates its exact hash and assets, reads live state and the + available price-book metadata, and performs no remote write. +- `workers push` repeats that deterministic preparation, displays the final + plan, waits for confirmation, applies preview changes, and returns inspection + evidence. +- `workers promote` releases the accepted immutable Artifact to production. ## Deferred backend work diff --git a/skills/xapi-workers/SKILL.md b/skills/xapi-workers/SKILL.md index c2c282b..a24885c 100644 --- a/skills/xapi-workers/SKILL.md +++ b/skills/xapi-workers/SKILL.md @@ -12,7 +12,7 @@ Use the `xapi` CLI (`xapi-to` is the same executable). Verify `xapi workers --he - Identify the control-plane host, Worker ID, and **preview or production** from the project and `workers get`. Test control plane and preview environment are separate choices. - Authentication precedence: `XAPI_KEY`, `XAPI_API_KEY`, then `~/.xapi/config.json`. Keys need `workers:read` and, for changes, `workers:write`, plus access to the target Worker. A scoped-out Worker can return 404. - Production API host is `api.xapi.to`; testing uses `XAPI_API_HOST=api.test.xapi.to` (host only). Load secrets from the user's existing secure environment. Never print keys, include them in code/artifacts, or send the xAPI key to a public Worker URL or Cloudflare. Runtime application authentication is separate. -- Start with `workers inspect [worker-id] --env ` and `workers capabilities`. Use `workers plan --env ` when a local project is available and desired-state drift matters. Read-only inspection needs no extra approval. Use existing user authorization for changes; don't expand cleanup from a test environment to production. +- Start with `workers inspect [worker-id] --env ` and `workers capabilities`. Use `workers plan --env ` when a local project is available and desired-state drift matters. `inspect` reads current runtime state only. `plan` runs the configured local build with credential-shaped environment variables removed, validates the exact Artifact, and compares it with live state without writing to the xAPI control plane. It also shows the budget cap, active price-book visibility, and usage-dependent resource changes; never present those estimates as an accrued invoice. Use existing user authorization for changes; don't expand cleanup from a test environment to production. ## Load the relevant workflow diff --git a/skills/xapi-workers/references/deployment.md b/skills/xapi-workers/references/deployment.md index f6ccf1b..3445272 100644 --- a/skills/xapi-workers/references/deployment.md +++ b/skills/xapi-workers/references/deployment.md @@ -8,8 +8,8 @@ the project workflow: | Intent | Command | Writes live state | | --- | --- | --- | | Inspect one running environment | `workers inspect [worker-id] --env ENV` | No | -| Compare local desired state with xAPI | `workers plan --env ENV` | No | -| Build, reconcile and deploy preview | `workers push --env preview` | Yes | +| Build locally and compare exact desired state with xAPI | `workers plan --env ENV` | No remote writes | +| Rebuild, present the final plan, reconcile and deploy preview | `workers push --env preview` | Yes, after confirmation | | Release the accepted preview Artifact | `workers promote --to production` | Yes | | Restore an earlier active version | `workers rollback --env ENV ...` | Yes | @@ -19,7 +19,11 @@ Deployment, routing, resource, Secret metadata, domain, and billing freshness reads into one report. Optional read failures stay `UNKNOWN`, never zero or success. It never reads Secret values and performs no health request that might trigger application behavior. Use `workers plan` separately when comparing -local desired state with xAPI. +local desired state with xAPI. Plan runs the configured local build first, validates +the native bundle and static assets, and then displays the exact Artifact hash, +resource/Secret/routing changes, budget-cap delta, price-book availability, and +usage-dependent cost effects. A budget is a cap rather than a predicted charge; +unknown traffic and storage must remain unknown. `workers build`, `upload`, and `deploy` are lower-level Artifact primitives for custom CI and recovery. A managed `build` only produces an Artifact; `deploy` only activates an existing Artifact. Neither replaces project convergence by @@ -89,11 +93,15 @@ Rollback restores code and compatibility settings, not data, schema, Secret valu Use the project's installed/pinned CLI, lockfile installation, and a scoped secret `XAPI_KEY`. Keep `XAPI_API_HOST` explicit and separate test/production credentials. CLI deployment does not require SSH into an API server or a Cloudflare account token. -Run plan, build/push, active-status and business checks in order. `--non-interactive` suppresses prompts; it does not accept retention policy or bypass preflight: +Run plan, push, inspect, active-status and business checks in order. Both plan +and push prepare the local Artifact; push performs that work before any Worker, +budget, resource, Artifact, or Deployment write. `--non-interactive` suppresses +prompts; it does not accept retention policy or bypass preflight: ```sh xapi workers plan --env preview --format json xapi workers push --env preview --non-interactive +xapi workers inspect --env preview --format json ``` Promote in the already authorized release job after preview acceptance. Follow repository AGENTS.md and branch/PR rules; do not infer release authorization from a successful preview push. On uncertain results inspect deployments/logs and retry unchanged inputs so stable idempotency keys can recover the same operation. Do not change IDs or clear deletion flags to force deployment through. diff --git a/skills/xapi/SKILL.md b/skills/xapi/SKILL.md index 9000aaf..1a873eb 100644 --- a/skills/xapi/SKILL.md +++ b/skills/xapi/SKILL.md @@ -70,7 +70,7 @@ Use granular commands only for multi-step work. Keep the instance ID, terminate ## Hosted Workers -Read `guides/workers.md` before creating, importing, planning, pushing, promoting, rolling back, attaching Cloudflare resources, scheduling tasks, or inspecting logs. Workers are continuously addressable JavaScript applications; Sandbox is ephemeral arbitrary compute. Prefer the project workflow: `workers init`, `workers plan --env preview`, `workers push --env preview`, then `workers promote --to production`. `init` has distinct new-project, existing frontend, Wrangler import, and Next.js SSR adapter paths; select the matching path from the guide instead of repeatedly regenerating project files. Use `xapi.worker.json` as managed-resource desired state, let `plan` compare live state, and use `workers resources pull` only to adopt healthy remote-only resources. Git is optional. `push` builds and uploads an immutable Artifact, including separately declared native static assets, uses stable recovery keys, and never silently deletes stateful resources or Secrets. For web applications, inspect `webAppReady`: path-prefix-aware applications can use fallback routing, while root-relative routes and OAuth callbacks need a dedicated hostname. An optional platform-owned ephemeral Sandbox build can produce the same Artifact type. Rollback restores code and compatibility settings, never KV/D1/R2/DO/Queue/Workflow/schedule data or Secret values. Run the provider capability check before provisioning so missing permissions such as D1 Edit are reported precisely. KV, D1, R2, Durable Object, Queue, Workflow, Secret, schedule, managed-domain, observability, and billing data are environment- or Worker-scoped; never assume preview and production share state. Queue messages use the documented route envelope, are delivered at least once, and require an idempotent target route. Only `ACTIVE` means deployment succeeded. +Read `guides/workers.md` before creating, importing, planning, pushing, promoting, rolling back, attaching Cloudflare resources, scheduling tasks, or inspecting logs. Workers are continuously addressable JavaScript applications; Sandbox is ephemeral arbitrary compute. Prefer the project workflow: `workers init`, `workers plan --env preview`, `workers push --env preview`, then `workers promote --to production`. `init` has distinct new-project, existing frontend, Wrangler import, and Next.js SSR adapter paths; select the matching path from the guide instead of repeatedly regenerating project files. Use `xapi.worker.json` as managed-resource desired state. `plan` runs and validates the configured local build, compares its exact Artifact and resource declarations with live state, and shows budget/price-book impact without remote writes; `inspect` reports what is already running. Use `workers resources pull` only to adopt healthy remote-only resources. Git is optional. `push` prepares the same immutable Artifact before any remote mutation, including separately declared native static assets, shows the final plan, uses stable recovery keys, and never silently deletes stateful resources or Secrets. For web applications, inspect `webAppReady`: path-prefix-aware applications can use fallback routing, while root-relative routes and OAuth callbacks need a dedicated hostname. An optional platform-owned ephemeral Sandbox build can produce the same Artifact type. Rollback restores code and compatibility settings, never KV/D1/R2/DO/Queue/Workflow/schedule data or Secret values. Run the provider capability check before provisioning so missing permissions such as D1 Edit are reported precisely. KV, D1, R2, Durable Object, Queue, Workflow, Secret, schedule, managed-domain, observability, and billing data are environment- or Worker-scoped; never assume preview and production share state. Queue messages use the documented route envelope, are delivered at least once, and require an idempotent target route. Only `ACTIVE` means deployment succeeded. ## Usage Workflow diff --git a/skills/xapi/guides/workers.md b/skills/xapi/guides/workers.md index e758289..9e1611a 100644 --- a/skills/xapi/guides/workers.md +++ b/skills/xapi/guides/workers.md @@ -37,9 +37,11 @@ Managed KV, D1, R2, Durable Object, Queue, and Workflow declarations belong in Use `xapi workers inspect --env preview` for one read-only operational view of the linked Worker. It reports the active environment, routing, Artifact, Deployment, resource and Secret metadata, domains, and billing freshness. -Unavailable sources remain `UNKNOWN`. Use `plan` for desired-state comparison; -`inspect` never builds, deploys, probes application routes, or reads Secret -values. +Unavailable sources remain `UNKNOWN`. Use `plan` for desired-state comparison. +Plan runs and validates the configured local build, then compares that exact +Artifact and desired resources with the live snapshot. It performs no remote +writes. `inspect` never builds, deploys, probes application routes, or reads +Secret values. Choose the `init` form from the project you actually have: @@ -158,7 +160,7 @@ Choose the command by intent: | Adopt live-only resources | `resources pull` | Live read, then safe local merge | | Stop declaring a resource | `resources remove` | Local desired state only | | Delete resource data | `resources destroy --yes` | Local desired state and one live environment | -| Check convergence | `workers plan` | None | +| Preview exact deployment changes | `workers plan` | Local build output only | Use this normal flow to add a resource: @@ -175,6 +177,12 @@ xapi workers plan --env preview xapi workers push --env preview ``` +`plan` reports the current and desired daily budget, active price-book +visibility, and any new metered Worker/resource declarations. Exact charges +remain usage-dependent; the CLI does not invent request, CPU, storage, or +operation volume. Use `inspect` and billing views for accrued usage and billing +freshness. + `--env both` creates matching declarations, not shared storage. `resources add` is idempotent and rejects conflicting binding reuse. `resources update` replaces the complete declaration. Before linking it can correct any local diff --git a/src/commands/workers.ts b/src/commands/workers.ts index d876877..0541b84 100644 --- a/src/commands/workers.ts +++ b/src/commands/workers.ts @@ -10,7 +10,10 @@ import { } from "../workers-init.ts"; import { listWorkerTemplates } from "../workers-templates.ts"; import { importWranglerProject } from "../workers-wrangler-import.ts"; -import { createWorkerPlan } from "../workers-plan.ts"; +import { + prepareWorkerPlan, + type WorkerDeploymentPlan, +} from "../workers-plan.ts"; import { formatWorkerPlan, useHumanWorkerPlanOutput, @@ -47,6 +50,7 @@ import { useHumanWorkerInspectionOutput, } from "../workers-inspect-output.ts"; import { loadWorkerProject } from "../workers-project.ts"; +import { WorkerProjectBuildError } from "../workers-project-build.ts"; export const WORKERS_HELP = `xapi-to workers - Deploy and manage xAPI-hosted Cloudflare Workers @@ -115,6 +119,8 @@ RESOURCES, BILLING, AND LIFECYCLE CHOOSING A WORKFLOW Normal application: init -> plan -> push -> promote Read-only review: inspect; use plan when comparing local desired state + workers plan runs the local build and validates the exact Artifact, then + compares it with live state. It never writes to the xAPI control plane. workers build creates an Artifact in a managed Sandbox; it does not deploy. workers deploy activates an existing Artifact; it does not build or converge project state. @@ -343,7 +349,7 @@ function options() { } function printWorkerPlan( - plan: Awaited>, + plan: WorkerDeploymentPlan, flagFormat?: string, ) { if ( @@ -666,12 +672,22 @@ export async function workersCommand( if (flags.config === "true" || flags.config === "") { err("--config requires a path"); } - const plan = await createWorkerPlan({ - environment: environment(flags.env) as "preview" | "production", - configPath: flags.config, - clientOptions: options(), - }); - printWorkerPlan(plan, flags.format); + try { + const prepared = await prepareWorkerPlan({ + environment: environment(flags.env) as "preview" | "production", + configPath: flags.config, + clientOptions: options(), + }); + printWorkerPlan(prepared.plan, flags.format); + } catch (error) { + if (error instanceof WorkerProjectBuildError) { + err(error.message, { + remoteChangesApplied: false, + ...error.recovery, + }); + } + err(error instanceof Error ? error.message : "Worker plan failed"); + } return; } case "retention": { diff --git a/src/tests/workers-plan-output.test.ts b/src/tests/workers-plan-output.test.ts index 63177cf..46669de 100644 --- a/src/tests/workers-plan-output.test.ts +++ b/src/tests/workers-plan-output.test.ts @@ -24,6 +24,24 @@ const plan: WorkerDeploymentPlan = { }, environment: "preview", remote: { linked: false }, + costImpact: { + status: "UNKNOWN", + desiredDailyBudgetUsd: 0.25, + meteredChanges: [ + { + kind: "worker", + key: "my-agent", + effect: "USAGE_DEPENDENT", + }, + { + kind: "resource", + key: "AGENT_STATE", + type: "durable_object", + effect: "USAGE_DEPENDENT", + }, + ], + notes: ["The daily budget is a spending cap, not a predicted charge."], + }, canApply: false, summary: { CREATE: 4, @@ -92,7 +110,9 @@ describe("Worker plan terminal output", () => { expect(rendered).toContain("Durable Object · class AgentState · managed by xAPI"); expect(rendered).toContain("MODEL_KEY"); expect(rendered).toContain("npm run build → dist/worker.mjs"); - expect(rendered).toContain("push rebuilds it before upload"); + expect(rendered).toContain("push applies the reviewed result"); + expect(rendered).toContain("$0.25/day target"); + expect(rendered).toContain("2 usage-dependent items"); expect(rendered).toContain("xapi workers push --env preview"); expect(rendered).not.toContain('"schemaVersion"'); expect(rendered).not.toContain( @@ -118,6 +138,8 @@ describe("Worker plan terminal output", () => { }; const rendered = formatWorkerPlan(converged); expect(rendered).toContain("No deployment changes are required"); + expect(rendered).toContain("Existing state (reused)"); + expect(rendered).toContain("= Worker"); expect(rendered).not.toContain("Apply this plan"); }); diff --git a/src/tests/workers-plan.test.ts b/src/tests/workers-plan.test.ts index 6845b86..e291619 100644 --- a/src/tests/workers-plan.test.ts +++ b/src/tests/workers-plan.test.ts @@ -11,7 +11,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { createHash } from "node:crypto"; import { HttpError } from "../client.ts"; -import { createWorkerPlan } from "../workers-plan.ts"; +import { createWorkerPlan, prepareWorkerPlan } from "../workers-plan.ts"; import { WORKER_PROJECT_SCHEMA_URL } from "../workers-project.ts"; import { deploymentPrefix } from "../workers-deployment-state.ts"; @@ -76,6 +76,44 @@ function unexpected(name: string): () => Promise { } describe("workers plan", () => { + test("builds and validates the exact Artifact before presenting the final plan", async () => { + const root = project(); + const events: string[] = []; + const prepared = await prepareWorkerPlan({ + cwd: root, + environment: "preview", + clientOptions: { apiHost: "localhost:3003", apiKey: "test-key" }, + client: { + listWorkers: async () => { + events.push("read-live-state"); + return []; + }, + getWorker: unexpected("getWorker"), + listWorkerResources: unexpected("listWorkerResources"), + listWorkerSecrets: unexpected("listWorkerSecrets"), + }, + runBuild: async () => { + events.push("build"); + mkdirSync(join(root, "dist"), { recursive: true }); + writeFileSync( + join(root, "dist/worker.mjs"), + "export default {fetch(){return new Response('ok')}};", + ); + }, + }); + expect(events).toEqual(["build", "read-live-state"]); + expect(prepared.plan.actions).toContainEqual( + expect.objectContaining({ + operation: "CREATE", + kind: "artifact", + desired: expect.objectContaining({ + sha256: prepared.bundle.contentSha256, + sizeBytes: prepared.bundle.sizeBytes, + }), + }), + ); + }); + test("plan compares the current remote binding snapshot, not only code", async () => { const bundle = "export default {fetch(){return new Response('ok')}}"; const root = project({ linked: true, bundle }); @@ -306,6 +344,13 @@ describe("workers plan", () => { { bindingName: "MODEL_KEY", version: 2 }, { bindingName: "OLD_SECRET", version: 1 }, ], + workerBillingQuery: async () => ({ + data: { + version: "workers-2026-09", + effectiveFrom: "2026-09-01T00:00:00.000Z", + rates: [{ metric: "WORKER_REQUEST", retailUnitPriceUsd: "0.01" }], + }, + }), }; const plan = await createWorkerPlan({ cwd: root, @@ -353,6 +398,18 @@ describe("workers plan", () => { // Legacy deployments have no configuration fingerprint: one safe redeploy. expect.objectContaining({ operation: "CREATE", kind: "deployment" }), ); + expect(plan.costImpact).toEqual( + expect.objectContaining({ + status: "AVAILABLE", + currentDailyBudgetUsd: 0.5, + desiredDailyBudgetUsd: 0.25, + dailyBudgetDeltaUsd: -0.25, + priceBook: expect.objectContaining({ + version: "workers-2026-09", + rateCount: 1, + }), + }), + ); }); test("uses only GET requests at the real HTTP client boundary", async () => { @@ -388,7 +445,7 @@ describe("workers plan", () => { clientOptions: { apiHost: "localhost:3003", apiKey: "test-key" }, }); expect(plan.canApply).toBe(true); - expect(methods).toEqual(["GET", "GET", "GET"]); + expect(methods).toEqual(["GET", "GET", "GET", "GET"]); }); test("propagates a safe hidden-instance 404 and performs no fallback lookup", async () => { diff --git a/src/tests/workers-push-output.test.ts b/src/tests/workers-push-output.test.ts index f379480..d8b9538 100644 --- a/src/tests/workers-push-output.test.ts +++ b/src/tests/workers-push-output.test.ts @@ -18,6 +18,15 @@ const result: WorkerPushResult = { }, environment: "preview", remote: { linked: true, workerId: "worker-id" }, + costImpact: { + status: "AVAILABLE", + desiredDailyBudgetUsd: 0.25, + currentDailyBudgetUsd: 0.25, + dailyBudgetDeltaUsd: 0, + priceBook: { version: "workers-v1", rateCount: 12 }, + meteredChanges: [], + notes: [], + }, canApply: true, summary: { CREATE: 2, @@ -47,7 +56,21 @@ const result: WorkerPushResult = { status: 200, attempts: 1, }, + inspection: { + schemaVersion: 1, + mode: "READ_ONLY", + controlPlane: "api.xapi.to", + worker: { id: "worker-id", status: "ACTIVE" }, + environment: { name: "PREVIEW", status: "ACTIVE" }, + resources: { status: "AVAILABLE", items: [] }, + secrets: { status: "AVAILABLE", items: [] }, + domains: { status: "AVAILABLE", items: [] }, + billing: { status: "AVAILABLE", summary: { dataQuality: "COMPLETE" } }, + diagnostics: [], + nextSteps: [], + }, commands: { + inspect: "xapi workers inspect worker-id --env preview", logs: "xapi workers logs worker-id --env preview", promote: "xapi workers promote --to production", }, @@ -60,6 +83,7 @@ describe("Worker push terminal output", () => { expect(rendered).toContain("https://my-agent.example.test"); expect(rendered).toContain("HTTP 200 · 1 attempt"); expect(rendered).toContain("2 reused"); + expect(rendered).toContain("xapi workers inspect worker-id --env preview"); expect(rendered).toContain("xapi workers logs worker-id --env preview"); expect(rendered).not.toContain('"initialPlan"'); }); diff --git a/src/tests/workers-push.test.ts b/src/tests/workers-push.test.ts index b07fe24..cec139b 100644 --- a/src/tests/workers-push.test.ts +++ b/src/tests/workers-push.test.ts @@ -189,6 +189,12 @@ function fakePlatform( return state.resources.at(-1); }, listWorkerSecrets: async () => state.secrets, + listWorkerDomains: async () => [], + workerBillingQuery: async () => ({ + snapshotId: "snapshot-1", + dataQuality: "COMPLETE", + data: { lifecycleState: "RUNNING", dailyBudgetUsd: 0.25 }, + }), listWorkerArtifacts: async () => state.artifacts, uploadWorkerArtifact: async (_api, _id, input) => { calls.uploadArtifact += 1; @@ -345,6 +351,9 @@ writeFileSync("observed-key.txt", process.env.XAPI_KEY || ""); expect(readFileSync(join(root, "observed-key.txt"), "utf8")).toBe(""); expect(first.resources.created).toEqual(["STATE"]); expect(first.deployment.status).toBe("ACTIVE"); + expect(first.inspection.mode).toBe("READ_ONLY"); + expect(first.inspection.environment.status).toBe("ACTIVE"); + expect(first.commands.inspect).toContain(`inspect ${workerId}`); expect(first.health.url).toBe( "https://push-agent.example.test/w/agent/preview/health", ); @@ -380,7 +389,7 @@ writeFileSync("observed-key.txt", process.env.XAPI_KEY || ""); expect(planViews).toHaveLength(1); }); - test("interactive bootstrap saves prerequisites but stops before build when a Secret is missing", async () => { + test("interactive bootstrap validates the build before saving prerequisites, then stops for a missing Secret", async () => { const root = fixture({ secrets: ["MODEL_KEY"] }); const platform = fakePlatform(); let buildCalls = 0; @@ -394,6 +403,11 @@ writeFileSync("observed-key.txt", process.env.XAPI_KEY || ""); confirm: async () => true, runBuild: async () => { buildCalls += 1; + mkdirSync(join(root, "dist"), { recursive: true }); + writeFileSync( + join(root, "dist/worker.mjs"), + "export default {fetch(){return new Response('ok')}};", + ); }, }); } catch (error) { @@ -405,7 +419,7 @@ writeFileSync("observed-key.txt", process.env.XAPI_KEY || ""); expect(JSON.stringify(caught?.recovery)).toContain( `secrets set ${workerId} MODEL_KEY`, ); - expect(buildCalls).toBe(0); + expect(buildCalls).toBe(1); expect(platform.calls.uploadArtifact).toBe(0); expect(platform.calls.deploy).toBe(0); expect(loadWorkerProject(root).config.workerId).toBe(workerId); @@ -431,6 +445,13 @@ writeFileSync("observed-key.txt", process.env.XAPI_KEY || ""); confirmations += 1; return true; }, + runBuild: async () => { + mkdirSync(join(root, "dist"), { recursive: true }); + writeFileSync( + join(root, "dist/worker.mjs"), + "export default {fetch(){return new Response('ok')}};", + ); + }, }), ).rejects.toThrow("requires reconciliation"); expect(confirmations).toBe(0); @@ -453,6 +474,13 @@ writeFileSync("observed-key.txt", process.env.XAPI_KEY || ""); clientOptions: { apiHost: "localhost:3003", apiKey: "test-key" }, client: platform.client, nonInteractive: true, + runBuild: async () => { + mkdirSync(join(root, "dist"), { recursive: true }); + writeFileSync( + join(root, "dist/worker.mjs"), + "export default {fetch(){return new Response('ok')}};", + ); + }, }), ).rejects.toThrow("requires reconciliation"); expect(platform.calls.createWorker).toBe(0); @@ -469,6 +497,13 @@ writeFileSync("observed-key.txt", process.env.XAPI_KEY || ""); clientOptions: { apiHost: "localhost:3003", apiKey: "test-key" }, client: platform.client, confirm: async () => false, + runBuild: async () => { + mkdirSync(join(root, "dist"), { recursive: true }); + writeFileSync( + join(root, "dist/worker.mjs"), + "export default {fetch(){return new Response('ok')}};", + ); + }, }), ).rejects.toThrow("cancelled"); expect(platform.calls.createWorker).toBe(0); @@ -495,6 +530,13 @@ writeFileSync("observed-key.txt", process.env.XAPI_KEY || ""); clientOptions: { apiHost: "localhost:3003", apiKey: "test-key" }, client: platform.client, confirm: async () => true, + runBuild: async () => { + mkdirSync(join(root, "dist"), { recursive: true }); + writeFileSync( + join(root, "dist/worker.mjs"), + "export default {fetch(){return new Response('ok')}};", + ); + }, }); } catch (error) { caught = error as WorkerPushError; @@ -601,6 +643,9 @@ writeFileSync("observed-key.txt", process.env.XAPI_KEY || ""); "missing-package-manager-that-does-not-exist run build", ); expect(caught?.recovery.next).toContain("Install the package manager"); + expect(caught?.recovery.remoteChangesApplied).toBe(false); + expect(platform.calls.createWorker).toBe(0); + expect(platform.calls.createResource).toBe(0); expect(platform.calls.uploadArtifact).toBe(0); expect(platform.calls.deploy).toBe(0); }); @@ -630,8 +675,10 @@ writeFileSync("observed-key.txt", process.env.XAPI_KEY || ""); expect(caught).toBeInstanceOf(WorkerPushError); expect(caught?.message).toContain("imports that are not in the Artifact"); expect(caught?.recovery).toEqual( - expect.objectContaining({ workerId, resourcesPreserved: true }), + expect.objectContaining({ remoteChangesApplied: false }), ); + expect(platform.calls.createWorker).toBe(0); + expect(platform.calls.createResource).toBe(0); expect(platform.calls.uploadArtifact).toBe(0); expect(platform.calls.deploy).toBe(0); }); diff --git a/src/workers-plan-output.ts b/src/workers-plan-output.ts index 75a47dc..8d81b9d 100644 --- a/src/workers-plan-output.ts +++ b/src/workers-plan-output.ts @@ -217,6 +217,9 @@ export function formatWorkerPlan(plan: WorkerDeploymentPlan): string { const blocked = plan.actions.filter( (action) => action.operation === "BLOCKED", ); + const unchanged = plan.actions.filter( + (action) => action.operation === "NO_CHANGE", + ); const rootBlocked = blocked.filter( (action) => action.kind !== "deployment", ).length; @@ -228,6 +231,14 @@ export function formatWorkerPlan(plan: WorkerDeploymentPlan): string { const remote = plan.remote.linked ? `Linked · ${plan.remote.workerId || plan.project.workerId || "existing Worker"}` : "Not linked · a new Worker will be created"; + const budget = plan.costImpact; + const budgetChange = + budget.currentDailyBudgetUsd === undefined + ? `$${budget.desiredDailyBudgetUsd.toFixed(2)}/day target` + : `$${budget.currentDailyBudgetUsd.toFixed(2)} → $${budget.desiredDailyBudgetUsd.toFixed(2)}/day (${budget.dailyBudgetDeltaUsd! >= 0 ? "+" : "-"}$${Math.abs(budget.dailyBudgetDeltaUsd!).toFixed(2)})`; + const priceBook = budget.priceBook + ? `${budget.priceBook.version || "active version"} · ${budget.priceBook.rateCount} rates` + : "Unavailable — verify before production promotion"; const lines = [ "xAPI Worker Plan", @@ -241,7 +252,7 @@ export function formatWorkerPlan(plan: WorkerDeploymentPlan): string { "Build", `${plan.project.build.command} → ${plan.project.build.output}${plan.project.build.main ? ` (main: ${plan.project.build.main})` : ""}`, ), - " Plan compares the current bundle; push rebuilds it before upload.", + " Plan built and validated this exact bundle; push applies the reviewed result.", "", "Summary", metadataRow("Create", String(plan.summary.CREATE)), @@ -250,6 +261,17 @@ export function formatWorkerPlan(plan: WorkerDeploymentPlan): string { metadataRow("Manual", String(plan.summary.MANUAL)), metadataRow("Blocked", String(plan.summary.BLOCKED)), "", + "Cost impact", + metadataRow("Daily budget", budgetChange), + metadataRow("Price book", priceBook), + metadataRow( + "Metered changes", + budget.meteredChanges.length + ? `${budget.meteredChanges.length} usage-dependent item${budget.meteredChanges.length === 1 ? "" : "s"}` + : "No new metered resource declarations", + ), + ...budget.notes.map((note) => ` · ${note}`), + "", "Planned changes", ...(planned.length ? planned.map(actionRow) : [" No changes required."]), ]; @@ -260,6 +282,9 @@ export function formatWorkerPlan(plan: WorkerDeploymentPlan): string { if (blocked.length) { lines.push("", "Blocked", ...blocked.map(actionRow)); } + if (unchanged.length) { + lines.push("", "Existing state (reused)", ...unchanged.map(actionRow)); + } lines.push("", "Next steps", ...nextSteps(plan), RULE); return lines.join("\n"); } diff --git a/src/workers-plan.ts b/src/workers-plan.ts index 3b898b2..93c0455 100644 --- a/src/workers-plan.ts +++ b/src/workers-plan.ts @@ -1,5 +1,8 @@ import { existsSync, lstatSync, statSync } from "node:fs"; -import type { WorkersClientOptions } from "./workers-client.ts"; +import type { + WorkerBillingQueryKind, + WorkersClientOptions, +} from "./workers-client.ts"; import * as workersClient from "./workers-client.ts"; import { loadWorkerArtifactInput, validateNativeDeploymentMetadata, WorkerArtifactError } from "./workers-artifact.ts"; import { deploymentPrefix, currentMatchingDeployment } from "./workers-deployment-state.ts"; @@ -12,6 +15,11 @@ import { resolveWorkerProjectPath, } from "./workers-project.ts"; import { remoteWorkerResourceState } from "./workers-resource-state.ts"; +import { + prepareWorkerProjectBundle, + type WorkerProjectBuildRunner, +} from "./workers-project-build.ts"; +import type { LoadedWorkerArtifact } from "./workers-artifact.ts"; export type WorkerPlanOperation = | "CREATE" @@ -49,6 +57,24 @@ export interface WorkerDeploymentPlan { }; environment: "preview" | "production"; remote: { linked: boolean; workerId?: string }; + costImpact: { + status: "AVAILABLE" | "PARTIAL" | "UNKNOWN"; + desiredDailyBudgetUsd: number; + currentDailyBudgetUsd?: number; + dailyBudgetDeltaUsd?: number; + priceBook?: { + version?: string; + effectiveFrom?: string; + rateCount: number; + }; + meteredChanges: Array<{ + kind: "worker" | "resource"; + key: string; + type?: string; + effect: "USAGE_DEPENDENT"; + }>; + notes: string[]; + }; canApply: boolean; summary: Record; actions: WorkerPlanAction[]; @@ -67,6 +93,12 @@ export interface PlanClient { id: string, environment: string, ): Promise; + workerBillingQuery?( + options: WorkersClientOptions, + id: string, + environment: string, + kind: WorkerBillingQueryKind, + ): Promise; } export interface CreateWorkerPlanOptions { @@ -77,6 +109,15 @@ export interface CreateWorkerPlanOptions { client?: PlanClient; } +export interface PrepareWorkerPlanOptions extends CreateWorkerPlanOptions { + runBuild?: WorkerProjectBuildRunner; +} + +export interface PreparedWorkerPlan { + plan: WorkerDeploymentPlan; + bundle: LoadedWorkerArtifact; +} + type UnknownRecord = Record; type DesiredResource = WorkerProjectConfig["environments"]["preview"]["resources"][number]; @@ -571,6 +612,73 @@ function planSummary( return result; } +function planCostImpact( + actions: WorkerPlanAction[], + desiredDailyBudgetUsd: number, + currentDailyBudgetUsd: number | undefined, + priceResponse: unknown, +): WorkerDeploymentPlan["costImpact"] { + const envelope = record(priceResponse); + const data = record(envelope?.data); + const rates = Array.isArray(data?.rates) ? data.rates : undefined; + const priceVersion = string(data?.version); + const effectiveFrom = string(data?.effectiveFrom); + const meteredChanges: WorkerDeploymentPlan["costImpact"]["meteredChanges"] = + actions + .filter( + (action) => + ["CREATE", "UPDATE"].includes(action.operation) && + (action.kind === "worker" || action.kind === "resource"), + ) + .map((action) => ({ + kind: action.kind as "worker" | "resource", + key: action.key, + ...(action.kind === "resource" && string(action.desired?.type) + ? { type: string(action.desired?.type) } + : {}), + effect: "USAGE_DEPENDENT" as const, + })); + const notes = [ + "The daily budget is a spending cap, not a predicted charge.", + "Worker and managed-resource charges depend on measured usage; plan does not invent traffic or storage assumptions.", + ]; + if (!rates) { + notes.push( + "The active price book could not be read for this environment; inspect billing before production promotion.", + ); + } + return { + status: rates + ? currentDailyBudgetUsd === undefined + ? "PARTIAL" + : "AVAILABLE" + : currentDailyBudgetUsd === undefined + ? "UNKNOWN" + : "PARTIAL", + desiredDailyBudgetUsd, + ...(currentDailyBudgetUsd !== undefined + ? { + currentDailyBudgetUsd, + dailyBudgetDeltaUsd: + Math.round( + (desiredDailyBudgetUsd - currentDailyBudgetUsd) * 1_000_000, + ) / 1_000_000, + } + : {}), + ...(rates + ? { + priceBook: { + ...(priceVersion ? { version: priceVersion } : {}), + ...(effectiveFrom ? { effectiveFrom } : {}), + rateCount: rates.length, + }, + } + : {}), + meteredChanges, + notes, + }; +} + export async function createWorkerPlan( options: CreateWorkerPlanOptions, ): Promise { @@ -743,6 +851,21 @@ export async function createWorkerPlan( options.environment, ); + let priceResponse: unknown; + if (remote && api.workerBillingQuery) { + try { + priceResponse = await api.workerBillingQuery( + options.clientOptions, + project.config.workerId!, + options.environment, + "prices", + ); + } catch { + // Price visibility is advisory. A transient billing read must not turn a + // valid deployment diff into a false success or a false blocker. + } + } + actions.sort( (a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || @@ -768,6 +891,12 @@ export async function createWorkerPlan( linked: !!remote, ...(remote ? { workerId: string(remote.id) } : {}), }, + costImpact: planCostImpact( + actions, + desired.dailyBudgetUsd, + currentBudget, + priceResponse, + ), canApply: summary.BLOCKED === 0 && !actions.some( @@ -778,3 +907,23 @@ export async function createWorkerPlan( actions, }; } + +/** + * Build and validate the exact local bundle before calculating the remote diff. + * This may update local build output, but it never writes to the xAPI control + * plane. Both `workers plan` and `workers push` use this path so the reviewed + * Artifact is the one that push will upload. + */ +export async function prepareWorkerPlan( + options: PrepareWorkerPlanOptions, +): Promise { + const project = loadWorkerProject(options.cwd, options.configPath); + validatePlanInputs(project); + const bundle = await prepareWorkerProjectBundle( + project, + options.environment, + options.runBuild, + ); + const plan = await createWorkerPlan(options); + return { plan, bundle }; +} diff --git a/src/workers-project-build.ts b/src/workers-project-build.ts new file mode 100644 index 0000000..be01de0 --- /dev/null +++ b/src/workers-project-build.ts @@ -0,0 +1,149 @@ +import { spawn } from "node:child_process"; +import type { LoadedWorkerArtifact } from "./workers-artifact.ts"; +import { + loadWorkerArtifactInput, + validateNativeDeploymentMetadata, + WorkerArtifactError, +} from "./workers-artifact.ts"; +import type { LoadedWorkerProject } from "./workers-project.ts"; +import { resolveWorkerProjectPath } from "./workers-project.ts"; +import { readWranglerDeploymentSettings } from "./workers-wrangler-import.ts"; + +const BUILD_TIMEOUT_MS = 15 * 60_000; + +export type WorkerProjectBuildRunner = ( + command: string, + cwd: string, +) => Promise; + +export class WorkerProjectBuildError extends Error { + constructor( + message: string, + public readonly recovery: Record = {}, + ) { + super(message); + this.name = "WorkerProjectBuildError"; + } +} + +function sanitizedBuildEnvironment(): NodeJS.ProcessEnv { + return Object.fromEntries( + Object.entries(process.env).filter( + ([name]) => + !/(?:^|_)(?:API_?KEY|TOKEN|SECRET|PASSWORD|CREDENTIALS?)(?:$|_)/i.test( + name, + ) && name !== "XAPI_KEY", + ), + ); +} + +export async function runWorkerProjectBuild( + command: string, + cwd: string, +): Promise { + await new Promise((resolve, reject) => { + const child = spawn(command, { + cwd, + env: sanitizedBuildEnvironment(), + shell: true, + stdio: ["inherit", "pipe", "pipe"], + }); + // stdout is reserved for the CLI's JSON contract. Build tools routinely + // print progress to stdout, so forward both streams to stderr where they + // remain visible without corrupting `--format json` output. + child.stdout?.pipe(process.stderr); + child.stderr?.pipe(process.stderr); + const timer = setTimeout(() => { + child.kill("SIGTERM"); + reject( + new WorkerProjectBuildError( + `Build exceeded the ${BUILD_TIMEOUT_MS / 60_000} minute timeout`, + { buildCommand: command, projectRoot: cwd }, + ), + ); + }, BUILD_TIMEOUT_MS); + child.once("error", (error) => { + clearTimeout(timer); + reject( + new WorkerProjectBuildError(`Unable to start build: ${error.message}`, { + buildCommand: command, + projectRoot: cwd, + }), + ); + }); + child.once("exit", (code, signal) => { + clearTimeout(timer); + if (code === 0) { + resolve(); + return; + } + reject( + new WorkerProjectBuildError( + code === 127 + ? "Build command could not run because a required executable was not found" + : `Build failed${signal ? ` with ${signal}` : ` with exit code ${code}`}`, + { + buildCommand: command, + projectRoot: cwd, + ...(code === 127 + ? { + next: + "Install the package manager used by build.command, then rerun the command", + } + : {}), + }, + ), + ); + }); + }); +} + +export async function loadWorkerProjectBundle( + project: LoadedWorkerProject, + environment: "preview" | "production", +): Promise { + const path = resolveWorkerProjectPath( + project, + project.config.build.output, + "build.output", + ); + try { + const bundle = await loadWorkerArtifactInput( + path, + project.config.build.main, + project.config.assets + ? { + ...project.config.assets, + directory: resolveWorkerProjectPath( + project, + project.config.assets.directory, + "assets.directory", + ), + } + : undefined, + ); + validateNativeDeploymentMetadata( + bundle, + readWranglerDeploymentSettings(project, environment), + project.config.environments[environment].resources, + ); + return bundle; + } catch (error) { + if (error instanceof WorkerArtifactError) { + throw new WorkerProjectBuildError(error.message, { + buildOutput: project.config.build.output, + remoteChangesApplied: false, + }); + } + throw error; + } +} + +export async function prepareWorkerProjectBundle( + project: LoadedWorkerProject, + environment: "preview" | "production", + runner: WorkerProjectBuildRunner = runWorkerProjectBuild, +): Promise { + await runner(project.config.build.command, project.rootDir); + return loadWorkerProjectBundle(project, environment); +} diff --git a/src/workers-push-output.ts b/src/workers-push-output.ts index cf186a7..69e23aa 100644 --- a/src/workers-push-output.ts +++ b/src/workers-push-output.ts @@ -46,8 +46,9 @@ export function formatWorkerPushResult(result: WorkerPushResult): string { "", "Next steps", ` 1. Open: ${result.publicUrl}`, - ` 2. Logs: ${result.commands.logs}`, - ` 3. Production: ${result.commands.promote}`, + ` 2. Inspect: ${result.commands.inspect}`, + ` 3. Logs: ${result.commands.logs}`, + ` 4. Production: ${result.commands.promote}`, RULE, ]; return lines.join("\n"); diff --git a/src/workers-push.ts b/src/workers-push.ts index b5dd75d..590444d 100644 --- a/src/workers-push.ts +++ b/src/workers-push.ts @@ -7,14 +7,10 @@ import { unlinkSync, writeFileSync, } from "node:fs"; -import { spawn } from "node:child_process"; import { createInterface } from "node:readline/promises"; import { HttpError, isRetryableRequestError } from "./client.ts"; import { type LoadedWorkerArtifact, - loadWorkerArtifactInput, - validateNativeDeploymentMetadata, - WorkerArtifactError, type WorkerArtifactUploadRequest, } from "./workers-artifact.ts"; import type { WorkersClientOptions } from "./workers-client.ts"; @@ -23,21 +19,27 @@ import { type LoadedWorkerProject, WorkerProjectConfigError, loadWorkerProject, - resolveWorkerProjectPath, workerProjectConfigSchema, } from "./workers-project.ts"; import { - createWorkerPlan, + prepareWorkerPlan, type PlanClient, type WorkerDeploymentPlan, } from "./workers-plan.ts"; import { readWranglerDeploymentSettings } from "./workers-wrangler-import.ts"; import { remoteWorkerResourceState } from "./workers-resource-state.ts"; import { deploymentPrefix, deploymentKey, currentMatchingDeployment } from "./workers-deployment-state.ts"; +import { + inspectWorker, + type WorkerInspection, +} from "./workers-inspect.ts"; +import { + WorkerProjectBuildError, + type WorkerProjectBuildRunner, +} from "./workers-project-build.ts"; const WORKER_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; -const BUILD_TIMEOUT_MS = 15 * 60_000; const DEPLOYMENT_TIMEOUT_MS = 3 * 60_000; const HEALTH_ATTEMPTS = 10; const HEALTH_INTERVAL_MS = 1_000; @@ -56,6 +58,16 @@ export interface DeploymentClient { } export interface PushClient extends PlanClient, DeploymentClient { + listWorkerDomains( + options: WorkersClientOptions, + id: string, + ): Promise; + workerBillingQuery( + options: WorkersClientOptions, + id: string, + environment: string, + kind: "prices" | "overview", + ): Promise; createWorker( options: WorkersClientOptions, input: Record, @@ -121,7 +133,7 @@ export interface PushWorkerProjectOptions { client?: PushClient; confirm?: (plan: WorkerDeploymentPlan) => Promise; onPlan?: (plan: WorkerDeploymentPlan) => void; - runBuild?: (command: string, cwd: string) => Promise; + runBuild?: WorkerProjectBuildRunner; fetchPublic?: typeof fetch; sleep?: (milliseconds: number) => Promise; } @@ -137,7 +149,8 @@ export interface WorkerPushResult { publicUrl: string; routing?: { mode?: string; webAppReady: boolean; publicOrigin?: string; publicBasePath?: string }; health: { url: string; status: number; attempts: number }; - commands: { logs: string; promote: string }; + inspection: WorkerInspection; + commands: { inspect: string; logs: string; promote: string }; } export class WorkerPushError extends Error { @@ -201,62 +214,6 @@ function shouldReconcileWrite(error: unknown): boolean { ); } -function sanitizedBuildEnvironment(): NodeJS.ProcessEnv { - return Object.fromEntries( - Object.entries(process.env).filter( - ([name]) => - !/(?:^|_)(?:API_?KEY|TOKEN|SECRET|PASSWORD|CREDENTIALS?)(?:$|_)/i.test( - name, - ) && name !== "XAPI_KEY", - ), - ); -} - -async function defaultRunBuild(command: string, cwd: string): Promise { - await new Promise((resolve, reject) => { - const child = spawn(command, { - cwd, - env: sanitizedBuildEnvironment(), - shell: true, - stdio: "inherit", - }); - const timer = setTimeout(() => { - child.kill("SIGTERM"); - reject( - new WorkerPushError( - `Build exceeded the ${BUILD_TIMEOUT_MS / 60_000} minute timeout`, - ), - ); - }, BUILD_TIMEOUT_MS); - child.once("error", (error) => { - clearTimeout(timer); - reject(new WorkerPushError(`Unable to start build: ${error.message}`)); - }); - child.once("exit", (code, signal) => { - clearTimeout(timer); - if (code === 0) resolve(); - else { - reject( - new WorkerPushError( - code === 127 - ? "Build command could not run because a required executable was not found" - : `Build failed${signal ? ` with ${signal}` : ` with exit code ${code}`}`, - { - buildCommand: command, - projectRoot: cwd, - ...(code === 127 - ? { - next: "Install the package manager used by build.command, then rerun workers push", - } - : {}), - }, - ), - ); - } - }); - }); -} - async function terminalConfirm(): Promise { if (!process.stdin.isTTY || !process.stdout.isTTY) { throw new WorkerPushError( @@ -275,35 +232,6 @@ async function terminalConfirm(): Promise { } } -async function validateBundle(project: LoadedWorkerProject): Promise { - const path = resolveWorkerProjectPath( - project, - project.config.build.output, - "build.output", - ); - try { - return await loadWorkerArtifactInput( - path, - project.config.build.main, - project.config.assets - ? { - ...project.config.assets, - directory: resolveWorkerProjectPath( - project, - project.config.assets.directory, - "assets.directory", - ), - } - : undefined, - ); - } catch (error) { - if (error instanceof WorkerArtifactError) { - throw new WorkerPushError(error.message); - } - throw error; - } -} - function environmentOf( worker: UnknownRecord, environment: "preview" | "production", @@ -553,7 +481,7 @@ async function ensureArtifact( api: PushClient, options: WorkersClientOptions, workerId: string, - bundle: Awaited>, + bundle: LoadedWorkerArtifact, ): Promise { const idempotencyKey = stableKey( "xapi-worker-artifact-v1", @@ -823,17 +751,31 @@ export async function pushWorkerProject( ); } const api = options.client || (workersClient as PushClient); + let prepared: Awaited>; + try { + prepared = await prepareWorkerPlan({ + cwd: options.cwd, + configPath: options.configPath, + environment: "preview", + clientOptions: options.clientOptions, + client: api, + runBuild: options.runBuild, + }); + } catch (error) { + if (error instanceof WorkerProjectBuildError) { + throw new WorkerPushError(error.message, { + remoteChangesApplied: false, + ...error.recovery, + }); + } + throw error; + } const project = loadWorkerProject(options.cwd, options.configPath); const initialConfig = readFileSync(project.configPath, "utf8"); const initialConfigSha256 = sha256(initialConfig); const compatibility = readWranglerDeploymentSettings(project, "preview"); - const initialPlan = await createWorkerPlan({ - cwd: project.rootDir, - configPath: project.configPath, - environment: "preview", - clientOptions: options.clientOptions, - client: api, - }); + const initialPlan = prepared.plan; + const bundle = prepared.bundle; options.onPlan?.(initialPlan); const blockers = unsafePlanBlockers(initialPlan, !project.config.workerId); if (blockers.length || (options.nonInteractive && !initialPlan.canApply)) { @@ -903,7 +845,7 @@ export async function pushWorkerProject( ); if (missing.length) { throw new WorkerPushError( - "Worker prerequisites were saved, but required Secrets are missing; build and deployment were not started", + "Worker prerequisites were saved, but required Secrets are missing; the validated local build was not uploaded or deployed", { workerId: workerState.id, missingSecrets: missing, @@ -914,12 +856,6 @@ export async function pushWorkerProject( }, ); } - await (options.runBuild || defaultRunBuild)( - linkedProject.config.build.command, - linkedProject.rootDir, - ); - const bundle = await validateBundle(linkedProject); - validateNativeDeploymentMetadata(bundle, compatibility, linkedProject.config.environments.preview.resources); const artifact = await ensureArtifact( api, options.clientOptions, @@ -968,6 +904,12 @@ export async function pushWorkerProject( ); const publicUrl = text(environmentOf(finalWorker, "preview").publicUrl)!; const finalEnvironment = environmentOf(finalWorker, "preview"); + const inspection = await inspectWorker({ + workerId: workerState.id, + environment: "preview", + clientOptions: options.clientOptions, + client: api, + }); return { schemaVersion: 1, status: "ACTIVE", @@ -996,7 +938,9 @@ export async function pushWorkerProject( publicBasePath: text(finalEnvironment.publicBasePath), } } : {}), health, + inspection, commands: { + inspect: `xapi workers inspect ${workerState.id} --env preview`, logs: `xapi workers logs ${workerState.id} --env preview`, promote: "xapi workers promote --to production", }, From 7cb7eb87697ff49b542ae66d87369945df3fd25c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 02:10:49 +0000 Subject: [PATCH 11/28] chore(main): release 0.1.23 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 18 ++++++++++++++++++ package.json | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 2f62a69..4f8c6fb 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.1.22" + ".": "0.1.23" } diff --git a/CHANGELOG.md b/CHANGELOG.md index ba1adc1..1655d72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [0.1.23](https://github.com/xapi-labs/xapi-cli/compare/v0.1.22...v0.1.23) (2026-09-21) + + +### Features + +* **skill:** bundle CLI-native provider workflows ([#29](https://github.com/xapi-labs/xapi-cli/issues/29)) ([fe1551a](https://github.com/xapi-labs/xapi-cli/commit/fe1551a3b302cef584154b61f3c45da10267a353)) +* **workers:** add managed project deployment workflows ([e002970](https://github.com/xapi-labs/xapi-cli/commit/e0029701152c9ad73277bef9e0750284e03c884d)) +* **workers:** add read-only environment inspection ([f5e7245](https://github.com/xapi-labs/xapi-cli/commit/f5e7245e7bc8b01f62a1a9c2669ea1dbf9c48c9d)) +* **workers:** add safe secret management workflows ([#32](https://github.com/xapi-labs/xapi-cli/issues/32)) ([0f0ec1b](https://github.com/xapi-labs/xapi-cli/commit/0f0ec1b498a012f35e6b5133fde2ec4c346c9ad6)) +* **workers:** make deployment plans exact ([45aeac2](https://github.com/xapi-labs/xapi-cli/commit/45aeac24d2081b1496bc882fa27a313ab9f21250)) +* **workers:** preserve native deployments and exact plans ([3b83be2](https://github.com/xapi-labs/xapi-cli/commit/3b83be2ac19061b53dbb1869a8a0061dbb5d9acf)) + + +### Bug Fixes + +* **workers:** clarify project deployment commands ([555dcb4](https://github.com/xapi-labs/xapi-cli/commit/555dcb4ff88d1633bf23d6709a56abb30f187104)) +* **workers:** preserve native deployment intent during import ([74148a4](https://github.com/xapi-labs/xapi-cli/commit/74148a48351772a54457fca143d0eaa87f73db7b)) + ## [0.1.22](https://github.com/xapi-labs/xapi-cli/compare/v0.1.21...v0.1.22) (2026-09-17) diff --git a/package.json b/package.json index 0083e63..1e63339 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "xapi-to", - "version": "0.1.22", + "version": "0.1.23", "description": "Agent-friendly CLI for xapi - discover and call capabilities and APIs", "type": "module", "bin": { From ac26d51de6ab9a9e187a668b5d45182508c62228 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E9=9B=84=E5=91=80?= <47734376+dxiongya@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:05:47 +0800 Subject: [PATCH 12/28] docs(skill): document Workers domain conflict recovery (#36) --- skills/xapi-workers/SKILL.md | 1 + .../references/domain-conflict-recovery.md | 54 +++++++++++++++++++ src/tests/skill-workers-guide.test.ts | 20 +++++++ 3 files changed, 75 insertions(+) create mode 100644 skills/xapi-workers/references/domain-conflict-recovery.md diff --git a/skills/xapi-workers/SKILL.md b/skills/xapi-workers/SKILL.md index a24885c..207dfd3 100644 --- a/skills/xapi-workers/SKILL.md +++ b/skills/xapi-workers/SKILL.md @@ -22,6 +22,7 @@ Use the `xapi` CLI (`xapi-to` is the same executable). Verify `xapi workers --he - **How much did it cost?** Read [billing.md](references/billing.md) before answering, collecting, or reconciling consumption. - **Pause/recover/delete/refund:** read [lifecycle.md](references/lifecycle.md) before lifecycle mutations. - **Buy or bind an xdomain domain:** read [domains.md](references/domains.md). Use the combined CLI command; do not manually create a CNAME to the Dispatcher or expose Cloudflare zone IDs. +- **Recover a domain-conflict quarantine (administrator only):** read [domain-conflict-recovery.md](references/domain-conflict-recovery.md). This is a backend recovery operation, not a customer deployment command. ## Evidence and completion diff --git a/skills/xapi-workers/references/domain-conflict-recovery.md b/skills/xapi-workers/references/domain-conflict-recovery.md new file mode 100644 index 0000000..41ab278 --- /dev/null +++ b/skills/xapi-workers/references/domain-conflict-recovery.md @@ -0,0 +1,54 @@ +# Administrator domain-conflict recovery + +Use this procedure only when a Worker environment was quarantined after a +custom-domain attach returned Cloudflare HTTP `409` with provider error +`100117` (`DOMAIN_ALREADY_BOUND`). This is an administrator recovery contract, +not a normal customer deployment command. + +## Endpoint + +```text +POST /api/admin/workers/domain-conflict-recovery +Authorization: Bearer +Content-Type: application/json +``` + +The endpoint uses the backend's existing administrator authentication and +authorization. Do not create a temporary token, put a Cloudflare token in the +request, or send an xAPI key to the Worker hostname. The caller must already be +an xAPI platform administrator. + +Request the exact identifiers and revision from the backend audit record: + +```json +{ + "workerId": "", + "environmentId": "", + "domainId": "", + "hostname": "app.example.com", + "expectedRevision": 236, + "expectedProviderStatus": 409, + "expectedProviderCode": 100117 +} +``` + +## Preconditions and result + +xAPI checks that the environment is the requested Worker, the legacy writer is +the one-step custom-domain `PUT` that recorded `409 / 100117`, and the +`domainProvision` scope in that writer matches the submitted domain, hostname, +zone, and dispatch service. It also verifies that Cloudflare shows another +service owning the hostname and that the xAPI dispatch service does not own it. + +The platform route KV must be absent, or must point to the failed script and be +safe to remove. If ownership evidence is missing, ambiguous, or belongs to +another xAPI script, recovery fails closed. + +Successful recovery clears only the failed legacy writer, returns the +environment to `LEGACY`, records the local domain as `ERROR` with +`worker_domain_conflict`, and writes an audit record. It does not detach or +delete the binding owned by another Cloudflare service. The user must resolve +the hostname conflict separately before trying another bind. + +After recovery, verify the returned state, the audit event +`control.legacy_domain_conflict_recovered`, and the unchanged external binding. diff --git a/src/tests/skill-workers-guide.test.ts b/src/tests/skill-workers-guide.test.ts index a776d82..1dd62df 100644 --- a/src/tests/skill-workers-guide.test.ts +++ b/src/tests/skill-workers-guide.test.ts @@ -25,6 +25,13 @@ const domainGuide = readFileSync( new URL('../../skills/xapi-workers/references/domains.md', import.meta.url), 'utf8', ); +const domainConflictRecovery = readFileSync( + new URL( + '../../skills/xapi-workers/references/domain-conflict-recovery.md', + import.meta.url, + ), + 'utf8', +); describe('bundled xAPI Workers skill guide', () => { it('routes hosted Worker tasks to the progressively loaded guide', () => { @@ -46,6 +53,19 @@ describe('bundled xAPI Workers skill guide', () => { expect(domainGuide).not.toContain('wrangler deploy'); }); + it('documents the administrator-only domain conflict recovery contract', () => { + expect(dedicatedSkill).toContain( + '[domain-conflict-recovery.md](references/domain-conflict-recovery.md)', + ); + expect(domainConflictRecovery).toContain( + 'POST /api/admin/workers/domain-conflict-recovery', + ); + expect(domainConflictRecovery).toContain('100117'); + expect(domainConflictRecovery).toContain('platform administrator'); + expect(domainConflictRecovery).toContain('fails closed'); + expect(domainConflictRecovery).toContain('does not detach or'); + }); + it('prefers project deployment and covers import, CI, recovery, and rollback boundaries', () => { expect(guide).toContain('xapi workers templates'); expect(guide).toContain('xapi workers init my-agent --template persistent-agent'); From a9e10276136cd9dd7f44b9c71919dd95e55db86a Mon Sep 17 00:00:00 2001 From: daxiongya Date: Sun, 20 Sep 2026 23:22:41 +0800 Subject: [PATCH 13/28] feat(workers): support native project placement (cherry picked from commit e25116cfdff42c2ce716d20ae25dede645ac62a1) --- README.md | 23 ++++++- schemas/worker-project.v1.schema.json | 8 +++ skills/xapi-workers/SKILL.md | 2 + skills/xapi-workers/references/deployment.md | 8 ++- skills/xapi/guides/workers.md | 38 ++++++++---- src/commands/workers.ts | 48 +++++++++++++++ src/tests/workers-client.test.ts | 40 +++++++++++- src/tests/workers-init.test.ts | 19 ++++++ src/tests/workers-plan.test.ts | 38 ++++++++++++ src/tests/workers-project.test.ts | 19 ++++++ src/tests/workers-promote.test.ts | 25 ++++++++ src/tests/workers-push.test.ts | 36 ++++++++++- src/workers-artifact.ts | 12 ++-- src/workers-client.ts | 65 ++++++++++++++++---- src/workers-framework-init.ts | 6 ++ src/workers-init.ts | 12 ++++ src/workers-plan-output.ts | 1 + src/workers-plan.ts | 41 ++++++++++-- src/workers-project.ts | 2 + src/workers-promote.ts | 32 +++++++++- src/workers-push.ts | 36 +++++++---- src/workers-wrangler-import.ts | 6 ++ 22 files changed, 460 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 9029533..90f5043 100644 --- a/README.md +++ b/README.md @@ -533,9 +533,26 @@ through xAPI as Cloudflare native static assets: `workers plan` shows whether the selected environment has a dedicated hostname. When `webAppReady` is false, production promotion asks you to review the base path, root-relative routes, and OAuth callbacks without blocking applications -that deliberately support path-prefix hosting. The current JSON Artifact -transport accepts 12 MiB of decoded Worker modules and static assets per -deployment. +that deliberately support path-prefix hosting. Project bundles use one +authenticated multipart request: modules and static assets are not uploaded as +independent deployments. Limits are 200 modules / 10 MiB module content, +10,000 assets / 25 MiB per asset, and 100 MiB total decoded project content. + +Environment placement is declared beside the budget. Workers remain globally +deployed; the data location is inherited only by newly created D1/R2 resources, +and Smart Placement lets Cloudflare optimize execution near backends: + +```json +{ + "dailyBudgetUsd": 0.25, + "defaultResourceLocation": "apac", + "placementMode": "smart" +} +``` + +Use `xapi workers environment preview --data-location apac +--placement smart` for an already linked project. This changes the environment +default and deployment metadata; it does not move existing D1/R2 data. Templates are versioned packages shipped with the CLI, not remote code fetched during `init`. `persistent-agent` includes buildable source plus KV, D1, R2, diff --git a/schemas/worker-project.v1.schema.json b/schemas/worker-project.v1.schema.json index 9b3ecf2..21730f3 100644 --- a/schemas/worker-project.v1.schema.json +++ b/schemas/worker-project.v1.schema.json @@ -101,6 +101,14 @@ "required": ["dailyBudgetUsd"], "properties": { "dailyBudgetUsd": { "type": "number", "minimum": 0.1, "maximum": 100 }, + "defaultResourceLocation": { + "type": "string", + "enum": ["wnam", "enam", "weur", "eeur", "apac", "oc"] + }, + "placementMode": { + "type": "string", + "enum": ["off", "smart"] + }, "healthCheck": { "type": "string", "pattern": "^/(?!/)[^\\s]*$", diff --git a/skills/xapi-workers/SKILL.md b/skills/xapi-workers/SKILL.md index 207dfd3..af9bffe 100644 --- a/skills/xapi-workers/SKILL.md +++ b/skills/xapi-workers/SKILL.md @@ -13,6 +13,8 @@ Use the `xapi` CLI (`xapi-to` is the same executable). Verify `xapi workers --he - Authentication precedence: `XAPI_KEY`, `XAPI_API_KEY`, then `~/.xapi/config.json`. Keys need `workers:read` and, for changes, `workers:write`, plus access to the target Worker. A scoped-out Worker can return 404. - Production API host is `api.xapi.to`; testing uses `XAPI_API_HOST=api.test.xapi.to` (host only). Load secrets from the user's existing secure environment. Never print keys, include them in code/artifacts, or send the xAPI key to a public Worker URL or Cloudflare. Runtime application authentication is separate. - Start with `workers inspect [worker-id] --env ` and `workers capabilities`. Use `workers plan --env ` when a local project is available and desired-state drift matters. `inspect` reads current runtime state only. `plan` runs the configured local build with credential-shaped environment variables removed, validates the exact Artifact, and compares it with live state without writing to the xAPI control plane. It also shows the budget cap, active price-book visibility, and usage-dependent resource changes; never present those estimates as an accrued invoice. Use existing user authorization for changes; don't expand cleanup from a test environment to production. +- Treat Worker execution and data placement separately. Worker code remains global. Use environment `defaultResourceLocation` only as the default for newly created D1/R2 resources and `placementMode: smart` only for Cloudflare Smart Placement. Never claim either setting migrates existing data. + ## Load the relevant workflow diff --git a/skills/xapi-workers/references/deployment.md b/skills/xapi-workers/references/deployment.md index 3445272..49d1b6b 100644 --- a/skills/xapi-workers/references/deployment.md +++ b/skills/xapi-workers/references/deployment.md @@ -37,6 +37,12 @@ cd my-service xapi workers plan --env preview ``` +For an APAC-oriented service, initialize or edit the environment desired state +with `defaultResourceLocation: "apac"` and `placementMode: "smart"`. The first +setting applies only when xAPI creates new D1/R2 resources; the second emits +Cloudflare's native Smart Placement metadata on Worker deployment. Workers +remain global, and neither setting moves existing stored data. + Choose the API host explicitly: `api.test.xapi.to` operates test-platform resources; `api.xapi.to` operates production-platform resources. `--env preview` selects a project's preview environment on that host, not the test API. A @@ -67,7 +73,7 @@ npx wrangler deploy --dry-run \ --outfile dist/app.worker.bundle ``` -Set `build.output` to the generated `.worker.bundle`, omit `build.main`, and set `assets.directory` to the generated client directory. `--dry-run` only creates the local Cloudflare upload artifact; `xapi workers push` remains the only publisher. The import report must show every unmapped Wrangler field; never split a framework application into per-file API uploads to work around an import problem. +Set `build.output` to the generated `.worker.bundle`, omit `build.main`, and set `assets.directory` to the generated client directory. `--dry-run` only creates the local Cloudflare upload artifact; `xapi workers push` remains the only publisher. The CLI sends the complete modules/assets set in one authenticated multipart Artifact request. The import report must show every unmapped Wrangler field; never split a framework application into per-file API uploads to work around an import problem. Use the environment's returned `publicUrl` for actual requests. A custom-domain URL needs verified DNS/TLS readiness; do not construct a hostname or infer readiness from the organization name. Use application authentication, never the control-plane key, on this URL. diff --git a/skills/xapi/guides/workers.md b/skills/xapi/guides/workers.md index 9e1611a..8982fc5 100644 --- a/skills/xapi/guides/workers.md +++ b/skills/xapi/guides/workers.md @@ -287,11 +287,12 @@ and must be configured through xAPI schedules. The granular `workers upload` command is artifact-only; use the project `push` workflow for coordinated compatibility, resource, secret and asset handling. -Current xAPI transport limits remain 200 modules / 10 MiB decoded modules and -12 MiB decoded modules plus assets. These are xAPI limits, not a statement of -CF's full native capacity. If exceeded, report the unsupported deployment; -never split a project into unrelated deployments or edit framework output to -work around the limit. +Project publishing uses one authenticated multipart Artifact request, then xAPI +stores an immutable content-addressed manifest. Limits are 200 modules / 10 MiB +decoded module content, 10,000 assets / 25 MiB per asset, and 100 MiB total +decoded project content. These are xAPI limits, not a statement of CF's full +native capacity. If exceeded, report the unsupported deployment; never split a +project into unrelated deployments or edit framework output to work around it. A `PATH_FALLBACK` URL is not a root-hosted Web application URL. Do not rewrite application routes or configure GitHub callbacks against an invented host. @@ -426,12 +427,11 @@ Artifact and the platform completes Cloudflare's native static-assets upload: } ``` -Wrangler imports preserve supported `assets` settings. Cloudflare permits up to -25 MiB per asset and 100,000 assets per version. Asset content stays separate -from Worker modules and is never silently dropped. The current xAPI JSON -Artifact transport accepts at most 12 MiB of decoded modules and assets in one -deployment; split larger sites before upload until the multipart Artifact -transport is available. +Wrangler imports preserve supported `assets` settings. xAPI accepts up to +10,000 assets, 25 MiB per asset, and 100 MiB of decoded project content in one +multipart Artifact request. Asset content stays separate from Worker modules +and is never silently dropped. The backend stores content-addressed blobs and +reassembles the exact immutable bundle for Cloudflare's native asset upload. Save the returned Artifact `id`, then deploy that exact Artifact to preview: @@ -669,4 +669,20 @@ Choose the expected primary data-access region when a project creates D1 or R2. Supported location hints are `wnam`, `enam`, `weur`, `eeur`, `apac`, and `oc`. `readReplication` is D1-only and accepts `auto` or `disabled`. Omitting these fields preserves the existing compatible behavior. +Set an environment default when every newly created D1/R2 resource should use +the same location, and optionally enable Cloudflare Smart Placement: + +```json +{ + "dailyBudgetUsd": 0.25, + "defaultResourceLocation": "apac", + "placementMode": "smart" +} +``` + +The equivalent targeted command is `xapi workers environment +preview --data-location apac --placement smart`. A resource-level `location` +overrides the environment default. Worker code remains globally deployed; +Smart Placement is native Worker execution metadata, not a fixed Worker region. + Location is creation-time placement. Changing it on an existing binding is blocked because Cloudflare cannot move an existing D1 database or R2 bucket in place. Create a new binding, migrate and verify the data, switch the application binding, and retain the old resource for rollback before deleting it. diff --git a/src/commands/workers.ts b/src/commands/workers.ts index 0541b84..d782e31 100644 --- a/src/commands/workers.ts +++ b/src/commands/workers.ts @@ -85,6 +85,7 @@ ADVANCED ARTIFACT PRIMITIVES (custom CI and recovery only) RESOURCES, BILLING, AND LIFECYCLE budget --daily-usd USD + environment [--daily-usd USD] [--data-location apac] [--placement smart] billing-status billing ledger --env ENV [--all] [--snapshot-time ISO] [--json] retention show|quote|accept|pause|resume|keep-paused|delete --env ENV @@ -134,6 +135,8 @@ CREATE FLAGS --description TEXT --preview-budget 0.10..100 Explicit preview daily budget --production-budget 0.10..100 Explicit production daily budget + --data-location REGION Default for new D1/R2: wnam|enam|weur|eeur|apac|oc + --placement MODE Worker execution placement: off|smart INIT FLAGS --template TEMPLATE worker|agent|chat|webhook|persistent-agent @@ -146,6 +149,8 @@ INIT FLAGS --slug SLUG Stable lowercase Worker slug --preview-budget 0.10..100 Default: 0.25 --production-budget 0.10..100 Default: 2 + --data-location REGION Default for newly created D1/R2 resources + --placement off|smart Cloudflare Worker placement metadata --force Overwrite template-managed files only --framework auto|react|vite|vue|next Override existing package detection @@ -295,6 +300,8 @@ FLAGS --slug SLUG --preview-budget USD --production-budget USD + --data-location wnam|enam|weur|eeur|apac|oc + --placement off|smart --force `; @@ -395,6 +402,21 @@ function budget(value: string | undefined, flag: string): number { return amount; } +type WorkerDataLocation = "wnam" | "enam" | "weur" | "eeur" | "apac" | "oc"; + +function dataLocation(value: string | undefined): WorkerDataLocation | undefined { + if (!value) return undefined; + if (!["wnam", "enam", "weur", "eeur", "apac", "oc"].includes(value)) + err("--data-location must be wnam, enam, weur, eeur, apac, or oc"); + return value as WorkerDataLocation; +} + +function placementMode(value: string | undefined): "off" | "smart" | undefined { + if (!value) return undefined; + if (!["off", "smart"].includes(value)) err("--placement must be off or smart"); + return value as "off" | "smart"; +} + function durationMs(value: string | undefined, fallback: number): number { if (!value) return fallback; const match = /^(\d+)(ms|s|m)$/.exec(value); @@ -572,6 +594,8 @@ export async function workersCommand( "production-budget", "force", "framework", + "data-location", + "placement", ]); if (rest.length > 1) { err("usage: xapi-to workers init [directory] [flags]"); @@ -611,6 +635,8 @@ export async function workersCommand( productionDailyBudgetUsd: flags["production-budget"] ? budget(flags["production-budget"], "--production-budget") : 2, + defaultResourceLocation: dataLocation(flags["data-location"]), + placementMode: placementMode(flags.placement), }); } catch (error) { err( @@ -653,6 +679,8 @@ export async function workersCommand( productionDailyBudgetUsd: flags["production-budget"] ? budget(flags["production-budget"], "--production-budget") : 2, + defaultResourceLocation: dataLocation(flags["data-location"]), + placementMode: placementMode(flags.placement), force: flags.force === "true", framework: flags.framework === "true" ? undefined : flags.framework, }), @@ -938,6 +966,8 @@ export async function workersCommand( "template", "preview-budget", "production-budget", + "data-location", + "placement", ]); if (rest.length) err("usage: xapi-to workers create [flags]"); const template = flags.template || "worker"; @@ -957,6 +987,8 @@ export async function workersCommand( flags["production-budget"], "--production-budget", ), + defaultResourceLocation: dataLocation(flags["data-location"]), + placementMode: placementMode(flags.placement), }), ); return; @@ -1086,6 +1118,22 @@ export async function workersCommand( ); return; } + case "environment": { + assertFlags(flags, ["daily-usd", "data-location", "placement"]); + if (rest.length !== 2) + err("usage: xapi-to workers environment [--daily-usd USD] [--data-location REGION] [--placement off|smart]"); + if (!["preview", "production"].includes(rest[1])) err("environment must be preview or production"); + const location = dataLocation(flags["data-location"]); + const placement = placementMode(flags.placement); + if (!flags["daily-usd"] && !location && !placement) + err("provide --daily-usd, --data-location, or --placement"); + output(await client.updateWorkerEnvironment(options(), rest[0], rest[1], { + ...(flags["daily-usd"] ? { dailyBudgetUsd: budget(flags["daily-usd"], "--daily-usd") } : {}), + ...(location ? { defaultResourceLocation: location } : {}), + ...(placement ? { placementMode: placement } : {}), + })); + return; + } case "audit": assertFlags(flags); output( diff --git a/src/tests/workers-client.test.ts b/src/tests/workers-client.test.ts index c24c2d0..9452c00 100644 --- a/src/tests/workers-client.test.ts +++ b/src/tests/workers-client.test.ts @@ -24,6 +24,7 @@ import { workerUsage, workerMeteredUsage, uploadWorkerArtifact, + updateWorkerEnvironment, } from "../workers-client.ts"; const options = { apiHost: "test.xapi.to", apiKey: "sk-test-value" }; @@ -175,10 +176,43 @@ describe("workers client", () => { bundle, idempotencyKey: "showcase-bundle-v1", }); - const [, init] = fetchSpy.mock.calls[0] as any[]; - expect(JSON.parse(init.body)).toEqual({ - bundle, + const [target, init] = fetchSpy.mock.calls[0] as any[]; + expect(target).toBe("https://test.xapi.to/api/v1/workers/worker%2Fid/artifacts/bundle"); + expect(init.body).toBeInstanceOf(FormData); + expect(init.headers["Content-Type"]).toBeUndefined(); + const form = init.body as FormData; + expect(JSON.parse(String(form.get("manifest")))).toEqual({ + version: 2, idempotencyKey: "showcase-bundle-v1", + mainModule: "worker.js", + modules: [ + { path: "worker.js", contentType: "application/javascript+module", fileIndex: 0 }, + { path: "chunk.js", contentType: "application/javascript+module", fileIndex: 1 }, + ], + }); + const files = form.getAll("files") as File[]; + expect(files).toHaveLength(2); + expect(await files[0].text()).toBe('import "./chunk.js"; export default {};'); + expect(await files[1].text()).toBe("export {};"); + }); + + it("updates native environment placement without changing unspecified settings", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ placementMode: "smart" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) as any; + await updateWorkerEnvironment(options, "worker/id", "preview", { + defaultResourceLocation: "apac", + placementMode: "smart", + }); + const [target, init] = fetchSpy.mock.calls[0] as any[]; + expect(target).toBe("https://test.xapi.to/api/v1/workers/worker%2Fid/environments/preview"); + expect(init.method).toBe("PATCH"); + expect(JSON.parse(init.body)).toEqual({ + defaultResourceLocation: "apac", + placementMode: "smart", }); }); diff --git a/src/tests/workers-init.test.ts b/src/tests/workers-init.test.ts index 9d5a2ff..5b9168c 100644 --- a/src/tests/workers-init.test.ts +++ b/src/tests/workers-init.test.ts @@ -231,6 +231,25 @@ describe("workers init", () => { expect(existsSync(join(target, "xapi-worker/index.ts"))).toBe(true); }); + test("writes APAC data defaults and Smart Placement into both environments", () => { + const cwd = workspace(); + initWorkerProject({ + cwd, + target: "apac-worker", + defaultResourceLocation: "apac", + placementMode: "smart", + }); + const project = loadWorkerProject(join(cwd, "apac-worker")); + expect(project.config.environments.preview).toMatchObject({ + defaultResourceLocation: "apac", + placementMode: "smart", + }); + expect(project.config.environments.production).toMatchObject({ + defaultResourceLocation: "apac", + placementMode: "smart", + }); + }); + test("adopts a statically exported Next project and rejects SSR without mutation", () => { const cwd = workspace(); const staticTarget = join(cwd, "next-static"); diff --git a/src/tests/workers-plan.test.ts b/src/tests/workers-plan.test.ts index e291619..98a8b8a 100644 --- a/src/tests/workers-plan.test.ts +++ b/src/tests/workers-plan.test.ts @@ -76,6 +76,44 @@ function unexpected(name: string): () => Promise { } describe("workers plan", () => { + test("shows native environment placement drift before deployment", async () => { + const root = project({ linked: true }); + const path = join(root, "xapi.worker.json"); + const config = JSON.parse(readFileSync(path, "utf8")); + config.environments.preview.defaultResourceLocation = "apac"; + config.environments.preview.placementMode = "smart"; + config.environments.preview.resources = []; + config.environments.preview.secrets = []; + writeFileSync(path, JSON.stringify(config)); + const plan = await createWorkerPlan({ + cwd: root, + environment: "preview", + clientOptions: { apiHost: "localhost:3003", apiKey: "test-key" }, + client: { + listWorkers: unexpected("listWorkers"), + getWorker: async () => ({ + id: workerId, + slug: "plan-agent", + environments: [{ + id: "env-preview", + name: "PREVIEW", + dailyBudgetUsd: 0.25, + placementMode: "off", + }], + artifacts: [], + deployments: [], + }), + listWorkerResources: async () => [], + listWorkerSecrets: async () => [], + }, + }); + expect(plan.actions).toContainEqual(expect.objectContaining({ + operation: "UPDATE", + kind: "placement", + desired: { defaultResourceLocation: "apac", placementMode: "smart" }, + })); + }); + test("builds and validates the exact Artifact before presenting the final plan", async () => { const root = project(); const events: string[] = []; diff --git a/src/tests/workers-project.test.ts b/src/tests/workers-project.test.ts index 7ca4a93..e16ebf9 100644 --- a/src/tests/workers-project.test.ts +++ b/src/tests/workers-project.test.ts @@ -68,6 +68,8 @@ describe("Worker project configuration", () => { "auto", "disabled", ]); + expect(schema.$defs.environment.properties.defaultResourceLocation.enum).toContain("apac"); + expect(schema.$defs.environment.properties.placementMode.enum).toEqual(["off", "smart"]); }); test("discovers the project config from a nested directory", () => { @@ -134,6 +136,23 @@ describe("Worker project configuration", () => { }); }); + test("accepts native environment data location and Smart Placement", () => { + const root = fixture({ + environments: { + preview: { + dailyBudgetUsd: 0.25, + defaultResourceLocation: "apac", + placementMode: "smart", + }, + production: { dailyBudgetUsd: 2, placementMode: "off" }, + }, + }); + expect(loadWorkerProject(root).config.environments.preview).toMatchObject({ + defaultResourceLocation: "apac", + placementMode: "smart", + }); + }); + test("accepts D1 and R2 placement and rejects it on unrelated resources", () => { const validRoot = fixture({ environments: { diff --git a/src/tests/workers-promote.test.ts b/src/tests/workers-promote.test.ts index 10d39b7..47d6755 100644 --- a/src/tests/workers-promote.test.ts +++ b/src/tests/workers-promote.test.ts @@ -89,6 +89,8 @@ function fakePlatform( secrets?: string[]; failDeployOnce?: boolean; webAppReady?: boolean; + defaultResourceLocation?: string; + placementMode?: string; } = {}, ) { const previewDeployments: Array> = [ @@ -148,6 +150,8 @@ function fakePlatform( dailyBudgetUsd: options.budget ?? 2, publicUrl: "https://agent.example.test/w/ref/production", webAppReady: options.webAppReady, + defaultResourceLocation: options.defaultResourceLocation, + placementMode: options.placementMode, }, ], artifacts, @@ -189,6 +193,27 @@ function fakePlatform( } describe("workers promote", () => { + test("blocks production promotion until declared placement matches", async () => { + const root = fixture(); + const path = join(root, "xapi.worker.json"); + const config = JSON.parse(await Bun.file(path).text()); + config.environments.production.defaultResourceLocation = "apac"; + config.environments.production.placementMode = "smart"; + writeFileSync(path, JSON.stringify(config)); + const prepared = await createWorkerPromotionPlan({ + cwd: root, + to: "production", + clientOptions: { apiHost: "localhost:3003", apiKey: "test-key" }, + client: fakePlatform({ placementMode: "off" }).client, + }); + expect(prepared.plan.canPromote).toBe(false); + expect(prepared.plan.production.checks).toContainEqual(expect.objectContaining({ + status: "BLOCKED", + kind: "placement", + command: expect.stringContaining("--data-location apac --placement smart"), + })); + }); + test("promotes the exact latest ACTIVE preview Artifact, waits, health-checks, and repeats safely", async () => { const root = fixture(); const platform = fakePlatform({ failDeployOnce: true }); diff --git a/src/tests/workers-push.test.ts b/src/tests/workers-push.test.ts index cec139b..e604b30 100644 --- a/src/tests/workers-push.test.ts +++ b/src/tests/workers-push.test.ts @@ -170,7 +170,7 @@ function fakePlatform( } return snapshot(); }, - updateWorkerBudget: async () => { + updateWorkerEnvironment: async () => { calls.updateBudget += 1; return { dailyBudgetUsd: 0.25 }; }, @@ -575,6 +575,40 @@ writeFileSync("observed-key.txt", process.env.XAPI_KEY || ""); expect(platform.calls.deploy).toBe(1); }); + test("applies declared environment placement before preview deployment", async () => { + const root = fixture({ linked: true }); + const path = join(root, "xapi.worker.json"); + const config = JSON.parse(readFileSync(path, "utf8")); + config.environments.preview.defaultResourceLocation = "apac"; + config.environments.preview.placementMode = "smart"; + writeFileSync(path, JSON.stringify(config)); + const platform = fakePlatform({ exists: true }); + let update: Record | undefined; + platform.client.updateWorkerEnvironment = async (_options, _id, _environment, input) => { + update = input; + return input; + }; + await pushWorkerProject({ + cwd: root, + environment: "preview", + clientOptions: { apiHost: "localhost:3003", apiKey: "test-key" }, + client: platform.client, + confirm: async () => true, + runBuild: async () => { + mkdirSync(join(root, "dist"), { recursive: true }); + writeFileSync(join(root, "dist/worker.mjs"), "export default {};"); + }, + fetchPublic: (async () => + Response.json({ ok: true })) as unknown as typeof fetch, + sleep: async () => undefined, + }); + expect(update).toEqual({ + dailyBudgetUsd: 0.25, + defaultResourceLocation: "apac", + placementMode: "smart", + }); + }); + test("reconciles an HTTP 500 and safely retries Artifact and Deployment writes with the same idempotency key", async () => { const root = fixture({ linked: true }); const platform = fakePlatform({ exists: true }); diff --git a/src/workers-artifact.ts b/src/workers-artifact.ts index 954b57d..bf9fa19 100644 --- a/src/workers-artifact.ts +++ b/src/workers-artifact.ts @@ -13,11 +13,11 @@ import { parse } from "acorn"; const MAX_LEGACY_ARTIFACT_BYTES = 1024 * 1024; const MAX_BUNDLE_CONTENT_BYTES = 10 * 1024 * 1024; const MAX_BUNDLE_MODULES = 200; -const MAX_ASSET_FILES = 100_000; +const MAX_ASSET_FILES = 10_000; const MAX_ASSET_FILE_BYTES = 25 * 1024 * 1024; -// The current xAPI JSON Artifact endpoint has a 20 MiB request-body ceiling. -// Base64 expansion leaves 12 MiB for decoded Worker modules plus assets. -const MAX_XAPI_ARTIFACT_CONTENT_BYTES = 12 * 1024 * 1024; +// Complete projects use one multipart binary request and content-addressed +// server storage. This is an xAPI project quota, not Cloudflare's account cap. +const MAX_XAPI_ARTIFACT_CONTENT_BYTES = 100 * 1024 * 1024; const SAFE_MODULE_PATH = /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._@+~/-]{1,240}$/; @@ -239,7 +239,7 @@ function collectAssetFiles(input: WorkerStaticAssetsInput): WorkerArtifactAssets if (info.size > MAX_ASSET_FILE_BYTES) throw new WorkerArtifactError(`Static asset exceeds Cloudflare's 25 MiB per-file limit: ${relativePath}`); const bytes = readFileSync(absolute); files.push({ path: `/${relativePath}`, content: bytes.toString("base64"), encoding: "base64", contentType: assetContentType(relativePath) }); - if (files.length > MAX_ASSET_FILES) throw new WorkerArtifactError(`Static assets exceed Cloudflare's ${MAX_ASSET_FILES} file limit`); + if (files.length > MAX_ASSET_FILES) throw new WorkerArtifactError(`Static assets exceed xAPI's ${MAX_ASSET_FILES} file limit`); } }; walk(root); @@ -272,7 +272,7 @@ function assertArtifactContentLimit(bundle: WorkerArtifactBundle): void { ) || 0; if (moduleBytes + assetBytes > MAX_XAPI_ARTIFACT_CONTENT_BYTES) { throw new WorkerArtifactError( - "Worker modules and static assets exceed the current xAPI Artifact transport limit of 12 MiB", + "Worker modules and static assets exceed the xAPI project limit of 100 MiB", ); } } diff --git a/src/workers-client.ts b/src/workers-client.ts index d2fc3b0..1502036 100644 --- a/src/workers-client.ts +++ b/src/workers-client.ts @@ -129,6 +129,43 @@ export function uploadWorkerArtifact( id: string, input: WorkerArtifactUploadRequest, ) { + if ("bundle" in input) { + const form = new FormData(); + const files: Blob[] = []; + const file = (content: string, encoding: "utf8" | "base64", type: string) => { + const bytes = Buffer.from(content, encoding === "base64" ? "base64" : "utf8"); + const index = files.length; + files.push(new Blob([bytes], { type })); + return index; + }; + const manifest = { + version: 2, + idempotencyKey: input.idempotencyKey, + mainModule: input.bundle.mainModule, + modules: input.bundle.modules.map((module) => ({ + path: module.path, + contentType: module.contentType, + fileIndex: file(module.content, module.encoding, module.contentType), + })), + ...(input.bundle.observability ? { observability: input.bundle.observability } : {}), + ...(input.bundle.assets ? { assets: { + files: input.bundle.assets.files.map((asset) => ({ + path: asset.path, + contentType: asset.contentType, + fileIndex: file(asset.content, "base64", asset.contentType), + })), + ...(input.bundle.assets.binding ? { binding: input.bundle.assets.binding } : {}), + ...(input.bundle.assets.config ? { config: input.bundle.assets.config } : {}), + } } : {}), + }; + form.append("manifest", JSON.stringify(manifest)); + files.forEach((blob, index) => form.append("files", blob, `artifact-${index}`)); + return request( + url(options, `/${encodeURIComponent(id)}/artifacts/bundle`), + { method: "POST", headers: headers(options), body: form }, + 180_000, + ); + } return request( url(options, `/${encodeURIComponent(id)}/artifacts`), { @@ -140,6 +177,22 @@ export function uploadWorkerArtifact( ); } +export function updateWorkerEnvironment( + options: WorkersClientOptions, + id: string, + environment: string, + input: { + dailyBudgetUsd?: number; + defaultResourceLocation?: "wnam" | "enam" | "weur" | "eeur" | "apac" | "oc"; + placementMode?: "off" | "smart"; + }, +) { + return request( + url(options, `/${encodeURIComponent(id)}/environments/${encodeURIComponent(environment)}`), + { method: "PATCH", headers: headers(options, true), body: JSON.stringify(input) }, + ); +} + export function listWorkerBuilds(options: WorkersClientOptions, id: string) { return request( url(options, `/${encodeURIComponent(id)}/builds`), @@ -195,17 +248,7 @@ export function updateWorkerBudget( environment: string, dailyBudgetUsd: number, ) { - return request( - url( - options, - `/${encodeURIComponent(id)}/environments/${encodeURIComponent(environment)}`, - ), - { - method: "PATCH", - headers: headers(options, true), - body: JSON.stringify({ dailyBudgetUsd }), - }, - ); + return updateWorkerEnvironment(options, id, environment, { dailyBudgetUsd }); } export function deleteWorker(options: WorkersClientOptions, id: string) { diff --git a/src/workers-framework-init.ts b/src/workers-framework-init.ts index dbb4b10..018b4a6 100644 --- a/src/workers-framework-init.ts +++ b/src/workers-framework-init.ts @@ -35,6 +35,8 @@ export interface InitExistingFrameworkOptions { compatibilityDate: string; previewDailyBudgetUsd: number; productionDailyBudgetUsd: number; + defaultResourceLocation?: "wnam" | "enam" | "weur" | "eeur" | "apac" | "oc"; + placementMode?: "off" | "smart"; framework?: string; } @@ -284,12 +286,16 @@ export function initExistingFrameworkProject( environments: { preview: { dailyBudgetUsd: options.previewDailyBudgetUsd, + ...(options.defaultResourceLocation ? { defaultResourceLocation: options.defaultResourceLocation } : {}), + ...(options.placementMode ? { placementMode: options.placementMode } : {}), healthCheck: "/health", resources: [], secrets: [], }, production: { dailyBudgetUsd: options.productionDailyBudgetUsd, + ...(options.defaultResourceLocation ? { defaultResourceLocation: options.defaultResourceLocation } : {}), + ...(options.placementMode ? { placementMode: options.placementMode } : {}), healthCheck: "/health", resources: [], secrets: [], diff --git a/src/workers-init.ts b/src/workers-init.ts index 7497528..0c98a57 100644 --- a/src/workers-init.ts +++ b/src/workers-init.ts @@ -36,6 +36,8 @@ export interface InitWorkerProjectOptions { slug?: string; previewDailyBudgetUsd?: number; productionDailyBudgetUsd?: number; + defaultResourceLocation?: "wnam" | "enam" | "weur" | "eeur" | "apac" | "oc"; + placementMode?: "off" | "smart"; force?: boolean; compatibilityDate?: string; framework?: string; @@ -132,6 +134,8 @@ function projectFiles( previewDailyBudgetUsd: number, productionDailyBudgetUsd: number, compatibilityDate: string, + defaultResourceLocation?: "wnam" | "enam" | "weur" | "eeur" | "apac" | "oc", + placementMode?: "off" | "smart", ): Record { const config: WorkerProjectConfig = { $schema: WORKER_PROJECT_SCHEMA_URL, @@ -147,12 +151,16 @@ function projectFiles( environments: { preview: { dailyBudgetUsd: previewDailyBudgetUsd, + ...(defaultResourceLocation ? { defaultResourceLocation } : {}), + ...(placementMode ? { placementMode } : {}), healthCheck: "/health", resources: template.defaultResources, secrets: template.defaultSecrets, }, production: { dailyBudgetUsd: productionDailyBudgetUsd, + ...(defaultResourceLocation ? { defaultResourceLocation } : {}), + ...(placementMode ? { placementMode } : {}), healthCheck: "/health", resources: template.defaultResources, secrets: template.defaultSecrets, @@ -274,6 +282,8 @@ export function initWorkerProject( compatibilityDate, previewDailyBudgetUsd, productionDailyBudgetUsd, + defaultResourceLocation: options.defaultResourceLocation, + placementMode: options.placementMode, framework: options.framework, }); return { @@ -302,6 +312,8 @@ export function initWorkerProject( previewDailyBudgetUsd, productionDailyBudgetUsd, compatibilityDate, + options.defaultResourceLocation, + options.placementMode, ); const managedFiles = [...COMMON_MANAGED_FILES, ...template.files.map((file) => file.target)]; diff --git a/src/workers-plan-output.ts b/src/workers-plan-output.ts index 8d81b9d..5aef9a8 100644 --- a/src/workers-plan-output.ts +++ b/src/workers-plan-output.ts @@ -8,6 +8,7 @@ const RULE = "─".repeat(72); const KIND_LABEL: Record = { worker: "Worker", budget: "Budget", + placement: "Placement", resource: "Resource", secret: "Secret", routing: "Routing", diff --git a/src/workers-plan.ts b/src/workers-plan.ts index 93c0455..1c68ea4 100644 --- a/src/workers-plan.ts +++ b/src/workers-plan.ts @@ -31,6 +31,7 @@ export type WorkerPlanOperation = export type WorkerPlanKind = | "worker" | "budget" + | "placement" | "resource" | "secret" | "routing" @@ -125,11 +126,12 @@ type DesiredResource = const KIND_ORDER: Record = { worker: 0, budget: 1, - resource: 2, - secret: 3, - routing: 4, - artifact: 5, - deployment: 6, + placement: 2, + resource: 3, + secret: 4, + routing: 5, + artifact: 6, + deployment: 7, }; function record(value: unknown): UnknownRecord | undefined { @@ -817,6 +819,35 @@ export async function createWorkerPlan( ); } + const desiredPlacement = { + ...(desired.defaultResourceLocation ? { defaultResourceLocation: desired.defaultResourceLocation } : {}), + ...(desired.placementMode ? { placementMode: desired.placementMode } : {}), + }; + if (Object.keys(desiredPlacement).length) { + const currentPlacement = { + ...(string(remoteEnvironmentState?.defaultResourceLocation) + ? { defaultResourceLocation: string(remoteEnvironmentState?.defaultResourceLocation) } + : {}), + placementMode: string(remoteEnvironmentState?.placementMode) || "off", + }; + const matches = + (!desired.defaultResourceLocation || currentPlacement.defaultResourceLocation === desired.defaultResourceLocation) && + (!desired.placementMode || currentPlacement.placementMode === desired.placementMode); + add( + actions, + remoteEnvironmentState ? (matches ? "NO_CHANGE" : "UPDATE") : "CREATE", + "placement", + options.environment, + remoteEnvironmentState + ? matches + ? "Environment placement already matches desired state" + : "Update native Cloudflare environment placement before deployment" + : "Set native Cloudflare environment placement during Worker creation", + desiredPlacement, + remoteEnvironmentState ? currentPlacement : undefined, + ); + } + prerequisiteBlocked = compareResources(actions, desired.resources, remoteResources, options.environment) || prerequisiteBlocked; diff --git a/src/workers-project.ts b/src/workers-project.ts index 05a23f4..2142a6a 100644 --- a/src/workers-project.ts +++ b/src/workers-project.ts @@ -116,6 +116,8 @@ const desiredSecretsSchema = z const environmentSchema = z .object({ dailyBudgetUsd: z.number().min(0.1).max(100), + defaultResourceLocation: z.enum(["wnam", "enam", "weur", "eeur", "apac", "oc"]).optional(), + placementMode: z.enum(["off", "smart"]).optional(), healthCheck: z .string() .max(500) diff --git a/src/workers-promote.ts b/src/workers-promote.ts index b5bfaeb..06d5ba9 100644 --- a/src/workers-promote.ts +++ b/src/workers-promote.ts @@ -36,7 +36,7 @@ export type PromotionCheckStatus = export interface WorkerPromotionCheck { status: PromotionCheckStatus; - kind: "budget" | "resource" | "secret" | "routing"; + kind: "budget" | "placement" | "resource" | "secret" | "routing"; key: string; message: string; command?: string; @@ -185,6 +185,32 @@ function productionChecks( }); } + const currentDefaultLocation = text(remoteEnvironment.defaultResourceLocation); + const currentPlacementMode = text(remoteEnvironment.placementMode) || "off"; + const placementMismatch = + (desired.defaultResourceLocation && currentDefaultLocation !== desired.defaultResourceLocation) || + (desired.placementMode && currentPlacementMode !== desired.placementMode); + if (placementMismatch) { + const flags = [ + desired.defaultResourceLocation ? `--data-location ${desired.defaultResourceLocation}` : "", + desired.placementMode ? `--placement ${desired.placementMode}` : "", + ].filter(Boolean).join(" "); + checks.push({ + status: "BLOCKED", + kind: "placement", + key: "production", + message: "Production environment placement differs from desired state", + command: `xapi workers environment ${workerId} production ${flags}`, + }); + } else if (desired.defaultResourceLocation || desired.placementMode) { + checks.push({ + status: "NO_CHANGE", + kind: "placement", + key: "production", + message: "Production environment placement matches desired state", + }); + } + const remoteResources = new Map(); for (const resource of resources) { const bindingName = text(resource.bindingName); @@ -286,8 +312,8 @@ function productionChecks( } checks.sort( (a, b) => - ({ routing: 0, budget: 1, resource: 2, secret: 3 })[a.kind] - - { routing: 0, budget: 1, resource: 2, secret: 3 }[b.kind] || + ({ routing: 0, budget: 1, placement: 2, resource: 3, secret: 4 })[a.kind] - + { routing: 0, budget: 1, placement: 2, resource: 3, secret: 4 }[b.kind] || a.key.localeCompare(b.key), ); return { checks, dataRisk: dataRisk.sort() }; diff --git a/src/workers-push.ts b/src/workers-push.ts index 590444d..61feab0 100644 --- a/src/workers-push.ts +++ b/src/workers-push.ts @@ -72,11 +72,11 @@ export interface PushClient extends PlanClient, DeploymentClient { options: WorkersClientOptions, input: Record, ): Promise; - updateWorkerBudget( + updateWorkerEnvironment( options: WorkersClientOptions, id: string, environment: string, - dailyBudgetUsd: number, + input: { dailyBudgetUsd?: number; defaultResourceLocation?: string; placementMode?: string }, ): Promise; createWorkerResource( options: WorkersClientOptions, @@ -333,6 +333,12 @@ async function ensureWorker( previewDailyBudgetUsd: desired.environments.preview.dailyBudgetUsd, productionDailyBudgetUsd: desired.environments.production.dailyBudgetUsd, + ...(desired.environments.preview.defaultResourceLocation + ? { defaultResourceLocation: desired.environments.preview.defaultResourceLocation } + : {}), + ...(desired.environments.preview.placementMode + ? { placementMode: desired.environments.preview.placementMode } + : {}), }), "created Worker", ); @@ -353,28 +359,32 @@ async function ensureWorker( return { worker: created, id, created: true }; } -async function ensureBudget( +async function ensureEnvironment( api: PushClient, options: WorkersClientOptions, workerId: string, - desired: number, + desired: LoadedWorkerProject["config"]["environments"]["preview"], ): Promise { let worker = record(await api.getWorker(options, workerId), "Worker"); let environment = environmentOf(worker, "preview"); - if ( - Math.abs((amount(environment.dailyBudgetUsd) ?? NaN) - desired) <= 0.00005 - ) { + const matches = () => + Math.abs((amount(environment.dailyBudgetUsd) ?? NaN) - desired.dailyBudgetUsd) <= 0.00005 && + (!desired.defaultResourceLocation || text(environment.defaultResourceLocation) === desired.defaultResourceLocation) && + (!desired.placementMode || (text(environment.placementMode) || "off") === desired.placementMode); + if (matches()) { return; } try { - await api.updateWorkerBudget(options, workerId, "preview", desired); + await api.updateWorkerEnvironment(options, workerId, "preview", { + dailyBudgetUsd: desired.dailyBudgetUsd, + ...(desired.defaultResourceLocation ? { defaultResourceLocation: desired.defaultResourceLocation } : {}), + ...(desired.placementMode ? { placementMode: desired.placementMode } : {}), + }); } catch (error) { if (!shouldReconcileWrite(error)) throw error; worker = record(await api.getWorker(options, workerId), "Worker"); environment = environmentOf(worker, "preview"); - if ( - Math.abs((amount(environment.dailyBudgetUsd) ?? NaN) - desired) > 0.00005 - ) { + if (!matches()) { throw error; } } @@ -823,11 +833,11 @@ export async function pushWorkerProject( } const linkedProject = loadWorkerProject(project.rootDir, project.configPath); try { - await ensureBudget( + await ensureEnvironment( api, options.clientOptions, workerState.id, - linkedProject.config.environments.preview.dailyBudgetUsd, + linkedProject.config.environments.preview, ); const resources = await ensureManagedResources( api, diff --git a/src/workers-wrangler-import.ts b/src/workers-wrangler-import.ts index 96dad0a..19aeccc 100644 --- a/src/workers-wrangler-import.ts +++ b/src/workers-wrangler-import.ts @@ -58,6 +58,8 @@ export interface ImportWranglerProjectOptions { buildMain?: string; previewDailyBudgetUsd?: number; productionDailyBudgetUsd?: number; + defaultResourceLocation?: "wnam" | "enam" | "weur" | "eeur" | "apac" | "oc"; + placementMode?: "off" | "smart"; } export interface ImportWranglerProjectResult { @@ -947,12 +949,16 @@ export function importWranglerProject( environments: { preview: { dailyBudgetUsd: budget(options.previewDailyBudgetUsd, "preview"), + ...(options.defaultResourceLocation ? { defaultResourceLocation: options.defaultResourceLocation } : {}), + ...(options.placementMode ? { placementMode: options.placementMode } : {}), healthCheck: "/health", resources: previewResources, secrets: previewSecrets, }, production: { dailyBudgetUsd: budget(options.productionDailyBudgetUsd, "production"), + ...(options.defaultResourceLocation ? { defaultResourceLocation: options.defaultResourceLocation } : {}), + ...(options.placementMode ? { placementMode: options.placementMode } : {}), healthCheck: "/health", resources: productionResources, secrets: productionSecrets, From d73d1e868a97c6a570b23ad997efb314ac228775 Mon Sep 17 00:00:00 2001 From: daxiongya Date: Mon, 21 Sep 2026 01:50:54 +0800 Subject: [PATCH 14/28] fix(workers): adopt native workspace metadata (cherry picked from commit 9894dd1de1070bbaaa374d7b8b3d5079ad02fb28) --- skills/xapi-workers/references/deployment.md | 10 ++++++++ src/tests/workers-init.test.ts | 25 ++++++++++++++++++++ src/tests/workers-native-bundle.test.ts | 8 +++++++ src/workers-artifact.ts | 8 +++++-- src/workers-framework-init.ts | 25 +++++++++++++------- 5 files changed, 65 insertions(+), 11 deletions(-) diff --git a/skills/xapi-workers/references/deployment.md b/skills/xapi-workers/references/deployment.md index 49d1b6b..eab66fa 100644 --- a/skills/xapi-workers/references/deployment.md +++ b/skills/xapi-workers/references/deployment.md @@ -75,6 +75,16 @@ npx wrangler deploy --dry-run \ Set `build.output` to the generated `.worker.bundle`, omit `build.main`, and set `assets.directory` to the generated client directory. `--dry-run` only creates the local Cloudflare upload artifact; `xapi workers push` remains the only publisher. The CLI sends the complete modules/assets set in one authenticated multipart Artifact request. The import report must show every unmapped Wrangler field; never split a framework application into per-file API uploads to work around an import problem. +Generated Wrangler configs may omit provider resource IDs and emit an +`inherit` binding in the dry-run bundle. Declare that binding exactly once in +the selected environment's `resources`; xAPI maps it by binding name and +injects the environment-owned resource during deployment. Do not add a copied +or placeholder Cloudflare resource ID merely to make the local bundle pass. + +For a package inside a pnpm, Yarn, or Bun workspace, run `init` from that +package directory. The CLI uses the nearest lockfile up to the repository root +and keeps the generated build command on the repository's package manager. + Use the environment's returned `publicUrl` for actual requests. A custom-domain URL needs verified DNS/TLS readiness; do not construct a hostname or infer readiness from the organization name. Use application authentication, never the control-plane key, on this URL. The dispatcher reserves and strips incoming `x-xapi-*` headers. Use an diff --git a/src/tests/workers-init.test.ts b/src/tests/workers-init.test.ts index 5b9168c..a985d33 100644 --- a/src/tests/workers-init.test.ts +++ b/src/tests/workers-init.test.ts @@ -250,6 +250,31 @@ describe("workers init", () => { }); }); + test("uses the repository package manager for a nested workspace package", () => { + const cwd = workspace(); + writeFileSync(join(cwd, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"); + mkdirSync(join(cwd, ".git")); + const target = join(cwd, "apps", "web"); + mkdirSync(target, { recursive: true }); + writeFileSync( + join(target, "package.json"), + JSON.stringify({ + name: "nested-vite", + private: true, + scripts: { build: "vite build" }, + dependencies: { react: "latest" }, + devDependencies: { vite: "latest" }, + }), + ); + + const result = initWorkerProject({ cwd, target: "apps/web" }); + const pkg = JSON.parse(readFileSync(join(target, "package.json"), "utf8")); + expect(pkg.scripts["xapi:build"]).toBe( + "pnpm run build && pnpm run xapi:worker:build", + ); + expect(result.nextSteps[0]).toBe("pnpm install"); + }); + test("adopts a statically exported Next project and rejects SSR without mutation", () => { const cwd = workspace(); const staticTarget = join(cwd, "next-static"); diff --git a/src/tests/workers-native-bundle.test.ts b/src/tests/workers-native-bundle.test.ts index d9fbec6..d1b8dfa 100644 --- a/src/tests/workers-native-bundle.test.ts +++ b/src/tests/workers-native-bundle.test.ts @@ -46,6 +46,14 @@ test('requires declared native bindings and own main_module',async()=>{ await expect(loadWorkerArtifactInput(path,'index.js')).rejects.toThrow('omit'); }); +test('maps Wrangler inherit bindings only through one declared xAPI resource',async()=>{ + const path=bundle([{...metadata,content:JSON.stringify({main_module:'index.js',compatibility_date:'2026-09-10',bindings:[{name:'DB',type:'inherit'}]})},entry]); + const a=await loadWorkerArtifactInput(path); + expect(()=>validateNativeDeploymentMetadata(a,{compatibilityDate:'2026-09-10'},[])).toThrow('DB'); + validateNativeDeploymentMetadata(a,{compatibilityDate:'2026-09-10'},[{bindingName:'DB',type:'d1_database'}]); + expect(JSON.stringify(a.upload)).not.toContain('inherit'); +}); + test('preserves observability in artifact identity and rejects unmapped settings', async () => { const path = bundle([{...metadata, content:JSON.stringify({...JSON.parse(metadata.content),observability:{enabled:true}})},entry]); const a = await loadWorkerArtifactInput(path); diff --git a/src/workers-artifact.ts b/src/workers-artifact.ts index bf9fa19..76a1e79 100644 --- a/src/workers-artifact.ts +++ b/src/workers-artifact.ts @@ -556,7 +556,7 @@ export async function loadWorkerArtifactInput( const unknown = Object.keys(metadata).filter(key => !known.has(key)); if (unknown.length) throw new WorkerArtifactError(`Native metadata needs explicit platform mapping: ${unknown.join(", ")}`); if (metadata.bindings !== undefined && (!Array.isArray(metadata.bindings) || metadata.bindings.some((binding: UnknownRecord) => - !binding || !["d1", "r2_bucket", "kv_namespace"].includes(String(binding.type)) || typeof binding.name !== "string" + !binding || !["d1", "r2_bucket", "kv_namespace", "inherit"].includes(String(binding.type)) || typeof binding.name !== "string" ))) throw new WorkerArtifactError("Native binding metadata needs explicit platform mapping; keep credentials in xAPI Secrets"); if (metadata.compatibility_flags !== undefined && (!Array.isArray(metadata.compatibility_flags) || metadata.compatibility_flags.some(flag => typeof flag !== "string"))) throw new WorkerArtifactError("Invalid native compatibility flags"); const observation = metadata.observability as UnknownRecord | undefined; @@ -609,7 +609,11 @@ export function validateNativeDeploymentMetadata( } const managed: Record = {d1: "d1_database", r2_bucket: "r2_bucket", kv_namespace: "kv_namespace"}; for (const binding of (metadata.bindings || []) as UnknownRecord[]) { - if (!resources.some(resource => resource.bindingName === binding.name && resource.type === managed[String(binding.type)])) { + const matching = resources.filter(resource => resource.bindingName === binding.name); + const declared = binding.type === "inherit" + ? matching.length === 1 && Object.values(managed).includes(matching[0].type) + : matching.some(resource => resource.type === managed[String(binding.type)]); + if (!declared) { throw new WorkerArtifactError(`Native binding ${binding.name} is missing from xAPI resource declarations`); } } diff --git a/src/workers-framework-init.ts b/src/workers-framework-init.ts index 018b4a6..da4a5cb 100644 --- a/src/workers-framework-init.ts +++ b/src/workers-framework-init.ts @@ -148,15 +148,22 @@ function detectFramework( } function packageManager(rootDir: string): { command: string; install: string } { - if (existsSync(join(rootDir, "pnpm-lock.yaml"))) - return { command: "pnpm", install: "pnpm install" }; - if (existsSync(join(rootDir, "yarn.lock"))) - return { command: "yarn", install: "yarn install" }; - if ( - existsSync(join(rootDir, "bun.lock")) || - existsSync(join(rootDir, "bun.lockb")) - ) - return { command: "bun", install: "bun install" }; + let current = rootDir; + while (true) { + if (existsSync(join(current, "pnpm-lock.yaml"))) + return { command: "pnpm", install: "pnpm install" }; + if (existsSync(join(current, "yarn.lock"))) + return { command: "yarn", install: "yarn install" }; + if ( + existsSync(join(current, "bun.lock")) || + existsSync(join(current, "bun.lockb")) + ) + return { command: "bun", install: "bun install" }; + if (existsSync(join(current, ".git"))) break; + const parent = dirname(current); + if (parent === current) break; + current = parent; + } return { command: "npm", install: "npm install" }; } From 9f24bd4e2d917916748d1a5fc494d2619217f1eb Mon Sep 17 00:00:00 2001 From: daxiongya Date: Mon, 21 Sep 2026 03:46:22 +0800 Subject: [PATCH 15/28] fix(workers): accept current Wrangler asset metadata (cherry picked from commit a6a30166955057501206101a2bead6aeb20acbb1) --- src/tests/workers-native-bundle.test.ts | 33 +++++++++++++++++++++++-- src/workers-artifact.ts | 30 +++++++++++++++++++--- 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/tests/workers-native-bundle.test.ts b/src/tests/workers-native-bundle.test.ts index d1b8dfa..6767620 100644 --- a/src/tests/workers-native-bundle.test.ts +++ b/src/tests/workers-native-bundle.test.ts @@ -1,7 +1,7 @@ import { afterEach, expect, test } from 'bun:test'; -import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { loadWorkerArtifactInput, validateNativeDeploymentMetadata } from '../workers-artifact.ts'; const roots: string[] = []; afterEach(() => { for (const root of roots.splice(0)) rmSync(root,{recursive:true,force:true}); }); @@ -63,3 +63,32 @@ test('preserves observability in artifact identity and rejects unmapped settings expect(a.contentSha256).not.toBe(plain.contentSha256); await expect(loadWorkerArtifactInput(bundle([{...metadata,content:JSON.stringify({...JSON.parse(metadata.content),observability:{enabled:true,unknown:true}})},entry]))).rejects.toThrow('mapping'); }); + +test('accepts Wrangler package diagnostics and the declared static assets binding', async () => { + const path = bundle([{...metadata, content:JSON.stringify({ + ...JSON.parse(metadata.content), + bindings:[{name:'ASSETS',type:'assets'}], + package_dependencies:[{name:'wrangler',packageJsonVersion:'^4.135.0',installedVersion:'4.135.0'}], + })},entry]); + const assets = join(dirname(path), 'public'); + mkdirSync(assets); + writeFileSync(join(assets, 'index.html'), '

Jev Trader

'); + const a = await loadWorkerArtifactInput(path, undefined, { + directory: assets, + binding: 'ASSETS', + }); + validateNativeDeploymentMetadata(a,{compatibilityDate:'2026-09-10',compatibilityFlags:['nodejs_compat']},[]); + expect(JSON.stringify(a.upload)).not.toContain('package_dependencies'); +}); + +test('rejects undeclared native assets bindings and malformed package diagnostics', async () => { + const assetMetadata = {...metadata, content:JSON.stringify({ + ...JSON.parse(metadata.content), + bindings:[{name:'ASSETS',type:'assets'}], + })}; + await expect(loadWorkerArtifactInput(bundle([assetMetadata,entry]))).rejects.toThrow('binding metadata'); + await expect(loadWorkerArtifactInput(bundle([{...metadata, content:JSON.stringify({ + ...JSON.parse(metadata.content), + package_dependencies:[{name:'wrangler',installedVersion:'4.135.0',unexpected:true}], + })},entry]))).rejects.toThrow('package dependency'); +}); diff --git a/src/workers-artifact.ts b/src/workers-artifact.ts index 76a1e79..a2fe31a 100644 --- a/src/workers-artifact.ts +++ b/src/workers-artifact.ts @@ -552,12 +552,32 @@ export async function loadWorkerArtifactInput( if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) throw new WorkerArtifactError("Invalid Wrangler metadata"); // Resource identities and credentials are owned by xAPI's control plane. // Do not silently import a native binding that has no managed equivalent here. - const known = new Set(["main_module", "bindings", "compatibility_date", "compatibility_flags", "observability"]); + const known = new Set(["main_module", "bindings", "compatibility_date", "compatibility_flags", "observability", "package_dependencies"]); const unknown = Object.keys(metadata).filter(key => !known.has(key)); if (unknown.length) throw new WorkerArtifactError(`Native metadata needs explicit platform mapping: ${unknown.join(", ")}`); - if (metadata.bindings !== undefined && (!Array.isArray(metadata.bindings) || metadata.bindings.some((binding: UnknownRecord) => - !binding || !["d1", "r2_bucket", "kv_namespace", "inherit"].includes(String(binding.type)) || typeof binding.name !== "string" - ))) throw new WorkerArtifactError("Native binding metadata needs explicit platform mapping; keep credentials in xAPI Secrets"); + const packageDependencies = metadata.package_dependencies; + if (packageDependencies !== undefined && ( + !Array.isArray(packageDependencies) || + packageDependencies.length > 1000 || + packageDependencies.some((dependency: UnknownRecord) => + !dependency || + typeof dependency !== "object" || + Array.isArray(dependency) || + Object.keys(dependency).some(key => !["name", "packageJsonVersion", "installedVersion"].includes(key)) || + typeof dependency.name !== "string" || + dependency.name.length < 1 || + dependency.name.length > 500 || + typeof dependency.packageJsonVersion !== "string" || + dependency.packageJsonVersion.length > 500 || + typeof dependency.installedVersion !== "string" || + dependency.installedVersion.length > 500 + ) + )) throw new WorkerArtifactError("Invalid native package dependency metadata"); + if (metadata.bindings !== undefined && (!Array.isArray(metadata.bindings) || metadata.bindings.some((binding: UnknownRecord) => { + if (!binding || typeof binding.name !== "string") return true; + if (["d1", "r2_bucket", "kv_namespace", "inherit"].includes(String(binding.type))) return false; + return binding.type !== "assets" || !staticAssets?.binding || binding.name !== staticAssets.binding; + }))) throw new WorkerArtifactError("Native binding metadata needs explicit platform mapping; keep credentials in xAPI Secrets"); if (metadata.compatibility_flags !== undefined && (!Array.isArray(metadata.compatibility_flags) || metadata.compatibility_flags.some(flag => typeof flag !== "string"))) throw new WorkerArtifactError("Invalid native compatibility flags"); const observation = metadata.observability as UnknownRecord | undefined; if (observation !== undefined && (!observation || typeof observation !== "object" || Array.isArray(observation) || typeof observation.enabled !== "boolean" || Object.keys(observation).some(key => key !== "enabled"))) throw new WorkerArtifactError("Native observability config needs explicit mapping"); @@ -609,6 +629,8 @@ export function validateNativeDeploymentMetadata( } const managed: Record = {d1: "d1_database", r2_bucket: "r2_bucket", kv_namespace: "kv_namespace"}; for (const binding of (metadata.bindings || []) as UnknownRecord[]) { + if (binding.type === "assets" && artifact.upload && "bundle" in artifact.upload && + artifact.upload.bundle.assets?.binding === binding.name) continue; const matching = resources.filter(resource => resource.bindingName === binding.name); const declared = binding.type === "inherit" ? matching.length === 1 && Object.values(managed).includes(matching[0].type) From 0e95b26bcf7d7a9e1f443a5526207a006103c77f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E9=9B=84=E5=91=80?= <47734376+dxiongya@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:49:12 +0800 Subject: [PATCH 16/28] feat(workers): deploy native container applications (#35) * feat(workers): deploy native container projects * docs(workers): explain container risk admission * fix(skill): clarify container rollout readiness (cherry picked from commit 1ade83c0345a78a81847850dea9dea4c3e09490c) --- package.json | 3 +- schemas/worker-project.v1.schema.json | 45 ++++ scripts/workers-container-local-e2e.mjs | 259 +++++++++++++++++++ skills/xapi-workers/SKILL.md | 6 +- skills/xapi-workers/references/billing.md | 4 + skills/xapi-workers/references/deployment.md | 21 +- skills/xapi-workers/references/resources.md | 6 +- src/commands/workers.ts | 6 + src/tests/workers-native-bundle.test.ts | 21 ++ src/tests/workers-project.test.ts | 16 ++ src/tests/workers-wrangler-import.test.ts | 43 +++ src/workers-artifact.ts | 90 ++++++- src/workers-plan.ts | 1 + src/workers-project.ts | 64 +++++ src/workers-wrangler-import.ts | 72 ++++++ 15 files changed, 646 insertions(+), 11 deletions(-) create mode 100644 scripts/workers-container-local-e2e.mjs diff --git a/package.json b/package.json index 1e63339..d35bd02 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,8 @@ "demo:sandbox": "npm run build && node examples/sandbox-api-cli-openai.mjs", "example:sandbox:openai": "bun run examples/openai-agents-sandbox-local.ts", "test:sandbox:playground": "npm run build && node scripts/sandbox-playground-e2e.mjs", - "test:sandbox:openai": "bun run scripts/openai-sandbox-agent-e2e.ts" + "test:sandbox:openai": "bun run scripts/openai-sandbox-agent-e2e.ts", + "test:workers:container-local": "npm run build && node scripts/workers-container-local-e2e.mjs" }, "engines": { "node": ">=18" diff --git a/schemas/worker-project.v1.schema.json b/schemas/worker-project.v1.schema.json index 21730f3..224bfce 100644 --- a/schemas/worker-project.v1.schema.json +++ b/schemas/worker-project.v1.schema.json @@ -78,6 +78,11 @@ } } }, + "containers": { + "type": "array", + "maxItems": 10, + "items": { "$ref": "#/$defs/container" } + }, "environments": { "type": "object", "additionalProperties": false, @@ -182,6 +187,46 @@ } } ] + }, + "container": { + "type": "object", + "additionalProperties": false, + "required": ["name", "className", "image"], + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z](?:[a-z0-9-]{0,62}[a-z0-9])?$" + }, + "className": { + "type": "string", + "pattern": "^[A-Za-z_$][A-Za-z0-9_$]{0,127}$" + }, + "image": { + "type": "string", + "maxLength": 512, + "description": "Remote image in Cloudflare Registry, Docker Hub, ECR, or Google Artifact Registry" + }, + "instanceType": { + "enum": ["lite", "basic", "standard-1", "standard-2", "standard-3", "standard-4"], + "default": "lite" + }, + "maxInstances": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 }, + "constraints": { + "type": "object", + "additionalProperties": false, + "properties": { + "regions": { + "type": "array", + "minItems": 1, + "maxItems": 8, + "uniqueItems": true, + "items": { "enum": ["ENAM", "WNAM", "EEUR", "WEUR", "APAC", "SAM", "ME", "OC", "AFR"] } + }, + "jurisdiction": { "enum": ["eu", "fedramp"] } + } + }, + "rolloutActiveGracePeriod": { "type": "integer", "minimum": 0, "maximum": 86400, "default": 0 } + } } } } diff --git a/scripts/workers-container-local-e2e.mjs b/scripts/workers-container-local-e2e.mjs new file mode 100644 index 0000000..c126db4 --- /dev/null +++ b/scripts/workers-container-local-e2e.mjs @@ -0,0 +1,259 @@ +import { createHash } from 'node:crypto'; +import { createServer } from 'node:http'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawn } from 'node:child_process'; + +const repository = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const root = mkdtempSync(join(tmpdir(), 'xapi-container-e2e-')); +const workerId = '22222222-2222-4222-8222-222222222222'; +const requests = []; +const state = { worker: null, resources: [], artifacts: [], deployments: [] }; + +function canonicalArtifact(bundle) { + const modules = [...bundle.modules] + .sort((a, b) => a.path.localeCompare(b.path)) + .map((module) => ({ + path: module.path, + contentBase64: + module.encoding === 'base64' + ? module.content + : Buffer.from(module.content, 'utf8').toString('base64'), + contentType: module.contentType, + })); + const stored = { + ...(bundle.observability ? { observability: bundle.observability } : {}), + ...(bundle.containers?.length ? { containers: bundle.containers } : {}), + version: 1, + mainModule: bundle.mainModule, + modules, + ...(bundle.assets ? { assets: bundle.assets } : {}), + }; + return Buffer.from(JSON.stringify(stored)); +} + +function snapshot(port) { + if (!state.worker) return null; + const active = state.deployments.find((deployment) => deployment.status === 'ACTIVE'); + return { + ...state.worker, + environments: [ + { + id: 'environment-preview', + name: 'PREVIEW', + enabled: true, + dailyBudgetUsd: 0.25, + activeDeploymentId: active?.id || null, + publicUrl: `http://localhost:${port}/runtime`, + }, + { + id: 'environment-production', + name: 'PRODUCTION', + enabled: true, + dailyBudgetUsd: 2, + activeDeploymentId: null, + publicUrl: `http://localhost:${port}/production`, + }, + ], + artifacts: state.artifacts, + deployments: state.deployments, + }; +} + +async function body(request) { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + return chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {}; +} + +function json(response, status, value) { + response.writeHead(status, { 'content-type': 'application/json' }); + response.end(JSON.stringify(value)); +} + +const server = createServer(async (request, response) => { + const url = new URL(request.url, 'http://localhost'); + requests.push({ method: request.method, path: url.pathname }); + if (url.pathname === '/runtime/health') return json(response, 200, { ok: true }); + if (request.headers['xapi-key'] !== 'local-container-key') { + return json(response, 401, { error: { code: 'unauthorized' } }); + } + const base = '/api/v1/workers'; + if (url.pathname === base && request.method === 'GET') { + return json(response, 200, state.worker ? [snapshot(server.address().port)] : []); + } + if (url.pathname === base && request.method === 'POST') { + const input = await body(request); + state.worker = { id: workerId, name: input.name, slug: input.slug, status: 'ACTIVE' }; + return json(response, 201, snapshot(server.address().port)); + } + const workerPath = `${base}/${workerId}`; + if (url.pathname === workerPath && request.method === 'GET') { + return json(response, 200, snapshot(server.address().port)); + } + const resourcePath = `${workerPath}/environments/preview/resources`; + if (url.pathname === resourcePath && request.method === 'GET') { + return json(response, 200, state.resources); + } + if (url.pathname === resourcePath && request.method === 'POST') { + const input = await body(request); + const resource = { + id: 'resource-do-1', + bindingName: input.bindingName, + type: 'DURABLE_OBJECT', + status: 'PROVISIONING', + config: { className: input.className }, + }; + state.resources.push(resource); + return json(response, 201, resource); + } + if ( + url.pathname === `${workerPath}/environments/preview/secrets` && + request.method === 'GET' + ) { + return json(response, 200, []); + } + if (url.pathname === `${workerPath}/artifacts` && request.method === 'GET') { + return json(response, 200, state.artifacts); + } + if (url.pathname === `${workerPath}/artifacts` && request.method === 'POST') { + const input = await body(request); + if (!input.bundle?.containers?.length) { + return json(response, 422, { error: { code: 'container_manifest_missing' } }); + } + const stored = canonicalArtifact(input.bundle); + const artifact = { + id: 'artifact-container-1', + idempotencyKey: input.idempotencyKey, + contentSha256: createHash('sha256').update(stored).digest('hex'), + sizeBytes: stored.length, + }; + state.artifacts.push(artifact); + return json(response, 201, artifact); + } + if (url.pathname === `${workerPath}/deployments` && request.method === 'POST') { + const input = await body(request); + const deployment = { + id: 'deployment-container-1', + artifactId: input.artifactId, + environmentId: 'environment-preview', + idempotencyKey: input.idempotencyKey, + status: 'ACTIVE', + }; + state.deployments.push(deployment); + return json(response, 201, deployment); + } + return json(response, 404, { error: { code: 'not_found', path: url.pathname } }); +}); + +function run(command, args, options) { + return new Promise((resolvePromise, rejectPromise) => { + const child = spawn(command, args, options); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => (stdout += chunk)); + child.stderr.on('data', (chunk) => (stderr += chunk)); + child.on('error', rejectPromise); + child.on('close', (code) => + code === 0 + ? resolvePromise({ stdout, stderr }) + : rejectPromise(new Error(`CLI exited ${code}: ${stderr || stdout}`)), + ); + }); +} + +try { + await new Promise((resolvePromise) => server.listen(0, 'localhost', resolvePromise)); + const port = server.address().port; + mkdirSync(join(root, 'src'), { recursive: true }); + writeFileSync( + join(root, 'wrangler.jsonc'), + JSON.stringify({ + name: 'container-local-e2e', + main: 'src/index.mjs', + compatibility_date: '2026-09-21', + durable_objects: { bindings: [{ name: 'API_CONTAINER', class_name: 'ApiContainer' }] }, + }), + ); + writeFileSync( + join(root, 'build.mjs'), + `import {mkdirSync,writeFileSync} from 'node:fs';mkdirSync('dist',{recursive:true});writeFileSync('dist/worker.mjs','export class ApiContainer {}\\nexport default {fetch(){return new Response("ok")}}');`, + ); + writeFileSync( + join(root, 'xapi.worker.json'), + JSON.stringify({ + version: 1, + worker: { name: 'Container Local E2E', slug: 'container-local-e2e', template: 'worker' }, + wrangler: 'wrangler.jsonc', + build: { command: 'node build.mjs', output: 'dist/worker.mjs' }, + containers: [ + { + name: 'api', + className: 'ApiContainer', + image: 'docker.io/library/nginx:1.27-alpine', + instanceType: 'lite', + maxInstances: 2, + constraints: { regions: ['APAC'] }, + rolloutActiveGracePeriod: 30, + }, + ], + environments: { + preview: { + dailyBudgetUsd: 0.25, + healthCheck: '/health', + resources: [{ type: 'durable_object', bindingName: 'API_CONTAINER', className: 'ApiContainer' }], + secrets: [], + }, + production: { + dailyBudgetUsd: 2, + healthCheck: '/health', + resources: [{ type: 'durable_object', bindingName: 'API_CONTAINER', className: 'ApiContainer' }], + secrets: [], + }, + }, + }, null, 2), + ); + const result = await run( + process.execPath, + [join(repository, 'dist/index.js'), 'workers', 'push', '--env', 'preview', '--non-interactive', '--format', 'json'], + { + cwd: root, + env: { + ...process.env, + HOME: root, + XAPI_ACTION_HOST: `localhost:${port}`, + XAPI_API_HOST: `localhost:${port}`, + XAPI_KEY: 'local-container-key', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + const output = JSON.parse(result.stdout.trim()); + const linked = JSON.parse(readFileSync(join(root, 'xapi.worker.json'), 'utf8')); + if ( + output.status !== 'ACTIVE' || + output.health?.status !== 200 || + linked.workerId !== workerId || + state.resources[0]?.config?.className !== 'ApiContainer' || + !state.artifacts.length || + !state.deployments.length + ) { + throw new Error('Local Container acceptance assertions failed'); + } + console.log( + JSON.stringify({ + ok: true, + workerId, + artifactSha256: state.artifacts[0].contentSha256, + deploymentId: state.deployments[0].id, + health: output.health, + requestCount: requests.length, + requests, + }), + ); +} finally { + await new Promise((resolvePromise) => server.close(resolvePromise)); + rmSync(root, { recursive: true, force: true }); +} diff --git a/skills/xapi-workers/SKILL.md b/skills/xapi-workers/SKILL.md index af9bffe..e01f58e 100644 --- a/skills/xapi-workers/SKILL.md +++ b/skills/xapi-workers/SKILL.md @@ -1,6 +1,6 @@ --- name: xapi-workers -description: Deploy, operate, and verify applications on xAPI-managed Cloudflare Workers for Platforms through the xAPI CLI. Use for Workers projects, preview/production deployment, KV, D1, R2, Durable Objects, Queues, Workflows, schedules, runtime logs, consumption queries, billing reconciliation, retention, recovery, and test-resource cleanup. Includes real resource acceptance and cost evidence; does not administer a Cloudflare account directly. +description: Deploy, operate, and verify applications on xAPI-managed Cloudflare Workers for Platforms through the xAPI CLI. Use for Workers projects, preview/production deployment, KV, D1, R2, Durable Objects, Queues, Workflows, Containers, schedules, runtime logs, consumption queries, billing reconciliation, retention, recovery, and test-resource cleanup. Includes real resource acceptance and cost evidence; does not administer a Cloudflare account directly. --- # xAPI Workers for Platforms @@ -19,7 +19,7 @@ Use the `xapi` CLI (`xapi-to` is the same executable). Verify `xapi workers --he ## Load the relevant workflow - **Build/deploy/import/CI:** read [deployment.md](references/deployment.md). -- **Use and verify resources:** read [resources.md](references/resources.md). +- **Use and verify resources, including Containers:** read [resources.md](references/resources.md). - **Configure runtime credentials:** read [secrets.md](references/secrets.md). Values go to the environment's Cloudflare User Worker binding; never ask xAPI to reveal them. - **How much did it cost?** Read [billing.md](references/billing.md) before answering, collecting, or reconciling consumption. - **Pause/recover/delete/refund:** read [lifecycle.md](references/lifecycle.md) before lifecycle mutations. @@ -32,4 +32,6 @@ Follow a full round: execute real business operations → collect failures → c `ACTIVE` deployment, a provisioned resource, an accepted asynchronous job, and a passing HTTP health check prove different things. Verify the intended business result and persisted state. Preserve sanitized evidence: host, environment, IDs, times, request/job IDs, statuses, snapshot and ledger IDs, exact decimal amounts, and unresolved gaps. Exclude tokens, cookies, passwords, and customer file contents. +For Cloudflare Containers, `ACTIVE` means the Worker upload, matching Durable Object namespace resolution, and the required Container Application create/update plus rollout request were accepted and recorded. Cloudflare may still be converging the native rollout; verify the application resource and business route separately. A Docker image build or registry push is a separate prerequisite and is never implied by an xAPI Artifact upload. + For incomplete observations report **unknown**, not zero or success. Keep deployment completion, resource behavior, xAPI consumption, storage-day finalization, provider invoice reconciliation, and physical cleanup as separate verdicts. Report only verified outcomes and the next concrete unresolved check. diff --git a/skills/xapi-workers/references/billing.md b/skills/xapi-workers/references/billing.md index 3348118..0a7b6dc 100644 --- a/skills/xapi-workers/references/billing.md +++ b/skills/xapi-workers/references/billing.md @@ -15,6 +15,10 @@ xapi workers retention show --env preview --format json `billing-status` is platform configuration status, not an individual consumption bill. `workers usage` is a different diagnostic; it is not a replacement for complete ledger evidence. `billing prices` is the live xAPI price book: record its version and units, do not hard-code past acceptance prices. +Container Applications add four provider-metered metrics: CPU seconds, memory byte-seconds, disk byte-seconds, and egress bytes. Worker request/CPU and Durable Object charges remain separate and can appear for the same business request. Match Container ledger rows by the saved Cloudflare application ID; never attribute account-wide Container totals by image name or class name. The five-minute collector is delayed postpaid observation and does not reserve or authorize each request. + +The deployment risk amount is not a bill, hold, or minimum rental. It is a conservative five-minute capacity ceiling used only when creating or enlarging Container applications. Actual customer charges continue to come from observed usage buckets and their price-book snapshot. A policy that permits unbounded egress is an operator-controlled acceptance for isolated accounts, not proof that egress is free or capped. + `metering` is a bounded diagnostic and may return truncated facts. There is no `metering --all` command. Use supported billing usage ranges/filters and the complete ledger for reconciliation; if raw facts remain truncated, request platform-operator evidence through an available authorized interface and mark coverage incomplete. Do not invent a pagination endpoint. ## One consistent snapshot diff --git a/skills/xapi-workers/references/deployment.md b/skills/xapi-workers/references/deployment.md index eab66fa..7eee750 100644 --- a/skills/xapi-workers/references/deployment.md +++ b/skills/xapi-workers/references/deployment.md @@ -51,7 +51,7 @@ production acceptance deployment can therefore use `api.xapi.to` with For an existing project use `xapi workers init --from-wrangler ./wrangler.jsonc` (TOML also supported). Generated framework configs may live below the project root, for example `dist/server/wrangler.json`; run the command from the package directory so xAPI writes `xapi.worker.json` beside `package.json` and resolves generated asset paths back to that root. Read its import report; do not auto-accept unsupported settings. xAPI creates environment-specific resources; do not copy another Cloudflare account's IDs. -`xapi.worker.json` holds desired xAPI state and Worker ID; Wrangler holds entrypoint, compatibility and binding declarations. The persistent-agent template declares all six managed resource types so it can demonstrate the complete platform, but an ordinary application should declare only the resources its business logic uses. Do not add unrelated bindings merely to complete an acceptance checklist. Test the full resource matrix in a separate disposable Worker or environment, then clean up only that isolated test state. Install/build according to the generated project instructions. Inspect plans for missing permissions, prices, secrets, budget, and policy requirements. +`xapi.worker.json` holds desired xAPI state and Worker ID; Wrangler holds entrypoint, compatibility and binding declarations. The persistent-agent template declares the six ordinary managed binding types so it can demonstrate the platform; Containers remain deployment-owned and must be declared explicitly. An ordinary application should declare only the resources its business logic uses. Do not add unrelated bindings merely to complete an acceptance checklist. Test the full resource matrix in a separate disposable Worker or environment, then clean up only that isolated test state. Install/build according to the generated project instructions. Inspect plans for missing permissions, prices, secrets, budget, and policy requirements. ```sh xapi workers push --env preview @@ -85,6 +85,25 @@ For a package inside a pnpm, Yarn, or Bun workspace, run `init` from that package directory. The CLI uses the nearest lockfile up to the repository root and keeps the generated build command on the repository's package manager. +## Native Containers + +Cloudflare Containers are part of the Worker deployment, not an ordinary binding created with `workers resources create`. Keep the native relationship explicit: + +1. Wrangler declares a Durable Object binding and `containers[].class_name` for the same class in the same Worker. +2. `xapi workers init --from-wrangler ...` imports the Container settings into shared `xapi.worker.json` state and creates separate DO resources for preview and production. +3. `xapi workers plan --env preview` must show the DO creation and an Artifact change. Verify `workers capabilities` reports `container_application` available before applying. +4. `xapi workers push --env preview` uploads the Worker, resolves the exact preview DO namespace, creates or updates the Container Application, submits the required rollout, records its application ID and receipt for metering, and only then marks the deployment ACTIVE. Native rollout convergence remains separately observable; xAPI keeps both old and new risk capacity counted until Cloudflare confirms it. + +Before the first Provider mutation, xAPI also requires a complete active price book and account-level five-minute Container risk capacity. This check does not freeze or deduct wallet funds and does not run on application requests. If push returns `worker_container_risk_policy_missing`, `worker_container_prices_incomplete`, `worker_container_egress_risk_not_accepted`, or `worker_container_risk_capacity_exceeded`, report the exact code and stop; do not bypass xAPI with Wrangler or reinterpret the amount as a customer charge. The first three require an xAPI operator to correct platform policy or pricing. The last requires reducing the deployment's instance size/count or adding balance/risk capacity. + +xAPI v1 accepts prebuilt images from Cloudflare Registry, Docker Hub, Amazon ECR, and Google Artifact Registry. Push the image before deployment and use a tag or immutable digest. A Wrangler `image: "./Dockerfile"` is intentionally reported as unsupported: the CLI does not silently build or publish registry credentials. Never put registry tokens in `xapi.worker.json`, the Artifact, or Worker secrets. + +Preview and production use distinct Worker scripts, DO namespaces, and physical Container Application names even though the same immutable Artifact is promoted. A Container rollout is a second Cloudflare mutation after Worker upload; failure leaves the deployment non-ACTIVE and retryable. Do not bypass the failed step with direct Wrangler deployment. + +Removing a Container declaration is also a deployment operation. Push the new immutable manifest; xAPI deletes only the exact Container Application no longer declared and releases its risk capacity after Provider deletion is confirmed. Do not delete the associated Durable Object or unrelated R2/D1/KV resources unless the application declaration and business migration require it. + +Repository maintainers can run `npm run test:workers:container-local` after building the CLI. It starts a loopback xAPI service, invokes the packaged CLI process, creates the declared DO, uploads the normalized Container Artifact, deploys it, and performs the public health request. This validates protocol wiring without consuming Cloudflare resources; a real preview deployment is still required before release when an account token with Containers Edit is available. + Use the environment's returned `publicUrl` for actual requests. A custom-domain URL needs verified DNS/TLS readiness; do not construct a hostname or infer readiness from the organization name. Use application authentication, never the control-plane key, on this URL. The dispatcher reserves and strips incoming `x-xapi-*` headers. Use an diff --git a/skills/xapi-workers/references/resources.md b/skills/xapi-workers/references/resources.md index 9a846d5..0284ac7 100644 --- a/skills/xapi-workers/references/resources.md +++ b/skills/xapi-workers/references/resources.md @@ -2,7 +2,7 @@ Run `workers capabilities` and `workers resources list --env preview --format json`. Permissions, availability, and price configuration are independent: provisioned alone does not mean priced or exercised. -Keep resource declarations driven by application behavior. R2, D1, KV, Durable Objects, Queues, and Workflows are independent bindings; none must be added or deleted just because another resource is used. When the goal is to verify every platform resource, use a separate disposable acceptance Worker so those checks cannot change a real application's storage or lifecycle. +Keep resource declarations driven by application behavior. R2, D1, KV, Durable Objects, Queues, Workflows, and Container Applications are independent bindings and resources; none must be added or deleted just because another resource is used. A Container Application is the one exception to the generic create command: it is deployment-owned and must reference a Durable Object class in the same Worker. When the goal is to verify every platform resource, use a separate disposable acceptance Worker so those checks cannot change a real application's storage or lifecycle. Prefer declarations plus plan/push. For granular provisioning: @@ -15,6 +15,10 @@ xapi workers resources create --env preview --type queue --binding J xapi workers resources create --env preview --type workflow --binding PIPELINE ``` +Do not run `resources create` for a Container. Declare it in Wrangler and `xapi.worker.json`, then deploy. After deployment, `resources list` exposes a read-only `CONTAINER_APPLICATION` record containing the physical application ID, image, instance type, maximum instances, placement, and rollout receipt. Its binding-like `CONTAINER_` key is an internal stable identity, not a Worker `env` binding. + +Exercise a Container through the application route that causes its controlling Durable Object to start or contact an instance. Verify the business response, Container status, DO coordination state, and four Container usage dimensions separately. Do not conclude that an image runs merely because the application resource exists. + Supply the explicitly accepted retention price version when required. Redeploy after binding changes. Use `env.`; no provider API/S3 credentials belong in application code. Initialize D1 schema through the application's migration mechanism; creation doesn't create tables. | Resource | Real business check | Evidence | diff --git a/src/commands/workers.ts b/src/commands/workers.ts index d782e31..22df615 100644 --- a/src/commands/workers.ts +++ b/src/commands/workers.ts @@ -223,6 +223,12 @@ RESOURCE FLAGS --binding NAME Uppercase env binding, for example STATE or FILES --yes Required for physical resource destruction +CONTAINERS + Declare prebuilt Container images in Wrangler and xapi.worker.json. Each + Container class must have a Durable Object resource in preview and production. + Container Applications are reconciled by plan/push; do not create them with + workers resources create. + ADVANCED REMOTE RESOURCE COMMANDS These recovery/debug commands mutate live state without updating xapi.worker.json. resources list --env preview|production diff --git a/src/tests/workers-native-bundle.test.ts b/src/tests/workers-native-bundle.test.ts index 6767620..e7e8eec 100644 --- a/src/tests/workers-native-bundle.test.ts +++ b/src/tests/workers-native-bundle.test.ts @@ -2,6 +2,7 @@ import { afterEach, expect, test } from 'bun:test'; import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; +import { createHash } from 'node:crypto'; import { loadWorkerArtifactInput, validateNativeDeploymentMetadata } from '../workers-artifact.ts'; const roots: string[] = []; afterEach(() => { for (const root of roots.splice(0)) rmSync(root,{recursive:true,force:true}); }); @@ -92,3 +93,23 @@ test('rejects undeclared native assets bindings and malformed package diagnostic package_dependencies:[{name:'wrangler',installedVersion:'4.135.0',unexpected:true}], })},entry]))).rejects.toThrow('package dependency'); }); + +test('requires native Container classes to match the explicit xAPI deployment intent', async () => { + const container = { + name:'trader', className:'TraderContainer', image:'docker.io/example/trader:v1', + instanceType:'lite' as const, maxInstances:2, rolloutActiveGracePeriod:0, + }; + const native = {...metadata, content:JSON.stringify({...JSON.parse(metadata.content),containers:[{class_name:'TraderContainer'}]})}; + const artifact = await loadWorkerArtifactInput(bundle([native,entry]), undefined, undefined, [container]); + if (!('bundle' in artifact.upload)) throw Error('bundle'); + expect(artifact.upload.bundle.containers).toEqual([container]); + const expectedStored = Buffer.from(JSON.stringify({ + containers:[container], version:1, mainModule:'index.js', modules:[{ + path:'index.js', contentBase64:Buffer.from(entry.content).toString('base64'), + contentType:'application/javascript+module', + }], + })); + expect(artifact.contentSha256).toBe(createHash('sha256').update(expectedStored).digest('hex')); + expect(artifact.sizeBytes).toBe(expectedStored.length); + await expect(loadWorkerArtifactInput(bundle([native,entry]), undefined, undefined, [])).rejects.toThrow('Container classes differ'); +}); diff --git a/src/tests/workers-project.test.ts b/src/tests/workers-project.test.ts index e16ebf9..c403d32 100644 --- a/src/tests/workers-project.test.ts +++ b/src/tests/workers-project.test.ts @@ -153,6 +153,22 @@ describe("Worker project configuration", () => { }); }); + test('requires every Container class to be a Durable Object in both environments', () => { + const container = { + name: 'trader', className: 'TraderContainer', image: 'docker.io/example/trader:v1', + }; + const valid = fixture({ + containers: [container], + environments: { + preview: { dailyBudgetUsd: 0.25, resources: [{ type: 'durable_object', bindingName: 'TRADER', className: 'TraderContainer' }] }, + production: { dailyBudgetUsd: 2, resources: [{ type: 'durable_object', bindingName: 'TRADER', className: 'TraderContainer' }] }, + }, + }); + expect(loadWorkerProject(valid).config.containers?.[0].instanceType).toBe('lite'); + const invalid = fixture({ containers: [container] }); + expect(() => loadWorkerProject(invalid)).toThrow('must match exactly one durable_object class in preview'); + }); + test("accepts D1 and R2 placement and rejects it on unrelated resources", () => { const validRoot = fixture({ environments: { diff --git a/src/tests/workers-wrangler-import.test.ts b/src/tests/workers-wrangler-import.test.ts index 77fef29..8342396 100644 --- a/src/tests/workers-wrangler-import.test.ts +++ b/src/tests/workers-wrangler-import.test.ts @@ -28,6 +28,49 @@ function workspace(): string { } describe("Wrangler project import", () => { + test('imports a prebuilt native Container application and its Durable Object link', () => { + const root = workspace(); + writeFileSync(join(root, 'package.json'), '{}'); + writeFileSync(join(root, 'wrangler.jsonc'), JSON.stringify({ + name: 'container-worker', + main: 'src/index.ts', + compatibility_date: '2026-09-21', + durable_objects: { + bindings: [{ name: 'TRADER', class_name: 'TraderContainer' }], + }, + containers: [{ + name: 'trader', + class_name: 'TraderContainer', + image: 'docker.io/example/trader:v1', + instance_type: 'lite', + max_instances: 3, + constraints: { regions: ['APAC'] }, + }], + }, null, 2)); + const result = importWranglerProject({ cwd: root, wranglerPath: 'wrangler.jsonc' }); + expect(result.wrote).toBe(true); + expect(result.report.entries).toContainEqual(expect.objectContaining({ + category: 'MANAGED', path: 'containers[0]', + })); + expect(loadWorkerProject(root).config.containers).toEqual([expect.objectContaining({ + name: 'trader', className: 'TraderContainer', instanceType: 'lite', maxInstances: 3, + })]); + }); + + test('does not silently import a local Dockerfile as a remotely deployable image', () => { + const root = workspace(); + writeFileSync(join(root, 'wrangler.jsonc'), JSON.stringify({ + name: 'container-worker', main: 'src/index.ts', + durable_objects: { bindings: [{ name: 'APP', class_name: 'AppContainer' }] }, + containers: [{ name: 'app', class_name: 'AppContainer', image: './Dockerfile' }], + })); + const result = importWranglerProject({ cwd: root, wranglerPath: 'wrangler.jsonc' }); + expect(result.wrote).toBe(false); + expect(result.report.entries).toContainEqual(expect.objectContaining({ + category: 'UNSUPPORTED', path: 'containers[0]', + })); + }); + test("reports every JSONC compatibility decision and blocks unsupported input", () => { const root = workspace(); const path = join(root, "wrangler.jsonc"); diff --git a/src/workers-artifact.ts b/src/workers-artifact.ts index a2fe31a..3bdab03 100644 --- a/src/workers-artifact.ts +++ b/src/workers-artifact.ts @@ -67,6 +67,17 @@ export interface WorkerArtifactBundle { modules: WorkerArtifactBundleModule[]; observability?: { enabled: boolean }; assets?: WorkerArtifactAssets; + containers?: WorkerContainerInput[]; +} + +export interface WorkerContainerInput { + name: string; + className: string; + image: string; + instanceType: 'lite' | 'basic' | 'standard-1' | 'standard-2' | 'standard-3' | 'standard-4'; + maxInstances: number; + constraints?: { regions?: string[]; jurisdiction?: 'eu' | 'fedramp' }; + rolloutActiveGracePeriod: number; } export type WorkerArtifactUploadInput = @@ -513,8 +524,9 @@ export async function loadWorkerArtifactInput( outputPath: string, mainModule?: string, staticAssets?: WorkerStaticAssetsInput, + containers?: WorkerContainerInput[], ): Promise { - if (!outputPath.endsWith(".bundle")) return loadWorkerArtifact(outputPath, mainModule, staticAssets); + if (!outputPath.endsWith(".bundle")) return attachContainers(loadWorkerArtifact(outputPath, mainModule, staticAssets), containers); if (mainModule) throw new WorkerArtifactError("Wrangler bundles contain their own main_module; omit --main/build.main"); if (!existsSync(outputPath) || !lstatSync(outputPath).isFile() || lstatSync(outputPath).isSymbolicLink()) { throw new WorkerArtifactError("Wrangler bundle must be a regular file"); @@ -552,7 +564,7 @@ export async function loadWorkerArtifactInput( if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) throw new WorkerArtifactError("Invalid Wrangler metadata"); // Resource identities and credentials are owned by xAPI's control plane. // Do not silently import a native binding that has no managed equivalent here. - const known = new Set(["main_module", "bindings", "compatibility_date", "compatibility_flags", "observability", "package_dependencies"]); + const known = new Set(["main_module", "bindings", "compatibility_date", "compatibility_flags", "observability", "package_dependencies", "containers"]); const unknown = Object.keys(metadata).filter(key => !known.has(key)); if (unknown.length) throw new WorkerArtifactError(`Native metadata needs explicit platform mapping: ${unknown.join(", ")}`); const packageDependencies = metadata.package_dependencies; @@ -608,14 +620,80 @@ export async function loadWorkerArtifactInput( if (!modules.some(module => module.path === main && module.contentType === "application/javascript+module")) throw new WorkerArtifactError("Native main_module is missing or not ESM"); modules.sort((a,b) => a.path.localeCompare(b.path)); const assets = staticAssets ? collectAssetFiles(staticAssets) : undefined; - const bundle: WorkerArtifactBundle = { version: 1, mainModule: main, modules, ...(observability ? {observability} : {}), ...(assets ? {assets} : {}) }; + const nativeContainers = metadata.containers; + if (nativeContainers !== undefined && (!Array.isArray(nativeContainers) || nativeContainers.some((item) => !item || typeof item !== 'object' || Array.isArray(item) || typeof (item as UnknownRecord).class_name !== 'string' || Object.keys(item as UnknownRecord).some((key) => key !== 'class_name')))) { + throw new WorkerArtifactError('Native Container metadata needs explicit platform mapping'); + } + const configuredClasses = [...(containers || [])].map((item) => item.className).sort(); + const nativeClasses = [...((nativeContainers || []) as UnknownRecord[])].map((item) => String(item.class_name)).sort(); + if (JSON.stringify(nativeClasses) !== JSON.stringify(configuredClasses)) { + throw new WorkerArtifactError('Wrangler Container classes differ from xapi.worker.json; rebuild or re-import before publishing'); + } + const bundle: WorkerArtifactBundle = { version: 1, mainModule: main, modules, ...(observability ? {observability} : {}), ...(assets ? {assets} : {}), ...(containers?.length ? {containers} : {}) }; assertArtifactContentLimit(bundle); - const stored = Buffer.from(JSON.stringify({ ...(observability ? {observability} : {}), version: 1, mainModule: main, modules: modules.map(module => ({ - path: module.path, contentBase64: Buffer.from(module.content, module.encoding === "base64" ? "base64" : "utf8").toString("base64"), contentType: module.contentType, - })), ...(assets ? {assets} : {}) })); + const stored = storedBundleBytes(bundle); return { kind: "bundle", contentSha256: sha256(stored), sizeBytes: stored.length, upload: {bundle}, nativeMetadata: metadata }; } +function attachContainers( + artifact: LoadedWorkerArtifact, + containers?: WorkerContainerInput[], +): LoadedWorkerArtifact { + if (!containers?.length) return artifact; + if (!('bundle' in artifact.upload)) { + const moduleCode = artifact.upload.moduleCode; + const bundle: WorkerArtifactBundle = { + version: 1, + mainModule: 'index.mjs', + modules: [{ + path: 'index.mjs', + content: moduleCode, + encoding: 'utf8', + contentType: 'application/javascript+module', + }], + containers, + }; + const bytes = storedBundleBytes(bundle); + return { kind: 'bundle', contentSha256: sha256(bytes), sizeBytes: bytes.length, upload: { bundle } }; + } + const bundle = { ...artifact.upload.bundle, containers }; + const bytes = storedBundleBytes(bundle); + return { ...artifact, contentSha256: sha256(bytes), sizeBytes: bytes.length, upload: { bundle } }; +} + +function storedBundleBytes(bundle: WorkerArtifactBundle): Buffer { + const modules = [...bundle.modules] + .sort((a, b) => a.path.localeCompare(b.path)) + .map((module) => ({ + path: module.path, + contentBase64: + module.encoding === 'base64' + ? module.content + : Buffer.from(module.content, 'utf8').toString('base64'), + contentType: module.contentType, + })); + const assets = bundle.assets + ? { + files: [...bundle.assets.files].sort((a, b) => + a.path.localeCompare(b.path), + ), + ...(bundle.assets.binding ? { binding: bundle.assets.binding } : {}), + ...(bundle.assets.config ? { config: bundle.assets.config } : {}), + } + : undefined; + return Buffer.from( + JSON.stringify({ + ...(bundle.observability ? { observability: bundle.observability } : {}), + ...(bundle.containers?.length ? { containers: bundle.containers } : {}), + version: 1, + mainModule: bundle.mainModule, + modules, + ...(assets ? { assets } : {}), + }), + 'utf8', + ); +} + export function validateNativeDeploymentMetadata( artifact: LoadedWorkerArtifact, settings: { compatibilityDate?: string; compatibilityFlags?: string[] }, diff --git a/src/workers-plan.ts b/src/workers-plan.ts index 1c68ea4..47a27aa 100644 --- a/src/workers-plan.ts +++ b/src/workers-plan.ts @@ -456,6 +456,7 @@ async function localArtifact(project: LoadedWorkerProject, environment: "preview ), } : undefined, + project.config.containers, ); validateNativeDeploymentMetadata(artifact, readWranglerDeploymentSettings(project, environment), project.config.environments[environment].resources); return { diff --git a/src/workers-project.ts b/src/workers-project.ts index 2142a6a..4c9ae1b 100644 --- a/src/workers-project.ts +++ b/src/workers-project.ts @@ -161,6 +161,50 @@ const staticAssetsSchema = z }) .strict(); +export const workerContainerSchema = z + .object({ + name: z.string().regex(/^[a-z](?:[a-z0-9-]{0,62}[a-z0-9])?$/), + className: z.string().regex(/^[A-Za-z_$][A-Za-z0-9_$]{0,127}$/), + image: z + .string() + .max(512) + .regex( + /^(?:registry\.cloudflare\.com\/[a-f0-9]{32}\/[a-z0-9._/-]+(?::[a-zA-Z0-9._-]+|@sha256:[a-f0-9]{64})?|docker\.io\/[a-z0-9._-]+\/[a-z0-9._/-]+(?::[a-zA-Z0-9._-]+|@sha256:[a-f0-9]{64})?|[a-z0-9.-]+\.dkr\.ecr\.[a-z0-9-]+\.amazonaws\.com\/[a-z0-9._/-]+(?::[a-zA-Z0-9._-]+|@sha256:[a-f0-9]{64})?|[a-z0-9.-]+-docker\.pkg\.dev\/[a-z0-9._/-]+(?::[a-zA-Z0-9._-]+|@sha256:[a-f0-9]{64})?)$/, + 'must be a remote image in Cloudflare Registry, Docker Hub, ECR, or Artifact Registry', + ), + instanceType: z + .enum(['lite', 'basic', 'standard-1', 'standard-2', 'standard-3', 'standard-4']) + .default('lite'), + maxInstances: z.number().int().min(1).max(100).default(20), + constraints: z + .object({ + regions: z + .array(z.enum(['ENAM', 'WNAM', 'EEUR', 'WEUR', 'APAC', 'SAM', 'ME', 'OC', 'AFR'])) + .min(1) + .max(8) + .optional(), + jurisdiction: z.enum(['eu', 'fedramp']).optional(), + }) + .strict() + .optional(), + rolloutActiveGracePeriod: z.number().int().min(0).max(86_400).default(0), + }) + .strict(); + +const containersSchema = z + .array(workerContainerSchema) + .max(10) + .superRefine((containers, context) => { + const names = new Set(); + const classes = new Set(); + containers.forEach((container, index) => { + if (names.has(container.name)) context.addIssue({ code: 'custom', path: [index, 'name'], message: 'must be unique' }); + if (classes.has(container.className)) context.addIssue({ code: 'custom', path: [index, 'className'], message: 'must be unique' }); + names.add(container.name); + classes.add(container.className); + }); + }); + export const workerProjectConfigSchema = z .object({ $schema: z.literal(WORKER_PROJECT_SCHEMA_URL).optional(), @@ -188,6 +232,7 @@ export const workerProjectConfigSchema = z }) .strict(), assets: staticAssetsSchema.optional(), + containers: containersSchema.optional(), environments: z .object({ preview: environmentSchema, @@ -199,6 +244,24 @@ export const workerProjectConfigSchema = z export type WorkerProjectConfig = z.infer; +export function assertWorkerContainerBindings(config: WorkerProjectConfig): void { + for (const container of config.containers || []) { + for (const environment of ['preview', 'production'] as const) { + const matching = config.environments[environment].resources.filter( + (resource) => + resource.type === 'durable_object' && + resource.className === container.className, + ); + if (matching.length !== 1) { + throw new WorkerProjectConfigError( + 'worker_container_durable_object_missing', + `Container ${container.name} className ${container.className} must match exactly one durable_object class in ${environment}`, + ); + } + } + } +} + export interface LoadedWorkerProject { configPath: string; rootDir: string; @@ -333,6 +396,7 @@ export function loadWorkerProject( `Invalid Worker project config: ${validationMessage(parsed.error)}`, ); } + assertWorkerContainerBindings(parsed.data); return { configPath, rootDir: dirname(configPath), config: parsed.data }; } diff --git a/src/workers-wrangler-import.ts b/src/workers-wrangler-import.ts index 19aeccc..2723f30 100644 --- a/src/workers-wrangler-import.ts +++ b/src/workers-wrangler-import.ts @@ -10,6 +10,7 @@ import { basename, dirname, extname, relative, resolve, sep } from "node:path"; import { parse as parseJsonc, printParseErrorCode } from "jsonc-parser"; import { parse as parseToml } from "smol-toml"; import { + assertWorkerContainerBindings, WORKER_PROJECT_CONFIG_FILE, WORKER_PROJECT_SCHEMA_URL, type LoadedWorkerProject, @@ -102,6 +103,7 @@ const MANAGED_TOP_LEVEL = new Set([ "durable_objects", "queues", "workflows", + "containers", ]); const REENTER_TOP_LEVEL = new Set(["secrets", "secrets_store_secrets"]); const PUBLIC_VARIABLE_TOP_LEVEL = new Set(["vars"]); @@ -332,6 +334,69 @@ function staticAssets( return parsed.data; } +function containerApplications( + preview: UnknownRecord, + production: UnknownRecord, + entries: WranglerCompatibilityEntry[], +): WorkerProjectConfig['containers'] | undefined { + const previewContainers = preview.containers; + const productionContainers = production.containers; + if (previewContainers === undefined && productionContainers === undefined) return undefined; + if (JSON.stringify(previewContainers) !== JSON.stringify(productionContainers)) { + compatibilityEntry(entries, 'UNSUPPORTED', 'containers', 'Environment-specific Container application settings are not portable; use one shared Container configuration'); + return undefined; + } + const source = Array.isArray(previewContainers) ? previewContainers : productionContainers; + if (!Array.isArray(source)) { + compatibilityEntry(entries, 'UNSUPPORTED', 'containers', 'Wrangler containers must be an array'); + return undefined; + } + const candidates = source.map((raw, index) => { + const item = record(raw); + const path = `containers[${index}]`; + if (!item) { + compatibilityEntry(entries, 'UNSUPPORTED', path, 'Container configuration must be an object'); + return null; + } + const retained = new Set(['name', 'class_name', 'image', 'instance_type', 'max_instances', 'constraints', 'rollout_active_grace_period']); + for (const key of Object.keys(item)) { + if (!retained.has(key)) compatibilityEntry(entries, 'UNSUPPORTED', `${path}.${key}`, 'This native Container option is not supported by xAPI yet'); + } + const constraints = record(item.constraints); + if (constraints) { + for (const key of Object.keys(constraints)) { + if (!['regions', 'jurisdiction'].includes(key)) compatibilityEntry(entries, 'UNSUPPORTED', `${path}.constraints.${key}`, 'This Container placement constraint is not supported by xAPI yet'); + } + } + const candidate = { + name: item.name, + className: item.class_name, + image: item.image, + ...(item.instance_type !== undefined ? { instanceType: item.instance_type } : {}), + ...(item.max_instances !== undefined ? { maxInstances: item.max_instances } : {}), + ...(constraints + ? { + constraints: { + ...(constraints.regions !== undefined ? { regions: constraints.regions } : {}), + ...(constraints.jurisdiction !== undefined ? { jurisdiction: constraints.jurisdiction } : {}), + }, + } + : {}), + ...(item.rollout_active_grace_period !== undefined + ? { rolloutActiveGracePeriod: item.rollout_active_grace_period } + : {}), + }; + const parsed = workerProjectConfigSchema.shape.containers.unwrap().element.safeParse(candidate); + if (!parsed.success) { + compatibilityEntry(entries, 'UNSUPPORTED', path, `Container configuration is invalid: ${parsed.error.issues[0]?.message || 'invalid configuration'}`); + return null; + } + compatibilityEntry(entries, 'MANAGED', path, 'xAPI will deploy this native Container application after its Worker and Durable Object namespace'); + return parsed.data; + }).filter((item): item is NonNullable => !!item); + return candidates.length ? candidates : undefined; +} + function physicalFields( item: UnknownRecord, retained: Set, @@ -846,6 +911,11 @@ export function importWranglerProject( sourceDir, rootDir, ); + const containers = containerApplications( + desired.preview.config, + desired.production.config, + entries, + ); const previewResources = resourceList( desired.preview.config, desired.preview.prefix, @@ -946,6 +1016,7 @@ export function importWranglerProject( ...(build.main ? { main: build.main } : {}), }, ...(assets ? { assets } : {}), + ...(containers ? { containers } : {}), environments: { preview: { dailyBudgetUsd: budget(options.previewDailyBudgetUsd, "preview"), @@ -973,6 +1044,7 @@ export function importWranglerProject( `Imported project field ${issue.path.join(".") || ""}: ${issue.message}`, ); } + assertWorkerContainerBindings(parsed.data); writeFileSync(configPath, `${JSON.stringify(parsed.data, null, 2)}\n`, { encoding: "utf8", flag: "w", From 73e7a61312ae63c0d65795ba0467cb130bcbb426 Mon Sep 17 00:00:00 2001 From: daxiongya Date: Mon, 21 Sep 2026 10:10:10 +0800 Subject: [PATCH 17/28] fix(workers): import initial SQLite DO migrations (cherry picked from commit 78fa45af3fd9b7e5f40e33a1b4693fec5dac29c1) --- skills/xapi-workers/references/deployment.md | 8 ++ src/tests/workers-wrangler-import.test.ts | 69 ++++++++++++ src/workers-wrangler-import.ts | 111 ++++++++++++++++++- 3 files changed, 187 insertions(+), 1 deletion(-) diff --git a/skills/xapi-workers/references/deployment.md b/skills/xapi-workers/references/deployment.md index 7eee750..02803e2 100644 --- a/skills/xapi-workers/references/deployment.md +++ b/skills/xapi-workers/references/deployment.md @@ -51,6 +51,14 @@ production acceptance deployment can therefore use `api.xapi.to` with For an existing project use `xapi workers init --from-wrangler ./wrangler.jsonc` (TOML also supported). Generated framework configs may live below the project root, for example `dist/server/wrangler.json`; run the command from the package directory so xAPI writes `xapi.worker.json` beside `package.json` and resolves generated asset paths back to that root. Read its import report; do not auto-accept unsupported settings. xAPI creates environment-specific resources; do not copy another Cloudflare account's IDs. +An initial Wrangler Durable Object migration containing only +`new_sqlite_classes` is managed when its class set exactly matches the imported +Durable Object bindings. xAPI creates those SQLite classes through managed +Workers for Platforms exports and does not copy provider migration tags. +Renames, deletions, regular-class migrations, repeated classes, and partial +class sets remain blocked because they need an explicit state migration plan. +`preview_urls` is also not copied: xAPI assigns the environment hostname. + `xapi.worker.json` holds desired xAPI state and Worker ID; Wrangler holds entrypoint, compatibility and binding declarations. The persistent-agent template declares the six ordinary managed binding types so it can demonstrate the platform; Containers remain deployment-owned and must be declared explicitly. An ordinary application should declare only the resources its business logic uses. Do not add unrelated bindings merely to complete an acceptance checklist. Test the full resource matrix in a separate disposable Worker or environment, then clean up only that isolated test state. Install/build according to the generated project instructions. Inspect plans for missing permissions, prices, secrets, budget, and policy requirements. ```sh diff --git a/src/tests/workers-wrangler-import.test.ts b/src/tests/workers-wrangler-import.test.ts index 8342396..365fb4d 100644 --- a/src/tests/workers-wrangler-import.test.ts +++ b/src/tests/workers-wrangler-import.test.ts @@ -213,6 +213,75 @@ database_id = "old-d1-id" expect(readFileSync(path, "utf8")).toBe(original); }); + test("maps initial SQLite Durable Object migrations to xAPI managed exports", () => { + const root = workspace(); + const path = join(root, "wrangler.toml"); + writeFileSync( + path, + `name = "collaborative-canvas" +main = "worker/worker.ts" +compatibility_date = "2026-09-21" +preview_urls = true + +[durable_objects] +bindings = [{ name = "ROOM", class_name = "Room" }] + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["Room"] +`, + ); + + const result = importWranglerProject({ cwd: root, wranglerPath: path }); + + expect(result.wrote).toBe(true); + expect(result.report.compatible).toBe(true); + expect(result.report.entries).toContainEqual( + expect.objectContaining({ category: "IGNORED", path: "preview_urls" }), + ); + expect( + result.report.entries.filter( + (entry) => entry.category === "MANAGED" && entry.path === "migrations", + ), + ).toHaveLength(2); + expect( + loadWorkerProject(root).config.environments.preview.resources, + ).toEqual([ + { type: "durable_object", bindingName: "ROOM", className: "Room" }, + ]); + }); + + test("blocks Durable Object migrations that managed exports cannot preserve", () => { + const root = workspace(); + const path = join(root, "wrangler.jsonc"); + writeFileSync( + path, + JSON.stringify({ + name: "unsafe-migration", + main: "worker.ts", + durable_objects: { + bindings: [{ name: "ROOM", class_name: "RoomV2" }], + }, + migrations: [ + { + tag: "v2", + renamed_classes: [{ from: "Room", to: "RoomV2" }], + }, + ], + }), + ); + + const result = importWranglerProject({ cwd: root, wranglerPath: path }); + + expect(result.wrote).toBe(false); + expect(result.report.entries).toContainEqual( + expect.objectContaining({ + category: "UNSUPPORTED", + path: "migrations[0]", + }), + ); + }); + test("requires force to replace only an existing xAPI project config", () => { const root = workspace(); const path = join(root, "wrangler.jsonc"); diff --git a/src/workers-wrangler-import.ts b/src/workers-wrangler-import.ts index 2723f30..5a07b17 100644 --- a/src/workers-wrangler-import.ts +++ b/src/workers-wrangler-import.ts @@ -101,6 +101,7 @@ const MANAGED_TOP_LEVEL = new Set([ "d1_databases", "r2_buckets", "durable_objects", + "migrations", "queues", "workflows", "containers", @@ -111,6 +112,7 @@ const IGNORED_TOP_LEVEL = new Set([ "$schema", "account_id", "workers_dev", + "preview_urls", "route", "routes", "dev", @@ -582,6 +584,97 @@ function publicVariables( } } +function managedDurableObjectMigrations( + config: UnknownRecord, + prefix: string, + environment: "preview" | "production", + resources: DesiredResource[], + entries: WranglerCompatibilityEntry[], +): void { + if (config.migrations === undefined || structurallyEmpty(config.migrations)) { + return; + } + if (!Array.isArray(config.migrations)) { + compatibilityEntry( + entries, + "UNSUPPORTED", + `${prefix}migrations`, + "Wrangler migrations must be an array", + { environment }, + ); + return; + } + + const declaredClasses = new Set( + resources.flatMap((resource) => + resource.type === "durable_object" && + typeof resource.className === "string" + ? [resource.className] + : [], + ), + ); + const migratedClasses = new Set(); + let valid = true; + + config.migrations.forEach((value, index) => { + const path = `${prefix}migrations[${index}]`; + const migration = record(value); + const keys = migration ? Object.keys(migration) : []; + const classes = migration?.new_sqlite_classes; + if ( + !migration || + typeof migration.tag !== "string" || + !migration.tag.trim() || + !Array.isArray(classes) || + classes.length < 1 || + classes.some( + (className) => + typeof className !== "string" || + !CLASS_NAME.test(className) || + !declaredClasses.has(className) || + migratedClasses.has(className), + ) || + keys.some((key) => key !== "tag" && key !== "new_sqlite_classes") + ) { + valid = false; + compatibilityEntry( + entries, + "UNSUPPORTED", + path, + "Only initial new_sqlite_classes migrations that exactly match managed Durable Object bindings can be imported; rename, delete, regular-class, and repeated-class migrations require an explicit migration workflow", + { environment }, + ); + return; + } + for (const className of classes as string[]) migratedClasses.add(className); + }); + + if ( + valid && + (migratedClasses.size !== declaredClasses.size || + [...declaredClasses].some((className) => !migratedClasses.has(className))) + ) { + compatibilityEntry( + entries, + "UNSUPPORTED", + `${prefix}migrations`, + "Initial SQLite migrations must exactly match the Durable Object classes managed by this environment", + { environment }, + ); + return; + } + + if (valid) { + compatibilityEntry( + entries, + "MANAGED", + `${prefix}migrations`, + "xAPI will create the declared SQLite Durable Object classes through managed Workers for Platforms exports; provider migration tags are not copied", + { environment, resourceType: "durable_object" }, + ); + } +} + function secretNames( config: UnknownRecord, prefix: string, @@ -660,7 +753,9 @@ function inspectTopLevel( key, key === "account_id" || key === "route" || key === "routes" ? "Provider ownership is not transferred; xAPI uses its own Cloudflare account and routing" - : "This Wrangler deployment option is not copied into xAPI project state", + : key === "preview_urls" + ? "xAPI assigns an environment hostname, so Cloudflare preview URL generation is not copied" + : "This Wrangler deployment option is not copied into xAPI project state", ); } else { if (structurallyEmpty(root[key])) continue; @@ -940,6 +1035,20 @@ export function importWranglerProject( "production", entries, ); + managedDurableObjectMigrations( + desired.preview.config, + desired.preview.prefix, + "preview", + previewResources, + entries, + ); + managedDurableObjectMigrations( + desired.production.config, + desired.production.prefix, + "production", + productionResources, + entries, + ); const previewSecrets = secretNames( desired.preview.config, desired.preview.prefix, From 8cee115528580221382b1cab1c9fb6bd95e0ffa1 Mon Sep 17 00:00:00 2001 From: daxiongya Date: Mon, 21 Sep 2026 10:24:51 +0800 Subject: [PATCH 18/28] fix(workers): deploy large workspace applications (cherry picked from commit 67d27327729a241bac7ebcaf3152ba17eaf3cee5) --- README.md | 5 ++- skills/xapi/guides/workers.md | 4 +- src/tests/workers-artifact.test.ts | 24 +++++++++++ src/tests/workers-init.test.ts | 39 +++++++++++++++++ src/tests/workers-push.test.ts | 44 ++++++++++++++++++++ src/workers-client.ts | 67 +++++++++++++++++++----------- src/workers-framework-init.ts | 58 +++++++++++++++++++------- src/workers-push.ts | 11 +++++ 8 files changed, 212 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 90f5043..38b8a10 100644 --- a/README.md +++ b/README.md @@ -505,7 +505,10 @@ xapi workers push --env preview The initializer adds `xapi:build`, `xapi:worker:build`, and `xapi:worker:dev`, plus a small `xapi-worker/index.ts`, `wrangler.jsonc`, and `xapi.worker.json`. `xapi:worker:dev` is only a package script around Wrangler; -there is no separate xAPI local runtime. Use `--framework react|vite|vue|next` +there is no separate xAPI local runtime. For workspace packages, `init` walks +to the repository root and honors its declared `packageManager` or lockfile; +the printed install and build commands are therefore safe for Yarn and pnpm +monorepos as well as npm and Bun projects. Use `--framework react|vite|vue|next` only when automatic package detection is ambiguous. `init` is a one-time adapter setup, not a synchronization command; after it creates `xapi.worker.json`, use resource commands and `plan` to manage state. diff --git a/skills/xapi/guides/workers.md b/skills/xapi/guides/workers.md index 8982fc5..05ba474 100644 --- a/skills/xapi/guides/workers.md +++ b/skills/xapi/guides/workers.md @@ -79,7 +79,9 @@ xapi workers push --env preview The added files are `xapi.worker.json`, `wrangler.jsonc`, and `xapi-worker/index.ts`. The added package scripts are `xapi:build`, `xapi:worker:build`, and `xapi:worker:dev`. Review the generated diff before -installing dependencies. Re-running `init` is not a synchronization command; +installing dependencies. A package inside a monorepo inherits the repository's +declared package manager or lockfile; use the install and build commands printed +by `init` rather than substituting npm. Re-running `init` is not a synchronization command; once `xapi.worker.json` exists, manage it with the project and resource commands. Use `--framework react|vite|vue|next` only for ambiguous package metadata. diff --git a/src/tests/workers-artifact.test.ts b/src/tests/workers-artifact.test.ts index abc3a7a..feba40c 100644 --- a/src/tests/workers-artifact.test.ts +++ b/src/tests/workers-artifact.test.ts @@ -128,6 +128,30 @@ describe("Worker Artifact loader", () => { }); }); + test("accepts complete-project assets larger than the legacy JSON limit", () => { + const root = directory(); + const worker = join(root, "worker.mjs"); + const assets = join(root, "public"); + mkdirSync(assets); + writeFileSync(worker, "export default {};"); + writeFileSync(join(assets, "large.bin"), Buffer.alloc(13 * 1024 * 1024, 7)); + + const artifact = loadWorkerArtifact(worker, undefined, { + directory: assets, + binding: "ASSETS", + }); + + expect(artifact.kind).toBe("bundle"); + if (!("bundle" in artifact.upload)) throw new Error("expected bundle"); + expect(artifact.upload.bundle.assets?.files[0]).toEqual( + expect.objectContaining({ + path: "/large.bin", + encoding: "base64", + contentType: "application/octet-stream", + }), + ); + }); + test("rejects missing relative modules and static website assets", () => { const root = directory(); writeFileSync( diff --git a/src/tests/workers-init.test.ts b/src/tests/workers-init.test.ts index a985d33..29c0ec3 100644 --- a/src/tests/workers-init.test.ts +++ b/src/tests/workers-init.test.ts @@ -250,6 +250,45 @@ describe("workers init", () => { }); }); + test("uses the repository package manager when adopting a workspace package", () => { + const cwd = workspace(); + mkdirSync(join(cwd, ".git")); + mkdirSync(join(cwd, "apps")); + const target = join(cwd, "apps/web"); + mkdirSync(target); + writeFileSync( + join(cwd, "package.json"), + JSON.stringify({ + private: true, + packageManager: "yarn@1.22.22", + workspaces: ["apps/*"], + }), + ); + writeFileSync(join(cwd, "yarn.lock"), ""); + writeFileSync( + join(target, "package.json"), + JSON.stringify({ + name: "workspace-web", + scripts: { build: "vite build" }, + dependencies: { react: "latest" }, + devDependencies: { vite: "latest" }, + }), + ); + + const result = initWorkerProject({ cwd, target: "apps/web" }); + const pkg = JSON.parse(readFileSync(join(target, "package.json"), "utf8")); + expect(pkg.scripts["xapi:build"]).toBe( + "corepack yarn run build && corepack yarn run xapi:worker:build", + ); + expect(loadWorkerProject(target).config.build.command).toBe( + "corepack yarn run xapi:build", + ); + expect(result.nextSteps.slice(0, 2)).toEqual([ + "corepack yarn install", + "corepack yarn run xapi:build", + ]); + }); + test("uses the repository package manager for a nested workspace package", () => { const cwd = workspace(); writeFileSync(join(cwd, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"); diff --git a/src/tests/workers-push.test.ts b/src/tests/workers-push.test.ts index e604b30..d513707 100644 --- a/src/tests/workers-push.test.ts +++ b/src/tests/workers-push.test.ts @@ -717,6 +717,50 @@ writeFileSync("observed-key.txt", process.env.XAPI_KEY || ""); expect(platform.calls.deploy).toBe(0); }); + test("diagnoses a stale control-plane ingress when bundle upload returns 413", async () => { + const root = fixture({ linked: true }); + const configPath = join(root, "xapi.worker.json"); + const config = JSON.parse(readFileSync(configPath, "utf8")); + config.build = { command: "fake-build", output: "dist", main: "worker.mjs" }; + writeFileSync(configPath, JSON.stringify(config, null, 2)); + const platform = fakePlatform({ exists: true }); + platform.client.uploadWorkerArtifact = async () => { + throw new HttpError(413, "Request Entity Too Large"); + }; + + let caught: WorkerPushError | undefined; + try { + await pushWorkerProject({ + cwd: root, + environment: "preview", + clientOptions: { apiHost: "localhost:3003", apiKey: "test-key" }, + client: platform.client, + confirm: async () => true, + runBuild: async () => { + mkdirSync(join(root, "dist"), { recursive: true }); + writeFileSync( + join(root, "dist/worker.mjs"), + 'import "./chunk.mjs"; export default {};', + ); + writeFileSync(join(root, "dist/chunk.mjs"), "export {};"); + }, + }); + } catch (error) { + caught = error as WorkerPushError; + } + + expect(caught?.message).toContain("complete-project upload"); + expect(caught?.recovery).toEqual( + expect.objectContaining({ + workerId, + resourcesPreserved: true, + errorCode: "worker_artifact_ingress_too_small", + expectedIngressLimitMiB: 128, + }), + ); + expect(platform.calls.deploy).toBe(0); + }); + test("uploads a code-split directory as one immutable Artifact", async () => { const root = fixture({ linked: true }); const configPath = join(root, "xapi.worker.json"); diff --git a/src/workers-client.ts b/src/workers-client.ts index 1502036..e261d12 100644 --- a/src/workers-client.ts +++ b/src/workers-client.ts @@ -1,5 +1,8 @@ import { request } from "./client.ts"; -import type { WorkerArtifactUploadRequest } from "./workers-artifact.ts"; +import type { + WorkerArtifactBundle, + WorkerArtifactUploadRequest, +} from "./workers-artifact.ts"; import { scheme } from "./config.ts"; export interface WorkersClientOptions { @@ -132,38 +135,54 @@ export function uploadWorkerArtifact( if ("bundle" in input) { const form = new FormData(); const files: Blob[] = []; - const file = (content: string, encoding: "utf8" | "base64", type: string) => { - const bytes = Buffer.from(content, encoding === "base64" ? "base64" : "utf8"); - const index = files.length; - files.push(new Blob([bytes], { type })); - return index; + const addFile = ( + entry: + | WorkerArtifactBundle["modules"][number] + | NonNullable["files"][number], + ) => { + const bytes = entry.encoding === "base64" + ? Buffer.from(entry.content, "base64") + : Buffer.from(entry.content, "utf8"); + const fileIndex = files.length; + files.push(new Blob([bytes], { type: entry.contentType })); + return { fileIndex, path: entry.path, contentType: entry.contentType }; }; const manifest = { version: 2, idempotencyKey: input.idempotencyKey, mainModule: input.bundle.mainModule, - modules: input.bundle.modules.map((module) => ({ - path: module.path, - contentType: module.contentType, - fileIndex: file(module.content, module.encoding, module.contentType), - })), - ...(input.bundle.observability ? { observability: input.bundle.observability } : {}), - ...(input.bundle.assets ? { assets: { - files: input.bundle.assets.files.map((asset) => ({ - path: asset.path, - contentType: asset.contentType, - fileIndex: file(asset.content, "base64", asset.contentType), - })), - ...(input.bundle.assets.binding ? { binding: input.bundle.assets.binding } : {}), - ...(input.bundle.assets.config ? { config: input.bundle.assets.config } : {}), - } } : {}), + modules: input.bundle.modules.map(addFile), + ...(input.bundle.observability + ? { observability: input.bundle.observability } + : {}), + ...(input.bundle.assets + ? { + assets: { + files: input.bundle.assets.files.map(addFile), + ...(input.bundle.assets.binding + ? { binding: input.bundle.assets.binding } + : {}), + ...(input.bundle.assets.config + ? { config: input.bundle.assets.config } + : {}), + }, + } + : {}), }; form.append("manifest", JSON.stringify(manifest)); - files.forEach((blob, index) => form.append("files", blob, `artifact-${index}`)); + files.forEach((file, index) => + form.append("files", file, `file-${index}`), + ); return request( url(options, `/${encodeURIComponent(id)}/artifacts/bundle`), - { method: "POST", headers: headers(options), body: form }, - 180_000, + { + method: "POST", + // fetch supplies the multipart boundary. Setting Content-Type here + // would make the body unparsable. + headers: headers(options), + body: form, + }, + 300_000, ); } return request( diff --git a/src/workers-framework-init.ts b/src/workers-framework-init.ts index da4a5cb..1d20271 100644 --- a/src/workers-framework-init.ts +++ b/src/workers-framework-init.ts @@ -5,7 +5,7 @@ import { readFileSync, writeFileSync, } from "node:fs"; -import { dirname, join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { WORKER_PROJECT_SCHEMA_URL, type WorkerProjectConfig, @@ -15,6 +15,7 @@ import { type PackageJson = Record & { name?: string; + packageManager?: string; scripts?: Record; dependencies?: Record; devDependencies?: Record; @@ -147,24 +148,53 @@ function detectFramework( ); } +function managerCommands( + name: "npm" | "pnpm" | "yarn" | "bun", + corepack: boolean, +): { command: string; install: string } { + const command = corepack && (name === "pnpm" || name === "yarn") + ? `corepack ${name}` + : name; + return { command, install: `${command} install` }; +} + function packageManager(rootDir: string): { command: string; install: string } { - let current = rootDir; + let cursor = rootDir; while (true) { - if (existsSync(join(current, "pnpm-lock.yaml"))) - return { command: "pnpm", install: "pnpm install" }; - if (existsSync(join(current, "yarn.lock"))) - return { command: "yarn", install: "yarn install" }; + const packagePath = join(cursor, "package.json"); + if (existsSync(packagePath) && lstatSync(packagePath).isFile()) { + const declared = readPackage(packagePath).packageManager; + const name = typeof declared === "string" + ? declared.match(/^(npm|pnpm|yarn|bun)@/)?.[1] + : undefined; + if (name) { + return managerCommands( + name as "npm" | "pnpm" | "yarn" | "bun", + name === "pnpm" || name === "yarn", + ); + } + } + if (existsSync(join(cursor, "pnpm-lock.yaml"))) + return managerCommands("pnpm", false); + if (existsSync(join(cursor, "yarn.lock"))) + return managerCommands("yarn", false); if ( - existsSync(join(current, "bun.lock")) || - existsSync(join(current, "bun.lockb")) + existsSync(join(cursor, "bun.lock")) || + existsSync(join(cursor, "bun.lockb")) ) - return { command: "bun", install: "bun install" }; - if (existsSync(join(current, ".git"))) break; - const parent = dirname(current); - if (parent === current) break; - current = parent; + return managerCommands("bun", false); + if (existsSync(join(cursor, "package-lock.json"))) + return managerCommands("npm", false); + + // Existing applications are often initialized from a workspace package. + // Include the repository root itself, then stop so an unrelated lockfile + // higher in the filesystem cannot change the generated commands. + if (existsSync(join(cursor, ".git"))) break; + const parent = dirname(cursor); + if (parent === cursor || basename(cursor) === "node_modules") break; + cursor = parent; } - return { command: "npm", install: "npm install" }; + return managerCommands("npm", false); } function outputDirectory(framework: ExistingFramework): string { diff --git a/src/workers-push.ts b/src/workers-push.ts index 61feab0..690eb41 100644 --- a/src/workers-push.ts +++ b/src/workers-push.ts @@ -513,6 +513,17 @@ async function ensureArtifact( "Artifact", ); } catch (error) { + if (error instanceof HttpError && error.status === 413 && "bundle" in bundle.upload) { + throw new WorkerPushError( + "The xAPI control plane rejected the complete-project upload before Artifact creation", + { + errorCode: "worker_artifact_ingress_too_small", + endpoint: `/api/v1/workers/${workerId}/artifacts/bundle`, + expectedIngressLimitMiB: 128, + next: "Deploy the control-plane multipart Artifact endpoint and its scoped 128 MiB ingress route, then rerun workers push", + }, + ); + } if (!shouldReconcileWrite(error)) throw error; const reconciled = await find(); if (reconciled) return reconciled; From 386aa58520a7923f8359cf48925549e9e3ba9d8f Mon Sep 17 00:00:00 2001 From: daxiongya Date: Mon, 21 Sep 2026 21:25:53 +0800 Subject: [PATCH 19/28] fix(workers): diagnose incompatible legacy artifacts (cherry picked from commit c48bb0a91061240b023ceeb98780c06e4e892511) --- src/tests/workers-client.test.ts | 67 ++++++++++++++++++++++++++++++++ src/workers-client.ts | 55 +++++++++++++++++++------- 2 files changed, 109 insertions(+), 13 deletions(-) diff --git a/src/tests/workers-client.test.ts b/src/tests/workers-client.test.ts index 9452c00..0f0747b 100644 --- a/src/tests/workers-client.test.ts +++ b/src/tests/workers-client.test.ts @@ -216,6 +216,73 @@ describe("workers client", () => { }); }); + it("falls back to the legacy JSON bundle endpoint during a rolling backend deployment", async () => { + fetchSpy = spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response(JSON.stringify({message: "Cannot POST /artifacts/bundle"}), { + status: 404, + headers: {"content-type": "application/json"}, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({id: "artifact-legacy"}), { + status: 200, + headers: {"content-type": "application/json"}, + }), + ) as any; + const input = { + bundle: { + version: 1 as const, + mainModule: "worker.js", + modules: [{ + path: "worker.js", + content: "export default {};", + encoding: "utf8" as const, + contentType: "application/javascript+module" as const, + }], + }, + idempotencyKey: "rolling-deploy-v1", + }; + + await uploadWorkerArtifact(options, "worker/id", input); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(fetchSpy.mock.calls[0][0]).toBe( + "https://test.xapi.to/api/v1/workers/worker%2Fid/artifacts/bundle", + ); + expect(fetchSpy.mock.calls[1][0]).toBe( + "https://test.xapi.to/api/v1/workers/worker%2Fid/artifacts", + ); + expect(JSON.parse((fetchSpy.mock.calls[1][1] as RequestInit).body as string)).toEqual(input); + }); + + it("rejects bundles that the legacy backend cannot retrieve", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({message: "Cannot POST /artifacts/bundle"}), { + status: 404, + headers: {"content-type": "application/json"}, + }), + ) as any; + const input = { + bundle: { + version: 1 as const, + mainModule: "worker.js", + modules: [{ + path: "worker.js", + content: Buffer.alloc(4 * 1024 * 1024).toString("base64"), + encoding: "base64" as const, + contentType: "application/javascript+module" as const, + }], + }, + idempotencyKey: "legacy-too-large-v1", + }; + + await expect( + uploadWorkerArtifact(options, "worker/id", input), + ).rejects.toThrow("exceeds the legacy 5 MiB compatibility channel"); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + it("uses the server-side build endpoint with an extended timeout", async () => { fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( new Response(JSON.stringify({ id: "build-1", status: "SUCCEEDED" }), { diff --git a/src/workers-client.ts b/src/workers-client.ts index e261d12..214e079 100644 --- a/src/workers-client.ts +++ b/src/workers-client.ts @@ -1,4 +1,4 @@ -import { request } from "./client.ts"; +import { HttpError, request } from "./client.ts"; import type { WorkerArtifactBundle, WorkerArtifactUploadRequest, @@ -127,7 +127,12 @@ export function listWorkerArtifacts(options: WorkersClientOptions, id: string) { ); } -export function uploadWorkerArtifact( +// Older backends persist the complete base64-wrapped request as one object. +// Keep fallback below the observed legacy retrieval ceiling; current backends +// use multipart blobs and do not have this compatibility limit. +const LEGACY_BUNDLE_REQUEST_LIMIT_BYTES = 5 * 1024 * 1024; + +export async function uploadWorkerArtifact( options: WorkersClientOptions, id: string, input: WorkerArtifactUploadRequest, @@ -173,17 +178,41 @@ export function uploadWorkerArtifact( files.forEach((file, index) => form.append("files", file, `file-${index}`), ); - return request( - url(options, `/${encodeURIComponent(id)}/artifacts/bundle`), - { - method: "POST", - // fetch supplies the multipart boundary. Setting Content-Type here - // would make the body unparsable. - headers: headers(options), - body: form, - }, - 300_000, - ); + try { + return await request( + url(options, `/${encodeURIComponent(id)}/artifacts/bundle`), + { + method: "POST", + // fetch supplies the multipart boundary. Setting Content-Type here + // would make the body unparsable. + headers: headers(options), + body: form, + }, + 300_000, + ); + } catch (error) { + // A rolling backend may not expose multipart ingress yet. Preserve + // compatibility with the previous JSON bundle endpoint only while its + // encoded request fits the legacy storage/retrieval path. + if (!(error instanceof HttpError) || error.status !== 404) throw error; + const legacyBody = JSON.stringify(input); + const requestBytes = Buffer.byteLength(legacyBody, "utf8"); + if (requestBytes > LEGACY_BUNDLE_REQUEST_LIMIT_BYTES) { + throw new Error( + `The xAPI backend does not expose multipart Artifact upload and this encoded bundle (${requestBytes} bytes) exceeds the legacy 5 MiB compatibility channel; upgrade the xAPI backend before deploying this project`, + {cause: error}, + ); + } + return request( + url(options, `/${encodeURIComponent(id)}/artifacts`), + { + method: "POST", + headers: headers(options, true), + body: legacyBody, + }, + 180_000, + ); + } } return request( url(options, `/${encodeURIComponent(id)}/artifacts`), From 21744159f427d35f10a5c09e44d87051e784d5a7 Mon Sep 17 00:00:00 2001 From: daxiongya Date: Wed, 23 Sep 2026 02:40:38 +0800 Subject: [PATCH 20/28] fix(workers): preserve container intent in multipart deployments --- docs/workers-refactor.md | 12 ++++++++ src/tests/workers-client.test.ts | 52 ++++++++++++++++++++++++++++++++ src/workers-client.ts | 3 ++ 3 files changed, 67 insertions(+) create mode 100644 docs/workers-refactor.md diff --git a/docs/workers-refactor.md b/docs/workers-refactor.md new file mode 100644 index 0000000..8451555 --- /dev/null +++ b/docs/workers-refactor.md @@ -0,0 +1,12 @@ +# Workers consolidation — 2026-09-23 + +Development branch: `feature/workers-refactor`, created from current `origin/main`. +This is a development baseline, not a release approval. No version, tag, global CI workflow, or production environment changed. + +Sources: #31 (`a6a3016`, native placement/metadata), #35 (`1ade83c`, Container + skill), #34 (`c48bb0a`, managed initial DO migrations / workspace and large-artifact compatibility). Duplicate Wrangler metadata changes are included once. Existing main inspect/plan and credentials handling are preserved. Container declarations now survive multipart serialization, covered by a failing-then-passing client test. + +Open P1 carried forward: #31 discussion_r4057644575 (placementMode omitted from deployment identity). The combined PR uses feat(skill) to satisfy bundled-skill release classification. Do not mark runtime P1 resolved because old PRs are superseded. #37 Release Please stays open and unmerged. + +Verification: bun install --frozen-lockfile; bun run typecheck; bun run build; bun run test (550 pass); npm pack --dry-run --ignore-scripts — all passed. No live xAPI / npm publish verification is claimed. + +Cross-repository inventory: xapi-backend, docs/plan/workers-refactor/README.md on the same branch. No protected-branch reset and no unrelated worktree changes. diff --git a/src/tests/workers-client.test.ts b/src/tests/workers-client.test.ts index 0f0747b..7cbf79c 100644 --- a/src/tests/workers-client.test.ts +++ b/src/tests/workers-client.test.ts @@ -196,6 +196,58 @@ describe("workers client", () => { expect(await files[1].text()).toBe("export {};"); }); + it("preserves Container deployment intent through multipart upload", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ id: "artifact-2" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) as any; + const containers = [{ name: "trader", className: "TraderContainer", image: "docker.io/example/trader:v1", instanceType: "lite" as const, maxInstances: 2, rolloutActiveGracePeriod: 0 }]; + const bundle = { + containers, + version: 1 as const, + mainModule: "worker.js", + modules: [ + { + path: "worker.js", + content: 'import "./chunk.js"; export default {};', + encoding: "utf8" as const, + contentType: "application/javascript+module" as const, + }, + { + path: "chunk.js", + content: "export {};", + encoding: "utf8" as const, + contentType: "application/javascript+module" as const, + }, + ], + }; + await uploadWorkerArtifact(options, "worker/id", { + bundle, + idempotencyKey: "showcase-bundle-v1", + }); + const [target, init] = fetchSpy.mock.calls[0] as any[]; + expect(target).toBe("https://test.xapi.to/api/v1/workers/worker%2Fid/artifacts/bundle"); + expect(init.body).toBeInstanceOf(FormData); + expect(init.headers["Content-Type"]).toBeUndefined(); + const form = init.body as FormData; + expect(JSON.parse(String(form.get("manifest")))).toEqual({ + version: 2, + containers, + idempotencyKey: "showcase-bundle-v1", + mainModule: "worker.js", + modules: [ + { path: "worker.js", contentType: "application/javascript+module", fileIndex: 0 }, + { path: "chunk.js", contentType: "application/javascript+module", fileIndex: 1 }, + ], + }); + const files = form.getAll("files") as File[]; + expect(files).toHaveLength(2); + expect(await files[0].text()).toBe('import "./chunk.js"; export default {};'); + expect(await files[1].text()).toBe("export {};"); + }); + it("updates native environment placement without changing unspecified settings", async () => { fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( new Response(JSON.stringify({ placementMode: "smart" }), { diff --git a/src/workers-client.ts b/src/workers-client.ts index 214e079..a2fe1ec 100644 --- a/src/workers-client.ts +++ b/src/workers-client.ts @@ -157,6 +157,9 @@ export async function uploadWorkerArtifact( idempotencyKey: input.idempotencyKey, mainModule: input.bundle.mainModule, modules: input.bundle.modules.map(addFile), + ...(input.bundle.containers?.length + ? { containers: input.bundle.containers } + : {}), ...(input.bundle.observability ? { observability: input.bundle.observability } : {}), From 5d6bcba2c5be1c2209cd4c1986478f404e8fdb51 Mon Sep 17 00:00:00 2001 From: daxiongya Date: Wed, 23 Sep 2026 07:06:24 +0800 Subject: [PATCH 21/28] fix(workers): include placement in deployment identity --- src/tests/workers-deployment-state.test.ts | 24 +++++++++++++++++++++- src/workers-deployment-state.ts | 3 ++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/tests/workers-deployment-state.test.ts b/src/tests/workers-deployment-state.test.ts index d08f18b..7777b97 100644 --- a/src/tests/workers-deployment-state.test.ts +++ b/src/tests/workers-deployment-state.test.ts @@ -4,7 +4,7 @@ import { deploymentPrefix } from "../workers-deployment-state.ts"; import { RequestTimeoutError } from "../client.ts"; function platform() { - const environment = { id: "env", name: "PREVIEW", activeDeploymentId: null as string | null, bindings: [] }; + const environment = { id: "env", name: "PREVIEW", activeDeploymentId: null as string | null, bindings: [], placementMode: "off" }; const resources: Record[] = []; const secrets: Record[] = []; const deployments: Record[] = []; @@ -77,3 +77,25 @@ test("fingerprint ignores polling noise and resource order, but includes environ expect(fingerprint([a, b])).toBe(fingerprint([b, { ...a, updatedAt: "later", config: { file_size: 20 } }])); expect(fingerprint([a])).not.toBe(fingerprint([a], [{ type: "plain_text", name: "MODE", text: "new" }])); }); + + +test("placement changes deploy the same artifact once, including returning to off", async () => { + const p = platform(); + const first = await p.run(); + p.environment.placementMode = "smart"; + const smart = await p.run(); + expect(smart.deployment.id).not.toBe(first.deployment.id); + expect((await p.run()).deployment.id).toBe(smart.deployment.id); + p.environment.placementMode = "off"; + const off = await p.run(); + expect(off.deployment.id).not.toBe(smart.deployment.id); + expect(off.deployment.id).not.toBe(first.deployment.id); + expect((await p.run()).deployment.id).toBe(off.deployment.id); + expect(p.deployments).toHaveLength(3); +}); + +test("missing placement uses the native off default", () => { + const prefix = (state: Record) => deploymentPrefix("worker", "preview", "artifact", {}, state, [], []); + expect(prefix({})).toBe(prefix({ placementMode: "off" })); + expect(prefix({})).not.toBe(prefix({ placementMode: "smart" })); +}); diff --git a/src/workers-deployment-state.ts b/src/workers-deployment-state.ts index 46b2387..7fab451 100644 --- a/src/workers-deployment-state.ts +++ b/src/workers-deployment-state.ts @@ -19,9 +19,10 @@ const sorted = (rows: Row[]) => rows.sort((a, b) => String(a.bindingName).locale export function deploymentPrefix(workerId: string, environment: string, artifactId: string, compatibility: { compatibilityDate?: string; compatibilityFlags?: string[] }, environmentState: Row, resources: Row[], secrets: Row[]): string { - return `v2-${hash({ workerId, environment: environment.toLowerCase(), artifactId, + return `v3-${hash({ workerId, environment: environment.toLowerCase(), artifactId, compatibilityDate: compatibility.compatibilityDate, compatibilityFlags: [...(compatibility.compatibilityFlags || [])].sort(), + placementMode: environmentState.placementMode || "off", bindings: environmentState.bindings || [], resources: sorted(resources.map(r => { const config = (r.config || {}) as Row; From c7d38070ab7deb9170a04b763aa8acc07421798c Mon Sep 17 00:00:00 2001 From: daxiongya Date: Wed, 23 Sep 2026 08:09:06 +0800 Subject: [PATCH 22/28] fix(workers): preserve Container intent in native project builds --- skills/xapi/guides/workers.md | 8 ++ src/tests/workers-native-bundle.test.ts | 13 +++ src/tests/workers-project-build.test.ts | 103 ++++++++++++++++++++++++ src/workers-artifact.ts | 25 +++++- src/workers-project-build.ts | 1 + 5 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 src/tests/workers-project-build.test.ts diff --git a/skills/xapi/guides/workers.md b/skills/xapi/guides/workers.md index 05ba474..b48ca01 100644 --- a/skills/xapi/guides/workers.md +++ b/skills/xapi/guides/workers.md @@ -282,6 +282,14 @@ D1/R2/KV binding names must match declared xAPI resources; native account IDs and resource IDs are not reused. Secrets are set separately through xAPI. The artifact also preserves `observability.enabled`. +Native bundles may include local Durable Object bindings, matched by both +binding name and class to declared `durable_object` resources. Container classes +must match `xapi.worker.json` Container definitions; their configuration is kept +in the uploaded artifact and its hash. Wrangler-generated Container application +names are not reused as provider identities. External DO script/namespace +references still require a supported ownership-aware mapping; do not remove the +reference silently to make validation pass. + This adapter currently supports the explicitly mapped metadata above, not every Wrangler setting. Unmapped metadata fails before artifact upload rather than being silently discarded. Cron triggers are separate from the upload bundle diff --git a/src/tests/workers-native-bundle.test.ts b/src/tests/workers-native-bundle.test.ts index e7e8eec..00ea7a6 100644 --- a/src/tests/workers-native-bundle.test.ts +++ b/src/tests/workers-native-bundle.test.ts @@ -113,3 +113,16 @@ test('requires native Container classes to match the explicit xAPI deployment in expect(artifact.sizeBytes).toBe(expectedStored.length); await expect(loadWorkerArtifactInput(bundle([native,entry]), undefined, undefined, [])).rejects.toThrow('Container classes differ'); }); + +test('maps local native Durable Object bindings by binding and class, not a foreign namespace', async () => { + const binding = {name:'API_CONTAINER', type:'durable_object_namespace', class_name:'ApiContainer'}; + const native = {...metadata, content:JSON.stringify({...JSON.parse(metadata.content), bindings:[binding]})}; + const artifact = await loadWorkerArtifactInput(bundle([native,entry])); + const settings = {compatibilityDate:'2026-09-10',compatibilityFlags:['nodejs_compat']}; + validateNativeDeploymentMetadata(artifact,settings,[{type:'durable_object',bindingName:'API_CONTAINER',className:'ApiContainer'}]); + expect(() => validateNativeDeploymentMetadata(artifact,settings,[{type:'durable_object',bindingName:'API_CONTAINER',className:'WrongClass'}])).toThrow('API_CONTAINER'); + expect(() => validateNativeDeploymentMetadata(artifact,settings,[])).toThrow('API_CONTAINER'); + for (const foreign of [{script_name:'another-worker'}, {namespace_id:'another-namespace'}, {environment:'production'}]) { + await expect(loadWorkerArtifactInput(bundle([{...native,content:JSON.stringify({...JSON.parse(native.content), bindings:[{...binding,...foreign}]})},entry]))).rejects.toThrow('mapping'); + } +}); diff --git a/src/tests/workers-project-build.test.ts b/src/tests/workers-project-build.test.ts new file mode 100644 index 0000000..75a3472 --- /dev/null +++ b/src/tests/workers-project-build.test.ts @@ -0,0 +1,103 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadWorkerProject } from "../workers-project.ts"; +import { loadWorkerProjectBundle } from "../workers-project-build.ts"; +import { loadWorkerArtifactInput } from "../workers-artifact.ts"; + +const roots: string[] = []; +afterEach(() => + roots + .splice(0) + .forEach((root) => rmSync(root, { recursive: true, force: true })), +); +const container = { + name: "api", + className: "ApiContainer", + image: "docker.io/example/api:v1", + instanceType: "lite" as const, + maxInstances: 2, + rolloutActiveGracePeriod: 0, +}; + +function project(native: boolean, withContainer = true) { + const root = mkdtempSync(join(tmpdir(), "container-project-build-")); + roots.push(root); + const output = native ? "worker.bundle" : "worker.mjs"; + const source = + 'export class ApiContainer {}\nexport default { fetch() { return new Response("ok"); } };'; + const settings = { + main_module: "worker.mjs", + compatibility_date: "2026-09-10", + bindings: [ + { + name: "API_CONTAINER", + type: "durable_object_namespace", + class_name: container.className, + }, + ], + containers: [ + { + name: "wrangler-generated-apicontainer", + class_name: container.className, + }, + ], + }; + writeFileSync( + join(root, output), + native + ? `--test-boundary\r\nContent-Disposition: form-data; name="metadata"\r\n\r\n${JSON.stringify(settings)}\r\n--test-boundary\r\nContent-Disposition: form-data; name="worker.mjs"; filename="worker.mjs"\r\nContent-Type: application/javascript+module\r\n\r\n${source}\r\n--test-boundary--\r\n` + : source, + ); + writeFileSync( + join(root, "wrangler.jsonc"), + JSON.stringify({ compatibility_date: "2026-09-10" }), + ); + const environment = { + dailyBudgetUsd: 0.25, + resources: [ + { + type: "durable_object", + bindingName: "API_CONTAINER", + className: "ApiContainer", + }, + ], + }; + writeFileSync( + join(root, "xapi.worker.json"), + JSON.stringify({ + version: 1, + worker: { name: "Container build", slug: "container-build" }, + wrangler: "wrangler.jsonc", + build: { command: "unused", output }, + ...(withContainer ? { containers: [container] } : {}), + environments: { preview: environment, production: environment }, + }), + ); + return loadWorkerProject(root); +} + +for (const native of [false, true]) { + test(`project build preserves Container intent and artifact identity (${native ? "native bundle" : "module"})`, async () => { + const config = project(native); + const actual = await loadWorkerProjectBundle(config, "preview"); + const expected = await loadWorkerArtifactInput( + join(config.rootDir, config.config.build.output), + undefined, + undefined, + [container], + ); + expect(actual.upload).toEqual(expected.upload); + expect(actual.contentSha256).toBe(expected.contentSha256); + expect(actual.sizeBytes).toBe(expected.sizeBytes); + if (!("bundle" in actual.upload)) + throw new Error("Container requires bundle"); + expect(actual.upload.bundle.containers).toEqual([container]); + }); +} +test("native Container class without explicit project intent remains rejected", async () => { + await expect( + loadWorkerProjectBundle(project(true, false), "preview"), + ).rejects.toThrow("Container classes differ"); +}); diff --git a/src/workers-artifact.ts b/src/workers-artifact.ts index 3bdab03..a21601d 100644 --- a/src/workers-artifact.ts +++ b/src/workers-artifact.ts @@ -588,6 +588,12 @@ export async function loadWorkerArtifactInput( if (metadata.bindings !== undefined && (!Array.isArray(metadata.bindings) || metadata.bindings.some((binding: UnknownRecord) => { if (!binding || typeof binding.name !== "string") return true; if (["d1", "r2_bucket", "kv_namespace", "inherit"].includes(String(binding.type))) return false; + if (binding.type === "durable_object_namespace") { + // Only a class in this script can map to the declared managed DO. An + // external script/namespace needs its own ownership-aware API contract. + return typeof binding.class_name !== "string" || !binding.class_name || + Object.keys(binding).some(key => !["name", "type", "class_name"].includes(key)); + } return binding.type !== "assets" || !staticAssets?.binding || binding.name !== staticAssets.binding; }))) throw new WorkerArtifactError("Native binding metadata needs explicit platform mapping; keep credentials in xAPI Secrets"); if (metadata.compatibility_flags !== undefined && (!Array.isArray(metadata.compatibility_flags) || metadata.compatibility_flags.some(flag => typeof flag !== "string"))) throw new WorkerArtifactError("Invalid native compatibility flags"); @@ -621,7 +627,16 @@ export async function loadWorkerArtifactInput( modules.sort((a,b) => a.path.localeCompare(b.path)); const assets = staticAssets ? collectAssetFiles(staticAssets) : undefined; const nativeContainers = metadata.containers; - if (nativeContainers !== undefined && (!Array.isArray(nativeContainers) || nativeContainers.some((item) => !item || typeof item !== 'object' || Array.isArray(item) || typeof (item as UnknownRecord).class_name !== 'string' || Object.keys(item as UnknownRecord).some((key) => key !== 'class_name')))) { + if (nativeContainers !== undefined && (!Array.isArray(nativeContainers) || nativeContainers.some((item) => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return true; + const value = item as UnknownRecord; + // Wrangler emits a generated application name even for unnamed config. + // xAPI keeps its own scoped application identity; only the class links + // this native upload to the explicit Container deployment definition. + return typeof value.class_name !== 'string' || + (value.name !== undefined && (typeof value.name !== 'string' || !value.name || value.name.length > 512)) || + Object.keys(value).some(key => !['class_name', 'name'].includes(key)); + }))) { throw new WorkerArtifactError('Native Container metadata needs explicit platform mapping'); } const configuredClasses = [...(containers || [])].map((item) => item.className).sort(); @@ -697,7 +712,7 @@ function storedBundleBytes(bundle: WorkerArtifactBundle): Buffer { export function validateNativeDeploymentMetadata( artifact: LoadedWorkerArtifact, settings: { compatibilityDate?: string; compatibilityFlags?: string[] }, - resources: Array<{type: string; bindingName: string}>, + resources: Array<{type: string; bindingName: string; className?: string}>, ): void { const metadata = artifact.nativeMetadata; if (!metadata) return; @@ -710,6 +725,12 @@ export function validateNativeDeploymentMetadata( if (binding.type === "assets" && artifact.upload && "bundle" in artifact.upload && artifact.upload.bundle.assets?.binding === binding.name) continue; const matching = resources.filter(resource => resource.bindingName === binding.name); + if (binding.type === "durable_object_namespace") { + if (matching.length !== 1 || matching[0].type !== "durable_object" || matching[0].className !== binding.class_name) { + throw new WorkerArtifactError(`Native Durable Object binding ${binding.name} must match its declared xAPI class`); + } + continue; + } const declared = binding.type === "inherit" ? matching.length === 1 && Object.values(managed).includes(matching[0].type) : matching.some(resource => resource.type === managed[String(binding.type)]); diff --git a/src/workers-project-build.ts b/src/workers-project-build.ts index be01de0..15af012 100644 --- a/src/workers-project-build.ts +++ b/src/workers-project-build.ts @@ -121,6 +121,7 @@ export async function loadWorkerProjectBundle( ), } : undefined, + project.config.containers, ); validateNativeDeploymentMetadata( bundle, From de175cb5dae717f0f6fef4c4c8002015593d74f5 Mon Sep 17 00:00:00 2001 From: daxiongya Date: Wed, 23 Sep 2026 15:00:51 +0800 Subject: [PATCH 23/28] fix(workers): include public Wrangler vars in project deployments --- ...ers-deployment-test-findings-2026-09-21.md | 7 +- skills/xapi/guides/workers.md | 16 +++-- src/tests/workers-client.test.ts | 2 + src/tests/workers-native-bundle.test.ts | 57 +++++++++++++++ src/tests/workers-project-build.test.ts | 54 +++++++++++++++ src/tests/workers-wrangler-import.test.ts | 34 +++------ src/workers-artifact.ts | 69 ++++++++++++++++++- src/workers-client.ts | 1 + src/workers-plan.ts | 23 ++----- src/workers-project-build.ts | 30 ++++++-- src/workers-wrangler-import.ts | 18 ++++- 11 files changed, 250 insertions(+), 61 deletions(-) diff --git a/docs/workers-deployment-test-findings-2026-09-21.md b/docs/workers-deployment-test-findings-2026-09-21.md index 38eddef..5fc1c51 100644 --- a/docs/workers-deployment-test-findings-2026-09-21.md +++ b/docs/workers-deployment-test-findings-2026-09-21.md @@ -77,9 +77,10 @@ has completed its normal dev → staging → main promotion: - structured runtime error classes that distinguish Worker, xAPI gateway, provider, authentication, and response-schema failures. -Until plain-text bindings exist, the importer fails closed on non-empty -Wrangler `vars`. `--accept-partial` records an explicit user decision but still -does not copy their values. +At the time of this report, non-empty Wrangler `vars` were rejected. The +2026-09-23 workers-refactor follow-up adds public string/JSON vars to immutable +artifacts and the native upload path. See the bundled Workers guide for the +current workflow; public configuration is never converted into Secrets. ## Items confirmed outside platform scope diff --git a/skills/xapi/guides/workers.md b/skills/xapi/guides/workers.md index b48ca01..0e287fb 100644 --- a/skills/xapi/guides/workers.md +++ b/skills/xapi/guides/workers.md @@ -129,12 +129,16 @@ xapi workers push --env preview write a partial project unless the user explicitly accepts the report with `--accept-partial`. -Wrangler `vars` are public plain-text bindings. The importer never copies their -values and never silently converts them into encrypted Secrets. A non-empty -`vars` block is reported as `UNSUPPORTED` until xAPI desired state has an -explicit plain-text binding workflow. Move only genuinely sensitive values to -`secrets`, set them with `workers secrets set`, and keep public values out of -the generated project until the binding is supported. +Wrangler `vars` are public string or JSON bindings. They remain in the referenced +Wrangler config and travel with the immutable deployment artifact, including +multipart uploads. A named environment uses its own `vars` (no root inheritance). +Native `.bundle` output must match the selected environment; rebuild if it is +stale. Module/directory builds include the selected config's public vars. +Changing vars changes the artifact identity. Do not put credentials here: use +`secrets` and `workers secrets set`. The importer report shows names, never values; +it does not read `.env` or `.dev.vars`. Variable names must not collide with +resource, asset or Secret bindings. Ordinary deployment rollback restores the +public vars stored in the selected artifact as well as its code. The project workflow does not require Git. Git repository, branch, and commit are optional provenance, not authentication and not a deployment prerequisite. diff --git a/src/tests/workers-client.test.ts b/src/tests/workers-client.test.ts index 7cbf79c..da8ed2f 100644 --- a/src/tests/workers-client.test.ts +++ b/src/tests/workers-client.test.ts @@ -206,6 +206,7 @@ describe("workers client", () => { const containers = [{ name: "trader", className: "TraderContainer", image: "docker.io/example/trader:v1", instanceType: "lite" as const, maxInstances: 2, rolloutActiveGracePeriod: 0 }]; const bundle = { containers, + vars: { PUBLIC_ORIGIN: "https://app.example", FEATURES: { images: true } }, version: 1 as const, mainModule: "worker.js", modules: [ @@ -235,6 +236,7 @@ describe("workers client", () => { expect(JSON.parse(String(form.get("manifest")))).toEqual({ version: 2, containers, + vars: { PUBLIC_ORIGIN: "https://app.example", FEATURES: { images: true } }, idempotencyKey: "showcase-bundle-v1", mainModule: "worker.js", modules: [ diff --git a/src/tests/workers-native-bundle.test.ts b/src/tests/workers-native-bundle.test.ts index 00ea7a6..fc51f19 100644 --- a/src/tests/workers-native-bundle.test.ts +++ b/src/tests/workers-native-bundle.test.ts @@ -126,3 +126,60 @@ test('maps local native Durable Object bindings by binding and class, not a fore await expect(loadWorkerArtifactInput(bundle([{...native,content:JSON.stringify({...JSON.parse(native.content), bindings:[{...binding,...foreign}]})},entry]))).rejects.toThrow('mapping'); } }); + + +test("carries native public string and JSON vars in immutable artifact identity", async () => { + const vars = { + PUBLIC_ORIGIN: "https://app.example", + FEATURES: { images: true }, + RETRIES: 3, + enabled: false, + }; + const make = (bindings: unknown[]) => + bundle([ + { + ...metadata, + content: JSON.stringify({ + ...JSON.parse(metadata.content), + bindings, + }), + }, + entry, + ]); + const bindings = Object.entries(vars).map(([name, value]) => + typeof value === "string" + ? { name, type: "plain_text", text: value } + : { name, type: "json", json: value }, + ); + const artifact = await loadWorkerArtifactInput(make(bindings)); + if (!("bundle" in artifact.upload)) throw Error("bundle"); + expect(artifact.upload.bundle.vars).toEqual(vars); + const reordered = await loadWorkerArtifactInput( + make([...bindings].reverse()), + ); + expect(artifact.contentSha256).toBe(reordered.contentSha256); + const changed = await loadWorkerArtifactInput( + make([...bindings, { name: "EXTRA", type: "plain_text", text: "new" }]), + ); + expect(changed.contentSha256).not.toBe(artifact.contentSha256); + const settings = { + compatibilityDate: "2026-09-10", + compatibilityFlags: ["nodejs_compat"], + }; + validateNativeDeploymentMetadata(artifact, settings, []); + expect(() => + validateNativeDeploymentMetadata(artifact, settings, [ + { type: "r2_bucket", bindingName: "PUBLIC_ORIGIN" }, + ]), + ).toThrow("Duplicate"); + await expect( + loadWorkerArtifactInput(make([...bindings, bindings[0]])), + ).rejects.toThrow("Duplicate"); + await expect( + loadWorkerArtifactInput( + make([ + { name: "XAPI_AI_BASE_URL", type: "plain_text", text: "override" }, + ]), + ), + ).rejects.toThrow("reserved"); +}); diff --git a/src/tests/workers-project-build.test.ts b/src/tests/workers-project-build.test.ts index 75a3472..80f1e1c 100644 --- a/src/tests/workers-project-build.test.ts +++ b/src/tests/workers-project-build.test.ts @@ -101,3 +101,57 @@ test("native Container class without explicit project intent remains rejected", loadWorkerProjectBundle(project(true, false), "preview"), ).rejects.toThrow("Container classes differ"); }); + + +test("includes selected public vars in module builds without inheriting root vars into a named environment", async () => { + const loaded = project(false, false); + writeFileSync( + join(loaded.rootDir, "wrangler.jsonc"), + JSON.stringify({ + compatibility_date: "2026-09-10", + vars: { ROOT_ONLY: "not-inherited" }, + env: { + preview: { + vars: { ORIGIN: "https://preview.example", FLAGS: { uploads: true } }, + }, + production: {}, + }, + }), + ); + const preview = await loadWorkerProjectBundle(loaded, "preview"); + const production = await loadWorkerProjectBundle(loaded, "production"); + expect("bundle" in preview.upload && preview.upload.bundle.vars).toEqual({ + ORIGIN: "https://preview.example", + FLAGS: { uploads: true }, + }); + expect( + "bundle" in production.upload && production.upload.bundle.vars, + ).toBeFalsy(); + expect(preview.contentSha256).not.toBe(production.contentSha256); +}); + +test("rejects stale native vars and collisions with declared Secrets before publishing", async () => { + const native = project(true); + writeFileSync( + join(native.rootDir, "wrangler.jsonc"), + JSON.stringify({ + compatibility_date: "2026-09-10", + vars: { ORIGIN: "changed" }, + }), + ); + await expect(loadWorkerProjectBundle(native, "preview")).rejects.toThrow( + "vars differ", + ); + const module = project(false, false); + module.config.environments.preview.secrets = ["PRIVATE_TOKEN"]; + writeFileSync( + join(module.rootDir, "wrangler.jsonc"), + JSON.stringify({ + compatibility_date: "2026-09-10", + vars: { PRIVATE_TOKEN: "public" }, + }), + ); + await expect(loadWorkerProjectBundle(module, "preview")).rejects.toThrow( + "Duplicate", + ); +}); diff --git a/src/tests/workers-wrangler-import.test.ts b/src/tests/workers-wrangler-import.test.ts index 365fb4d..6bbc010 100644 --- a/src/tests/workers-wrangler-import.test.ts +++ b/src/tests/workers-wrangler-import.test.ts @@ -119,7 +119,7 @@ describe("Wrangler project import", () => { ); expect(blocked.report.entries).toContainEqual( expect.objectContaining({ - category: "UNSUPPORTED", + category: "SUPPORTED", path: "vars.MODEL_KEY", bindingName: "MODEL_KEY", }), @@ -177,22 +177,10 @@ binding = "DB" database_id = "old-d1-id" `; writeFileSync(path, original); - const blocked = importWranglerProject({ - cwd: root, - wranglerPath: "wrangler.toml", - }); - expect(blocked.wrote).toBe(false); - expect(blocked.report.entries).toContainEqual( - expect.objectContaining({ - category: "UNSUPPORTED", - path: "env.preview.vars.MODEL_KEY", - }), - ); - const result = importWranglerProject({ - cwd: root, - wranglerPath: "wrangler.toml", - acceptPartial: true, - }); + const result = importWranglerProject({ cwd: root, wranglerPath: "wrangler.toml" }); + expect(result.report.entries).toContainEqual(expect.objectContaining({ + category: "SUPPORTED", path: "env.preview.vars.MODEL_KEY", + })); expect(result.wrote).toBe(true); expect(result.report.format).toBe("toml"); const project = loadWorkerProject(root); @@ -401,15 +389,9 @@ new_sqlite_classes = ["Room"] }), ); - const blocked = importWranglerProject({ cwd: root, wranglerPath: path }); - expect(blocked.wrote).toBe(false); - expect(JSON.stringify(blocked.report)).not.toContain("public-visible-value"); - - importWranglerProject({ - cwd: root, - wranglerPath: path, - acceptPartial: true, - }); + const result = importWranglerProject({ cwd: root, wranglerPath: path }); + expect(result.wrote).toBe(true); + expect(JSON.stringify(result.report)).not.toContain("public-visible-value"); const project = loadWorkerProject(root); expect(project.config.environments.preview.secrets).toEqual([ "PRIVATE_TOKEN", diff --git a/src/workers-artifact.ts b/src/workers-artifact.ts index a21601d..471f2ef 100644 --- a/src/workers-artifact.ts +++ b/src/workers-artifact.ts @@ -68,6 +68,7 @@ export interface WorkerArtifactBundle { observability?: { enabled: boolean }; assets?: WorkerArtifactAssets; containers?: WorkerContainerInput[]; + vars?: Record; } export interface WorkerContainerInput { @@ -587,6 +588,12 @@ export async function loadWorkerArtifactInput( )) throw new WorkerArtifactError("Invalid native package dependency metadata"); if (metadata.bindings !== undefined && (!Array.isArray(metadata.bindings) || metadata.bindings.some((binding: UnknownRecord) => { if (!binding || typeof binding.name !== "string") return true; + if (binding.type === "plain_text") { + return typeof binding.text !== "string" || Object.keys(binding).some(key => !["name", "type", "text"].includes(key)); + } + if (binding.type === "json") { + return !("json" in binding) || Object.keys(binding).some(key => !["name", "type", "json"].includes(key)); + } if (["d1", "r2_bucket", "kv_namespace", "inherit"].includes(String(binding.type))) return false; if (binding.type === "durable_object_namespace") { // Only a class in this script can map to the declared managed DO. An @@ -596,6 +603,12 @@ export async function loadWorkerArtifactInput( } return binding.type !== "assets" || !staticAssets?.binding || binding.name !== staticAssets.binding; }))) throw new WorkerArtifactError("Native binding metadata needs explicit platform mapping; keep credentials in xAPI Secrets"); + const nativeBindings = (metadata.bindings || []) as UnknownRecord[]; + const bindingNames = nativeBindings.map(binding => binding.name); + if (new Set(bindingNames).size !== bindingNames.length) throw new WorkerArtifactError("Duplicate native binding name"); + const vars = normalizeWorkerVars(Object.fromEntries(nativeBindings + .filter(binding => binding.type === "plain_text" || binding.type === "json") + .map(binding => [String(binding.name), binding.type === "plain_text" ? binding.text : binding.json]))); if (metadata.compatibility_flags !== undefined && (!Array.isArray(metadata.compatibility_flags) || metadata.compatibility_flags.some(flag => typeof flag !== "string"))) throw new WorkerArtifactError("Invalid native compatibility flags"); const observation = metadata.observability as UnknownRecord | undefined; if (observation !== undefined && (!observation || typeof observation !== "object" || Array.isArray(observation) || typeof observation.enabled !== "boolean" || Object.keys(observation).some(key => key !== "enabled"))) throw new WorkerArtifactError("Native observability config needs explicit mapping"); @@ -644,7 +657,7 @@ export async function loadWorkerArtifactInput( if (JSON.stringify(nativeClasses) !== JSON.stringify(configuredClasses)) { throw new WorkerArtifactError('Wrangler Container classes differ from xapi.worker.json; rebuild or re-import before publishing'); } - const bundle: WorkerArtifactBundle = { version: 1, mainModule: main, modules, ...(observability ? {observability} : {}), ...(assets ? {assets} : {}), ...(containers?.length ? {containers} : {}) }; + const bundle: WorkerArtifactBundle = { version: 1, mainModule: main, modules, ...(vars ? {vars} : {}), ...(observability ? {observability} : {}), ...(assets ? {assets} : {}), ...(containers?.length ? {containers} : {}) }; assertArtifactContentLimit(bundle); const stored = storedBundleBytes(bundle); return { kind: "bundle", contentSha256: sha256(stored), sizeBytes: stored.length, upload: {bundle}, nativeMetadata: metadata }; @@ -700,6 +713,7 @@ function storedBundleBytes(bundle: WorkerArtifactBundle): Buffer { JSON.stringify({ ...(bundle.observability ? { observability: bundle.observability } : {}), ...(bundle.containers?.length ? { containers: bundle.containers } : {}), + ...(normalizeWorkerVars(bundle.vars) ? { vars: normalizeWorkerVars(bundle.vars) } : {}), version: 1, mainModule: bundle.mainModule, modules, @@ -725,6 +739,10 @@ export function validateNativeDeploymentMetadata( if (binding.type === "assets" && artifact.upload && "bundle" in artifact.upload && artifact.upload.bundle.assets?.binding === binding.name) continue; const matching = resources.filter(resource => resource.bindingName === binding.name); + if (binding.type === "plain_text" || binding.type === "json") { + if (matching.length) throw new WorkerArtifactError(`Duplicate Worker binding: ${binding.name}`); + continue; + } if (binding.type === "durable_object_namespace") { if (matching.length !== 1 || matching[0].type !== "durable_object" || matching[0].className !== binding.class_name) { throw new WorkerArtifactError(`Native Durable Object binding ${binding.name} must match its declared xAPI class`); @@ -739,3 +757,52 @@ export function validateNativeDeploymentMetadata( } } } + +/** Public deployment configuration only; credentials use the Secrets API. */ +export function normalizeWorkerVars(value: unknown): Record | undefined { + if (value === undefined) return undefined; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new WorkerArtifactError('Worker vars must be a JSON object'); + } + const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b)); + for (const [name] of entries) { + if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) || name === 'XAPI_AI_BASE_URL') { + throw new WorkerArtifactError(`Invalid or reserved Worker variable: ${name}`); + } + } + try { + // Reject lossy serialization (undefined, functions, NaN, circular values). + JSON.stringify(value, (_key, item) => { + if (item === undefined || typeof item === 'function' || typeof item === 'symbol' || + typeof item === 'bigint' || (typeof item === 'number' && !Number.isFinite(item))) { + throw new Error('Not JSON'); + } + return item; + }); + } catch { + throw new WorkerArtifactError('Worker vars must contain JSON values'); + } + return entries.length ? Object.fromEntries(entries) : undefined; +} + + +/** Keep native output authoritative; reject a stale build instead of changing it silently. */ +export function withWorkerVars(artifact: LoadedWorkerArtifact, value: unknown): LoadedWorkerArtifact { + const vars = normalizeWorkerVars(value); + if (artifact.nativeMetadata) { + const actual = 'bundle' in artifact.upload ? normalizeWorkerVars(artifact.upload.bundle.vars) : undefined; + if (JSON.stringify(actual) !== JSON.stringify(vars)) { + throw new WorkerArtifactError('Wrangler bundle vars differ from the selected environment; rebuild before publishing'); + } + return artifact; + } + if (!vars) return artifact; + const bundle: WorkerArtifactBundle = 'bundle' in artifact.upload + ? { ...artifact.upload.bundle, vars } + : { version: 1, mainModule: 'index.mjs', vars, modules: [{ + path: 'index.mjs', content: artifact.upload.moduleCode, + encoding: 'utf8', contentType: 'application/javascript+module', + }] }; + const bytes = storedBundleBytes(bundle); + return { ...artifact, kind: 'bundle', contentSha256: sha256(bytes), sizeBytes: bytes.length, upload: { bundle } }; +} diff --git a/src/workers-client.ts b/src/workers-client.ts index a2fe1ec..8de6265 100644 --- a/src/workers-client.ts +++ b/src/workers-client.ts @@ -160,6 +160,7 @@ export async function uploadWorkerArtifact( ...(input.bundle.containers?.length ? { containers: input.bundle.containers } : {}), + ...(input.bundle.vars ? { vars: input.bundle.vars } : {}), ...(input.bundle.observability ? { observability: input.bundle.observability } : {}), diff --git a/src/workers-plan.ts b/src/workers-plan.ts index 47a27aa..a27b1d2 100644 --- a/src/workers-plan.ts +++ b/src/workers-plan.ts @@ -4,7 +4,7 @@ import type { WorkersClientOptions, } from "./workers-client.ts"; import * as workersClient from "./workers-client.ts"; -import { loadWorkerArtifactInput, validateNativeDeploymentMetadata, WorkerArtifactError } from "./workers-artifact.ts"; +import { WorkerArtifactError } from "./workers-artifact.ts"; import { deploymentPrefix, currentMatchingDeployment } from "./workers-deployment-state.ts"; import { readWranglerDeploymentSettings } from "./workers-wrangler-import.ts"; import { @@ -17,6 +17,8 @@ import { import { remoteWorkerResourceState } from "./workers-resource-state.ts"; import { prepareWorkerProjectBundle, + loadWorkerProjectBundle, + WorkerProjectBuildError, type WorkerProjectBuildRunner, } from "./workers-project-build.ts"; import type { LoadedWorkerArtifact } from "./workers-artifact.ts"; @@ -443,28 +445,13 @@ async function localArtifact(project: LoadedWorkerProject, environment: "preview ); if (!existsSync(path)) return {}; try { - const artifact = await loadWorkerArtifactInput( - path, - project.config.build.main, - project.config.assets - ? { - ...project.config.assets, - directory: resolveWorkerProjectPath( - project, - project.config.assets.directory, - "assets.directory", - ), - } - : undefined, - project.config.containers, - ); - validateNativeDeploymentMetadata(artifact, readWranglerDeploymentSettings(project, environment), project.config.environments[environment].resources); + const artifact = await loadWorkerProjectBundle(project, environment); return { sha256: artifact.contentSha256, sizeBytes: artifact.sizeBytes, }; } catch (error) { - if (error instanceof WorkerArtifactError) { + if (error instanceof WorkerArtifactError || error instanceof WorkerProjectBuildError) { return { blocked: error.message }; } throw error; diff --git a/src/workers-project-build.ts b/src/workers-project-build.ts index 15af012..22ed808 100644 --- a/src/workers-project-build.ts +++ b/src/workers-project-build.ts @@ -4,10 +4,14 @@ import { loadWorkerArtifactInput, validateNativeDeploymentMetadata, WorkerArtifactError, + withWorkerVars, } from "./workers-artifact.ts"; import type { LoadedWorkerProject } from "./workers-project.ts"; import { resolveWorkerProjectPath } from "./workers-project.ts"; -import { readWranglerDeploymentSettings } from "./workers-wrangler-import.ts"; +import { + readWranglerDeploymentSettings, + readWranglerPublicVars, +} from "./workers-wrangler-import.ts"; const BUILD_TIMEOUT_MS = 15 * 60_000; @@ -87,8 +91,7 @@ export async function runWorkerProjectBuild( projectRoot: cwd, ...(code === 127 ? { - next: - "Install the package manager used by build.command, then rerun the command", + next: "Install the package manager used by build.command, then rerun the command", } : {}), }, @@ -108,7 +111,7 @@ export async function loadWorkerProjectBundle( "build.output", ); try { - const bundle = await loadWorkerArtifactInput( + const built = await loadWorkerArtifactInput( path, project.config.build.main, project.config.assets @@ -123,6 +126,25 @@ export async function loadWorkerProjectBundle( : undefined, project.config.containers, ); + const bundle = withWorkerVars( + built, + readWranglerPublicVars(project, environment), + ); + const vars = + "bundle" in bundle.upload ? bundle.upload.bundle.vars : undefined; + const occupied = new Set([ + ...project.config.environments[environment].resources.map( + (resource) => resource.bindingName, + ), + ...(project.config.environments[environment].secrets || []), + ...(project.config.assets?.binding + ? [project.config.assets.binding] + : []), + ]); + for (const name of Object.keys(vars || {})) { + if (occupied.has(name)) + throw new WorkerArtifactError(`Duplicate Worker binding: ${name}`); + } validateNativeDeploymentMetadata( bundle, readWranglerDeploymentSettings(project, environment), diff --git a/src/workers-wrangler-import.ts b/src/workers-wrangler-import.ts index 5a07b17..647fbf0 100644 --- a/src/workers-wrangler-import.ts +++ b/src/workers-wrangler-import.ts @@ -576,9 +576,9 @@ function publicVariables( for (const name of Object.keys(vars || {}).sort()) { compatibilityEntry( entries, - "UNSUPPORTED", + "SUPPORTED", `${prefix}vars.${name}`, - "Plain-text variables are not copied or converted into Secrets. Remove this var from Wrangler and declare a Secret explicitly only when the value is sensitive", + "Public variables remain in Wrangler and are included in its native deployment bundle; sensitive values must use Secrets", { environment, bindingName: name }, ); } @@ -723,6 +723,8 @@ function selectedConfig( if (!environmentConfig) return { config: root, prefix: "" }; const merged: UnknownRecord = { ...root, ...environmentConfig }; delete merged.env; + // Wrangler vars are non-inheritable for named environments. + if (!("vars" in environmentConfig)) delete merged.vars; return { config: merged, prefix: `env.${environment}.` }; } @@ -1167,7 +1169,7 @@ export function importWranglerProject( nextSteps: [ `Review ${WORKER_PROJECT_CONFIG_FILE}`, "Set every REENTER secret with xapi workers secrets set", - "Resolve every reported Wrangler var as a public binding or an explicit Secret; xAPI never converts it automatically", + "Review public Wrangler vars; sensitive values belong in explicitly declared Secrets", "xapi workers plan --env preview", ], }; @@ -1210,3 +1212,13 @@ export function readWranglerDeploymentSettings( compatibilityFlags: [...new Set((rawFlags || []) as string[])].sort(), }; } + +/** Read only public Wrangler vars, never .env/.dev.vars or process credentials. */ +export function readWranglerPublicVars( + project: LoadedWorkerProject, + environment: "preview" | "production", +): unknown { + const path = resolveWorkerProjectPath(project, project.config.wrangler, "wrangler"); + const { config } = parseWrangler(path); + return selectedConfig(config, environment).config.vars; +} From 9c8ba4555dd2e7c8628661d93ba8b8479da73877 Mon Sep 17 00:00:00 2001 From: daxiongya Date: Wed, 23 Sep 2026 19:32:51 +0800 Subject: [PATCH 24/28] fix(skill): clarify Workers conflicts and Container deletion recovery --- skills/xapi-workers/references/deployment.md | 2 +- skills/xapi-workers/references/lifecycle.md | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/skills/xapi-workers/references/deployment.md b/skills/xapi-workers/references/deployment.md index 02803e2..6e3333b 100644 --- a/skills/xapi-workers/references/deployment.md +++ b/skills/xapi-workers/references/deployment.md @@ -149,6 +149,6 @@ xapi workers inspect --env preview --format json Promote in the already authorized release job after preview acceptance. Follow repository AGENTS.md and branch/PR rules; do not infer release authorization from a successful preview push. On uncertain results inspect deployments/logs and retry unchanged inputs so stable idempotency keys can recover the same operation. Do not change IDs or clear deletion flags to force deployment through. -A `worker_control_*` conflict is a server rollout or environment-enrollment failure, not a hint to bypass xAPI with Wrangler. Preserve the existing deployment and resources, record the exact error code, inspect `workers audit`, and have the platform operator restore a compatible control-plane configuration before retrying the unchanged deployment. +Inspect the exact `worker_control_*` code and operation status. A conflict can mean an overlapping change to the same script/resource, an unknown native result, a changed resource identity, or incompatible server configuration. It is not automatically an environment-enrollment problem. Users do not choose LEGACY/CONTROL. Preserve IDs and receipts; inspect `workers audit` and current deployment/resource state. On an explicitly requested retry, reuse unchanged inputs: the server may continue steps that have not been sent or repair known-success local state. Do not loop on UNKNOWN, clear operation records, switch modes, or bypass xAPI with Wrangler. Scope/configuration mismatches require platform investigation; ordinary in-progress operations require status inspection. For explicit artifact operations: `workers upload --file dist/worker.mjs --idempotency-key `, then `workers deploy --artifact --env preview --idempotency-key `. Reuse a key only for identical inputs. `workers build` is an optional managed Sandbox build, not a requirement for deploying locally built code. diff --git a/skills/xapi-workers/references/lifecycle.md b/skills/xapi-workers/references/lifecycle.md index d6e9062..28aba32 100644 --- a/skills/xapi-workers/references/lifecycle.md +++ b/skills/xapi-workers/references/lifecycle.md @@ -26,6 +26,18 @@ Use actions permitted by the current lifecycle. Manual pause, low balance and pe For crash recovery, distinguish slow live ownership from an expired lease or exited process. Do not kill shared services to reproduce a failure. Use an isolated authorized test process/environment. Record deployment ID, lease/recovery state, delete intent and reserve changes; verify no script is recreated after deletion wins. +## Delete one Container Application + +For an authorized individual application deletion, use the resource ID returned by xAPI: + +```sh +xapi workers resources delete --env preview --yes +``` + +This does not delete its Durable Object, R2, D1 or other independent resources. Update the project declaration as well if future pushes should omit the Container; leaving it declared can request its creation on a later deployment. Use a complete manifest deployment when removing the declaration and updating code together. + +Keep the operation ID and inspect its result. With the unified execution backend, an explicitly repeated delete can verify native absence and finish a lost local receipt without resending an uncertain DELETE. `worker_control_recover_through_deletion` directs recovery through this same resource endpoint. It does not mean a new resource should be created or the operation record discarded. If absence cannot be confirmed, report the pending result instead of repeatedly issuing changes. Resource disappearance alone does not prove final metering or release of all environment-level capacity; check those separately using billing/lifecycle evidence. + ## End a test without deleting production 1. Inventory the test Worker/environment, resources, bindings, active jobs/schedules and test objects. Pause producers and schedules; preserve user data. From d5c0bd199f23613bc000ad56da0a251554bee931 Mon Sep 17 00:00:00 2001 From: daxiongya Date: Wed, 23 Sep 2026 22:45:38 +0800 Subject: [PATCH 25/28] fix(workers): upload native-sized bundles without module count cutoff --- skills/xapi-workers/references/deployment.md | 9 ++++ src/tests/workers-artifact.test.ts | 31 +++++++++++ src/tests/workers-native-bundle.test.ts | 52 ++++++++++++++++++ src/workers-artifact.ts | 57 +++++++++++++------- 4 files changed, 131 insertions(+), 18 deletions(-) diff --git a/skills/xapi-workers/references/deployment.md b/skills/xapi-workers/references/deployment.md index 6e3333b..e91610e 100644 --- a/skills/xapi-workers/references/deployment.md +++ b/skills/xapi-workers/references/deployment.md @@ -83,6 +83,15 @@ npx wrangler deploy --dry-run \ Set `build.output` to the generated `.worker.bundle`, omit `build.main`, and set `assets.directory` to the generated client directory. `--dry-run` only creates the local Cloudflare upload artifact; `xapi workers push` remains the only publisher. The CLI sends the complete modules/assets set in one authenticated multipart Artifact request. The import report must show every unmapped Wrangler field; never split a framework application into per-file API uploads to work around an import problem. +The complete-project upload channel accepts up to 64 MiB of uncompressed +Worker modules and 100 MiB of combined module/static-asset content; each static +asset is limited to 25 MiB. There is no 200-module cutoff. A single-file build +larger than 1 MiB automatically uses the bundle channel; do not split application +code just to fit the old source-text endpoint. Native `.bundle` files have a +separate 128 MiB envelope allowance for metadata/framing. Public ingress and +Cloudflare account limits still apply, so local acceptance does not certify +public upload capacity. Deploy the matching backend before the CLI update. + Generated Wrangler configs may omit provider resource IDs and emit an `inherit` binding in the dry-run bundle. Declare that binding exactly once in the selected environment's `resources`; xAPI maps it by binding name and diff --git a/src/tests/workers-artifact.test.ts b/src/tests/workers-artifact.test.ts index feba40c..c791fa6 100644 --- a/src/tests/workers-artifact.test.ts +++ b/src/tests/workers-artifact.test.ts @@ -169,3 +169,34 @@ describe("Worker Artifact loader", () => { ); }); }); + +test("routes a large single module through the multipart bundle channel, with or without assets", () => { + const root = directory(); + const source = "/*" + "x".repeat(2 * 1024 * 1024) + "*/ export default {};"; + const path = join(root, "worker.mjs"); + writeFileSync(path, source); + const artifact = loadWorkerArtifact(path); + expect(artifact.kind).toBe("bundle"); + if (!("bundle" in artifact.upload)) throw Error("bundle"); + expect(artifact.upload.bundle.modules[0].content).toBe(source); + const assets = join(root, "assets"); + mkdirSync(assets); + writeFileSync(join(assets, "index.html"), "

large worker

"); + const withAssets = loadWorkerArtifact(path, undefined, { + directory: assets, + binding: "ASSETS", + }); + if (!("bundle" in withAssets.upload)) throw Error("bundle"); + expect(withAssets.upload.bundle.modules[0].content).toBe(source); + expect(withAssets.upload.bundle.assets?.files).toHaveLength(1); +}); + +test("accepts more than 200 directory modules", () => { + const root = directory(); + writeFileSync(join(root, "index.js"), "export default {};"); + for (let i = 0; i < 300; i++) + writeFileSync(join(root, `${i}.js`), "export {};"); + const artifact = loadWorkerArtifact(root, "index.js"); + if (!("bundle" in artifact.upload)) throw Error("bundle"); + expect(artifact.upload.bundle.modules).toHaveLength(301); +}); diff --git a/src/tests/workers-native-bundle.test.ts b/src/tests/workers-native-bundle.test.ts index fc51f19..768b9d5 100644 --- a/src/tests/workers-native-bundle.test.ts +++ b/src/tests/workers-native-bundle.test.ts @@ -183,3 +183,55 @@ test("carries native public string and JSON vars in immutable artifact identity" ), ).rejects.toThrow("reserved"); }); + +test("accepts more than 200 native modules without changing module names", async () => { + const chunks = Array.from({ length: 300 }, (_, index) => ({ + name: `chunks/${index}.js`, + type: "application/javascript+module", + content: `export const value = ${index};`, + })); + const artifact = await loadWorkerArtifactInput( + bundle([metadata, entry, ...chunks]), + ); + if (!("bundle" in artifact.upload)) throw Error("bundle"); + expect(artifact.upload.bundle.modules).toHaveLength(301); + expect( + artifact.upload.bundle.modules.find((m) => m.path === "chunks/299.js") + ?.content, + ).toBe("export const value = 299;"); +}); + +test("accepts a native 64 MiB module set, including multipart overhead, and rejects one extra byte", async () => { + const data = Buffer.alloc( + 64 * 1024 * 1024 - Buffer.byteLength(entry.content), + 7, + ); + const artifact = await loadWorkerArtifactInput( + bundle([ + metadata, + entry, + { name: "data.bin", type: "application/octet-stream", content: data }, + ]), + ); + if (!("bundle" in artifact.upload)) throw Error("bundle"); + expect( + Buffer.from( + artifact.upload.bundle.modules.find((m) => m.path === "data.bin")! + .content, + "base64", + ), + ).toEqual(data); + await expect( + loadWorkerArtifactInput( + bundle([ + metadata, + entry, + { + name: "data.bin", + type: "application/octet-stream", + content: Buffer.concat([data, Buffer.from([0])]), + }, + ]), + ), + ).rejects.toThrow("capacity"); +}, 30000); diff --git a/src/workers-artifact.ts b/src/workers-artifact.ts index 471f2ef..0eb5aaa 100644 --- a/src/workers-artifact.ts +++ b/src/workers-artifact.ts @@ -11,9 +11,10 @@ import { posix, relative, resolve, sep } from "node:path"; import { parse } from "acorn"; const MAX_LEGACY_ARTIFACT_BYTES = 1024 * 1024; -const MAX_BUNDLE_CONTENT_BYTES = 10 * 1024 * 1024; -const MAX_BUNDLE_MODULES = 200; -const MAX_ASSET_FILES = 10_000; +const MAX_BUNDLE_CONTENT_BYTES = 64 * 1024 * 1024; +const MAX_NATIVE_METADATA_BYTES = 10 * 1024 * 1024; +const MAX_NATIVE_MULTIPART_BYTES = 128 * 1024 * 1024; +const MAX_ASSET_FILES = 100_000; const MAX_ASSET_FILE_BYTES = 25 * 1024 * 1024; // Complete projects use one multipart binary request and content-addressed // server storage. This is an xAPI project quota, not Cloudflare's account cap. @@ -237,6 +238,7 @@ function collectAssetFiles(input: WorkerStaticAssetsInput): WorkerArtifactAssets throw new WorkerArtifactError("Static assets path must be a directory and not a symbolic link"); } const files: WorkerArtifactAsset[] = []; + let assetBytes = 0; const walk = (directory: string): void => { for (const entry of readdirSync(directory, { withFileTypes: true })) { const absolute = resolve(directory, entry.name); @@ -249,6 +251,8 @@ function collectAssetFiles(input: WorkerStaticAssetsInput): WorkerArtifactAssets throw new WorkerArtifactError(`Static asset path is invalid: ${relativePath}`); } if (info.size > MAX_ASSET_FILE_BYTES) throw new WorkerArtifactError(`Static asset exceeds Cloudflare's 25 MiB per-file limit: ${relativePath}`); + assetBytes += info.size; + if (assetBytes > MAX_XAPI_ARTIFACT_CONTENT_BYTES) throw new WorkerArtifactError("Static assets exceed the xAPI project limit of 100 MiB"); const bytes = readFileSync(absolute); files.push({ path: `/${relativePath}`, content: bytes.toString("base64"), encoding: "base64", contentType: assetContentType(relativePath) }); if (files.length > MAX_ASSET_FILES) throw new WorkerArtifactError(`Static assets exceed xAPI's ${MAX_ASSET_FILES} file limit`); @@ -313,10 +317,8 @@ function loadSingleModule(path: string): LoadedWorkerArtifact { throw new WorkerArtifactError("Worker build output is not a file or directory"); } if (!info.size) throw new WorkerArtifactError("Worker build output is empty"); - if (info.size > MAX_LEGACY_ARTIFACT_BYTES) { - throw new WorkerArtifactError( - "Single-file Worker output exceeds the 1 MiB artifact limit; use a code-split output directory when appropriate", - ); + if (info.size > MAX_BUNDLE_CONTENT_BYTES) { + throw new WorkerArtifactError("Worker modules exceed Cloudflare’s 64 MiB uncompressed limit"); } const bytes = readFileSync(path); const moduleCode = bytes.toString("utf8"); @@ -326,6 +328,25 @@ function loadSingleModule(path: string): LoadedWorkerArtifact { ); } validateJavaScript(portableRelativePath(resolve(path, ".."), path), moduleCode, undefined, true); + if (info.size > MAX_LEGACY_ARTIFACT_BYTES) { + const bundle: WorkerArtifactBundle = { + version: 1, + mainModule: posix.basename(path), + modules: [{ + path: posix.basename(path), + content: moduleCode, + encoding: "utf8", + contentType: "application/javascript+module", + }], + }; + const stored = storedBundleBytes(bundle); + return { + kind: "bundle", + contentSha256: sha256(stored), + sizeBytes: stored.length, + upload: { bundle }, + }; + } return { kind: "module", contentSha256: sha256(bytes), @@ -336,7 +357,9 @@ function loadSingleModule(path: string): LoadedWorkerArtifact { function loadSingleModuleWithAssets(path: string, staticAssets: WorkerStaticAssetsInput): LoadedWorkerArtifact { const legacy = loadSingleModule(path); - if (!("moduleCode" in legacy.upload)) throw new WorkerArtifactError("Worker module could not be loaded"); + const moduleCode = "moduleCode" in legacy.upload + ? legacy.upload.moduleCode + : legacy.upload.bundle.modules[0].content; const mainModule = posix.basename(path); const assets = collectAssetFiles(staticAssets); const bundle: WorkerArtifactBundle = { @@ -344,7 +367,7 @@ function loadSingleModuleWithAssets(path: string, staticAssets: WorkerStaticAsse mainModule, modules: [{ path: mainModule, - content: legacy.upload.moduleCode, + content: moduleCode, encoding: "utf8", contentType: "application/javascript+module", }], @@ -356,7 +379,7 @@ function loadSingleModuleWithAssets(path: string, staticAssets: WorkerStaticAsse mainModule, modules: [{ path: mainModule, - contentBase64: Buffer.from(legacy.upload.moduleCode, "utf8").toString("base64"), + contentBase64: Buffer.from(moduleCode, "utf8").toString("base64"), contentType: "application/javascript+module", }], assets, @@ -374,6 +397,7 @@ function collectBundleFiles(root: string): Array<{ bytes: Buffer; contentType: WorkerModuleContentType; }> = []; + let moduleBytes = 0; const walk = (directory: string): void => { for (const entry of readdirSync(directory, { withFileTypes: true })) { const absolute = resolve(directory, entry.name); @@ -404,12 +428,9 @@ function collectBundleFiles(root: string): Array<{ `Worker output contains an unsupported module file: ${relativePath}. Code bundles support .js, .mjs, .wasm, .txt, and .bin; publish website assets through the static-assets workflow.`, ); } + moduleBytes += info.size; + if (moduleBytes > MAX_BUNDLE_CONTENT_BYTES) throw new WorkerArtifactError("Worker modules exceed Cloudflare’s 64 MiB uncompressed limit"); files.push({ path: relativePath, bytes: readFileSync(absolute), contentType }); - if (files.length > MAX_BUNDLE_MODULES) { - throw new WorkerArtifactError( - `Worker bundle exceeds the ${MAX_BUNDLE_MODULES} module limit`, - ); - } } }; walk(root); @@ -532,7 +553,7 @@ export async function loadWorkerArtifactInput( if (!existsSync(outputPath) || !lstatSync(outputPath).isFile() || lstatSync(outputPath).isSymbolicLink()) { throw new WorkerArtifactError("Wrangler bundle must be a regular file"); } - if (statSync(outputPath).size > 18 * 1024 * 1024) throw new WorkerArtifactError("Wrangler bundle exceeds the current Artifact transport limit"); + if (statSync(outputPath).size > MAX_NATIVE_MULTIPART_BYTES) throw new WorkerArtifactError("Wrangler bundle exceeds the current Artifact transport limit"); const bytes = readFileSync(outputPath); const firstLine = bytes.subarray(0, bytes.indexOf("\r\n")).toString("ascii"); if (!/^--[A-Za-z0-9_-]{1,70}$/.test(firstLine)) throw new WorkerArtifactError("Invalid Wrangler multipart boundary"); @@ -540,7 +561,7 @@ export async function loadWorkerArtifactInput( const entries = await new Promise>((resolve, reject) => { const result: Array<[string, string | NativeFile]> = []; const parser = busboy({ headers: {"content-type": `multipart/form-data; boundary=${firstLine.slice(2)}`}, preservePath: true, - limits: { files: MAX_BUNDLE_MODULES, fields: 1, parts: MAX_BUNDLE_MODULES + 1, fieldSize: 1024 * 1024, fileSize: MAX_BUNDLE_CONTENT_BYTES } }); + limits: { fields: 1, fieldSize: MAX_NATIVE_METADATA_BYTES, fileSize: MAX_BUNDLE_CONTENT_BYTES + 1 } }); parser.on("field", (name, value, info) => { if (info.valueTruncated || info.nameTruncated) reject(new WorkerArtifactError("Truncated native metadata")); result.push([name, value]); @@ -626,7 +647,7 @@ export async function loadWorkerArtifactInput( if (!types.has(contentType)) throw new WorkerArtifactError(`Native module type needs platform mapping: ${contentType}`); const content = Buffer.from(await value.arrayBuffer()); moduleBytes += content.length; - if (moduleBytes > MAX_BUNDLE_CONTENT_BYTES || seen.size > MAX_BUNDLE_MODULES) throw new WorkerArtifactError("Native modules exceed current Artifact capacity"); + if (moduleBytes > MAX_BUNDLE_CONTENT_BYTES) throw new WorkerArtifactError("Native modules exceed current Artifact capacity"); const utf8 = contentType === "application/javascript+module" || contentType === "text/plain"; if (utf8 && !Buffer.from(content.toString("utf8"), "utf8").equals(content)) throw new WorkerArtifactError(`Invalid UTF-8 module: ${name}`); // Native module linkage (including computed imports) is validated by CF. From 121e61341dc2848ccd5a23339313b4e2d51f71eb Mon Sep 17 00:00:00 2001 From: daxiongya Date: Wed, 23 Sep 2026 23:05:01 +0800 Subject: [PATCH 26/28] fix(workers): import native options and expose deployment prerequisites --- skills/xapi-workers/references/deployment.md | 23 ++ src/tests/workers-native-bundle.test.ts | 108 ++++++++++ src/tests/workers-project-build.test.ts | 29 +++ src/tests/workers-wrangler-import.test.ts | 78 +++++++ src/workers-artifact.ts | 212 +++++++++++++++++-- src/workers-client.ts | 6 + src/workers-project-build.ts | 20 +- src/workers-wrangler-import.ts | 152 ++++++++++++- 8 files changed, 600 insertions(+), 28 deletions(-) diff --git a/skills/xapi-workers/references/deployment.md b/skills/xapi-workers/references/deployment.md index e91610e..6d38584 100644 --- a/skills/xapi-workers/references/deployment.md +++ b/skills/xapi-workers/references/deployment.md @@ -161,3 +161,26 @@ Promote in the already authorized release job after preview acceptance. Follow r Inspect the exact `worker_control_*` code and operation status. A conflict can mean an overlapping change to the same script/resource, an unknown native result, a changed resource identity, or incompatible server configuration. It is not automatically an environment-enrollment problem. Users do not choose LEGACY/CONTROL. Preserve IDs and receipts; inspect `workers audit` and current deployment/resource state. On an explicitly requested retry, reuse unchanged inputs: the server may continue steps that have not been sent or repair known-success local state. Do not loop on UNKNOWN, clear operation records, switch modes, or bypass xAPI with Wrangler. Scope/configuration mismatches require platform investigation; ordinary in-progress operations require status inspection. For explicit artifact operations: `workers upload --file dist/worker.mjs --idempotency-key `, then `workers deploy --artifact --env preview --idempotency-key `. Reuse a key only for identical inputs. `workers build` is an optional managed Sandbox build, not a requirement for deploying locally built code. + + +### Native configuration and application deployment prerequisites + +`cache.enabled`, `cache.cross_version_cache` and `version_metadata.binding` are +preserved through the Artifact and native upload. These require a backend version +containing the native-options changes; an older deployment is not evidence of +support. A stale `.bundle` whose cache/version settings disagree with the selected +Wrangler environment must be rebuilt. Cache settings apply to the user Worker, +not to the xAPI dispatcher. Required Secret names in `secrets.required` are +imported; set their values through the Secrets API, never inside the Artifact. + +The import report now includes a `deploymentPlan` for each environment: +`BEFORE_CODE` (D1 migrations), `CODE` (Worker configuration), `AFTER_CODE` (Queue +consumers and Cron). `REQUIRES_MAPPING` is unfinished execution support, not a +successful deployment. D1 directories are relative to the referenced Wrangler +file, not necessarily the project root. Code rollback does not undo applied SQL. + +Current managed Queue delivery is HTTP; it is not equivalent to `queue(batch)`. +Current HTTP schedules are not equivalent to `scheduled()`. Do not remove these +fields from an app or use `--accept-partial` to claim full compatibility. Before +publishing an app that uses them, implement/verify the declared event semantics +and database initialization, then test them in the selected xAPI test environment. diff --git a/src/tests/workers-native-bundle.test.ts b/src/tests/workers-native-bundle.test.ts index 768b9d5..309236c 100644 --- a/src/tests/workers-native-bundle.test.ts +++ b/src/tests/workers-native-bundle.test.ts @@ -235,3 +235,111 @@ test("accepts a native 64 MiB module set, including multipart overhead, and reje ), ).rejects.toThrow("capacity"); }, 30000); + +test("preserves native cache/version config and maps required secrets and queue identities", async () => { + const { withNativeWorkerOptions } = await import("../workers-artifact.ts"); + const options = { + cacheOptions: { enabled: true, cross_version_cache: false }, + versionMetadata: { binding: "CF_VERSION_METADATA" }, + }; + const a = await loadWorkerArtifactInput( + bundle([ + { + ...metadata, + content: JSON.stringify({ + ...JSON.parse(metadata.content), + cache_options: options.cacheOptions, + bindings: [ + { type: "version_metadata", name: "CF_VERSION_METADATA" }, + { type: "inherit", name: "AUTH_SECRET" }, + { type: "queue", name: "JOBS", queue_name: "foreign-queue" }, + ], + }), + }, + entry, + ]), + ); + if (!("bundle" in a.upload)) throw Error("bundle"); + expect(a.upload.bundle).toMatchObject(options); + expect(JSON.stringify(a.upload)).not.toContain("foreign-queue"); + const settings = { + compatibilityDate: "2026-09-10", + compatibilityFlags: ["nodejs_compat"], + }; + validateNativeDeploymentMetadata( + a, + settings, + [{ type: "queue", bindingName: "JOBS" }], + ["AUTH_SECRET"], + ); + expect(() => + validateNativeDeploymentMetadata(a, settings, [ + { type: "queue", bindingName: "JOBS" }, + ]), + ).toThrow("AUTH_SECRET"); + expect(() => + validateNativeDeploymentMetadata( + a, + settings, + [ + { type: "queue", bindingName: "JOBS" }, + { type: "d1_database", bindingName: "CF_VERSION_METADATA" }, + ], + ["AUTH_SECRET"], + ), + ).toThrow("Duplicate"); + expect(withNativeWorkerOptions(a, options)).toBe(a); + expect(() => + withNativeWorkerOptions(a, { + ...options, + cacheOptions: { enabled: false }, + }), + ).toThrow("rebuild"); + expect(() => withNativeWorkerOptions(a, {})).toThrow("rebuild"); + const plain = await loadWorkerArtifactInput(bundle([metadata, entry])); + expect(a.contentSha256).not.toBe(plain.contentSha256); +}); + +test("rejects malformed cache settings and unsupported metadata binding fields", async () => { + for (const cache_options of [ + null, + { enabled: "true" }, + { enabled: true, unknown: 1 }, + { enabled: true, cross_version_cache: 1 }, + ]) { + await expect( + loadWorkerArtifactInput( + bundle([ + { + ...metadata, + content: JSON.stringify({ + ...JSON.parse(metadata.content), + cache_options, + }), + }, + entry, + ]), + ), + ).rejects.toThrow("cache"); + } + await expect( + loadWorkerArtifactInput( + bundle([ + { + ...metadata, + content: JSON.stringify({ + ...JSON.parse(metadata.content), + bindings: [ + { + type: "version_metadata", + name: "VERSION", + namespace: "foreign", + }, + ], + }), + }, + entry, + ]), + ), + ).rejects.toThrow("mapping"); +}); diff --git a/src/tests/workers-project-build.test.ts b/src/tests/workers-project-build.test.ts index 80f1e1c..fb7c7ff 100644 --- a/src/tests/workers-project-build.test.ts +++ b/src/tests/workers-project-build.test.ts @@ -155,3 +155,32 @@ test("rejects stale native vars and collisions with declared Secrets before publ "Duplicate", ); }); + +test("attaches native configuration to an ordinary module build and detects binding collisions", async () => { + const config = project(false); + writeFileSync( + join(config.rootDir, "wrangler.jsonc"), + JSON.stringify({ + compatibility_date: "2026-09-10", + cache: { enabled: false }, + version_metadata: { binding: "VERSION" }, + }), + ); + const artifact = await loadWorkerProjectBundle(config, "preview"); + expect(artifact.upload).toMatchObject({ + bundle: { + cacheOptions: { enabled: false }, + versionMetadata: { binding: "VERSION" }, + }, + }); + writeFileSync( + join(config.rootDir, "wrangler.jsonc"), + JSON.stringify({ + compatibility_date: "2026-09-10", + version_metadata: { binding: "API_CONTAINER" }, + }), + ); + await expect(loadWorkerProjectBundle(config, "preview")).rejects.toThrow( + "Duplicate", + ); +}); diff --git a/src/tests/workers-wrangler-import.test.ts b/src/tests/workers-wrangler-import.test.ts index 6bbc010..eca54de 100644 --- a/src/tests/workers-wrangler-import.test.ts +++ b/src/tests/workers-wrangler-import.test.ts @@ -443,3 +443,81 @@ new_sqlite_classes = ["Room"] }); }); }); + +test("imports native metadata/cache and modern required secrets without values", () => { + const root = workspace(); + writeFileSync( + join(root, "wrangler.jsonc"), + JSON.stringify({ + name: "native-options", + main: "src/index.ts", + cache: { enabled: true }, + version_metadata: { binding: "CF_VERSION_METADATA" }, + secrets: { required: ["AUTH_SECRET"] }, + }), + ); + const result = importWranglerProject({ + cwd: root, + wranglerPath: "wrangler.jsonc", + }); + expect(result.wrote).toBe(true); + expect(result.report.entries).toContainEqual( + expect.objectContaining({ category: "SUPPORTED", path: "cache" }), + ); + expect(result.report.entries).toContainEqual( + expect.objectContaining({ + category: "SUPPORTED", + path: "version_metadata", + }), + ); + expect(result.config?.environments.preview.secrets).toEqual(["AUTH_SECRET"]); +}); + +test("reports native event and SQL migration gaps rather than silently claiming deployment compatibility", () => { + const root = workspace(); + writeFileSync( + join(root, "wrangler.jsonc"), + JSON.stringify({ + name: "event-worker", + main: "src/index.ts", + triggers: { crons: ["0 * * * *"] }, + d1_databases: [{ binding: "DB", migrations_dir: "migrations" }], + queues: { + producers: [{ binding: "JOBS", queue: "jobs" }], + consumers: [{ queue: "jobs", max_batch_size: 1, max_retries: 5 }], + }, + }), + ); + const result = importWranglerProject({ + cwd: root, + wranglerPath: "wrangler.jsonc", + }); + expect(result.wrote).toBe(false); + const phases = result.report.deploymentPlan.filter( + (step) => step.environment === "preview", + ); + expect(phases.map((step) => step.kind)).toEqual([ + "D1_MIGRATIONS", + "WORKER", + "QUEUE_CONSUMER", + "CRON", + ]); + expect(phases[0]).toMatchObject({ + bindingName: "DB", + configuration: { directory: "migrations", table: "d1_migrations" }, + }); + expect(phases[2]).toMatchObject({ + bindingName: "JOBS", + status: "REQUIRES_MAPPING", + configuration: { max_batch_size: 1, max_retries: 5 }, + }); + for (const path of [ + "triggers.crons", + "d1_databases[0].migrations", + "queues.consumers", + ]) { + expect(result.report.entries).toContainEqual( + expect.objectContaining({ category: "UNSUPPORTED", path }), + ); + } +}); diff --git a/src/workers-artifact.ts b/src/workers-artifact.ts index 0eb5aaa..26a7466 100644 --- a/src/workers-artifact.ts +++ b/src/workers-artifact.ts @@ -62,10 +62,69 @@ export interface WorkerStaticAssetsInput { runWorkerFirst?: boolean | string[]; } +export type WorkerCacheOptions = { + enabled: boolean; + cross_version_cache?: boolean; +}; +export type WorkerVersionMetadata = { binding: string }; + +export function normalizeNativeWorkerOptions(input: { + cacheOptions?: unknown; + versionMetadata?: unknown; +}): { + cacheOptions?: WorkerCacheOptions; + versionMetadata?: WorkerVersionMetadata; +} { + const result: { + cacheOptions?: WorkerCacheOptions; + versionMetadata?: WorkerVersionMetadata; + } = {}; + if (input.cacheOptions !== undefined) { + const value = input.cacheOptions as WorkerCacheOptions; + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + typeof value.enabled !== "boolean" || + (value.cross_version_cache !== undefined && + typeof value.cross_version_cache !== "boolean") || + Object.keys(value).some( + (key) => !["enabled", "cross_version_cache"].includes(key), + ) + ) { + throw new WorkerArtifactError("Invalid Worker cache options"); + } + result.cacheOptions = { + enabled: value.enabled, + ...(value.cross_version_cache !== undefined + ? { cross_version_cache: value.cross_version_cache } + : {}), + }; + } + if (input.versionMetadata !== undefined) { + const value = input.versionMetadata as WorkerVersionMetadata; + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + typeof value.binding !== "string" || + !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value.binding) || + value.binding === "XAPI_AI_BASE_URL" || + Object.keys(value).some((key) => key !== "binding") + ) { + throw new WorkerArtifactError("Invalid Worker version metadata binding"); + } + result.versionMetadata = { binding: value.binding }; + } + return result; +} + export interface WorkerArtifactBundle { version: 1; mainModule: string; modules: WorkerArtifactBundleModule[]; + cacheOptions?: WorkerCacheOptions; + versionMetadata?: WorkerVersionMetadata; observability?: { enabled: boolean }; assets?: WorkerArtifactAssets; containers?: WorkerContainerInput[]; @@ -586,7 +645,16 @@ export async function loadWorkerArtifactInput( if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) throw new WorkerArtifactError("Invalid Wrangler metadata"); // Resource identities and credentials are owned by xAPI's control plane. // Do not silently import a native binding that has no managed equivalent here. - const known = new Set(["main_module", "bindings", "compatibility_date", "compatibility_flags", "observability", "package_dependencies", "containers"]); + const known = new Set([ + "main_module", + "bindings", + "compatibility_date", + "compatibility_flags", + "observability", + "package_dependencies", + "containers", + "cache_options", + ]); const unknown = Object.keys(metadata).filter(key => !known.has(key)); if (unknown.length) throw new WorkerArtifactError(`Native metadata needs explicit platform mapping: ${unknown.join(", ")}`); const packageDependencies = metadata.package_dependencies; @@ -615,6 +683,17 @@ export async function loadWorkerArtifactInput( if (binding.type === "json") { return !("json" in binding) || Object.keys(binding).some(key => !["name", "type", "json"].includes(key)); } + if (binding.type === "version_metadata") + return Object.keys(binding).some( + (key) => !["name", "type"].includes(key), + ); + if (binding.type === "queue") + return ( + typeof binding.queue_name !== "string" || + Object.keys(binding).some( + (key) => !["name", "type", "queue_name"].includes(key), + ) + ); if (["d1", "r2_bucket", "kv_namespace", "inherit"].includes(String(binding.type))) return false; if (binding.type === "durable_object_namespace") { // Only a class in this script can map to the declared managed DO. An @@ -627,6 +706,19 @@ export async function loadWorkerArtifactInput( const nativeBindings = (metadata.bindings || []) as UnknownRecord[]; const bindingNames = nativeBindings.map(binding => binding.name); if (new Set(bindingNames).size !== bindingNames.length) throw new WorkerArtifactError("Duplicate native binding name"); + const versions = nativeBindings.filter( + (binding) => binding.type === "version_metadata", + ); + if (versions.length > 1) + throw new WorkerArtifactError( + "Only one version metadata binding is supported by Wrangler configuration", + ); + const nativeOptions = normalizeNativeWorkerOptions({ + cacheOptions: metadata.cache_options, + ...(versions.length + ? { versionMetadata: { binding: versions[0].name } } + : {}), + }); const vars = normalizeWorkerVars(Object.fromEntries(nativeBindings .filter(binding => binding.type === "plain_text" || binding.type === "json") .map(binding => [String(binding.name), binding.type === "plain_text" ? binding.text : binding.json]))); @@ -678,7 +770,16 @@ export async function loadWorkerArtifactInput( if (JSON.stringify(nativeClasses) !== JSON.stringify(configuredClasses)) { throw new WorkerArtifactError('Wrangler Container classes differ from xapi.worker.json; rebuild or re-import before publishing'); } - const bundle: WorkerArtifactBundle = { version: 1, mainModule: main, modules, ...(vars ? {vars} : {}), ...(observability ? {observability} : {}), ...(assets ? {assets} : {}), ...(containers?.length ? {containers} : {}) }; + const bundle: WorkerArtifactBundle = { + version: 1, + mainModule: main, + modules, + ...nativeOptions, + ...(vars ? { vars } : {}), + ...(observability ? { observability } : {}), + ...(assets ? { assets } : {}), + ...(containers?.length ? { containers } : {}), + }; assertArtifactContentLimit(bundle); const stored = storedBundleBytes(bundle); return { kind: "bundle", contentSha256: sha256(stored), sizeBytes: stored.length, upload: {bundle}, nativeMetadata: metadata }; @@ -732,49 +833,97 @@ function storedBundleBytes(bundle: WorkerArtifactBundle): Buffer { : undefined; return Buffer.from( JSON.stringify({ + ...normalizeNativeWorkerOptions(bundle), ...(bundle.observability ? { observability: bundle.observability } : {}), ...(bundle.containers?.length ? { containers: bundle.containers } : {}), - ...(normalizeWorkerVars(bundle.vars) ? { vars: normalizeWorkerVars(bundle.vars) } : {}), + ...(normalizeWorkerVars(bundle.vars) + ? { vars: normalizeWorkerVars(bundle.vars) } + : {}), version: 1, mainModule: bundle.mainModule, modules, ...(assets ? { assets } : {}), }), - 'utf8', + "utf8", ); } export function validateNativeDeploymentMetadata( artifact: LoadedWorkerArtifact, settings: { compatibilityDate?: string; compatibilityFlags?: string[] }, - resources: Array<{type: string; bindingName: string; className?: string}>, + resources: Array<{ type: string; bindingName: string; className?: string }>, + secrets: string[] = [], ): void { const metadata = artifact.nativeMetadata; if (!metadata) return; - if (metadata.compatibility_date !== settings.compatibilityDate || - JSON.stringify([...(metadata.compatibility_flags as string[] || [])].sort()) !== JSON.stringify([...(settings.compatibilityFlags || [])].sort())) { - throw new WorkerArtifactError("Wrangler bundle compatibility settings differ from deployment configuration; rebuild before publishing"); + if ( + metadata.compatibility_date !== settings.compatibilityDate || + JSON.stringify( + [...((metadata.compatibility_flags as string[]) || [])].sort(), + ) !== JSON.stringify([...(settings.compatibilityFlags || [])].sort()) + ) { + throw new WorkerArtifactError( + "Wrangler bundle compatibility settings differ from deployment configuration; rebuild before publishing", + ); } - const managed: Record = {d1: "d1_database", r2_bucket: "r2_bucket", kv_namespace: "kv_namespace"}; + const managed: Record = { + d1: "d1_database", + r2_bucket: "r2_bucket", + kv_namespace: "kv_namespace", + queue: "queue", + }; for (const binding of (metadata.bindings || []) as UnknownRecord[]) { - if (binding.type === "assets" && artifact.upload && "bundle" in artifact.upload && - artifact.upload.bundle.assets?.binding === binding.name) continue; - const matching = resources.filter(resource => resource.bindingName === binding.name); - if (binding.type === "plain_text" || binding.type === "json") { - if (matching.length) throw new WorkerArtifactError(`Duplicate Worker binding: ${binding.name}`); + if ( + binding.type === "assets" && + artifact.upload && + "bundle" in artifact.upload && + artifact.upload.bundle.assets?.binding === binding.name + ) + continue; + const matching = resources.filter( + (resource) => resource.bindingName === binding.name, + ); + if ( + binding.type === "plain_text" || + binding.type === "json" || + binding.type === "version_metadata" + ) { + if (matching.length || secrets.includes(String(binding.name))) + throw new WorkerArtifactError( + `Duplicate Worker binding: ${binding.name}`, + ); continue; } if (binding.type === "durable_object_namespace") { - if (matching.length !== 1 || matching[0].type !== "durable_object" || matching[0].className !== binding.class_name) { - throw new WorkerArtifactError(`Native Durable Object binding ${binding.name} must match its declared xAPI class`); + if ( + matching.length !== 1 || + matching[0].type !== "durable_object" || + matching[0].className !== binding.class_name + ) { + throw new WorkerArtifactError( + `Native Durable Object binding ${binding.name} must match its declared xAPI class`, + ); } continue; } - const declared = binding.type === "inherit" - ? matching.length === 1 && Object.values(managed).includes(matching[0].type) - : matching.some(resource => resource.type === managed[String(binding.type)]); + if (binding.type === "inherit" && secrets.includes(String(binding.name))) { + if (matching.length) + throw new WorkerArtifactError( + `Duplicate Worker binding: ${binding.name}`, + ); + continue; + } + const declared = + binding.type === "inherit" + ? matching.length === 1 && + Object.values(managed).includes(matching[0].type) + : matching.some( + (resource) => resource.type === managed[String(binding.type)], + ); if (!declared) { - throw new WorkerArtifactError(`Native binding ${binding.name} is missing from xAPI resource declarations`); + throw new WorkerArtifactError( + `Native binding ${binding.name} is missing from xAPI resource declarations`, + ); } } } @@ -807,6 +956,29 @@ export function normalizeWorkerVars(value: unknown): Record | u } +/** Attach declared native options; reject stale native build metadata. */ +export function withNativeWorkerOptions(artifact: LoadedWorkerArtifact, options: { + cacheOptions?: unknown; versionMetadata?: unknown; +}): LoadedWorkerArtifact { + const expected = normalizeNativeWorkerOptions(options); + if (artifact.nativeMetadata) { + const actual = 'bundle' in artifact.upload ? normalizeNativeWorkerOptions(artifact.upload.bundle) : {}; + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new WorkerArtifactError('Wrangler bundle cache/version metadata differs from the selected environment; rebuild before publishing'); + } + return artifact; + } + if (!Object.keys(expected).length) return artifact; + const bundle: WorkerArtifactBundle = 'bundle' in artifact.upload + ? { ...artifact.upload.bundle, ...expected } + : { version: 1, mainModule: 'index.mjs', ...expected, modules: [{ + path: 'index.mjs', content: artifact.upload.moduleCode, + encoding: 'utf8', contentType: 'application/javascript+module', + }] }; + const bytes = storedBundleBytes(bundle); + return { ...artifact, kind: 'bundle', contentSha256: sha256(bytes), sizeBytes: bytes.length, upload: { bundle } }; +} + /** Keep native output authoritative; reject a stale build instead of changing it silently. */ export function withWorkerVars(artifact: LoadedWorkerArtifact, value: unknown): LoadedWorkerArtifact { const vars = normalizeWorkerVars(value); diff --git a/src/workers-client.ts b/src/workers-client.ts index 8de6265..abad2f5 100644 --- a/src/workers-client.ts +++ b/src/workers-client.ts @@ -156,6 +156,12 @@ export async function uploadWorkerArtifact( version: 2, idempotencyKey: input.idempotencyKey, mainModule: input.bundle.mainModule, + ...(input.bundle.cacheOptions + ? { cacheOptions: input.bundle.cacheOptions } + : {}), + ...(input.bundle.versionMetadata + ? { versionMetadata: input.bundle.versionMetadata } + : {}), modules: input.bundle.modules.map(addFile), ...(input.bundle.containers?.length ? { containers: input.bundle.containers } diff --git a/src/workers-project-build.ts b/src/workers-project-build.ts index 22ed808..0cd0b9e 100644 --- a/src/workers-project-build.ts +++ b/src/workers-project-build.ts @@ -3,6 +3,7 @@ import type { LoadedWorkerArtifact } from "./workers-artifact.ts"; import { loadWorkerArtifactInput, validateNativeDeploymentMetadata, + withNativeWorkerOptions, WorkerArtifactError, withWorkerVars, } from "./workers-artifact.ts"; @@ -126,8 +127,9 @@ export async function loadWorkerProjectBundle( : undefined, project.config.containers, ); + const settings = readWranglerDeploymentSettings(project, environment); const bundle = withWorkerVars( - built, + withNativeWorkerOptions(built, settings), readWranglerPublicVars(project, environment), ); const vars = @@ -141,14 +143,28 @@ export async function loadWorkerProjectBundle( ? [project.config.assets.binding] : []), ]); + const versionBinding = + "bundle" in bundle.upload + ? bundle.upload.bundle.versionMetadata?.binding + : undefined; + if ( + versionBinding && + (occupied.has(versionBinding) || + Object.hasOwn(vars || {}, versionBinding)) + ) { + throw new WorkerArtifactError( + `Duplicate Worker binding: ${versionBinding}`, + ); + } for (const name of Object.keys(vars || {})) { if (occupied.has(name)) throw new WorkerArtifactError(`Duplicate Worker binding: ${name}`); } validateNativeDeploymentMetadata( bundle, - readWranglerDeploymentSettings(project, environment), + settings, project.config.environments[environment].resources, + project.config.environments[environment].secrets, ); return bundle; } catch (error) { diff --git a/src/workers-wrangler-import.ts b/src/workers-wrangler-import.ts index 647fbf0..e2881a2 100644 --- a/src/workers-wrangler-import.ts +++ b/src/workers-wrangler-import.ts @@ -1,3 +1,8 @@ +import { + normalizeNativeWorkerOptions, + type WorkerCacheOptions, + type WorkerVersionMetadata, +} from "./workers-artifact.ts"; import { existsSync, lstatSync, @@ -47,6 +52,14 @@ export interface WranglerImportReport { compatible: boolean; entries: WranglerCompatibilityEntry[]; summary: Record; + deploymentPlan: Array<{ + phase: "BEFORE_CODE" | "CODE" | "AFTER_CODE"; + kind: "D1_MIGRATIONS" | "WORKER" | "QUEUE_CONSUMER" | "CRON"; + environment: "preview" | "production"; + status: "SUPPORTED" | "REQUIRES_MAPPING"; + bindingName?: string; + configuration: Record; + }>; } export interface ImportWranglerProjectOptions { @@ -75,6 +88,8 @@ export interface ImportWranglerProjectResult { export interface WranglerDeploymentSettings { compatibilityDate?: string; compatibilityFlags: string[]; + cacheOptions?: WorkerCacheOptions; + versionMetadata?: WorkerVersionMetadata; } type UnknownRecord = Record; @@ -95,6 +110,8 @@ const SUPPORTED_TOP_LEVEL = new Set([ "compatibility_date", "compatibility_flags", "assets", + "cache", + "version_metadata", ]); const MANAGED_TOP_LEVEL = new Set([ "kv_namespaces", @@ -125,7 +142,6 @@ const IGNORED_TOP_LEVEL = new Set([ "tsconfig", "rules", "build", - "triggers", "usage_model", "keep_vars", "send_metrics", @@ -134,7 +150,6 @@ const IGNORED_TOP_LEVEL = new Set([ "legacy_assets", "site", "limits", - "version_metadata", "tail_consumers", // Wrangler-generated framework configs can include build-time defaults that // have already been applied to the emitted Worker bundle. They are not @@ -479,7 +494,7 @@ function resourceList( item.binding, `${prefix}d1_databases[${index}]`, item, - new Set(["binding"]), + new Set(["binding", "migrations_dir", "migrations_table"]), ), ); array(config.r2_buckets).forEach((item, index) => @@ -533,7 +548,7 @@ function resourceList( entries, "UNSUPPORTED", `${prefix}queues.consumers`, - "Queue consumer configuration is not imported; xAPI managed queues use the hosted Worker target", + "Native queue(batch) delivery is not implemented: current xAPI consumers forward HTTP. Batch/ack/retry/dead-letter settings must be mapped before deployment", { environment }, ); } @@ -682,8 +697,11 @@ function secretNames( entries: WranglerCompatibilityEntry[], ): string[] { const candidates = new Set(); - if (Array.isArray(config.secrets)) { - for (const value of config.secrets) { + const declaredSecrets = Array.isArray(config.secrets) + ? config.secrets + : record(config.secrets)?.required; + if (Array.isArray(declaredSecrets)) { + for (const value of declaredSecrets) { if (typeof value === "string") candidates.add(value); } } @@ -987,6 +1005,7 @@ export function importWranglerProject( const rootDir = discoverProjectRoot(cwd, sourcePath); const configPath = resolve(rootDir, WORKER_PROJECT_CONFIG_FILE); const entries: WranglerCompatibilityEntry[] = []; + const deploymentPlan: WranglerImportReport["deploymentPlan"] = []; inspectTopLevel(wrangler, entries); if (typeof wrangler.main !== "string" || !wrangler.main.trim()) { compatibilityEntry( @@ -1001,6 +1020,117 @@ export function importWranglerProject( preview: selectedConfig(wrangler, "preview"), production: selectedConfig(wrangler, "production"), }; + for (const environment of ["preview", "production"] as const) { + const { config, prefix } = desired[environment]; + let nativeOptionsValid = true; + try { + normalizeNativeWorkerOptions({ + cacheOptions: config.cache, + versionMetadata: config.version_metadata, + }); + } catch (error) { + nativeOptionsValid = false; + compatibilityEntry( + entries, + "UNSUPPORTED", + `${prefix}cache/version_metadata`, + error instanceof Error ? error.message : "Invalid native options", + { environment }, + ); + } + deploymentPlan.push({ + phase: "CODE", + kind: "WORKER", + environment, + status: nativeOptionsValid ? "SUPPORTED" : "REQUIRES_MAPPING", + configuration: { + ...(config.cache !== undefined ? { cache: config.cache } : {}), + ...(config.version_metadata !== undefined + ? { version_metadata: config.version_metadata } + : {}), + }, + }); + const queueConfig = record(config.queues); + for (const consumer of array(queueConfig?.consumers)) { + const producer = array(queueConfig?.producers).find( + (item) => item.queue === consumer.queue, + ); + deploymentPlan.push({ + phase: "AFTER_CODE", + kind: "QUEUE_CONSUMER", + environment, + status: "REQUIRES_MAPPING", + ...(typeof producer?.binding === "string" + ? { bindingName: producer.binding } + : {}), + configuration: Object.fromEntries( + Object.entries(consumer).filter(([key]) => + [ + "queue", + "max_batch_size", + "max_batch_timeout", + "max_retries", + "max_concurrency", + "retry_delay", + "dead_letter_queue", + ].includes(key), + ), + ), + }); + } + const crons = record(config.triggers)?.crons; + if (Array.isArray(crons) && crons.length) { + deploymentPlan.push({ + phase: "AFTER_CODE", + kind: "CRON", + environment, + status: "REQUIRES_MAPPING", + configuration: { crons, timezone: "UTC" }, + }); + compatibilityEntry( + entries, + "UNSUPPORTED", + `${prefix}triggers.crons`, + `Native scheduled() delivery requires a verified WfP trigger mapping; HTTP schedules are not equivalent. Requested UTC schedules: ${crons.join(", ")}`, + { environment }, + ); + } + array(config.d1_databases).forEach((database, index) => { + if ( + database.migrations_dir !== undefined || + database.migrations_table !== undefined || + existsSync(resolve(sourceDir, "migrations")) + ) { + deploymentPlan.push({ + phase: "BEFORE_CODE", + kind: "D1_MIGRATIONS", + environment, + status: "REQUIRES_MAPPING", + ...(typeof database.binding === "string" + ? { bindingName: database.binding } + : {}), + configuration: { + directory: database.migrations_dir ?? "migrations", + table: database.migrations_table ?? "d1_migrations", + relativeTo: + relative(rootDir, sourceDir).split(sep).join("/") || ".", + }, + }); + compatibilityEntry( + entries, + "UNSUPPORTED", + `${prefix}d1_databases[${index}].migrations`, + `D1 SQL migrations require a separate target-database execution plan before code deployment; directory=${String(database.migrations_dir ?? "migrations")}, table=${String(database.migrations_table ?? "d1_migrations")}. Code rollback does not roll back SQL.`, + { + environment, + ...(typeof database.binding === "string" + ? { bindingName: database.binding } + : {}), + }, + ); + } + }); + } const assets = staticAssets( desired.preview.config, desired.production.config, @@ -1083,6 +1213,12 @@ export function importWranglerProject( ), entries: sortedEntries, summary: summary(sortedEntries), + deploymentPlan: deploymentPlan.sort( + (a, b) => + a.environment.localeCompare(b.environment) || + ["BEFORE_CODE", "CODE", "AFTER_CODE"].indexOf(a.phase) - + ["BEFORE_CODE", "CODE", "AFTER_CODE"].indexOf(b.phase), + ), }; if (!report.compatible && !options.acceptPartial) { return { @@ -1210,6 +1346,10 @@ export function readWranglerDeploymentSettings( return { ...(date ? { compatibilityDate: date } : {}), compatibilityFlags: [...new Set((rawFlags || []) as string[])].sort(), + ...normalizeNativeWorkerOptions({ + cacheOptions: selected.cache, + versionMetadata: selected.version_metadata, + }), }; } From e6d2ef4a3406639821d7283cce998159fa68dc03 Mon Sep 17 00:00:00 2001 From: daxiongya Date: Wed, 23 Sep 2026 23:31:38 +0800 Subject: [PATCH 27/28] fix(skill): explain retention before creation without an acceptance gate --- skills/xapi-workers/SKILL.md | 6 ++++++ skills/xapi-workers/references/deployment.md | 2 +- skills/xapi-workers/references/lifecycle.md | 8 ++------ src/commands/workers.ts | 2 +- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/skills/xapi-workers/SKILL.md b/skills/xapi-workers/SKILL.md index e01f58e..0e9f65c 100644 --- a/skills/xapi-workers/SKILL.md +++ b/skills/xapi-workers/SKILL.md @@ -15,6 +15,12 @@ Use the `xapi` CLI (`xapi-to` is the same executable). Verify `xapi workers --he - Start with `workers inspect [worker-id] --env ` and `workers capabilities`. Use `workers plan --env ` when a local project is available and desired-state drift matters. `inspect` reads current runtime state only. `plan` runs the configured local build with credential-shaped environment variables removed, validates the exact Artifact, and compares it with live state without writing to the xAPI control plane. It also shows the budget cap, active price-book visibility, and usage-dependent resource changes; never present those estimates as an accrued invoice. Use existing user authorization for changes; don't expand cleanup from a test environment to production. - Treat Worker execution and data placement separately. Worker code remains global. Use environment `defaultResourceLocation` only as the default for newly created D1/R2 resources and `placementMode: smart` only for Cloudflare Smart Placement. Never claim either setting migrates existing data. +## Before creating a Worker or resource + +Briefly tell the user: when retention is enabled, provisioning/deployment freezes the quoted retention reserve; an empty project record does not freeze funds. Storage can keep costing money while paused. After insufficient balance starts retention, reaching the reserve cleanup threshold can trigger automatic deletion. Unused reserve is returned after confirmed cleanup and the required settlement window; recharging does not automatically resume service. Retention estimates are not guaranteed fixed retention periods. + +This is an informational reminder, not a separate approval gate. Use the user's existing creation/deployment authorization; do not require a `retention accept` call. xAPI records the default policy with the first retention hold. Read the current resource quote and pass its exact price version when required; see [lifecycle.md](references/lifecycle.md) for details. If an older server still returns `retention_policy_acceptance_required`, report the server-version mismatch instead of silently accepting policy or bypassing xAPI. + ## Load the relevant workflow diff --git a/skills/xapi-workers/references/deployment.md b/skills/xapi-workers/references/deployment.md index 6d38584..ca046b8 100644 --- a/skills/xapi-workers/references/deployment.md +++ b/skills/xapi-workers/references/deployment.md @@ -148,7 +148,7 @@ Use the project's installed/pinned CLI, lockfile installation, and a scoped secr Run plan, push, inspect, active-status and business checks in order. Both plan and push prepare the local Artifact; push performs that work before any Worker, budget, resource, Artifact, or Deployment write. `--non-interactive` suppresses -prompts; it does not accept retention policy or bypass preflight: +prompts; it does not bypass quote, balance or preflight checks. A separate retention-policy acceptance call is not required: ```sh xapi workers plan --env preview --format json diff --git a/skills/xapi-workers/references/lifecycle.md b/skills/xapi-workers/references/lifecycle.md index 28aba32..2d88bbd 100644 --- a/skills/xapi-workers/references/lifecycle.md +++ b/skills/xapi-workers/references/lifecycle.md @@ -8,13 +8,9 @@ xapi workers retention quote --env preview --type WORKER --format js xapi workers billing lifecycle --env preview --json ``` -A quote is not policy acceptance. If already authorized, accept the returned exact version: +Before creation, give the informational retention reminder in SKILL.md; do not add a separate policy-confirmation step. With retention enabled, xAPI records the default policy atomically with the first retention hold. `retention accept` remains a compatibility command, not a prerequisite for new deployments. -```sh -xapi workers retention accept --env preview --price-version --yes -``` - -Explain material automatic-deletion terms when they require a new user decision; do not request approval again when that policy and scope are already authorized. Provision with the same accepted version where required. Different resource types can have separate quotes; inspect the API response instead of copying an old price version. +Policy enrollment and price selection are different: provision with the current quoted `--retention-price-version` where required, using the user's existing deployment authorization. Different resource types can have separate quotes; inspect the API response instead of copying an old price version. Automatic enrollment does not bypass balance checks, reset paused/deleting state, or rewrite a previously funded policy. ```sh xapi workers retention pause --env preview diff --git a/src/commands/workers.ts b/src/commands/workers.ts index 22df615..30ee40c 100644 --- a/src/commands/workers.ts +++ b/src/commands/workers.ts @@ -163,7 +163,7 @@ PUSH FLAGS --env preview Required; production uses workers promote --config PATH Explicit xapi.worker.json path --non-interactive CI mode; never bypasses BLOCKED checks - --retention-price-version VERSION Explicit accepted freeze quote; does not auto-accept policy + --retention-price-version VERSION Current freeze quote; no separate policy acceptance required PROMOTE FLAGS --to production Required explicit production target From 93d17149ee6c610402e21c43d79538080e7fedeb Mon Sep 17 00:00:00 2001 From: daxiongya Date: Thu, 24 Sep 2026 12:33:44 +0800 Subject: [PATCH 28/28] feat(workers): plan native events and synchronize resource references safely --- README.md | 24 +- skills/xapi-workers/references/deployment.md | 54 ++- skills/xapi-workers/references/domains.md | 2 + skills/xapi-workers/references/lifecycle.md | 2 + skills/xapi-workers/references/resources.md | 2 +- skills/xapi-workers/references/secrets.md | 4 +- skills/xapi/SKILL.md | 2 +- skills/xapi/guides/workers.md | 42 ++- src/tests/workers-deployment-state.test.ts | 4 +- src/tests/workers-native-deployment.test.ts | 238 +++++++++++++ src/tests/workers-plan.test.ts | 6 +- src/tests/workers-project-resources.test.ts | 50 ++- src/tests/workers-promote.test.ts | 16 +- src/tests/workers-push-output.test.ts | 1 + src/tests/workers-push.test.ts | 78 ++++- src/tests/workers-wrangler-import.test.ts | 8 +- src/workers-client.ts | 13 + src/workers-cron.ts | 38 +++ src/workers-deployment-state.ts | 3 +- src/workers-native-deployment.ts | 333 +++++++++++++++++++ src/workers-plan.ts | 25 +- src/workers-project-resources.ts | 39 ++- src/workers-promote.ts | 45 ++- src/workers-push-output.ts | 1 + src/workers-push.ts | 60 +++- src/workers-resource-sync.ts | 128 +++++++ src/workers-wrangler-import.ts | 62 ++-- 27 files changed, 1149 insertions(+), 131 deletions(-) create mode 100644 src/tests/workers-native-deployment.test.ts create mode 100644 src/workers-cron.ts create mode 100644 src/workers-native-deployment.ts create mode 100644 src/workers-resource-sync.ts diff --git a/README.md b/README.md index 38b8a10..b701943 100644 --- a/README.md +++ b/README.md @@ -615,9 +615,9 @@ make both environments share one physical resource. `push` creates missing preview resources only after its full plan passes. `promote` performs the same production preflight and, after confirmation, creates missing production declarations before activating the exact tested -preview Artifact. A budget mismatch, missing Secret, incompatible binding, or -undeclared production resource blocks the command before any resource or -deployment write. If creation requires an accepted freeze quote, pass its exact +preview Artifact. A budget mismatch, missing required Secret or incompatible binding blocks activation. +An undeclared production resource is retained and unbound by the next deployment. +If creation requires an accepted freeze quote, pass its exact version with `--retention-price-version`. `resources update` requires the resource type because it replaces the complete @@ -636,13 +636,14 @@ git diff -- xapi.worker.json xapi workers plan --env preview ``` -`pull` performs an additive, all-or-nothing merge. It preserves local-only -declarations, writes no provider IDs, deletes nothing, and rejects unhealthy, -unsupported, duplicate, or conflicting remote bindings. `--env both` reads and -merges preview and production independently. +`pull` imports compatible live declarations initially, then uses a metadata-only +`.xapi/resource-sync-*` baseline for a three-way merge. Local edits and removed +bindings are preserved; remote-only changes are adopted; conflicting edits abort +without overwriting JSON. It changes no native resources and copies no Secret +values. `--env both` uses independent environment baselines. Use `resources remove --env ... --binding ...` only when the live resource must -remain. `plan` then marks it `MANUAL`, and `resources pull` can adopt it again. +remain. `plan` explains that the next deployment removes its binding only. To delete data, back it up first and run: ```bash @@ -652,18 +653,19 @@ xapi workers resources destroy --env preview --binding FILES --yes `destroy` accepts one environment at a time, removes the local declaration before requesting deletion, and reports deletion as requested until the live resource disappears. If the request fails, the live resource remains visible -and `resources pull` restores the declaration. `resources list/create/delete +and the user can inspect and explicitly retry deletion. `resources list/create/delete ...` remain low-level recovery primitives and do not update project files. Deployment identity includes the code Artifact, remote resource identities, -Secret versions, environment bindings and compatibility settings. Changing only +environment bindings and compatibility settings. Secret values are independent +and do not trigger a code deployment. Changing only resources or compatibility settings therefore deploys again; repeating an unchanged push reuses the current activation. Older deployments without this configuration fingerprint require one deployment to establish the baseline. Removing a resource from `xapi.worker.json` does **not** destroy it: `plan` -reports `MANUAL`, and the resource remains billable. `resources destroy` is the +shows the unbinding consequence, and the resource remains billable. `resources destroy` is the project-aware destructive operation. Preserve a backup before using it and wait until `resources list` no longer returns the binding. A successful deployment alone is not proof of deletion or final billing settlement. diff --git a/skills/xapi-workers/references/deployment.md b/skills/xapi-workers/references/deployment.md index ca046b8..01269b4 100644 --- a/skills/xapi-workers/references/deployment.md +++ b/skills/xapi-workers/references/deployment.md @@ -173,14 +173,46 @@ Wrangler environment must be rebuilt. Cache settings apply to the user Worker, not to the xAPI dispatcher. Required Secret names in `secrets.required` are imported; set their values through the Secrets API, never inside the Artifact. -The import report now includes a `deploymentPlan` for each environment: -`BEFORE_CODE` (D1 migrations), `CODE` (Worker configuration), `AFTER_CODE` (Queue -consumers and Cron). `REQUIRES_MAPPING` is unfinished execution support, not a -successful deployment. D1 directories are relative to the referenced Wrangler -file, not necessarily the project root. Code rollback does not undo applied SQL. - -Current managed Queue delivery is HTTP; it is not equivalent to `queue(batch)`. -Current HTTP schedules are not equivalent to `scheduled()`. Do not remove these -fields from an app or use `--accept-partial` to claim full compatibility. Before -publishing an app that uses them, implement/verify the declared event semantics -and database initialization, then test them in the selected xAPI test environment. +The import report includes `BEFORE_CODE` (D1 migrations), `CODE` (Worker +configuration), and `AFTER_CODE` (Queue consumers and Cron). Current push/promote +execute these steps only with the matching backend and Dispatcher release. Check +the plan before confirmation; migrations are resolved relative to the referenced +Wrangler file, constrained to the project, and frozen with their SHA256 before +execution. Promote uses the selected Artifact plus the displayed local migration +and event plan. It does not restore these from the old Artifact automatically. + +D1 files execute remotely through xAPI in order, recording each file and checksum +in the target D1 database. Require APPLIED/ALREADY_APPLIED and remote:true receipts. +An existing Wrangler record without a checksum is skipped with sha256:null: its +original content has not been verified. Failed SQL stops later files/code release; +completed migrations do not roll back with code. A lost response is reconciled +against the remote ledger, never blindly replayed. Preserve partial receipts. + +Queue consumers invoke queue(batch, env, ctx) through the platform event adapter; +CF owns ack/retry/delay/DLQ delivery. The adapter preserves message IDs, attempts, +timestamps, logical names, binary bodies and waitUntil failure. It does not claim +exactly-once or every possible V8 serialized type. Cron invokes scheduled() with +scheduledTime, cron, noRetry and waitUntil. Only UTC numeric five-field expressions +are currently supported, with CF weekdays 1=Sunday through 7=Saturday. Named fields, +L/W/# and singleton steps are rejected before deployment. Do not delete unsupported +settings or use --accept-partial to claim full compatibility. + +These are platform mappings over the existing metered Dispatcher path, not direct +namespace native trigger registrations. Scheduler waiting is currently at most +120 seconds; existing Dispatcher CPU/subrequest limits still apply. Configuration +probes require the deployed signed adapter and real handlers; an HTML 200 is not +readiness. Explicit crons:[] disables only CLI-owned schedules in the target +environment; absent triggers preserves them. Independently created user schedules +are not removed. + +A successful push proves deployment/configuration receipts and the configured +HTTP health probe, not live Queue/Cron or financial acceptance. Before reporting +CF acceptance, verify actual Queue messages/retries/DLQ, a naturally triggered Cron, +remote D1 ledger and business side effects, pause/ownership controls, and attributed +usage/billing on the selected xAPI test environment. Local workerd/Miniflare, +mocked transport and run-now alone are insufficient. Never bypass xAPI with a direct +Wrangler cloud deployment to manufacture a successful result. + +## Plan freshness + +The CLI freezes the project configuration and the target environment's active deployment ID when preparing the plan. If the JSON changes during build/confirmation, or another deployment becomes active before submission, rerun the plan and review its effects. Do not retry the old plan by changing its IDs. The API checks `expectedActiveDeploymentId` again when claiming deployment; `null` means the environment had no active deployment. This is a check at deployment submission, not a long-lived environment lock. Independent resources that are omitted from bindings are retained and may still incur storage costs. diff --git a/skills/xapi-workers/references/domains.md b/skills/xapi-workers/references/domains.md index 9f80601..f9471af 100644 --- a/skills/xapi-workers/references/domains.md +++ b/skills/xapi-workers/references/domains.md @@ -61,6 +61,8 @@ Detach only a customer custom domain: xapi workers domains detach --yes ``` +An explicit retry first checks for an exact completed binding. If confirmed, it returns completion; otherwise it submits the current authorized intent. A failed observation does not permanently block retry. Failed/timed-out user attempts are not automatically reissued in the background. A timeout means the result is unconfirmed, not proof Cloudflare did nothing. Late results remain in history and cannot replace a newer local bind/detach state. A custom-domain failure does not disable the normal platform hostname. + Platform-generated hostnames follow the environment lifecycle and cannot be detached independently. After detach, the exact hostname has a short reuse cooldown while stale edge route state expires. Wait until the API's `reusableAt` time before binding that diff --git a/skills/xapi-workers/references/lifecycle.md b/skills/xapi-workers/references/lifecycle.md index 2d88bbd..6016d17 100644 --- a/skills/xapi-workers/references/lifecycle.md +++ b/skills/xapi-workers/references/lifecycle.md @@ -20,6 +20,8 @@ xapi workers retention keep-paused --env preview Use actions permitted by the current lifecycle. Manual pause, low balance and pending deletion are distinct. A deposit doesn't prove reserve replenishment or automatic resumption. Retention-v3 can start cleanup at the reserve cleanup threshold; an estimate in hours is not necessarily a fixed expiry. Read actual deadlines and reserve budget. A 409 or PENDING_DELETION needs inspection of blockers and operation history, not a forced redeploy or edited database flag. +System retention pause/delete is a durable policy intent and is retried by the system after rechecking current policy, generation and native identity. Failed/expired management attempts must not indefinitely block stopping consumption. This differs from user-requested mutations, which are not automatically replayed. Missing final samples remain metering gaps; deletion permission does not itself authorize a refund or financial release. + For crash recovery, distinguish slow live ownership from an expired lease or exited process. Do not kill shared services to reproduce a failure. Use an isolated authorized test process/environment. Record deployment ID, lease/recovery state, delete intent and reserve changes; verify no script is recreated after deletion wins. ## Delete one Container Application diff --git a/skills/xapi-workers/references/resources.md b/skills/xapi-workers/references/resources.md index 0284ac7..9823919 100644 --- a/skills/xapi-workers/references/resources.md +++ b/skills/xapi-workers/references/resources.md @@ -31,7 +31,7 @@ Supply the explicitly accepted retention price version when required. Redeploy a | Workflow | Start and poll the instance to terminal state | Instance ID, final status and durable result | | Schedule | Trigger an immediate run and inspect run history | Schedule/run ID and resulting business change | -Queue uses a managed consumer that routes an envelope to the same Worker environment: +For imported native Queue consumers, the managed adapter invokes `queue(batch, env, ctx)` and returns ack/retry decisions to Cloudflare. The compatibility HTTP mode instead routes the following envelope to the same Worker environment; do not mistake that route for a native handler: ```js await env.JOBS.send({ path: "/tasks/report", method: "POST", body: { taskId } }); diff --git a/skills/xapi-workers/references/secrets.md b/skills/xapi-workers/references/secrets.md index a477847..6af0ebb 100644 --- a/skills/xapi-workers/references/secrets.md +++ b/skills/xapi-workers/references/secrets.md @@ -35,7 +35,9 @@ xapi workers secrets list --env preview xapi workers secrets status --env preview ``` -`list` returns xAPI metadata. `status` performs a read-only name comparison against Cloudflare and still never returns values. If a write times out, treat its result as unknown and run `status`; do not automatically replay a captured value. If the intended value cannot be proven, obtain or generate a fresh credential and rotate it. +`list` returns xAPI metadata. `status` performs a read-only name comparison against Cloudflare and still never returns values. If a write times out, treat its result as unknown and run `status`; do not automatically replay a captured value. Name presence cannot prove a value. The user may explicitly set their intended value again; do not require credential rotation merely because a response was lost. + +JSON lists required Secret names, not values or an exclusive allowlist. Removing a name does not delete its value. Value writes are independent for each environment/key; unrelated resource work and code publication do not create a global Secret lock. The first write to an absent script may briefly coordinate script initialization. Code deploy, promotion, rollback, pause, and resume preserve provider secrets with Cloudflare native binding inheritance. They do not read values from xAPI or copy values between environments. A missing required binding should block activation rather than opening a route with incomplete runtime configuration. diff --git a/skills/xapi/SKILL.md b/skills/xapi/SKILL.md index 1a873eb..c9e806f 100644 --- a/skills/xapi/SKILL.md +++ b/skills/xapi/SKILL.md @@ -70,7 +70,7 @@ Use granular commands only for multi-step work. Keep the instance ID, terminate ## Hosted Workers -Read `guides/workers.md` before creating, importing, planning, pushing, promoting, rolling back, attaching Cloudflare resources, scheduling tasks, or inspecting logs. Workers are continuously addressable JavaScript applications; Sandbox is ephemeral arbitrary compute. Prefer the project workflow: `workers init`, `workers plan --env preview`, `workers push --env preview`, then `workers promote --to production`. `init` has distinct new-project, existing frontend, Wrangler import, and Next.js SSR adapter paths; select the matching path from the guide instead of repeatedly regenerating project files. Use `xapi.worker.json` as managed-resource desired state. `plan` runs and validates the configured local build, compares its exact Artifact and resource declarations with live state, and shows budget/price-book impact without remote writes; `inspect` reports what is already running. Use `workers resources pull` only to adopt healthy remote-only resources. Git is optional. `push` prepares the same immutable Artifact before any remote mutation, including separately declared native static assets, shows the final plan, uses stable recovery keys, and never silently deletes stateful resources or Secrets. For web applications, inspect `webAppReady`: path-prefix-aware applications can use fallback routing, while root-relative routes and OAuth callbacks need a dedicated hostname. An optional platform-owned ephemeral Sandbox build can produce the same Artifact type. Rollback restores code and compatibility settings, never KV/D1/R2/DO/Queue/Workflow/schedule data or Secret values. Run the provider capability check before provisioning so missing permissions such as D1 Edit are reported precisely. KV, D1, R2, Durable Object, Queue, Workflow, Secret, schedule, managed-domain, observability, and billing data are environment- or Worker-scoped; never assume preview and production share state. Queue messages use the documented route envelope, are delivered at least once, and require an idempotent target route. Only `ACTIVE` means deployment succeeded. +Read `guides/workers.md` before creating, importing, planning, pushing, promoting, rolling back, attaching Cloudflare resources, scheduling tasks, or inspecting logs. Workers are continuously addressable JavaScript applications; Sandbox is ephemeral arbitrary compute. Prefer the project workflow: `workers init`, `workers plan --env preview`, `workers push --env preview`, then `workers promote --to production`. `init` has distinct new-project, existing frontend, Wrangler import, and Next.js SSR adapter paths; select the matching path from the guide instead of repeatedly regenerating project files. Use `xapi.worker.json` as managed-resource desired state. `plan` runs and validates the configured local build, compares its exact Artifact and resource declarations with live state, and shows budget/price-book impact without remote writes; `inspect` reports what is already running. Use `workers resources pull` to merge healthy resource inventory with local declarations using a metadata-only three-way baseline; conflicting edits require explicit resolution. Removing a declaration unbinds it on deployment and retains its native data and storage charges. Git is optional. `push` prepares the same immutable Artifact before any remote mutation, including separately declared native static assets, shows the final plan, uses stable recovery keys, and never silently deletes stateful resources or Secrets. For web applications, inspect `webAppReady`: path-prefix-aware applications can use fallback routing, while root-relative routes and OAuth callbacks need a dedicated hostname. An optional platform-owned ephemeral Sandbox build can produce the same Artifact type. Rollback restores code and compatibility settings, never KV/D1/R2/DO/Queue/Workflow/schedule data or Secret values. Run the provider capability check before provisioning so missing permissions such as D1 Edit are reported precisely. KV, D1, R2, Durable Object, Queue, Workflow, Secret, schedule, managed-domain, observability, and billing data are environment- or Worker-scoped; never assume preview and production share state. Queue supports the native handler adapter or the documented compatibility HTTP envelope, as selected in the deployment plan. Delivery is at least once, so handlers must be idempotent. Only `ACTIVE` means deployment succeeded. ## Usage Workflow diff --git a/skills/xapi/guides/workers.md b/skills/xapi/guides/workers.md index 0e287fb..de25d1a 100644 --- a/skills/xapi/guides/workers.md +++ b/skills/xapi/guides/workers.md @@ -145,8 +145,10 @@ are optional provenance, not authentication and not a deployment prerequisite. It runs the configured build, creates the remote Worker when `workerId` is absent, safely creates or updates declared resources, uploads one immutable Artifact, deploys preview, waits for the active state, and runs the configured -health check. `push` never deletes an extra stateful resource or Secret; `plan` -marks such drift `MANUAL` for explicit handling. +health check. `push` binds the resources declared for that environment. Removing +a declaration unbinds it on the next deployment; the resource, data and storage +charges remain until an explicit destruction request. The project workflow never deletes an extra stateful resource or Secret. +Extra Secret values remain independent and are not deleted by changing declarations. ## Resource state without drift @@ -155,7 +157,8 @@ There are only two resource states: - `xapi.worker.json` is the desired state that belongs in Git. It contains binding names and portable options, never Cloudflare or xAPI resource IDs. - xAPI is the live state. `plan` reads it every time and compares it with the - selected environment in `xapi.worker.json`; there is no cached state file. + selected environment in `xapi.worker.json`. A metadata-only `.xapi/resource-sync-*` + baseline is used by `resources pull` to merge edits; it is not authoritative live state. Choose the command by intent: @@ -207,15 +210,18 @@ git diff -- xapi.worker.json xapi workers plan --env preview ``` -`pull` is an additive, all-or-nothing merge. It imports only supported healthy -resources, preserves pending local declarations, never writes provider IDs, -never deletes anything, and refuses to overwrite a binding whose type, -Durable Object class, location, or D1 replication differs. `--env both` reads -the environments independently because their physical resources are separate. +The first `pull` imports supported healthy resources, enriches compatible +declarations and reports conflicting local definitions. Subsequent pulls compare +the local JSON and live inventory with the previous observations: preserve local +edits (including removed bindings), adopt remote-only changes and report conflicting +edits to the same binding without overwriting either side. It never writes provider +IDs into JSON, changes native resources or copies Secret values. Confirmed removal +from remote inventory can remove its unchanged local declaration. `--env both` +keeps independent baselines. A local file edit during the read aborts the merge. -`resources remove` changes desired state only. The following `plan` shows the -live resource as `MANUAL`; keep it with `resources pull`, or back it up and use -the project-aware destructive command: +`resources remove` changes desired state only. Review `plan` and deploy to remove +the Worker binding while retaining the physical resource. To also delete its data, +back it up and use the separate destructive command: ```bash xapi workers resources destroy --env preview --binding FILES --yes @@ -223,8 +229,9 @@ xapi workers resources destroy --env preview --binding FILES --yes `destroy` accepts one environment, removes the declaration before requesting live deletion, and reports a deletion request rather than claiming immediate -physical destruction. If the request fails, the live resource remains and -`resources pull` restores desired state before retrying. +physical destruction. If the request fails or its result is unknown, inspect it +and explicitly retry `destroy` against the same binding. Do not recreate the +resource or infer zero usage/refund from a timeout. `resources list/create/delete ...` are recovery and debugging primitives. They mutate or inspect live state without updating @@ -601,7 +608,14 @@ An immediate run exercises the same lease, retry, audit, budget, and Worker rout ### Encrypted Secrets -Prefer `--from-env` so plaintext does not appear in shell history. The control plane encrypts the value at rest and public reads expose only binding name, version, and timestamps. When a script is already active, rotation is applied immediately; otherwise it is applied during the next deployment. +Prefer `--from-env` so plaintext does not appear in shell history. Values go to the +native Secret endpoint; public reads expose metadata only. Set, replace and delete +values independently in preview or production. JSON lists required names, not values +or an allowlist. Removing a name from JSON does not delete its value. Code deployment, +promotion and rollback preserve the destination environment's current Secrets. +If the native script does not exist yet, the first set initializes a placeholder; +it does not overwrite an existing script whose local deployment record is missing. +Failure/timeout ends that attempt; inspect metadata and explicitly retry as needed. ```bash export MODEL_KEY='...' diff --git a/src/tests/workers-deployment-state.test.ts b/src/tests/workers-deployment-state.test.ts index 7777b97..bde51f7 100644 --- a/src/tests/workers-deployment-state.test.ts +++ b/src/tests/workers-deployment-state.test.ts @@ -44,7 +44,7 @@ test("same code: add, replace and explicitly remove bindings deploy; unchanged r expect(p.deployments).toHaveLength(4); }); -test("compatibility and secret versions change the deployment, not the Artifact", async () => { +test("compatibility changes deploy; independent Secret changes do not redeploy code", async () => { const p = platform(); await p.run(); await p.run("2026-09-06"); @@ -53,7 +53,7 @@ test("compatibility and secret versions change the deployment, not the Artifact" p.secrets[0].version = 2; await p.run("2026-09-06"); await p.run("2026-09-06"); - expect(p.deployments).toHaveLength(4); + expect(p.deployments).toHaveLength(2); expect(new Set(p.deployments.map(d => d.artifactId)).size).toBe(1); }); diff --git a/src/tests/workers-native-deployment.test.ts b/src/tests/workers-native-deployment.test.ts new file mode 100644 index 0000000..217dc3b --- /dev/null +++ b/src/tests/workers-native-deployment.test.ts @@ -0,0 +1,238 @@ +import { test, expect } from "bun:test"; +import { applyNativeDeploymentPhase } from "../workers-native-deployment.ts"; +const options = { + apiKey: "test-key", + apiBaseUrl: "https://api.xapi.to", +} as any; +const plan = { + migrations: [ + { + bindingName: "DB", + table: "d1_migrations", + name: "0001.sql", + sql: "CREATE TABLE example(id);", + sha256: "hash", + }, + { + bindingName: "DB", + table: "d1_migrations", + name: "0002.sql", + sql: "ALTER TABLE example ADD name;", + sha256: "hash2", + }, + ], + consumers: [ + { + bindingName: "JOBS", + configuration: { queue: "logical-jobs", max_batch_size: 1 }, + }, + ], + crons: ["0 * * * *"], + cronsConfigured: true, +}; +const resources = [ + { id: "db-id", bindingName: "DB", type: "D1_DATABASE", status: "ACTIVE" }, + { id: "queue-id", bindingName: "JOBS", type: "QUEUE", status: "ACTIVE" }, +]; +test("remote migrations are ordered and failures stop subsequent files", async () => { + const seen: string[] = []; + const api = { + async listWorkerResources() { + return resources; + }, + async applyWorkerD1Migration( + _o: any, + _w: any, + _e: any, + id: any, + input: any, + ) { + expect(id).toBe("db-id"); + seen.push(input.name); + throw new Error("remote SQL rejected"); + }, + }; + await expect( + applyNativeDeploymentPhase( + api, + options, + "worker", + "preview", + plan, + "BEFORE_CODE", + ), + ).rejects.toThrow("remote SQL rejected"); + expect(seen).toEqual(["0001.sql"]); +}); +test("does not accept local/missing receipts as remote completion", async () => { + const api = { + async listWorkerResources() { + return resources; + }, + async applyWorkerD1Migration() { + return { status: "APPLIED" }; + }, + }; + await expect( + applyNativeDeploymentPhase( + api, + options, + "worker", + "preview", + plan, + "BEFORE_CODE", + ), + ).rejects.toThrow("Remote migration receipt missing"); +}); +test("configures consumer and scheduled handler after code, reuses existing schedule", async () => { + const seen: any[] = []; + const api = { + async listWorkerResources() { + return resources; + }, + async configureWorkerQueueConsumer(...args: any[]) { + seen.push(args); + return { status: "CONFIGURED" }; + }, + async listWorkerSchedules(): Promise { + return []; + }, + async createWorkerSchedule(_o: any, _id: any, input: any) { + seen.push(input); + return input; + }, + async updateWorkerSchedule() { + throw new Error("unexpected update"); + }, + }; + await applyNativeDeploymentPhase( + api, + options, + "worker", + "preview", + plan, + "AFTER_CODE", + ); + expect(seen[0][3]).toBe("queue-id"); + expect(seen[0][4]).toEqual(plan.consumers[0].configuration); + expect(seen[1]).toMatchObject({ + handler: "scheduled", + cron: "0 * * * *", + timezone: "UTC", + }); + api.listWorkerSchedules = async () => [ + { ...seen[1], id: "schedule", enabled: true }, + ]; + await applyNativeDeploymentPhase( + api, + options, + "worker", + "preview", + plan, + "AFTER_CODE", + ); + expect(seen).toHaveLength(3); +}); + +test("preserves completed remote migration receipts when the next file fails", async () => { + const receipt = { status: "APPLIED", remote: true, name: "0001.sql" }; + let count = 0; + const api = { + async listWorkerResources() { + return resources; + }, + async applyWorkerD1Migration() { + if (count++) throw new Error("second SQL failed"); + return receipt; + }, + }; + try { + await applyNativeDeploymentPhase( + api, + options, + "worker", + "production", + plan, + "BEFORE_CODE", + ); + throw new Error("expected failure"); + } catch (error: any) { + expect(error.phase).toBe("BEFORE_CODE"); + expect(error.completed).toEqual([receipt]); + expect(error.message).toBe("second SQL failed"); + } +}); + +test("an explicit empty Cron list disables only CLI-managed schedules in this environment", async () => { + const updates: any[] = []; + const schedule = { + environment: "PREVIEW", + handler: "scheduled", + name: "Wrangler cron 1234567890123456", + enabled: true, + }; + const api = { + async listWorkerResources() { + return []; + }, + async listWorkerSchedules() { + return [ + { ...schedule, id: "owned" }, + { ...schedule, id: "production", environment: "PRODUCTION" }, + { ...schedule, id: "manual", name: "User task" }, + ]; + }, + async createWorkerSchedule() { + throw new Error("unexpected"); + }, + async updateWorkerSchedule(...args: any[]) { + updates.push(args); + return { id: args[2], enabled: false }; + }, + }; + const empty = { + migrations: [], + consumers: [], + crons: [], + cronsConfigured: false, + }; + await applyNativeDeploymentPhase( + api, + options, + "worker", + "preview", + empty, + "AFTER_CODE", + ); + expect(updates).toEqual([]); + await applyNativeDeploymentPhase( + api, + options, + "worker", + "preview", + { ...empty, cronsConfigured: true }, + "AFTER_CODE", + ); + expect(updates.map((args) => args.slice(2))).toEqual([ + ["owned", { enabled: false }], + ]); +}); + +import { validNativeCron } from "../workers-native-deployment.ts"; +test("validates CF numeric Cron ranges before any remote deployment phase", () => { + for (const cron of ["*/5 * * * *", "0 12 * * 1", "0 0 1-31/2 * 7"]) + expect(validNativeCron(cron)).toBe(true); + for (const cron of [ + "60 * * * *", + "0 24 * * *", + "0 0 0 * *", + "0 0 * 13 *", + "0 0 * * 0", + "*/0 * * * *", + "5/2 * * * *", + "0 * * * MON", + "0 0 L * *", + "- * * * *", + ]) + expect(validNativeCron(cron)).toBe(false); +}); diff --git a/src/tests/workers-plan.test.ts b/src/tests/workers-plan.test.ts index 98a8b8a..bac9df3 100644 --- a/src/tests/workers-plan.test.ts +++ b/src/tests/workers-plan.test.ts @@ -396,7 +396,7 @@ describe("workers plan", () => { clientOptions: { apiHost: "localhost:3003", apiKey: "test-key" }, client, }); - expect(plan.canApply).toBe(false); + expect(plan.canApply).toBe(true); expect(plan.actions).toContainEqual( expect.objectContaining({ operation: "UPDATE", kind: "budget" }), ); @@ -409,10 +409,10 @@ describe("workers plan", () => { ); expect(plan.actions).toContainEqual( expect.objectContaining({ - operation: "MANUAL", + operation: "NO_CHANGE", kind: "resource", key: "OLD_DB", - message: expect.stringContaining("resources pull"), + message: expect.stringContaining("Not referenced by this JSON"), }), ); expect(plan.actions).toContainEqual( diff --git a/src/tests/workers-project-resources.test.ts b/src/tests/workers-project-resources.test.ts index b56f4a2..009183a 100644 --- a/src/tests/workers-project-resources.test.ts +++ b/src/tests/workers-project-resources.test.ts @@ -111,7 +111,7 @@ describe("project resource declarations", () => { expect(result.nextSteps).toContain( "Delete its data after backup: xapi workers resources destroy --env preview --binding FILES --yes", ); - expect(result.nextSteps).not.toContain("xapi workers push --env preview"); + expect(result.nextSteps).toContain("xapi workers push --env preview"); const config = loadWorkerProject(root).config; expect(config.environments.preview.resources).toEqual([]); expect(config.environments.production.resources).toEqual([]); @@ -350,6 +350,54 @@ describe("project resource declarations", () => { expect(readFileSync(path, "utf8")).toBe(before); }); + test("three-way pull preserves local removal while adopting remote-only changes", async () => { + const root = linkedProject(); + const remote = [ + { bindingName: "FILES", type: "R2_BUCKET", status: "ACTIVE" }, + { bindingName: "DB", type: "D1_DATABASE", status: "ACTIVE", config: { readReplication: { mode: "disabled" } } }, + ]; + const pull = () => pullProjectResources({ cwd: root, environments: ["preview"], clientOptions: { apiHost: "localhost:3003", apiKey: "test-key" }, client: { listWorkerResources: async () => remote } }); + await pull(); + removeProjectResource({ cwd: root, environments: ["preview"], bindingName: "FILES" }); + remote[1].config!.readReplication.mode = "auto"; + const updated = await pull(); + expect(updated.environments[0].updated).toEqual(["DB"]); + expect(loadWorkerProject(root).config.environments.preview.resources).toEqual([ + { type: "d1_database", bindingName: "DB", readReplication: "auto" }, + ]); + expect((await pull()).changed).toBe(false); + // Confirmed remote destruction updates the local declaration, not another resource. + remote.pop(); + expect((await pull()).environments[0].removed).toEqual(["DB"]); + expect(loadWorkerProject(root).config.environments.preview.resources).toEqual([]); + }); + + test("three-way conflicts preserve the file and previous baseline", async () => { + const root = linkedProject(); + const remote = [{ bindingName: "DB", type: "D1_DATABASE", status: "ACTIVE", config: { requestedLocation: "apac" } }]; + const pull = () => pullProjectResources({ cwd: root, environments: ["preview"], clientOptions: { apiHost: "localhost:3003", apiKey: "test-key" }, client: { listWorkerResources: async () => remote } }); + await pull(); + updateProjectResource({ cwd: root, environments: ["preview"], resource: { bindingName: "DB", type: "d1_database", location: "weur" } }); + remote[0].config.requestedLocation = "enam"; + const before = readFileSync(join(root, "xapi.worker.json"), "utf8"); + await expect(pull()).rejects.toThrow("changed both locally and remotely"); + expect(readFileSync(join(root, "xapi.worker.json"), "utf8")).toBe(before); + remote[0].config.requestedLocation = "apac"; + expect((await pull()).changed).toBe(false); + expect(loadWorkerProject(root).config.environments.preview.resources[0].location).toBe("weur"); + }); + + test("pull never overwrites a JSON edit made while fetching remote state", async () => { + const root = linkedProject(); + await expect(pullProjectResources({ cwd: root, environments: ["preview"], clientOptions: { apiHost: "localhost:3003", apiKey: "test-key" }, client: { + listWorkerResources: async () => { + addProjectResource({ cwd: root, environments: ["preview"], resource: { bindingName: "LOCAL", type: "kv_namespace" } }); + return [{ bindingName: "REMOTE", type: "R2_BUCKET", status: "ACTIVE" }]; + }, + } })).rejects.toThrow("JSON changed during pull"); + expect(loadWorkerProject(root).config.environments.preview.resources.map(r => r.bindingName)).toEqual(["LOCAL"]); + }); + test("does not import incomplete or unhealthy remote state", async () => { const root = linkedProject(); await expect( diff --git a/src/tests/workers-promote.test.ts b/src/tests/workers-promote.test.ts index 47d6755..4edd1b1 100644 --- a/src/tests/workers-promote.test.ts +++ b/src/tests/workers-promote.test.ts @@ -111,8 +111,8 @@ function fakePlatform( ]; const productionDeployments: Array> = []; const productionResources: Array> = options.resources - ? [...options.resources] - : [{ bindingName: "STATE", type: "KV_NAMESPACE", status: "ACTIVE" }]; + ? options.resources.map((resource, index) => ({ id: `resource-${index}`, ...resource })) + : [{ id: "resource-state", bindingName: "STATE", type: "KV_NAMESPACE", status: "ACTIVE" }]; const artifacts = [ { id: "artifact-latest", @@ -163,7 +163,7 @@ function fakePlatform( listWorkerResources: async () => productionResources, createWorkerResource: async (_api, _id, _environment, input) => { calls.createResource += 1; - const created = { ...input, status: "ACTIVE" }; + const created = { id: `resource-${calls.createResource}`, ...input, status: "ACTIVE" }; productionResources.push(created); return created; }, @@ -376,7 +376,7 @@ describe("workers promote", () => { expect(ready.plan.canPromote).toBe(true); }); - test("shows extra production state as MANUAL data risk and cancellation is mutation-free", async () => { + test("unreferenced production resources are retained; cancellation remains mutation-free", async () => { const root = fixture(); const platform = fakePlatform({ resources: [ @@ -390,10 +390,10 @@ describe("workers promote", () => { clientOptions: { apiHost: "localhost:3003", apiKey: "test-key" }, client: platform.client, }); - expect(prepared.plan.canPromote).toBe(false); + expect(prepared.plan.canPromote).toBe(true); expect(prepared.plan.production.checks).toContainEqual( expect.objectContaining({ - status: "MANUAL", + status: "NO_CHANGE", kind: "resource", key: "OLD_DB", }), @@ -415,8 +415,8 @@ describe("workers promote", () => { return false; }, }), - ).rejects.toThrow("resource drift requires reconciliation"); - expect(confirmations).toBe(0); + ).rejects.toThrow("promotion cancelled"); + expect(confirmations).toBe(1); expect(platform.calls.deploy).toBe(0); }); }); diff --git a/src/tests/workers-push-output.test.ts b/src/tests/workers-push-output.test.ts index d8b9538..1687be8 100644 --- a/src/tests/workers-push-output.test.ts +++ b/src/tests/workers-push-output.test.ts @@ -8,6 +8,7 @@ import type { WorkerPushResult } from "../workers-push.ts"; const result: WorkerPushResult = { schemaVersion: 1, status: "ACTIVE", + nativeReceipts: [], initialPlan: { schemaVersion: 1, project: { diff --git a/src/tests/workers-push.test.ts b/src/tests/workers-push.test.ts index d513707..b6d196d 100644 --- a/src/tests/workers-push.test.ts +++ b/src/tests/workers-push.test.ts @@ -179,6 +179,7 @@ function fakePlatform( calls.createResource += 1; state.resources.push({ ...input, + id: `resource-${calls.createResource}`, type: input.type.toUpperCase(), status: "ACTIVE", }); @@ -233,6 +234,7 @@ function fakePlatform( deployWorker: async (_api, _id, input) => { calls.deploy += 1; const deployment = { + resourceIds: input.resourceIds, id: "deployment-1", idempotencyKey: input.idempotencyKey, artifactId: input.artifactId, @@ -425,7 +427,7 @@ writeFileSync("observed-key.txt", process.env.XAPI_KEY || ""); expect(loadWorkerProject(root).config.workerId).toBe(workerId); }); - test("refuses to deploy through remote-only resource drift", async () => { + test("unreferenced resources remain stored but are excluded from deployed bindings", async () => { const root = fixture({ linked: true }); const platform = fakePlatform({ exists: true }); platform.state.resources.push({ @@ -435,8 +437,7 @@ writeFileSync("observed-key.txt", process.env.XAPI_KEY || ""); status: "ACTIVE", }); let confirmations = 0; - await expect( - pushWorkerProject({ + await pushWorkerProject({ cwd: root, environment: "preview", clientOptions: { apiHost: "localhost:3003", apiKey: "test-key" }, @@ -452,16 +453,58 @@ writeFileSync("observed-key.txt", process.env.XAPI_KEY || ""); "export default {fetch(){return new Response('ok')}};", ); }, - }), - ).rejects.toThrow("requires reconciliation"); - expect(confirmations).toBe(0); - expect(platform.calls).toEqual({ - createWorker: 0, - updateBudget: 0, - createResource: 0, - uploadArtifact: 0, - deploy: 0, + fetchPublic: (async () => Response.json({ ok: true })) as unknown as typeof fetch, + sleep: async () => undefined, }); + expect(confirmations).toBe(1); + expect(platform.state.resources).toHaveLength(1); + expect(platform.state.resources[0].id).toBe("resource-old-db"); + expect(platform.state.deployments[0].resourceIds).toEqual([]); + expect(platform.calls.deploy).toBe(1); + }); + + test.each(["build", "confirmation"])("rejects JSON edits during %s before remote mutations", async (phase) => { + const root = fixture({ linked: true }); + const platform = fakePlatform({ exists: true }); + const edit = () => { + const path = join(root, "xapi.worker.json"); + const config = JSON.parse(readFileSync(path, "utf8")); + config.environments.preview.dailyBudgetUsd = 0.5; + writeFileSync(path, JSON.stringify(config)); + }; + await expect(pushWorkerProject({ + cwd: root, environment: "preview", + clientOptions: {apiHost:"localhost:3003", apiKey:"test-key"}, client: platform.client, + runBuild: async () => { + mkdirSync(join(root,"dist"), {recursive:true}); + writeFileSync(join(root,"dist/worker.mjs"), "export default {fetch(){return new Response('ok')}}"); + if (phase === "build") edit(); + }, + confirm: async () => { if (phase === "confirmation") edit(); return true; }, + })).rejects.toThrow("configuration changed"); + expect(platform.calls.uploadArtifact).toBe(0); + expect(platform.calls.deploy).toBe(0); + expect(platform.calls.createWorker).toBe(0); + }); + + test("rejects a deployment published while the user reviewed the plan before resource writes", async () => { + const root = fixture({linked:true, resources:[{type:"kv_namespace",bindingName:"STATE"}]}); + const platform = fakePlatform({exists:true}); + await expect(pushWorkerProject({ + cwd:root, environment:"preview", client:platform.client, + clientOptions:{apiHost:"localhost:3003",apiKey:"test-key"}, + runBuild:async()=>{ + mkdirSync(join(root,"dist"),{recursive:true}); + writeFileSync(join(root,"dist/worker.mjs"),"export default {fetch(){return new Response('ok')}}"); + }, + confirm:async()=>{ + platform.state.deployments.push({id:"newer-deployment",status:"ACTIVE",environment:"preview"}); + return true; + }, + })).rejects.toThrow("changed"); + expect(platform.calls.createResource).toBe(0); + expect(platform.calls.uploadArtifact).toBe(0); + expect(platform.calls.deploy).toBe(0); }); test("non-interactive mode fails a missing-Secret plan before any mutation", async () => { @@ -609,7 +652,7 @@ writeFileSync("observed-key.txt", process.env.XAPI_KEY || ""); }); }); - test("reconciles an HTTP 500 and safely retries Artifact and Deployment writes with the same idempotency key", async () => { + test("immutable Artifact upload can retry; an unconfirmed publish requires an explicit retry", async () => { const root = fixture({ linked: true }); const platform = fakePlatform({ exists: true }); const upload = platform.client.uploadWorkerArtifact.bind(platform.client); @@ -630,9 +673,9 @@ writeFileSync("observed-key.txt", process.env.XAPI_KEY || ""); } return deploy(...args); }; - const result = await pushWorkerProject({ + const options = { cwd: root, - environment: "preview", + environment: "preview" as const, clientOptions: { apiHost: "localhost:3003", apiKey: "test-key" }, client: platform.client, confirm: async () => true, @@ -646,7 +689,10 @@ writeFileSync("observed-key.txt", process.env.XAPI_KEY || ""); fetchPublic: (async () => Response.json({ ok: true })) as unknown as typeof fetch, sleep: async () => undefined, - }); + }; + await expect(pushWorkerProject(options)).rejects.toThrow("No second publish was sent"); + expect(deployAttempts).toBe(1); + const result = await pushWorkerProject(options); expect(result.status).toBe("ACTIVE"); expect(uploadAttempts).toBe(2); expect(deployAttempts).toBe(2); diff --git a/src/tests/workers-wrangler-import.test.ts b/src/tests/workers-wrangler-import.test.ts index eca54de..8498d18 100644 --- a/src/tests/workers-wrangler-import.test.ts +++ b/src/tests/workers-wrangler-import.test.ts @@ -473,7 +473,7 @@ test("imports native metadata/cache and modern required secrets without values", expect(result.config?.environments.preview.secrets).toEqual(["AUTH_SECRET"]); }); -test("reports native event and SQL migration gaps rather than silently claiming deployment compatibility", () => { +test("reports ordered remote migrations and explicit platform event mappings", () => { const root = workspace(); writeFileSync( join(root, "wrangler.jsonc"), @@ -492,7 +492,7 @@ test("reports native event and SQL migration gaps rather than silently claiming cwd: root, wranglerPath: "wrangler.jsonc", }); - expect(result.wrote).toBe(false); + expect(result.wrote).toBe(true); const phases = result.report.deploymentPlan.filter( (step) => step.environment === "preview", ); @@ -508,7 +508,7 @@ test("reports native event and SQL migration gaps rather than silently claiming }); expect(phases[2]).toMatchObject({ bindingName: "JOBS", - status: "REQUIRES_MAPPING", + status: "SUPPORTED", configuration: { max_batch_size: 1, max_retries: 5 }, }); for (const path of [ @@ -517,7 +517,7 @@ test("reports native event and SQL migration gaps rather than silently claiming "queues.consumers", ]) { expect(result.report.entries).toContainEqual( - expect.objectContaining({ category: "UNSUPPORTED", path }), + expect.objectContaining({ category: "MANAGED", path }), ); } }); diff --git a/src/workers-client.ts b/src/workers-client.ts index abad2f5..602ed2e 100644 --- a/src/workers-client.ts +++ b/src/workers-client.ts @@ -755,3 +755,16 @@ export function deleteWorkerSecret( 60_000, ); } + +export function applyWorkerD1Migration(options: WorkersClientOptions, id: string, environment: string, + resourceId: string, input: Record) { + return request(url(options, `/${encodeURIComponent(id)}/environments/${encodeURIComponent(environment)}/resources/${encodeURIComponent(resourceId)}/d1/migrations`), { + method: 'POST', headers: headers(options, true), body: JSON.stringify(input), + }); +} +export function configureWorkerQueueConsumer(options: WorkersClientOptions, id: string, environment: string, + resourceId: string, input: Record) { + return request(url(options, `/${encodeURIComponent(id)}/environments/${encodeURIComponent(environment)}/resources/${encodeURIComponent(resourceId)}/queue-consumer`), { + method: 'PUT', headers: headers(options, true), body: JSON.stringify(input), + }); +} diff --git a/src/workers-cron.ts b/src/workers-cron.ts new file mode 100644 index 0000000..28c6563 --- /dev/null +++ b/src/workers-cron.ts @@ -0,0 +1,38 @@ +// CF weekdays are 1 (Sunday) through 7 (Saturday). Reject unsupported +// syntax before resource writes; do not silently apply Unix cron semantics. +export function validNativeCron(cron: string): boolean { + const ranges = [ + [0, 59], + [0, 23], + [1, 31], + [1, 12], + [1, 7], + ]; + const fields = cron.trim().split(/\s+/); + return ( + fields.length === 5 && + fields.every((field, index) => + field.split(",").every((part) => { + const match = /^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/.exec(part); + if ( + !match || + (match[2] !== undefined && + (!Number.isSafeInteger(Number(match[2])) || Number(match[2]) < 1)) + ) + return false; + // A singleton/step has differing cron dialect semantics; ranges are explicit. + if ( + match[2] !== undefined && + match[1] !== "*" && + !match[1].includes("-") + ) + return false; + if (match[1] === "*") return true; + const [start, end = start] = match[1].split("-").map(Number); + return ( + start >= ranges[index][0] && end <= ranges[index][1] && start <= end + ); + }), + ) + ); +} diff --git a/src/workers-deployment-state.ts b/src/workers-deployment-state.ts index 7fab451..266b8e7 100644 --- a/src/workers-deployment-state.ts +++ b/src/workers-deployment-state.ts @@ -18,7 +18,7 @@ const sorted = (rows: Row[]) => rows.sort((a, b) => String(a.bindingName).locale // Provider observations must not cause deployments. No secret plaintext is read. export function deploymentPrefix(workerId: string, environment: string, artifactId: string, compatibility: { compatibilityDate?: string; compatibilityFlags?: string[] }, - environmentState: Row, resources: Row[], secrets: Row[]): string { + environmentState: Row, resources: Row[], _secrets: Row[]): string { return `v3-${hash({ workerId, environment: environment.toLowerCase(), artifactId, compatibilityDate: compatibility.compatibilityDate, compatibilityFlags: [...(compatibility.compatibilityFlags || [])].sort(), @@ -31,7 +31,6 @@ export function deploymentPrefix(workerId: string, environment: string, artifact className: config.className, state: config.state, status: r.status === "PROVISIONING" ? "ACTIVE" : r.status }; })), - secrets: sorted(secrets.map(s => ({ bindingName: s.bindingName, version: s.version }))), })}-`; } diff --git a/src/workers-native-deployment.ts b/src/workers-native-deployment.ts new file mode 100644 index 0000000..ec95593 --- /dev/null +++ b/src/workers-native-deployment.ts @@ -0,0 +1,333 @@ +import { validNativeCron } from "./workers-cron.ts"; +export { validNativeCron } from "./workers-cron.ts"; +import { createHash } from "node:crypto"; +import { + existsSync, + readFileSync, + readdirSync, + realpathSync, + statSync, +} from "node:fs"; +import { relative, resolve } from "node:path"; +import { z } from "zod"; +import { + type LoadedWorkerProject, + resolveWorkerProjectPath, +} from "./workers-project.ts"; +import { + queueBinding, + readWranglerEventConfig, +} from "./workers-wrangler-import.ts"; +import type { WorkersClientOptions } from "./workers-client.ts"; + +const consumerSchema = z + .object({ + queue: z.string().regex(/^[a-zA-Z0-9_-]{1,63}$/), + max_batch_size: z.number().int().min(1).max(100).optional(), + max_batch_timeout: z.number().int().min(0).max(60).optional(), + max_retries: z.number().int().min(0).max(100).optional(), + retry_delay: z.number().int().min(0).max(43200).optional(), + max_concurrency: z.number().int().min(1).max(250).optional(), + dead_letter_queue: z.string().optional(), + }) + .strict(); + +export function nativeDeploymentPlan( + project: LoadedWorkerProject, + environment: "preview" | "production", +) { + const source = readWranglerEventConfig(project, environment); + const migrations: Array<{ + bindingName: string; + table: string; + name: string; + sql: string; + sha256: string; + }> = []; + for (const database of source.databases) { + if (database.migrations_pattern !== undefined) + throw new Error( + "migrations_pattern needs an explicit supported file-discovery mapping; no migration was executed", + ); + const path = resolve( + source.directory, + String(database.migrations_dir ?? "migrations"), + ); + if (!existsSync(path) && database.migrations_dir === undefined) continue; + const directory = resolveWorkerProjectPath( + project, + relative(project.rootDir, path), + "D1 migrations", + ); + const realDirectory = realpathSync(directory); + resolveWorkerProjectPath( + project, + relative(project.rootDir, realDirectory), + "D1 migrations real path", + ); + const table = z + .string() + .regex(/^[A-Za-z_][A-Za-z0-9_]{0,127}$/) + .parse(database.migrations_table ?? "d1_migrations"); + if (table.toLowerCase() === "__xapi_migration_hashes") + throw new Error("Reserved D1 migration table"); + for (const name of readdirSync(directory) + .filter((name) => name.endsWith(".sql")) + .sort()) { + const file = resolveWorkerProjectPath( + project, + relative(project.rootDir, resolve(directory, name)), + "D1 migration file", + ); + resolveWorkerProjectPath( + project, + relative(project.rootDir, realpathSync(file)), + "D1 migration real path", + ); + if (!statSync(file).isFile()) + throw new Error(`Migration is not a file: ${name}`); + const sql = readFileSync(file, "utf8"); + if (!sql.trim()) throw new Error(`Empty D1 migration: ${name}`); + migrations.push({ + bindingName: String(database.binding), + table, + name, + sql, + sha256: createHash("sha256").update(sql).digest("hex"), + }); + } + } + const consumers = source.consumers.map((raw) => { + const { dead_letter_queue, ...consumer } = consumerSchema.parse(raw); + return { + bindingName: queueBinding(source.config, consumer.queue), + configuration: { + ...consumer, + ...(dead_letter_queue + ? { + deadLetterBinding: queueBinding(source.config, dead_letter_queue), + } + : {}), + }, + }; + }); + const crons = [ + ...new Set(z.array(z.string().min(1).max(100)).parse(source.crons ?? [])), + ]; + for (const cron of crons) { + if (!validNativeCron(cron)) + throw new Error( + "The platform scheduled adapter currently supports numeric five-field UTC Cron; Quartz extensions must not be silently converted", + ); + } + for (const item of [...migrations, ...consumers]) { + const expectedType = "sql" in item ? "d1_database" : "queue"; + if ( + !project.config.environments[environment].resources.some( + (resource) => + resource.bindingName === item.bindingName && + resource.type === expectedType, + ) + ) + throw new Error( + `Missing managed ${expectedType} binding ${item.bindingName}; re-import Wrangler before deploying`, + ); + } + return { + migrations, + consumers, + crons, + cronsConfigured: source.crons !== undefined, + }; +} +export type NativeDeploymentPlan = ReturnType; +export function publicNativeDeploymentPlan(plan: NativeDeploymentPlan) { + return { + ...plan, + migrations: plan.migrations.map(({ sql: _sql, ...migration }) => migration), + }; +} +export class NativeDeploymentError extends Error { + constructor( + message: string, + public readonly phase: string, + public readonly completed: unknown[], + ) { + super(message); + } +} + +export interface NativeDeploymentClient { + listWorkerResources( + options: WorkersClientOptions, + id: string, + environment: string, + ): Promise; + applyWorkerD1Migration?( + options: WorkersClientOptions, + id: string, + environment: string, + resourceId: string, + input: Record, + ): Promise; + configureWorkerQueueConsumer?( + options: WorkersClientOptions, + id: string, + environment: string, + resourceId: string, + input: Record, + ): Promise; + listWorkerSchedules?( + options: WorkersClientOptions, + id: string, + ): Promise; + createWorkerSchedule?( + options: WorkersClientOptions, + id: string, + input: Record, + ): Promise; + updateWorkerSchedule?( + options: WorkersClientOptions, + id: string, + scheduleId: string, + input: Record, + ): Promise; +} +const rows = (value: any): any[] => { + if (Array.isArray(value)) return value; + if (Array.isArray(value?.data)) return value.data; + throw new Error("Invalid resource/schedule list response"); +}; +export async function applyNativeDeploymentPhase( + api: NativeDeploymentClient, + options: WorkersClientOptions, + workerId: string, + environment: "preview" | "production", + plan: NativeDeploymentPlan, + phase: "BEFORE_CODE" | "AFTER_CODE", +) { + const receipts: unknown[] = []; + try { + const resources = + plan.migrations.length || plan.consumers.length + ? rows(await api.listWorkerResources(options, workerId, environment)) + : []; + const resourceId = (binding: string, type: string) => { + const resource = resources.find( + (resource) => + resource.bindingName === binding && + String(resource.type).toLowerCase() === type && + resource.status === "ACTIVE", + ); + if (!resource?.id) + throw new Error(`Active ${type} binding ${binding} is required`); + return resource.id as string; + }; + if (phase === "BEFORE_CODE") { + for (const migration of plan.migrations) { + if (!api.applyWorkerD1Migration) + throw new Error("Client does not support remote D1 migrations"); + const result: any = await api.applyWorkerD1Migration( + options, + workerId, + environment, + resourceId(migration.bindingName, "d1_database"), + { table: migration.table, name: migration.name, sql: migration.sql }, + ); + if ( + !["APPLIED", "ALREADY_APPLIED"].includes(result?.status) || + result.remote !== true + ) + throw new Error( + `Remote migration receipt missing: ${migration.name}`, + ); + receipts.push(result); + } + return receipts; + } + for (const consumer of plan.consumers) { + if (!api.configureWorkerQueueConsumer) + throw new Error("Client does not support Queue event configuration"); + const result: any = await api.configureWorkerQueueConsumer( + options, + workerId, + environment, + resourceId(consumer.bindingName, "queue"), + consumer.configuration, + ); + if (!["CONFIGURED", "UNCHANGED"].includes(result?.status)) + throw new Error( + `Queue consumer receipt missing: ${consumer.bindingName}`, + ); + receipts.push({ bindingName: consumer.bindingName, ...result }); + } + if (plan.crons.length || plan.cronsConfigured) { + if ( + !api.listWorkerSchedules || + !api.createWorkerSchedule || + !api.updateWorkerSchedule + ) + throw new Error("Client does not support scheduled handlers"); + const existing = rows(await api.listWorkerSchedules(options, workerId)); + const names = new Set( + plan.crons.map( + (cron) => + "Wrangler cron " + + createHash("sha256").update(cron).digest("hex").slice(0, 16), + ), + ); + for (const schedule of existing) { + if ( + schedule.environment.toLowerCase() === environment && + schedule.handler === "scheduled" && + /^Wrangler cron [a-f0-9]{16}$/.test(schedule.name) && + schedule.enabled && + !names.has(schedule.name) + ) + receipts.push( + await api.updateWorkerSchedule(options, workerId, schedule.id, { + enabled: false, + }), + ); + } + for (const cron of plan.crons) { + const name = + "Wrangler cron " + + createHash("sha256").update(cron).digest("hex").slice(0, 16); + const schedule = existing.find( + (row) => + row.environment.toLowerCase() === environment && + row.handler === "scheduled" && + row.name === name && + row.cron === cron && + row.timezone === "UTC", + ); + receipts.push( + schedule + ? schedule.enabled + ? schedule + : await api.updateWorkerSchedule(options, workerId, schedule.id, { + enabled: true, + }) + : await api.createWorkerSchedule(options, workerId, { + name, + handler: "scheduled", + environment, + cron, + timezone: "UTC", + path: "/", + method: "POST", + enabled: true, + }), + ); + } + } + return receipts; + } catch (error) { + throw new NativeDeploymentError( + error instanceof Error ? error.message : "Native deployment step failed", + phase, + receipts, + ); + } +} diff --git a/src/workers-plan.ts b/src/workers-plan.ts index a27b1d2..b3f4f9b 100644 --- a/src/workers-plan.ts +++ b/src/workers-plan.ts @@ -1,4 +1,5 @@ -import { existsSync, lstatSync, statSync } from "node:fs"; +import { nativeDeploymentPlan, publicNativeDeploymentPlan, type NativeDeploymentPlan } from './workers-native-deployment.ts'; +import { existsSync, lstatSync, readFileSync, statSync } from "node:fs"; import type { WorkerBillingQueryKind, WorkersClientOptions, @@ -51,6 +52,7 @@ export interface WorkerPlanAction { export interface WorkerDeploymentPlan { schemaVersion: 1; + nativeSteps?: { migrations: Array<{ bindingName: string; table: string; name: string; sha256: string }>; consumers: unknown[]; crons: string[] }; project: { rootDir: string; configPath: string; @@ -59,7 +61,7 @@ export interface WorkerDeploymentPlan { build: { command: string; output: string; main?: string }; }; environment: "preview" | "production"; - remote: { linked: boolean; workerId?: string }; + remote: { linked: boolean; workerId?: string; activeDeploymentId?: string | null }; costImpact: { status: "AVAILABLE" | "PARTIAL" | "UNKNOWN"; desiredDailyBudgetUsd: number; @@ -119,6 +121,8 @@ export interface PrepareWorkerPlanOptions extends CreateWorkerPlanOptions { export interface PreparedWorkerPlan { plan: WorkerDeploymentPlan; bundle: LoadedWorkerArtifact; + nativePlan: NativeDeploymentPlan; + configContent: string; } type UnknownRecord = Record; @@ -361,10 +365,10 @@ function compareResources( )) { add( actions, - "MANUAL", + "NO_CHANGE", "resource", name, - `Remote-only resource may keep accruing charges. Adopt it with \`xapi workers resources pull --env ${environment}\`, or back it up and run \`xapi workers resources destroy --env ${environment} --binding ${name} --yes\``, + `Not referenced by this JSON: remove its Worker binding on deploy, retain the resource and its storage charges. Physical deletion requires resources destroy.`, undefined, { ...(string(existing.id) ? { resourceId: string(existing.id) } : {}), @@ -565,7 +569,7 @@ async function artifactAndDeployment( !actions.some(a => a.kind === "resource" && a.operation === "CREATE") ? currentMatchingDeployment(deployments, environmentState, artifactId, deploymentPrefix(String(remote.id), environmentName, artifactId, - readWranglerDeploymentSettings(project, environmentName), environmentState, resources, secrets)) + readWranglerDeploymentSettings(project, environmentName), environmentState, resources.filter(resource => project.config.environments[environmentName].resources.some(desired => desired.bindingName === resource.bindingName)), secrets)) : undefined; if (active) { add( @@ -885,6 +889,7 @@ export async function createWorkerPlan( } } + const native = nativeDeploymentPlan(project, options.environment); actions.sort( (a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || @@ -894,6 +899,7 @@ export async function createWorkerPlan( const summary = planSummary(actions); return { schemaVersion: 1, + nativeSteps: publicNativeDeploymentPlan(native), project: { rootDir: project.rootDir, configPath: project.configPath, @@ -908,6 +914,7 @@ export async function createWorkerPlan( environment: options.environment, remote: { linked: !!remote, + activeDeploymentId: string(remoteEnvironmentState?.activeDeploymentId) || null, ...(remote ? { workerId: string(remote.id) } : {}), }, costImpact: planCostImpact( @@ -937,12 +944,18 @@ export async function prepareWorkerPlan( options: PrepareWorkerPlanOptions, ): Promise { const project = loadWorkerProject(options.cwd, options.configPath); + const configContent = readFileSync(project.configPath, "utf8"); validatePlanInputs(project); const bundle = await prepareWorkerProjectBundle( project, options.environment, options.runBuild, ); + const nativePlan = nativeDeploymentPlan(project, options.environment); const plan = await createWorkerPlan(options); - return { plan, bundle }; + if (readFileSync(project.configPath, "utf8") !== configContent) + throw new WorkerProjectBuildError("Project configuration changed while planning; rerun the plan", { remoteChangesApplied: false }); + if (JSON.stringify(plan.nativeSteps) !== JSON.stringify(publicNativeDeploymentPlan(nativePlan))) + throw new Error('Wrangler configuration or migrations changed while planning; rerun the plan'); + return { plan, bundle, nativePlan, configContent }; } diff --git a/src/workers-project-resources.ts b/src/workers-project-resources.ts index a90c3d1..8ef3e0e 100644 --- a/src/workers-project-resources.ts +++ b/src/workers-project-resources.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; -import { renameSync, statSync, writeFileSync } from "node:fs"; +import { readFileSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { mergeResourceChanges, resourceSyncState, sameResource } from "./workers-resource-sync.ts"; import { loadWorkerProject, type WorkerProjectConfig, @@ -53,6 +54,7 @@ export interface PullProjectResourcesResult { added: string[]; updated: string[]; unchanged: string[]; + removed: string[]; }>; nextSteps: string[]; } @@ -195,6 +197,8 @@ export async function pullProjectResources( ); } const api = options.client || workersClient; + const originalConfig = readFileSync(project.configPath, "utf8"); + const sync = resourceSyncState(project.configPath, options.clientOptions.apiHost, workerId); const liveByEnvironment = await Promise.all( options.environments.map(async (environment) => ({ environment, @@ -211,14 +215,17 @@ export async function pullProjectResources( const result: PullProjectResourcesResult["environments"] = []; let changed = false; - // Pull is an all-or-nothing local merge. It never deletes declarations and - // never writes provider IDs, so pending local work is preserved. + // Pull updates declarations only, never the provider. A previous observation + // and local snapshot preserve deliberate local removals and pending changes. for (const { environment, resources } of liveByEnvironment) { const seen = new Set(); const added: string[] = []; const updated: string[] = []; const unchanged: string[] = []; + const removed: string[] = []; const desired = config.environments[environment].resources; + const observed: Resource[] = []; + const baseline = sync.state.environments[environment]; for (const raw of [...resources].sort((left, right) => { const leftName = remoteWorkerResourceState(left).bindingName || ""; const rightName = remoteWorkerResourceState(right).bindingName || ""; @@ -254,6 +261,8 @@ export async function pullProjectResources( `${environment} binding ${state.bindingName} has unsupported type ${state.rawType || "UNKNOWN"} or incomplete Durable Object metadata`, ); } + observed.push(remote); + if (baseline) continue; const index = desired.findIndex( (item) => item.bindingName === remote.bindingName, ); @@ -277,10 +286,27 @@ export async function pullProjectResources( unchanged.push(remote.bindingName); } } - result.push({ name: environment, added, updated, unchanged }); + if (baseline) { + const merged = mergeResourceChanges(desired, observed, baseline, environment); + for (const item of merged) { + const prior = desired.find(row => row.bindingName === item.bindingName); + if (!prior) added.push(item.bindingName); + else if (!sameResource(prior, item)) updated.push(item.bindingName); + else unchanged.push(item.bindingName); + } + for (const item of desired) if (!merged.some(row => row.bindingName === item.bindingName)) removed.push(item.bindingName); + config.environments[environment].resources = merged; + changed ||= !!(added.length || updated.length || removed.length); + } + sync.state.environments[environment] = { local: structuredClone(config.environments[environment].resources), remote: observed }; + result.push({ name: environment, added, updated, unchanged, removed }); } + if (readFileSync(project.configPath, "utf8") !== originalConfig) + throw new WorkerProjectConfigError("worker_project_config_changed", "Project JSON changed during pull; no changes were written. Run pull again"); + sync.assertUnchanged(); if (changed) writeConfig(project.configPath, config); + sync.save(); return { changed, configPath: project.configPath, @@ -422,12 +448,11 @@ export function removeProjectResource( bindingName: selectedBinding, nextSteps: project.config.workerId ? [ + ...steps(options.environments), + "Deployment removes the binding; the remote resource and its storage charges remain until explicit destruction", ...options.environments.flatMap((environment) => [ - `xapi workers plan --env ${environment}`, - `Keep it: xapi workers resources pull --env ${environment}`, `Delete its data after backup: xapi workers resources destroy --env ${environment} --binding ${selectedBinding} --yes`, ]), - "Re-run plan after that choice; deploy only after MANUAL resource drift is gone", ] : steps(options.environments), }; diff --git a/src/workers-promote.ts b/src/workers-promote.ts index 06d5ba9..29c76a5 100644 --- a/src/workers-promote.ts +++ b/src/workers-promote.ts @@ -1,3 +1,4 @@ +import { nativeDeploymentPlan, publicNativeDeploymentPlan, applyNativeDeploymentPhase, NativeDeploymentError, type NativeDeploymentClient, type NativeDeploymentPlan } from './workers-native-deployment.ts'; import { createInterface } from "node:readline/promises"; import type { WorkersClientOptions } from "./workers-client.ts"; import * as workersClient from "./workers-client.ts"; @@ -15,7 +16,7 @@ import { readWranglerDeploymentSettings } from "./workers-wrangler-import.ts"; type UnknownRecord = Record; -export interface PromotionClient extends DeploymentClient, ManagedResourceClient { +export interface PromotionClient extends DeploymentClient, ManagedResourceClient, NativeDeploymentClient { listWorkerResources( options: WorkersClientOptions, id: string, @@ -49,9 +50,11 @@ export interface WorkerPromotionPlan { previewDeployment: { id: string; artifactId: string; deployedAt?: string }; artifact: { id: string; contentSha256: string; sizeBytes?: number }; production: { + activeDeploymentId?: string | null; checks: WorkerPromotionCheck[]; dataRisk: string[]; }; + nativeSteps: ReturnType; canPromote: boolean; } @@ -78,6 +81,7 @@ export interface WorkerPromotionResult { artifact: { id: string; contentSha256: string; sizeBytes?: number }; resources: { created: string[]; unchanged: string[] }; deployment: { id: string; status: "ACTIVE"; idempotencyKey: string }; + nativeReceipts: unknown[]; publicUrl: string; health: { url: string; status: number; attempts: number }; commands: { logs: string; rollback: string }; @@ -144,7 +148,7 @@ function productionChecks( ): { checks: WorkerPromotionCheck[]; dataRisk: string[] } { const checks: WorkerPromotionCheck[] = []; const dataRisk: string[] = [ - "Promotion changes the Worker code Artifact only; it does not snapshot, copy, or roll back production data", + "Promotion deploys the selected Worker Artifact and the displayed local Wrangler migration/event plan; it does not snapshot, copy, or roll back production data", ]; const currentBudget = amount(remoteEnvironment.dailyBudgetUsd); if (hasStaticAssets) { @@ -262,11 +266,11 @@ function productionChecks( a.localeCompare(b), )) { checks.push({ - status: "MANUAL", + status: "NO_CHANGE", kind: "resource", key: bindingName, message: - "Extra production stateful resource is preserved and not modified", + "Not referenced by this JSON: remove its Worker binding on deploy; preserve the resource and storage charges. Physical deletion requires resources destroy", }); dataRisk.push( `${bindingName} (${remoteType(resource.type)}) contains independent production state; promotion does not copy preview data or delete it`, @@ -345,6 +349,7 @@ export async function createWorkerPromotionPlan( plan: WorkerPromotionPlan; worker: UnknownRecord; artifact: UnknownRecord; + nativePlan: NativeDeploymentPlan; }> { if (options.to !== "production") { throw new WorkerPushError("workers promote requires --to production"); @@ -416,6 +421,8 @@ export async function createWorkerPromotionPlan( secrets, Boolean(project.config.assets), ); + const nativePlan = nativeDeploymentPlan(project, "production"); + checked.dataRisk.push("Remote D1 migrations run before code; Queue/Cron configuration runs after code. Completed database changes are not rolled back on code/configuration failure."); const plan: WorkerPromotionPlan = { schemaVersion: 1, workerId, @@ -434,14 +441,15 @@ export async function createWorkerPromotionPlan( ? { sizeBytes: amount(artifact.sizeBytes) } : {}), }, - production: checked, + production: {...checked, activeDeploymentId: text(production.activeDeploymentId) || null}, + nativeSteps: publicNativeDeploymentPlan(nativePlan), canPromote: !checked.checks.some( (item) => item.status === "BLOCKED" || (item.status === "MANUAL" && item.kind === "resource"), ), }; - return { plan, worker, artifact }; + return { plan, worker, artifact, nativePlan }; } export async function promoteWorkerProject( @@ -449,6 +457,7 @@ export async function promoteWorkerProject( ): Promise { const api = options.client || (workersClient as PromotionClient); const project = loadWorkerProject(options.cwd, options.configPath); + const compatibility = readWranglerDeploymentSettings(project, "production"); const prepared = await createWorkerPromotionPlan({ ...options, client: api }); options.onPlan?.(prepared.plan); if (!prepared.plan.canPromote) { @@ -475,12 +484,19 @@ export async function promoteWorkerProject( ); } } - const compatibility = readWranglerDeploymentSettings(project, "production"); + if (JSON.stringify(loadWorkerProject(project.rootDir, project.configPath).config) !== JSON.stringify(project.config) || + JSON.stringify(readWranglerDeploymentSettings(project, "production")) !== JSON.stringify(compatibility)) + throw new WorkerPushError("Production configuration changed after plan; rerun promote to review it before applying changes"); const wait = options.sleep || ((milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds))); + const nativeReceipts: unknown[] = []; + let releasedDeploymentId: string | undefined; try { + const currentWorker = record(await api.getWorker(options.clientOptions, prepared.plan.workerId), "Worker"); + if (prepared.plan.production.activeDeploymentId !== (text(environment(currentWorker, "production").activeDeploymentId) || null)) + throw new WorkerPushError("Production deployment changed after plan; rerun promote before replacing it"); const resources = await ensureManagedResources( api, options.clientOptions, @@ -489,6 +505,7 @@ export async function promoteWorkerProject( project.config.environments.production.resources, options.retentionPriceVersion, ); + nativeReceipts.push(...await applyNativeDeploymentPhase(api, options.clientOptions, prepared.plan.workerId, "production", prepared.nativePlan, "BEFORE_CODE")); const deployed = await ensureActiveDeployment( api, options.clientOptions, @@ -497,8 +514,13 @@ export async function promoteWorkerProject( "production", compatibility, wait, + options.retentionPriceVersion, + project.config.environments.production.resources.map(resource => resource.bindingName), + prepared.plan.production.activeDeploymentId, ); - const deploymentId = text(deployed.deployment.id); + releasedDeploymentId = text(deployed.deployment.id); + nativeReceipts.push(...await applyNativeDeploymentPhase(api, options.clientOptions, prepared.plan.workerId, "production", prepared.nativePlan, "AFTER_CODE")); + const deploymentId = releasedDeploymentId; if (!deploymentId) { throw new WorkerPushError("ACTIVE production deployment is missing id"); } @@ -518,6 +540,7 @@ export async function promoteWorkerProject( schemaVersion: 1, status: "ACTIVE", plan: prepared.plan, + nativeReceipts, workerId: prepared.plan.workerId, artifact: prepared.plan.artifact, resources, @@ -539,6 +562,9 @@ export async function promoteWorkerProject( workerId: prepared.plan.workerId, artifactId: prepared.plan.artifact.id, productionResourcesPreserved: true, + releasedDeploymentId, + nativeReceipts: [...nativeReceipts, ...(error instanceof NativeDeploymentError ? error.completed : [])], + ...(error instanceof NativeDeploymentError ? { failedPhase: error.phase } : {}), ...error.recovery, }); } @@ -548,6 +574,9 @@ export async function promoteWorkerProject( workerId: prepared.plan.workerId, artifactId: prepared.plan.artifact.id, productionResourcesPreserved: true, + releasedDeploymentId, + nativeReceipts: [...nativeReceipts, ...(error instanceof NativeDeploymentError ? error.completed : [])], + ...(error instanceof NativeDeploymentError ? { failedPhase: error.phase } : {}), }, ); } diff --git a/src/workers-push-output.ts b/src/workers-push-output.ts index 69e23aa..64b1e37 100644 --- a/src/workers-push-output.ts +++ b/src/workers-push-output.ts @@ -43,6 +43,7 @@ export function formatWorkerPushResult(result: WorkerPushResult): string { `HTTP ${result.health.status} · ${result.health.attempts} attempt${result.health.attempts === 1 ? "" : "s"}`, ), metadata("Resources", resources.join(" · ") || "No managed resources"), + ...(result.nativeReceipts.length ? [metadata("Native steps", `${result.nativeReceipts.length} deployment receipts; event execution still needs verification`)] : []), "", "Next steps", ` 1. Open: ${result.publicUrl}`, diff --git a/src/workers-push.ts b/src/workers-push.ts index 690eb41..652d5a7 100644 --- a/src/workers-push.ts +++ b/src/workers-push.ts @@ -1,3 +1,4 @@ +import { NativeDeploymentError, applyNativeDeploymentPhase, type NativeDeploymentClient } from './workers-native-deployment.ts'; import { createHash, randomUUID } from "node:crypto"; import { existsSync, @@ -57,7 +58,7 @@ export interface DeploymentClient { ): Promise; } -export interface PushClient extends PlanClient, DeploymentClient { +export interface PushClient extends PlanClient, DeploymentClient, NativeDeploymentClient { listWorkerDomains( options: WorkersClientOptions, id: string, @@ -146,6 +147,7 @@ export interface WorkerPushResult { resources: { created: string[]; unchanged: string[] }; artifact: { id: string; contentSha256: string; sizeBytes: number }; deployment: { id: string; status: "ACTIVE"; idempotencyKey: string }; + nativeReceipts: unknown[]; publicUrl: string; routing?: { mode?: string; webAppReady: boolean; publicOrigin?: string; publicBasePath?: string }; health: { url: string; status: number; attempts: number }; @@ -609,6 +611,8 @@ export async function ensureActiveDeployment( compatibility: ReturnType, sleep: (milliseconds: number) => Promise, retentionPriceVersion?: string, + desiredBindings?: string[], + expectedActiveDeploymentId?: string | null, ): Promise<{ deployment: UnknownRecord; idempotencyKey: string }> { const currentWorker = record( await api.getWorker(options, workerId), @@ -619,13 +623,24 @@ export async function ensureActiveDeployment( api.listWorkerResources(options, workerId, environment), api.listWorkerSecrets(options, workerId, environment), ]); + const resourceRows = records(resourceState, "managed resources"); + const selected = desiredBindings ? desiredBindings.map(name => { + const matches = resourceRows.filter(row => row.bindingName === name); + if (matches.length !== 1 || !text(matches[0].id)) + throw new WorkerPushError(`Resource binding ${name} is missing or ambiguous; rerun plan`); + return matches[0]; + }) : resourceRows; + const resourceIds = selected.filter(row => row.type !== "CONTAINER_APPLICATION").map(row => text(row.id)!); const prefix = deploymentPrefix(workerId, environment, artifactId, compatibility, - environmentState, records(resourceState, "managed resources"), records(secretState, "Secrets")); + environmentState, selected, records(secretState, "Secrets")); const alreadyActive = currentMatchingDeployment(records(currentWorker.deployments || [], "Deployments"), environmentState, artifactId, prefix); if (alreadyActive) { return { deployment: alreadyActive, idempotencyKey: text(alreadyActive.idempotencyKey)! }; } + if (expectedActiveDeploymentId !== undefined && + expectedActiveDeploymentId !== (text(environmentState.activeDeploymentId) || null)) + throw new WorkerPushError("Active deployment changed after plan; rerun plan before replacing it", {workerId}); const idempotencyKey = deploymentKey(prefix, environmentState); let deployment = await deploymentFromWorker( api, @@ -639,6 +654,8 @@ export async function ensureActiveDeployment( await api.deployWorker(options, workerId, { environment, artifactId, + ...(desiredBindings ? {resourceIds} : {}), + ...(expectedActiveDeploymentId !== undefined ? {expectedActiveDeploymentId} : {}), idempotencyKey, compatibilityDate: compatibility.compatibilityDate, compatibilityFlags: compatibility.compatibilityFlags, @@ -655,17 +672,9 @@ export async function ensureActiveDeployment( idempotencyKey, ); if (!deployment) { - deployment = record( - await api.deployWorker(options, workerId, { - environment, - artifactId, - idempotencyKey, - compatibilityDate: compatibility.compatibilityDate, - compatibilityFlags: compatibility.compatibilityFlags, - ...(retentionPriceVersion ? { retentionPriceVersion } : {}), - }), - "Deployment", - ); + throw new WorkerPushError("Deployment result is unconfirmed; inspect it or explicitly retry the command. No second publish was sent", { + workerId, artifactId, idempotencyKey, resultUnconfirmed: true, + }); } } } @@ -792,7 +801,7 @@ export async function pushWorkerProject( throw error; } const project = loadWorkerProject(options.cwd, options.configPath); - const initialConfig = readFileSync(project.configPath, "utf8"); + const initialConfig = prepared.configContent; const initialConfigSha256 = sha256(initialConfig); const compatibility = readWranglerDeploymentSettings(project, "preview"); const initialPlan = prepared.plan; @@ -827,6 +836,9 @@ export async function pushWorkerProject( } let workerState: { worker: UnknownRecord; id: string; created: boolean }; + if (sha256(readFileSync(project.configPath, "utf8")) !== initialConfigSha256 || + JSON.stringify(readWranglerDeploymentSettings(project, "preview")) !== JSON.stringify(compatibility)) + throw new WorkerPushError("Deployment configuration changed after plan; run push again to review the new plan", { remoteChangesApplied: false }); try { workerState = await ensureWorker( project, @@ -843,6 +855,13 @@ export async function pushWorkerProject( ); } const linkedProject = loadWorkerProject(project.rootDir, project.configPath); + if (JSON.stringify({ ...linkedProject.config, workerId: project.config.workerId }) !== JSON.stringify(project.config)) + throw new WorkerPushError("Deployment configuration changed during Worker setup; rerun plan", { workerId: workerState.id }); + if (initialPlan.remote.activeDeploymentId !== undefined && + initialPlan.remote.activeDeploymentId !== (text(environmentOf(workerState.worker, "preview").activeDeploymentId) || null)) + throw new WorkerPushError("Active deployment changed after plan; rerun plan before replacing it", {workerId: workerState.id}); + const nativeReceipts: unknown[] = []; + let releasedDeploymentId: string | undefined; try { await ensureEnvironment( api, @@ -858,6 +877,7 @@ export async function pushWorkerProject( linkedProject.config.environments.preview.resources, options.retentionPriceVersion, ); + const nativePlan = prepared.nativePlan; const missing = await missingSecrets( api, options.clientOptions, @@ -894,6 +914,7 @@ export async function pushWorkerProject( }, ); } + nativeReceipts.push(...await applyNativeDeploymentPhase(api, options.clientOptions, workerState.id, "preview", nativePlan, "BEFORE_CODE")); const deployed = await ensureActiveDeployment( api, options.clientOptions, @@ -905,7 +926,11 @@ export async function pushWorkerProject( ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))), options.retentionPriceVersion, + linkedProject.config.environments.preview.resources.map(resource => resource.bindingName), + initialPlan.remote.activeDeploymentId, ); + releasedDeploymentId = text(deployed.deployment.id); + nativeReceipts.push(...await applyNativeDeploymentPhase(api, options.clientOptions, workerState.id, "preview", nativePlan, "AFTER_CODE")); const deploymentId = text(deployed.deployment.id); if (!deploymentId) { throw new WorkerPushError("ACTIVE deployment response is missing id"); @@ -935,6 +960,7 @@ export async function pushWorkerProject( schemaVersion: 1, status: "ACTIVE", initialPlan, + nativeReceipts, worker: { id: workerState.id, created: workerState.created, @@ -971,6 +997,9 @@ export async function pushWorkerProject( throw new WorkerPushError(error.message, { workerId: workerState.id, resourcesPreserved: true, + releasedDeploymentId, + nativeReceipts: [...nativeReceipts, ...(error instanceof NativeDeploymentError ? error.completed : [])], + ...(error instanceof NativeDeploymentError ? { failedPhase: error.phase } : {}), ...error.recovery, }); } @@ -979,6 +1008,9 @@ export async function pushWorkerProject( { workerId: workerState.id, resourcesPreserved: true, + releasedDeploymentId, + nativeReceipts: [...nativeReceipts, ...(error instanceof NativeDeploymentError ? error.completed : [])], + ...(error instanceof NativeDeploymentError ? { failedPhase: error.phase } : {}), recovery: `xapi workers plan --env preview`, }, ); diff --git a/src/workers-resource-sync.ts b/src/workers-resource-sync.ts new file mode 100644 index 0000000..38fa5f4 --- /dev/null +++ b/src/workers-resource-sync.ts @@ -0,0 +1,128 @@ +import { createHash, randomUUID } from "node:crypto"; +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + writeFileSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +import { + WorkerProjectConfigError, + workerManagedResourceSchema, +} from "./workers-project.ts"; +import type { WorkerDesiredResource as Resource } from "./workers-resource-state.ts"; + +type Baseline = { local: Resource[]; remote: Resource[] }; +type State = { + version: 1; + environments: Partial>; +}; + +export function sameResource( + a: Resource | undefined, + b: Resource | undefined, +): boolean { + const value = (item: Resource | undefined) => + item && Object.entries(item).sort(([a], [b]) => a.localeCompare(b)); + return JSON.stringify(value(a)) === JSON.stringify(value(b)); +} + +/** Metadata only. The baseline is scoped to this file, API host and Worker. */ +export function resourceSyncState( + configPath: string, + apiHost: string, + workerId: string, +) { + const key = createHash("sha256") + .update(JSON.stringify([configPath, apiHost, workerId])) + .digest("hex") + .slice(0, 24); + const path = join(dirname(configPath), ".xapi", `resource-sync-${key}.json`); + const original = existsSync(path) ? readFileSync(path, "utf8") : undefined; + let state: State = { version: 1, environments: {} }; + if (original) { + try { + const parsed = JSON.parse(original); + if ( + parsed.version !== 1 || + !parsed.environments || + typeof parsed.environments !== "object" + ) + throw new Error(); + for (const [environment, baseline] of Object.entries( + parsed.environments, + ) as Array<[string, Baseline]>) { + if (!["preview", "production"].includes(environment) || !baseline) + throw new Error(); + for (const entries of [baseline.local, baseline.remote]) { + if ( + !Array.isArray(entries) || + new Set(entries.map((r) => r.bindingName)).size !== entries.length + ) + throw new Error(); + for (const resource of entries) + workerManagedResourceSchema.parse(resource); + } + } + state = parsed; + } catch { + throw new WorkerProjectConfigError( + "worker_resource_sync_invalid", + "Resource sync baseline is invalid; preserve it for inspection before starting a fresh pull", + ); + } + } + const assertUnchanged = () => { + const latest = existsSync(path) ? readFileSync(path, "utf8") : undefined; + if (latest !== original) + throw new WorkerProjectConfigError( + "worker_resource_sync_changed", + "Another resource pull updated the baseline; run pull again", + ); + }; + return { + state, + assertUnchanged, + save() { + assertUnchanged(); + mkdirSync(dirname(path), { recursive: true }); + const temporary = `${path}.${randomUUID()}.tmp`; + writeFileSync(temporary, JSON.stringify(state, null, 2) + "\n", { + mode: 0o600, + flag: "wx", + }); + renameSync(temporary, path); + }, + }; +} + +export function mergeResourceChanges( + local: Resource[], + remote: Resource[], + base: Baseline, + environment: string, +): Resource[] { + const byName = (rows: Resource[]) => + new Map(rows.map((row) => [row.bindingName, row])); + const currentLocal = byName(local), + currentRemote = byName(remote); + const oldLocal = byName(base.local), + oldRemote = byName(base.remote); + const merged = new Map(currentLocal); + for (const name of new Set([...oldRemote.keys(), ...currentRemote.keys()])) { + const next = currentRemote.get(name), + desired = currentLocal.get(name); + if (sameResource(next, oldRemote.get(name))) continue; + const localChanged = !sameResource(desired, oldLocal.get(name)); + if (localChanged && !sameResource(desired, next)) { + throw new WorkerProjectConfigError( + "worker_project_resource_pull_conflict", + `${environment} binding ${name} changed both locally and remotely since the last pull; neither side was overwritten`, + ); + } + if (next) merged.set(name, next); + else merged.delete(name); + } + return [...merged.values()]; +} diff --git a/src/workers-wrangler-import.ts b/src/workers-wrangler-import.ts index e2881a2..43a5a99 100644 --- a/src/workers-wrangler-import.ts +++ b/src/workers-wrangler-import.ts @@ -1,3 +1,5 @@ +import { validNativeCron } from './workers-cron.ts'; +import { createHash } from 'node:crypto'; import { normalizeNativeWorkerOptions, type WorkerCacheOptions, @@ -120,6 +122,7 @@ const MANAGED_TOP_LEVEL = new Set([ "durable_objects", "migrations", "queues", + "triggers", "workflows", "containers", ]); @@ -540,18 +543,20 @@ function resourceList( new Set(["binding"]), ), ); - if ( - queues?.consumers !== undefined && - !structurallyEmpty(queues.consumers) - ) { - compatibilityEntry( - entries, - "UNSUPPORTED", - `${prefix}queues.consumers`, - "Native queue(batch) delivery is not implemented: current xAPI consumers forward HTTP. Batch/ack/retry/dead-letter settings must be mapped before deployment", - { environment }, - ); + const queueNames = new Set(); + for (const consumer of array(queues?.consumers)) { + if (typeof consumer.queue === 'string') queueNames.add(consumer.queue); + if (typeof consumer.dead_letter_queue === 'string') queueNames.add(consumer.dead_letter_queue); + const allowed = new Set(['queue', 'max_batch_size', 'max_batch_timeout', 'max_retries', 'max_concurrency', 'retry_delay', 'dead_letter_queue']); + for (const key of Object.keys(consumer)) if (!allowed.has(key)) + compatibilityEntry(entries, 'UNSUPPORTED', `${prefix}queues.consumers.${key}`, 'Unsupported Queue consumer option', { environment }); } + for (const name of queueNames) { + if (!array(queues?.producers).some(item => item.queue === name)) + resources.push({ type: 'queue', bindingName: queueBinding(config, name) }); + } + if (queueNames.size) compatibilityEntry(entries, 'MANAGED', `${prefix}queues.consumers`, + 'Platform event adapter invokes queue(batch), preserving explicit acknowledgements, retries and binary bodies; CF owns delivery and dead-letter routing.', { environment }); array(config.workflows).forEach((item, index) => add( "workflow", @@ -1059,10 +1064,8 @@ export function importWranglerProject( phase: "AFTER_CODE", kind: "QUEUE_CONSUMER", environment, - status: "REQUIRES_MAPPING", - ...(typeof producer?.binding === "string" - ? { bindingName: producer.binding } - : {}), + status: "SUPPORTED", + bindingName: queueBinding(config, String(consumer.queue)), configuration: Object.fromEntries( Object.entries(consumer).filter(([key]) => [ @@ -1080,18 +1083,21 @@ export function importWranglerProject( } const crons = record(config.triggers)?.crons; if (Array.isArray(crons) && crons.length) { + const supportedCrons = crons.every(cron => typeof cron === 'string' && validNativeCron(cron)); + if (!supportedCrons) + compatibilityEntry(entries, 'UNSUPPORTED', `${prefix}triggers.crons`, 'Invalid or unsupported CF numeric UTC Cron; check ranges and mapping support (no silent conversion)', { environment }); deploymentPlan.push({ phase: "AFTER_CODE", kind: "CRON", environment, - status: "REQUIRES_MAPPING", + status: supportedCrons ? "SUPPORTED" : "REQUIRES_MAPPING", configuration: { crons, timezone: "UTC" }, }); compatibilityEntry( entries, - "UNSUPPORTED", + "MANAGED", `${prefix}triggers.crons`, - `Native scheduled() delivery requires a verified WfP trigger mapping; HTTP schedules are not equivalent. Requested UTC schedules: ${crons.join(", ")}`, + `Platform scheduler invokes scheduled() through a metered event adapter (not native WfP Cron registration). Requested UTC schedules: ${crons.join(", ")}`, { environment }, ); } @@ -1105,7 +1111,7 @@ export function importWranglerProject( phase: "BEFORE_CODE", kind: "D1_MIGRATIONS", environment, - status: "REQUIRES_MAPPING", + status: "SUPPORTED", ...(typeof database.binding === "string" ? { bindingName: database.binding } : {}), @@ -1118,9 +1124,9 @@ export function importWranglerProject( }); compatibilityEntry( entries, - "UNSUPPORTED", + "MANAGED", `${prefix}d1_databases[${index}].migrations`, - `D1 SQL migrations require a separate target-database execution plan before code deployment; directory=${String(database.migrations_dir ?? "migrations")}, table=${String(database.migrations_table ?? "d1_migrations")}. Code rollback does not roll back SQL.`, + `D1 SQL migrations execute remotely against the owned binding before code deployment; directory=${String(database.migrations_dir ?? "migrations")}, table=${String(database.migrations_table ?? "d1_migrations")}. Code rollback does not roll back SQL.`, { environment, ...(typeof database.binding === "string" @@ -1362,3 +1368,17 @@ export function readWranglerPublicVars( const { config } = parseWrangler(path); return selectedConfig(config, environment).config.vars; } + +export function queueBinding(config: UnknownRecord, queue: string): string { + const producer = array(record(config.queues)?.producers).find(item => item.queue === queue); + return typeof producer?.binding === 'string' ? producer.binding : + 'XAPI_QUEUE_' + createHash('sha256').update(queue).digest('hex').slice(0, 16).toUpperCase(); +} + +export function readWranglerEventConfig(project: LoadedWorkerProject, environment: 'preview' | 'production') { + const path = resolveWorkerProjectPath(project, project.config.wrangler, 'wrangler'); + const { config } = parseWrangler(path); + const selected = selectedConfig(config, environment).config; + return { config: selected, directory: dirname(path), consumers: array(record(selected.queues)?.consumers), + crons: record(selected.triggers)?.crons, databases: array(selected.d1_databases) }; +}