Skip to content

fix(cli): os package publish prints the server's reason, not [object Object] - #10940

Merged
os-elon merged 1 commit into
mainfrom
claude/issue-10763-publish-error-envelope
Aug 21, 2026
Merged

fix(cli): os package publish prints the server's reason, not [object Object]#10940
os-elon merged 1 commit into
mainfrom
claude/issue-10763-publish-error-envelope

Conversation

@os-elon

Copy link
Copy Markdown
Collaborator

Fixes#10763

The defect, re-derived at head

Both request helpers in packages/cli/src/commands/package/publish.ts built their failure text the same way — at :646 (postJson) and :691 (postBinary), the anchors the card gives, which still land exactly there on eee2b65018:

consterrMsg=parsed?.error??response.statusText??`HTTP ${response.status}`;return{ok: false,status: response.status,body: parsed,error: String(errMsg)};

In the declared envelope (packages/types/src/response-envelope.ts) error is an object{ code, message } — so String(errMsg) stringified the object. The ?? chain never reached statusText, because an object is not nullish; there was no useful fallback left to reach. Every failed publish printed the same literal regardless of what the control plane had refused, at all three call sites: package registration, version publish, and the icon upload.

The premise-first measurement, and what it decided

Triage made this a dispatch condition: measure the publish routes' real failure envelope(s) before picking the read shape, and do not assume either arm. What was measured:

  • The three routes are POST /api/v1/cloud/packages, POST /api/v1/cloud/packages/:id/icon, and POST /api/v1/cloud/packages/:id/versions — all /api/v1/cloud/**.

  • That family is served by the sibling cloud repo, and this repo's dispatcher explicitly refuses those paths, so no in-repo ledger can vouch for them — stated in docs/audits/2026-07-dispatcher-client-route-coverage.md §10. Two direct measurements were attempted and both are genuinely unavailable from an agent session: the cloud repo is not readable, and the live host is refused by egress policy. Neither is a substitute for evidence, so neither was treated as one.

  • The evidence that does exist is first-hand and from the same service-cloud family: readApiError in objectui, packages/app-shell/src/console/marketplace/marketplaceApi.ts. Its docblock records that those routes answer failures in two shapes, mid-conversion from one to the other, tracked as cloud#944:

    • { success: false, error: 'a sentence' } — today, via the cloud's fail() helper
    • { success: false, error: { code, message } } — the declared envelope

Both arms are live. That is the finding, and it decides the fork the card left open.

Why the strict #10675 reader is not reused

packages/cli/src/utils/response-envelope.ts's readEnvelope is deliberately strict, and its docblock is explicit that it does not also accept the legacy flat shape, because a consumer-side fallback would re-create the divergence as a second de-facto contract (Prime Directive #12). That reasoning is sound for the routes it was measured against — the in-repo /api/v1/datasources/** family, where this repo owns the producer and can fix it.

It does not transfer here. Against the dialect the control plane still emits today, a strict envelope-only read would print "not the declared envelope" in place of the server's actual sentence — trading one unreadable failure for another, on what is currently the common path. The ablation below shows this directly: the flat-dialect case is the one case that already passed before the fix.

The strictness difference follows the job, not the file:

  • readEnvelope decides whether a request succeeded and yields its payload. Tolerance there lets an off-spec body be read as data — that is how a second contract grows, and why it refuses.
  • readErrorMessage decides nothing. The caller has already seen !response.ok. The failure is reported either way; the only question is whether the operator sees the server's sentence or a placeholder.

Both now live in the same file, with the distinction written out, so the next author finds one home for "reading the platform's response envelope" rather than two files inviting a copy.

The change

Both sites now call a new readErrorMessage: the declared envelope's error.message, degrading to error.code when a refusal carries no message, then a non-blank statusText, then the status line. Every branch yields a checked non-empty string, so no input can produce the stringified-object output again.

A blank statusText counts as absent, which is the second half of "there is no useful fallback": HTTP/2 carries no reason phrase, so fetch reports '', and the old ?? chain kept the empty string and printed nothing after the status code. The tests supply statusText: '' exactly as an HTTP/2 response would, so no case can pass on a reason phrase the real transport would not send.

The flat branch is written to be deletable on its own and its removal trigger is recorded on the function and filed as #10938.

Deliberately not accepted: a top-level body.message. objectui tolerates one because a few other routes in that family put text there; no publish route was measured doing it, and inventing a third dialect to read is the accretion #12 forbids.

The two siblings are deliberately untouched

The card notes that one package carries three readings of one envelope, and asked that siblings be touched only with per-site evidence that they are also wrong. Measured per site, they are not:

  • plugin/publish.ts:179 — hits the same two cloud routes. Reads parsed?.error?.message ?? parsed?.error ?? response.statusText, which is correct on both measured arms. Its only weakness is on an unmeasured malformed body (an error object carrying no message), and inventing that to justify an edit would be exactly the harmonising the card forbids.
  • package/install.ts:275 — targets the runtime's/api/v1/marketplace/install-local, a different route family that cloud#944 does not govern. It is also already immune to this defect class: its typeof errMsg === 'string' ? errMsg : JSON.stringify(errMsg) guard means a non-string never becomes the stringified object.

So the three readings are not one bug in three places, and they are not consolidated on the assumption that they should be one. #10938 records the conditions under which they converge.

Tests

New end-to-end coverage drives the real command — the defect was at the call site, in the value handed to printError — across all three sites, plus unit coverage for the reader. packages/cli suite: 149 files / 1650 tests passed; the two touched files verbosely: 18 passed.

Reverse verification, with the direction predicted before running: reverting only the two call sites turns it red, reproducing the card's symptom verbatim —

✗ Register package failed (422): [object Object]
✗ Publish version failed (422): [object Object]
✗ Icon upload failed (413): [object Object]

4 failed | 1 passed. The one that stayed green is the flat-fail() case, which is the measurement confirming itself from the other side: the old code already handled that arm, and a strict reader would have broken it. Restoring the fix returns 18/18. The ablation subject resolves from source (the test imports relatively into src/), not through a dependency's dist/, so no rebuild gates either leg; the fixture writer sendError does resolve through @objectstack/types' dist/, and that was built by the dependency-closure build below.

Gates

Dependency closure built first (pnpm --filter '@objectstack/cli^...' build, VERDICT command-exit 0), then pnpm --filter @objectstack/cli typecheck (tsc --noEmit, VERDICT command-exit 0).

The gate union was derived at the final commit — node scripts/pm/dispatch-gates.mjs with no paths, letting it take its own change set from the merge base — and re-run there. All green at 1016233cb7, exit codes captured before any pipe:

check:changeset-gate-self-tests · check:objectui-changeset · check-empty-changeset · check-changeset-no-major · check-adr-0087-registration · check:cross-package-test-inputs · check:test-source-alias · check:type-source-resolution · check:slot-lookup · check:type-check-coverage · check:engine-double-contract · check:where-matcher · check:query-options-erasure · check-plugin-teardown-shape · check-ci-filter-parity · check:nul-bytes · docs-audit/check-affected-docs

Quoting the gates' own verdict lines rather than a bare exit status:

check-nul-bytes: OK (scanned 6313 text file(s) -- 6313 tracked, 0 untracked-not-ignored; skipped 5 binary; no raw ASCII control bytes).
OK: 13 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.
check-engine-double-contract: OK — 376 pinned, 133 in the DEBT ledger, 2 exempt.
✓ where-matcher conformance holds: 275 matcher(s) discovered ... none new.
check-type-check-coverage: OK — 64/77 workspace packages type-checked (plus the root) ...

One declared narrowing:check:type-check-debt --re-measure was not run locally — it needs the whole workspace closure built. It has no count this change can move: packages/cli carries no test-typecheck-debt.json entry, its tests are inside the tsc program its own typecheck runs (which compiled the new files and passed), and the structural half check:type-check-coverage is green above. CI runs the full ratchet.

Scope

Fixes #10763 because the card's subject — the read in package/publish.ts — is fully addressed. Out-of-scope finding filed as #10938 (the scheduled retirement of the flat accommodation once cloud#944 lands), unassigned and labelled finding. #9559 covers the same defect class for packages/rest's own flat dialect, a different in-repo producer; it is not addressed here and remains open. Nothing under content/docs/releases/** was touched, and no gate was weakened.

Clause-② is no: this changes only how the CLI reads a failure response. The server sends exactly what it sent before, and no request the CLI sends changes.


Generated by Claude Code

…Object]
Both request helpers in package/publish.ts built their failure text with
String(parsed?.error ?? response.statusText ?? `HTTP ${status}`). In the
declared envelope `error` is an OBJECT — { code, message } — so String()
stringified it, and the ?? chain never reached statusText because an object is
not nullish. All three call sites (package registration, version publish, icon
upload) printed the same literal regardless of what the control plane refused.
Both sites now read through a new readErrorMessage in
packages/cli/src/utils/response-envelope.ts: the declared envelope's
error.message, degrading to error.code, then a non-blank statusText, then the
status line. A blank statusText counts as absent — HTTP/2 carries no reason
phrase, and the old ?? chain kept the empty string.
The reader also accepts the flat `error: '<sentence>'` dialect. That is
measured, not assumed: /api/v1/cloud/** is served by the sibling cloud repo,
and objectui's readApiError records that the same service-cloud family answers
failures in both shapes while cloud#944 converts it. A strict envelope-only
read (#10675's readEnvelope, measured against the in-repo
/api/v1/datasources/** routes) would have replaced today's live flat dialect
with a different unreadable failure, so it is not reused here.
The two sibling readings are deliberately untouched: plugin/publish.ts already
reads correctly on both measured arms, and package/install.ts targets the
runtime's /api/v1/marketplace/install-local, a different route family.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 7 documentable anchor(s).

9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx(via os package publish (command))
  • content/docs/deployment/cli.mdx(via os package publish (command))
  • content/docs/deployment/index.mdx(via os package publish (command))
  • content/docs/deployment/publish-and-preview.mdx(via os package publish (command))
  • content/docs/protocol/kernel/index.mdx(via os package publish (command))
  • content/docs/protocol/kernel/lifecycle.mdx(via os package publish (command))
  • content/docs/protocol/kernel/metadata-service.mdx(via os package publish (command))
  • content/docs/protocol/objectql/index.mdx(via os package publish (command))
  • content/docs/protocol/objectql/schema.mdx(via os package publish (command))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v9.mdx(via os package publish (command))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 9d101d28424fcdb067277709bbbb9097a3bc2fd9packageMentionDocs.

Which tree this was computed on

This run read content/docs from 041fa3c5262059b001e047efaf53e695902c9d25 — the merge of head 1016233cb77cd8afe5ba256d1be328cf63c20a32 into base 9d101d28424fcdb067277709bbbb9097a3bc2fd9, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 041fa3c5262059b001e047efaf53e695902c9d25 && git checkout 041fa3c5262059b001e047efaf53e695902c9d25
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9d101d28424fcdb067277709bbbb9097a3bc2fd9 1016233cb77cd8afe5ba256d1be328cf63c20a32 && git checkout -B drift-repro 9d101d28424fcdb067277709bbbb9097a3bc2fd9 && git merge --no-ff 1016233cb77cd8afe5ba256d1be328cf63c20a32
node scripts/docs-audit/affected-docs.mjs --json 9d101d28424fcdb067277709bbbb9097a3bc2fd9

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 9d101d28424fcdb067277709bbbb9097a3bc2fd9 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 21, 2026
@os-elon
os-elon marked this pull request as ready for review August 21, 2026 20:13
@os-elon
os-elon added this pull request to the merge queueAug 21, 2026
Merged via the queue into main with commit c2b97c2Aug 21, 2026
32 checks passed
@os-elon
os-elon deleted the claude/issue-10763-publish-error-envelope branch August 21, 2026 20:22
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 32522541067 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Console Pin Gate — 失败步骤: Build the Console SPA at the pinned objectui SHA

    ✗ Build failed in 6.21s
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

跨 PR 相同签名(24h,按失败测试文件聚合):

  • ⚠️本次没有可用的聚合签名(日志里没有能解析出测试文件名的 FAIL 行)—— 这不是「没有同签名的其他 PR」,是这一轮没测到。跨 PR 聚合本次不可用,请手工比对其他 PR 的同类评论。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 47 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

os package publish renders a failed publish as [object Object] — it reads the flat body.error, not error.message

2 participants

@os-elon@claude