From 74148a48351772a54457fca143d0eaa87f73db7b Mon Sep 17 00:00:00 2001 From: daxiongya Date: Mon, 21 Sep 2026 09:28:35 +0800 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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", },