# Host 与数据契约 — dsh-tokbook > **Doc meta** · role: contracts · language: zh-CN · status: current · last-reviewed: 2026-10-18 · supersedes: — > 本文只写**代码里读不出来的规则**:违反了它不会报错,但会把钱算错、把状态搞坏。 > 路由表、wire 字段清单、配置键、数据文件清单、事件投影**都不在这里**——它们是代码,直接读: | 想确认什么 | 直接读 | |---|---| | host 路由(方法 / handler) | `src/routes.ts`,路径与 query 键在 `src/route-paths.ts`(两半区共享的真源) | | wire 载荷与共享类型(逐字段) | `src/types.ts` | | 会话事件 → 账本行的投影 | `src/usage-record.ts` | | 数据目录的文件布局与写入方式 | `src/ledger.ts`、`src/usage-log.ts` | | 计价与价格表解析(取价 / 费率行身份) | `src/pricing.ts`、`src/price-format.ts` | | models.dev 目录的拉取与采信边界 | `src/models-dev.ts`;合并门槛在 `src/pricing-sync.ts` | 跨模块级重载的去重边界语义见 `docs/HMR.md` §4;端到端故事与配置示例见 `../README.md`。 ## 1. 术语(只留会读错的对应) - **通道** = 代码与 wire 里的 `provider`(路由键、JSON 键、URL 一律保持原文,不翻译); 做模型、定官方价的一方是**模型厂商**——同一个模型可经多条通道计费,同一家公司也可能兼任两者。 - **未归属** = 会话没有 `cwd`、或目录未注册为工作区(工作区视图里 `id: ''` 的那一行)。归属靠 `resolve` 归一后的路径比较,**大小写折叠只在 Windows 上做**——在 POSIX 上折叠会把两个真正不同的 项目并成一行。宿主**根本没有**工作区注册表时这一面整块为空,绝不把所有记录塞进「未归属」:那是 「这一面不存在」,不是「这些用量不属于任何工作区」。 > 「用量未知」「价格未知」「实际支出 / 订阅价值」几个词的**含义就是 §2 的规则**,不另立定义。 > 界面上的两种说法是同一件事:整卡报「N 次没上报用量」,单行报「用量未知」。 ## 2. 让数字保持诚实的不变量 - **去重身份是 `requestId`**;同一进程内重叠写者共享同一个去重集与同一条串行追加队列。 - **用量未知 ≠ 0**:计费调用缺 `usage` 时仍**写行**、仍计入 `requests`(summarize 另计一次 `compactions`),但四个 token 桶一个都不加,改由 `usageUnknown` 单独计数。**不估算、不猜测** ——这些 token 数在日志里根本不存在;**丢行**比「未上报用量」更不可查,所以两条都要写行。 - **价格未知 ≠ 免费**:没有价的 (route, model) 按 ¥0 计,但必须被列为价格未知——¥0 是**未知**, 费率为 0 才是已知价。**价格未知只有两个触发**:**整行没有价**(价文件里没有这一条 (route, model)), 或**按美元报价的通道没有可用汇率**(`$fx` 缺席时 `cnyTable` 把整条丢掉)。**价格表上没有生效时间**: 每个 (route, model) 只有**一份当前价**(四档基准价 + 可选的峰谷时段),账本里所有记录都用它折算—— 「有记录落在某个时刻之外」不再是价格未知,历史也不会被新写的价**截断**成 ¥0。旧文件的价段数组 仍读得进来(读路径取生效时刻最晚的那一段,日期串是定宽逐位可比的,字典序即时间序),写路径只接受 当前形态(数组与 `from` 都被拒,报错直说原因)。**价格表**上「价格未知」由通道 / 模型下拉的文字 报出(「N 个模型未知」),用量明细的通道卡另报无法定价的 (route, model)。 - **金额是「现价刻度」,不是历史账单**:价既然只有一份,`byDay` 的金额每次读取都从 rate rows 重新 折叠(只读缓存的「N 次请求」透镜也**不写折叠**),**改价后逐日金额整体变**。拿今天的价去折算 一年前的用量,既不是那笔支出、也不是今天的价值——所以**窗口本身是一条口径**:金额只在 30 天及 更短的窗口里给(用量明细的今日 / 昨天 / 近 7 天 / 近 30 天),**「全部账本」只给词元**,词元是 事实、放多久都成立。哪一类窗口给金额由 `rangeCarriesMoney`(`src/types.ts`)一处判定,UI 在那 一个窗口里明写「全部账本只给词元」。近 30 天里仍可能发生厂商调价,那部分偏差是**已知且接受**的 (维护者本机数据实测约 1–2%);赌注押在「价是当前的」上,而不是维护一份用户拿不到的调价史。 **归日也只有一份实现**:日文件命名、按日折叠与会话页的「今日支出」都走 `price-format.ts` 的 `dayKeyOf`,两份漂移就是同一笔钱在两张脸上对不上。 - **行内那个「此刻的价」是读出来的,不是存下来的**:client 每次渲染按当前时刻从这一份价里现算 (基准价叠上生效的峰谷时段,与成本层 `ratesOfEntry` 同一条规则),wire 上**不带**这种快照。 - **费率行的身份** = 那一刻生效的峰谷时段身份(`start` + `days`;`null` = 基准价),或显式的「无价」。 它是折叠行的归组与回查键,**不是跨价表的保护**:价文件任何改动都会让 `pricingRevision` 变化、 整份折叠作废(见下条),历史因此**按记录时刻在新表上重新推导**。所以编辑时段的费率、**开始小时、 `days` 或结束小时**、乃至**删掉它**,都是同一种效果——就地重算:受影响的小时改按新规则计价或 退回基准价,不会掉成价格未知。`priceOfKey` 的回查落空在生产里只为一种记录现形:**按美元报价 却没设汇率的通道**(换算表里整条被丢)。 - **价按通道的报价币种记,汇率是唯一的换算率**:`pricing.json` 里每条 route 的价用它自己的币种 (保留键 `$currency`,缺席 = 人民币/百万词元)。`$fx`(`{ cnyPerUsd }`)把美元报价的通道折成 人民币——**计价路径读它**:`cnyTable` 在 `rateContext` 那一个缝上换算出人民币表,成本层与 `stats.ts` 的折叠因此完全不认识币种。随之而来的三条硬规则:**① 改汇率会重算美元通道的历史 金额**(那些价是美元记的,人民币数字本来就是汇率的函数),人民币通道一分不动;**② 美元报价 必须有可用汇率**(client 的 `composePriceBody` 与 host 的 `parsePriceInput` 各挡一道;没有它 那些价算不成人民币,读路径按价格未知报出,绝不把美元数当人民币——那会差一个数量级); **③ 一条通道的价原样读写**:格子显示什么、你敲进去就存什么,另一种货币的读数只挂在悬停提示里。 所以**切换报价币种是一次真正的改价**(整条通道的价按汇率换一种货币记,人民币金额 6 位小数内 不变),而**切换汇率不改任何已写的价**。报价币种与 `$billing` 同形、同样是 route 级事实, 作用于该路由的全部历史。`pnpm smoke` 的「价格表币种」一节断言上面每一条(含「$currency 只写 美元那几条」「美元价原样回落盘」「没有汇率时计价层丢掉美元通道」)。 - **models.dev 补价只补「美元通道 × 账本用过的 × 缺价的」格子,且手写价永远赢**: models.dev 是美元单一币种的目录,只有 `$currency: USD` 的通道可以直接采用它的数—— 人民币通道的挂牌价不是美元价的函数(厂商各自取整定价,见 §2 的「不估算」),机械换算 是估算,绝不做。目录里同一模型同时挂在原厂与几百个转售通道上、挂牌价系统性分裂, **哪家是原厂**在数据里没有标记,采信边界是 `models-dev.ts` 里显式的原厂 slug 清单—— 增删 slug 等于改变哪些价可信,清单外再便宜也不采;多个原厂条目对同一 id 报分裂价时 整键弃采(无法替它选边)。同步只补洞,已有价(含手写坏行)一格不动;补上的价在金额 的「现价刻度」口径下自动回填窗口内的历史。失败(离线/超时)不落任何盘,只把原因记进 状态文件;日节流按**本地日**记在 `models-dev-sync.json`——它**不进 `pricing.json`**, 那是整文件替换的文件,混进来的状态会被下一次自动保存静默丢掉。 **节流、退避与 `force` 三者的关系**(改这三个开关前必读):成功过就当天不再自动拉; 失败后记一个退避截止,自动那条在退避期内不打网络——整包 5 MB、超时 15 s,离线时 每次打开 tab 都白等一次是真实代价。界面上的「取价」按钮走 `force`,**两者都绕过** (那是用户的明确动作);挂载时那次不带 `force`,因此当天已取过时一个请求都不发, 只把宿主的 `throttled` 判决交给界面。成功那一笔必须清空退避,否则下一次会被旧退避挡住。 断言在 `pnpm smoke` 的「models.dev 补价」一节。 - **「查价」是用户自己判断的那条路,与上面的自动补价共用同一份采信目录**: 价格表 tab 的「查价」按钮把**当前选中的模型名**当关键词在目录里模糊搜索,列出 原厂采信的相关模型与四档美元价,用户挑一条就**填进当前这一行**。它与自动补价的 三条边界相同:**① 只在美元报价的通道上开放采用**(人民币通道的按钮停用,得先在 详情头把 `$currency` 切成美元——那是整条通道的一次改价,不是把美元数当人民币); **② 候选池就是 `parseCatalog` 的采信集**,转售价与分裂价根本不在里面,所以不存在 「便宜的转售价冒充通道成本」,挑哪家由用户定;**③ 采用即一次普通改价**(只填四档 基准价,已有峰谷时段原样不动),自动保存接着写盘,手写价与已填的价都会被覆盖—— 这是用户明确选的,不是同步的补洞。关键词可编辑,搜不到时换个词再搜;目录带缓存 (host 内存,TTL 6 小时,不落盘),改关键词再搜不会每次重下 5 MB。 - `rollup.json` 带 **`pricingRevision`**(价格文件内容的 djb2 签名)与 **`files`**(折进去的每一天 分日文件**当时的版本**,`size:mtimeMs`):费率行身份既是从价格表推导的,签名一变**整份折叠作废**、 由日文件重建;`schemaVersion` 不符同理,旧版本或签名不符一律**读作缺失**——所以折叠格式一变 (比如 rate 行换了身份)就必须把版本号 +1,否则旧折叠会按旧身份回查、整批落空读成价格未知(¥0)。 `files` 的理由是 `upto` 只是**天级水位线**,看不见**往日被追加**(权威 walk 补写历史、模块级 HMR 后的第二个实例、 手工编辑)——折叠因此得自己记下它依据的那批文件版本并逐版本核对,对不上就整份重折。**同一个道理 也用在解析缓存上**:分日文件的进程内解析同样按版本判新旧,**没有**「今日之前不可变」这条捷径。 所以「写进日文件的行必须能被读路径看见」是派生态的**自证属性**,不靠调用方记得作废; **读失败的那一天不记版本戳**——把它记成「这一天是空的」会让零额永久粘住(下一次读必须先 作废整份折叠、重折一遍才可能看见它),这是这份自证属性的前提,不是可选优化。 - **金额分双桶**:`spend` 只含 `metered` 路由(实际支出),`subscriptionValue` 只含 `subscription` 路由(订阅价值、非现金),UI **不得**把后者当支出展示。计费性质以**通道路由**为单位(`$billing` 一条,省略 = `metered`),**作用于该路由的全部历史**——改 `$billing` 会同时改变历史的分桶。 - **额度卡的门(`gate`)按当次响应派生**:每次拉取按当次响应重算,续费后不会被旧值掩盖; `unconfigured` / `error` 卡**从不**当作「最近已知」持久化。**已知偏差:门会随 'ok' 卡一起落进 `quota-.json`**——「读时重推」需要一套按 provider 的规则(Go 的 `none` 无法从快照内容 推出),而门同时决定告急点与卡片正文,草率剥掉它会把假告急点引进来,故修它属独立任务。两条硬规则: **① 门只从「真的答复了」的端点推导,绝不从失败推导**——超时 / 5xx / 网络错误是「**不知道**」 而不是「没有订阅」,此时**不设门**(缺省 = `subscription`),绝不把网络故障说成账户没订阅; **② `balance` 门保证带 `credits`**——余额端点同样没答复时,没有数字可以替代窗口,降级为 `none`。 告急点只看**有门约束的 ok 卡**:任一窗口达到阈值或已超限即为告急;失效 / 没有订阅的计划沉到 活跃卡后面但**组内保持原序**(那是稳定分区,不是排序)。门的未知按活跃处理——把「不知道」读成 「不限量」会让一张真的用超了的卡排到后面去。 - **读路径不等权威 walk**:它端出的是**账本当前内容**,新鲜度由实时 hook 保证;需要折的行按节流排到 后台自愈,**例外只有一个**——冷账本(目录里还没有任何日文件)时等一轮,否则首次安装会把「还没回填完」 显示成「没有记录」。机制细节读 `src/index.ts` / `src/sync.ts`。 - **历史回填的失败必须就地兜住**:单条事件折不动只作废它自己,整份会话日志读不动只跳过该会话。 异常一旦穿出 walk,调用方就不推进同步基线,那份日志会被每个新鲜度探针反复全量重走、尾部还永远 补不上。基线文件(`sync-progress.json`)也读严格:缺失 / 撕裂 / 畸形读作 null(保守全量), **绝不返回部分 map**——否则一次撕裂写就能让探针只信任一部分会话的签名。基线只在同步成功之后写, 签名绝不跑在它所覆盖的内容之前。 - **「屏上那份还要不要重算」只看账本代数,不看时间**:`/tokbook/sync` 端出的 `ledger` (`src/usage-log.ts` 每个数据目录的计数器,**每追加一行 +1、refile 每挪动一行 +1**,只读内存) 是这条判断的唯一依据。它取代了「每 N 秒盲重拉一次」——盲轮询的代价正比于页数 × 周期, 而它只在**真的落了新行**时才产生一次载荷请求,于是会话在跑时界面跟着走、停手时零请求。 三条硬规则:**① 只比较相等,不比大小**(进程内计数器,重启 / HMR 归零,跨进程比较没有意义); **② 读不到它不等于没变化**——宿主不端出这一格(旧 host、只换了一半的 HMR)时各页必须退回 各自的定时轮询,否则界面会永久冻住;**③ 探针本身不许碰账本**(它是 `syncState()` 的内存投影, 成本 ≈ 0 ms;拿概览当探针就是每次 500 ms 一次全量聚合)。 - **「未知」必须在某处说出生效,绝不静默按 ¥0 展示**:用量未知与价格未知都由用量明细的通道卡报出、 价格未知在价格表里另有**选项文字**——通道下拉报「N 个模型未知」、模型下拉逐行标「价格未知」 (价格表一屏只面对一个模型,没有平铺的全局筛选,也没有「只看价格未知」那种收窄),道理写在 「统计口径」页。金额层**没有**「未知」这个值——未知行按 ¥0 计入, 所以 `spend` / `subscriptionValue` 对它们偏低;这只能靠**可见**解决,不能靠拆桶。 年历缺年也是同一种「未知」:价格表 tab 必须报出「账本覆盖到、年历里没有」的年份,因为 `workdays` 在那几年会静默把法定节假日算成高峰;报哪几年要按**合并之后**的年历算(手写 ∪ 自动), 自动补上的年份不该继续报缺。 - **自动年历只补洞,且手写永远优先**:从 holiday-cn 来的数据只采信 `isOffDay` 为真的天(调休上班日 不进年历——周末本来就按闲时算,收它等于把周末改成高峰);同一年的手写数据**整年**盖过自动数据 (逐日取并集会把用户手动删掉的那几天加回来);自动年份**不写回** `pricing.json`,写回去就固化成 手写数据,源头的更正再也进不来。反过来,用户一旦手改了自动来的那一年,那一年从此算手写—— 否则改动落不了盘,状态栏还会说「已保存」。年历内容变了要并进 `pricingRevision`(金额按它重算)。 - **价表写路径上,client 的 `composePriceBody` 必须覆盖 host 接受规则的全集**:host 是校验者而非 变换者(只排序、不改内容语义),成功写入即「磁盘 ≡ 草稿」,所以 client **从不**用响应回灌草稿 (回灌会让正在输入的输入框 remount)。规则两侧各写一份的后果不对称:host 拒绝是**可见**的,但 写入是**整文件原子**的——**一条过不去的键就让这一笔全都写不进去**;而 host 静默规范化了 client 没规范化的东西则是草稿与磁盘**无声分叉**。所以「接受规则」只有一份真源:保留键、route / model 长度上限、时段词表全在 `src/price-format.ts`,两侧引用同一批常量,并由 `pnpm test` 的 「价格表写路径」一节把 `composePriceBody(draft).body` **真的喂给** `parsePriceInput` 断言 「草稿产出 ⊆ host 接受」;**填了却写不出去的档(四档里任一格非法)也必须让那一行保持脏**, 不许出现「工具栏说已保存、输入框还红着」。唯一的对账点是重新挂载 tab(会重新 GET 并重新播种)。 **读路径同样不许产出写路径会拒的形态**:`$calendar` 的跨年区间在读的时候被夹到它自己的年 key 内 ——client 把写规则用在读结果上,读路径放行一个跨年区间就等于让整份价文件存不进去,并让那一年的 节假日集合为空(`workdays` 在假期当天照高峰算,显示价与计费价当场分叉)。 - **价格表要画的卡片是三个源的并集**:route catalog、账本计过费的 (route, model)、价文件里命名的 一切。从配置里消失的 route、只带手写价的 route 都得留着卡片,否则它们的金额变成孤儿;更硬的一条 是**只出现在 `$billing` / `$currency` 里的 route 也必须有卡片**——这一屏的保存是整份重写,没有 卡片就没有草稿行,那两个手写键会在下一次自动保存时静默消失。同一条「整表重写」也解释了畸形手写价 的下场:读不懂的 (route, model) 整条读作价格未知(**不猜**:手写的价要么读得出四档价,要么这一行 没有价),而它在**下一次整表保存时会被永久删掉**——这是接受的代价,错钱比丢一条手写价更重。 - **用时是读时 join 出来的,账本行永不回填时长**:账本行只有 `requestId` / `time` / 四桶这些, 没有任何时长字段(同 ADR-0001 对轮次归属的处置)。一步的用时全部从该会话日志的事件时刻现算, 算法只有一份:`src/session-timing.ts`。**未计时 ≠ 0s**——这是「用量未知 ≠ 0」在时间上的同一条: 取消 / 重试耗尽 / 日志读不到的步,`timing` 整个缺席(或只有墙钟、没有模型段),UI 写「未计时」, 绝不折算成 0。 - **join 资料按日志的 `revision` 缓存**:归属索引、每步用时、日志首末时刻合称 join 资料,随会话 日志的变更令牌(`sessionPersistence.stat(id).revision`)记住,**令牌变过才重算**。这一条不是 优化而是必需:会话词元页每跟一次账本变化都要出这份载荷,而整份日志重读重解是秒级(本机最大 的一份压缩日志 14 MB)。两条边界:**读不到 `revision` 就每次都重算**(旧宿主没有这个字段—— 宁可多算,不可拿一份可能已过时的归属去报数);**日志读不到时那份不缓存**(那是「这一次没读到」, 不是「那里什么都没有」)。 - **一步的墙钟就是「模型 + 工具」**:本机 12.6 万步实测 `墙钟 − (模型 + 工具)` 的中位数 2ms、 p99 6ms、无一为负,所以不再另报「合计用时」。**工具取并集而不是求和**——并行调用逐对相加会 超过墙钟(实测 0.2% 的差,但个别步能翻倍)。**配对的工具结果不得越过这一步的收尾**:会话被 挂起再续上时结果可能几天后才落盘,跨过收尾的那一段若算进工具用时,同样会让工具用时超过墙钟。 - **一步只由一行账本报时**:重试过的步在账本里有**两行**(那次重试的失败行 + 最终产出那行), 而 `(turn, step)` 与 `step/start → step/end` 都只有一个。按行各报一次会把同一步加两遍、 让「引擎工作」超过「会话跨度」(本机修前 27 个会话如此);故用时归**有产出的那行**, 其余同步行读作未计时。 - **跨度取日志首末事件,不取账本首末记录**:账本只在有计费调用时落行,用它算跨度会把 「最后一句话之后挂了几小时」全丢掉。跨度与「引擎工作总时长」**并排展示**才是它们的用处 ——两者之差就是没在算的时间,所以页面必须同时给,不能只给一个。 - TTFT 必须走宿主 `dsh-llm` 的规则(打包段从 `time0` 累加 `dt`,块起止不算 token): 天真地取流里第一个 `time` 会把「块开始」当首 token,TTFT 系统性偏小(实测中位数 2.4s vs 3.6s)。 - 已知缺口(记录而不修):压缩没有流落盘,故压缩行只有那次调用的整段、没有首 token / 生成; 重试退避落在「模型」那段之外(进墙钟、不进任何分段);某些通道(`deepseek-official` 的 vision 模型)不上报 `stream`,那批步没有 TTFT。用时是**本机墙钟**,不是计费时长。 - **界面上的三段尺**画的是 `等首 token` / `生成` / `工具`,**不是** `模型 + 工具`:`模型` 是前两段 之和,把它当第三段画会重复计数。缺 TTFT 的步(通道不上报流)整段按「模型」画——**不冒充 「生成」**:那会断言「没有等待」,而这里是「不知道有没有等待」。 这条尺怎么画(长度通道归谁、尺的段边界为什么与块头同源、明度与纹路怎么承载相位)是**呈现 口径**,真源在 [`CONTEXT.md`](CONTEXT.md)「用时」一节的「构成尺」,这里不复述。 - **子代理的用量按会话血缘读时归集,不写进父会话的账本行**:子代理是**独立的 `sessionId`**,账本行落在它自己的 id 下。词元页的「子代理」一段把 `header.parentSession` 指向本会话、且 `header.origin === 'subagent'` 的会话折进来,每个**直接**子代理一行、含 它自己派出的**整棵子树**——不按后代各列一行,否则同一段成本会在行与行之间重复加。 抬头合计与按轮次明细仍只含主会话。**`parentSession` 不等于子代理**:用户手动 fork 的 会话也带它,但 `origin` 缺席;只认 `origin` 才不会把 fork 的用量算成子代理花的。 两处边界:会话清单读不到(独立查看器没有持久化面)、或宿主 header 不带这两格血缘 (旧宿主)时这一段**整块不出**——「不知道有没有下级」不写成「没有」;标签来自父会话 日志里的 `subagent/catalog`,与 join 共用同一次读,目录事件缺席时行仍报数、只是标签 退化为短会话 id。子代理的 token 本来就在全量账本里(概览已计入),这一段只做**归集**, 不新增、不重复落行。 - **fork 出来的会话日志从 `inheritedEventCount` 起才是它自己的记录**:它的存盘日志从 seq 0 就含 父会话的历史,重折这段前缀会把父会话的压缩 / 失败行按子会话 id 再落一行,而账本只追加,那一行 永久留着。两处读日志(`sync.ts`、`session-usage.ts`)都得从这里起读。 > **本节这几条不靠"记住"兜底**:费率行身份与回查语义由 `pnpm smoke` 的「费率身份」一节 > 断言;`pricingRevision` → 折叠整份重建与「改时段就地重算、不留价格未知」由 `pnpm selfcheck` 的 > 「pricingRevision」一节断言;折叠本身是派生态(删掉 / 弄坏 / 旧版本都能从日文件无损重建,且等于 > 直接全量聚合)由「rollup: 派生状态无损重建」断言,去重集按目录共享、重启后从磁盘重新推出由 > 「dedupe」断言,重启后只走漂移 / 新增、残缺基线读作 `null` 由「baseline」断言;门不从失败推导 > 由「额度:门只从答复推导」与两节桩 HTTP(Command Code / OpenCode Go)断言——**没有叫「额度」的节**, > `--only 额度` 会命中六节;「没有价的行按 ¥0 计」与「此刻价」由「价格表草稿」/「没有价的行」两节断言, > 旧价段数组 → 一份现价由「手写价表」与 `pnpm selfcheck` 的「price」一节断言,写路径两侧对齐由 > 「价格表写路径」一节断言;折叠与解析缓存的**自证有效**(往日被追加后自动重折)由 `pnpm selfcheck` > 的「fold」一节断言(按**节名**定位,不按位置——节还会加)。**要核这些断言时**跑 `pnpm test`;只想跑 > 其中一节时用 `pnpm smoke --only <节名子串>`(子串匹配、只筛节不改断言,空串与一节未命中都直接报错; > **`--only` 只属于 smoke**,两套 selfcheck 没有筛选开关,跑就是全套)——它们不是每次改动的必跑项。 > 存储自愈(撕裂尾行不吞下一条记录、`mkdir` 失败不被永久记忆、 > refile 的接收文件不丢畸形行)与「一条畸形事件不截断该会话后面的历史」由 `pnpm selfcheck` > 的「log」/「refile」/「sync」三节断言;「读失败不写版本戳 → 下一次读整份作废重折」由 `pnpm smoke` > 的「折叠自证有效」一节断言,同一件事在真目录上(往日被追加后自动重折)由 `pnpm selfcheck` 的 > 「fold」一节再验一遍;计价读路径的降级(`.bak` 恢复、BOM 容忍、畸形价让整条 `(路由, 模型)` 变价格未知、 > 跨年 `$calendar` 夹取)与「秒级 epoch 不外推」由 `pnpm smoke` 的「手写价表」/「日历日轴与价文件读入」/ > 「额度」各节断言; > 用时的口径(墙钟 = 模型 + 工具、并行工具取并集、同一步只报一次、跨度含挂起时间、未计时不算 0) > 由 `pnpm smoke` 的「会话用时」一节断言;子代理的子树归集、fork 排除与「无血缘时整段缺席」 > 由「会话子代理」一节断言;金额双桶由「双桶金额」一节断言;窗口的日界由「窗口 → 日界」一节断言; > 路由目录(卡片三源并集)由「路由目录」一节断言;用量明细三种切法(按通道的卡 / 按工作区 / 按模型)与 > 「没有工作区注册表时按工作区为空」由「通道卡片与按模型切法」「工作区」两节断言——它们量的是 > `window-usage.ts` 那**一趟**聚合,而记账口径必须与 `stats.ts` 的 `summarizeRecords` 逐字一致。 > 逐字一致里也包括「`model` 是空串的失败行只进合计与按日、不进按模型 / 按小时」——空白表格行会假装 > 我们知道 route,两处各按各的分法就是两份账。 ## 3. 一个数据目录,一个写入进程 - 一个数据集一个目录(默认 `$DSH_HOME/tokbook`,可用 `path` 配置);**一个 host 进程写一个目录**。 启动时写 `owner.json` 标记:检测到**存活的异进程**已占用则**告警且不接管**(不覆盖它的标记—— 覆盖会让之后启动的进程把一个活着的属主读成陈旧标记、进而静默接管),崩溃留下的**陈旧标记静默接管**。 第二个 host 请指向**自己的 `path`**,不要共享目录。探测属主是否存活时,EPERM 仍算存活(进程在、 只是不归我们),其余探测失败与畸形 pid 读作不存活——守卫只是建议性的,从不抛。 - 日文件**只追加、绝不重写**;行归属于其事件的本地日历日。 - **日文件尾部撕裂时要先补一个换行**:上个进程写到一半崩了会留下没有结尾换行的半行,直接追加会把 新行拼到那半行后面——两个身份合成一条非法 JSON,已完整写入的那条记录就从所有聚合里消失。先补一个 换行把半行封成它自己那条坏行,新行因此是可解析的独立行,而且仍是纯追加、绝不重写。反过来,文件 尾巴良构时要清掉这个标记,集合才始终等于真实的撕裂文件。 - **存储层的失败一律降级,绝不挡住写入**:数据目录建不起来时那条 rejected promise **不得**被缓存 (缓存的拒绝会让本进程之后每一次写入都失败到重启为止——清掉缓存、把拒绝照旧抛给调用方,障碍解除 后下一次写入自己就能恢复);列目录 / 首次去重扫描失败时**照样写实时行**(坏数据目录不得丢掉实时 记录,幂等的 rescan 稍后清理);追加失败要释放这次 request-id 的认领,稍后的 sync 才能重试它。 refile 重写日文件时,源文件与接收文件里的畸形行都原样保留——坏行绝不被丢掉。 - 派生文件原子写入(temp + rename)。**`pricing.json` 是例外**:先把上一版 rename 成 `.bak` 再落新文件 ——它是唯一**用户手写**的文件,崩溃窗口内缺失会被读成空表,并可能被自动保存整表写掉。读的时候 `.bak` **只在这一种情况下回退**:主文件确实不存在(ENOENT)。权限 / 占用错误读的是还在的文件, 拿备份盖它会盖掉一份好文件;JSON 语法错误也不回退(文件在、内容坏,按既有语义读成空表)。 **读入先剥掉 UTF-8 BOM**:手写工具链会给它加 BOM(PowerShell 5.1 的 `Set-Content -Encoding utf8`、 编辑器里的「UTF-8 with BOM」),那一个字符不是畸形,剥掉后的表必须与不带 BOM 时逐字段相同。 - `pricing.json` 是价格与**全部金额**的唯一入口:删了它,账本仍在,但每一条记录都只能按「价格未知」读 (金额 ¥0)。 - **每个 handler 都要过 method 与 same-origin 两道守卫**(浏览器带 `Sec-Fetch-Site`,不带这个 header 的非浏览器客户端放行)。少一道,任何网页都能替你改价表——那是整份文件重写。 ## 4. 不显然的默认值 - `plans.` **默认尝试**(额度计划按通道路由内置),`enabled: false` 才关闭。 - key 解析顺序:`plans..apiKey` **字面量优先**,其次该通道的**凭据引用名**(`apiKeyEnv`, 缺省为通道自己的默认名)。都解析不到 → 一张 `unconfigured` 卡,**绝不**回退到本地估算。 - `TokBookConfig` 的顶层形状**严格校验**(未知键直接抛),`plans.` 却**宽松纠错**(丢掉 未知字段与畸形项,只留能用的几格)。这个不对称是故意的:配置写错应当场看见,而额度计划是可选旋钮, 一个笔误不得把宿主拖垮。