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/README.en.md +1 -1
- package/README.md +1 -1
- package/lib/index.js +113 -91
- package/lib/index.js.map +2 -2
- package/package.json +1 -1
- package/src/index.js +154 -113
- package/src/session-meta-cache.js +18 -0
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.
|
|
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,
|
|
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
|
-
|
|
183
|
-
|
|
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
|
-
//
|
|
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 =
|
|
203
|
-
if (!
|
|
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
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
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
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
//
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
if (
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
}
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
if (
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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,
|
|
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.
|
|
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)
|
|
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
|
-
|
|
1258
|
-
|
|
1259
|
-
const
|
|
1260
|
-
const
|
|
1261
|
-
|
|
1262
|
-
//
|
|
1263
|
-
//
|
|
1264
|
-
|
|
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
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
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)
|
|
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
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1427
|
+
// issue #8:缺失标题不在请求路径上同步投影(runtime 投影 = 逐条整本解码),
|
|
1428
|
+
// 交后台预热补齐;侧栏高频轮询会让补齐结果自然浮现。
|
|
1429
|
+
if (rest.length) enqueueWarm(rest, statsById, entryById)
|
|
1375
1430
|
for (const id of ids) {
|
|
1376
|
-
|
|
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
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
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) {
|