dsh-sessions-manager 3.6.0 → 3.6.1

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-sessions-manager",
3
3
  "description": "DSH 设置面板会话管理器:归档 / 恢复 / 彻底删除 / 移动到其他工作区,带工作区标签与会话日期;统一「会话管理」面板。Session manager for the DeepSeek Harness settings panel — archive / restore / permanently delete / move sessions across workspaces, with workspace tags & session dates in one unified panel.",
4
- "version": "3.6.0",
4
+ "version": "3.6.1",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
package/src/index.js CHANGED
@@ -18,7 +18,7 @@ import { createPendingMoveStore } from './pending-moves.js'
18
18
  import { sweepStaleStateTemps } from './state-temp-sweep.js'
19
19
  import { aggregateStorage } from './storage-stats.js'
20
20
  import { createAutoArchiveStore, pickInactiveCandidates } from './auto-archive.js'
21
- import { createSessionMetaCache, fingerprintOf, isPersistableFingerprint } from './session-meta-cache.js'
21
+ import { createSessionMetaCache, persistFingerprintOf } from './session-meta-cache.js'
22
22
  import { createTitleIndexStore } from './title-persist-index.js'
23
23
  import { createPersistenceAdapter } from './compat/persistence.js'
24
24
  import { detectCapabilities, requireCapability } from './compat/capabilities.js'
