Skip to content

serve.ts 的 ADR-0093 D5 catch 把 OrganizationsPlugin 抛出的任何错误都改写成「包加载不出来」,并给出会误导的 OS_ALLOW_DEGRADED_TENANCY 解法 #4818

Closed

Description

@xuyushun441-sys

发现于 cloud 侧实现 objectstack-ai/cloud#1020(多组织许可闸门归位到插件自身)。不改本条不阻塞那边——cloud 已用「在 app config 里更早拒绝」绕开——但 framework 这一侧的守卫确实把两种不同的失败合并成了一种,属于 ADR-0049「声明与执行不一致」那一类。

现象

packages/cli/src/commands/serve.ts(当前 main,约 1745-1810 行):

const tenancyPosture = resolveTenancyPosture();
const multiTenant = tenancyPosture !== 'single';
if (multiTenant) {
  try {
    const mod: any = await importFromHost('@objectstack/organizations');
    await kernel.use(new mod.OrganizationsPlugin());   // <- 也在 try 里
    trackPlugin('Organizations');
  } catch (orgErr) {
    if (!resolveAllowDegradedTenancy()) {
      console.error(chalk.red(
        `\n  ✖ FATAL: tenancy posture '${tenancyPosture}' was requested but ` +
        '@objectstack/organizations could not be loaded,\n' + ...
        '      • set OS_ALLOW_DEGRADED_TENANCY=1 to boot in an explicitly degraded single-org state.\n\n'
      ));
      process.exit(1);
    }
    console.warn(chalk.yellow('  ⚠ DEGRADED TENANCY (OS_ALLOW_DEGRADED_TENANCY=1) ...'));
  }
}

这个 catch 同时覆盖 importFromHost(...) 和 kernel.use(new mod.OrganizationsPlugin())。所以插件在构造/挂载阶段抛出的任何错误——不只是模块解析失败——都会:

  1. 被打印成 「@objectstack/organizations could not be loaded」,即「包缺席」;
  2. 附带 「set OS_ALLOW_DEGRADED_TENANCY=1」 作为一条出路;
  3. 在该 env 已设时,被降级为一条 warning 并继续启动。

为什么这是个问题

cloud 的多组织运行时(私有包 @objectstack/organizations)现在会在构造时对未授权抛错(企业许可闸门)。这与「包缺席」是两件事,解法完全不同:

包缺席 未授权
事实 运行时不在这台机器上 运行时就在镜像里,但这个部署没被授权用它
解法 装上它 / 改单组织 / 显式接受降级 拿一张许可 / 改单组织
OS_ALLOW_DEGRADED_TENANCY=1 该不该管用 该(operator 明确接受能力缺席) 不该(那是授权问题,共享逃生口等于把许可闸门搬到一个 env 变量上)

合并之后有两个具体后果:

  • 运维排错跑偏:包明明在镜像里,日志却说它「加载不出来」,第一反应是去查模块解析 / NODE_PATH / prune,而真因是授权。
  • 逃生口串味:OS_ALLOW_DEGRADED_TENANCY=1 会把「未授权」也吞成 warning 继续启动。它的语义只应是「包加载不出来但我接受降级」。

建议

只改错误分类,不动 D5 的态度(要求了隔离就不能假装有,仍然拒绝启动):

  1. 把 import 与 kernel.use(...) 拆成两段 try。前者失败 = 包缺席,走现有 D5 文案与 OS_ALLOW_DEGRADED_TENANCY 逃生口;后者失败 = 插件自己拒绝,属于另一类。
  2. 对插件抛出的、带结构化标识的错误原样上报并无条件退出,不套用「could not be loaded」文案,也不认 OS_ALLOW_DEGRADED_TENANCY。判定建议用结构化字段而非 instanceof——该包是 importFromHost 动态加载的,CLI 与它可能持有不同模块实例。cloud 侧已经导出了这种形状(err.code === 'MULTI_ORG_NOT_LICENSED',以及一个 isMultiOrgLicenseError(err) 谓词),framework 只需一个通用判据即可,例如「错误自带 code 且插件包导出了对应谓词」,或更中性的「插件抛出的错误一律原样上报,只有 import 阶段的失败才归入 D5」。后者更简单,也不需要 framework 知道任何 cloud 私有语义——推荐这一条。

兼容性

第 2 条只影响「插件存在但挂载失败」这条路径,今天它本来就走 D5 退出(除非设了 degraded)。变化是:文案变准确,且 degraded 逃生口不再能吞掉插件自己的拒绝。包真缺席的路径行为完全不变。

关联

  • objectstack-ai/cloud#1020(许可闸门归位插件;cloud 侧已合的规避是在 app config 里、插件挂载之前先拒绝)
  • ADR-0093 D5(降级即拒绝启动)、ADR-0049(声明与执行不一致)、ADR-0022(离线许可 / license is deterrence, not DRM)

