@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 +55 -14
- package/package.json +2 -2
- package/src/archive-reader.js +138 -0
- package/src/client.js +539 -128
- package/src/collector.js +175 -29
- package/src/direct-log-reader.js +126 -0
- package/src/pricing.js +5 -6
- package/src/query.js +38 -14
- package/src/routes.js +14 -1
- package/src/store.js +86 -4
- package/test/archive-reader.test.mjs +365 -0
- package/test/client.test.mjs +210 -12
- package/test/collector.test.mjs +458 -19
- package/test/direct-log-reader.test.mjs +104 -0
- package/test/pricing-parity.test.mjs +38 -4
- package/test/pricing.test.mjs +50 -18
- package/test/query.test.mjs +50 -1
- package/test/routes.test.mjs +9 -2
- package/test/stats-line.test.mjs +6 -6
- package/test/store.test.mjs +116 -1
- package/test/stream-parity.test.mjs +75 -0
- package/test/turn-tail.test.mjs +161 -41
package/src/store.js
CHANGED
|
@@ -82,8 +82,12 @@ export const usageRowSchema = z.object({
|
|
|
82
82
|
outputTokens: z.number(),
|
|
83
83
|
cacheReadTokens: z.number(),
|
|
84
84
|
cacheWriteTokens: z.number(),
|
|
85
|
-
// 模型时长累计(毫秒)
|
|
85
|
+
// 模型时长累计(毫秒)与 decode 口径速度分子:optional 兼容存量记录(域 open 逐记录 parse)
|
|
86
86
|
durationMs: z.number().optional(),
|
|
87
|
+
decodeTokens: z.number().optional(),
|
|
88
|
+
// 首 token 延迟累计(毫秒)与样本步数:optional 兼容存量记录
|
|
89
|
+
ttftMs: z.number().optional(),
|
|
90
|
+
ttftSteps: z.number().optional(),
|
|
87
91
|
requests: z.number(),
|
|
88
92
|
turns: z.number(),
|
|
89
93
|
lastSeen: z.number(),
|
|
@@ -104,6 +108,24 @@ export const usageStatsDomain = defineDomain({
|
|
|
104
108
|
},
|
|
105
109
|
})
|
|
106
110
|
|
|
111
|
+
// 重建回退备份域:与主域同构表,global 多记 takenAt(快照时刻)。
|
|
112
|
+
// 独立域而非主域内备份表——reset 清主域数据,同域备份会陪葬
|
|
113
|
+
export const usageBackupDomain = defineDomain({
|
|
114
|
+
name: `${DOMAIN_NAME}_backup`,
|
|
115
|
+
version: 1,
|
|
116
|
+
tables: {
|
|
117
|
+
[TABLE_BUCKETS]: domainTable(usageRowSchema),
|
|
118
|
+
},
|
|
119
|
+
global: {
|
|
120
|
+
schema: z.object({
|
|
121
|
+
backfilledSessions: z.array(z.string()),
|
|
122
|
+
liveFirstSeq: z.record(z.string(), z.number()).optional(),
|
|
123
|
+
takenAt: z.number().optional(),
|
|
124
|
+
}),
|
|
125
|
+
initial: { backfilledSessions: [], liveFirstSeq: {}, takenAt: undefined },
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
|
|
107
129
|
export function rowKey(g, bucket, provider, model) {
|
|
108
130
|
return `${g}|${bucket}|${provider}|${model}`
|
|
109
131
|
}
|
|
@@ -118,13 +140,17 @@ function emptyRow(bucket, provider, model, nowMs) {
|
|
|
118
140
|
cacheReadTokens: 0,
|
|
119
141
|
cacheWriteTokens: 0,
|
|
120
142
|
durationMs: 0,
|
|
143
|
+
decodeTokens: 0,
|
|
144
|
+
ttftMs: 0,
|
|
145
|
+
ttftSteps: 0,
|
|
121
146
|
requests: 0,
|
|
122
147
|
turns: 0,
|
|
123
148
|
lastSeen: nowMs,
|
|
124
149
|
}
|
|
125
150
|
}
|
|
126
151
|
|
|
127
|
-
// 样本折叠为计数增量:turn/request 只计次,token
|
|
152
|
+
// 样本折叠为计数增量:turn/request 只计次,token 样本累加四类桶;
|
|
153
|
+
// decodeTokens 与 durationMs 同源配对构成速度,timing 缺失按零累计
|
|
128
154
|
function deltaOf(sample, nowMs) {
|
|
129
155
|
const delta = emptyRow('', '', '', nowMs)
|
|
130
156
|
delete delta.bucket
|
|
@@ -138,6 +164,9 @@ function deltaOf(sample, nowMs) {
|
|
|
138
164
|
delta.cacheReadTokens = sample.cacheReadTokens
|
|
139
165
|
delta.cacheWriteTokens = sample.cacheWriteTokens
|
|
140
166
|
delta.durationMs = sample.durationMs ?? 0
|
|
167
|
+
delta.decodeTokens = sample.decodeTokens ?? 0
|
|
168
|
+
delta.ttftMs = sample.ttftMs ?? 0
|
|
169
|
+
delta.ttftSteps = sample.ttftMs !== undefined ? 1 : 0
|
|
141
170
|
}
|
|
142
171
|
return delta
|
|
143
172
|
}
|
|
@@ -152,6 +181,9 @@ function addDelta(base, delta) {
|
|
|
152
181
|
cacheWriteTokens: base.cacheWriteTokens + delta.cacheWriteTokens,
|
|
153
182
|
// base 侧 ?? 0 容存量旧格式行(缺字段);delta 侧经 deltaOf 恒为数值
|
|
154
183
|
durationMs: (base.durationMs ?? 0) + delta.durationMs,
|
|
184
|
+
decodeTokens: (base.decodeTokens ?? 0) + delta.decodeTokens,
|
|
185
|
+
ttftMs: (base.ttftMs ?? 0) + delta.ttftMs,
|
|
186
|
+
ttftSteps: (base.ttftSteps ?? 0) + delta.ttftSteps,
|
|
155
187
|
requests: base.requests + delta.requests,
|
|
156
188
|
turns: base.turns + delta.turns,
|
|
157
189
|
lastSeen: Math.max(base.lastSeen, delta.lastSeen),
|
|
@@ -171,6 +203,8 @@ export class UsageStore {
|
|
|
171
203
|
this.onFlushError = options.onFlushError
|
|
172
204
|
this.table = null
|
|
173
205
|
this.domain = null
|
|
206
|
+
this.backupDomain = undefined
|
|
207
|
+
this.backupTable = undefined
|
|
174
208
|
this.openError = undefined
|
|
175
209
|
this.lastPruneDay = ''
|
|
176
210
|
this.markChain = Promise.resolve()
|
|
@@ -191,6 +225,9 @@ export class UsageStore {
|
|
|
191
225
|
if ((cursor?.backfilledSessions?.length ?? 0) === 0 && !this.table.keys().next().done) {
|
|
192
226
|
for (const key of [...this.table.keys()]) await this.table.delete(key)
|
|
193
227
|
}
|
|
228
|
+
// 备份域打开失败仅禁用回退能力,不拖垮主采集(openError 只覆盖主域)
|
|
229
|
+
this.backupDomain = await facility.open(usageBackupDomain).catch(() => undefined)
|
|
230
|
+
this.backupTable = this.backupDomain?.table(TABLE_BUCKETS)
|
|
194
231
|
}
|
|
195
232
|
|
|
196
233
|
get degradation() {
|
|
@@ -375,6 +412,9 @@ export class UsageStore {
|
|
|
375
412
|
reset(boundaries) {
|
|
376
413
|
return this.enqueueGlobalWrite(async () => {
|
|
377
414
|
await this.flushBatch()
|
|
415
|
+
// 清空前快照到备份域:重建丢数据(规则缺陷/中断/口径变化)可整体回退;
|
|
416
|
+
// 备份不可用视为 reset 失败——回退安全带是重建的前置条件
|
|
417
|
+
await this.snapshotToBackup()
|
|
378
418
|
const table = this.requireTable()
|
|
379
419
|
for (const key of [...table.keys()]) await table.delete(key)
|
|
380
420
|
const liveFirstSeq = {}
|
|
@@ -383,11 +423,53 @@ export class UsageStore {
|
|
|
383
423
|
})
|
|
384
424
|
}
|
|
385
425
|
|
|
386
|
-
//
|
|
426
|
+
// 当前全量(表行 + 游标 + 时刻)写入备份域,覆盖旧备份:恢复点恒为最近一次清空/恢复前;
|
|
427
|
+
// 备份域不可用时跳过(重建不被卡死,仅失去回退点,backupInfo 透明展示)
|
|
428
|
+
async snapshotToBackup() {
|
|
429
|
+
if (this.backupTable === undefined) return
|
|
430
|
+
const cursor = this.readCursor() ?? {}
|
|
431
|
+
for (const key of [...this.backupTable.keys()]) await this.backupTable.delete(key)
|
|
432
|
+
for (const [key, row] of this.requireTable().entries()) await this.backupTable.put(key, row)
|
|
433
|
+
await this.backupDomain.global.set({
|
|
434
|
+
backfilledSessions: cursor.backfilledSessions ?? [],
|
|
435
|
+
liveFirstSeq: cursor.liveFirstSeq ?? {},
|
|
436
|
+
takenAt: this.now(),
|
|
437
|
+
})
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// 备份写回主域;当前态先入备份(恢复动作自身可回退),故必须先取备份内容
|
|
441
|
+
// 再覆盖备份域;返回快照时刻与回写行数
|
|
442
|
+
async restoreFromBackup() {
|
|
443
|
+
if (this.backupTable === undefined) throw new Error('usage backup domain unavailable')
|
|
444
|
+
return this.enqueueGlobalWrite(async () => {
|
|
445
|
+
await this.flushBatch()
|
|
446
|
+
const savedRows = [...this.backupTable.entries()]
|
|
447
|
+
const saved = this.backupDomain.global.get()
|
|
448
|
+
await this.snapshotToBackup()
|
|
449
|
+
for (const key of [...this.requireTable().keys()]) await this.table.delete(key)
|
|
450
|
+
for (const [key, row] of savedRows) await this.table.put(key, row)
|
|
451
|
+
await this.writeGlobal(saved?.backfilledSessions ?? [], saved?.liveFirstSeq ?? {})
|
|
452
|
+
return { takenAt: saved?.takenAt, rows: savedRows.length }
|
|
453
|
+
})
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// 备份元信息:available=false 表示无回退点(从未重建/备份域不可用)
|
|
457
|
+
backupInfo() {
|
|
458
|
+
if (this.backupTable === undefined) return { available: false }
|
|
459
|
+
const saved = this.backupDomain.global.get()
|
|
460
|
+
return {
|
|
461
|
+
available: typeof saved?.takenAt === 'number',
|
|
462
|
+
takenAt: saved?.takenAt,
|
|
463
|
+
rows: [...this.backupTable.keys()].length,
|
|
464
|
+
sessions: saved?.backfilledSessions?.length ?? 0,
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// global 只有整值覆写:全部游标写挂同一链,读改写不再交错;write 返回值透传
|
|
387
469
|
enqueueGlobalWrite(write) {
|
|
388
470
|
const pending = this.markChain.then(async () => {
|
|
389
471
|
await this.ready
|
|
390
|
-
|
|
472
|
+
return write()
|
|
391
473
|
})
|
|
392
474
|
this.markChain = pending.then(() => {}, () => {})
|
|
393
475
|
return pending
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
// archive-reader 行为测试:宿主 sessionPersistence API 多版本适配层的
|
|
2
|
+
// 工厂分派与各代映射。宿主 API 形状以各代真实签名为蓝本,collector 只见
|
|
3
|
+
// 内部统一契约(list → [{id}],readLog → {inheritedEventCount, events})。
|
|
4
|
+
// 分页读、close 吞错、畸形行丢弃对齐竞品 0.1.12 双路径语义;
|
|
5
|
+
// V3 日志兼容由 collector 测试的专有事件忽略用例钉住。
|
|
6
|
+
|
|
7
|
+
import test from 'node:test'
|
|
8
|
+
import assert from 'node:assert/strict'
|
|
9
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
|
|
10
|
+
import { tmpdir } from 'node:os'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { zstdCompressSync } from 'node:zlib'
|
|
13
|
+
|
|
14
|
+
import { createArchiveReader } from '../src/archive-reader.js'
|
|
15
|
+
|
|
16
|
+
const ev = (seq) => ({ type: 'turn/end', seq, time: 0, data: { turn: 0 } })
|
|
17
|
+
const NAMES = {
|
|
18
|
+
handle: 'HandleArchiveReader',
|
|
19
|
+
inspect: 'InspectArchiveReader',
|
|
20
|
+
unknown: 'UnknownArchiveReader',
|
|
21
|
+
}
|
|
22
|
+
const nameOf = (reader) => reader.constructor.name
|
|
23
|
+
|
|
24
|
+
test('工厂:open 形宿主分派到 handle 代适配器', () => {
|
|
25
|
+
const persistence = { open: () => {}, list: () => {} }
|
|
26
|
+
assert.equal(nameOf(createArchiveReader(persistence)), NAMES.handle)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('工厂:inspect 形宿主分派到 inspect 代适配器', () => {
|
|
30
|
+
const persistence = { inspect: () => {}, list: () => {} }
|
|
31
|
+
assert.equal(nameOf(createArchiveReader(persistence)), NAMES.inspect)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('工厂:两代形状都不匹配分派到 unknown 恒抛适配器', async () => {
|
|
35
|
+
const reader = createArchiveReader({})
|
|
36
|
+
assert.equal(nameOf(reader), NAMES.unknown)
|
|
37
|
+
await assert.rejects(reader.list(), /sessionPersistence/)
|
|
38
|
+
await assert.rejects(reader.readLog('s1'), /sessionPersistence/)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('handle 代:list 归一 snapshot 为 {id},signal 包裹透传,畸形行丢弃', () => {
|
|
42
|
+
return withDirectRoot(async () => {
|
|
43
|
+
const seen = []
|
|
44
|
+
const persistence = {
|
|
45
|
+
async list(options) {
|
|
46
|
+
seen.push(options)
|
|
47
|
+
return [
|
|
48
|
+
{ header: { id: 'a' }, revision: 'r1', sizeBytes: 10 },
|
|
49
|
+
{ header: { id: 'b' }, revision: 'r2' },
|
|
50
|
+
{ revision: 'no-header' },
|
|
51
|
+
null,
|
|
52
|
+
{ header: { id: '' } },
|
|
53
|
+
]
|
|
54
|
+
},
|
|
55
|
+
async open() {
|
|
56
|
+
throw new Error('not reached')
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
const signal = new AbortController().signal
|
|
60
|
+
const reader = createArchiveReader(persistence)
|
|
61
|
+
assert.deepEqual(await reader.list(signal), [{ id: 'a' }, { id: 'b' }])
|
|
62
|
+
assert.deepEqual(seen, [{ signal }])
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('handle 代:readLog 分页循环直到空页,handle 元数据透出', async () => {
|
|
67
|
+
const calls = []
|
|
68
|
+
const events = Array.from({ length: 1250 }, (_, seq) => ev(seq))
|
|
69
|
+
const persistence = {
|
|
70
|
+
async open(id, access, options) {
|
|
71
|
+
calls.push(['open', id, access, options])
|
|
72
|
+
return {
|
|
73
|
+
header: { id },
|
|
74
|
+
inheritedEventCount: 3,
|
|
75
|
+
async read(offset, length, readOptions) {
|
|
76
|
+
calls.push(['read', offset, length])
|
|
77
|
+
assert.equal(typeof readOptions, 'object')
|
|
78
|
+
assert.equal(length, 500)
|
|
79
|
+
return { eventState: 'detached', events: events.slice(offset, offset + length) }
|
|
80
|
+
},
|
|
81
|
+
async close() {
|
|
82
|
+
calls.push(['close'])
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
const signal = new AbortController().signal
|
|
88
|
+
const reader = createArchiveReader(persistence)
|
|
89
|
+
const result = await reader.readLog('s1', signal)
|
|
90
|
+
assert.equal(result.inheritedEventCount, 3)
|
|
91
|
+
assert.deepEqual(result.events, events)
|
|
92
|
+
const reads = calls.filter(([kind]) => kind === 'read')
|
|
93
|
+
// 1250 条 = 500+500+250 三页取到,第 4 次读到空页才终止
|
|
94
|
+
assert.equal(reads.length, 4)
|
|
95
|
+
assert.deepEqual(reads.map(([, offset]) => offset), [0, 500, 1000, 1250])
|
|
96
|
+
assert.deepEqual(calls[calls.length - 1], ['close'])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('handle 代:inheritedEventCount 非法值回落 0', async () => {
|
|
100
|
+
const persistence = {
|
|
101
|
+
async open() {
|
|
102
|
+
return {
|
|
103
|
+
inheritedEventCount: -5,
|
|
104
|
+
async read() {
|
|
105
|
+
return { eventState: 'detached', events: [] }
|
|
106
|
+
},
|
|
107
|
+
async close() {},
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
const reader = createArchiveReader(persistence)
|
|
112
|
+
const result = await reader.readLog('s1')
|
|
113
|
+
assert.equal(result.inheritedEventCount, 0)
|
|
114
|
+
assert.deepEqual(result.events, [])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('handle 代:read 抛错时 close 仍被调用且原错误传播', async () => {
|
|
118
|
+
let closed = 0
|
|
119
|
+
const failure = new Error('corrupted log')
|
|
120
|
+
const persistence = {
|
|
121
|
+
async open() {
|
|
122
|
+
return {
|
|
123
|
+
inheritedEventCount: 0,
|
|
124
|
+
async read() {
|
|
125
|
+
throw failure
|
|
126
|
+
},
|
|
127
|
+
async close() {
|
|
128
|
+
closed += 1
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
const reader = createArchiveReader(persistence)
|
|
134
|
+
await assert.rejects(reader.readLog('s1'), (error) => error === failure)
|
|
135
|
+
assert.equal(closed, 1)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('handle 代:read 抛错且 close 也抛错时,原错误传播不被掩盖', async () => {
|
|
139
|
+
const failure = new Error('corrupted log')
|
|
140
|
+
const persistence = {
|
|
141
|
+
async open() {
|
|
142
|
+
return {
|
|
143
|
+
inheritedEventCount: 0,
|
|
144
|
+
async read() {
|
|
145
|
+
throw failure
|
|
146
|
+
},
|
|
147
|
+
async close() {
|
|
148
|
+
throw new Error('close failure')
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
const reader = createArchiveReader(persistence)
|
|
154
|
+
await assert.rejects(reader.readLog('s1'), (error) => error === failure)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('handle 代:read 成功后 close 失败被吞掉,读取结果照常返回', async () => {
|
|
158
|
+
let closed = 0
|
|
159
|
+
const events = [ev(0)]
|
|
160
|
+
let reads = 0
|
|
161
|
+
const persistence = {
|
|
162
|
+
async open() {
|
|
163
|
+
return {
|
|
164
|
+
inheritedEventCount: 0,
|
|
165
|
+
async read() {
|
|
166
|
+
reads += 1
|
|
167
|
+
// 首读返回数据页,再读返回空页终止分页循环
|
|
168
|
+
return { eventState: 'detached', events: reads === 1 ? events : [] }
|
|
169
|
+
},
|
|
170
|
+
async close() {
|
|
171
|
+
closed += 1
|
|
172
|
+
throw new Error('close failure')
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
const reader = createArchiveReader(persistence)
|
|
178
|
+
const result = await reader.readLog('s1')
|
|
179
|
+
assert.equal(closed, 1)
|
|
180
|
+
assert.deepEqual(result, { inheritedEventCount: 0, events })
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('handle 代:abort 中断分页循环,当前页已读数据保留', async () => {
|
|
184
|
+
const controller = new AbortController()
|
|
185
|
+
const events = Array.from({ length: 700 }, (_, seq) => ev(seq))
|
|
186
|
+
const persistence = {
|
|
187
|
+
async open() {
|
|
188
|
+
return {
|
|
189
|
+
inheritedEventCount: 0,
|
|
190
|
+
async read(offset) {
|
|
191
|
+
if (offset > 0) controller.abort()
|
|
192
|
+
return { eventState: 'detached', events: events.slice(offset, offset + 500) }
|
|
193
|
+
},
|
|
194
|
+
async close() {},
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
}
|
|
198
|
+
const reader = createArchiveReader(persistence)
|
|
199
|
+
// abort 在第二页读取时触发:第二页已返回故被保留,下一轮循环检测到中止退出
|
|
200
|
+
const result = await reader.readLog('s1', controller.signal)
|
|
201
|
+
assert.equal(result.events.length, 700)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('handle 代:open 抛错直接传播,不触发 close', async () => {
|
|
205
|
+
const failure = new Error('format unsupported')
|
|
206
|
+
const persistence = {
|
|
207
|
+
async open() {
|
|
208
|
+
throw failure
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
const reader = createArchiveReader(persistence)
|
|
212
|
+
await assert.rejects(reader.readLog('s1'), (error) => error === failure)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test('inspect 代:list 归一 header 数组为 {id},signal 直传,畸形行丢弃', () => {
|
|
216
|
+
return withDirectRoot(async () => {
|
|
217
|
+
const seen = []
|
|
218
|
+
const persistence = {
|
|
219
|
+
async list(arg) {
|
|
220
|
+
seen.push(arg)
|
|
221
|
+
return [{ id: 'a', createdAt: 1 }, { id: 'b', createdAt: 2 }, null, {}]
|
|
222
|
+
},
|
|
223
|
+
async inspect() {
|
|
224
|
+
throw new Error('not reached')
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
const signal = new AbortController().signal
|
|
228
|
+
const reader = createArchiveReader(persistence)
|
|
229
|
+
assert.deepEqual(await reader.list(signal), [{ id: 'a' }, { id: 'b' }])
|
|
230
|
+
assert.deepEqual(seen, [signal])
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test('inspect 代:readLog 经 inspect 整读,signal 直传', async () => {
|
|
235
|
+
const seen = []
|
|
236
|
+
const events = [ev(0)]
|
|
237
|
+
const signal = new AbortController().signal
|
|
238
|
+
const persistence = {
|
|
239
|
+
async inspect(id, arg) {
|
|
240
|
+
seen.push([id, arg])
|
|
241
|
+
return { meta: { id }, inheritedEventCount: 2, events }
|
|
242
|
+
},
|
|
243
|
+
}
|
|
244
|
+
const reader = createArchiveReader(persistence)
|
|
245
|
+
assert.deepEqual(
|
|
246
|
+
await reader.readLog('s1', signal),
|
|
247
|
+
{ inheritedEventCount: 2, events },
|
|
248
|
+
)
|
|
249
|
+
assert.deepEqual(seen, [['s1', signal]])
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
// 降级直读的测试桩:DSH_HOME 指向临时根,档案落 sessions/<bucket>/<id>/
|
|
253
|
+
function withDirectRoot(run) {
|
|
254
|
+
const root = mkdtempSync(join(tmpdir(), 'ud-fallback-'))
|
|
255
|
+
process.env.DSH_HOME = root
|
|
256
|
+
return Promise.resolve(run(root)).finally(() => {
|
|
257
|
+
delete process.env.DSH_HOME
|
|
258
|
+
rmSync(root, { recursive: true, force: true })
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function seedArtifact(root, id, events) {
|
|
263
|
+
const text = [
|
|
264
|
+
JSON.stringify({ type: 'session/header', id }),
|
|
265
|
+
...events.map((event) => JSON.stringify(event)),
|
|
266
|
+
].join('\n')
|
|
267
|
+
const dir = join(root, 'sessions', 'bucket-x', id)
|
|
268
|
+
mkdirSync(dir, { recursive: true })
|
|
269
|
+
writeFileSync(join(dir, 'session.v3.jsonl.zstd'), zstdCompressSync(Buffer.from(text)))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
test('handle 代:宿主拒读(descriptor)降级文件直读', () => {
|
|
273
|
+
return withDirectRoot(async (root) => {
|
|
274
|
+
seedArtifact(root, 'refused', [ev(0), ev(1)])
|
|
275
|
+
const persistence = {
|
|
276
|
+
async open() {
|
|
277
|
+
return {
|
|
278
|
+
inheritedEventCount: 0,
|
|
279
|
+
async read() {
|
|
280
|
+
throw new Error('subagent session "refused" uses unsupported descriptor version 2')
|
|
281
|
+
},
|
|
282
|
+
async close() {},
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
}
|
|
286
|
+
const reader = createArchiveReader(persistence)
|
|
287
|
+
const result = await reader.readLog('refused')
|
|
288
|
+
assert.equal(result.inheritedEventCount, 0)
|
|
289
|
+
assert.equal(result.events.length, 2)
|
|
290
|
+
})
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
test('handle 代:open 阶段拒读(descriptor 校验)同样降级直读', () => {
|
|
294
|
+
return withDirectRoot(async (root) => {
|
|
295
|
+
seedArtifact(root, 'open-refused', [ev(3)])
|
|
296
|
+
const persistence = {
|
|
297
|
+
async open() {
|
|
298
|
+
throw new Error('session "open-refused" header uses unsupported descriptor version 2')
|
|
299
|
+
},
|
|
300
|
+
}
|
|
301
|
+
const reader = createArchiveReader(persistence)
|
|
302
|
+
const result = await reader.readLog('open-refused')
|
|
303
|
+
assert.equal(result.events.length, 1)
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
test('handle 代:open 拒读且直读失败时,原错误传播', () => {
|
|
308
|
+
return withDirectRoot(async () => {
|
|
309
|
+
const persistence = {
|
|
310
|
+
async open() {
|
|
311
|
+
throw new Error('session "absent" header uses unsupported descriptor version 2')
|
|
312
|
+
},
|
|
313
|
+
}
|
|
314
|
+
const reader = createArchiveReader(persistence)
|
|
315
|
+
await assert.rejects(reader.readLog('absent'), /descriptor version 2/)
|
|
316
|
+
})
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
test('handle 代:非拒读错误不触发直读,原错误传播', () => {
|
|
320
|
+
return withDirectRoot((root) => {
|
|
321
|
+
seedArtifact(root, 'gone', [ev(0)])
|
|
322
|
+
const persistence = {
|
|
323
|
+
async open() {
|
|
324
|
+
throw new Error('session "gone" not found')
|
|
325
|
+
},
|
|
326
|
+
}
|
|
327
|
+
const reader = createArchiveReader(persistence)
|
|
328
|
+
return assert.rejects(reader.readLog('gone'), /not found/)
|
|
329
|
+
})
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
test('handle 代:拒读且直读也失败时,原错误传播', () => {
|
|
333
|
+
return withDirectRoot(async (root) => {
|
|
334
|
+
const persistence = {
|
|
335
|
+
async open() {
|
|
336
|
+
return {
|
|
337
|
+
inheritedEventCount: 0,
|
|
338
|
+
async read() {
|
|
339
|
+
throw new Error('stored log is corrupt: SessionFormatError: seq gap')
|
|
340
|
+
},
|
|
341
|
+
async close() {},
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
}
|
|
345
|
+
const reader = createArchiveReader(persistence)
|
|
346
|
+
await assert.rejects(reader.readLog('absent'), /corrupt/)
|
|
347
|
+
})
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
test('handle 代:list 并集补齐宿主不可见的磁盘档案', () => {
|
|
351
|
+
return withDirectRoot(async (root) => {
|
|
352
|
+
seedArtifact(root, 'v3-only', [ev(0)])
|
|
353
|
+
const persistence = {
|
|
354
|
+
async list() {
|
|
355
|
+
return [{ header: { id: 'host-known' } }]
|
|
356
|
+
},
|
|
357
|
+
async open() {
|
|
358
|
+
throw new Error('not reached')
|
|
359
|
+
},
|
|
360
|
+
}
|
|
361
|
+
const reader = createArchiveReader(persistence)
|
|
362
|
+
const ids = (await reader.list()).map((row) => row.id).sort()
|
|
363
|
+
assert.deepEqual(ids, ['host-known', 'v3-only'])
|
|
364
|
+
})
|
|
365
|
+
})
|