@@ -179,8 +179,11 @@ export function apply(ctx) {
179
179
  const stat = statsById.get(id)
180
180
  const entry = store && store[id]
181
181
  if (!stat || !entry) continue
182
- const fp = fingerprintOf(stat)
183
- if (!isPersistableFingerprint(fp)) continue
182
+ // 持久索引只认可跨重启的指纹(legacy 的文件指纹 / handle 世代的 sz 指纹,
183
+ // 见 session-meta-cache.persistFingerprintOf——issue #8 之前 handle 世代
184
+ // 从不落盘,导致每次重启都全量重新解码)。
185
+ const fp = persistFingerprintOf(stat)
186
+ if (!fp) continue
184
187
  if (fp && entry.fingerprint === fp) {
185
188
  hits.set(id, { title: entry.title, cwd: entry.cwd, createdAt: entry.createdAt })
186
189
  }
@@ -192,15 +195,15 @@ export function apply(ctx) {
192
195
  // 队列内部已串行化 + 原子替换)。**必须 await** —— 它是持久写入,浮动写入会在
193
196
  // 调用方/测试 teardown 已开始清理状态目录时落地(`ENOTEMPTY: rmdir .../state`),
194
197
  // 且"响应已返回但索引没落盘"时进程若恰好退出就会丢条目。
195
- // ⚠️ revision 指纹(SessionHandle 世代)绝不落盘:它只在当前 service
196
- // 实例内有意义,跨进程比较无意义,误用会把陈旧数据当新鲜数据。
198
+ // 指纹用 persistFingerprintOf:legacy 文件指纹 + handle 世代 sz(sizeBytes) 指纹
199
+ // 都可跨重启;rev: 指纹仍被排除(实例内 opaque token,issue #8)。
197
200
  async function persistDecoded(decoded, statsById) {
198
201
  if (!decoded || !decoded.size) return
199
202
  const batch = {}
200
203
  const now = Date.now()
201
204
  for (const [id, meta] of decoded) {
202
- const fp = fingerprintOf(statsById.get(id))
203
- if (!isPersistableFingerprint(fp)) continue
205
+ const fp = persistFingerprintOf(statsById.get(id))
206
+ if (!fp) continue
204
207
  batch[id] = { title: meta.title, cwd: meta.cwd, createdAt: meta.createdAt, fingerprint: fp, updatedAt: now }
205
208
  }
206
209
  if (!Object.keys(batch).length) return
@@ -284,65 +287,82 @@ export function apply(ctx) {
284
287
 
285
288
  // Resolve one session's display metadata.
286
289
  //
287
- // 成本模型(issue #1):下面的解码路径会把整本 .jsonl.zstd 逐帧解压、逐行
288
- // JSON.parse,只为折叠出标题——大库上一次全表要几秒阻塞式 CPU。日志内容没变
289
- // 就意味着折叠结果不可能变(legacy 用 (mtime, size) 文件指纹;SessionHandle
290
- // 世代用官方 snapshot.revision),命中即直接复用,跳过整本解码。
290
+ // 成本模型(issue #1 → issue #8):runtime 的标题投影(readTitleSnapshots)
291
+ // 在 0.1.x 上对每条非 live 会话都是**整本日志解码**(SessionCorpus.projectMany
292
+ // → inspectPersisted)。列表构建绝不能在请求路径上同步触发它——issue #8 实测:
293
+ // 87 本日志 / 249MB 的库,一次冷启动全量投影把宿主打满 ~90s CPU。
291
294
  //
292
- // 0.1.3-alpha 兼容(避免放大官方已知的历史会话加载性能回退):
293
- // - cwd/createdAt 优先来自 list() 快照的 snapshot.header;
294
- // - 标题优先来自批量 readTitleSnapshots;
295
- // - **标题缺失绝不单独触发整本日志解码**——无标题就显示「(无标题)」。
296
- // 只有在拿不到 cwd(工作区归属失效)或 runtime 完全没有标题投影能力时
297
- // 才回退到日志解码,且该解码走 inspectSession 分块折叠,不做整本驻留。
298
- async function resolveOne(id, usage, opts = {}) {
299
- const key = String(id)
300
- const statInfo = usage ? (usage.statsById ? usage.statsById.get(key) : null)
301
- || { mtimeMs: usage.mtimeById && usage.mtimeById.get(key), size: usage.sizeById && usage.sizeById.get(key) } : null
302
- const cached = metaCache.get(key, statInfo)
303
- if (cached) return buildItem(key, cached, usage, opts.exposeUsage)
304
-
305
- let meta = { title: null, cwd: null, createdAt: null }
306
- // 第一来源:list() 返回的 SessionPersistenceSnapshot.header(0.1.3+ 官方
307
- // 契约里 header 携带 cwd/createdAt,无需任何日志读取)。
308
- if (opts.listHeader) {
309
- if (typeof opts.listHeader.cwd === 'string') meta.cwd = opts.listHeader.cwd
310
- if (opts.listHeader.createdAt != null) meta.createdAt = opts.listHeader.createdAt
311
- }
312
- if (opts.preloaded !== undefined) {
313
- const projected = metaFromSnapshot(unwrapSnapshot(opts.preloaded))
314
- if (projected.title) meta.title = projected.title
315
- if (!meta.cwd && projected.cwd) meta.cwd = projected.cwd
316
- if (!meta.createdAt && projected.createdAt) meta.createdAt = projected.createdAt
317
- } else if (typeof sq.readTitleSnapshot === 'function') {
318
- try {
319
- const projected = metaFromSnapshot(await sq.readTitleSnapshot(id))
320
- if (projected.title) meta.title = projected.title
321
- if (!meta.cwd && projected.cwd) meta.cwd = projected.cwd
322
- if (!meta.createdAt && projected.createdAt) meta.createdAt = projected.createdAt
323
- } catch (e) { /* fall through */ }
295
+ // 因此请求路径只消费两级缓存(metaCache 内存缓存 → titleIndex 持久索引),
296
+ // 缺失的会话交给后台预热队列分批补齐(见 enqueueWarm/runWarm);侧栏高频
297
+ // 轮询 + 管理面板刷新会让补齐结果自然浮现。
298
+ const EMPTY_META = { title: null, cwd: null, createdAt: null }
299
+
300
+ // —— 冷启动后台预热(issue #8)—————————————————————————————————————
301
+ // 单飞队列:请求路径发现缓存未命中的会话,把 { stat, header } 快照丢进
302
+ // warmQueue,由 runWarm 以小批次消费。每批之间 setImmediate 让步事件循环,
303
+ // 绝不独占宿主主线程;结果写 metaCache + 持久标题索引。stat 是入队时刻的
304
+ // 快照——若日志在预热期间又变了,下次请求的指纹比对会再次判未命中并重新
305
+ // 入队,天然自愈。
306
+ const WARM_CHUNK = 4
307
+ const warmQueue = new Map()
308
+ let warmRunning = false
309
+ let warmKickTimer = null
310
+
311
+ function scheduleWarm() {
312
+ if (warmKickTimer || warmRunning) return
313
+ warmKickTimer = setTimeout(() => { warmKickTimer = null; runWarm() }, 25)
314
+ // unref:预热绝不反过来把宿主进程钉住(与 autoArchive「无定时器」同一原则)。
315
+ if (typeof warmKickTimer.unref === 'function') warmKickTimer.unref()
316
+ }
317
+
318
+ function enqueueWarm(ids, statsById, entryById) {
319
+ if (!ids || !ids.length) return
320
+ for (const id of ids) {
321
+ const key = String(id)
322
+ const entry = entryById ? entryById.get(key) : null
323
+ warmQueue.set(key, {
324
+ stat: statsById ? (statsById.get(key) || null) : null,
325
+ header: (entry && entry.header) || null,
326
+ })
324
327
  }
325
- // cwd 缺失 → 工作区归属失效,值得一次解码兜底(cwd 在 header 里,通常
326
- // 快照已带回,这里只在快照缺 cwd 时发生)。runtime 完全没有标题投影能力
327
- // 时(老后端无 readTitleSnapshot),解码同时兜底标题。
328
- const projectionAvailable = typeof sq.readTitleSnapshot === 'function' || typeof sq.readTitleSnapshots === 'function'
329
- if (!meta.cwd || (!meta.title && !projectionAvailable)) {
330
- try {
331
- let foldedTitle = null
332
- const summary = await persistence.inspectSession(key, {
333
- onEvents: (events) => { if (!foldedTitle) foldedTitle = foldTitle(events) },
334
- })
335
- if (summary && summary.meta) {
336
- if (!meta.cwd) meta.cwd = summary.meta.cwd || null
337
- if (!meta.createdAt) meta.createdAt = summary.meta.createdAt || null
338
- }
339
- if (!meta.title && foldedTitle) meta.title = foldedTitle
340
- } catch (e2) { /* keep what we have */ }
328
+ scheduleWarm()
329
+ }
330
+
331
+ async function runWarm() {
332
+ if (warmRunning) return
333
+ warmRunning = true
334
+ try {
335
+ while (warmQueue.size) {
336
+ const batch = [...warmQueue.entries()].slice(0, WARM_CHUNK)
337
+ for (const [id] of batch) warmQueue.delete(id)
338
+ try {
339
+ const ids = batch.map(([id]) => id)
340
+ const snapshotById = await projectTitles(ids)
341
+ const decoded = new Map()
342
+ const statsById = new Map()
343
+ for (const [id, desc] of batch) {
344
+ const snapshot = snapshotById.has(id) ? snapshotById.get(id) : null
345
+ const meta = metaFromSnapshot(snapshot)
346
+ // cwd/createdAt 永远以最新 list header 为权威(投影的 session 头可能
347
+ // 陈旧;移动会话后尤其如此),投影只负责标题。
348
+ if (desc.header) {
349
+ if (typeof desc.header.cwd === 'string') meta.cwd = desc.header.cwd
350
+ if (desc.header.createdAt != null) meta.createdAt = desc.header.createdAt
351
+ }
352
+ metaCache.set(id, desc.stat, meta)
353
+ statsById.set(id, desc.stat)
354
+ if (desc.stat) decoded.set(id, meta)
355
+ }
356
+ await persistDecoded(decoded, statsById)
357
+ } catch (e) { /* 单批失败不影响后续批次:索引/缓存只是加速器 */ }
358
+ // 批间让步:保证宿主事件循环(RPC/心跳/其他插件)始终可调度。
359
+ await new Promise((resolve) => setImmediate(resolve))
360
+ }
361
+ } finally {
362
+ warmRunning = false
363
+ // 预热期间又有新请求入队 → 再排一轮。
364
+ if (warmQueue.size) scheduleWarm()
341
365
  }
342
- metaCache.set(key, statInfo, meta)
343
- // 本条是「真解码」出来的:交给调用方回写持久标题索引(P4 冷启动加速)。
344
- if (opts.collectDecoded && statInfo) opts.collectDecoded(key, meta)
345
- return buildItem(key, meta, usage, opts.exposeUsage)
346
366
  }
347
367
 
348
368
  // Disk usage + last-write time for every session, in one pass. Also produces
@@ -373,10 +393,13 @@ export function apply(ctx) {
373
393
  const id = entry && entry.id != null ? String(entry.id) : (header && header.id != null ? String(header.id) : null)
374
394
  if (!id) return
375
395
  // SessionHandle 世代:snapshot(header/revision/sizeBytes)是权威轻量
376
- // 观察,绝不再绕道私有磁盘路径补 stat。
396
+ // 观察,绝不再绕道私有磁盘路径补 stat。revision 进内存缓存指纹;
397
+ // sizeBytes 供 persistFingerprintOf 派生跨重启的 sz 持久指纹(issue #8)。
377
398
  if (entry && typeof entry.revision === 'string' && entry.revision) {
378
399
  if (Number.isFinite(entry.sizeBytes)) sizeById.set(id, Number(entry.sizeBytes))
379
- statsById.set(id, { revision: entry.revision })
400
+ statsById.set(id, Number.isFinite(entry.sizeBytes)
401
+ ? { revision: entry.revision, sizeBytes: Number(entry.sizeBytes) }
402
+ : { revision: entry.revision })
380
403
  return
381
404
  }
382
405
  if (entry && Number.isFinite(entry.sizeBytes)) sizeById.set(id, Number(entry.sizeBytes))
@@ -1212,7 +1235,8 @@ export function apply(ctx) {
1212
1235
  // 1. sp.list() 只调一次(原先列了两遍目录)
1213
1236
  // 2. 变更令牌优先来自 list 快照:legacy 走 locate+stat,SessionHandle 世代
1214
1237
  // 直接用 snapshot.revision(无 locate 可用,也绝不绕私有路径补 stat)
1215
- // 3. 未命中缓存的会话走**一次**批量投影(sq.readTitleSnapshots),而不是逐条
1238
+ // 3. 缓存未命中的会话**绝不在请求路径上同步投影**(issue #8:runtime 的
1239
+ // readTitleSnapshots 每条都是整本解码)——入队后台预热,先以占位元数据返回
1216
1240
  async function allSessionItemsDetailed(opts = {}) {
1217
1241
  let entries = []
1218
1242
  let headersOk = false
@@ -1242,37 +1266,59 @@ export function apply(ctx) {
1242
1266
  wsByPath = {}
1243
1267
  try { for (const ent of w.list()) wsByPath[ent.path] = ent } catch (e) { wsByPath = {} }
1244
1268
  const currentArchived = new Set((await archivedState().catch(() => ({ archivedSessionIds: [] }))).archivedSessionIds || [])
1245
- const items = []
1246
1269
  const usage = await collectUsage(entries)
1247
- // 先按指纹把「缓存命中」与「需要解码」分开,只对后者做批量投影。
1270
+ // 先按指纹把「缓存命中」与「需要补齐」分开。
1248
1271
  const statsById = new Map(visibleIds.map((id) => [
1249
1272
  id,
1250
1273
  (usage.statsById && usage.statsById.get(id)) || { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(id) },
1251
1274
  ]))
1252
- const { missing } = metaCache.partition(visibleIds, statsById)
1275
+ const { cached, missing } = metaCache.partition(visibleIds, statsById)
1253
1276
  // P4:missing 里先查持久标题索引(冷启动跳过整本解码),命中的回填内存缓存。
1277
+ // cwd/createdAt 回填时以最新 list header 为权威——持久条目里的 cwd 可能因
1278
+ // 移动会话而陈旧(sz 指纹对 cwd 无感知),标题才是索引的产出物。
1254
1279
  const persisted = await hydrateFromPersist(missing, statsById)
1255
- for (const [id, meta] of persisted) metaCache.set(id, statsById.get(id), meta)
1280
+ for (const [id, meta] of persisted) {
1281
+ const entry = entryById.get(id)
1282
+ const header = entry && entry.header ? entry.header : null
1283
+ metaCache.set(id, statsById.get(id), {
1284
+ title: meta.title,
1285
+ cwd: (header && typeof header.cwd === 'string' && header.cwd) ? header.cwd : meta.cwd,
1286
+ createdAt: (header && header.createdAt != null) ? header.createdAt : meta.createdAt,
1287
+ })
1288
+ }
1256
1289
  const stillMissing = missing.filter((id) => !persisted.has(id))
1257
- const snapshotById = await projectTitles(stillMissing)
1258
- const decoded = new Map()
1259
- const collectDecoded = (id, meta) => { decoded.set(id, meta) }
1260
- const CHUNK = 6
1261
- for (let i = 0; i < visibleIds.length; i += CHUNK) {
1262
- // Arrow wrapper on purpose: Array#map passes (value, index, array), and
1263
- // resolveOne's second and third arguments are fixed here.
1264
- const res2 = await Promise.all(visibleIds.slice(i, i + CHUNK).map((id) => {
1290
+ // issue #8:缺失部分交后台预热(分批 + 让步),请求路径零投影、零日志解码。
1291
+ if (stillMissing.length) enqueueWarm(stillMissing, statsById, entryById)
1292
+ const items = []
1293
+ for (const id of visibleIds) {
1294
+ // metaCache 优先:persisted 命中已带 header 权威 cwd/createdAt 回填进
1295
+ // metaCache(persisted.get 里的 cwd 可能因移动会话而陈旧)。
1296
+ // 完全未命中的会话也不能丢 cwd/createdAt——它们来自 list header,零成本
1297
+ // 且是工作区归属/创建时间的权威来源;只有标题允许占位等后台预热(issue #8)。
1298
+ let meta = metaCache.get(id, statsById.get(id)) || persisted.get(id) || null
1299
+ if (!meta) {
1300
+ meta = { title: null, cwd: null, createdAt: null }
1265
1301
  const entry = entryById.get(id)
1266
- return resolveOne(id, usage, {
1267
- exposeUsage: !!(opts && opts.usage),
1268
- listHeader: entry ? entry.header : null,
1269
- preloaded: snapshotById.has(id) ? snapshotById.get(id) : undefined,
1270
- collectDecoded,
1271
- })
1272
- }))
1273
- for (const it of res2) items.push({ ...it, archived: currentArchived.has(it.sessionId) })
1302
+ const header = entry && entry.header ? entry.header : null
1303
+ if (header) {
1304
+ if (typeof header.cwd === 'string') meta.cwd = header.cwd
1305
+ if (header.createdAt != null) meta.createdAt = header.createdAt
1306
+ }
1307
+ }
1308
+ const it = buildItem(id, meta, usage, !!(opts && opts.usage))
1309
+ items.push({ ...it, archived: currentArchived.has(it.sessionId) })
1310
+ }
1311
+ if (opts && opts.onlyArchived) {
1312
+ // /archived-sessions/list 的语义:只返回归档集里仍然存在的会话,
1313
+ // 且不带 archived 布尔注记(旧路由的输出形状)。
1314
+ const archivedItems = []
1315
+ for (const it of items) {
1316
+ if (!it.archived) continue
1317
+ const { archived: _drop, ...rest } = it
1318
+ archivedItems.push(rest)
1319
+ }
1320
+ return { items: archivedItems, usage }
1274
1321
  }
1275
- await persistDecoded(decoded, statsById)
1276
1322
  // Annotate stars; GC only when we have a trustworthy id baseline, so a
1277
1323
  // failing sp.list() can never wipe the whole index.
1278
1324
  let starredSet = new Set()
@@ -1360,6 +1406,7 @@ export function apply(ctx) {
1360
1406
  const activeTombstones = store.purgedSessionIds.map(String).filter((id) => !present.has(id))
1361
1407
  if (ids.length) {
1362
1408
  const usage = await collectUsage(entries)
1409
+ const entryById = new Map(entries.map((entry) => [entry.id, entry]))
1363
1410
  const statsById = new Map(ids.map((id) => [
1364
1411
  id,
1365
1412
  (usage.statsById && usage.statsById.get(id)) || { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(id) },
@@ -1367,30 +1414,23 @@ export function apply(ctx) {
1367
1414
  const { cached, missing } = metaCache.partition(ids, statsById)
1368
1415
  // P4:与列表构建共用持久标题索引,冷启动零解码。
1369
1416
  const persisted = await hydrateFromPersist(missing, statsById)
1370
- for (const [id, meta] of persisted) metaCache.set(id, statsById.get(id), meta)
1417
+ for (const [id, meta] of persisted) {
1418
+ const entry = entryById.get(id)
1419
+ const header = entry && entry.header ? entry.header : null
1420
+ metaCache.set(id, statsById.get(id), {
1421
+ title: meta.title,
1422
+ cwd: (header && typeof header.cwd === 'string' && header.cwd) ? header.cwd : meta.cwd,
1423
+ createdAt: (header && header.createdAt != null) ? header.createdAt : meta.createdAt,
1424
+ })
1425
+ }
1371
1426
  const rest = missing.filter((id) => !persisted.has(id))
1372
- const snapshotById = await projectTitles(rest)
1373
- const decoded = new Map()
1374
- const collectDecoded = (id, meta) => { decoded.set(id, meta) }
1427
+ // issue #8:缺失标题不在请求路径上同步投影(runtime 投影 = 逐条整本解码),
1428
+ // 交后台预热补齐;侧栏高频轮询会让补齐结果自然浮现。
1429
+ if (rest.length) enqueueWarm(rest, statsById, entryById)
1375
1430
  for (const id of ids) {
1376
- let meta = cached.get(id) || persisted.get(id) || null
1377
- if (!meta) {
1378
- const entry = entries.find((e) => e.id === id)
1379
- const snapshot = snapshotById.has(id)
1380
- ? snapshotById.get(id)
1381
- : (typeof sq.readTitleSnapshot === 'function' ? await sq.readTitleSnapshot(id).catch(() => null) : null)
1382
- const next = metaFromSnapshot(snapshot)
1383
- if (entry && entry.header) {
1384
- if (!next.cwd && typeof entry.header.cwd === 'string') next.cwd = entry.header.cwd
1385
- if (!next.createdAt && entry.header.createdAt != null) next.createdAt = entry.header.createdAt
1386
- }
1387
- metaCache.set(id, statsById.get(id), next)
1388
- if (statsById.get(id)) collectDecoded(id, next)
1389
- meta = next
1390
- }
1431
+ const meta = metaCache.get(id, statsById.get(id)) || persisted.get(id) || null
1391
1432
  if (meta && meta.title) authorityTitleCache.set(id, String(meta.title))
1392
1433
  }
1393
- await persistDecoded(decoded, statsById)
1394
1434
  }
1395
1435
  // 血缘分类(issue #6):结构分类(子代理 / fork 分支)来自 list snapshot
1396
1436
  // 的 header,零解码;空白判定见下方 refineEmptyLineage(0.1.3 起体积法
@@ -1639,12 +1679,13 @@ export function apply(ctx) {
1639
1679
  const idStrs = ids.map(String).filter((id) => !hidden.has(id) && (materialized.has(id) || (live && live.get(id))))
1640
1680
  wsByPath = {}
1641
1681
  try { for (const ent of w.list()) wsByPath[ent.path] = ent } catch (e) { wsByPath = {} }
1642
- const items = []
1643
- const CHUNK = 6
1644
- for (let i = 0; i < idStrs.length; i += CHUNK) {
1645
- const res2 = await Promise.all(idStrs.slice(i, i + CHUNK).map((id) => resolveOne(id)))
1646
- items.push.apply(items, res2)
1647
- }
1682
+ // issue #8:归档列表也复用「缓存直读 + 后台预热」的统一管线。
1683
+ // 旧实现逐条 resolveOne(id)(无 header、无指纹)——冷启动对每条归档
1684
+ // 会话整本解码,且连进程内缓存都永远命中不了。
1685
+ const wanted = new Set(idStrs)
1686
+ const detailed = await allSessionItemsDetailed()
1687
+ const items = detailed.items.filter((it) => wanted.has(String(it.sessionId)) && it.archived)
1688
+ for (const it of items) delete it.archived
1648
1689
  json(res, { items })
1649
1690
  } catch (e) {
1650
1691
  json(res, { error: String((e && e.message) || e) }, 500)
@@ -58,6 +58,24 @@ export function isPersistableFingerprint(fingerprint) {
58
58
  return typeof fingerprint === 'string' && fingerprint !== '' && !fingerprint.startsWith(REVISION_PREFIX)
59
59
  }
60
60
 
61
+ // 跨进程(重启后)仍可用的持久指纹,供 title-persist-index 使用(issue #8)。
62
+ //
63
+ // - legacy 世代:fingerprintOf 产出 (mtimeMs:size) 文件指纹,本身可持久化;
64
+ // - handle 世代(0.1.3+):fingerprintOf 产出 rev: 指纹,被 isPersistableFingerprint
65
+ // 正确拒绝(revision 是实例内 opaque token)。但这样持久索引在该世代**从不
66
+ // 写入**——issue #8 实测:249MB / 87 日志的库每次重启都重新全量解码约 90s。
67
+ // 退而求其次用官方 list()/stat() 每次带回的 sizeBytes("sz:<n>"):
68
+ // 日志任何追加/截断都会改变 size,size 不变而标题变化在真实场景里基本
69
+ // 不存在;最坏代价是显示一条陈旧标题,绝不产生错误数据。
70
+ // cwd/createdAt 不受此影响:回填时始终以最新 list header 为权威(见 index.js)。
71
+ // - 拿不到任何可用观测量时返回 null(调用方按未命中/不落盘处理)。
72
+ export function persistFingerprintOf(stat) {
73
+ const fp = fingerprintOf(stat)
74
+ if (fp && isPersistableFingerprint(fp)) return fp
75
+ if (stat && Number.isFinite(stat.sizeBytes) && stat.sizeBytes >= 0) return `sz:${stat.sizeBytes}`
76
+ return null
77
+ }
78
+
61
79
  // 缓存条目是否仍然新鲜(纯函数)。
62
80
  // stat 传 { revision } 或 { mtimeMs, size };两类指纹不能互相匹配。
63
81
  export function isFresh(entry, stat, now, ttlMs = DEFAULT_TTL_MS) {