diff --git a/.claude/skills/wdl-deploy/SKILL.md b/.claude/skills/wdl-deploy/SKILL.md index 4117f2b..ce222bd 100644 --- a/.claude/skills/wdl-deploy/SKILL.md +++ b/.claude/skills/wdl-deploy/SKILL.md @@ -46,27 +46,39 @@ authoritative, and agent-facing references use the English set. New Wrangler configs should use `compatibility_date = "2026-06-17"` unless a project feature requires a newer target or the operator gives a different -target. WDL follows Wrangler config priority (`wrangler.json`, then -`wrangler.jsonc`, then `wrangler.toml`); both JSON filenames use Wrangler's -JSONC syntax, including comments and trailing commas. The control plane is -canonical for unsupported runtime shapes such as unsupported workerd -experimental compatibility flags and WDL-reserved injected module names; the -CLI still fails fast for cheap local cases such as Python Workers modules, -unmapped top-level or selected-env Wrangler runtime/deploy keys (`[site]`, -`workers_dev`, `pages_build_output_dir`, `observability`, `limits`, +target. Control rejects explicit dates before `2026-04-01`, invalid or future +dates, dates newer than the bundled workerd supports, upstream experimental +enable flags, `legacy_error_serialization`, and +`allow_irrevocable_stub_storage`. WDL follows Wrangler config priority +(`wrangler.json`, then `wrangler.jsonc`, then `wrangler.toml`). Both JSON +filenames use Wrangler's JSONC syntax, including comments and trailing commas. +The control plane is canonical for unsupported runtime shapes such as +unsupported workerd compatibility flags and WDL-reserved injected module +names. The CLI still fails fast for cheap local cases such as Python Workers +modules, unmapped top-level or selected-env Wrangler runtime/deploy keys +(`[site]`, `workers_dev`, `pages_build_output_dir`, `observability`, `limits`, `placement`, etc.), and ambiguous runtime `env` name collisions between `[vars]`, explicit bindings, and the implicit `ASSETS` binding. WDL-only `[[exports]]`, `[[platform_bindings]]`, `[[triggers.schedules]]`, and `[[services]].ns` are parsed by the CLI and removed from Wrangler's temporary bundle config; other fields retain their existing Wrangler passthrough behavior. Wrangler's object-shaped declarative `exports` config is unsupported. +The dry-run child hides Wrangler's banner (and its normal update check) and +disables anonymous telemetry. Wrangler may still consult the configured npm +registry when reporting an unknown configuration field; project build hooks +retain their normal network access. +For `[[services]]` and `[[exports]]`, read `docs/deploy.md`: tenant JSRPC may +delegate service or Durable Object class stubs as opaque capabilities, but the +receiver cannot rewrite their host-authored caller properties. Keep delegated +stubs in memory; long-term irrevocable stub storage is unsupported. Never recommend setting `CONTROL_CONNECT_HOST` outside local development: it overrides the TCP target the admin token connects to (Host header + TLS SNI still track `CONTROL_URL`), and a stale value in a CI or production shell could route the token to an unintended host. A URL-form override uses its scheme only to choose the default TCP port; transport still follows `CONTROL_URL`. GUIDE -covers the details. +covers the details. Local deploy output also derives the public Worker scheme +and port from `CONTROL_URL`, never `CONTROL_CONNECT_HOST`. `wdl deploy` runs the project's Wrangler dry-run and build hooks as the user, so they can read the on-disk token store (`~/.config/wdl/credentials`); only deploy diff --git a/CHANGELOG.md b/CHANGELOG.md index c1421b5..4a54c39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ ## Unreleased +### Changed + +- `wdl workers` now reports workflow-definition state (`unknown` when an older + control omits it), `wdl workflows list` marks retired definitions, and + `wdl delete worker --dry-run` reports worker-secret and workflow-definition + presence without overstating blocked deletion. +- Local deploy output now derives the Worker URL scheme and port from + `CONTROL_URL` instead of assuming `http://...:8080`. +- Platform capability docs now cover the current compatibility-date, Durable + Object RPC, Workflows, R2 metadata, secret-key, D1 error, and JSRPC contracts. +- The packaged deploy toolchain now pins Wrangler 4.112.0 and uses + jsonc-parser 3.3.1 and smol-toml 1.7.0. Unmapped Wrangler `addresses` and + `dependencies_instrumentation` fields now fail loudly instead of being + silently dropped. +- `wdl deploy` hides Wrangler's banner (and its normal update check) and + disables anonymous telemetry for its dry-run subprocess. Wrangler can still + consult the configured npm registry when explaining unknown config fields; + project build hooks retain their normal network access. + +### Fixed + +- `wdl deploy` now gives an actionable hint when control returns + `compatibility_flag_unsupported`. + ## 1.4.1 ### Fixed diff --git a/GUIDE-zh.md b/GUIDE-zh.md index b75bb58..98eef5d 100644 --- a/GUIDE-zh.md +++ b/GUIDE-zh.md @@ -93,7 +93,7 @@ CLI 只会从 `.env` 读取 WDL 平台变量:`ADMIN_TOKEN`、`CONTROL_URL`、` `wdl deploy` 在上传前会以你的 OS 用户身份运行项目本地的 Wrangler dry-run 和 build 钩子,这些代码能读到磁盘上的 store(env scrub 只把 WDL 变量挡在 Wrangler 子进程的环境外,挡不住文件),所以只部署你信任的项目。`--no-token-store`(或 `WDL_TOKEN_STORE=off`)让 CLI 只从 flag / shell / `.env` 解析凭据、完全不读 store —— 这是给不太信任的项目或 CI 用的解析 opt-out,不是对文件本身的保护。 -用 `wdl config explain` 查看最终 namespace、control URL、脱敏 token 以及每个值的来源。用 `wdl whoami` 调 control-plane `/whoami`,查看当前 authenticated principal、token id、platform version、最低支持 CLI version 和 URL hints。用 `wdl doctor` 做本地可用性检查,包括 Node.js、wdl-cli、Wrangler、配置文件是否存在、凭据是否能解析,以及 `/whoami` 是否可达;在 CI 里可加 `--strict`,命令仍会打印检查结果,但只要任一检查失败就以非零退出。当 control plane 暴露 `/whoami` 时,`doctor` 可以发现 token 是否有效、principal namespace、platform version 和 CLI compatibility;更细的 capability 检查仍需要额外的 control endpoint。 +用 `wdl config explain` 查看最终 namespace、control URL、脱敏 token 以及每个值的来源。用 `wdl whoami` 调 control-plane `/whoami`,查看当前 authenticated principal、token id、platform version、最低支持 CLI version 和 URL hints。用 `wdl doctor` 做本地可用性检查,包括 Node.js、wdl-cli、Wrangler、配置文件是否存在、凭据是否能解析,以及 `/whoami` 是否可达;在 CI 里可加 `--strict`,命令仍会打印检查结果,但只要任一检查失败就以非零退出。当 control plane 暴露 `/whoami` 时,`doctor` 可以发现 token 是否有效、principal namespace、platform version 和 CLI compatibility;更细的 capability 检查仍需要额外的 control endpoint。运维方没有配置公开 platform domain 时,namespace URL 可能显示为 `(unavailable)`;认证和其它 `/whoami` 字段仍然有效。 ## 脚手架新 Worker @@ -146,7 +146,9 @@ APP_NAME = "hello" 新项目建议使用 `compatibility_date = "2026-06-17"`;除非需要的功能或管理方明确要求更新的目标日期。 -你可以继续使用 `wrangler dev` 做本地开发;部署到本平台时改用 `wdl deploy`。平台部署命令会调用 `wrangler deploy --dry-run`(Wrangler v4)打包项目,解析顺序是 `WDL_WRANGLER_BIN`、Worker 项目本地 wrangler、CLI 包本地 wrangler、最后是 `PATH`。TypeScript、模块解析、esbuild 打包等流程仍走 wrangler 的标准路径。 +Control 会拒绝早于 `2026-04-01` 的显式日期、无效或未来日期,以及超出 bundled workerd 支持范围的日期。上游 experimental enable flags、`legacy_error_serialization` 和 `allow_irrevocable_stub_storage` 不受支持。这些校验由 control canonical 持有,CLI 不复制 workerd flag table。 + +你可以继续使用 `wrangler dev` 做本地开发;部署到本平台时改用 `wdl deploy`。平台部署命令会调用 `wrangler deploy --dry-run`(Wrangler v4)打包项目,解析顺序是 `WDL_WRANGLER_BIN`、Worker 项目本地 wrangler、CLI 包本地 wrangler、最后是 `PATH`。TypeScript、模块解析、esbuild 打包等流程仍走 wrangler 的标准路径。WDL 会隐藏这个 dry-run 子进程的 Wrangler banner(因此跳过常规 banner 更新检查)并关闭匿名遥测。Wrangler 在报告未知配置字段时仍可能访问已配置的 npm registry;项目 build hook 仍保留正常的网络访问能力。 如果同时存在多个 Wrangler 配置文件,WDL 跟随 Wrangler 的优先级:`wrangler.json`,然后 `wrangler.jsonc`,最后 `wrangler.toml`。 @@ -220,6 +222,8 @@ cron / queue delivery 会在 `wdl tail` 里显示 start/finish 事件,包含 w 不要把用户访问 Worker 的流量打到 control URL。control URL 只用于部署和管理命令。 +对于本地开发 control host,deploy 摘要会复用 `CONTROL_URL` 的 scheme 和对外端口生成 Worker URL。`CONTROL_CONNECT_HOST` 仍只覆盖 control socket 的连接目标,不会改变输出的 Worker origin。 + 自定义域名和 Wrangler `routes` 目前还不是 tenant self-service 的 GA 能力。除非管理方明确为你的 namespace 开通自定义 host,否则使用默认 Worker URL: ```text @@ -230,7 +234,7 @@ https://.// ## 支持的 wrangler 配置 -Wrangler 能打包、但 WDL 不能运行的形状由 control plane 作为 canonical validator 拒绝,包括不支持的 workerd experimental compatibility flags 和 WDL 保留注入模块名。CLI 仍会对低成本的本地问题 fail-fast,例如 Python Workers modules,以及 `[vars]`、显式 bindings、隐式 `ASSETS` binding 之间的 runtime `env` 名称冲突。Deploy 和 secret mutation 还会校验留有 headroom 的 workerd 1 MiB `workerLoader` env budget;过大的 `[vars]`、secrets、binding metadata 或 retained versions 可能触发 `worker_env_too_large`。 +Wrangler 能打包、但 WDL 不能运行的形状由 control plane 作为 canonical validator 拒绝,包括不支持的 workerd compatibility flags 和 WDL 保留注入模块名。CLI 仍会对低成本的本地问题 fail-fast,例如 Python Workers modules,以及 `[vars]`、显式 bindings、隐式 `ASSETS` binding 之间的 runtime `env` 名称冲突。Deploy 和 secret mutation 还会校验留有 headroom 的 workerd 1 MiB `workerLoader` env budget;过大的 `[vars]`、secrets、binding metadata 或 retained versions 可能触发 `worker_env_too_large`。 | 配置 | 支持情况 | | --- | --- | @@ -336,6 +340,8 @@ WDL 不会创建真实的 per-namespace S3 bucket。`bucket_name` 是部署时 当前支持 `head`、`get`、`put`、`delete`、`list` 等常用路径。需要时也可以使用条件读取、range GET 和 `list({ include })` metadata hydration;metadata hydration 会在并发上限内额外发起 HEAD。`put(stream, ...)` 目前会先 buffer,再做单次 S3 PUT,最大 25 MiB。multipart upload、SSE-C、checksum selection 暂不支持。 +当 `httpMetadata` 使用 `Headers` object 传入时,`Expires` header 必须是 canonical IMF-fixdate,例如 `Wed, 21 Oct 2015 07:28:00 GMT`。 + 用 `wdl r2` 命令查看或显式删除 namespace 内的 R2 数据: ```bash @@ -405,6 +411,8 @@ D1 运行时请求在执行前有边界:binary query body 最大 8 MiB;解 `wdl d1 migrations status/apply` 走 control-plane JSON request parser,所以请求体上限是 1 MiB。特别大的 migration 集合或 SQL 文件应拆成更小批次再 apply。 +Control-plane D1 lifecycle API 会把 D1 runtime/backend 转发来的 5xx failure 收敛为 `Internal error`,只保留有界的 machine classifier:`upstreamCode`、`upstreamCategory`、`upstreamRetryable` 和 `upstreamStatus`。Control 自己生成的 contention / collision 503 response 仍保留可操作消息;对外 4xx SQL / migration failure 也可保留可操作诊断。 + `examples/d1-demo` 提供了一个最小 visitor counter 示例,包含 D1 binding 和 forward-only migration。 ### Durable Objects @@ -447,6 +455,8 @@ export default { 当前支持 `stub.fetch()`、JSON-structured `stub.method(...args)` RPC、native `ctx.storage`、同步 `ctx.storage.sql`、alarm、普通 WebSocket upgrade 以及 native WebSocket hibernation API surface。跨 script binding、rename/delete migration、平台级 WebSocket session/cursor 恢复暂未实现。 +DO fetch 请求体上限是 1 MiB。RPC method name 必须符合 JavaScript identifier grammar,且最多 256 ASCII bytes。RPC arguments 最多 1 MiB,只接受 structural JSON:finite value、dense array 和 plain object;序列化不会调用 `toJSON()`。Object name 和 id 必须是 well-formed Unicode;DO class name 使用 ASCII JavaScript class-name grammar,最多 468 bytes。 + 使用 `ctx.storage.sql` 时,不要使用以 `_cf_` 开头的应用表名;workerd 对这个前缀做大小写不敏感保留。`ctx.storage.deleteAll()` 也会保留平台自有的 `_cf_*` 表。 `examples/durable-objects-demo` 提供了最小的同 Worker Durable Object 计数器,使用 SQLite-backed storage 保存状态。 @@ -474,11 +484,15 @@ wdl workflows restart api orders order-123 --yes wdl workflows terminate api orders order-123 --yes ``` +`wdl workflows list` 会把 active Worker version 不再导出的定义标为 `retired=yes`。既有实例仍可查看和 terminate,但 restart 会返回 `workflow_not_exported`;需要先部署一个重新导出该 workflow name 的 active version。 + 这是 WDL Workflows 支持,不是完整 Cloudflare Workflows parity。 `script_name`、跨 worker workflow、跨 worker callback、service-binding callback 和 Cloudflare source-AST visualizer 不支持。same-worker DO progress callback 和 runtime-observed parallel/DAG `step.do` execution 可用。 重要运行限制和编程规则: +- `createBatch()` 每次最多接受 100 个 entry。单个 workflow result 上限是 1 MiB;一次 runtime 到 Workflows backend 的 JSON 请求上限是 2 MiB。 - 每个 instance 的聚合 payload 上限是 16 MiB。超过上限的 step/event 写入会让请求失败;runtime 写入过大的 terminal result 时,会在同一个事务里把 instance 转为 failed。 +- 新创建的 completed、failed 和 terminated instance 默认保留 8 小时;需要时可通过 `create()` 的 `retention` option 覆盖 success/error retention。 - 一个 `step.do` 如果永久失败,本次 run 会进入 terminal failure,即使用户代码 catch 了这次抛错也一样。 - 单个 step 最多记录 1000 条 dependency edge。单次 dispatch turn 最多有 1000 个 in-flight workflow steps,并最多启动 1000 个 fresh backend steps。 - 用户代码必须 await 所有已启动的 `step.do`。run 在仍有 started step 未 settle 时返回会失败为 `workflow_invalid_step`。 @@ -514,7 +528,7 @@ printf '%s' "$DATABASE_URL" | wdl secret put --scope ns DATABASE_URL - `secret_encryption_unconfigured`、`secret_decrypt_failed`、`invalid_envelope`、`unsupported_envelope`、`unknown_kid` 或 `secret_not_encrypted` 这类 secret-envelope 错误表示 mutation 没有写入;等运维侧修复 envelope 配置或已存储数据后再重试。 - worker-level secret 可以在第一次部署前设置;第一次部署会读取这些 secret。 - namespace-level secret 会共享给 namespace 下的所有 Worker,但不会批量 bump 所有 Worker。它会在下一次自然 cold-load 时生效,例如新部署、runtime recycle 或 isolate eviction。 -- secret key 必须符合环境变量命名规则,例如 `STRIPE_KEY`;value 最大 64 KiB,并且和 `[vars]` 一样计入 workerLoader env budget。 +- secret key 必须符合 `[A-Za-z_][A-Za-z0-9_]*`,最多 128 个字符;runtime-reserved name 和保留的 `Object.prototype` key 会被拒绝。Value 最大 64 KiB,并且和 `[vars]` 一样计入 workerLoader env budget。 ### Queues @@ -682,6 +696,8 @@ Service binding 在部署时解析并绑定到目标当时的线上版本。目 `export default function(request, env, ctx)` 会被当作 fetch handler 简写,并通过 `.fetch(...)` 暴露;它不是可直接调用的 default RPC method。RPC method 应放在命名 `WorkerEntrypoint` 上,或放在 default object / class 的方法上。 +当前 tenant JSRPC 可以序列化 `Blob` value,并把 service 或 Durable Object class stub 作为 opaque capability argument 传递。接收方可以调用被委托的目标,但不能改写 stub 携带的 host-authored caller properties。Delegated stub 只应留在内存中;WDL 不支持长期 irrevocable stub storage。 + ### Platform bindings 如果平台提供了第一方共享能力,你只需要绑定平台给出的符号名: @@ -798,7 +814,7 @@ wdl delete worker hello --dry-run wdl delete worker hello ``` -`wdl delete worker` 默认会要求确认。建议先用 `--dry-run` 预览受影响的线上版本、保留版本、路由、worker secrets、queue consumers 和资产清理。自动化脚本里只有在已有独立安全检查后,才建议传 `--yes`。 +`wdl delete worker` 默认会要求确认。建议先用 `--dry-run` 预览受影响的线上版本、保留版本、路由、worker secrets、workflow definitions、queue consumers 和资产清理。即使没有 deployed version,`wdl workers` 也会用 `workflow-defs=yes` 显示仍有 workflow definitions 的 entry;旧 control 未上报该字段时,CLI 显示 `workflow-defs=unknown`,这不表示没有 workflow definitions。自动化脚本里只有在已有独立安全检查后,才建议传 `--yes`。 确认后删除 D1 数据库: diff --git a/GUIDE.md b/GUIDE.md index c09fb7f..f5ada40 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -158,6 +158,8 @@ gate; the command still prints the checks, then exits non-zero if any check fails. `doctor` can detect token validity, principal namespace, platform version, and CLI compatibility when the control plane exposes `/whoami`; deeper capability checks still require additional control endpoints. +The namespace URL can be `(unavailable)` when the operator has not configured a +public platform domain; authentication and other `/whoami` fields still work. ## Scaffolding a New Worker @@ -221,6 +223,11 @@ APP_NAME = "hello" For new projects, use `compatibility_date = "2026-06-17"` unless a required feature or your operator gives you a newer target. +Control rejects explicit dates before `2026-04-01`, invalid or future dates, +and dates newer than the bundled workerd supports. Upstream experimental enable +flags, `legacy_error_serialization`, and `allow_irrevocable_stub_storage` are +unsupported. These checks are control-owned; the CLI does not mirror workerd's +flag table. You can keep using `wrangler dev` for local development. To deploy to this platform, use `wdl deploy` instead. The deploy command runs @@ -229,6 +236,11 @@ platform, use `wdl deploy` instead. The deploy command runs wrangler, then `PATH`. TypeScript, module resolution, esbuild bundling, and related build behavior still follow Wrangler. +WDL hides Wrangler's banner (which skips the normal banner update check) and +disables anonymous telemetry for this dry-run subprocess. Wrangler may still +consult the configured npm registry when reporting an unknown configuration +field. Project build hooks retain their normal network access. + When several Wrangler config files exist, WDL follows Wrangler's priority: `wrangler.json`, then `wrangler.jsonc`, then `wrangler.toml`. Both JSON filenames use Wrangler's JSONC syntax, including comments and @@ -316,6 +328,10 @@ process is not consuming output fast enough. Do not send end-user Worker traffic to the control URL. The control URL is only for deployment and management commands. +For local-development control hosts, deploy summaries reuse `CONTROL_URL`'s +scheme and public port for the Worker URL. `CONTROL_CONNECT_HOST` remains only a +control socket override and does not change the printed Worker origin. + Custom domains and Wrangler `routes` are not generally available for tenant self-service yet. Use the default Worker URL unless your operator explicitly enables a custom host for your namespace: @@ -334,13 +350,13 @@ for you. ## Supported Wrangler Configuration The control plane is the canonical validator for shapes that Wrangler can -bundle but WDL cannot run, including unsupported workerd experimental -compatibility flags and WDL-reserved injected module names. The CLI still -fails fast for cheap local cases such as Python Worker modules and ambiguous -runtime `env` name collisions between `[vars]`, explicit bindings, and the -implicit `ASSETS` binding. Deploy and secret mutation also enforce the -headroomed 1 MiB workerd `workerLoader` env budget; large `[vars]`, secrets, -binding metadata, or retained versions can fail with `worker_env_too_large`. +bundle but WDL cannot run, including unsupported workerd compatibility flags +and WDL-reserved injected module names. The CLI still fails fast for cheap local +cases such as Python Worker modules and ambiguous runtime `env` name collisions +between `[vars]`, explicit bindings, and the implicit `ASSETS` binding. Deploy +and secret mutation also enforce the headroomed 1 MiB workerd `workerLoader` env +budget; large `[vars]`, secrets, binding metadata, or retained versions can fail +with `worker_env_too_large`. | Configuration | Support | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -501,6 +517,9 @@ also supported for Workers that need them; metadata hydration issues extra HEAD requests under a concurrency cap. `put(stream, ...)` currently buffers before sending a single S3 PUT and has a 25 MiB maximum. Multipart upload, SSE-C, and checksum selection are not supported. +When `httpMetadata` is supplied as a `Headers` object, an `Expires` header must +use canonical IMF-fixdate syntax, such as +`Wed, 21 Oct 2015 07:28:00 GMT`. Use `wdl r2` commands to inspect or explicitly delete namespace R2 data: @@ -600,6 +619,13 @@ call are rolled back. its request body is capped at 1 MiB. Split very large migration sets or SQL files into smaller batches before applying. +Control-plane D1 lifecycle APIs redact 5xx failures forwarded from the D1 +runtime/backend to `Internal error` and retain only bounded machine classifiers +(`upstreamCode`, `upstreamCategory`, `upstreamRetryable`, and +`upstreamStatus`). Control-generated contention and collision 503 responses +retain actionable messages. Outward 4xx SQL and migration failures can retain +actionable diagnostics. + See `examples/d1-demo` for a minimal visitor counter using D1 plus a forward-only migration. @@ -649,6 +675,13 @@ Supported DO surface includes `stub.fetch()`, JSON-structured hibernation API surface. Cross-script bindings, renamed/deleted migrations, and platform-level WebSocket session/cursor recovery are not currently available. +DO fetch request bodies are capped at 1 MiB. RPC method names must use +JavaScript identifier grammar and are capped at 256 ASCII bytes. RPC arguments +are capped at 1 MiB and must be structural JSON: finite values, dense arrays, +and plain objects only; serialization does not call `toJSON()`. Object names and +ids must be well-formed Unicode, and DO class names use ASCII JavaScript +class-name grammar with a 468-byte cap. + For `ctx.storage.sql`, avoid application table names starting with `_cf_`; workerd reserves that prefix case-insensitively. `ctx.storage.deleteAll()` also leaves platform-owned `_cf_*` tables alone. @@ -679,6 +712,11 @@ wdl workflows restart api orders order-123 --yes wdl workflows terminate api orders order-123 --yes ``` +`wdl workflows list` marks definitions absent from the active Worker version as +`retired=yes`. Existing instances remain inspectable and may be terminated, but +restart returns `workflow_not_exported` until an active version exports that +workflow name again. + This is WDL Workflows support, not full Cloudflare Workflows parity. `script_name`, cross-worker workflows, cross-worker callbacks, service-binding callbacks, and Cloudflare source-AST visualizer are not supported. Same-worker @@ -687,9 +725,15 @@ available. Important runtime limits and programming rules: +- `createBatch()` accepts at most 100 entries per call. A single workflow result + is capped at 1 MiB. One runtime-to-Workflows backend JSON request is capped at + 2 MiB. - Per-instance aggregate payload is capped at 16 MiB. Step/event writes over the cap fail the request; an over-cap runtime terminal result transitions the instance to failed in the same transaction. +- Newly created completed, failed, and terminated instances are retained for 8 + hours by default. Override success/error retention through the `retention` + option passed to `create()` when needed. - A permanently failed `step.do` makes the run terminal even if user code catches the thrown error. - One step may record at most 1000 dependency edges. One dispatch turn may have @@ -749,9 +793,10 @@ Effect timing: - Namespace-level secret changes are shared by every Worker in the namespace, but they do not bump all Workers. They take effect on the next natural cold-load, such as a new deploy, runtime recycle, or isolate eviction. -- Secret keys must use environment-variable grammar, for example `STRIPE_KEY`; - values are limited to 64 KiB and count toward the same workerLoader env budget - as `[vars]`. +- Secret keys must match `[A-Za-z_][A-Za-z0-9_]*` and are capped at 128 + characters; runtime-reserved names and reserved `Object.prototype` keys are + rejected. Values are limited to 64 KiB and count toward the same workerLoader + env budget as `[vars]`. ### Queues @@ -955,6 +1000,12 @@ shorthand and is exposed through `.fetch(...)`; it is not a callable default RPC method. Put RPC methods on a named `WorkerEntrypoint` or on default object/class methods. +Current tenant JSRPC can serialize `Blob` values and pass service or Durable +Object class stubs as opaque capability arguments. The receiver may call the +delegated target but cannot rewrite the host-authored caller properties carried +by the stub. Keep delegated stubs in memory; long-term irrevocable stub storage +is not a supported WDL surface. + ### Platform Bindings If the platform provides a first-party shared capability, bind the @@ -1096,8 +1147,11 @@ wdl delete worker hello `wdl delete worker` asks for confirmation by default. Use `--dry-run` first to preview the affected active version, retained versions, routes, worker secrets, -queue consumers, and asset cleanup. In automation, pass `--yes` only after a -separate safety check. +workflow definitions, queue consumers, and asset cleanup. `wdl workers` reports +`workflow-defs=yes` even for entries that have no deployed version. When an +older control does not report this field, the CLI displays +`workflow-defs=unknown`; that does not mean no definitions exist. In automation, +pass `--yes` only after a separate safety check. Delete a D1 database after confirming: diff --git a/commands/delete.js b/commands/delete.js index 7fa263c..fa59cd1 100644 --- a/commands/delete.js +++ b/commands/delete.js @@ -96,7 +96,7 @@ function usageText() { description: "Delete retained versions or all worker-owned lifecycle state.", commands: [ "version Delete one retained non-active worker version.", - "worker Delete a worker, its versions, secrets, routes, and queue consumers.", + "worker Delete a worker, its versions, secrets, workflow definitions, routes, and queue consumers.", ], options: optionHelp(DELETE_OPTIONS), }); diff --git a/commands/deploy.js b/commands/deploy.js index f37a1b4..103028d 100644 --- a/commands/deploy.js +++ b/commands/deploy.js @@ -208,6 +208,9 @@ function deployErrorHint(text) { if (error === "experimental_compat_flag_unsupported") { return "; remove the unsupported workerd experimental compatibility flag."; } + if (error === "compatibility_flag_unsupported") { + return "; remove the unsupported compatibility flag."; + } if (isSecretEnvelopeErrorCode(error)) { return "; secret-envelope configuration or stored secret data needs operator repair before retrying."; } @@ -301,8 +304,14 @@ async function runDeploy({ values, positionals, context: baseContext }) { const controlHost = new URL(controlUrl).hostname; const isLocal = isLocalDevHost(controlHost); if (isLocal) { - const host = `${ns}.${platformDomain || "workers.local"}`; - writeStatusLine(stdout, ` http://${host}:8080/${workerName}/`); + const workerUrl = new URL(controlUrl); + workerUrl.username = ""; + workerUrl.password = ""; + workerUrl.hostname = `${ns}.${platformDomain || "workers.local"}`; + workerUrl.pathname = `/${workerName}/`; + workerUrl.search = ""; + workerUrl.hash = ""; + writeStatusLine(stdout, ` ${workerUrl.href}`); } else if (platformDomain) { writeStatusLine(stdout, ` https://${ns}.${platformDomain}/${workerName}/`); } diff --git a/commands/workers.js b/commands/workers.js index 63e9646..acb4d3a 100644 --- a/commands/workers.js +++ b/commands/workers.js @@ -42,7 +42,7 @@ async function printWorkersList(context, json) { function usageText() { return formatHelp({ usage: ["wdl workers [options]"], - description: "List workers, active versions, retained versions, and secret-only entries.", + description: "List active and retained versions, secret-only entries, and workflow-definition-only entries.", options: optionHelp(WORKERS_OPTIONS), }); } diff --git a/commands/workflows.js b/commands/workflows.js index 90ded62..1257ea0 100644 --- a/commands/workflows.js +++ b/commands/workflows.js @@ -1,7 +1,12 @@ import { defineCommand } from "../lib/command.js"; import { CliError, defineCliOption, formatHelp, isMain, optionHelp, unexpectedArgument } from "../lib/common.js"; import { confirmAction } from "../lib/stdin.js"; -import { escapeTerminalText, writeResult } from "../lib/output.js"; +import { + escapeTerminalText, + writeJsonOr, + writeResult, + writeStatusLine, +} from "../lib/output.js"; import { formatInstanceList, formatInstanceStatus, @@ -97,9 +102,11 @@ async function runWorkflows({ values, positionals, context }) { { method: "POST", headers }, `${subcommand} workflow instance`, )); - writeResult(Boolean(values.json), body, () => [ - `OK ${ns}/${worker}/${workflow}/${body.id || instanceId} ${subcommand} status=${body.status || "-"}`, - ], stdout); + if (writeJsonOr(Boolean(values.json), body, stdout)) return; + writeStatusLine( + stdout, + `OK ${ns}/${worker}/${workflow}/${body.id || instanceId} ${subcommand} status=${body.status || "-"}` + ); return; } diff --git a/docs/README-zh.md b/docs/README-zh.md index b93b94c..7d162c7 100644 --- a/docs/README-zh.md +++ b/docs/README-zh.md @@ -24,6 +24,7 @@ | WDL 环境覆盖 `[env.]` 规则 | [env-overrides-zh.md](./env-overrides-zh.md) | | Worker / namespace 运行时 secrets | [secrets-zh.md](./secrets-zh.md) | | 本地存储控制面 token | [token-zh.md](./token-zh.md) | +| Worker-to-Worker service binding 与 JSRPC delegation | [deploy-zh.md](./deploy-zh.md) | 组合功能时读多个专题。例如:queue 消费后写状态,读 [queues-zh.md](./queues-zh.md) 和 [kv-zh.md](./kv-zh.md);上传文件后记录索引,读 [r2-zh.md](./r2-zh.md) 和 [d1-zh.md](./d1-zh.md);带静态页面的管理工具,读 [assets-zh.md](./assets-zh.md) 和实际绑定对应的专题。 diff --git a/docs/README.md b/docs/README.md index 6ea6471..edd4578 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,6 +33,7 @@ English set. | WDL `[env.]` override rules | [env-overrides.md](./env-overrides.md) | | Worker / namespace runtime secrets | [secrets.md](./secrets.md) | | Storing control-plane tokens locally | [token.md](./token.md) | +| Worker-to-Worker service bindings and JSRPC delegation | [deploy.md](./deploy.md) | Combining features means reading several topics. For example: writing state after consuming a queue, read [queues.md](./queues.md) and [kv.md](./kv.md); diff --git a/docs/d1-zh.md b/docs/d1-zh.md index 8a3212c..c108d7d 100644 --- a/docs/d1-zh.md +++ b/docs/d1-zh.md @@ -113,6 +113,8 @@ D1 请求在执行前会被限流:binary query body 最大 8 MiB;解码后 D1 migration 管理走 control-plane JSON request parser,所以 `wdl d1 migrations status/apply` 请求体上限是 1 MiB。特别大的 migration 集合或 SQL 文件应拆成更小批次再 apply。`d1 execute --file` 的路径必须存在、可读,并且留在项目根目录内;文件缺失或不可读时 CLI 会在本地拒绝,不会联系 control。 +Control-plane D1 lifecycle API 会把 D1 runtime/backend 转发来的 5xx failure 收敛为 `Internal error`,只保留有界的 machine classifier:`upstreamCode`、`upstreamCategory`、`upstreamRetryable` 和 `upstreamStatus`。Control 自己生成的 contention / collision 503 response 仍保留可操作消息;对外 4xx SQL / migration failure 也可保留可操作的 D1 诊断。 + 以 `_cf_` 开头的 SQLite object name 是 workerd 保留名,大小写不敏感。不要创建或 `RENAME TO` 到 `_cf_*` 形式的应用 table、index、trigger 或 view;包含这类 DDL 的 migration 在新数据库上可能失败。已经 apply 的 migration 文件不要回改;需要修正时新增 forward migration,把应用数据迁到非保留名称。 ## 删除数据库 diff --git a/docs/d1.md b/docs/d1.md index 2dd0b24..c37bcbf 100644 --- a/docs/d1.md +++ b/docs/d1.md @@ -138,6 +138,13 @@ large migration sets or large SQL files into smaller batches before applying. project root; the CLI rejects missing or unreadable files before contacting control. +Control-plane D1 lifecycle APIs redact 5xx failures forwarded from the D1 +runtime/backend to `Internal error` and expose only bounded machine classifiers +(`upstreamCode`, `upstreamCategory`, `upstreamRetryable`, and +`upstreamStatus`). Control-generated contention and collision 503 responses +retain actionable messages. Outward 4xx SQL and migration failures can retain +actionable D1 diagnostics. + SQLite object names beginning with `_cf_` are reserved by workerd, case-insensitively. Do not create or rename application tables, indexes, triggers, or views to `_cf_*`; a migration containing that DDL can fail on a new diff --git a/docs/deploy-zh.md b/docs/deploy-zh.md index 4f6e259..7575e2b 100644 --- a/docs/deploy-zh.md +++ b/docs/deploy-zh.md @@ -6,6 +6,8 @@ wrangler 解析顺序是 `WDL_WRANGLER_BIN`、Worker 项目本地 wrangler、CLI 包本地 wrangler、最后是 `PATH`。默认不会临时 `npx --yes wrangler` 拉包;只有设置 `WDL_ALLOW_NPX_WRANGLER=1` 时才允许这个 fallback。 +WDL 会隐藏这个 dry-run 子进程的 Wrangler banner(因此跳过常规 banner 更新检查)并关闭匿名遥测。Wrangler 在报告未知配置字段时仍可能访问已配置的 npm registry;项目 build hook 仍保留正常的网络访问能力。 + ## CLI 调用形式 按以下顺序选一种: @@ -38,7 +40,7 @@ CLI 需要三个值: **不可信项目:** `wdl deploy` 在上传前会以你的 OS 用户身份运行项目本地的 Wrangler dry-run 和 build 钩子,这些代码能读到磁盘上的 token store(凭证 scrub 只把 WDL 变量挡在 Wrangler 子进程环境外,挡不住文件)。只部署你信任的项目。对不可信 / 第三方项目,用临时的 `--token` / `--control-url` 加 `--no-token-store`(或 `WDL_TOKEN_STORE=off`)让 CLI 不读 store —— 而且根本别留全局 store,因为这个 flag 只是不**读**文件,挡不住文件本身在磁盘上。详见 [token-zh.md](./token-zh.md)。 -不确定最终取了哪个值时,运行 `wdl config explain`;要确认 token 实际连到哪个 control、principal、platform version 和 URL hints,运行 `wdl whoami`;本机与远端基础排查运行 `wdl doctor`。当 control 支持 `/whoami` 时,`doctor` 会验证远端 token、principal namespace、platform version 和 CLI compatibility。CI 里需要失败即挡住后续步骤时,用 `wdl doctor --strict`。 +不确定最终取了哪个值时,运行 `wdl config explain`;要确认 token 实际连到哪个 control、principal、platform version 和 URL hints,运行 `wdl whoami`;本机与远端基础排查运行 `wdl doctor`。当 control 支持 `/whoami` 时,`doctor` 会验证远端 token、principal namespace、platform version 和 CLI compatibility。CI 里需要失败即挡住后续步骤时,用 `wdl doctor --strict`。如果运维方没有配置公开 platform domain,namespace URL 可能显示为 `(unavailable)`;这不代表认证失败。 运行时密钥(与 `ADMIN_TOKEN` 不同)见 [secrets-zh.md](./secrets-zh.md)。 @@ -50,6 +52,8 @@ https://.// Worker 看到的路径是**剥掉 `/` 之后的路径**。除非运维方明确启用,租户没有自定义路由能力;首次配置不要加 `route` / `routes`。 +对于本地开发 control host,deploy 摘要会复用 `CONTROL_URL` 的 scheme 和对外端口生成 Worker URL。`CONTROL_CONNECT_HOST` 只改变 control socket 的连接目标,不会改变输出的 Worker origin。 + ## 核心命令 | 目标 | 命令 | @@ -97,11 +101,17 @@ wdl deploy . --env production `wrangler.json` 和 `wrangler.jsonc` 都按 Wrangler 的 JSONC 语法解析,支持注释和尾随逗号。 +新项目应继续使用 `2026-06-17` compatibility date,除非具体功能需要更新日期。Control 会拒绝早于 `2026-04-01` 的显式日期、无效或未来日期,以及超出 bundled workerd 支持范围的日期。上游 experimental enable flags、`legacy_error_serialization` 和 `allow_irrevocable_stub_storage` 不受支持。 + **支持:** `name`、`main`、`compatibility_date` / `compatibility_flags`、`[vars]`、`[[kv_namespaces]]`、`[[d1_databases]]`、`[[durable_objects.bindings]]`、`[[workflows]]`、`[[r2_buckets]]`、`[assets] directory`、`[triggers] crons`、`[[triggers.schedules]]`(带 timezone,平台扩展)、`[[queues.producers]]` / `[[queues.consumers]]`、`[[services]]`、`[[platform_bindings]]`、`[[exports]]`、`[env.]`。 WDL 会自行解析 `[[exports]]`、`[[platform_bindings]]`、`[[triggers.schedules]]` 和 `[[services]].ns`,并从传给 Wrangler bundler 的临时配置中移除这些私有扩展;其它字段保持既有的 Wrangler 透传行为。WDL 不支持 Wrangler 对象形态的 declarative `exports` 配置。 -**不支持(部署失败):** Analytics Engine。Durable Objects 仅支持同 worker class;`script_name`、rename/delete migration 暂未实现。WDL Workflows 仅支持当前 Worker 内定义的 workflow class,不是完整 Cloudflare Workflows parity;`script_name`、跨 worker workflow、跨 worker callback、service-binding callback 和 Cloudflare source-AST visualizer 暂不支持。`route` / `routes` 仅在运维方启用时支持。Python Workers modules、workerd experimental compatibility flags 和 WDL 保留注入模块名会在部署时被拒绝:CLI 会对本地 `.py` module fail-fast,workerd compatibility 与 bundle-shape policy 由 control plane canonical 判断。WDL 会忽略、且无法映射进 manifest 的顶层或所选 env Wrangler runtime/deploy 配置字段和 section 也会由 CLI 直接拒绝,包括 legacy `[site]` Workers Sites、`workers_dev`、`pages_build_output_dir`、`observability`、`limits`、`placement`,以及错误信息点名的其它 unsupported binding/config field 或 section。`assets.run_worker_first` 会被静默忽略。 +### Service bindings 与 capability delegation + +Tenant JSRPC 可以序列化 `Blob` value,并把 service 或 Durable Object class stub 作为 opaque capability argument 传递。接收方可以调用被委托的目标,但不能改写 stub 携带的 host-authored caller properties。Delegated stub 只应留在内存中;WDL 不支持长期 irrevocable stub storage。 + +**不支持(部署失败):** Analytics Engine。Durable Objects 仅支持同 worker class;`script_name`、rename/delete migration 暂未实现。WDL Workflows 仅支持当前 Worker 内定义的 workflow class,不是完整 Cloudflare Workflows parity;`script_name`、跨 worker workflow、跨 worker callback、service-binding callback 和 Cloudflare source-AST visualizer 暂不支持。`route` / `routes` 仅在运维方启用时支持。Python Workers modules、不支持的 workerd compatibility flags 和 WDL 保留注入模块名会在部署时被拒绝:CLI 会对本地 `.py` module fail-fast,workerd compatibility 与 bundle-shape policy 由 control plane canonical 判断。WDL 会忽略、且无法映射进 manifest 的顶层或所选 env Wrangler runtime/deploy 配置字段和 section 也会由 CLI 直接拒绝,包括 legacy `[site]` Workers Sites、`workers_dev`、`pages_build_output_dir`、`observability`、`limits`、`placement`,以及错误信息点名的其它 unsupported binding/config field 或 section。`assets.run_worker_first` 会被静默忽略。 Cron triggers 和 queue consumers 是 runtime dispatch 能力,只应声明在可路由的 tenant Worker 上。通过 `[[platform_bindings]]` 选择的 Worker 是冷加载的平台能力,不是 public/runtime dispatch 目标,不能声明 cron triggers 或 queue consumers。 @@ -109,6 +119,8 @@ Cron triggers 和 queue consumers 是 runtime dispatch 能力,只应声明在 `wdl delete worker`、`wdl delete version`、`wdl d1 delete`、`wdl secret delete` 默认会提示确认。如果有 `--dry-run`,先跑一遍(或先做只读检查),然后跟用户确认了再加 `--yes`。**不要**主动加 `--yes`。 +`wdl workers` 会显示 `workflow-defs=yes` 或 `workflow-defs=no`;`unknown` 表示旧 control 没有返回该字段,不表示没有 workflow definitions。即使 blocker 使 `wouldDelete=no`,worker delete dry-run 仍会报告 secret 和 workflow-definition 是否存在。 + 删除 worker **不会**删除 R2 数据 —— 见 [r2-zh.md](./r2-zh.md)。 ## 常见错误 @@ -122,6 +134,7 @@ Cron triggers 和 queue consumers 是 runtime dispatch 能力,只应声明在 | `[vars] : only string/number/boolean values are supported` | 移除嵌套值;敏感字符串改用 secret。 | | `binding name collision: ` | `[vars]`、显式 bindings 或隐式 `ASSETS` binding 复用了 runtime env 名称;重命名其中一个。 | | `experimental_compat_flag_unsupported` | 移除 experimental workerd compatibility flag。 | +| `compatibility_flag_unsupported` | 移除 control 点名的不支持 compatibility flag。 | | `python_workers_unsupported` | WDL 不支持 Python Workers;移除 Python Worker modules。CLI 也会对本地 `.py` module fail-fast。 | | `worker_env_too_large` | 减少 `[vars]`、secrets 或 binding metadata;如果错误点名 retained version,redeploy/delete 该版本。 | | `worker_code_too_large` | 减少生成的 Worker code 大小,或拆分 worker。 | diff --git a/docs/deploy.md b/docs/deploy.md index 3208ebe..ee58bf5 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -12,6 +12,11 @@ wrangler, the CLI package's local wrangler, then `PATH`. By default there is no transient `npx --yes wrangler` fetch; that fallback is allowed only when `WDL_ALLOW_NPX_WRANGLER=1` is set. +WDL hides Wrangler's banner (which skips the normal banner update check) and +disables anonymous telemetry for this dry-run subprocess. Wrangler may still +consult the configured npm registry when reporting an unknown configuration +field. Project build hooks retain their normal network access. + ## CLI invocation forms Pick one in this order: @@ -76,6 +81,8 @@ run `wdl whoami`; for baseline local and remote diagnostics, run `wdl doctor`. When the control plane supports `/whoami`, `doctor` verifies the remote token, principal namespace, platform version, and CLI compatibility. Use `wdl doctor --strict` in CI when a failed check should make the job fail. +The namespace URL may be `(unavailable)` when the operator has not configured a +public platform domain; that does not mean authentication failed. For runtime secrets (distinct from `ADMIN_TOKEN`), see [secrets.md](./secrets.md). @@ -90,6 +97,10 @@ The Worker sees the path **with the `/` prefix stripped**. Tenants have no custom routing capability unless the operator explicitly enables it; do not add `route` / `routes` in a first-time setup. +For local-development control hosts, the deploy summary reuses `CONTROL_URL`'s +scheme and public port when constructing the Worker URL. `CONTROL_CONNECT_HOST` +changes only the control socket target and never the printed Worker origin. + ## Core commands | Goal | Command | @@ -162,6 +173,12 @@ When multiple Wrangler config files exist, the CLI follows Wrangler's priority: Both JSON filenames use Wrangler's JSONC syntax, including comments and trailing commas. +New projects should keep the `2026-06-17` compatibility date unless a feature +requires a newer one. Explicit dates before `2026-04-01`, invalid or future +dates, and dates newer than the bundled workerd supports are rejected by +control. Upstream experimental enable flags, `legacy_error_serialization`, and +`allow_irrevocable_stub_storage` are unsupported. + **Supported:** `name`, `main`, `compatibility_date` / `compatibility_flags`, `[vars]`, `[[kv_namespaces]]`, `[[d1_databases]]`, `[[durable_objects.bindings]]`, `[[workflows]]`, `[[r2_buckets]]`, `[assets] directory`, `[triggers] crons`, @@ -175,16 +192,24 @@ extensions from the temporary config passed to the Wrangler bundler. Other fields retain their existing Wrangler passthrough behavior. Wrangler's object-shaped declarative `exports` configuration is not supported by WDL. +### Service bindings and delegated capabilities + +Tenant JSRPC can serialize `Blob` values and pass service or Durable Object +class stubs as opaque capability arguments. A receiver may call the delegated +target but cannot rewrite the host-authored caller properties carried by the +stub. Keep delegated stubs in memory; irrevocable persistence is unsupported. + **Unsupported (deploy fails):** Analytics Engine. Durable Objects supports same-worker classes only; `script_name` and rename/delete migrations are not implemented yet. WDL Workflows supports only workflow classes defined in the current Worker — not full Cloudflare Workflows parity; `script_name`, cross-worker workflows, cross-worker callbacks, service-binding callbacks, and the Cloudflare source-AST visualizer are not supported. `route` / `routes` are -supported only when the operator enables them. Python Workers modules, workerd -experimental compatibility flags, and WDL-reserved injected module names are -rejected during deploy: the CLI fails fast on local `.py` modules, and the -control plane is canonical for workerd compatibility and bundle-shape policy. +supported only when the operator enables them. Python Workers modules, +unsupported workerd compatibility flags, and WDL-reserved injected module +names are rejected during deploy: the CLI fails fast on local `.py` modules, +and the control plane is canonical for workerd compatibility and bundle-shape +policy. Top-level or selected-environment Wrangler runtime/deploy config fields and sections that WDL would otherwise ignore are also rejected by the CLI, including legacy `[site]` Workers Sites, `workers_dev`, `pages_build_output_dir`, @@ -205,6 +230,11 @@ consumers. run it first (or do a read-only check), then add `--yes` only after confirming with the user. Do **not** add `--yes` on your own. +`wdl workers` reports `workflow-defs=yes` or `workflow-defs=no`; `unknown` +means an older control omitted the field, not that no definitions exist. Worker +delete dry-runs report secret and workflow-definition presence even when a +blocker makes `wouldDelete=no`. + Deleting a worker does **not** delete R2 data — see [r2.md](./r2.md). ## Common errors @@ -218,6 +248,7 @@ Deleting a worker does **not** delete R2 data — see [r2.md](./r2.md). | `[vars] : only string/number/boolean values are supported` | Remove nested values; move sensitive strings to a secret. | | `binding name collision: ` | `[vars]`, explicit bindings, or the implicit `ASSETS` binding reused a runtime env name. Rename one of them. | | `experimental_compat_flag_unsupported` | Remove the experimental workerd compatibility flag. | +| `compatibility_flag_unsupported` | Remove the unsupported compatibility flag named by control. | | `python_workers_unsupported` | Python Workers are not supported by WDL; remove Python Worker modules. The CLI also fails fast on local `.py` modules. | | `worker_env_too_large` | Reduce `[vars]`, secrets, or binding metadata; redeploy/delete any retained version named in the error. | | `worker_code_too_large` | Reduce generated Worker code size or split the worker. | diff --git a/docs/durable-objects-zh.md b/docs/durable-objects-zh.md index f5cd641..d8b8b66 100644 --- a/docs/durable-objects-zh.md +++ b/docs/durable-objects-zh.md @@ -44,6 +44,10 @@ export default { 当前支持 `stub.fetch()`、JSON-structured `stub.method(...args)` RPC、native `ctx.storage`、同步 `ctx.storage.sql`、alarm、普通 WebSocket upgrade 以及 native WebSocket hibernation API surface。 +DO fetch 请求体上限是 1 MiB。RPC method name 必须符合 JavaScript identifier grammar,且最多 256 ASCII bytes。RPC arguments 最多 1 MiB,只接受 structural JSON:finite number、string、boolean、null、dense array 和 plain object。序列化不会调用 `toJSON()`;sparse array、循环结构、非 plain object 和其它非 JSON 值会在 dispatch 前被拒绝。 + +Object name 和 id 必须是 well-formed Unicode;lone UTF-16 surrogate 会被拒绝。DO class name 使用 ASCII JavaScript class-name grammar,最多 468 bytes。 + 使用 `ctx.storage.sql` 时,不要使用以 `_cf_` 开头的应用表名;workerd 对这个前缀做大小写不敏感保留。`ctx.storage.deleteAll()` 也会保留平台自有的 `_cf_*` 表。 ## 端到端示例 diff --git a/docs/durable-objects.md b/docs/durable-objects.md index d71f902..a070a66 100644 --- a/docs/durable-objects.md +++ b/docs/durable-objects.md @@ -50,6 +50,17 @@ Currently supported: `stub.fetch()`, JSON-structured `stub.method(...args)` RPC, native `ctx.storage`, synchronous `ctx.storage.sql`, alarms, ordinary WebSocket upgrade, and the native WebSocket hibernation API surface. +DO fetch request bodies are capped at 1 MiB. RPC method names must use +JavaScript identifier grammar and are capped at 256 ASCII bytes. RPC arguments +are capped at 1 MiB and must be structural JSON: finite numbers, strings, +booleans, null, dense arrays, and plain objects. Serialization does not call +`toJSON()`; sparse arrays, circular structures, non-plain objects, and other +non-JSON values are rejected before dispatch. + +Object names and ids must be well-formed Unicode; lone UTF-16 surrogates are +rejected. DO class names use ASCII JavaScript class-name grammar and are capped +at 468 bytes. + For `ctx.storage.sql`, avoid application table names beginning with `_cf_`; workerd reserves that prefix case-insensitively. `ctx.storage.deleteAll()` also leaves platform-owned `_cf_*` tables alone. diff --git a/docs/r2-zh.md b/docs/r2-zh.md index e8599d9..230fbd7 100644 --- a/docs/r2-zh.md +++ b/docs/r2-zh.md @@ -85,6 +85,8 @@ export default { `list({ include: ["httpMetadata", "customMetadata"] })` 会为列表里的对象额外发起 HEAD 来补齐 metadata,并带有并发上限。需要大范围扫描时,不要默认打开 metadata include;只在列表结果确实要展示或决策 metadata 时使用。 +当 `httpMetadata` 使用 `Headers` object 传入时,`Expires` header 必须是 canonical IMF-fixdate,例如 `Wed, 21 Oct 2015 07:28:00 GMT`。格式错误的值会在调用 host binding 前被拒绝。 + ### `put` 大小上限 `put(stream, ...)` 会先把流缓冲下来再发一次 S3 PUT,**上限 25 MiB**。当前平台还不支持 multipart upload;超过上限会报错,不会自动分片上传。 diff --git a/docs/r2.md b/docs/r2.md index 4a14386..9939465 100644 --- a/docs/r2.md +++ b/docs/r2.md @@ -99,6 +99,10 @@ requests to hydrate metadata for the listed objects, under a concurrency cap. For large scans, do not enable the metadata include by default; use it only when the list results genuinely need metadata for display or decisions. +When `httpMetadata` is supplied as a `Headers` object, an `Expires` header must +use canonical IMF-fixdate syntax, such as `Wed, 21 Oct 2015 07:28:00 GMT`. +Malformed values are rejected before the host binding call. + ### `put` size cap `put(stream, ...)` buffers the stream first, then sends a single S3 PUT, with a diff --git a/docs/secrets-zh.md b/docs/secrets-zh.md index 9b2f054..2e3f87e 100644 --- a/docs/secrets-zh.md +++ b/docs/secrets-zh.md @@ -54,7 +54,7 @@ Worker 级 secret mutation 是原子的:如果更新期间 active version 变 ## 约束 -- Key 必须符合环境变量命名规范:`[A-Z_][A-Z0-9_]*` —— 例如 `STRIPE_KEY`、`API_TOKEN`、`SIGNING_SECRET`。 +- Key 必须符合环境变量命名规范 `[A-Za-z_][A-Za-z0-9_]*`,最多 128 个字符。Runtime-reserved name 和保留的 `Object.prototype` key 会被拒绝。 - 值上限 64 KiB。 - Secrets 会和 `[vars]`、binding metadata 一起计入 workerLoader env budget。如果 mutation 返回 `worker_env_too_large`,减少 env payload;如果错误点名 retained version,redeploy/delete 该版本。 diff --git a/docs/secrets.md b/docs/secrets.md index 4e8c4d3..57a9e2e 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -75,8 +75,9 @@ retry after the operator reports the envelope issue repaired. ## Constraints -- Keys must follow environment-variable grammar: `[A-Z_][A-Z0-9_]*` — e.g. - `STRIPE_KEY`, `API_TOKEN`, `SIGNING_SECRET`. +- Keys must follow environment-variable grammar: `[A-Za-z_][A-Za-z0-9_]*`, + with a maximum length of 128 characters. Runtime-reserved names and reserved + `Object.prototype` keys are rejected. - Values are limited to 64 KiB. - Secrets count toward the workerLoader env budget together with `[vars]` and binding metadata. If a mutation returns `worker_env_too_large`, reduce the diff --git a/docs/workflows-zh.md b/docs/workflows-zh.md index a13a983..d5691c6 100644 --- a/docs/workflows-zh.md +++ b/docs/workflows-zh.md @@ -21,7 +21,10 @@ Worker 代码遵循 Cloudflare Workflows 心智模型:`WorkflowEntrypoint`、 ## 编写限制 +- `createBatch()` 每次最多接受 100 个 entry。 +- 单个 workflow result 上限是 1 MiB;一次 runtime 到 Workflows backend 的 JSON 请求上限是 2 MiB。 - 每个 workflow instance 的聚合 payload 上限是 16 MiB。超过上限的 step/event 写入会让请求失败;runtime 写入过大的 terminal result 时,会在同一个事务里把 instance 转为 failed。 +- 新创建的 completed、failed 和 terminated instance 默认保留 8 小时。需要时可通过 `create({ retention: { successRetention, errorRetention } })` 分别覆盖成功和错误保留时间。 - 一个 step 最多记录 1000 条 dependency edge。单次 runtime dispatch turn 最多有 1000 个 in-flight workflow steps,且最多启动 1000 个 fresh backend steps。 - 启动了 `step.do` promise 后,必须 await 所有已启动 step;run 在仍有 started step 未 settle 时返回会失败为 `workflow_invalid_step`。 - 并行 `step.do` sibling 必须在同一个同步 fan-out batch 里创建,再统一 `await`。一旦 await 了其中一个 sibling,就必须等完整 batch 结束后才能启动下一批 durable step,这样 replay 才能计算相同的 dependency frontier。 @@ -41,7 +44,9 @@ wdl workflows terminate --yes `restart` 和 `terminate` 是破坏性实例生命周期操作;只有在已经独立确认 namespace、worker、workflow 和 instance id 后才传 `--yes`。 -Workflows API 的语义大小限制会返回 `request_too_large`;HTTP body 解析阶段的大小限制可能返回 `request_body_too_large`。Workflows 5xx 表示平台或 backend 故障,响应体会保持通用错误摘要;底层诊断进入平台日志,不作为稳定 CLI 输出。 +`wdl workflows list` 会把 active Worker version 不再导出的定义标为 `retired=yes`。既有实例仍可查看和 terminate,但 restart 会返回 `workflow_not_exported`;需要先部署一个重新导出该 workflow name 的 active version。 + +Workflows API 的语义大小限制会返回 `request_too_large`;HTTP body 解析阶段的大小限制可能返回 `request_body_too_large`。Workflows 5xx 表示平台或 backend 故障,响应体会保持通用错误摘要;底层诊断进入平台日志,不作为稳定 CLI 输出。`workflow_metadata_contention` 表示 control 读取期间 active workflow metadata 发生变化,重试命令即可。 ## 端到端示例 diff --git a/docs/workflows.md b/docs/workflows.md index 7a3fafc..5085d6e 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -33,9 +33,15 @@ if user code catches the thrown error. ## Programming limits +- `createBatch()` accepts at most 100 entries per call. +- A single workflow result is capped at 1 MiB, and one runtime-to-Workflows + backend JSON request is capped at 2 MiB. - Per-instance aggregate payload is capped at 16 MiB. Step/event writes over the cap fail the request; when the runtime writes an over-cap terminal result, it transitions the instance to failed in the same transaction. +- Newly created completed, failed, and terminated instances are retained for 8 + hours by default. Override success and error retention with + `create({ retention: { successRetention, errorRetention } })` when needed. - One step may record at most 1000 dependency edges. A single runtime dispatch turn may have at most 1000 in-flight workflow steps and start at most 1000 fresh backend steps. @@ -68,11 +74,18 @@ wdl workflows terminate --yes `--yes` only after independently confirming the namespace, worker, workflow, and instance id. +`wdl workflows list` marks definitions absent from the active Worker version as +`retired=yes`. Existing instances remain inspectable and may be terminated, but +restart returns `workflow_not_exported` until an active version exports that +workflow name again. + Semantic size limits in the Workflows API return `request_too_large`; size limits hit during HTTP body parsing may return `request_body_too_large`. A Workflows 5xx means a platform or backend failure, and the response body stays a generic error summary; underlying diagnostics go to platform logs and are not stable CLI output. +`workflow_metadata_contention` means the active workflow metadata changed while +control was reading it; retry the command. ## End-to-end example diff --git a/lib/delete-format.js b/lib/delete-format.js index a69461d..3c12ee8 100644 --- a/lib/delete-format.js +++ b/lib/delete-format.js @@ -54,8 +54,9 @@ const ASSET_WARNING_KEYS = [ * @property {boolean} [deleted] * @property {boolean} [noop] * @property {boolean} [hasWorkerSecrets] + * @property {boolean} [hasWorkflowDefs] * @property {string[]} [versionsDeleted] - * @property {string} [activeDeleted] + * @property {string | null} [activeDeleted] * @property {string[]} [affectedHosts] * @property {number} [queueConsumersRemoved] * @property {DeleteBlocker[]} [blockers] @@ -113,7 +114,8 @@ function formatDryRun(body) { `DRY RUN ${field(body.namespace)}/${field(body.name)} wouldDelete=${body.deleted ? "yes" : "no"} active=${active} versions=${versions}`, ]; if (body.noop) lines.push(" no worker-owned state found"); - if (body.hasWorkerSecrets) lines.push(" worker secrets would be deleted"); + if (body.hasWorkerSecrets) lines.push(" worker secrets present"); + if (body.hasWorkflowDefs) lines.push(" workflow definitions present"); if (Array.isArray(body.affectedHosts) && body.affectedHosts.length) { lines.push(` affected hosts: ${body.affectedHosts.map((host) => field(host)).join(",")}`); } diff --git a/lib/workers-format.js b/lib/workers-format.js index 7b58ac9..b56f011 100644 --- a/lib/workers-format.js +++ b/lib/workers-format.js @@ -1,11 +1,14 @@ // Human-readable rendering for `wdl workers`. +import { escapeTerminalText } from "./output.js"; + /** * @typedef {object} WorkerSummary * @property {string} [name] * @property {string[]} [versions] * @property {string | null} [activeVersion] null when the worker has no deployed version. * @property {boolean} [hasSecrets] + * @property {boolean} [hasWorkflowDefs] */ /** @@ -17,10 +20,13 @@ export function formatWorkersList(body) { if (workers.length === 0) return ["(no workers)"]; return workers.map((w) => { const versions = Array.isArray(w.versions) && w.versions.length - ? w.versions.join(",") + ? w.versions.map((version) => escapeTerminalText(version)).join(",") : "-"; - const active = w.activeVersion || "-"; + const active = w.activeVersion ? escapeTerminalText(w.activeVersion) : "-"; const secrets = w.hasSecrets ? "yes" : "no"; - return `${w.name}\tactive=${active}\tversions=${versions}\tsecrets=${secrets}`; + const workflowDefs = typeof w.hasWorkflowDefs === "boolean" + ? (w.hasWorkflowDefs ? "yes" : "no") + : "unknown"; + return `${escapeTerminalText(w.name)}\tactive=${active}\tversions=${versions}\tsecrets=${secrets}\tworkflow-defs=${workflowDefs}`; }); } diff --git a/lib/workflows-format.js b/lib/workflows-format.js index da38b70..fd292f5 100644 --- a/lib/workflows-format.js +++ b/lib/workflows-format.js @@ -1,11 +1,14 @@ +import { escapeTerminalText, formatDiagnosticValue } from "./output.js"; + /** * @typedef {object} WorkflowSummary * @property {string} [worker] * @property {string} [name] - * @property {string} [binding] + * @property {string | null} [binding] * @property {string} [className] * @property {string} [activeVersion] * @property {string} [workflowKey] + * @property {boolean} [retired] */ /** @@ -21,6 +24,11 @@ * @property {string} status */ +/** @param {unknown} value */ +function field(value) { + return value == null || value === "" ? "-" : escapeTerminalText(value); +} + /** * @param {{ workflows?: WorkflowSummary[] } | null | undefined} body * @returns {string[]} @@ -29,7 +37,7 @@ export function formatWorkflowList(body) { const workflows = Array.isArray(body?.workflows) ? body.workflows : []; if (workflows.length === 0) return ["(no workflows)"]; return workflows.map((entry) => - `${entry.worker}/${entry.name}\tbinding=${entry.binding || "-"}\tclass=${entry.className || "-"}\tactive=${entry.activeVersion || "-"}\tkey=${entry.workflowKey || "-"}` + `${field(entry.worker)}/${field(entry.name)}\tbinding=${field(entry.binding)}\tclass=${field(entry.className)}\tactive=${field(entry.activeVersion)}\tkey=${field(entry.workflowKey)}\tretired=${entry.retired ? "yes" : "no"}` ); } @@ -41,8 +49,8 @@ export function formatInstanceList(body) { const instances = Array.isArray(body?.instances) ? body.instances : []; const lines = instances.length === 0 ? ["(no workflow instances)"] - : instances.map((entry) => `${entry.id}\tstatus=${entry.status || "-"}`); - if (body?.cursor) lines.push(`Next cursor: ${body.cursor}`); + : instances.map((entry) => `${field(entry.id)}\tstatus=${field(entry.status)}`); + if (body?.cursor) lines.push(`Next cursor: ${escapeTerminalText(body.cursor)}`); return lines; } @@ -57,18 +65,18 @@ export function formatInstanceList(body) { * @returns {string[]} */ export function formatInstanceStatus(body) { - const lines = [`${body.id || "-"}\tstatus=${body.status || "-"}`]; + const lines = [`${field(body.id)}\tstatus=${field(body.status)}`]; if (body.output !== undefined && body.output !== null) { - lines.push(`output=${JSON.stringify(body.output)}`); + lines.push(`output=${formatDiagnosticValue(body.output)}`); } if (body.error !== undefined && body.error !== null) { - lines.push(`error=${JSON.stringify(body.error)}`); + lines.push(`error=${formatDiagnosticValue(body.error)}`); } if (Array.isArray(body.steps?.entries)) { const suffix = body.steps.truncated ? " (truncated)" : ""; lines.push(`steps=${body.steps.entries.length}${suffix}`); for (const step of body.steps.entries) { - lines.push(` #${step.ordinal} ${step.name} status=${step.status}`); + lines.push(` #${escapeTerminalText(step.ordinal)} ${field(step.name)} status=${field(step.status)}`); } } return lines; diff --git a/lib/wrangler/command.js b/lib/wrangler/command.js index 46f577c..18723fa 100644 --- a/lib/wrangler/command.js +++ b/lib/wrangler/command.js @@ -126,7 +126,14 @@ export function checkWranglerVersion({ execFile = execFileSync, cwd, env, wrangl */ export function wranglerChildEnv(env) { /** @type {NodeJS.ProcessEnv} */ - const childEnv = { ...env, CLOUDFLARE_API_TOKEN: "dry-run-dummy" }; + const childEnv = { + ...env, + CLOUDFLARE_API_TOKEN: "dry-run-dummy", + // WDL captures Wrangler's banner and only needs local bundle output. Hide + // the banner (and its normal update check) and disable anonymous telemetry. + WRANGLER_HIDE_BANNER: "true", + WRANGLER_SEND_METRICS: "false", + }; for (const key of WRANGLER_SCRUB_KEYS) { delete childEnv[key]; } diff --git a/lib/wrangler/config.js b/lib/wrangler/config.js index 8946bfb..34c0ff7 100644 --- a/lib/wrangler/config.js +++ b/lib/wrangler/config.js @@ -30,6 +30,7 @@ const TOP_LEVEL_ONLY_ENV_KEYS = new Set([ // Bundling-only keys (build, alias, tsconfig, rules, etc.) stay allowed // because Wrangler consumes them before the CLI collects the output manifest. const UNSUPPORTED_WRANGLER_KEYS = [ + "addresses", "agent_memory", "ai", "ai_search", @@ -42,6 +43,7 @@ const UNSUPPORTED_WRANGLER_KEYS = [ "compliance_region", "containers", "data_blobs", + "dependencies_instrumentation", "dispatch_namespaces", "first_party_worker", "flagship", diff --git a/package-lock.json b/package-lock.json index 4412a95..32bf2a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,18 +9,18 @@ "version": "1.4.1", "license": "Apache-2.0", "dependencies": { - "jsonc-parser": "3.2.0", - "smol-toml": "^1.3.1", - "wrangler": "^4.102.0" + "jsonc-parser": "3.3.1", + "smol-toml": "^1.7.0", + "wrangler": "4.112.0" }, "bin": { "wdl": "bin/wdl.js" }, "devDependencies": { "@eslint/js": "^10.0.1", - "@types/node": "^22.19.21", - "eslint": "^10.4.1", - "globals": "^17.6.0", + "@types/node": "^22.20.1", + "eslint": "^10.7.0", + "globals": "^17.7.0", "typescript": "^6.0.3" }, "engines": { @@ -52,9 +52,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260617.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260617.1.tgz", - "integrity": "sha512-jWwmgEVVWbsHNrLSNXzwjJaH90VzRxq1cWkQFUidxyeUPnMxemeNE8I9qFAfrpzGgE11e9sKDcE3ettJW08swQ==", + "version": "1.20260714.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260714.1.tgz", + "integrity": "sha512-ZWXqAN8G7Cx9hMRQuk+59ziJhR3j1F4iO+Qs8aHdfKZ3Dq5Yi/57xvkJTgCGBnW1YU/L78r8f6HEy51bwbTpNw==", "cpu": [ "x64" ], @@ -68,9 +68,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260617.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260617.1.tgz", - "integrity": "sha512-LHH7b565g9znfCUOkwbec6FG2rmRbsgCy6aJiU9KN662mNheWl5sw/iKleiFSiljPKQQP3HkjnC/NSkdgi/aSA==", + "version": "1.20260714.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260714.1.tgz", + "integrity": "sha512-tueWxWC3wyCbMG6zRAxsMXX0YLgrRWbiAPYFQ2uJ7dUH8G+5E7UTWaQS9B1HdJ0bpKFW1NWxhs1o2noKVFSUYg==", "cpu": [ "arm64" ], @@ -84,9 +84,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260617.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260617.1.tgz", - "integrity": "sha512-FMnaAKXe4Cfd8TQurCVd9fs2XQVBFRCsP+Id/SRdUv89MlwYu9zXfoyx6BxM+brPTIUK38SHbo8iaxiwzLi9JQ==", + "version": "1.20260714.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260714.1.tgz", + "integrity": "sha512-1VChTZRb0l0F7R4e1G5RtLKV4oFi6x+rQgxh2+yu887j3l/3TLgatuv1L8/5zhc9gKEhATTxOh0e52Rtd9dDWQ==", "cpu": [ "x64" ], @@ -100,9 +100,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260617.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260617.1.tgz", - "integrity": "sha512-MRoifFYcqbxxIIQy7PqO5tFY/qPFSnjXzakWl0sO93l+HLyG35jRAgOi6jfqa4kBxc7gKKtH861DcewjxUfkjA==", + "version": "1.20260714.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260714.1.tgz", + "integrity": "sha512-rMm3G+NirG2UdgHIRDdF1asNC6FqgIzZzkRG+VDhhDGcVxAQwvrMT1E38BivEvHr3G04MB4AfhcOczX0+GtRkQ==", "cpu": [ "arm64" ], @@ -116,9 +116,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260617.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260617.1.tgz", - "integrity": "sha512-rgBV9wQrv0OSKgCTTbhFUFY3sLGNANZ88aqaLvtmEn2gmbFVb1J4PDGochVUdB7NSEp4D/ghHva6/8SZmbONpw==", + "version": "1.20260714.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260714.1.tgz", + "integrity": "sha512-cGqnU3Hg2YZS/k3SAqrMp1DjpdsyFde72tWltdl6ZT9+SFz/Zrk/8gyTU1TcxC4YApXeNVH5TyU5cOGPgUJ0pg==", "cpu": [ "x64" ], @@ -144,9 +144,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", "license": "MIT", "optional": true, "dependencies": { @@ -1367,9 +1367,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.21", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz", - "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1571,11 +1571,14 @@ } }, "node_modules/eslint": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.1.tgz", - "integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==", + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz", + "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -1822,9 +1825,9 @@ } }, "node_modules/globals": { - "version": "17.6.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", - "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", "dev": true, "license": "MIT", "engines": { @@ -1906,9 +1909,9 @@ "license": "MIT" }, "node_modules/jsonc-parser": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", - "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", "license": "MIT" }, "node_modules/keyv": { @@ -1961,15 +1964,15 @@ } }, "node_modules/miniflare": { - "version": "4.20260617.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260617.0.tgz", - "integrity": "sha512-A+H5gcOCQZsKFg7/daZUtx8WHn4gGxwUfH1jnNDAisyAWSvvSZHe+GCeQWs16uthnUDcm72UQIQ1NXDJtnuo9Q==", + "version": "4.20260714.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260714.0.tgz", + "integrity": "sha512-MYlTCLdWCPqvrYY2uLwOjXwmglXuiHE3TGGkbOW4BwjUPa1r07E0iuHwrNDIs/sxK21r+o90Jx58AV2KeNdJZw==", "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.28.0", - "workerd": "1.20260617.1", + "workerd": "1.20260714.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, @@ -2113,9 +2116,9 @@ } }, "node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -2192,9 +2195,9 @@ } }, "node_modules/smol-toml": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", - "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz", + "integrity": "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==", "license": "BSD-3-Clause", "engines": { "node": ">= 18" @@ -2311,9 +2314,9 @@ } }, "node_modules/workerd": { - "version": "1.20260617.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260617.1.tgz", - "integrity": "sha512-Re5pl6pdowt3ZmWUzGlOuB7jbRIIPetgKalmo4cYmucQnVhpo7/3e4MfpekbhLi2EhZZz5EY9NWRu8zFzuEZew==", + "version": "1.20260714.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260714.1.tgz", + "integrity": "sha512-oIbQzfdyl9UQUnG6XLegcSq0Mgt/7WKDbFOoqGgOWCS+/fhyGB460uKEgdAQQ9RHCO/ttcNCX/KiMIQzdoeu3Q==", "hasInstallScript": true, "license": "Apache-2.0", "bin": { @@ -2323,27 +2326,27 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260617.1", - "@cloudflare/workerd-darwin-arm64": "1.20260617.1", - "@cloudflare/workerd-linux-64": "1.20260617.1", - "@cloudflare/workerd-linux-arm64": "1.20260617.1", - "@cloudflare/workerd-windows-64": "1.20260617.1" + "@cloudflare/workerd-darwin-64": "1.20260714.1", + "@cloudflare/workerd-darwin-arm64": "1.20260714.1", + "@cloudflare/workerd-linux-64": "1.20260714.1", + "@cloudflare/workerd-linux-arm64": "1.20260714.1", + "@cloudflare/workerd-windows-64": "1.20260714.1" } }, "node_modules/wrangler": { - "version": "4.102.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.102.0.tgz", - "integrity": "sha512-GPljlQs9a+/Ai2h0TdEUYaaWv9upK4fUteSWTPlruas7tdixiIwr74CZSWHcEPuRgZYkyYKHIBbT1w+BYPkPrw==", + "version": "4.112.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.112.0.tgz", + "integrity": "sha512-5H+XUD0TySCv1LuktFHDIEOkboH2nTfQs+35L+USt3MtntjDTMVIJprLgQcL2WBjulOyjxpd1vyTiSTJVW5MjQ==", "license": "MIT OR Apache-2.0", "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", - "miniflare": "4.20260617.0", + "miniflare": "4.20260714.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", - "workerd": "1.20260617.1" + "workerd": "1.20260714.1" }, "bin": { "cf-wrangler": "bin/cf-wrangler.js", @@ -2357,7 +2360,7 @@ "fsevents": "2.3.3" }, "peerDependencies": { - "@cloudflare/workers-types": "^4.20260617.1" + "@cloudflare/workers-types": "^5.20260714.1" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { diff --git a/package.json b/package.json index 283d278..8b177a2 100644 --- a/package.json +++ b/package.json @@ -44,9 +44,9 @@ "GUIDE-zh.md" ], "dependencies": { - "jsonc-parser": "3.2.0", - "smol-toml": "^1.3.1", - "wrangler": "^4.102.0" + "jsonc-parser": "3.3.1", + "smol-toml": "^1.7.0", + "wrangler": "4.112.0" }, "engines": { "node": ">=22" @@ -63,9 +63,9 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@types/node": "^22.19.21", - "eslint": "^10.4.1", - "globals": "^17.6.0", + "@types/node": "^22.20.1", + "eslint": "^10.7.0", + "globals": "^17.7.0", "typescript": "^6.0.3" } } diff --git a/templates/AGENTS.md b/templates/AGENTS.md index af028d7..24fd55d 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -32,6 +32,7 @@ package. | WDL environment override rules (preview / production) | `env-overrides.md` | | Runtime secrets | `secrets.md` | | Storing control-plane tokens locally | `token.md` | +| Service bindings / JSRPC capability delegation | `deploy.md` | | Deploy / dry-run / list and delete workers | `deploy.md` | Open the relevant doc before editing `wrangler.json` / `wrangler.jsonc` / @@ -40,15 +41,17 @@ read each matching doc and merge their wrangler config snippets. New Wrangler configs should use `compatibility_date = "2026-06-17"` unless a project feature requires a newer target or the operator gives a different -target. WDL follows Wrangler config priority +target. Control rejects explicit dates before `2026-04-01`, invalid or future +dates, dates newer than the bundled workerd supports, upstream experimental +enable flags, `legacy_error_serialization`, and +`allow_irrevocable_stub_storage`. WDL follows Wrangler config priority (`wrangler.json`, then `wrangler.jsonc`, then `wrangler.toml`). The control plane is canonical for unsupported runtime shapes such as unsupported workerd -experimental compatibility flags and WDL-reserved injected module names; the -CLI still fails fast for cheap local cases such as Python Workers modules, -unmapped top-level or selected-env Wrangler runtime/deploy keys (`[site]`, -`workers_dev`, `pages_build_output_dir`, etc.), and ambiguous runtime `env` -name collisions between `[vars]`, explicit bindings, and the implicit `ASSETS` -binding. +compatibility flags and WDL-reserved injected module names. The CLI still fails +fast for cheap local cases such as Python Workers modules, unmapped top-level or +selected-env Wrangler runtime/deploy keys (`[site]`, `workers_dev`, +`pages_build_output_dir`, etc.), and ambiguous runtime `env` name collisions +between `[vars]`, explicit bindings, and the implicit `ASSETS` binding. ## Runnable end-to-end examples diff --git a/tests/unit/cli-config-doctor.test.js b/tests/unit/cli-config-doctor.test.js index 956c927..e497ce0 100644 --- a/tests/unit/cli-config-doctor.test.js +++ b/tests/unit/cli-config-doctor.test.js @@ -238,6 +238,25 @@ test("whoami calls control introspection and prints platform compatibility", asy }); }); +test("whoami reports an unavailable namespace URL when control omits it", async () => { + await withTempDir(async (cwd) => { + /** @type {string[]} */ + const lines = []; + await runWhoamiCommand(["--ns", "acme", "--control-url", "http://ctl.test", "--token", "secret-token"], { + cwd, + env: {}, + stdout: (/** @type {string} */ line) => lines.push(line), + controlFetch: async () => response({ + ok: true, + principal: { kind: "ns", ns: "acme" }, + urls: { control: "http://ctl.test" }, + }), + }); + + assert.match(lines.join("\n"), /Namespace URL: \(unavailable\)/); + }); +}); + test("whoami text reports configured namespace mismatch", async () => { await withTempDir(async (cwd) => { /** @type {string[]} */ diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index a0eae43..8c798da 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -1616,6 +1616,7 @@ test("validateUnsupportedWranglerConfig rejects unmapped wrangler runtime/deploy "workers_dev", ]); for (const key of [ + "addresses", "agent_memory", "ai", "artifacts", @@ -1623,6 +1624,7 @@ test("validateUnsupportedWranglerConfig rejects unmapped wrangler runtime/deploy "cache", "cloudchamber", "compliance_region", + "dependencies_instrumentation", "hyperdrive", "first_party_worker", "flagship", @@ -1678,6 +1680,20 @@ test("validateUnsupportedWranglerConfig rejects unmapped wrangler runtime/deploy /unsupported Wrangler field "vectorize"/ ); + for (const [key, value] of /** @type {Array<[string, unknown]>} */ ([ + ["addresses", []], + ["dependencies_instrumentation", null], + ])) { + assert.throws( + () => validateUnsupportedWranglerConfig({ + name: "demo", + main: "src/index.js", + [key]: value, + }, null, "wrangler.toml"), + new RegExp(`unsupported Wrangler field "${key}"`) + ); + } + assert.throws( () => validateUnsupportedWranglerConfig({ name: "demo", @@ -1714,8 +1730,10 @@ test("validateUnsupportedWranglerConfig rejects unmapped wrangler runtime/deploy * @returns {unknown} */ function unsupportedWranglerFixtureValue(key, objectShapeKeys, booleanShapeKeys) { + if (key === "dependencies_instrumentation") return { enabled: false }; if (objectShapeKeys.has(key)) return { binding: "B" }; if (booleanShapeKeys.has(key)) return true; + if (key === "addresses") return ["support@example.com"]; if (key === "compliance_region") return "eu"; if (key === "pages_build_output_dir") return "dist"; return [{ binding: "B" }]; @@ -2160,7 +2178,7 @@ test("resolveWranglerCommand on win32 fails loudly when only a bare PATH .cmd sh } }); -test("wranglerChildEnv strips WDL control-plane environment", () => { +test("wranglerChildEnv scrubs control env, hides Wrangler's banner, and disables telemetry", () => { assert.deepEqual( wranglerChildEnv({ ADMIN_TOKEN: "secret", @@ -2171,11 +2189,15 @@ test("wranglerChildEnv strips WDL control-plane environment", () => { // export does not leak the control endpoint into the bundler. ADMIN_URL: "https://legacy-admin.example", CLOUDFLARE_API_TOKEN: "real-cloudflare-token", + WRANGLER_HIDE_BANNER: "false", + WRANGLER_SEND_METRICS: "true", PATH: "/bin", KEEP_ME: "ok", }), { CLOUDFLARE_API_TOKEN: "dry-run-dummy", + WRANGLER_HIDE_BANNER: "true", + WRANGLER_SEND_METRICS: "false", PATH: "/bin", KEEP_ME: "ok", } @@ -2593,7 +2615,7 @@ test("runDeployCommand rejects non-object vars before bundling", async () => { } }); -test("runDeployCommand prints a direct http URL for a local deploy", async () => { +test("runDeployCommand preserves the local control scheme and port in the Worker URL", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-localurl-")); try { mkdirSync(path.join(dir, "src"), { recursive: true }); @@ -2604,9 +2626,9 @@ test("runDeployCommand prints a direct http URL for a local deploy", async () => const lines = []; let fetchCount = 0; await runDeployCommand( - [dir, "--ns", "demo", "--control-url", "http://localhost:8080"], + [dir, "--ns", "demo", "--control-url", "https://localhost:8443"], { - env: { ADMIN_TOKEN: "tok" }, + env: { ADMIN_TOKEN: "tok", CONTROL_CONNECT_HOST: "127.0.0.1:18080" }, stdout: (/** @type {string} */ line) => lines.push(/** @type {string} */ line), stderr: () => {}, execFile: (/** @type {string} */ _cmd, /** @type {readonly string[]} */ args) => { @@ -2624,7 +2646,7 @@ test("runDeployCommand prints a direct http URL for a local deploy", async () => } ); - assert.ok(lines.includes(" http://demo.workers.local:8080/api/"), "local deploy prints a direct http URL with the gateway port"); + assert.ok(lines.includes(" https://demo.workers.local:8443/api/")); assert.equal(lines.some((line) => line.includes("curl -H")), false, "no curl hint"); } finally { rmSync(dir, { recursive: true, force: true }); @@ -2672,7 +2694,7 @@ test("runDeployCommand detects local control by hostname only", async () => { } }); -test("runDeployCommand treats a .test control host as local (http URL, not https)", async () => { +test("runDeployCommand uses the default port from a local control URL", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-test-host-")); try { mkdirSync(path.join(dir, "src"), { recursive: true }); @@ -2704,8 +2726,8 @@ test("runDeployCommand treats a .test control host as local (http URL, not https ); assert.ok( - lines.includes(" http://demo.workers.local:8080/api/"), - "a .test control host prints the local http URL" + lines.includes(" http://demo.workers.local/api/"), + "a .test control host without an explicit port uses the http default" ); assert.equal(lines.some((line) => line.startsWith(" https://")), false, "no production https URL for a local deploy"); } finally { @@ -3452,6 +3474,49 @@ test("runDeployCommand explains control-rejected experimental compatibility flag } }); +test("runDeployCommand explains control-rejected unsupported compatibility flags", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-unsupported-flag-")); + try { + mkdirSync(path.join(dir, "src"), { recursive: true }); + writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); + writeFileSync(path.join(dir, "wrangler.toml"), [ + 'name = "api"', + 'main = "src/index.js"', + 'compatibility_flags = ["legacy_error_serialization"]', + ].join("\n")); + + /** @type {RecordedFetch[]} */ + const fetchCalls = []; + await assert.rejects( + () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { + fetchCalls.push({ url, init }); + return response({ + error: "compatibility_flag_unsupported", + message: "unsupported compatibility flag: legacy_error_serialization", + }, 400); + }, + }), + (err) => { + const message = /** @type {Error} */ (err).message; + assert.match(message, /compatibility_flag_unsupported/); + assert.match(message, /remove the unsupported compatibility flag/); + return true; + } + ); + + assert.equal(fetchCalls.length, 1); + const manifest = JSON.parse(/** @type {string} */ (fetchCalls[0].init.body)); + assert.deepEqual(manifest.compatibilityFlags, ["legacy_error_serialization"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("runDeployCommand projects unknown deploy warnings before printing", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-warning-project-")); try { diff --git a/tests/unit/cli-lifecycle.test.js b/tests/unit/cli-lifecycle.test.js index 0303d41..c377cc8 100644 --- a/tests/unit/cli-lifecycle.test.js +++ b/tests/unit/cli-lifecycle.test.js @@ -14,10 +14,16 @@ import { runWorkersCommand, formatWorkersList } from "../../commands/workers.js" import { runWorkflowsCommand } from "../../commands/workflows.js"; import { main as wdlMain } from "../../bin/wdl.js"; import { CliError, readJsonOrFail } from "../../lib/common.js"; +import { formatWorkerDelete } from "../../lib/delete-format.js"; import { LONG_CONTROL_TIMEOUT_MS, UNLIMITED_CONTROL_BODY_BYTES, } from "../../lib/control-fetch.js"; +import { + formatInstanceList, + formatInstanceStatus, + formatWorkflowList, +} from "../../lib/workflows-format.js"; import { ESC, assertNoRawTerminalControls, mockDeps, response } from "./helpers.js"; /** @typedef {import("./helpers.js").ControlCall} ControlCall */ @@ -74,29 +80,39 @@ function ttyStdinLine(value) { return stdin; } -test("readJsonOrFail compacts structured control errors", async () => { +test("readJsonOrFail compacts redacted D1 lifecycle errors", async () => { const errBody = { error: "d1_database_initialize_failed", namespace: "demo", databaseId: "d1_test", - message: "D1 backend is unavailable: internal error; reference = ref-1", + message: "Internal error", upstreamCode: "backend-unavailable", upstreamCategory: "internal", upstreamRetryable: true, upstreamStatus: 503, - detail: { - success: false, - error: "backend-unavailable", - message: "D1 backend is unavailable: internal error; reference = ref-1", - category: "internal", - retryable: true, - }, }; await assert.rejects( () => readJsonOrFail(response(errBody, 503), "create d1 database"), { - message: "create d1 database failed: 503 d1_database_initialize_failed: D1 backend is unavailable: internal error; reference = ref-1 namespace=demo databaseId=d1_test upstreamCode=backend-unavailable upstreamCategory=internal upstreamRetryable=true upstreamStatus=503", + message: "create d1 database failed: 503 d1_database_initialize_failed: Internal error namespace=demo databaseId=d1_test upstreamCode=backend-unavailable upstreamCategory=internal upstreamRetryable=true upstreamStatus=503", + } + ); +}); + +test("readJsonOrFail omits nested details from compact control errors", async () => { + await assert.rejects( + () => readJsonOrFail(response({ + error: "d1_database_initialize_failed", + message: "Internal error", + upstreamCode: "backend-unavailable", + detail: { + message: "unredacted upstream detail", + internalReference: "ref-1", + }, + }, 503), "create d1 database"), + { + message: "create d1 database failed: 503 d1_database_initialize_failed: Internal error upstreamCode=backend-unavailable", } ); }); @@ -262,7 +278,13 @@ test("workers command lists namespace worker state", async () => { const body = { namespace: "demo", workers: [ - { name: "api", activeVersion: "v2", versions: ["v1", "v2"], hasSecrets: true }, + { + name: "api", + activeVersion: "v2", + versions: ["v1", "v2"], + hasSecrets: true, + hasWorkflowDefs: true, + }, ], }; const { calls, lines, deps } = mockDeps(body); @@ -272,7 +294,9 @@ test("workers command lists namespace worker state", async () => { assert.equal(calls.length, 1); assert.equal(calls[0].url, "http://ctl.test/ns/demo/workers"); assert.deepEqual(/** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ (calls[0].init).headers, { "x-admin-token": "tok" }); - assert.deepEqual(lines, ["api\tactive=v2\tversions=v1,v2\tsecrets=yes"]); + assert.deepEqual(lines, [ + "api\tactive=v2\tversions=v1,v2\tsecrets=yes\tworkflow-defs=yes", + ]); }); test("workers command does not double-slash paths when CONTROL_URL has a trailing slash", async () => { @@ -305,35 +329,61 @@ test("workers command rejects unexpected positional arguments", async () => { }); test("wdl workers escapes control sequences from the control plane but keeps tab columns", async () => { + const hostile = `${ESC}[2J\nFORGED\rBAD\tCOLUMN\u009b`; /** @type {string[]} */ const lines = []; await runWorkersCommand(["--ns", "demo", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, stdout: (/** @type {string} */ line) => lines.push(line), controlFetch: async () => response({ - workers: [{ name: "ev\u001bil", activeVersion: "v1", versions: ["v1"], hasSecrets: false }], + workers: [{ + name: `worker-${hostile}`, + activeVersion: `v2-${hostile}`, + versions: [`v1-${hostile}`], + hasSecrets: false, + }], }), }); const out = lines.join("\n"); - assert.ok(!out.includes("\u001b"), "raw ESC must not reach the terminal"); - assert.ok(out.includes("ev\\u001bil"), "worker name must be escaped"); - assert.ok(out.includes("\t"), "tab column separators must be preserved"); + assertNoRawTerminalControls(out, "workers output"); + assert.match(out, /worker-\\u001b\[2J\\nFORGED\\rBAD\\tCOLUMN\\u009b/); + assert.match(out, /active=v2-\\u001b\[2J\\nFORGED\\rBAD\\tCOLUMN\\u009b/); + assert.match(out, /versions=v1-\\u001b\[2J\\nFORGED\\rBAD\\tCOLUMN\\u009b/); + assert.equal(out.split("\t").length - 1, 4, "only formatter column separators may remain as raw tabs"); }); -test("formatWorkersList handles empty and deploy-only entries", () => { +test("formatWorkersList handles empty and workflow-definition-only entries", () => { assert.deepEqual(formatWorkersList({ workers: [] }), ["(no workers)"]); - // NOTE: lib/workers-format.js types `activeVersion` as `string | undefined`, - // but the control plane (and this test) sends `null` for an undeployed - // worker. `formatWorkersList` handles it (`w.activeVersion || "-"`); the - // typedef just omits `null`. Cast through the real param type so the test - // keeps exercising the null path without widening the lib type here. assert.deepEqual( - formatWorkersList(/** @type {Parameters[0]} */ ( - /** @type {unknown} */ ({ - workers: [{ name: "draft", activeVersion: null, versions: ["v1"], hasSecrets: false }], - }) - )), - ["draft\tactive=-\tversions=v1\tsecrets=no"] + formatWorkersList({ + workers: [ + { + name: "draft", + activeVersion: null, + versions: [], + hasSecrets: false, + hasWorkflowDefs: true, + }, + { + name: "legacy", + activeVersion: "v1", + versions: ["v1"], + hasSecrets: false, + }, + { + name: "empty", + activeVersion: null, + versions: [], + hasSecrets: false, + hasWorkflowDefs: false, + }, + ], + }), + [ + "draft\tactive=-\tversions=-\tsecrets=no\tworkflow-defs=yes", + "legacy\tactive=v1\tversions=v1\tsecrets=no\tworkflow-defs=unknown", + "empty\tactive=-\tversions=-\tsecrets=no\tworkflow-defs=no", + ] ); }); @@ -458,6 +508,7 @@ test("delete worker supports dry-run query and raw json output", async () => { affectedHosts: ["demo.workers.example"], queueConsumersRemoved: 1, hasWorkerSecrets: true, + hasWorkflowDefs: true, }; const { calls, lines, deps } = mockDeps(body); @@ -472,6 +523,37 @@ test("delete worker supports dry-run query and raw json output", async () => { assert.deepEqual(lines, [JSON.stringify(body, null, 2)]); }); +test("delete worker dry-run reports state presence without overstating deletion", () => { + const base = { + dryRun: true, + namespace: "demo", + name: "api", + activeDeleted: null, + versionsDeleted: [], + }; + assert.deepEqual( + formatWorkerDelete({ + ...base, + deleted: true, + hasWorkerSecrets: true, + hasWorkflowDefs: true, + }), + [ + "DRY RUN demo/api wouldDelete=yes active=- versions=-", + " worker secrets present", + " workflow definitions present", + ] + ); + assert.deepEqual( + formatWorkerDelete({ ...base, deleted: false, hasWorkflowDefs: false }), + ["DRY RUN demo/api wouldDelete=no active=- versions=-"] + ); + assert.deepEqual( + formatWorkerDelete({ ...base, deleted: false }), + ["DRY RUN demo/api wouldDelete=no active=- versions=-"] + ); +}); + test("delete worker dry-run renders workflow blockers in human output", async () => { const hostile = `bad${ESC}[2J\nFORGED\rBAD`; const body = { @@ -482,6 +564,8 @@ test("delete worker dry-run renders workflow blockers in human output", async () activeDeleted: `v2-${hostile}`, versionsDeleted: [`v1-${hostile}`], affectedHosts: [`host-${hostile}.example`], + hasWorkerSecrets: true, + hasWorkflowDefs: true, blockers: [{ version: `v1-${hostile}`, referrers: [{ @@ -511,6 +595,8 @@ test("delete worker dry-run renders workflow blockers in human output", async () assert.ok(lines.some((line) => /workflow blocker/.test(line))); assert.match(joined, /DRY RUN demo-bad\\u001b\[2J\\nFORGED\\rBAD\/api-bad\\u001b\[2J\\nFORGED\\rBAD/); assert.match(joined, /affected hosts: host-bad\\u001b\[2J\\nFORGED\\rBAD\.example/); + assert.match(joined, /worker secrets present/); + assert.match(joined, /workflow definitions present/); assert.match(joined, /binding=binding-bad\\u001b\[2J\\nFORGED\\rBAD/); assert.match(joined, /workflow_instances_active-bad\\u001b\[2J\\nFORGED\\rBAD/); assert.match(joined, /wf-bad\\u001b\[2J\\nFORGED\\rBAD instance=inst-bad\\u001b\[2J\\nFORGED\\rBAD/); @@ -594,6 +680,22 @@ test("delete command exposes only documented destructive subcommands", async () ); }); +test("delete worker help lists workflow definitions among deleted state", async () => { + /** @type {string[]} */ + const lines = []; + await runDeleteCommand(["worker", "--help"], { + env: {}, + stdout: (/** @type {string} */ line) => lines.push(line), + controlFetch: async () => { + throw new Error("controlFetch should not be called for help"); + }, + }); + assert.match( + lines.join("\n"), + /Delete a worker, its versions, secrets, workflow definitions, routes, and queue consumers\./ + ); +}); + test("delete command rejects unexpected positional arguments", async () => { const deps = { env: { ADMIN_TOKEN: "tok" }, @@ -1378,14 +1480,25 @@ test("workflows commands call encoded control endpoints", async () => { calls.push({ url, init }); if (url.endsWith("/workflows")) { return response({ - workflows: [{ - worker: "api", - name: "orders", - binding: "ORDERS", - className: "OrderWorkflow", - activeVersion: "v2", - workflowKey: "wf_1234", - }], + workflows: [ + { + worker: "api", + name: "orders", + binding: "ORDERS", + className: "OrderWorkflow", + activeVersion: "v2", + workflowKey: "wf_1234", + }, + { + worker: "api", + name: "legacy", + binding: null, + className: "LegacyWorkflow", + activeVersion: "v2", + workflowKey: "wf_retired", + retired: true, + }, + ], }); } if (url.includes("/instances?")) { @@ -1423,12 +1536,88 @@ test("workflows commands call encoded control endpoints", async () => { assert.equal(calls[6].url, "http://ctl.test/ns/demo%20space/workflows/api/orders/instances/order%2F1/terminate"); assert.equal(calls[6].init.method, "POST"); assert.deepEqual(calls[0].init.headers, { "x-admin-token": "tok" }); - assert.ok(lines.includes("api/orders\tbinding=ORDERS\tclass=OrderWorkflow\tactive=v2\tkey=wf_1234")); + assert.ok(lines.includes("api/orders\tbinding=ORDERS\tclass=OrderWorkflow\tactive=v2\tkey=wf_1234\tretired=no")); + assert.ok(lines.includes("api/legacy\tbinding=-\tclass=LegacyWorkflow\tactive=v2\tkey=wf_retired\tretired=yes")); assert.ok(lines.includes("Next cursor: 1")); assert.ok(lines.includes("steps=1")); assert.equal(lines.at(-1), "OK demo space/api/orders/order/1 terminate status=paused"); }); +test("workflow formatters escape control fields but preserve their own layout", () => { + const hostile = `${ESC}[2J\nFORGED\rBAD\tCOLUMN\u009b`; + const lines = [ + ...formatWorkflowList({ + workflows: [{ + worker: hostile, + name: hostile, + binding: hostile, + className: hostile, + activeVersion: hostile, + workflowKey: hostile, + retired: true, + }], + }), + ...formatInstanceList({ + instances: [{ id: hostile, status: hostile }], + cursor: hostile, + }), + ...formatInstanceStatus({ + id: hostile, + status: hostile, + output: { value: hostile }, + error: { message: hostile }, + steps: { + entries: [{ ordinal: 0, name: hostile, status: hostile }], + }, + }), + ]; + const out = lines.join("\n"); + + assertNoRawTerminalControls(out, "workflow formatter output"); + assert.ok(out.includes("\\u001b[2J\\nFORGED\\rBAD\\tCOLUMN\\u009b")); + assert.equal( + out.split("\t").length - 1, + 7, + "only formatter-owned column separators may remain as raw tabs" + ); +}); + +test("workflow lifecycle status lines escape control fields and preserve JSON", async () => { + const hostile = `${ESC}[2J\nFORGED\rBAD\tCOLUMN\u009b`; + const body = { id: `id-${hostile}`, status: `status-${hostile}` }; + const human = mockDeps(body); + + await runWorkflowsCommand([ + "pause", + "api", + "orders", + "instance", + "--ns", + "demo", + "--control-url", + "http://ctl.test", + ], human.deps); + + assert.equal(human.lines.length, 1); + assertNoRawTerminalControls(human.lines[0], "workflow lifecycle status"); + assert.ok(human.lines[0].includes("id-\\u001b[2J\\nFORGED\\rBAD\\tCOLUMN\\u009b")); + assert.equal(human.lines[0].includes("\t"), false, "status lines must not preserve raw tabs"); + + const json = mockDeps(body); + await runWorkflowsCommand([ + "pause", + "api", + "orders", + "instance", + "--ns", + "demo", + "--control-url", + "http://ctl.test", + "--json", + ], json.deps); + assert.deepEqual(JSON.parse(json.lines[0]), body); +}); + test("workflows list accepts flags before the subcommand", async () => { const { calls, lines, deps } = mockDeps({ workflows: [] });