dsh-sessions-manager 3.4.0 → 3.4.2
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/client.js +31 -4
- package/lib/client.js.map +2 -2
- package/lib/index.js +408 -93
- package/lib/index.js.map +4 -4
- package/package.json +1 -1
- package/src/client/index.jsx +47 -4
- package/src/index.js +240 -71
- package/src/session-meta-cache.js +117 -0
- package/src/title-persist-index.js +137 -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.4.
|
|
4
|
+
"version": "3.4.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
package/src/client/index.jsx
CHANGED
|
@@ -432,7 +432,9 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
432
432
|
setBusy(null)
|
|
433
433
|
const n = it.title || it.sessionId
|
|
434
434
|
showToast(action === 'archive' ? `已归档「${n}」` : `已恢复「${n}」`)
|
|
435
|
-
refresh
|
|
435
|
+
// 单条归档/恢复只是成员标记翻转:本地更新即可,不走整表 refresh
|
|
436
|
+
//(refresh 会清空多选状态;host 侧缓存已把列表成本压到 stat 级别)。
|
|
437
|
+
setSessions((s) => s && s.map((x) => (x.sessionId === it.sessionId ? { ...x, archived: action === 'archive' } : x)))
|
|
436
438
|
})
|
|
437
439
|
.catch((e) => { setBusy(null); setError(String((e && e.message) || e)) })
|
|
438
440
|
}
|
|
@@ -445,8 +447,12 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
445
447
|
setBusy(null)
|
|
446
448
|
const n = delTarget.title || delTarget.sessionId
|
|
447
449
|
showToast(`已删除 ${n}(已移入回收站)`)
|
|
450
|
+
const sid = String(delTarget.sessionId)
|
|
448
451
|
setDelTarget(null)
|
|
449
|
-
refresh
|
|
452
|
+
// 删除进入回收站:本地移除该行 + 单刷回收站,不整表 refresh。
|
|
453
|
+
setSessions((s) => s && s.filter((x) => String(x.sessionId) !== sid))
|
|
454
|
+
loadTrash()
|
|
455
|
+
dsmLoadTrashIds()
|
|
450
456
|
})
|
|
451
457
|
.catch((e) => { setBusy(null); setDelTarget(null); setError(String((e && e.message) || e)) })
|
|
452
458
|
}
|
|
@@ -559,7 +565,10 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
559
565
|
setMoveMode('existing')
|
|
560
566
|
setNewPath('')
|
|
561
567
|
showToast(`已把「${it.title || it.sessionId}」移到 ${r.workspaceTitle || targetPath}`)
|
|
562
|
-
refresh
|
|
568
|
+
// 移动只改 workspacePath/workspaceTitle:本地替换该行,不整表 refresh。
|
|
569
|
+
setSessions((s) => s && s.map((x) => (x.sessionId === it.sessionId
|
|
570
|
+
? { ...x, workspacePath: r.workspacePath || targetPath, workspaceTitle: r.workspaceTitle || targetPath }
|
|
571
|
+
: x)))
|
|
563
572
|
})
|
|
564
573
|
.catch((e) => { setBusy(null); setError(String((e && e.message) || e)) })
|
|
565
574
|
}
|
|
@@ -1627,6 +1636,16 @@ function installSidebarWorkspaceDrag() {
|
|
|
1627
1636
|
// native drag gesture for reordering sessions inside one group; only a real
|
|
1628
1637
|
// workspace-heading target is intercepted here, so same-group sorting keeps
|
|
1629
1638
|
// its official behavior.
|
|
1639
|
+
// 指针按下先于 dragstart 数十到数百毫秒,正好用来给「Map 里还没有的行」预热:
|
|
1640
|
+
// replace the 5s poll with a just-in-time fetch, so a brand-new session is
|
|
1641
|
+
// draggable on the first gesture without a background poll.
|
|
1642
|
+
document.addEventListener('pointerdown', (event) => {
|
|
1643
|
+
if (moving) return
|
|
1644
|
+
const row = eventRow(event)
|
|
1645
|
+
if (!row || sessionForRow(row)) return
|
|
1646
|
+
maybeRefresh()
|
|
1647
|
+
}, true)
|
|
1648
|
+
|
|
1630
1649
|
document.addEventListener('dragstart', (event) => {
|
|
1631
1650
|
const row = eventRow(event)
|
|
1632
1651
|
const item = row && sessionForRow(row)
|
|
@@ -1682,7 +1701,31 @@ function installSidebarWorkspaceDrag() {
|
|
|
1682
1701
|
refresh()
|
|
1683
1702
|
const observer = new MutationObserver(scheduleDecorate)
|
|
1684
1703
|
observer.observe(document.body, { childList: true, subtree: true })
|
|
1685
|
-
|
|
1704
|
+
// 原先这里每 5s 无脑全表刷新一次。该接口在 host 侧要遍历全部会话,
|
|
1705
|
+
// 大库(issue #1: 49 会话 / 14 万帧)上每次都是一次重活,页面挂着就一直占宿主 CPU,
|
|
1706
|
+
// 连累 session.history 之类 RPC 超时。拖拽元数据根本不需要 5s 精度:
|
|
1707
|
+
// - 背景标签页完全不刷新
|
|
1708
|
+
// - 前台每 60s 兜底一次
|
|
1709
|
+
// - 标签页重新可见、或真正开始拖拽而 Map 里没有该行时,按需刷新
|
|
1710
|
+
let lastRefreshAt = Date.now()
|
|
1711
|
+
const maybeRefresh = () => {
|
|
1712
|
+
if (typeof document !== 'undefined' && document.hidden) return
|
|
1713
|
+
lastRefreshAt = Date.now()
|
|
1714
|
+
return refresh()
|
|
1715
|
+
}
|
|
1716
|
+
const REFRESH_MS = 60000
|
|
1717
|
+
setInterval(() => {
|
|
1718
|
+
if (typeof document !== 'undefined' && document.hidden) return
|
|
1719
|
+
if (Date.now() - lastRefreshAt < REFRESH_MS) return
|
|
1720
|
+
maybeRefresh()
|
|
1721
|
+
}, REFRESH_MS)
|
|
1722
|
+
if (typeof document !== 'undefined' && document.addEventListener) {
|
|
1723
|
+
document.addEventListener('visibilitychange', () => {
|
|
1724
|
+
if (document.hidden) return
|
|
1725
|
+
if (Date.now() - lastRefreshAt < 30000) return
|
|
1726
|
+
maybeRefresh()
|
|
1727
|
+
})
|
|
1728
|
+
}
|
|
1686
1729
|
}
|
|
1687
1730
|
|
|
1688
1731
|
export function apply(ctx) {
|
package/src/index.js
CHANGED
|
@@ -16,6 +16,8 @@ import { renderSessionMarkdown } from './markdown.js'
|
|
|
16
16
|
import { createStarIndex } from './star-index.js'
|
|
17
17
|
import { aggregateStorage } from './storage-stats.js'
|
|
18
18
|
import { createAutoArchiveStore, pickInactiveCandidates } from './auto-archive.js'
|
|
19
|
+
import { createSessionMetaCache, fingerprintOf } from './session-meta-cache.js'
|
|
20
|
+
import { createTitleIndexStore } from './title-persist-index.js'
|
|
19
21
|
|
|
20
22
|
|
|
21
23
|
export const name = 'dsh-sessions-manager'
|
|
@@ -123,7 +125,67 @@ export function apply(ctx) {
|
|
|
123
125
|
const sq = ctx.sessionQuery
|
|
124
126
|
const dom = () => ctx.storageDomain.get('workspace')
|
|
125
127
|
const authorityTitleCache = new Map()
|
|
126
|
-
|
|
128
|
+
// 会话原始元数据缓存(title / cwd / createdAt),按日志文件 (mtime, size) 指纹校验。
|
|
129
|
+
// 见 src/session-meta-cache.js 的说明:列表构建原本每条会话都要整本解压日志,
|
|
130
|
+
// 这个缓存让「日志没变」的会话直接跳过解码。
|
|
131
|
+
const metaCache = createSessionMetaCache()
|
|
132
|
+
// 持久标题索引(冷启动加速):metaCache 是进程内的,重启即空——第一次列表
|
|
133
|
+
// 仍要全库解码。索引按同样的 (mtime, size) 指纹存解码结果,指纹没变的会话
|
|
134
|
+
// 重启后也直接复用。见 src/title-persist-index.js。
|
|
135
|
+
const titleIndex = createTitleIndexStore({ dir: TRASH_DIR, file: join(TRASH_DIR, 'title-index.json') })
|
|
136
|
+
|
|
137
|
+
// P4:对「内存缓存未命中」的会话查持久索引,指纹一致才可信。
|
|
138
|
+
// 返回 Map<id, meta>;调用方应把命中条目回填 metaCache 并从 missing 里剔除。
|
|
139
|
+
async function hydrateFromPersist(ids, statsById) {
|
|
140
|
+
const hits = new Map()
|
|
141
|
+
if (!ids || !ids.length) return hits
|
|
142
|
+
let store
|
|
143
|
+
try { store = await titleIndex.entries() } catch (e) { return hits }
|
|
144
|
+
for (const id of ids) {
|
|
145
|
+
const stat = statsById.get(id)
|
|
146
|
+
const entry = store && store[id]
|
|
147
|
+
if (!stat || !entry) continue
|
|
148
|
+
const fp = fingerprintOf(stat)
|
|
149
|
+
if (fp && entry.fingerprint === fp) {
|
|
150
|
+
hits.set(id, { title: entry.title, cwd: entry.cwd, createdAt: entry.createdAt })
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return hits
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 把本批真正解码出的元数据异步回写持久索引(fire-and-forget:索引只是
|
|
157
|
+
// 加速器,写失败不影响响应,队列内部已串行化 + 原子替换)。
|
|
158
|
+
function persistDecoded(decoded, statsById) {
|
|
159
|
+
if (!decoded || !decoded.size) return
|
|
160
|
+
const batch = {}
|
|
161
|
+
const now = Date.now()
|
|
162
|
+
for (const [id, meta] of decoded) {
|
|
163
|
+
const fp = fingerprintOf(statsById.get(id))
|
|
164
|
+
if (!fp) continue
|
|
165
|
+
batch[id] = { title: meta.title, cwd: meta.cwd, createdAt: meta.createdAt, fingerprint: fp, updatedAt: now }
|
|
166
|
+
}
|
|
167
|
+
if (!Object.keys(batch).length) return
|
|
168
|
+
titleIndex.merge(batch).catch(() => {})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 从投影快照里抽出元数据;快照缺失/异常时返回零值 meta(调用方决定兜底)。
|
|
172
|
+
function metaFromSnapshot(o) {
|
|
173
|
+
let title = null, createdAt = null, cwd = null
|
|
174
|
+
if (o) {
|
|
175
|
+
if (o.title && o.title.title) title = String(o.title.title)
|
|
176
|
+
if (o.session) { cwd = o.session.cwd || null; createdAt = o.session.createdAt || null }
|
|
177
|
+
}
|
|
178
|
+
return { title, cwd, createdAt }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 投影快照的两种返回形态都兼容:新版 runtime 返回 settled 结果
|
|
182
|
+
// ({ status: 'fulfilled', value }),老版本直接返回快照本身。
|
|
183
|
+
function unwrapSnapshot(result) {
|
|
184
|
+
if (!result) return null
|
|
185
|
+
if (result.status === 'fulfilled') return result.value || null
|
|
186
|
+
if (result.status === 'rejected') return null
|
|
187
|
+
return result
|
|
188
|
+
}
|
|
127
189
|
|
|
128
190
|
async function archivedState() {
|
|
129
191
|
const d = dom()
|
|
@@ -157,46 +219,70 @@ export function apply(ctx) {
|
|
|
157
219
|
|
|
158
220
|
let wsByPath = {}
|
|
159
221
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const o = await sq.readTitleSnapshot(id)
|
|
164
|
-
if (o) {
|
|
165
|
-
if (o.title && o.title.title) title = String(o.title.title)
|
|
166
|
-
if (o.session) { cwd = o.session.cwd || null; createdAt = o.session.createdAt || null }
|
|
167
|
-
}
|
|
168
|
-
} catch (e) { /* fall back to raw log */ }
|
|
169
|
-
if (!title || !cwd) {
|
|
170
|
-
try {
|
|
171
|
-
const r = await sp.readFrom(id, 0)
|
|
172
|
-
if (r.meta) {
|
|
173
|
-
if (!cwd) cwd = r.meta.cwd || null
|
|
174
|
-
if (!createdAt) createdAt = r.meta.createdAt || null
|
|
175
|
-
}
|
|
176
|
-
if (!title && Array.isArray(r.events)) title = foldTitle(r.events)
|
|
177
|
-
} catch (e2) { /* keep what we have */ }
|
|
178
|
-
}
|
|
222
|
+
// 把原始元数据渲染成列表项。缓存命中与解码两条路径共用,保证输出一致。
|
|
223
|
+
function buildItem(key, meta, usage, exposeUsage) {
|
|
224
|
+
const cwd = meta.cwd || null
|
|
179
225
|
const ws = cwd ? wsByPath[cwd] : undefined
|
|
180
|
-
const
|
|
226
|
+
const title = meta.title || null
|
|
181
227
|
const display = title ? (String(title).length > MAX_TITLE ? String(title).slice(0, MAX_TITLE) + '…' : String(title)) : null
|
|
182
228
|
const base = {
|
|
183
|
-
sessionId:
|
|
229
|
+
sessionId: key,
|
|
184
230
|
title: display,
|
|
185
|
-
createdAt: createdAt || null,
|
|
186
|
-
workspacePath: cwd
|
|
231
|
+
createdAt: meta.createdAt || null,
|
|
232
|
+
workspacePath: cwd,
|
|
187
233
|
workspaceTitle: (ws && ws.title) ? ws.title : null,
|
|
188
|
-
workspaceGone:
|
|
234
|
+
workspaceGone: !!(cwd && !ws),
|
|
189
235
|
hasWorkspace: !!cwd,
|
|
190
236
|
}
|
|
191
|
-
// sizeBytes / updatedAt
|
|
192
|
-
//
|
|
193
|
-
if (usage) {
|
|
194
|
-
if (usage.sizeById && usage.sizeById.has(
|
|
195
|
-
if (usage.mtimeById && usage.mtimeById.has(
|
|
237
|
+
// sizeBytes / updatedAt 只在需要的路由(存储分析 / 自动归档)里带上:
|
|
238
|
+
// 它们本就来自 usage,附带输出对列表渲染无益。
|
|
239
|
+
if (exposeUsage && usage) {
|
|
240
|
+
if (usage.sizeById && usage.sizeById.has(key)) base.sizeBytes = usage.sizeById.get(key)
|
|
241
|
+
if (usage.mtimeById && usage.mtimeById.has(key)) base.updatedAt = usage.mtimeById.get(key)
|
|
196
242
|
}
|
|
197
243
|
return base
|
|
198
244
|
}
|
|
199
245
|
|
|
246
|
+
// Resolve one session's display metadata.
|
|
247
|
+
//
|
|
248
|
+
// 成本模型(issue #1):下面的解码路径会把整本 .jsonl.zstd 逐帧解压、逐行
|
|
249
|
+
// JSON.parse,只为折叠出标题——大库上一次全表要几秒阻塞式 CPU。日志的
|
|
250
|
+
// (mtime, size) 没变就意味着内容没变,折叠结果也不可能变,所以命中缓存时
|
|
251
|
+
// 直接复用上次的元数据,跳过整本解码。
|
|
252
|
+
//
|
|
253
|
+
// opts.preloaded:批量投影(sq.readTitleSnapshots)已经拿到的快照;传了就不再
|
|
254
|
+
// 对同一条日志做第二次单例投影——issue 里「一条日志在单次列表里被解码两次」
|
|
255
|
+
// 正是这么来的。
|
|
256
|
+
async function resolveOne(id, usage, opts = {}) {
|
|
257
|
+
const key = String(id)
|
|
258
|
+
const statInfo = usage ? { mtimeMs: usage.mtimeById.get(key), size: usage.sizeById.get(key) } : null
|
|
259
|
+
const cached = metaCache.get(key, statInfo)
|
|
260
|
+
if (cached) return buildItem(key, cached, usage, opts.exposeUsage)
|
|
261
|
+
|
|
262
|
+
let meta = { title: null, cwd: null, createdAt: null }
|
|
263
|
+
if (opts.preloaded !== undefined) {
|
|
264
|
+
meta = metaFromSnapshot(unwrapSnapshot(opts.preloaded))
|
|
265
|
+
} else if (typeof sq.readTitleSnapshot === 'function') {
|
|
266
|
+
try { meta = metaFromSnapshot(await sq.readTitleSnapshot(id)) } catch (e) { /* fall back to raw log */ }
|
|
267
|
+
}
|
|
268
|
+
// 兜底:投影没给出标题或 cwd 时,才回退到整本解码(这条路径本身就贵,
|
|
269
|
+
// 且结果同样会进缓存,下一次列表就不会再走一遍)。
|
|
270
|
+
if (!meta.title || !meta.cwd) {
|
|
271
|
+
try {
|
|
272
|
+
const r = await sp.readFrom(id, 0)
|
|
273
|
+
if (r.meta) {
|
|
274
|
+
if (!meta.cwd) meta.cwd = r.meta.cwd || null
|
|
275
|
+
if (!meta.createdAt) meta.createdAt = r.meta.createdAt || null
|
|
276
|
+
}
|
|
277
|
+
if (!meta.title && Array.isArray(r.events)) meta.title = foldTitle(r.events)
|
|
278
|
+
} catch (e2) { /* keep what we have */ }
|
|
279
|
+
}
|
|
280
|
+
metaCache.set(key, statInfo, meta)
|
|
281
|
+
// 本条是「真解码」出来的:交给调用方回写持久标题索引(P4 冷启动加速)。
|
|
282
|
+
if (opts.collectDecoded && statInfo) opts.collectDecoded(key, meta)
|
|
283
|
+
return buildItem(key, meta, usage, opts.exposeUsage)
|
|
284
|
+
}
|
|
285
|
+
|
|
200
286
|
// Disk usage + last-write time for every session, in one pass.
|
|
201
287
|
//
|
|
202
288
|
// sp.locate(header) resolves the log file behind a session header; a single
|
|
@@ -205,11 +291,13 @@ export function apply(ctx) {
|
|
|
205
291
|
// file's last write tracks the conversation's last turn. It errs safe: a log
|
|
206
292
|
// we relocated (move) gets a fresh mtime and therefore looks *more* active
|
|
207
293
|
// than it is, which can only delay an auto-archive, never cause a wrong one.
|
|
208
|
-
|
|
294
|
+
// headers 可由调用方传入复用(列表构建里已经 sp.list() 过一次,避免重复列目录)。
|
|
295
|
+
async function collectUsage(preloadedHeaders) {
|
|
209
296
|
const sizeById = new Map()
|
|
210
297
|
const mtimeById = new Map()
|
|
211
|
-
let headers =
|
|
212
|
-
|
|
298
|
+
let headers = null
|
|
299
|
+
if (Array.isArray(preloadedHeaders)) headers = preloadedHeaders
|
|
300
|
+
else { try { headers = await sp.list() } catch (e) { headers = [] } }
|
|
213
301
|
if (!Array.isArray(headers)) headers = []
|
|
214
302
|
const CHUNK = 8
|
|
215
303
|
for (let i = 0; i < headers.length; i += CHUNK) {
|
|
@@ -705,20 +793,43 @@ export function apply(ctx) {
|
|
|
705
793
|
})
|
|
706
794
|
}
|
|
707
795
|
|
|
708
|
-
//
|
|
709
|
-
//
|
|
796
|
+
// 批量投影:一次调用把多条会话的标题/header 拿出来,避免逐条触发整本解码。
|
|
797
|
+
// 老 runtime 没有 readTitleSnapshots 时返回空 Map,调用方自然回退到逐条投影
|
|
798
|
+
// (功能不受影响,只是少了这层优化——插件不能假设对方的 runtime 版本)。
|
|
799
|
+
async function projectTitles(ids) {
|
|
800
|
+
const out = new Map()
|
|
801
|
+
if (!ids || !ids.length) return out
|
|
802
|
+
if (typeof sq.readTitleSnapshots !== 'function') return out
|
|
803
|
+
try {
|
|
804
|
+
const results = await sq.readTitleSnapshots(ids)
|
|
805
|
+
if (!Array.isArray(results)) return out
|
|
806
|
+
results.forEach((result, index) => {
|
|
807
|
+
const id = String(ids[index])
|
|
808
|
+
out.set(id, unwrapSnapshot(result))
|
|
809
|
+
})
|
|
810
|
+
} catch (e) { /* 批量失败:逐条回退 */ }
|
|
811
|
+
return out
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// opts.usage: expose sizeBytes + updatedAt on each item (storage analysis and
|
|
815
|
+
// the auto-archive sweep need them; the panel list does not).
|
|
816
|
+
//
|
|
817
|
+
// 性能要点(issue #1):
|
|
818
|
+
// 1. sp.list() 只调一次(原先列了两遍目录)
|
|
819
|
+
// 2. 无条件做一遍 stat——一次 stat 是微秒级,而它算出的 (mtime, size) 指纹
|
|
820
|
+
// 是元数据缓存能否跳过整本解码的前提,收益远大于成本
|
|
821
|
+
// 3. 未命中缓存的会话走**一次**批量投影(sq.readTitleSnapshots),而不是逐条
|
|
710
822
|
async function allSessionItems(opts = {}) {
|
|
711
|
-
let
|
|
712
|
-
let live = ctx.get('sessions')
|
|
823
|
+
let headers = []
|
|
713
824
|
let headersOk = false
|
|
714
825
|
try {
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
headersOk =
|
|
718
|
-
} catch (e) {
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
if (live) { try { live.list().forEach((s) => { if (!ids.includes(
|
|
826
|
+
headers = await sp.list()
|
|
827
|
+
headersOk = Array.isArray(headers)
|
|
828
|
+
if (!headersOk) headers = []
|
|
829
|
+
} catch (e) { headers = [] }
|
|
830
|
+
let live = ctx.get('sessions')
|
|
831
|
+
const ids = headers.map((h) => String(h.id))
|
|
832
|
+
if (live) { try { live.list().forEach((s) => { const sid = String(s.id); if (!ids.includes(sid)) ids.push(sid) }) } catch (e) { /* ignore */ } }
|
|
722
833
|
// Exclude sessions already moved to the recycle bin (软删除): they live in
|
|
723
834
|
// 回收站, not in 会话管理, so the panel won't re-list them after a delete.
|
|
724
835
|
let hiddenIds = new Set()
|
|
@@ -731,14 +842,29 @@ export function apply(ctx) {
|
|
|
731
842
|
try { for (const ent of w.list()) wsByPath[ent.path] = ent } catch (e) { wsByPath = {} }
|
|
732
843
|
const currentArchived = new Set((await archivedState().catch(() => ({ archivedSessionIds: [] }))).archivedSessionIds || [])
|
|
733
844
|
const items = []
|
|
734
|
-
const usage =
|
|
845
|
+
const usage = await collectUsage(headers)
|
|
846
|
+
// 先按指纹把「缓存命中」与「需要解码」分开,只对后者做批量投影。
|
|
847
|
+
const statsById = new Map(visibleIds.map((id) => [id, { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(id) }]))
|
|
848
|
+
const { cached, missing } = metaCache.partition(visibleIds, statsById)
|
|
849
|
+
// P4:missing 里先查持久标题索引(冷启动跳过整本解码),命中的回填内存缓存。
|
|
850
|
+
const persisted = await hydrateFromPersist(missing, statsById)
|
|
851
|
+
for (const [id, meta] of persisted) metaCache.set(id, statsById.get(id), meta)
|
|
852
|
+
const stillMissing = missing.filter((id) => !persisted.has(id))
|
|
853
|
+
const snapshotById = await projectTitles(stillMissing)
|
|
854
|
+
const decoded = new Map()
|
|
855
|
+
const collectDecoded = (id, meta) => { decoded.set(id, meta) }
|
|
735
856
|
const CHUNK = 6
|
|
736
857
|
for (let i = 0; i < visibleIds.length; i += CHUNK) {
|
|
737
858
|
// Arrow wrapper on purpose: Array#map passes (value, index, array), and
|
|
738
|
-
// resolveOne's second
|
|
739
|
-
const res2 = await Promise.all(visibleIds.slice(i, i + CHUNK).map((id) => resolveOne(id, usage
|
|
859
|
+
// resolveOne's second and third arguments are fixed here.
|
|
860
|
+
const res2 = await Promise.all(visibleIds.slice(i, i + CHUNK).map((id) => resolveOne(id, usage, {
|
|
861
|
+
exposeUsage: !!(opts && opts.usage),
|
|
862
|
+
preloaded: snapshotById.has(id) ? snapshotById.get(id) : undefined,
|
|
863
|
+
collectDecoded,
|
|
864
|
+
})))
|
|
740
865
|
for (const it of res2) items.push({ ...it, archived: currentArchived.has(it.sessionId) })
|
|
741
866
|
}
|
|
867
|
+
persistDecoded(decoded, statsById)
|
|
742
868
|
// Annotate stars; GC only when we have a trustworthy id baseline, so a
|
|
743
869
|
// failing sp.list() can never wipe the whole index.
|
|
744
870
|
let starredSet = new Set()
|
|
@@ -794,28 +920,45 @@ export function apply(ctx) {
|
|
|
794
920
|
return { ok: true, archived, candidates: candidates.length, failed, lastRunAt: now }
|
|
795
921
|
}
|
|
796
922
|
|
|
923
|
+
// 侧栏权威数据:标题 + 回收站 id 集合。
|
|
924
|
+
//
|
|
925
|
+
// 标题原先「首次调用算一次就永久缓存」,日志之后再变也不会更新——标题会陈旧。
|
|
926
|
+
// 现在复用 metaCache:每次调用只 stat 一遍,日志没变直接取缓存,变了才重解码,
|
|
927
|
+
// 既不会陈旧也不会回到「每次全量解码」。
|
|
797
928
|
async function sidebarAuthority() {
|
|
798
929
|
const ids = []
|
|
799
|
-
|
|
930
|
+
let headers = []
|
|
931
|
+
try { headers = await sp.list() } catch (e) { headers = [] }
|
|
932
|
+
if (!Array.isArray(headers)) headers = []
|
|
933
|
+
for (const header of headers) ids.push(String(header.id))
|
|
800
934
|
const sessions = ctx.get('sessions')
|
|
801
|
-
try { if (sessions) sessions.list().forEach((session) => { if (!ids.includes(
|
|
935
|
+
try { if (sessions) sessions.list().forEach((session) => { const sid = String(session.id); if (!ids.includes(sid)) ids.push(sid) }) } catch (e) {}
|
|
802
936
|
const store = await readTrashStore()
|
|
803
|
-
if (
|
|
804
|
-
const
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
937
|
+
if (ids.length) {
|
|
938
|
+
const usage = await collectUsage(headers)
|
|
939
|
+
const statsById = new Map(ids.map((id) => [id, { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(id) }]))
|
|
940
|
+
const { cached, missing } = metaCache.partition(ids, statsById)
|
|
941
|
+
// P4:与列表构建共用持久标题索引,冷启动零解码。
|
|
942
|
+
const persisted = await hydrateFromPersist(missing, statsById)
|
|
943
|
+
for (const [id, meta] of persisted) metaCache.set(id, statsById.get(id), meta)
|
|
944
|
+
const rest = missing.filter((id) => !persisted.has(id))
|
|
945
|
+
const snapshotById = await projectTitles(rest)
|
|
946
|
+
const decoded = new Map()
|
|
947
|
+
const collectDecoded = (id, meta) => { decoded.set(id, meta) }
|
|
948
|
+
for (const id of ids) {
|
|
949
|
+
let meta = cached.get(id) || persisted.get(id) || null
|
|
950
|
+
if (!meta) {
|
|
951
|
+
const snapshot = snapshotById.has(id)
|
|
952
|
+
? snapshotById.get(id)
|
|
953
|
+
: (typeof sq.readTitleSnapshot === 'function' ? await sq.readTitleSnapshot(id).catch(() => null) : null)
|
|
954
|
+
const next = metaFromSnapshot(snapshot)
|
|
955
|
+
metaCache.set(id, statsById.get(id), next)
|
|
956
|
+
if (statsById.get(id)) collectDecoded(id, next)
|
|
957
|
+
meta = next
|
|
808
958
|
}
|
|
809
|
-
|
|
810
|
-
authorityTitlesLoaded = true
|
|
811
|
-
} else if (!authorityTitlesLoaded) {
|
|
812
|
-
for (const sid of ids) {
|
|
813
|
-
try {
|
|
814
|
-
const snapshot = await sq.readTitleSnapshot(sid)
|
|
815
|
-
if (snapshot && snapshot.title && typeof snapshot.title.title === 'string') authorityTitleCache.set(sid, snapshot.title.title)
|
|
816
|
-
} catch (e) {}
|
|
959
|
+
if (meta && meta.title) authorityTitleCache.set(id, String(meta.title))
|
|
817
960
|
}
|
|
818
|
-
|
|
961
|
+
persistDecoded(decoded, statsById)
|
|
819
962
|
}
|
|
820
963
|
return {
|
|
821
964
|
titles: Object.fromEntries(authorityTitleCache),
|
|
@@ -1031,7 +1174,11 @@ export function apply(ctx) {
|
|
|
1031
1174
|
const body = await readJsonBody(req)
|
|
1032
1175
|
const sid = body && typeof body.sessionId === 'string' ? body.sessionId : null
|
|
1033
1176
|
if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
|
|
1034
|
-
|
|
1177
|
+
const out = await deleteOne(sid)
|
|
1178
|
+
// 日志被搬进回收站(文件已不在原处):丢弃缓存条目,避免下次 stat 失败
|
|
1179
|
+
// 时残留旧元数据。
|
|
1180
|
+
metaCache.invalidate(sid)
|
|
1181
|
+
json(res, out)
|
|
1035
1182
|
} catch (e) {
|
|
1036
1183
|
json(res, { ok: false, error: String((e && e.message) || e) }, errorStatus(e))
|
|
1037
1184
|
}
|
|
@@ -1048,7 +1195,7 @@ export function apply(ctx) {
|
|
|
1048
1195
|
if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
|
|
1049
1196
|
const results = []
|
|
1050
1197
|
for (const sid of ids) {
|
|
1051
|
-
try { results.push({ sessionId: sid, ok: true, ...(await deleteOne(sid)) }) }
|
|
1198
|
+
try { results.push({ sessionId: sid, ok: true, ...(await deleteOne(sid)) }); metaCache.invalidate(sid) }
|
|
1052
1199
|
catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
|
|
1053
1200
|
}
|
|
1054
1201
|
json(res, { ok: true, deleted: results.filter((r) => r.ok).length, results })
|
|
@@ -1117,7 +1264,9 @@ export function apply(ctx) {
|
|
|
1117
1264
|
const body = await readJsonBody(req)
|
|
1118
1265
|
const sid = body && typeof body.sessionId === 'string' ? body.sessionId : null
|
|
1119
1266
|
if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
|
|
1120
|
-
|
|
1267
|
+
const out = await restoreFromTrash(sid)
|
|
1268
|
+
metaCache.invalidate(sid)
|
|
1269
|
+
json(res, out)
|
|
1121
1270
|
} catch (e) {
|
|
1122
1271
|
json(res, { ok: false, error: String((e && e.message) || e) }, 500)
|
|
1123
1272
|
}
|
|
@@ -1132,7 +1281,11 @@ export function apply(ctx) {
|
|
|
1132
1281
|
const body = await readJsonBody(req)
|
|
1133
1282
|
const sid = body && typeof body.sessionId === 'string' ? body.sessionId : null
|
|
1134
1283
|
if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
|
|
1135
|
-
|
|
1284
|
+
const out = await purgeFromTrash(sid)
|
|
1285
|
+
metaCache.invalidate(sid)
|
|
1286
|
+
// 彻底删除:持久标题索引里的条目一并清掉(issue #1 P4)。
|
|
1287
|
+
titleIndex.remove([sid]).catch(() => {})
|
|
1288
|
+
json(res, out)
|
|
1136
1289
|
} catch (e) {
|
|
1137
1290
|
json(res, { ok: false, error: String((e && e.message) || e) }, 500)
|
|
1138
1291
|
}
|
|
@@ -1149,7 +1302,7 @@ export function apply(ctx) {
|
|
|
1149
1302
|
if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
|
|
1150
1303
|
const results = []
|
|
1151
1304
|
for (const sid of ids) {
|
|
1152
|
-
try { results.push({ sessionId: sid, ok: true, ...(await purgeFromTrash(sid)) }) }
|
|
1305
|
+
try { results.push({ sessionId: sid, ok: true, ...(await purgeFromTrash(sid)) }); metaCache.invalidate(sid); titleIndex.remove([sid]).catch(() => {}) }
|
|
1153
1306
|
catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
|
|
1154
1307
|
}
|
|
1155
1308
|
json(res, { ok: true, purged: results.filter((r) => r.ok).length, results })
|
|
@@ -1260,6 +1413,9 @@ export function apply(ctx) {
|
|
|
1260
1413
|
if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
|
|
1261
1414
|
if (!target) return json(res, { ok: false, error: 'missing targetPath' }, 400)
|
|
1262
1415
|
const moved = await moveOne(sid, target)
|
|
1416
|
+
// 移动会改写日志 frame0 的 cwd:元数据(cwd)已变,主动丢弃缓存条目,
|
|
1417
|
+
// 不等 mtime 指纹自然失效(Windows 上 mtime 精度较粗,指纹可能不变)。
|
|
1418
|
+
metaCache.invalidate(sid)
|
|
1263
1419
|
// Reindex the host's in-memory sessionPath index so the sidebar
|
|
1264
1420
|
// reflects the new grouping immediately (no DSH restart needed).
|
|
1265
1421
|
// Do this before replying: drag/drop and menu clients treat a 2xx
|
|
@@ -1343,7 +1499,10 @@ export function apply(ctx) {
|
|
|
1343
1499
|
|
|
1344
1500
|
// Auto-archive settings. A plain read (no patch keys) doubles as the lazy
|
|
1345
1501
|
// sweep trigger — that is how the once-a-day cleanup gets a chance to run
|
|
1346
|
-
// without a background timer.
|
|
1502
|
+
// without a background timer. The sweep runs in the background so opening
|
|
1503
|
+
// the panel never waits on it (P5, issue #1): the sweep itself already
|
|
1504
|
+
// reuses the metadata caches, and this keeps the settings read latency
|
|
1505
|
+
// independent of library size.
|
|
1347
1506
|
disposers.push(ctx.webServer.register({
|
|
1348
1507
|
kind: 'exact',
|
|
1349
1508
|
path: '/archived-sessions/auto-archive/settings',
|
|
@@ -1353,10 +1512,20 @@ export function apply(ctx) {
|
|
|
1353
1512
|
const patch = {}
|
|
1354
1513
|
if (body && Object.prototype.hasOwnProperty.call(body, 'inactiveDays')) patch.inactiveDays = body.inactiveDays
|
|
1355
1514
|
if (body && Object.prototype.hasOwnProperty.call(body, 'skipStarred')) patch.skipStarred = body.skipStarred
|
|
1356
|
-
const
|
|
1515
|
+
const isPatch = Object.keys(patch).length > 0
|
|
1516
|
+
const settings = isPatch
|
|
1357
1517
|
? await autoArchive.update(patch)
|
|
1358
1518
|
: (await autoArchive.read()).settings
|
|
1359
|
-
|
|
1519
|
+
let sweep
|
|
1520
|
+
if (isPatch) {
|
|
1521
|
+
// 显式保存设置:保持「保存即生效」的同步 sweep(含刚启用时的首次归档)。
|
|
1522
|
+
sweep = await autoArchiveSweep()
|
|
1523
|
+
} else {
|
|
1524
|
+
// 面板打开的纯读取:sweep 转后台执行,打开延迟与库大小解耦
|
|
1525
|
+
//(P5,issue #1)。sweep 本身已复用元数据缓存 + 持久标题索引。
|
|
1526
|
+
void autoArchiveSweep().catch(() => {})
|
|
1527
|
+
sweep = { triggered: true }
|
|
1528
|
+
}
|
|
1360
1529
|
const store = await autoArchive.read()
|
|
1361
1530
|
json(res, {
|
|
1362
1531
|
ok: true,
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// session-meta-cache.js — 会话「原始元数据」内存缓存(按日志文件指纹校验)。
|
|
2
|
+
//
|
|
3
|
+
// 背景(issue #1):列表构建原本对每条会话调用 readTitleSnapshot,而该调用会把
|
|
4
|
+
// 会话日志(.jsonl.zstd)的**所有 zstd 帧**逐帧解压、逐行 JSON.parse,只为折叠出
|
|
5
|
+
// 最新标题。大库(数十条会话、十万级帧)一次全表要几秒 CPU,且解码是同步块,
|
|
6
|
+
// 会阻塞宿主事件循环,连累 session.history 之类的 RPC 超时。
|
|
7
|
+
//
|
|
8
|
+
// 关键观察:解码得出的元数据(title / cwd / createdAt)只随**日志文件内容**变化,
|
|
9
|
+
// 而任何append/改名/移动都会更新日志文件的 mtime。所以只要记下当时的
|
|
10
|
+
// (mtimeMs, size),下次 stat 到相同指纹就可以直接复用缓存,跳过整本解码。
|
|
11
|
+
//
|
|
12
|
+
// 为什么自己实现而不用 runtime 的 prepared 缓存:插件不能假设对方的 runtime 版本
|
|
13
|
+
// (issue 报告者是 0.1.1-rc.2,本机是 0.1.2-rc.1),runtime 侧的缓存容量/命中策略
|
|
14
|
+
// 各版本不同。本模块只用 node 原生能力与插件已有的 sp.locate + stat,任何版本行为一致。
|
|
15
|
+
//
|
|
16
|
+
// 失效策略(三重保险):
|
|
17
|
+
// 1. 指纹校验:mtimeMs 或 size 任一变化即视为过期(append 改 mtime、移动改写 frame0 也改 mtime)
|
|
18
|
+
// 2. TTL:防止「mtime 精度/时钟回拨」导致的长期陈旧
|
|
19
|
+
// 3. 显式 invalidate:删除 / 移动 / 归档等宿主操作后主动丢弃对应条目
|
|
20
|
+
//
|
|
21
|
+
// 纯逻辑与副作用分离:isFresh / partitionByCache 都是纯函数,便于单测。
|
|
22
|
+
|
|
23
|
+
const DEFAULT_TTL_MS = 5 * 60 * 1000
|
|
24
|
+
const DEFAULT_MAX = 4000
|
|
25
|
+
|
|
26
|
+
// 文件指纹:只有同时拿到 mtime 与 size 才可信(两者都变才算内容变了)。
|
|
27
|
+
// 拿不到 stat 信息时返回 null——表示「无法校验」,调用方必须按未命中处理,
|
|
28
|
+
// 绝不能在有疑问时返回旧数据。
|
|
29
|
+
export function fingerprintOf(stat) {
|
|
30
|
+
if (!stat || typeof stat !== 'object') return null
|
|
31
|
+
const mtimeMs = stat.mtimeMs
|
|
32
|
+
const size = stat.size
|
|
33
|
+
if (typeof mtimeMs !== 'number' || !Number.isFinite(mtimeMs) || mtimeMs <= 0) return null
|
|
34
|
+
if (typeof size !== 'number' || !Number.isFinite(size) || size < 0) return null
|
|
35
|
+
return `${Math.floor(mtimeMs)}:${size}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 缓存条目是否仍然新鲜(纯函数)。
|
|
39
|
+
export function isFresh(entry, stat, now, ttlMs = DEFAULT_TTL_MS) {
|
|
40
|
+
if (!entry) return false
|
|
41
|
+
const fp = fingerprintOf(stat)
|
|
42
|
+
if (!fp) return false
|
|
43
|
+
if (entry.fingerprint !== fp) return false
|
|
44
|
+
if (typeof entry.at !== 'number') return false
|
|
45
|
+
return (now - entry.at) <= ttlMs
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 把一批 id 分成「命中缓存」与「需要解码」两组(纯函数,便于单测)。
|
|
49
|
+
// statsById: Map<id, {mtimeMs, size}>;cache: 与 SessionMetaCache 同构的 Map。
|
|
50
|
+
export function partitionByCache(ids, statsById, cache, now = Date.now(), ttlMs = DEFAULT_TTL_MS) {
|
|
51
|
+
const cached = new Map()
|
|
52
|
+
const missing = []
|
|
53
|
+
for (const id of ids) {
|
|
54
|
+
const entry = cache && cache.get(String(id))
|
|
55
|
+
const stat = statsById && statsById.get(String(id))
|
|
56
|
+
if (isFresh(entry, stat, now, ttlMs) && entry && entry.meta) {
|
|
57
|
+
cached.set(String(id), entry.meta)
|
|
58
|
+
} else {
|
|
59
|
+
missing.push(String(id))
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { cached, missing }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createSessionMetaCache(opts = {}) {
|
|
66
|
+
const ttlMs = Number.isFinite(opts.ttlMs) ? opts.ttlMs : DEFAULT_TTL_MS
|
|
67
|
+
const max = Number.isInteger(opts.max) && opts.max > 0 ? opts.max : DEFAULT_MAX
|
|
68
|
+
const map = new Map()
|
|
69
|
+
let hits = 0
|
|
70
|
+
let misses = 0
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
// 命中返回 meta,未命中/无法校验返回 null。
|
|
74
|
+
get(id, stat) {
|
|
75
|
+
const key = String(id)
|
|
76
|
+
const entry = map.get(key)
|
|
77
|
+
if (isFresh(entry, stat, Date.now(), ttlMs)) {
|
|
78
|
+
hits++
|
|
79
|
+
// LRU:命中后移到末尾,容量满时优先淘汰最久未用。
|
|
80
|
+
map.delete(key)
|
|
81
|
+
map.set(key, entry)
|
|
82
|
+
return entry.meta
|
|
83
|
+
}
|
|
84
|
+
misses++
|
|
85
|
+
return null
|
|
86
|
+
},
|
|
87
|
+
set(id, stat, meta) {
|
|
88
|
+
if (!meta) return null
|
|
89
|
+
const fp = fingerprintOf(stat)
|
|
90
|
+
// 无法算出指纹(没 stat / stat 失败)时不写缓存:写进去就再也无法可靠失效。
|
|
91
|
+
if (!fp) return null
|
|
92
|
+
const key = String(id)
|
|
93
|
+
map.delete(key)
|
|
94
|
+
map.set(key, { fingerprint: fp, at: Date.now(), meta })
|
|
95
|
+
if (map.size > max) {
|
|
96
|
+
// 淘汰最久未用的一个(Map 保持插入顺序,首个即最旧)。
|
|
97
|
+
const oldest = map.keys().next().value
|
|
98
|
+
if (oldest !== undefined) map.delete(oldest)
|
|
99
|
+
}
|
|
100
|
+
return meta
|
|
101
|
+
},
|
|
102
|
+
// 批量判定:一次算出「命中缓存」与「需要解码」两组,供列表构建做批量投影。
|
|
103
|
+
partition(ids, statsById) {
|
|
104
|
+
return partitionByCache(ids, statsById, map, Date.now(), ttlMs)
|
|
105
|
+
},
|
|
106
|
+
invalidate(id) {
|
|
107
|
+
if (id == null) return false
|
|
108
|
+
const key = String(id)
|
|
109
|
+
const had = map.has(key)
|
|
110
|
+
map.delete(key)
|
|
111
|
+
return had
|
|
112
|
+
},
|
|
113
|
+
clear() { map.clear() },
|
|
114
|
+
get size() { return map.size },
|
|
115
|
+
stats() { return { size: map.size, hits, misses, ttlMs } },
|
|
116
|
+
}
|
|
117
|
+
}
|