dsh-skill-hub 0.3.8 → 0.3.10

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/src/index.ts CHANGED
@@ -19,7 +19,7 @@ import type {} from '@deepseek-ai/dsh-settings'
19
19
  import { HUB_CONFIG_DEFAULTS, HEX_COLOR_RE, type HubConfig, type HubSettingsValue } from './protocol.ts'
20
20
  import { SkillHubProvider } from './provider.ts'
21
21
  import { makeRoutes } from './routes.ts'
22
- import { createSkillStatsReader, type SkillStatsReader } from './stats.ts'
22
+ import { createSkillStatsReader, asPersistenceSeam, type SessionPersistenceLike, type SessionQueryLike, type SkillStatsReader } from './stats.ts'
23
23
  import { SkillHubStore } from './store.ts'
24
24
  import { cleanupLeftoverImportDirs, setGithubToken } from './repo.ts'
25
25
  import { dshHome } from './store.ts'
@@ -252,13 +252,34 @@ export function apply(ctx: Context, config?: Config): void {
252
252
  // re-registers the routes with the reader attached (mirrors the optional
253
253
  // stats wiring — no sessionQuery service ever mounted means none of this
254
254
  // runs).
255
- ctx.inject(['sessionQuery'], (sctx) => {
256
- // 恢复 sidecar 里的增量扫描检查点后再建 reader(异步、不阻塞注入回调):
257
- // 重启后无需重新解压全部历史日志;每次扫描完都落盘(含上次总数),所以
258
- // 重启后面板秒出旧数、后台重扫。扫描间隔与滚动窗口都从设置命名空间
259
- // 实时读取——卡片里改完即生效,无需重启。
255
+ //
256
+ // Read-path preference: the host's sessionPersistence seam serves raw stored
257
+ // logs (decompress + parse only — no Session restore/validation, the host
258
+ // cost that OOMed large histories, see issue #7), with the query seam
259
+ // covering live sessions. Without a persistence service the reader falls
260
+ // back to sequential query reads. Scans are strictly lazy: the first one
261
+ // starts on the panel's first /stats poll, never at plugin startup (a
262
+ // headless boot with no browser must not pay for statistics nobody reads).
263
+ //
264
+ // Mount ordering between the two host services is not guaranteed, so the
265
+ // reader is (re-)built generation-guarded: a query-only reader wires
266
+ // immediately, and a later-arriving persistence service upgrades it to the
267
+ // cold path. A superseded build never assigns.
268
+ let statsQuery: SessionQueryLike | undefined
269
+ let statsPersistence: SessionPersistenceLike | undefined
270
+ let statsGeneration = 0
271
+ const wireStats = (): void => {
272
+ const query = statsQuery
273
+ if (query === undefined) return
274
+ const generation = ++statsGeneration
275
+ const cold = statsPersistence
260
276
  void (async () => {
277
+ // 恢复 sidecar 里的增量扫描检查点后再建 reader(异步、不阻塞):
278
+ // 重启后无需重新解压全部历史日志;每次扫描完都落盘(含上次总数),所以
279
+ // 重启后面板秒出旧数、后台重扫。扫描间隔与滚动窗口都从设置命名空间
280
+ // 实时读取——卡片里改完即生效,无需重启。
261
281
  const saved = await store.getSkillStatsState().catch(() => undefined)
282
+ if (generation !== statsGeneration) return
262
283
  const scanMinutes = (): number => {
263
284
  const value = current().statsScanMinutes
264
285
  return typeof value === 'number' && value >= 1 ? Math.floor(value) : HUB_CONFIG_DEFAULTS.statsScanMinutes
@@ -267,21 +288,61 @@ export function apply(ctx: Context, config?: Config): void {
267
288
  const value = current().statsWindowDays
268
289
  return typeof value === 'number' && value >= 0 ? Math.floor(value) : HUB_CONFIG_DEFAULTS.statsWindowDays
269
290
  }
270
- const reader = createSkillStatsReader(sctx.sessionQuery, () => scanMinutes() * 60_000, {
291
+ const reader = createSkillStatsReader(query, () => scanMinutes() * 60_000, {
271
292
  checkpoint: saved,
272
293
  windowDays,
294
+ ...(cold === undefined ? {} : {
295
+ persistence: cold,
296
+ listLiveIds: async () => {
297
+ try {
298
+ const records = await query.listSessions()
299
+ return records.filter((record) => (record as { live?: unknown }).live === true).map((record) => record.header.id)
300
+ } catch {
301
+ return []
302
+ }
303
+ },
304
+ readLiveSession: async (id) => {
305
+ try {
306
+ return { events: (await query.readSession(id)).events }
307
+ } catch {
308
+ return undefined
309
+ }
310
+ },
311
+ }),
273
312
  onCheckpoint: (next) => {
274
313
  void store.saveSkillStatsState(next).catch((error) => {
275
314
  ctx.logger.warn('[dsh-skill-hub] persisting skill-stats checkpoint failed', error)
276
315
  })
277
316
  },
278
317
  })
318
+ if (generation !== statsGeneration) return
319
+ ctx.logger.info(`[dsh-skill-hub] stats seam: ${cold === undefined ? 'session-query (fallback)' : 'session-persistence (cold)'}`)
279
320
  stats = reader
280
321
  sync()
281
- // 冷启动预热:检查点无总数时,全量扫描(数十 MB 会话日志)在启动时就
282
- // 开始,而不是等面板第一次打开才起跑;有总数时这次调用命中内存缓存,
283
- // 开销可忽略。失败由 reader 内部吞掉(保留旧总数),不影响启动。
284
- void reader()
285
322
  })()
323
+ }
324
+ ctx.inject(['sessionQuery'], (sctx) => {
325
+ statsQuery = sctx.sessionQuery
326
+ wireStats()
327
+ // The persistence service may mount after the query service: upgrade the
328
+ // reader when it arrives (no-op when it never does).
329
+ sctx.inject(['sessionPersistence'] as unknown as ['sessionQuery'], (pctx) => {
330
+ void (async () => {
331
+ const raw = (pctx as unknown as { sessionPersistence?: unknown }).sessionPersistence
332
+ const seam = asPersistenceSeam(raw)
333
+ if (seam === undefined) {
334
+ ctx.logger.warn('[dsh-skill-hub] sessionPersistence shape mismatch, staying on query fallback')
335
+ return
336
+ }
337
+ try {
338
+ await seam.list() // probe: headers only, must succeed before trusting the cold path
339
+ } catch (error) {
340
+ ctx.logger.warn('[dsh-skill-hub] persistence seam probe failed, staying on query fallback', error)
341
+ return
342
+ }
343
+ statsPersistence = seam
344
+ wireStats()
345
+ })()
346
+ })
286
347
  })
287
348
  }
@@ -15,15 +15,25 @@ export interface StatsResponse {
15
15
  available: boolean
16
16
  /** Sorted per-skill invocation counts. */
17
17
  stats: SkillStat[]
18
+ /** Which read path produced the counts (debug aid for memory-issue follow-ups). */
19
+ source?: 'cold' | 'query'
18
20
  }
19
21
 
20
22
  /**
21
23
  * Persisted incremental-scan checkpoint for the usage statistics (sidecar
22
- * `skillStats` field). Sessions created before `frozenBefore` are treated as
23
- * finalized: their per-session counts live in `frozenSessions` (only sessions
24
- * with at least one invocation are kept) and they are not re-read on
25
- * incremental scans. A daily full reconciliation rebuilds the cache and
26
- * advances the watermark, so a resumed old session is eventually re-counted.
24
+ * `skillStats` field). Two buckets cover the two read paths:
25
+ *
26
+ * - `coldRevisions` (preferred): per-session counts keyed by the persistence
27
+ * seam's opaque revision token. A session is re-read only when its revision
28
+ * changes, so resumed old sessions are picked up exactly and the daily full
29
+ * reconciliation is unnecessary on this path.
30
+ * - `frozenSessions` + `frozenBefore` + `lastFullReconcile` (fallback): the
31
+ * legacy time-watermark scheme used when only the heavier `sessionQuery`
32
+ * seam is available. Sessions older than the effective watermark are treated
33
+ * as finalized: their per-session counts live in `frozenSessions` (only
34
+ * sessions with at least one invocation are kept) and they are not re-read
35
+ * on incremental scans. A daily full reconciliation rebuilds the cache and
36
+ * advances the watermark, so a resumed old session is eventually re-counted.
27
37
  */
28
38
  export interface SkillStatsCheckpoint {
29
39
  /** The rolling-window configuration this checkpoint was built for (0 = all history). */
@@ -39,4 +49,20 @@ export interface SkillStatsCheckpoint {
39
49
  * start so a restart still shows numbers while the background rescan runs.
40
50
  */
41
51
  lastTotals?: SkillStat[]
52
+ /**
53
+ * Per-session counts read through the persistence seam, keyed by session id
54
+ * and stamped with the revision they were read at. Absent until the first
55
+ * cold-path scan completes; the query fallback path ignores it.
56
+ */
57
+ coldRevisions?: Record<string, ColdRevisionEntry>
58
+ }
59
+
60
+ /** One cold-path cache entry: what a session's log contained at a revision. */
61
+ export interface ColdRevisionEntry {
62
+ /** The persistence seam's opaque revision token, stringified. */
63
+ rev: string
64
+ /** header.createdAt at read time (0 when the header carried none). */
65
+ createdAt: number
66
+ /** Per-skill counts (empty when the session invoked no skill). */
67
+ counts: Record<string, { count: number; lastUsed: number }>
42
68
  }
@@ -25,6 +25,7 @@ import {
25
25
  import { createSkill, disableSkill, enableSkill, parseFrontmatter, readSkillInterface, rootPath, trashSkill } from '../skillfs.ts'
26
26
  import {
27
27
  buildCatalog,
28
+ configOf,
28
29
  homeOf,
29
30
  pathExists,
30
31
  queryParam,
@@ -301,8 +302,15 @@ export function catalogRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
301
302
  writeJson(res, 200, { ok: true, available: false, stats: [] } satisfies StatsResponse)
302
303
  return
303
304
  }
305
+ // All usage displays off means nobody reads statistics: answer
306
+ // unavailable without invoking the reader, so no scan is triggered.
307
+ const display = configOf(deps)
308
+ if (display.showUseCount === false && display.showUseTime === false && display.showGroupSummary === false) {
309
+ writeJson(res, 200, { ok: true, available: false, stats: [] } satisfies StatsResponse)
310
+ return
311
+ }
304
312
  const stats = await deps.stats()
305
- writeJson(res, 200, { ok: true, available: true, stats } satisfies StatsResponse)
313
+ writeJson(res, 200, { ok: true, available: true, ...(deps.stats.source !== undefined ? { source: deps.stats.source } : {}), stats } satisfies StatsResponse)
306
314
  },
307
315
  },
308
316
  ]
@@ -110,6 +110,38 @@ describe('skill-hub routes', () => {
110
110
  expect(res.status).toBe(403)
111
111
  })
112
112
 
113
+ it('reports stats unavailable without a reader, or when all usage displays are off', async () => {
114
+ // 无 reader:面板显示空统计,不触发任何扫描。
115
+ const empty = new FakeResponse()
116
+ await routeFor(SKILL_HUB_API.stats).handler(fakeReq('GET', SKILL_HUB_API.stats), empty as never)
117
+ expect(empty.json()).toEqual({ ok: true, available: false, stats: [] })
118
+
119
+ // 有 reader 但三个展示开关全关:同样不调用 reader(kill-switch,issue #7)。
120
+ let calls = 0
121
+ deps.stats = async () => {
122
+ calls += 1
123
+ return [{ name: 'tdd', count: 1 }]
124
+ }
125
+ deps.config = () => ({ showUseCount: false, showUseTime: false, showGroupSummary: false }) as HubConfig
126
+ const off = new FakeResponse()
127
+ await routeFor(SKILL_HUB_API.stats).handler(fakeReq('GET', SKILL_HUB_API.stats), off as never)
128
+ expect(off.json()).toEqual({ ok: true, available: false, stats: [] })
129
+ expect(calls).toBe(0)
130
+
131
+ // 默认开任意一个即正常服务。
132
+ deps.config = () => ({ showUseCount: true }) as HubConfig
133
+ const on = new FakeResponse()
134
+ await routeFor(SKILL_HUB_API.stats).handler(fakeReq('GET', SKILL_HUB_API.stats), on as never)
135
+ expect(on.json()).toEqual({ ok: true, available: true, stats: [{ name: 'tdd', count: 1 }] })
136
+ expect(calls).toBe(1)
137
+
138
+ // reader 自带的来源标记会透出(无标记时不加字段)。
139
+ deps.stats.source = 'cold'
140
+ const sourced = new FakeResponse()
141
+ await routeFor(SKILL_HUB_API.stats).handler(fakeReq('GET', SKILL_HUB_API.stats), sourced as never)
142
+ expect(sourced.json()).toEqual({ ok: true, available: true, source: 'cold', stats: [{ name: 'tdd', count: 1 }] })
143
+ })
144
+
113
145
  it('serves the catalog with writable flags and diagnostics', async () => {
114
146
  skills.snapshot = async () => ({
115
147
  skills: [summary({ source: 'user-dsh' }), summary({ name: 'bundled-x', source: 'bundled', provider: 'bundled' })],
package/src/stats.test.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { countSkillInvocations, createSkillStatsReader, readSkillStats, STATS_FREEZE_AFTER_MS, type SessionQueryLike } from './stats.ts'
2
+ import { asPersistenceSeam, countSkillInvocations, createSkillStatsReader, readColdSkillStats, readSkillStats, STATS_FREEZE_AFTER_MS, type SessionPersistenceLike, type SessionQueryLike } from './stats.ts'
3
3
  import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
4
+ import type { SkillStatsCheckpoint } from './protocol.ts'
4
5
 
5
6
  /** Minimal user/message event carrying a skill-invocation source. */
6
7
  function invocationEvent(name: string, seq = 1, time = 0): SessionEvent {
@@ -148,7 +149,9 @@ describe('createSkillStatsReader', () => {
148
149
  it('serves stale totals after expiry and rescans only once', async () => {
149
150
  let reads = 0
150
151
  const query: SessionQueryLike = {
151
- listSessions: async () => [{ header: { id: 'a' as never } }],
152
+ // 有时间戳的近期会话:增量扫描会重读,正好验证“过期触发且只刷一次”。
153
+ // (无时间戳会话首轮进冻结桶后不再重读,见下文增量测试。)
154
+ listSessions: async () => [{ header: { id: 'a' as never, createdAt: Date.now() } }],
152
155
  readSession: async () => {
153
156
  reads += 1
154
157
  return { events: [invocationEvent('tdd', 1)] }
@@ -200,7 +203,7 @@ async function flush(): Promise<void> {
200
203
  }
201
204
 
202
205
  describe('scan read concurrency', () => {
203
- it('reads session logs in parallel, not one by one', async () => {
206
+ it('reads session logs sequentially (peak 1) to bound host restore memory', async () => {
204
207
  const ids = Array.from({ length: 8 }, (_, i) => 's' + i)
205
208
  let inFlight = 0
206
209
  let peak = 0
@@ -214,11 +217,11 @@ describe('scan read concurrency', () => {
214
217
  return { events: [invocationEvent(String(id))] }
215
218
  },
216
219
  }
217
- // No checkpoint → full path, all 8 sessions read.
220
+ // No checkpoint → full path, all 8 sessions read, one at a time: a heavy
221
+ // corpus costs wall time (issue #7), never a memory spike.
218
222
  const stats = await readSkillStats(query)
219
223
  expect(stats).toHaveLength(8)
220
- expect(peak).toBeGreaterThan(1)
221
- expect(peak).toBeLessThanOrEqual(6)
224
+ expect(peak).toBe(1)
222
225
  })
223
226
  })
224
227
 
@@ -491,4 +494,239 @@ describe('rolling stats window (configurable days)', () => {
491
494
  expect(stats).toEqual([])
492
495
  expect(checkpoint.frozenSessions['stale-entry']).toBeUndefined() // 已被懒清理
493
496
  })
497
+
498
+ it('keeps sessions without createdAt in the frozen bucket after the first read', async () => {
499
+ // 无时间戳会话按时间永远冻不住:首轮全量读到调用就进缓存,
500
+ // 增量扫描不再重读(issue #7:否则每轮都重读)。
501
+ const reads: string[] = []
502
+ const query = fakeQuery(
503
+ [record('no-stamp'), record('fresh', NOW - 1 * DAY)],
504
+ { 'no-stamp': [invocationEvent('x', 1)], fresh: [invocationEvent('y', 1)] },
505
+ reads,
506
+ )
507
+ let clock = NOW
508
+ const checkpoint: SkillStatsCheckpoint = { windowDays: 0, frozenBefore: 0, frozenSessions: {}, lastFullReconcile: 0 }
509
+ const reader = createSkillStatsReader(query, 60_000, { now: () => clock, checkpoint })
510
+ expect(await reader()).toEqual([])
511
+ await flush()
512
+ expect(reads).toEqual(['no-stamp', 'fresh']) // 首轮全量:两个都读
513
+ expect(checkpoint.frozenSessions['no-stamp']).not.toBeUndefined()
514
+ clock += 120_000 // TTL 过期 → 增量扫描
515
+ await reader()
516
+ await flush()
517
+ expect(reads).toEqual(['no-stamp', 'fresh', 'fresh']) // 只重读 fresh,no-stamp 不再读
518
+ })
519
+ })
520
+
521
+ // ------------------------------------------------ 冷路径(persistence seam)
522
+
523
+ /** One fake persisted session. */
524
+ function coldEntry(id: string, createdAt: number | undefined, rev: string, events: SessionEvent[] | 'corrupt'): { id: string; createdAt: number | undefined; rev: string; events: SessionEvent[] | 'corrupt' } {
525
+ return { id, createdAt, rev, events }
526
+ }
527
+
528
+ /** Fake persistence seam recording opens/closes (handles must always close). */
529
+ function fakePersistence(
530
+ entries: ReturnType<typeof coldEntry>[],
531
+ trace: { lists: number; opens: string[]; closes: string[] },
532
+ failList = false,
533
+ ): SessionPersistenceLike {
534
+ return {
535
+ list: async () => {
536
+ trace.lists += 1
537
+ if (failList) throw new Error('backend down')
538
+ return entries.map((entry) => ({
539
+ header: { id: entry.id as never, ...(entry.createdAt !== undefined ? { createdAt: entry.createdAt } : {}) },
540
+ revision: entry.rev,
541
+ }))
542
+ },
543
+ open: async (id) => {
544
+ trace.opens.push(String(id))
545
+ const entry = entries.find((candidate) => candidate.id === String(id))
546
+ if (entry === undefined || entry.events === 'corrupt') throw new Error('unreadable')
547
+ const events = entry.events
548
+ return {
549
+ read: async () => ({ events }),
550
+ close: () => { trace.closes.push(String(id)) },
551
+ }
552
+ },
553
+ }
554
+ }
555
+
556
+ function coldTrace(): { lists: number; opens: string[]; closes: string[] } {
557
+ return { lists: 0, opens: [], closes: [] }
558
+ }
559
+
560
+ describe('readColdSkillStats', () => {
561
+ it('totals invocations across sessions and sorts by name', async () => {
562
+ const trace = coldTrace()
563
+ const persistence = fakePersistence([
564
+ coldEntry('a', NOW - 1 * DAY, 'r1', [invocationEvent('tdd', 1), invocationEvent('tdd', 2)]),
565
+ coldEntry('b', NOW - 2 * DAY, 'r1', [invocationEvent('code-review', 1), skillToolCall('tdd', 2)]),
566
+ ], trace)
567
+ expect(await readColdSkillStats(persistence)).toEqual([
568
+ { name: 'code-review', count: 1, lastUsed: 0 },
569
+ { name: 'tdd', count: 3, lastUsed: 0 },
570
+ ])
571
+ expect(trace.opens).toEqual(['a', 'b'])
572
+ expect(trace.closes).toEqual(['a', 'b']) // 每个 handle 都关闭
573
+ })
574
+
575
+ it('never opens out-of-window logs', async () => {
576
+ const trace = coldTrace()
577
+ const persistence = fakePersistence([
578
+ coldEntry('old', NOW - 10 * DAY, 'r1', [invocationEvent('oldskill', 1)]),
579
+ coldEntry('fresh', NOW - 1 * DAY, 'r1', [invocationEvent('tdd', 1)]),
580
+ ], trace)
581
+ const checkpoint = { windowDays: 7, frozenBefore: 0, frozenSessions: {}, lastFullReconcile: 0 }
582
+ const reader = createSkillStatsReader({ listSessions: async () => [], readSession: async () => ({ events: [] }) }, 60_000, {
583
+ now: () => NOW,
584
+ checkpoint,
585
+ windowDays: () => 7,
586
+ persistence,
587
+ })
588
+ expect(await reader()).toEqual([])
589
+ await flush()
590
+ expect(await reader()).toEqual([{ name: 'tdd', count: 1, lastUsed: 0 }])
591
+ expect(trace.opens).toEqual(['fresh']) // old 连开都没开
592
+ })
593
+
594
+ it('skips corrupt sessions without failing the scan', async () => {
595
+ const trace = coldTrace()
596
+ const persistence = fakePersistence([
597
+ coldEntry('bad', NOW - 1 * DAY, 'r1', 'corrupt'),
598
+ coldEntry('good', NOW - 1 * DAY, 'r1', [invocationEvent('tdd', 1)]),
599
+ ], trace)
600
+ expect(await readColdSkillStats(persistence)).toEqual([{ name: 'tdd', count: 1, lastUsed: 0 }])
601
+ })
602
+
603
+ it('re-reads only sessions whose revision changed', async () => {
604
+ const trace = coldTrace()
605
+ const entries = [
606
+ coldEntry('steady', NOW - 5 * DAY, 'r1', [invocationEvent('tdd', 1)]),
607
+ coldEntry('growing', NOW - 1 * DAY, 'r1', [invocationEvent('tdd', 1)]),
608
+ ]
609
+ const persistence = fakePersistence(entries, trace)
610
+ let clock = NOW
611
+ const query: SessionQueryLike = { listSessions: async () => [], readSession: async () => ({ events: [] }) }
612
+ const checkpoints: Array<Record<string, unknown>> = []
613
+ const reader = createSkillStatsReader(query, 60_000, {
614
+ now: () => clock,
615
+ persistence,
616
+ onCheckpoint: (cp) => { checkpoints.push(cp as unknown as Record<string, unknown>) },
617
+ })
618
+ expect(await reader()).toEqual([])
619
+ await flush()
620
+ expect(await reader()).toEqual([{ name: 'tdd', count: 2, lastUsed: 0 }])
621
+ expect(trace.opens).toEqual(['steady', 'growing'])
622
+
623
+ // growing 续写(revision 变化),steady 不变:第二轮只重读 growing。
624
+ entries[1]!.rev = 'r2'
625
+ entries[1]!.events = [invocationEvent('tdd', 1), invocationEvent('tdd', 2)]
626
+ clock += 120_000 // TTL 过期,触发后台重扫
627
+ expect(await reader()).toEqual([{ name: 'tdd', count: 2, lastUsed: 0 }]) // 先回 stale
628
+ await flush()
629
+ expect(await reader()).toEqual([{ name: 'tdd', count: 3, lastUsed: 0 }])
630
+ expect(trace.opens).toEqual(['steady', 'growing', 'growing'])
631
+ // 检查点里记了两个 revision,下次重启不用重读。
632
+ const saved = checkpoints.at(-1)?.['coldRevisions'] as Record<string, { rev: string }>
633
+ expect(saved['steady']?.rev).toBe('r1')
634
+ expect(saved['growing']?.rev).toBe('r2')
635
+ })
636
+
637
+ it('reads live sessions through the query seam without double-counting', async () => {
638
+ const trace = coldTrace()
639
+ const persistence = fakePersistence([
640
+ coldEntry('live-one', NOW - 1 * DAY, 'r1', [invocationEvent('persisted-part', 1)]),
641
+ coldEntry('calm', NOW - 2 * DAY, 'r1', [invocationEvent('tdd', 1)]),
642
+ ], trace)
643
+ const query: SessionQueryLike = {
644
+ listSessions: async () => [],
645
+ readSession: async (id) => ({
646
+ // live 会话的内存态比落盘新:全量当前日志(含已落盘部分)。
647
+ events: String(id) === 'live-one'
648
+ ? [invocationEvent('persisted-part', 1), invocationEvent('live-part', 2)]
649
+ : [],
650
+ }),
651
+ }
652
+ const reader = createSkillStatsReader(query, 60_000, {
653
+ now: () => NOW,
654
+ persistence,
655
+ listLiveIds: async () => ['live-one' as never],
656
+ readLiveSession: (id) => query.readSession(id),
657
+ })
658
+ expect(await reader()).toEqual([])
659
+ await flush()
660
+ // live-one 只按 live 读了一次(persisted-part 不翻倍),calm 走 revision 缓存。
661
+ expect(await reader()).toEqual([
662
+ { name: 'live-part', count: 1, lastUsed: 0 },
663
+ { name: 'persisted-part', count: 1, lastUsed: 0 },
664
+ { name: 'tdd', count: 1, lastUsed: 0 },
665
+ ])
666
+ expect(trace.opens).toEqual(['calm'])
667
+ })
668
+
669
+ it('keeps previous totals when the listing fails', async () => {
670
+ const trace = coldTrace()
671
+ const persistence = fakePersistence([coldEntry('a', NOW - 1 * DAY, 'r1', [invocationEvent('tdd', 1)])], trace, true)
672
+ const query: SessionQueryLike = { listSessions: async () => [], readSession: async () => ({ events: [] }) }
673
+ const reader = createSkillStatsReader(query, 60_000, {
674
+ now: () => NOW,
675
+ persistence,
676
+ checkpoint: {
677
+ windowDays: 0,
678
+ frozenBefore: 0,
679
+ frozenSessions: {},
680
+ lastFullReconcile: NOW,
681
+ lastTotals: [{ name: 'oldskill', count: 9, lastUsed: 7 }],
682
+ },
683
+ })
684
+ expect(await reader()).toEqual([{ name: 'oldskill', count: 9, lastUsed: 7 }])
685
+ await flush()
686
+ expect(await reader()).toEqual([{ name: 'oldskill', count: 9, lastUsed: 7 }]) // 失败保留旧数
687
+ expect(trace.opens).toEqual([])
688
+ })
689
+ })
690
+
691
+ describe('asPersistenceSeam', () => {
692
+ it('rejects non-service values without I/O', () => {
693
+ expect(asPersistenceSeam(undefined)).toBeUndefined()
694
+ expect(asPersistenceSeam(null)).toBeUndefined()
695
+ expect(asPersistenceSeam({})).toBeUndefined()
696
+ expect(asPersistenceSeam({ list: async () => [] })).toBeUndefined()
697
+ expect(asPersistenceSeam({ open: async () => ({}) })).toBeUndefined()
698
+ })
699
+
700
+ it('adapts list/open/read/close with shape validation', async () => {
701
+ const closes: string[] = []
702
+ const seam = asPersistenceSeam({
703
+ list: async () => [{ header: { id: 'a', createdAt: 5 }, revision: 42 }],
704
+ open: async (id: unknown) => ({
705
+ read: async (offset: unknown) => {
706
+ expect(offset).toBe(0)
707
+ return { events: [invocationEvent('tdd', 1)] }
708
+ },
709
+ close: () => { closes.push(String(id)) },
710
+ }),
711
+ })
712
+ expect(seam).not.toBeUndefined()
713
+ const snapshots = await seam!.list()
714
+ expect(snapshots).toEqual([{ header: { id: 'a', createdAt: 5 }, revision: '42' }])
715
+ const handle = await seam!.open('a' as never, 'read')
716
+ expect((await handle.read()).events).toHaveLength(1)
717
+ await handle.close()
718
+ expect(closes).toEqual(['a'])
719
+ })
720
+
721
+ it('throws on malformed snapshots, handles, and reads', async () => {
722
+ const badList = asPersistenceSeam({ list: async () => [{ nope: true }], open: async () => ({}) })!
723
+ await expect(badList.list()).rejects.toThrow()
724
+ const badOpen = asPersistenceSeam({ list: async () => [], open: async () => ({}) })!
725
+ await expect(badOpen.open('a' as never, 'read')).rejects.toThrow()
726
+ const badRead = asPersistenceSeam({
727
+ list: async () => [],
728
+ open: async () => ({ read: async () => ({ nope: 1 }), close: () => {} }),
729
+ })!
730
+ await expect((await badRead.open('a' as never, 'read')).read()).rejects.toThrow()
731
+ })
494
732
  })