@mzzsfy/dsh-usage-dash 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @mzzsfy/dsh-usage-dash
2
2
 
3
- 用量统计面板(dsh 插件)。天/小时/分钟三粒度 token 与请求统计,设置页自绘面板:汇总卡、活动热力图、缓存命中率曲线、模型 donut 与列表、回扫状态行、会话底栏接管与单轮用量费用行,支持 en/zh 双语与可选费用估算。复刻自 [HaoyueQin/dsh-usage-statistics-panel](https://github.com/HaoyueQin/dsh-usage-statistics-panel),感谢原作者。
3
+ 用量统计面板(dsh 插件)。天/小时/分钟三粒度 token 与请求统计,设置页自绘面板:汇总卡、活动热力图、缓存命中率曲线、模型 donut 与列表、回扫状态行、会话底栏接管与回合费用芯片,支持 en/zh 双语与可选费用估算。复刻自 [HaoyueQin/dsh-usage-statistics-panel](https://github.com/HaoyueQin/dsh-usage-statistics-panel),感谢原作者。
4
4
 
5
5
  ## 功能(全阶段已交付)
6
6
 
@@ -9,17 +9,17 @@
9
9
  - 三粒度视图:天(7/30/90 天/自定义日期段)、小时(24 小时/3 天/7 天/15 天)、分钟(3 小时/24 小时/3 天/7 天,10 分钟桶粒度),后两者为滚动窗口;各视图时间范围统一 4 挡,时/分选择上限与数据保留期一致
10
10
  - 汇总卡六张:Tokens 用量(服务商总口径)、会话数量、请求数量、最常用模型、平均缓存命中率、活跃天数(恒按天口径,不随视图切换);配置定价规则后 Tokens 卡头部行右侧显示估算费用
11
11
  - 活动热力图:GitHub 风格周列×星期行,26 周窗口,五档色阶,悬停明细;仅按天视图展示
12
- - 缓存命中率曲线:日粒度命中率 + 右侧副轴,并叠加平均生成速度曲线(单独颜色,读数走悬停;仅含有时长数据的槽,整图无时长数据不绘制),悬停显示当前时段命中率、平均生成速度与 token 明细(配置定价规则后附当前时段估算费用行,受「费用显示」开关;三粒度趋势图通用)
13
- - 模型 donut 与列表:按 token 前 5 模型占比环形图(中心为总量),列表含命中率与占比,并为每模型显示平均生成速度(tok/s,无时长数据不显示),悬停联动
12
+ - 缓存命中率曲线:日粒度命中率 + 右侧副轴,并叠加平均生成速度曲线与首 token 延迟曲线(各自单独颜色,读数走悬停;仅含配对数据的槽参与,整图无数据不绘制),悬停显示当前时段命中率、平均生成速度、首 token 延迟与 token 明细(配置定价规则后附当前时段估算费用行,受「费用显示」开关;三粒度趋势图通用)
13
+ - 模型 donut 与列表:按 token 前 5 模型占比环形图(中心为总量),列表含命中率与占比;每模型右侧两行:第一行占比%·token 数,第二行估算费用·TTFT·生成速度(语言中立,点分隔;费用需配置定价规则,TTFT/速度无配对数据不显示),悬停联动
14
14
  - 三粒度堆叠柱状趋势图(数据量大时裁最旧并提示);图例可点击切换显隐:点击单选(仅显示该项,左轴刻度按可见项归一)/再点恢复全部,Ctrl+点击多选,隐藏最后一项无效
15
15
  - 回扫状态行默认隐藏:首次启用自动回扫历史会话,运行中显示进度(常显);右上角为折叠箭头与刷新图标,折叠层内展开扫描异常日志块(逐条:时间/类型/明细,计数即明细条数,上限 200 条超限丢最旧,重建时清空)与「重建」入口(二次确认 3 秒后清库重扫),采集错误常显;工具栏分主区与右侧操作区,窄宽度时挡位组在主区内换行
16
- - 会话底栏接管:替换官方信息行为增强版,偏好卡三个开关默认全开——精确缓存命中率(两位小数)、会话 Token 明细(总/命中缓存/未命中缓存/输出)、费用显示(底栏费用项与趋势 tooltip 费用行);三开关全关时与官方逐字节一致
17
- - 会话尾部单轮行:每轮对话结束后动作行上方显示该轮 token 摘要与估算费用(最新一轮常显,历史轮悬停显现,触屏恒显;有产出文件的轮次自动让位产物行)
18
- - 定价规则编辑器:设置面板内编辑模型与四桶单价,货币为全局切换(「定价规则」标题右侧,仅 ¥/$ 两档,整表统一,不逐模型设置),显式「保存」整表写入
16
+ - 会话底栏接管:替换官方信息行为增强版,偏好卡三个开关默认全开——精确缓存命中率(两位小数)、会话 Token 明细(总/命中缓存/未命中缓存/输出)、费用显示(底栏费用项、趋势 tooltip 费用行与回合费用芯片);三开关全关时与官方逐字节一致
17
+ - 回合费用芯片:经官方 `conversation.chat.assistant-actions` 槽注入动作行(复制与分支图标之间,官方赞/踩与上下文跳转同排),每轮对话结束后显示该轮估算费用(悬停 title 带 token 摘要与估算口径;显隐节奏随官方动作行——最新一轮常显,历史轮悬停显现;受「费用显示」开关;官方用量芯片弹窗已有 token 明细,芯片只承载费用)。旧宿主无该插槽时告警禁用
18
+ - 定价规则编辑器:设置面板内按**模型分组**编辑,组头 = 模型键(整组一次改名,改后即时重新聚合)+ 删除整组;组内 = **默认价**槽(价格四桶,不设条件——附加规则全不命中时兜底,禁排序/删除)+ **附加计费规则**列表(四桶单价 + 条件组合,「+添加条件」追加条件行,行内下拉切换类型,规则带 ↑/↓ 调序与删除),「添加额外计费规则」追加一条(默认带全天时段条件);货币为全局切换(「定价规则」标题右侧,仅 ¥/$ 两档,整表统一),显式「保存」整表写入
19
19
  - 双语:跟随宿主语言设置(设置 → 通用 → 语言)即时切换 en/zh
20
20
  - 数据 API 守卫:POST 同源校验 + JSON content-type(与 dsh-usage-panel 同构),局域网远程访问可用
21
21
 
22
- 口径:token 总量 = 未缓存输入 + 输出 + 缓存读 + 缓存写;命中率 = 缓存读 / (缓存读 + 未缓存输入 + 缓存写);桶按 host 本地时区。平均生成速度 = 输出 token ÷ 模型时长,时长为 `step/start` 到该步首个 usage 汇报(与官方 session-stats 投影 llmMs 口径同构,`llm/retry-started` 重置起点,时长只含最终尝试);仅带时长的行参与速度分子分母,升级前存量数据与未观测到起点的样本不显示速度。保留策略:天桶永久,小时桶固定 15 天,分钟桶默认 7 天且上限 7 天(设置项 `minuteRetentionDays`,0 = 禁用分钟桶)。
22
+ 口径:token 总量 = 未缓存输入 + 输出 + 缓存读 + 缓存写;命中率 = 缓存读 / (缓存读 + 未缓存输入 + 缓存写);桶按 host 本地时区。平均生成速度 = decode 配对分子 ÷ 解码时长,两者均取官方吞吐口径(与官方 session-stats 投影同构):解码时长为该步首 token 时刻(首个产出 token 的 attempt 流,回落 message 自带流)到 usage 汇报时刻,不含首 token 前的排队与提示处理等待;首 token 延迟(TTFT)= token 时刻 − `step/start`(起点不随 `llm/retry-started` 重置,即含失败尝试时间)。首 token 时刻在 chunk token 样本上不可得,由后续 usage 报告(message 为主)补发零桶 timing 增量行承载(decodeTokens 与时长同源配对,不重复计 token);存量旧行(时长为旧全时长口径)速度分子回落输出 token,聚合随新数据自然收敛,重建(重扫)可全量按新口径重建。保留策略:天桶永久,小时桶固定 15 天,分钟桶默认 7 天且上限 7 天(设置项 `minuteRetentionDays`,0 = 禁用分钟桶)。
23
23
 
24
24
  写入模型:样本先同步合并进内存 pending,按 2 秒周期批量落盘(单布局存储域每次持久化写都全量重发布 unit 文档,合并把每样本 3 次写降为每脏行 1 次,回扫万级样本写放大降约 99%);查询前自动 flush 保证读己之写;flush 失败的行留 pending 下轮重试并经异常日志可观测,崩溃丢失窗口 = flush 周期,统计可由会话重扫重建。
25
25
 
@@ -27,16 +27,17 @@
27
27
 
28
28
  规则存于设置存储域,经 `GET/POST /api/usage-dash/pricing` 读写(响应含单调 revision);设置面板编辑器为常规入口。
29
29
 
30
- 规则形态:`{ model, currency, price: { input, output, cacheRead, cacheWrite }, conditions }`,单价单位固定「每百万 token」。货币为编辑器级全局设置:「定价规则」标题右侧切换,仅 ¥/$ 两档(无「空」档),打开编辑器即按首个非空货币归一显示(无非空回落 ¥),切换或保存后整表统一;wire 形态不变(仍为逐规则字段),存量空货币规则经编辑器保存后归一。费用显示为全局价格定位:汇总卡/趋势悬浮/模型列表/底栏费用项/单轮费用行的货币符号统一取规则表首个非空货币(数值仍按命中规则单价计算),切换并保存后全部显示点随之变更。
30
+ 规则形态:`{ model, currency, price: { input, output, cacheRead, cacheWrite }, conditions }`,单价单位固定「每百万 token」。货币为编辑器级全局设置:「定价规则」标题右侧切换,仅 ¥/$ 两档(无「空」档),打开编辑器即按首个非空货币归一显示(无非空回落 ¥),切换或保存后整表统一;wire 形态不变(仍为逐规则字段),存量空货币规则经编辑器保存后归一。费用显示为全局价格定位:汇总卡/趋势悬浮/模型列表/底栏费用项/回合费用芯片的货币符号统一取规则表首个非空货币(数值仍按命中规则单价计算),切换并保存后全部显示点随之变更。
31
31
 
32
32
  - 模型匹配:`model` 为两段式 `vendor/model`(首个 `/` 分段,模型段允许含 `/`),两段各自可 `*` 通配;匹配链为 全名精确 > 模型名精确(`*/model`,跨供应商同模型名同价)> 供应商精确(`vendor/*`,同供应商多模型同价)> `*/*` 全通,档位相同按数组序取首个命中,高档条件不满足逐层落低档;全链无命中不计费用并计 unpriced。提交(编辑器保存与 pricing POST)强制两段式,单段旧形态(`*` 或裸名)不再合法
33
- - 条件类型(数组内 AND,空数组恒生效),编辑器支持添加/编辑/删除:每规则卡下方「+时段/+星期/+号段/+日期段」按钮即添加对应类型默认条件(时段默认全天、周几默认空、号段默认 1~31、日期段默认当天),类型下拉切换即重置为该类型默认值;编辑器端校验拒绝非法时刻(需 HH:MM)、周几(需 0-6)、月号( 1-31)、非规范日期与倒序日期段,不合法不可保存
34
- - `dailyWindow`:`{ from: 'HH:MM', to: 'HH:MM' }` 每日时段,from<to 含头不含尾,from>to 跨午夜,from===to 全天生效
35
- - `weekdays`:`{ days: [0-6] }` 星期几,0=周日,空数组不成立;编辑器为日~六七枚 pill 多选
36
- - `monthDays`:`{ from, to }` 月内号段双闭整数,from>to 跨月环绕(账单周期),2 月无 31 号自然不触发
37
- - `dateRange`:`{ from: 'YYYY-MM-DD', to: 'YYYY-MM-DD' }` 字典序双闭,倒序视为配置错误不成立
33
+ - 同模型多规则(分时段定价):组内附加计费规则从上到下首个「条件全过」者生效,全不命中落组内默认价(无条件规则,恒兜底);附加规则卡带 ↑/↓ 调序。注意同一模型的多条规则请使用相同的模型键写法——不同写法( `*/model` 与 `vendor/model`)分属不同档位,按档位优先级而非数组序取胜
34
+ - 条件类型(数组内 AND,空数组恒生效),**所有范围条件统一双侧包含**(含起始含结束,from===to 即单点/单日;相邻区间请写 1~15 与 16~31,端点重叠时数组靠前者优先),编辑器支持添加/编辑/删除:每条计费规则下方「+添加条件」按钮追加一条默认条件(时段默认全天 00:00~23:59,行内类型下拉切换即重置为该类型默认值:周几默认空、号段默认全月 1~31、日期段默认当天单日);编辑器端校验拒绝非法时刻(需 HH:MM)、周几(需 0-6)、月号(需 1-31)、非规范日期与日期段倒序(时段/号段倒序是跨午夜/跨月环绕,合法)
35
+ - `dailyWindow`:`{ from: 'HH:MM', to: 'HH:MM' }` 每日时段双侧包含,from<to 正向,from>to 跨午夜,from===to 单点;全天即 00:00~23:59
36
+ - `weekdays`:`{ days: [0-6] }` 星期几(集合,非范围),0=周日,空数组不成立;编辑器为日~六七枚 pill 多选
37
+ - `monthDays`:`{ from, to }` 月内号段双侧包含整数(1-31),from>to 跨月环绕(账单周期),from===to 单日(如 5~5 即 5 号);2 月无 31 号自然不触发
38
+ - `dateRange`:`{ from: 'YYYY-MM-DD', to: 'YYYY-MM-DD' }` 零填充字典序双侧包含,from===to 单日,from>to 倒序不成立(不可保存)
38
39
  - 费用精度:**小时级**——聚合按小时桶起点时刻匹配价格,分钟槽费用由其所属小时桶价格导出;改价即时生效,历史费用下次查询按新规则重算(不回溯账单)
39
- - 时区口径:匹配与聚合均用 host 进程本地时区;client 侧注入点(底栏费用项/单轮费用行)按**浏览器本地时区**的当前时刻评估条件,跨时区访问时与面板费用存在预期内偏差;所有费用均为按当前费率的估算值(标注「≈」与「估算」),不构成账单
40
+ - 时区口径:匹配与聚合均用 host 进程本地时区;client 侧注入点(底栏费用项/回合费用芯片)按**浏览器本地时区**的当前时刻评估条件,跨时区访问时与面板费用存在预期内偏差;所有费用均为按当前费率的估算值(标注「≈」与「估算」),不构成账单
40
41
 
41
42
  ## 与 dsh-usage-statistics-panel 的关系
42
43
 
@@ -44,6 +45,46 @@
44
45
 
45
46
  同装时两插件争抢会话底栏 'stats' 槽位:槽注册表对同 id 同 priority 直接抛错,本插件以更低 priority 注册遮蔽原插件(lowest renders),同装时本插件胜出、卸载本插件后原插件恢复。仍建议卸载原插件以避免重复采集。
46
47
 
48
+ ## 存档兼容
49
+
50
+ 回扫经内部存档读取适配层(`src/archive-reader.js`)访问宿主 `sessionPersistence`:按能力检测在宿主 API 代际间分派(现役 `open('read')` 句柄式 / 旧代 `inspect` 一次整读),宿主再变只增适配器,回扫主体不动。旧格式会话存档文件(v0 起)由宿主迁移链在读路径统一转换为当前事件词汇,新旧多版本存档均可解析。
51
+
52
+ ### 降级直读(宿主拒读时的恢复能力)
53
+
54
+ 宿主对部分档案 fail-closed 拒读(descriptor 元数据校验、格式代际校验、seq gap 等),但档案数据本身完好。适配层在宿主 `open`/`read`/`inspect` 拒读时自动降级为**文件直读**(`src/direct-log-reader.js`):定位 `sessions/` 下的会话档案,按 zstd 帧解压(Node 内置 `node:zlib`,零外部依赖、零宿主模块),JSONL 宽松解析出 usage 词汇交由统计折叠。统计只消费 `assistant/message` 的用量与 `request/context` 路由,无需完整会话语义——corrupt(seq gap)与 legacy(未知成员)对统计无影响,直读天然免疫。`list` 同样并入磁盘直扫,补齐旧宿主不枚举的新代文件名档案。
55
+
56
+ 实测(0.1.5-alpha.1,同一批 1023 个可见档案):纯宿主读取 503 成功 / 520 拒读;直读降级后 **1023 全部恢复,skipped=0**,近 7 天统计从 30.5 亿 tokens 回补至 35.2 亿(+4.7 亿 tokens、+7857 请求为拒读档沉淀的真实用量)。
57
+
58
+ ### 宿主可见性边界(0.1.1-rc.2 / 0.1.2-rc.1 / 0.1.5-alpha.1 实测)
59
+
60
+ 三代宿主对同一批 1036 个会话档(1000 个旧格式 `session.jsonl.zstd` + 36 个 V3 格式 `session.v3.jsonl.zstd`)的枚举与解析能力各不相同:
61
+
62
+ | 宿主 | 列出 | 宿主可解析 | 直读补齐后 |
63
+ |---|---|---|---|
64
+ | 0.1.1-rc.2 | 1000 | 566 | V3 档经直读可见可解析 |
65
+ | 0.1.2-rc.1 | 1000 | 993 | V3 档经直读可见可解析 |
66
+ | 0.1.5-alpha.1 | 1023 | 503 | 520 个拒读档经直读全部恢复 |
67
+
68
+ 插件无法修正宿主自身的解析语义,但拒读档的恢复不再依赖宿主修复:失败不进游标,每轮扫描自动重试直读。
69
+
70
+ ### 跨宿主互补回扫
71
+
72
+ 游标与统计存于全局 storage-domain(跨 profile/宿主共享):某代宿主读得动的档,扫过即永久入账;读不动的档留给能读的宿主。遇到 0.1.5 下 skipped 偏多时,可用旧宿主跑一轮互补:
73
+
74
+ ```sh
75
+ # 以 0.1.2-rc.1(实测兼容性最好)挂同一份会话数据启动,扫完即走
76
+ dsh --profile <profile> --no-open --port 9191
77
+ ```
78
+
79
+ 两代扫描的并集即当前可达的历史全集。
80
+
81
+ > **警告:多宿主测试必须隔离 `DSH_HOME`**,仅建独立 profile 不够——`~/.dsh/storages/` 全局共享,旧版本插件(旧 store schema)在旧宿主上扫描后会把统计域覆盖成旧格式,新版本宿主读到不认识的 schema 即清空重建(游标与样本丢失,靠全量回扫自愈)。正确姿势:
82
+ >
83
+ > ```sh
84
+ > $env:DSH_HOME = "$env:USERPROFILE\.dsh-probe" # PowerShell;bash 用 export DSH_HOME=...
85
+ > dsh web --no-open --port 9191
86
+ > ```
87
+
47
88
  ## 注意
48
89
 
49
90
  装载本插件需要它在 `dsh.profile.bundles`(`dsh plugin --profile web add @mzzsfy/dsh-usage-dash`);bundles 表变化不支持热重载,需重启 dsh 生效。之后代码改动:宿主半区经 dev-link 自动热重载,client 半区刷新页面即生效。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-usage-dash",
3
- "description": "用量统计面板:天/小时/分钟三粒度 token 与请求统计,26 周活跃热力图,会话底栏信息接管与回合级用量注入,多条件分时段定价规则与费用估算;复刻自 github.com/HaoyueQin/dsh-usage-statistics-panel,感谢原作者 HaoyueQin",
4
- "version": "0.3.0",
3
+ "description": "用量统计面板:天/小时/分钟三粒度 token 与请求统计,26 周活跃热力图,会话底栏信息接管与官方动作行回合费用芯片,多条件分时段定价规则与费用估算;复刻自 github.com/HaoyueQin/dsh-usage-statistics-panel,感谢原作者 HaoyueQin",
4
+ "version": "0.5.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
@@ -0,0 +1,138 @@
1
+ // 存档读取适配层:宿主 sessionPersistence 服务 API 按代演化(inspect 一次
2
+ // 整读 → open(read) 句柄式分片),本层把各代形状折叠为内部统一契约,collector
3
+ // 只面向契约编程,宿主再变只增适配器。旧格式会话存档文件(v0 起)由宿主
4
+ // 迁移链在读路径统一转换为当前事件词汇,适配层与文件格式代际解耦。
5
+ // 分页读与 close 吞错语义对齐竞品 dsh-usage-statistics-panel 0.1.12 的
6
+ // backfill 双路径实现;V3 专有事件由折叠层默认忽略,测试钉住。
7
+ //
8
+ // 统一契约(ArchiveReader):
9
+ // list(signal) → Promise<readonly { id }[]> 枚举全部已存会话
10
+ // readLog(id, signal) → Promise<{ inheritedEventCount, events }> 整读单会话全部事件
11
+
12
+ import { listSessionIdsDirect, readSessionLogDirect } from './direct-log-reader.js'
13
+
14
+ const READER_HINT = 'sessionPersistence API 未识别(宿主再次升级?),请反馈补充适配器'
15
+ const HANDLE_READ_PAGE = 500
16
+
17
+ // 宿主 fail-closed 拒读的原因词型:descriptor 元数据校验、格式代际校验、
18
+ // seq 完整性校验。命中即降级文件直读(档案数据本身完好,只是校验不放行);
19
+ // 词型必须精确,任意读失败(网络/权限/不存在)不得误判
20
+ const HOST_REFUSAL_PATTERN = /unsupported descriptor version|stored log is corrupt|SessionFormatError|format v\d|unexpected member|seq gap/i
21
+
22
+ function isHostRefusal(error) {
23
+ return HOST_REFUSAL_PATTERN.test(error instanceof Error ? error.message : String(error))
24
+ }
25
+
26
+ // 现役宿主:list 返回 snapshot(id 在 .header),整读走 read 句柄分页循环。
27
+ // 信号包裹为 options 对象;read 失败仍保证 close。close 失败吞掉:句柄
28
+ // 关闭失败不代表已读数据无效,数据可用性优先。有意不附加
29
+ // interruptedTurnClosers:crash 中断的 turn 不计 turn/end,与两代既有
30
+ // 统计口径一致,不为统计引入宿主模块依赖。
31
+ class HandleArchiveReader {
32
+ constructor(persistence) {
33
+ this.persistence = persistence
34
+ }
35
+
36
+ async list(signal) {
37
+ const snapshots = await this.persistence.list(signal === undefined ? undefined : { signal })
38
+ // 畸形行(无有效 header.id)丢弃不炸,单个坏行不放大为整轮失败;
39
+ // 宿主 list 不枚举的磁盘档案(旧宿主不见新代文件名)由直读侧补齐
40
+ const ids = new Set(
41
+ snapshots
42
+ .filter((snapshot) => typeof snapshot?.header?.id === 'string' && snapshot.header.id !== '')
43
+ .map((snapshot) => snapshot.header.id),
44
+ )
45
+ for (const id of listSessionIdsDirect()) ids.add(id)
46
+ return [...ids].map((id) => ({ id }))
47
+ }
48
+
49
+ async readLog(id, signal) {
50
+ const options = signal === undefined ? undefined : { signal }
51
+ // descriptor 元数据校验在 open 阶段拒读,open 必须同在降级范围内
52
+ let handle
53
+ try {
54
+ handle = await this.persistence.open(id, 'read', options)
55
+ const inherited = handle.inheritedEventCount
56
+ const inheritedEventCount = typeof inherited === 'number' && Number.isSafeInteger(inherited) && inherited >= 0 ? inherited : 0
57
+ const events = []
58
+ let offset = 0
59
+ for (;;) {
60
+ if (signal?.aborted) return { inheritedEventCount, events }
61
+ const slice = await handle.read(offset, HANDLE_READ_PAGE, options)
62
+ const page = slice?.events ?? []
63
+ if (page.length === 0) break
64
+ for (const event of page) events.push(event)
65
+ offset += page.length
66
+ }
67
+ return { inheritedEventCount, events }
68
+ } catch (error) {
69
+ // 宿主校验拒读而档案数据完好:降级文件直读恢复统计;直读失败
70
+ // (档案缺失等)回抛原拒读错误,降级是尽力而为,不掩盖原状态
71
+ if (isHostRefusal(error)) {
72
+ try {
73
+ return { inheritedEventCount: 0, events: readSessionLogDirect(id) }
74
+ } catch {
75
+ throw error
76
+ }
77
+ }
78
+ throw error
79
+ } finally {
80
+ await handle?.close().catch(() => {})
81
+ }
82
+ }
83
+ }
84
+
85
+ // 旧代宿主:list 直接返回 header 数组,inspect 一次整读;两处 signal 均直传。
86
+ class InspectArchiveReader {
87
+ constructor(persistence) {
88
+ this.persistence = persistence
89
+ }
90
+
91
+ async list(signal) {
92
+ const headers = await this.persistence.list(signal)
93
+ const ids = new Set(
94
+ headers
95
+ .filter((header) => typeof header?.id === 'string' && header.id !== '')
96
+ .map((header) => header.id),
97
+ )
98
+ for (const id of listSessionIdsDirect()) ids.add(id)
99
+ return [...ids].map((id) => ({ id }))
100
+ }
101
+
102
+ async readLog(id, signal) {
103
+ try {
104
+ const inspection = await this.persistence.inspect(id, signal)
105
+ const inherited = inspection.inheritedEventCount
106
+ const inheritedEventCount = typeof inherited === 'number' && Number.isSafeInteger(inherited) && inherited >= 0 ? inherited : 0
107
+ return { inheritedEventCount, events: inspection.events ?? [] }
108
+ } catch (error) {
109
+ // 宿主校验拒读而档案数据完好:降级文件直读恢复统计;直读失败回抛原错
110
+ if (isHostRefusal(error)) {
111
+ try {
112
+ return { inheritedEventCount: 0, events: readSessionLogDirect(id) }
113
+ } catch {
114
+ throw error
115
+ }
116
+ }
117
+ throw error
118
+ }
119
+ }
120
+ }
121
+
122
+ // 形状都不匹配:恒抛错,失败经回扫入口与启动兜底日志可见,提示补新适配器。
123
+ class UnknownArchiveReader {
124
+ async list() {
125
+ throw new Error(READER_HINT)
126
+ }
127
+
128
+ async readLog() {
129
+ throw new Error(READER_HINT)
130
+ }
131
+ }
132
+
133
+ // 特性检测分派:能力优先于版本号探测。
134
+ export function createArchiveReader(persistence) {
135
+ if (typeof persistence?.open === 'function') return new HandleArchiveReader(persistence)
136
+ if (typeof persistence?.inspect === 'function') return new InspectArchiveReader(persistence)
137
+ return new UnknownArchiveReader()
138
+ }