Skip to content

feat(runtime): GET /packages 与 GET /packages/:id 每行携带服务端自己的可写判定 writable(isWritablePackage),Studio 不再用 scope 启发式反推 #14375

Closed

Description

@hotlong

Part of #14122 · ADR-0130 Consequences 表第 6 行(Studio 按包分组)的服务端半边
Blocks: objectstack-ai/objectui#7177

业务需求

多包物(ADR-0130,#14240 装载路径 + #14354 安装闸已落地)把一个产品拆成 type: app + 若干 type: module 子包。Studio 的包选择器要正确显示每个子包能不能在里面写元数据——这是 Studio 决定给作者哪一套控件(可写 / 🔒 只读 + "duplicate into a writable base")的唯一依据。今天这个判定在客户端用 scope 反推,而服务端的权威规则根本不看 scope 的这一维,两边在多包物上必然分叉。

实测:两条规则不是同一条

服务端权威——isWritablePackage(engine, id)(packages/metadata-protocol/src/package-writability.ts:73,ADR-0070 D2;requireWritablePackage 与 SysMetadataRepository.assertAllowed 都引用它,#8146 维护者裁定"一个可写答案、徽标说真话"的落点):

if (e?.manifests?.has?.(packageId)) return false;          // ① 启动时经 registerApp 注册的代码包 → 只读(不看 scope)
const scope = e?.registry?.getPackage?.(packageId)?.manifest?.scope;
if (typeof scope === 'string' && READ_ONLY_PACKAGE_SCOPES.includes(scope)) return false; // ② system / cloud → 只读
return true;                                              // ③ 其余(project 或缺省 scope 的 DB base)→ 可写

客户端启发式——objectui packages/app-shell/src/views/studio-design/packages-io.ts:38-42:

const scope = typeof m.scope === 'string' ? m.scope : '';
if (scope === 'system' || scope === 'cloud') continue;
out.push({ id, name, writable: scope !== 'project', namespace });

分叉点在 ①:服务端判"代码包"的信号是 engine.manifests(ObjectQL.registerApp → engine.ts:4784 this.manifests.set(id, manifest);#14240 的装载路径对物内每个子包都调 registerApp,所以每个子包都进这张表),客户端没有这张表,只能拿 scope 猜。

链条(每环已对 origin/main 核实):

环 事实 位置
a ManifestSchema.scope 默认 'project' 只在 parse 时施加 spec/src/kernel/manifest.zod.ts:263
b 装载路径刻意把原始 body 交给 registerApp(D7 逐位相同) objectql/src/artifact-packages.ts 模块头
c installPackage 原样存 InstalledPackage.manifest objectql/src/registry.ts
d GET /packages 直接返回 registry.getAllPackages(),只有 ?status= / ?type= 过滤,没有可写判定 runtime/src/domains/packages.ts:269-277
e 一个省略 scope 键的 module 子包(常态——作者依赖默认值)→ 客户端 scope='' → writable: true;服务端 ① → 只读 —

结果:Studio 给一个服务端必拒的包画上"可写"徽标,作者写到 WRITABLE_PACKAGE_REQUIRED 422 才知道。这正是 #8146 裁定要消灭的形状("两面回答同一个问题不一致,其中一面在说谎"),方向相反而已。

⛔ 客户端不能自己修。 直觉修法"缺省 scope 视为 project → 只读"会把 Studio 自建的 DB base 一并翻成只读:它们同样缺省 scope(服务端 ③ 判可写),客户端靠 scope === '' 才把它们显示为可写。区分二者的唯一信号 engine.manifests 只在服务端。所以修法落在平台。

方案

GET /packages 与 GET /packages/:id 的每个包记录附加一个字段:

writable: isWritablePackage(qlService, pkg.manifest?.id ?? pkg.id)
  • 用同一个导出函数,不重拼规则(与 requireWritablePackage 同源,packages.ts:184 已 import)。
  • 纯加法:在响应组装处 spread 一份带 writable 的副本,⛔ 不改写 registry 里的 InstalledPackage 对象,⛔ 不改 isWritablePackage 自身,⛔ 不动 READ_ONLY_PACKAGE_SCOPES。
  • engine 参数就是现有路由已解析的 qlService(与 requireWritablePackage 的传法一致)。

Pins(runtime 域测试,四态一个不少)

  1. 经 registerApp 注册、scope: 'project' 的代码包 → writable: false
  2. 经 registerApp 注册、省略 scope 的代码包(模拟多包物 module 子包)→ writable: false ← 本卡的正题
  3. scope: 'system' / 'cloud' → writable: false
  4. 经 installPackage(不经 registerApp)、省略 scope 的 DB base → writable: true ← 保护 Studio 自建 base
  5. 负向:writable 之外,每行其余字节与今日响应逐位相同(不改 manifest、status、installedAt 等既有键)
  6. 消融:把 ① 那一行的 manifests.has 改成恒 false,pin 2 必须红(证明 writable 真的读了服务端的表,不是又一份 scope 启发式)

边界

  • ⛔ 不动 isWritablePackage 的语义;有争议走 ADR-0070。
  • ⛔ 不动消费者市场面(ADR-0019 D2/D3)。
  • ⛔ 不在本卡改 objectui;objectui#7177 消费这个字段(有则用,无则回落今日启发式以兼容旧服务端)。
  • changeset:@objectstack/runtime: patch(响应加法)。Clause-② 预期 NO:接受/拒绝面不动,只多一个只读字段;若 route ledger / 响应形状快照有门,按门的要求更新。

验收

Activity

  1. self-assigned this
    on Sep 2, 2026
  2. hotlong commented on Sep 2, 2026

    @hotlong
    ContributorAuthor

    认领


    Generated by Claude Code

  3. added
    domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they land
    on Sep 2, 2026
  4. hotlong commented on Sep 2, 2026

    @hotlong
    ContributorAuthor

    实施中:文件面扩到 metadata-protocol 的生产者——原因

    派发的 dev agent 因账户会话额度触顶(05:40 UTC 重置)起步即失败,本卡由 PM 会话(session_01UHvF5hyiZjnCyExFnfQB8m)直接实现,工作树 objectstack-14375,分支不变。

    读实现时发现卡面只写了 runtime 的 handlePackagesRequest 是不够的:GET /api/v1/packages 有两条注册路(packages/client/src/index.ts:1736-1744 早有记录),Studio 走的 REST 门不读 registry:

    门 行的来源 修法
    runtime dispatcher handlePackagesRequest registry.getAllPackages() / getPackage(id) 卡面原计划:withWritableVerdict(qlService, row),list + detail
    REST packages/rest/src/package-routes.ts:664/805 options.protocol.getMetaItems({ type: 'package' }) 的 item,再把 PackageService.list() 的持久行摊在其上 REST 不依赖 metadata-protocol 运行时(刻意,package-routes.ts:41),不能在 REST 里算 → 在生产者 ObjectStackProtocolImplementation.getMetaItems 的 package 分支给 item 加 writable: this.isWritablePackage(id)(protocol.ts:12657 已有的同一谓词封装)

    两处都是 spread 副本,registry 记录不动、不落库。REST 侧只加一条 pin:持久行摊在 registry item 上不能抹掉 writable(摊的顺序是 REST 唯一可能丢字段的地方);持久-only 行(本进程未注册)不带裁定——registry item 是唯一载体,写进 changeset。REST detail 门 DB-first 的行同样不带(改它要动 #11376 钉死的读拒绝顺序,不在本卡)。

    changeset:@objectstack/runtime: patch + @objectstack/metadata-protocol: patch。


    Generated by Claude Code

  5. hotlong commented on Sep 2, 2026

    @hotlong
    ContributorAuthor

    真实启动取证结果(PR #14430 正文已同步)

    PR 已 ready,随 PASS 自审评论 arm auto-merge。


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they land

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions