设备管理包设计复核(独立,读代码)
复核人:代理(独立于设计稿作者)。方法:通读 docs/hub-r4-device-pack.md 全文,对照 contracts/src/pack.ts、contracts/src/product.ts、contracts/src/resources.ts、contracts/src/run.ts(经 contracts/generated/vocabulary.ts 的轴)、hub/runs/engine/start.ts、hub/runs/engine/target.ts、hub/runs/engine/store.ts、hub/runs/packs/validate.ts、hub/knowledge/builds.ts、hub/knowledge/sync.ts、hub/records/store.ts、hub/records/pointers.ts、hub/records/api.ts,以及模型元素 D02/D03/D04/D07、B05、C07、K03、T04、W03/W06、R01(node tools/show-element.ts)。逐条写明依据;结论不是“可行/不可行”,是要改什么。
总结论
设计的骨架(清单是知识、占用是租约、版本是主张、全部经 start_run、不加工具)与现有代码贴合,但有六处站不住或没想清楚:方案 A 挂在产品包机制上有三处硬伤(见 1);租约意图放投影结果里要求投影先于运行记录存储执行,而现在的投影在 startFresh 里没有任何事务保护,取得租约和存运行记录之间崩溃会留下无主租约(见 2);“客户端不持有令牌”与设计里“用户拿租约号”不冲突,但“预订跨运行持有”与 D04 R[0] 的原文冲突,需要改模型措辞而不是“澄清”(见 4);并发方案应改为唯一约束而非 CAS 重试(见 3);清单删除占用中设备、时钟、Hub 重启三条失败路径设计完全没写(见 5)。核心(不含包)估计 8–12 个文件、1500 行上下,见 6 的拆分。
1. 7.1 清单的家:两个方案都有硬伤,选修正后的 A
方案 A 的三处硬伤(依据:contracts/src/product.ts:268-299 ProductDeclaration、:103-134 SyncDeclaration、:111-125 SyncPlanInput/Output):
- 同步的来源不是“上传的文件”。
SyncDeclaration.start是ExternalTemplate数组(product.ts:128),模板变量只有product/line/build/base/family/platform(product.ts:31),展开结果是ExternalObjectId——即“外部系统里的一页”。同步循环(hub/knowledge/sync.ts的 syncProduct)拿着外部读取端口逐轮读这些对象。管理员上传的拓扑文件没有外部对象身份,进不了这个循环。要走 A,要么给文件一个伪外部身份(如bed:file:<台架>@<修订>,由读取端口从存储里答——这是给读取端口加一种“内部来源”,改动在hub/connectors的读取器分发处,约 100–200 行),要么不走 sync 循环、单独一个“清单导入”管理命令(改动在hub/server/cli-admin.ts加一条bed manifest import,调hub/knowledge的新入口,约 150–250 行,不动 sync)。后者更小也更诚实:清单是管理员声明,不是从外部系统发现的,语义上本来就不是同步。 product@1的必填字段在这里没有意义。ProductDeclaration要求names(≥1 个产品名)、catalogs(目录声明,complete布尔决定“缺席是不是事实”,C07 R[0])、producers;BuildInfo(product.ts:83-95)要求family、line、base、built_at、released_at、sources。台架没有名称族、没有版本线、没有“发布”——family/line/released_at全是凑数。根的标识也尴尬:知识根按product + base build键(hub/knowledge/builds.ts的baseOf/latestProduction,builds.ts:155-170),台架的“构建”是什么?如果把拓扑修订当“构建”,built_at是修订时间,勉强能塞,但select_build(无 build 时选最新发布构建,target.ts:75起)会选出“最新发布的修订”——语义错位。结论:A 方案不能原样走product@1;要走就得为清单另立一种“树类”(或者把清单做成无产品绑定的知识树),这是对知识包的结构改动(hub/knowledge/store.ts的树键、hub/knowledge/produce.ts的生产入口,估计 300–500 行),不是“零新增存储”。workflow.json的product绑定是必填的(contracts/src/pack.ts:371-376:product字段带catalogs,没有 optional 标记;hub/runs/packs/validate.ts:242解析它)。设计里的bed-search等工作流要读清单树,但清单不是产品线;把 test-bed 包绑到 famx 上则所有床事实都挂在一条产品线下,别的产品线的床没法用。所幸target.ts:80-82已支持“不绑产品线”(unbound = binding.id === undefined && binding.catalogs.length === 0),hub/runs/packs/的boundProducts同理——工作流可以不绑产品,但要读知识就得回答“读哪棵树”:现在的读取全部以product为键(hub/knowledge/builds.ts的rootOfBuild(product, name)、hub/knowledge/views.ts的读取器)。这是 A 真正的坑:知识读取的键是产品线。清单树要么也挂一个伪产品线(test-bed自己算一条“产品线”,product@1的字段凑数),要么给知识读取加“按清单根直接读”的路径(改hub/knowledge/views.ts的视图读取入口和hub/refs的解析,约 200–300 行)。
方案 B 的代价(依据:contracts/src/resources.ts:646-685 ResourceRecord):resource@1 的 origins 是 record(RoleName, BaseUrl)——每角色一个 HTTP 基址;allow_plaintext、credentials、credential_digests 全是 HTTP/凭据语义;settings 是给读取函数的自由 JSON(≤64KB)。把设备塞进资源要:origins 改 optional 或加判别(设备没有基址)、动作从“读”泛化到“占用/释放”(现在 SystemProfile.routes 全是 read: literal(true),resources.ts:378,资源体系根本不能写)。这是对 connectors 包的大改(resources.ts 的形状、hub/connectors/resources.ts 的管理命令、hub/connectors/connectors.ts 的读取路径,估计 400–600 行),而且违背了资源的本义(D01:部署实例;D07:执行器)。
我的选择:修正后的 A’——管理员命令导入清单,知识树挂“test-bed 自己的树”,不走 sync、不伪装产品线。 具体:hub/server/cli-admin.ts 加 bed manifest import --file <path> --bed <name>(管理员专用),字节经现有源工件管道入库(B02,hub/records/artifacts.ts),包里的解析器作为生产器对这份源工件产出台架/设备/接口条目(hub/knowledge/produce.ts 的生产入口已经接受“读取的源工件 + 包的生产器”,produce.ts:2-4 的注释;难点只是树的键不用产品线——hub/knowledge/store.ts 加一种“床清单树”的键,或者最省的办法:test-bed 包同时是一个最小 product@1(names=[‘test-bed’],没有 sync、一个不声明 complete 的目录),凑两个字段换零结构改动)。代价写清楚:凑 names/line(用台架名当“版本线”)不体面但改动最小(包内 + hub/server/cli-admin.ts 一条命令,约 200 行);做干净的床清单树约 300–500 行(store.ts/produce.ts/views.ts)。取舍留给所有者:省 vs 干净。方案 B 不推荐:改动更大且方向错。
2. 7.2 租约意图放哪、和运行的事务关系
放投影结果里(设计的推荐)是对的先例延续,但设计没写关键一步的顺序。现在 startFresh 的顺序(hub/runs/engine/start.ts:300-447):bindInputs → contextBuilds → projectItems(无事务,纯计算加动作记录)→ bindExpected → 组装 RunRecord → evaluateProduction(生产运行在这里全算完)→ putRun(hub/runs/engine/store.ts:53-74,单条 put 自带一个事务)→ putAction('parse') → keepProduction。运行记录和它的生产结果不在同一个事务里(putRun 一个事务,keepProduction 又一个)。
我认为对的顺序(租约取得夹在中间,两个事务,各有一个补偿):
projectItems返回lease_requests(包的纯函数算出,无副作用)。- Hub 校验(前缀、上限)后先取得租约(
hub/leases的一次 CAS/唯一约束插入,自带事务),得到租约号和到期时刻。 - 把租约结果(号、键、到期)写进
RunRecord的新字段(如leases),putRun存运行。 - 若
putRun失败(超限等,start.ts:410-416的LimitError路径):立刻释放刚取得的租约(补偿),再抛拒绝。 - 若进程在 2 和 3 之间崩溃:租约已存在但无运行引用。处理:租约记录里存
run字段,hub/leases的读取按“run非空且该运行记录不存在”判孤儿,孤儿视为过期(下一读者可抢占)。这要求孤儿判定能查运行记录(store.find按INDEX.runId,已有,store.ts:76-80)——这就是为什么租约不该做成动作记录(B05)而该是自己的记录种类,见 4。
不建议把租约取得放进 putRun 的同一个数据库事务:putRun 是通用记录写入(hub/records/store.ts:39-80),把床租约的语义塞进去要在记录层开洞;而且生产运行在 putRun 之前还有 evaluateProduction(可秒级),把租约持有点提前到冻结之前只会拉长持锁窗口。先租约、后存记录、失败补偿、孤儿按运行存在性回收——这是改动最小且崩溃安全的顺序。每步文件:意图校验 hub/runs/engine/start.ts(projectItems 之后);取得 hub/leases/service.ts(新);写回 start.ts:325-399 的 record 组装与 contracts/src/run.ts 的 RunRecord;补偿 start.ts 的 putRun catch;孤儿 hub/leases/service.ts。
3. 7.3 并发:不要乐观重试,用唯一约束加过期行
设计 4.1 写“compare-and-swap、表头变了重算、最多三次”。对照代码:仓库里现成的 CAS 先例是根指针(hub/records/pointers.ts 的 move:pg_advisory_xact_lock + 期望比对,pointers.ts 的事务体),它没有重试——调用方(hub/knowledge/builds.ts:288-303 的 reconcileLine)重试三次是因为指针可能被别人合法移动。租约不是这种场景:两个用户同时订同一台架,恰好一个该赢,不需要“重算后再试”。
更简单的做法:**租约表 (key) 唯一约束,插入即取得**;键上已有**未过期**行 → LEASE_UNAVAILABLE(带上那行的到期时刻当“最早空闲时刻”);已有**过期**行 → 在一个事务里 UPDATE … WHERE key = $1 AND expires_at <= now() RETURNING`(原子的“抢占过期行”),更新数为 0 说明刚被人抢走,返回不可用。两个场景:
- 两个用户同时订同一台架:唯一方案里两个 INSERT 只有一个成功(PostgreSQL 唯一约束保证,无重试、无窗口);CAS 方案里两次读表头都看到“空闲”,两次 CAS 一个成功一个重算——重算后看到对手,返回不可用。结果相同,但唯一方案没有“最多三次”的循环,代码是一条 INSERT 的 catch。
- 过期行的清理:唯一方案不需要清理任务——过期行原地保留,下一次取得同一键时原子抢占(上面的 UPDATE),读到的人按
expires_at <= now()判空闲;行不会无限增长(键空间有限,每键至多一行)。CAS 方案要么删行(删和插之间有窗口,得再锁),要么同样留行——那就退化成和唯一方案一样,CAS 白做。
结论:唯一约束 + 过期行原子抢占,比设计 4.1 的 CAS 简单且不会输;CAS 重试删掉。围栏令牌用行上的单调序列(token bigint,每次取得/续期 +1,UPDATE ... SET token = token + 1 顺带返回),不另做表头摘要。
4. 模型一致性:五处冲突及处理
- “租约按运行持有”(D04 R[0])vs 预订跨运行:原文“运行第一次向某目标派发时由 Hub 取得,同一运行之后的派发沿用……交付、取消或超期时释放”。预订运行取得、之后的自动化运行沿用,不是“同一运行”。设计第 4 节建议的读法(持有者是血缘 W06)是改模型,不是澄清:D04 R[0] 要加一句“预订类租约按血缘持有:以预订运行为前驱的运行沿用同一租约,由 Hub 在派发时核对前驱关系”。这不是设计能单方面“确认”的——按
contracts/model.json的规矩(contracts/src/codes.test.ts:6-14,协议码和记录种类都从模型生成),模型措辞改动要走所有者的模型修订。 - “客户端不持有令牌”(D04 R[0] 末句)vs “客户端只拿租约号”:不冲突,但要把“号”和“令牌”分开写死:租约号是标识(可给人看),围栏令牌是凭据(只进 Hub 派发的效果,D07 R[4] 的注入方式)。设计 4.1 已写对,复核确认,无改动。
- D02 的前提表:设计第 6 节“未排除本地操作”的限定正确引用了 D02 R[5]。但设计漏了 D02 R[0] 的另一半:“没有证据按最弱处理”意味着阶段 1 的一切占用结论都不该写成“台架空闲”,而该写“Hub 记录里空闲(未排除本地操作)”——设计的 bed-search 输出措辞要统一带这个限定,不只是阶段 3 的操作。
- B05 动作记录承载租约事件:
ActionKind是封闭枚举(contracts/generated/vocabulary.ts:271:read/parse/generate/read_back/validate/dispatch/classify/move_root,从模型生成),没有“租约”种类;hub/records/api.ts:498的ActionBody按action判别。把取得/释放塞进现有种类是语义造假。租约该是新的记录种类(如lease@1),随RECORD_KINDS从模型生成(contracts/src/codes.test.ts:38-42断言记录种类来自模型)——这又是模型修订。动作记录里补一条dispatch或新增种类的“租约取得”事件作为审计线可以,但权威状态在租约记录。 context封闭列表:CONTEXT_KEY = ['builds'] as const(contracts/src/pack.ts:191),ContextKeySchema = z.enum(CONTEXT_KEY)。加leases要动:contracts/src/pack.ts:190-193(常量与枚举);RunContext(pack.ts:196-211)加leases可选字段;tools/gen-contracts.ts重新生成contracts/generated/schemas/*.json(RunContext 出现在多个工具的输入 schema 里,gen-contracts.ts:411一带)和tools.json;发布校验hub/runs/packs/validate.ts:242一带对context的检查(目前只查重复,pack.ts:393-395,无需大改,但workflow.json的 shape 校验随生成文件走);hub/runs/engine/start.ts:89-91(contextBuilds旁加contextLeases,按工作流声明的前缀过滤、附now)。设计 4.2 说“先例是 builds”——对,但漏了生成器这步,工作量里要记。
5. 设计漏掉的失败路径(各给处理,不留待定)
- Hub 重启时租约的状态:租约在数据库(新记录种类),重启无损失;进行中的“取得”若没提交,事务已回滚,无孤儿。孤儿只来自第 2 节的崩溃窗口,按“引用的运行不存在”回收。不需要恢复任务。
- 时钟:过期按读取时刻算(设计 4.1 已写)意味着不同节点时钟漂移会给出不同答案。处理:过期判定一律用数据库时钟(
expires_at <= now()在 SQL 里,pointers.ts的先例),Hub 进程时钟只用于展示;运行记录里已存的时刻是 ISO(run.ts的 started_at),不受影响。 - 同一用户重复预订:设计没写。处理:允许(同一用户对同一键的第二个租约请求返回新的租约记录?不——唯一约束下同键只能一行,第二个请求得到
LEASE_UNAVAILABLE,最早空闲时刻是自己的到期时刻)。要“续订”语义就用extend,不新取得。写进 SKILL。 - 清单修订删除了还被占着的设备:处理:修订导入不检查占用(清单是事实,占用是意图;事实更新不迁就意图)。被占的键从此不在清单里——租约仍有效到到期(键是字符串,不依赖清单存在),但 bed-facts 对“清单外键的租约”要报未知(“设备已不在清单里,占用记录仍在”),管理员可见并可强制释放。包的检查函数把“租约键不在当前清单”作为一条警告。
- 拓扑修订换了设备名:同上——旧名字的租约照常到期;bed-search 只认新清单;旧租约对 bed-search 不可见(键前缀里带台架名的话,台架名没变就还能看见)。建议键里带台架名和设备名(
bed/<台架>/<设备>,设计已这么写),并在修订导入时对“存在租约的旧设备名”输出管理员警告。 - 管理员强制释放时持有者的运行:设计只说“留下动作记录和理由”。处理:强制释放把租约行删掉(或标记 released),引用它的运行不作废(运行是不可变记录),但该运行之后对这台设备的派发会被
hub/leases拒绝(围栏令牌已变)——这正好复用 D04 R[0] 的“租约中断后同一租约约束失效”。运行摘要里加一条管理员备注(hub/server/mcp.ts:140-145的 adminNote 先例)。
6. 工作量:修正后的拆分
核心(hub/ 与 contracts/,必须先行于包):
| 项 | 文件 | 内容与测试 | 估计 |
|---|---|---|---|
| C1 模型与合约 | contracts/model.json(D04 R[0] 措辞、记录种类 lease、动作种类若加)、contracts/src/records.ts(lease@1 形状)、contracts/src/pack.ts(CONTEXT_KEY、RunContext、workflow 的 leases 声明)、contracts/src/run.ts(RunRecord 的 leases 字段)、生成器产物 |
测试:contracts/src/codes.test.ts 扩、pack.test.ts 的 workflow shape |
300–400 行;先于一切 |
| C2 租约服务 | hub/leases/service.ts(新)+ hub/leases/README.md:唯一约束建表(迁移在 hub/records/ 的建表处)、取得/续期/释放/强制释放/按前缀读、过期原子抢占、孤儿回收、围栏令牌单调 |
测试:并发(两连接同时插入)、过期抢占、孤儿、前缀过滤、强制释放 | 500–700 行;依赖 C1 |
| C3 start_run 接线 | hub/runs/engine/start.ts(意图校验、取得、写回、补偿)、hub/runs/engine/store.ts(索引 run→lease) |
测试:投影返回意图 → 租约进记录;putRun 失败补偿;非生产包拒绝租约意图 | 200–300 行;依赖 C1、C2 |
| C4 清单导入命令 | hub/server/cli-admin.ts 一条命令 + hub/knowledge 的清单树入口(或最小 product@1 路线,见 1) |
测试:命令级(cli-admin.test.ts 的先例) | 200 行(product@1 路线)或 300–500 行(干净树);依赖 C1;可与 C2 并行 |
包(packs/workflow/test-bed 及 test-bed-*,全部依赖对应核心项):
| 项 | 内容 | 测试 | 估计 |
|---|---|---|---|
| P1 清单解析器 | 拓扑文件解析纯函数 + 修订根 | 解析正反例(重复地址、地址落不进段) | 200–300 行;依赖 C4 |
| P2 bed-search | 工作流 + 投影 | 合成三台架 | 200 行;依赖 P1、C2 |
| P3 bed-booking | 租约意图投影 + 参数 action | 预订/续期/释放/地址 | 300 行;依赖 C2、C3 |
| P4 bed-facts、bed-version | 环境事实、版本主张 | 监听/盲地址、租约持有核对 | 300–400 行;依赖 P1、C2 |
| P5 验收与真实 Circle 场景 | 设计第 7 节验收 1–10 | scripts/real 场景 | 200–300 行;最后 |
原拆分 L1–L5 的顺序(L1 租约 → L2 上下文 → L3 清单 → L4 工作流 → L5 验收)大体对,但漏了模型先行(C1 必须第一个做,否则 C2 的记录种类和 C3 的合约字段没有依据)和“清单(原 L3)可与租约并行”这一事实。
7. 设计稿里我认为错的地方(汇总)
- 4.1 的 CAS 重试应删,改唯一约束(见 3)。
- 第 5 节“清单存哪里待复核定”的两个选项都没写“知识读取以产品线为键”这个真正的障碍(见 1)。
- 第 4 节把“预订跨运行”说成“澄清 D04”——是模型修订(见 4.1)。
- 第 7 节验收 3“不靠运气”在 CAS 方案下反而难测(要构造表头恰好变化的窗口),唯一约束下是自然的两连接插入测试(见 3、6 C2)。
- 第 3 节“占用……Hub 的动作记录(B05)”——动作种类是模型生成的封闭枚举,租约状态放动作记录是错的家(见 4.4)。
8. 没读懂或拿不准的地方
hub/knowledge/produce.ts的生产入口能否接受“无 sync 的包”(我读了开头与ProductDeclaration.sync是 optional 的定义,推断可以,但没有端到端验证一条不走 sync 的知识树能被get_nodes读到——C4 做时要先做一个探针)。- “最小 product@1 路线”里
select_build对台架的错位行为(见 1.2)没有深挖:如果床树从不通过start_run的 build 选择读取,错位可能根本不触发;我按最坏情形估的 300–500 行可能偏高。 - 设计第 6 节“占用者对其他用户可见:所有者定”——我没有评估可见性改动在
hub/records/api.ts的读者模型里是几行(identityOf/canSeeRun一带没细读),这项没计入工作量。