From 25eaaf56cbbebb155634af01fd93dc556d6f763b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 09:02:10 +0000 Subject: [PATCH] =?UTF-8?q?fix(runtime):=20=E6=97=A0=20`setFallbackHandler?= =?UTF-8?q?`=20=E7=9A=84=E9=80=82=E9=85=8D=E5=99=A8=E6=94=B9=E4=BB=A5=20`w?= =?UTF-8?q?arn`=20=E5=AE=A3=E5=91=8A=E5=A3=B0=E6=98=8E=E5=BC=8F=E7=AB=AF?= =?UTF-8?q?=E7=82=B9=E4=B8=8D=E5=8F=AF=E8=BE=BE=20(#5400)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `setFallbackHandler` 是 metadata 声明的 `apis:` 端点抵达 handler 的**唯一**接缝, 且在 `IHttpServer` 上是可选成员。适配器不暴露它时,所有声明的端点永久不可服务, 调用方只拿到传输层的裸 404 —— 与拼错路径毫无区别。 此前这条只记 `debug`。该级别曾经是对的:在非空 `apis:` 于发布期被整体拒绝(#4936) 的年代,没有任何部署可能"缺"什么,因为没有任何部署能声明什么。#5040 E7 发布翻转 终结了这个前提 —— 声明现在能发布,stack 也确实在发。而默认 `level: 'info'` 根本 不打印 `debug`(`packages/core/src/logger.ts` 的 `isEnabled`),运维侧等于零信号, 正是 AGENTS.md「Absence must be loud」(Route & surface ownership §3)点名要避免的 「留一个裸 404 让人去诊断」。 因此升为 `warn`,并按该节要求让这一行同时带上两半: - **后果** —— 该传输上任何 metadata 声明的 `apis:` 端点都不会被服务,只会回裸 404; - **补救** —— 换用实现了 `setFallbackHandler` 的 HTTP 适配器 (如 `@objectstack/plugin-hono-server`)。 刻意是 `warn` 而非 `error`:按「Degradation log levels」那个唯一判据 —— 降级后系统 对外看着正常,而它声称已持久化的东西其实没落盘吗? —— 答案是否,这里没有任何持久化 承诺。属功能性降级(某能力没挂上,下一个调用它的人自然会发现),与参考文案 「scheduled flows will not run until a job service is registered」同形。 新增 `dispatcher-plugin.fallback-absence-warn.test.ts` 把级别焊死(参照 #5226 「级别由门禁焊住」姿态):级别退回 `debug` 或误升 `error` 都会红,并另钉一条反向 用例 —— 合规适配器上这条完全不出声,保证 warn 是信号而非启动噪音。 反向验证(方向先于执行预判,属常规 Red 方向,因断言读的是发出级别这一**谓词**, 而非可能"因为什么都没产出而通过"的计数): - 改回 `debug` → 1-4 号用例红(`expected 'debug' to be 'warn'`),5 号仍绿; - 升到 `error` → 同样 1-4 号红(`expected 'error' to be 'warn'`)。 仅运维可见:无 API / schema / 路由变更,已在合规适配器(默认 `@objectstack/plugin-hono-server`)上的部署看不到任何新增输出。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016FNvXhtSdnEGEfLEsMmvxh --- .../dispatcher-fallback-absence-warn.md | 34 ++++ ...tcher-plugin.fallback-absence-warn.test.ts | 168 ++++++++++++++++++ packages/runtime/src/dispatcher-plugin.ts | 47 +++-- 3 files changed, 236 insertions(+), 13 deletions(-) create mode 100644 .changeset/dispatcher-fallback-absence-warn.md create mode 100644 packages/runtime/src/dispatcher-plugin.fallback-absence-warn.test.ts diff --git a/.changeset/dispatcher-fallback-absence-warn.md b/.changeset/dispatcher-fallback-absence-warn.md new file mode 100644 index 0000000000..443450bf39 --- /dev/null +++ b/.changeset/dispatcher-fallback-absence-warn.md @@ -0,0 +1,34 @@ +--- +"@objectstack/runtime": patch +--- + +fix(runtime): an HTTP adapter without `setFallbackHandler` now warns that declarative endpoints are unreachable (#5400) + +`setFallbackHandler` is the ONE seam by which a metadata-declared `apis:` +endpoint reaches a handler, and it is optional on `IHttpServer`. On an adapter +that omits it, every declared endpoint is permanently unservable and the caller +gets the transport's bare 404 — indistinguishable from a typo. + +Until now the dispatcher announced that at `debug`, which the default +`level: 'info'` does not print at all, so operators had no signal whatsoever. +That level was correct only while a non-empty `apis:` was rejected wholesale at +publish (#4936): no deployment could be missing anything, because none could +declare anything. The #5040 E7 publish flip ended that premise — declarations +publish now and stacks ship them — so the line is raised to `warn` and carries +both halves AGENTS.md's "Absence must be loud" requires: + +- **consequence** — every metadata-declared `apis:` endpoint is UNREACHABLE on + this transport and will answer a bare 404; +- **remedy** — compose an HTTP adapter that implements `setFallbackHandler` + (e.g. `@objectstack/plugin-hono-server`). + +`warn` and deliberately not `error`: this is a functional degradation (a +capability is not mounted, and its next caller finds out), not a durability one +— nothing here claims to have persisted anything. The level is welded by +`packages/runtime/src/dispatcher-plugin.fallback-absence-warn.test.ts`, which +fails both on a slide back to `debug` and on escalation to `error`, and pins +that a conforming adapter stays silent. + +Operator-visible only: no API, schema or routing change. A deployment already on +a conforming adapter (the default `@objectstack/plugin-hono-server`) sees +nothing new. diff --git a/packages/runtime/src/dispatcher-plugin.fallback-absence-warn.test.ts b/packages/runtime/src/dispatcher-plugin.fallback-absence-warn.test.ts new file mode 100644 index 0000000000..8d84da97a2 --- /dev/null +++ b/packages/runtime/src/dispatcher-plugin.fallback-absence-warn.test.ts @@ -0,0 +1,168 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The declarative-endpoint seam's ABSENCE is announced at `warn` (#5400). + * + * ## Why a whole file exists to hold one log level + * + * `packages/runtime/src/dispatcher-plugin.ts` mounts declarative `apis:` + * endpoints through exactly one seam — `IHttpServer.setFallbackHandler`, which + * is OPTIONAL on the contract and feature-detected with `typeof === 'function'`. + * On an adapter that omits it there is no second path: every endpoint a stack + * declared in metadata is unreachable, forever, and the transport answers the + * bare 404 it would answer for a typo. + * + * That line used to be `debug`, and correctly so — while a non-empty `apis:` + * was rejected WHOLESALE at publish (#4936), no deployment could be missing + * anything, because no deployment could declare anything. The #5040 E7 publish + * flip (`packages/spec/src/api/endpoint-publish-gate.ts`, "This module is that + * flip") ended that premise: declarations publish now and stacks ship them. + * A `debug` under the default `level: 'info'` is not printed at all + * (`isEnabled`, `packages/core/src/logger.ts`), so the operator's signal for a + * silently-dead surface was nothing whatsoever — the exact outcome AGENTS.md's + * "Absence must be loud" (Route & surface ownership §3) forbids. + * + * ## The level is the assertion, so it is welded here + * + * A log level is one identifier away from silence and nothing else in the + * build notices it change. Following #5226's posture — "the level is held by a + * gate, not by the comment next to it" — these tests fail if the line slides + * back to `debug` (invisible again) AND if it is escalated to `error` (wrong + * class). `warn` is what AGENTS.md's "Degradation log levels" question yields: + * nothing here claims to have PERSISTED anything, so this is a functional + * degradation — a capability that is not mounted, whose next caller finds out — + * not a durability one. + * + * Reverse verification, direction predicted BEFORE running: restoring + * `ctx.logger.debug` in the seam-absent branch must turn the level pins RED + * (this is the ordinary direction — the pins read a predicate on the emitted + * level, not a count that can pass by producing nothing). Confirmed: with + * `debug` restored, `emitted at warn` and `never at debug` both fail on the + * captured line, and the consequence/remedy pins fail with "no warn line". + * + * Harness note: the fake server is the `dispatcher-plugin.routes.test.ts` + * shape. It is the honest one for this branch — the absent member is spelled + * by simply not being there, which is what the contract tells consumers to + * probe for, and a real adapter cannot express "I omit this" any better. + */ + +import { describe, it, expect } from 'vitest'; + +import { createDispatcherPlugin } from './dispatcher-plugin.js'; + +interface LogLine { level: string; message: string; meta?: Record } + +/** + * A server WITHOUT `setFallbackHandler` — the member simply absent, the shape + * the contract documents (`packages/spec/src/contracts/http-server.ts`: "an + * adapter that cannot express a not-found hook simply omits it"). + */ +function makeFallbacklessServer() { + const noop = () => { /* route registration is not what this file tests */ }; + return { + get: noop, post: noop, put: noop, delete: noop, patch: noop, + } as any; +} + +/** The same server, plus the seam — the control case. */ +function makeSeamedServer() { + const server = makeFallbacklessServer(); + server.setFallbackHandler = () => { /* installed, never invoked here */ }; + return server; +} + +function makeCtx(fakeServer: any) { + const logs: LogLine[] = []; + const kernel = { + getService: () => undefined, + getServiceAsync: async () => undefined, + }; + const ctx = { + getKernel: () => kernel, + getService: (name: string) => (name === 'http.server' ? fakeServer : undefined), + environmentId: undefined, + logger: { + info(message: string, meta?: any) { logs.push({ level: 'info', message, meta }); }, + warn(message: string, meta?: any) { logs.push({ level: 'warn', message, meta }); }, + error(message: string, meta?: any) { logs.push({ level: 'error', message, meta }); }, + debug(message: string, meta?: any) { logs.push({ level: 'debug', message, meta }); }, + }, + hook: () => {}, + on: () => {}, + } as any; + return { ctx, logs }; +} + +/** Every line that talks about the missing seam, at whatever level it came out. */ +const seamLines = (logs: LogLine[]) => logs.filter((l) => l.message.includes('setFallbackHandler')); + +async function bootWith(server: any) { + const { ctx, logs } = makeCtx(server); + const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false }); + await plugin.start?.(ctx); + return logs; +} + +describe('dispatcher declarative-endpoint seam — absence is loud (#5400)', () => { + it('announces the missing seam at `warn` — never `debug`, never `error`', async () => { + const logs = await bootWith(makeFallbacklessServer()); + + const lines = seamLines(logs); + // Said ONCE, at boot, not once per anything. + expect(lines).toHaveLength(1); + // The level IS the fix. `debug` is the pre-#5400 state and is invisible + // under the default `info`; `error` is the over-escalation AGENTS.md's + // durability question rules out (nothing here claims persistence). + expect(lines[0].level).toBe('warn'); + expect(logs.filter((l) => l.level === 'debug' && l.message.includes('setFallbackHandler'))).toEqual([]); + expect(logs.filter((l) => l.level === 'error')).toEqual([]); + }, 60_000); + + it('names the CONSEQUENCE: declared endpoints are unreachable and answer a bare 404', async () => { + const logs = await bootWith(makeFallbacklessServer()); + + const line = seamLines(logs).find((l) => l.level === 'warn'); + expect(line).toBeDefined(); + const msg = line!.message; + // What is lost: not "some routes", but every endpoint declared in + // metadata, on this transport. + expect(msg).toMatch(/metadata-declared/); + expect(msg).toMatch(/`apis:`/); + expect(msg).toMatch(/UNREACHABLE/); + // And what the caller sees instead — the bare 404 that reads like a + // typo and sends operators hunting in the wrong place. + expect(msg).toMatch(/bare 404/); + }, 60_000); + + it('names the REMEDY: compose an adapter that implements the seam', async () => { + const logs = await bootWith(makeFallbacklessServer()); + + const msg = seamLines(logs).find((l) => l.level === 'warn')!.message; + // The member to implement... + expect(msg).toMatch(/setFallbackHandler/); + // ...and a concrete adapter that already does, so the remedy is + // actionable without reading the contract first. + expect(msg).toMatch(/@objectstack\/plugin-hono-server/); + }, 60_000); + + it('carries the affected mount prefix as structured meta', async () => { + const logs = await bootWith(makeFallbacklessServer()); + + const line = seamLines(logs).find((l) => l.level === 'warn')!; + expect(line.meta).toMatchObject({ + mount: '/api/v1/apps/', + declarativeEndpoints: 'unreachable', + }); + }, 60_000); + + it('stays SILENT when the adapter does expose the seam', async () => { + const logs = await bootWith(makeSeamedServer()); + + // The counter-case that keeps the warn a signal instead of boot noise: + // on a conforming adapter nothing is missing, so nothing is announced. + expect(seamLines(logs)).toEqual([]); + // And the positive line is the one that gets printed instead. + expect(logs.some((l) => l.level === 'info' && l.message.includes('Declarative endpoint dispatch step armed'))) + .toBe(true); + }, 60_000); +}); diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index abf8aa47e1..b0eb3955d9 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -1432,19 +1432,40 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu executes: true, }); } else { - // Still `debug`, and that is now UNDER-STATED — tracked by - // #5400, deliberately not changed here (#5399 is comment-only). - // The reason this was debug has expired: it read "no stack can - // declare an endpoint yet", which stopped being true at the - // #5040 E7 publish flip. Declarations exist now, so on an - // adapter without this seam they are silently unservable and - // the operator's only signal is a line that the default `info` - // level does not even print. This is the "absence must be loud" - // case (AGENTS.md, Route & surface ownership §3); #5400 raises - // it to `warn` carrying the consequence and the remedy. - ctx.logger.debug( - '[dispatcher] http.server exposes no `setFallbackHandler`; declarative endpoints ' - + 'would be unreachable on this transport.', + // ── Absence must be loud (AGENTS.md, Route & surface ownership + // §3) ──────────────────────────────────────────────────────── + // `warn` since #5400. This was `debug`, on a reason that has + // since expired: it read "no stack can declare an endpoint yet", + // true only while a non-empty `apis:` was rejected WHOLESALE at + // publish. The #5040 E7 flip ended that — declarations publish + // now and stacks ship them — so on an adapter without this seam + // they are legitimately, permanently unservable. At `debug` that + // was not a signal at all: the default `level: 'info'` does not + // print it (`isEnabled`, `packages/core/src/logger.ts`), leaving + // a bare 404 as the only evidence — precisely the "leave a bare + // 404 to be diagnosed" outcome the rule names. + // + // `warn`, NOT `error`, deliberately. AGENTS.md's "Degradation log + // levels" question — does the system look normal from outside + // while something it claims is PERSISTED did not land? — answers + // no: nothing here claims durability. This is a functional + // degradation (a capability is not mounted, and the next caller + // of it finds out), the same shape as the reference text + // "scheduled flows will not run until a job service is + // registered". `dispatcher-plugin.fallback-absence-warn.test.ts` + // welds the level so a quiet slide back to `debug` fails. + // + // The line owes both halves the rule demands, and carries them + // in the first (and only) thing it prints: + // consequence — every metadata-declared `apis:` endpoint is + // unreachable on this transport, answering a bare 404; + // remedy — compose an adapter that implements the seam. + ctx.logger.warn( + '[dispatcher] http.server exposes no `setFallbackHandler`: every metadata-declared ' + + '`apis:` endpoint is UNREACHABLE on this transport and will answer a bare 404. ' + + 'Fix: compose an HTTP adapter that implements `setFallbackHandler` ' + + '(e.g. `@objectstack/plugin-hono-server`).', + { mount: appEndpointMountPrefix(prefix), declarativeEndpoints: 'unreachable' }, ); }