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/lib/index.js +230 -16
- package/lib/types/protocol/stats.d.ts +33 -5
- package/lib/types/stats.d.ts +86 -11
- package/package.json +29 -28
- package/src/index.ts +72 -11
- package/src/protocol/stats.ts +31 -5
- package/src/routes/catalog.ts +9 -1
- package/src/routes.test.ts +32 -0
- package/src/stats.test.ts +244 -6
- package/src/stats.ts +235 -24
- package/src/store/migrate.ts +25 -0
- package/src/store/store.ts +2 -0
- package/src/store.test.ts +29 -0
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
|
-
|
|
256
|
-
|
|
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(
|
|
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
|
}
|
package/src/protocol/stats.ts
CHANGED
|
@@ -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).
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
}
|
package/src/routes/catalog.ts
CHANGED
|
@@ -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
|
]
|
package/src/routes.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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).
|
|
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
|
})
|