Activity

  1. added
    pm:queueReady for the PM dispatch loop
    bugSomething isn't working
    domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they land
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Aug 3, 2026
  2. xuyushun441-sys commented on Aug 3, 2026

    @xuyushun441-sys
    CollaboratorAuthor

    认领:PM 循环第 10 轮
    会话:session_018iARDqtrhQgz6fVHDeDkbQ
    分支:claude/issue-4818-split-organizations-import-from-mount
    Worktree:objectstack-issue-4818

    车道:domain:cli —— 维护者已在发版优先期批准本会话在 domain:devx 之外加认领该域,登记 #4604。
    文件面声明(供其他 PM 的全局在飞检查):packages/cli/src/commands/serve.ts + 其旁测试。

    全局在飞检查:已列全仓 pm:dispatched,无一触碰 serve.ts。主 PM 在飞的 #4700(@objectstack/verify 裸 import)、#4829(rest filterAppForUser)按认领协议归其收尾,与本单文件面不相交。

    前提复核(对最新 main):缺陷仍在,逐字如 issue 所述 —— serve.ts:1760 的 importFromHost(organizationsPkg) 与 :1761 的 kernel.use(new mod.OrganizationsPlugin()) 在同一个 try 内,catch (orgErr) 在 :1763,D5 文案与 OS_ALLOW_DEGRADED_TENANCY 逃生口在 :1789。

    为什么排在本域首位:发版后运维第一眼看到的就是启动错误。一条误导性的错误信息比没有错误更贵 —— 它把人指向模块解析 / NODE_PATH / prune,而真因是授权;更糟的是 OS_ALLOW_DEGRADED_TENANCY=1 会把「插件拒绝挂载」也吞成 warning 继续启动,等于把一道许可闸门搬到一个 env 变量上。

    方案取 issue 推荐的那一条(中性判据):import 阶段失败 → 归 D5(现有文案 + 逃生口不变);插件构造/挂载阶段抛出的错误一律原样上报并无条件退出,不套「could not be loaded」文案、不认 OS_ALLOW_DEGRADED_TENANCY。不要引入 err.code === 'MULTI_ORG_NOT_LICENSED' 或 isMultiOrgLicenseError 这类判据 —— framework 不该知道任何 cloud 私有语义,而且该包是 importFromHost 动态加载的,CLI 与它可能持有不同模块实例,instanceof 与具名 code 都是脆的。按阶段分类是唯一不需要 framework 了解插件内部的切法。

    D5 的态度不变:要求了隔离就不能假装有,仍然拒绝启动。


    Generated by Claude Code

  3. xuyushun441-sys commented on Aug 3, 2026

    @xuyushun441-sys
    CollaboratorAuthor

    审核通过 — PR #4858 已取消草稿并挂上 auto-merge(SQUASH)

    三条要害都核过了实物,不是只读 PR 描述:

    判据轴 = 阶段,不是错误形状。 我在 diff 里搜了 cloud 私有谓词:没有 MULTI_ORG_NOT_LICENSED,没有 isMultiOrgLicenseError。三处 instanceof 都是良性的 —— 两处是 Error 收窄以取 message,一处在解释性注释里,另有一处是改动前就在的 driver 代码。这一点是本 issue 最容易做错的地方:按错误的 code/类型分类看起来更"精确",实则要求 framework 编码 cloud 的私有语义,而且该包是 importFromHost 动态加载的,CLI 与插件可能持有不同模块实例,instanceof 和具名 code 都靠不住。"哪个阶段抛的"是唯一不需要 framework 了解插件内部的切法,这条推理已写进 serve.ts 的注释里留给下一个人。

    包缺席那条路分毫未动。 同一条 ADR-0093 D5 文案、同一个 OS_ALLOW_DEGRADED_TENANCY=1 逃生口,而且用例 2 把这个行为钉住了 —— 不是"我没改",是"改了就会红"。

    测试是有承重的。 反向验证(把 serve.ts stash 回改前)让挂载失败的两个用例失败、import 失败的两个用例通过。只观察到绿的测试和一个什么都断言不到的测试,从外部无法区分 —— 这条要求本会话已经吃过教训(#4690、#4804),不再接受单向证明。

    落点纯净:3 个文件,全在 packages/cli/** 与 .changeset/,零文件越界到其他 domain,未触碰 content/docs/releases/。

    CI 18 项无红,auto-merge 已挂;进队合入后本 issue 自动关闭。


    Generated by Claude Code

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

Metadata

Metadata

Labels

bugSomething isn't workingdomain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they landpm:dispatchedDispatched to a dev agent by /pm-dispatchpm:queueReady for the PM dispatch loop

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions