实施 #5412 (拆开 os doctor 的 ledger 读取 catch)时核验发现;未在该 PR 里改 —— #5412 的判定面被 PM 限定为 packages/cli/src/commands/doctor.ts,本条在 packages/cloud-connection,且要动 list() 的返回契约(跨包)。
事实
packages/cloud-connection/src/local-manifest-source.ts(origin/main b4872a868),LocalManifestSource.list():
/** Every valid entry in the ledger (corrupt files are skipped). */
list(): InstalledManifestEntry[] {
if (!existsSync(this.dir)) return [];
const out: InstalledManifestEntry[] = [];
for (const name of readdirSync(this.dir)) {
if (!name.endsWith('.json')) continue;
try {
const raw = readFileSync(join(this.dir, name), 'utf8');
out.push(JSON.parse(raw));
} catch { /* skip corrupt files */ }
}
return out;
}
每个文件一个不带绑定的 catch。一个截断 / 不可读 / JSON 解析失败的 manifest 被就地丢掉,list() 成功返回 一个短列表 —— 调用方无法 把它和一个完整列表区分开:没有返回值上的差别,没有日志,没有计数。抛出的对象在 catch 处就被丢弃了。
实测(worktree 里跑真实实现,一好一坏两个条目):
A) truncated-entry list() DID NOT THROW; returned 1 entries: [ 'good' ]
后果(两个消费者,都是静默的错答案)
packages/cloud-connection/src/marketplace-install-local-plugin.ts:1279 readAll = () => this.ledger.list(),两处调用:
rehydrate()(:212) —— boot 时把每个已缓存 manifest 重新注册进 kernel。条目损坏 = 那个已装应用不被注册 ,即从这个 runtime 里消失(app switcher 里没有、对象不存在),而日志里一个字都没有。函数自己对「拿不到 manifest service」是会 warn 的,唯独对「少读了几个条目」不会。
handleList()(:702) —— 控制台「已安装应用」列表。损坏条目直接不在返回的 items 里,前端得到的是一份看起来完整的清单。
第三个消费者是 os doctor 的 ADR-0120 D5e unique-scope 建议(os doctor 的 installed-package ledger 读取 catch 把「损坏」和「没装」当成同一件事 —— D5e 建议因此静默少报,并打出「clean」 #5412 )。os doctor 的 installed-package ledger 读取 catch 把「损坏」和「没装」当成同一件事 —— D5e 建议因此静默少报,并打出「clean」 #5412 修掉了目录级 读取失败(✓ Unique scope 不再在 ledger 不可读时打印),但条目级 损坏够不着那个 catch —— 因为它在这里就被吸收了。os doctor 的 installed-package ledger 读取 catch 把「损坏」和「没装」当成同一件事 —— D5e 建议因此静默少报,并打出「clean」 #5412 的 PR 用一条 ⚠ SCOPE BOUNDARY 测试把这个边界钉住了(doctor-ledger-read-failure.test.ts),本 issue 修好后那条测试会转红,这是预期的。
也属于 #4801 / cloud#1020「诊断面与运行时不一致」和 #5403 / #5412 「catch 吞掉唯一能解释状况的那个对象」的家族。注释 /* skip corrupt files */ 说明跳过是有意 的 —— 有意跳过是对的(一个坏文件不该让整个 runtime 起不来),有意不说 才是缺陷。
复现路径
.objectstack/installed-packages/good.json # 完整条目
.objectstack/installed-packages/broken.json # 截断的 JSON
os serve 起一个装了 broken 那个包的 runtime:该应用不出现,日志无提示;GET 已装列表:少一项,success: true。
决策点(不预设,留给分诊)
list() 的返回契约要改成什么,是本单真正的决策点,不是实现细节 —— 它是 @objectstack/cloud-connection 的公开 API,有 3 个消费者:
A. 返回结构化结果 (如 { entries, skipped: Array of { file, cause } }):调用方各自决定怎么报;类型即契约,漏报变成编译期就看得见。破坏性,要改 3 处调用方。
B. 保持返回类型,加一个可选的 onSkip 回调 / 累加器 :非破坏性,但「不传就还是静默」—— 默认仍然是错的那一边,和仓规「declared = enforced」相反。
C. list() 内部 logger.warn 一条 :最小改动,但 LocalManifestSource 目前不持有 logger,且把「谁来报告」的决定从调用方手里拿走(doctor 要的是一行 HealthCheckResult,不是 stderr)。
倾向 A(契约优先:让「读了一半」在类型上无法被忽略),但 3 个调用方的改法需要维护者拍板。
备注
实施 #5412(拆开
os doctor的 ledger 读取 catch)时核验发现;未在该 PR 里改 —— #5412 的判定面被 PM 限定为packages/cli/src/commands/doctor.ts,本条在packages/cloud-connection,且要动list()的返回契约(跨包)。事实
packages/cloud-connection/src/local-manifest-source.ts(origin/mainb4872a868),LocalManifestSource.list():每个文件一个不带绑定的
catch。一个截断 / 不可读 / JSON 解析失败的 manifest 被就地丢掉,list()成功返回一个短列表 —— 调用方无法把它和一个完整列表区分开:没有返回值上的差别,没有日志,没有计数。抛出的对象在 catch 处就被丢弃了。实测(worktree 里跑真实实现,一好一坏两个条目):
后果(两个消费者,都是静默的错答案)
packages/cloud-connection/src/marketplace-install-local-plugin.ts:1279readAll = () => this.ledger.list(),两处调用:rehydrate()(:212) —— boot 时把每个已缓存 manifest 重新注册进 kernel。条目损坏 = 那个已装应用不被注册,即从这个 runtime 里消失(app switcher 里没有、对象不存在),而日志里一个字都没有。函数自己对「拿不到 manifest service」是会warn的,唯独对「少读了几个条目」不会。handleList()(:702) —— 控制台「已安装应用」列表。损坏条目直接不在返回的items里,前端得到的是一份看起来完整的清单。第三个消费者是
os doctor的 ADR-0120 D5e unique-scope 建议(os doctor的 installed-package ledger 读取 catch 把「损坏」和「没装」当成同一件事 —— D5e 建议因此静默少报,并打出「clean」 #5412)。os doctor的 installed-package ledger 读取 catch 把「损坏」和「没装」当成同一件事 —— D5e 建议因此静默少报,并打出「clean」 #5412 修掉了目录级读取失败(✓ Unique scope不再在 ledger 不可读时打印),但条目级损坏够不着那个 catch —— 因为它在这里就被吸收了。os doctor的 installed-package ledger 读取 catch 把「损坏」和「没装」当成同一件事 —— D5e 建议因此静默少报,并打出「clean」 #5412 的 PR 用一条⚠ SCOPE BOUNDARY测试把这个边界钉住了(doctor-ledger-read-failure.test.ts),本 issue 修好后那条测试会转红,这是预期的。也属于 #4801 / cloud#1020「诊断面与运行时不一致」和 #5403 / #5412「catch 吞掉唯一能解释状况的那个对象」的家族。注释
/* skip corrupt files */说明跳过是有意的 —— 有意跳过是对的(一个坏文件不该让整个 runtime 起不来),有意不说才是缺陷。复现路径
os serve起一个装了broken那个包的 runtime:该应用不出现,日志无提示;GET已装列表:少一项,success: true。决策点(不预设,留给分诊)
list()的返回契约要改成什么,是本单真正的决策点,不是实现细节 —— 它是@objectstack/cloud-connection的公开 API,有 3 个消费者:{ entries, skipped: Array of { file, cause } }):调用方各自决定怎么报;类型即契约,漏报变成编译期就看得见。破坏性,要改 3 处调用方。onSkip回调 / 累加器:非破坏性,但「不传就还是静默」—— 默认仍然是错的那一边,和仓规「declared = enforced」相反。list()内部logger.warn一条:最小改动,但LocalManifestSource目前不持有 logger,且把「谁来报告」的决定从调用方手里拿走(doctor 要的是一行HealthCheckResult,不是 stderr)。倾向 A(契约优先:让「读了一半」在类型上无法被忽略),但 3 个调用方的改法需要维护者拍板。
备注
LocalManifestSource/installed-packages/ledger/corrupt manifest),无重复;唯一命中是os doctor的 installed-package ledger 读取 catch 把「损坏」和「没装」当成同一件事 —— D5e 建议因此静默少报,并打出「clean」 #5412 自身。os doctor的 installed-package ledger 读取 catch 把「损坏」和「没装」当成同一件事 —— D5e 建议因此静默少报,并打出「clean」 #5412 无依赖关系,独立成单:os doctor的 installed-package ledger 读取 catch 把「损坏」和「没装」当成同一件事 —— D5e 建议因此静默少报,并打出「clean」 #5412 的 PR 只动doctor.ts的目录级 catch,不触及本函数。不作为os doctor的 installed-package ledger 读取 catch 把「损坏」和「没装」当成同一件事 —— D5e 建议因此静默少报,并打出「clean」 #5412 的 sub-issue —— 本条的修复面在另一个包、且是跨包契约变更,不落在os doctor的 installed-package ledger 读取 catch 把「损坏」和「没装」当成同一件事 —— D5e 建议因此静默少报,并打出「clean」 #5412 的完成范围内。