dsh-sessions-manager 3.3.0 → 3.4.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 +21 -21
- package/README.md +21 -21
- package/assets/screenshot-session-autoarch.png +0 -0
- package/assets/screenshot-session-details.png +0 -0
- package/assets/screenshot-session-settings.png +0 -0
- package/assets/screenshot-session-starred.png +0 -0
- package/assets/screenshot-session-storage.png +0 -0
- package/assets/screenshot-session-trash.png +0 -0
- package/lib/client.js +192 -8
- package/lib/client.js.map +2 -2
- package/lib/index.js +506 -80
- package/lib/index.js.map +4 -4
- package/package.json +1 -1
- package/src/auto-archive.js +168 -0
- package/src/client/index.jsx +224 -9
- package/src/index.js +311 -58
- package/src/session-meta-cache.js +117 -0
- package/src/storage-stats.js +94 -0
- package/assets/screenshot-session-settingsmenu.png +0 -0
package/src/index.js
CHANGED
|
@@ -14,6 +14,10 @@ import { homedir } from 'node:os'
|
|
|
14
14
|
import { rewriteFrame0Cwd } from './zstd-frame.js'
|
|
15
15
|
import { renderSessionMarkdown } from './markdown.js'
|
|
16
16
|
import { createStarIndex } from './star-index.js'
|
|
17
|
+
import { aggregateStorage } from './storage-stats.js'
|
|
18
|
+
import { createAutoArchiveStore, pickInactiveCandidates } from './auto-archive.js'
|
|
19
|
+
import { createSessionMetaCache } from './session-meta-cache.js'
|
|
20
|
+
|
|
17
21
|
|
|
18
22
|
export const name = 'dsh-sessions-manager'
|
|
19
23
|
export const inject = ['webServer', 'workspaceRegistry', 'sessionPersistence', 'sessionQuery', 'storageDomain']
|
|
@@ -29,6 +33,7 @@ const DEFAULT_TRASH_SETTINGS = Object.freeze({ retentionDays: 0 })
|
|
|
29
33
|
const FETCH_TOOL_RE = /search|fetch|download|browse/i
|
|
30
34
|
const MAX_FETCHES = 12 // fetch 记录上限(防响应过大)
|
|
31
35
|
const MAX_FILES = 20 // write/edit 文件列表上限
|
|
36
|
+
const MAX_STORAGE_TOP = 50 // 存储排行返回上限(防响应过大)
|
|
32
37
|
|
|
33
38
|
function json(res, value, status = 200) {
|
|
34
39
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
@@ -119,7 +124,29 @@ export function apply(ctx) {
|
|
|
119
124
|
const sq = ctx.sessionQuery
|
|
120
125
|
const dom = () => ctx.storageDomain.get('workspace')
|
|
121
126
|
const authorityTitleCache = new Map()
|
|
122
|
-
|
|
127
|
+
// 会话原始元数据缓存(title / cwd / createdAt),按日志文件 (mtime, size) 指纹校验。
|
|
128
|
+
// 见 src/session-meta-cache.js 的说明:列表构建原本每条会话都要整本解压日志,
|
|
129
|
+
// 这个缓存让「日志没变」的会话直接跳过解码。
|
|
130
|
+
const metaCache = createSessionMetaCache()
|
|
131
|
+
|
|
132
|
+
// 从投影快照里抽出元数据;快照缺失/异常时返回零值 meta(调用方决定兜底)。
|
|
133
|
+
function metaFromSnapshot(o) {
|
|
134
|
+
let title = null, createdAt = null, cwd = null
|
|
135
|
+
if (o) {
|
|
136
|
+
if (o.title && o.title.title) title = String(o.title.title)
|
|
137
|
+
if (o.session) { cwd = o.session.cwd || null; createdAt = o.session.createdAt || null }
|
|
138
|
+
}
|
|
139
|
+
return { title, cwd, createdAt }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 投影快照的两种返回形态都兼容:新版 runtime 返回 settled 结果
|
|
143
|
+
// ({ status: 'fulfilled', value }),老版本直接返回快照本身。
|
|
144
|
+
function unwrapSnapshot(result) {
|
|
145
|
+
if (!result) return null
|
|
146
|
+
if (result.status === 'fulfilled') return result.value || null
|
|
147
|
+
if (result.status === 'rejected') return null
|
|
148
|
+
return result
|
|
149
|
+
}
|
|
123
150
|
|
|
124
151
|
async function archivedState() {
|
|
125
152
|
const d = dom()
|
|
@@ -153,37 +180,100 @@ export function apply(ctx) {
|
|
|
153
180
|
|
|
154
181
|
let wsByPath = {}
|
|
155
182
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
183
|
+
// 把原始元数据渲染成列表项。缓存命中与解码两条路径共用,保证输出一致。
|
|
184
|
+
function buildItem(key, meta, usage, exposeUsage) {
|
|
185
|
+
const cwd = meta.cwd || null
|
|
186
|
+
const ws = cwd ? wsByPath[cwd] : undefined
|
|
187
|
+
const title = meta.title || null
|
|
188
|
+
const display = title ? (String(title).length > MAX_TITLE ? String(title).slice(0, MAX_TITLE) + '…' : String(title)) : null
|
|
189
|
+
const base = {
|
|
190
|
+
sessionId: key,
|
|
191
|
+
title: display,
|
|
192
|
+
createdAt: meta.createdAt || null,
|
|
193
|
+
workspacePath: cwd,
|
|
194
|
+
workspaceTitle: (ws && ws.title) ? ws.title : null,
|
|
195
|
+
workspaceGone: !!(cwd && !ws),
|
|
196
|
+
hasWorkspace: !!cwd,
|
|
197
|
+
}
|
|
198
|
+
// sizeBytes / updatedAt 只在需要的路由(存储分析 / 自动归档)里带上:
|
|
199
|
+
// 它们本就来自 usage,附带输出对列表渲染无益。
|
|
200
|
+
if (exposeUsage && usage) {
|
|
201
|
+
if (usage.sizeById && usage.sizeById.has(key)) base.sizeBytes = usage.sizeById.get(key)
|
|
202
|
+
if (usage.mtimeById && usage.mtimeById.has(key)) base.updatedAt = usage.mtimeById.get(key)
|
|
203
|
+
}
|
|
204
|
+
return base
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Resolve one session's display metadata.
|
|
208
|
+
//
|
|
209
|
+
// 成本模型(issue #1):下面的解码路径会把整本 .jsonl.zstd 逐帧解压、逐行
|
|
210
|
+
// JSON.parse,只为折叠出标题——大库上一次全表要几秒阻塞式 CPU。日志的
|
|
211
|
+
// (mtime, size) 没变就意味着内容没变,折叠结果也不可能变,所以命中缓存时
|
|
212
|
+
// 直接复用上次的元数据,跳过整本解码。
|
|
213
|
+
//
|
|
214
|
+
// opts.preloaded:批量投影(sq.readTitleSnapshots)已经拿到的快照;传了就不再
|
|
215
|
+
// 对同一条日志做第二次单例投影——issue 里「一条日志在单次列表里被解码两次」
|
|
216
|
+
// 正是这么来的。
|
|
217
|
+
async function resolveOne(id, usage, opts = {}) {
|
|
218
|
+
const key = String(id)
|
|
219
|
+
const statInfo = usage ? { mtimeMs: usage.mtimeById.get(key), size: usage.sizeById.get(key) } : null
|
|
220
|
+
const cached = metaCache.get(key, statInfo)
|
|
221
|
+
if (cached) return buildItem(key, cached, usage, opts.exposeUsage)
|
|
222
|
+
|
|
223
|
+
let meta = { title: null, cwd: null, createdAt: null }
|
|
224
|
+
if (opts.preloaded !== undefined) {
|
|
225
|
+
meta = metaFromSnapshot(unwrapSnapshot(opts.preloaded))
|
|
226
|
+
} else if (typeof sq.readTitleSnapshot === 'function') {
|
|
227
|
+
try { meta = metaFromSnapshot(await sq.readTitleSnapshot(id)) } catch (e) { /* fall back to raw log */ }
|
|
228
|
+
}
|
|
229
|
+
// 兜底:投影没给出标题或 cwd 时,才回退到整本解码(这条路径本身就贵,
|
|
230
|
+
// 且结果同样会进缓存,下一次列表就不会再走一遍)。
|
|
231
|
+
if (!meta.title || !meta.cwd) {
|
|
166
232
|
try {
|
|
167
233
|
const r = await sp.readFrom(id, 0)
|
|
168
234
|
if (r.meta) {
|
|
169
|
-
if (!cwd) cwd = r.meta.cwd || null
|
|
170
|
-
if (!createdAt) createdAt = r.meta.createdAt || null
|
|
235
|
+
if (!meta.cwd) meta.cwd = r.meta.cwd || null
|
|
236
|
+
if (!meta.createdAt) meta.createdAt = r.meta.createdAt || null
|
|
171
237
|
}
|
|
172
|
-
if (!title && Array.isArray(r.events)) title = foldTitle(r.events)
|
|
238
|
+
if (!meta.title && Array.isArray(r.events)) meta.title = foldTitle(r.events)
|
|
173
239
|
} catch (e2) { /* keep what we have */ }
|
|
174
240
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
241
|
+
metaCache.set(key, statInfo, meta)
|
|
242
|
+
return buildItem(key, meta, usage, opts.exposeUsage)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Disk usage + last-write time for every session, in one pass.
|
|
246
|
+
//
|
|
247
|
+
// sp.locate(header) resolves the log file behind a session header; a single
|
|
248
|
+
// stat() then yields both its size and its mtime. mtime doubles as the
|
|
249
|
+
// session's last-activity time — appending an event rewrites the log, so the
|
|
250
|
+
// file's last write tracks the conversation's last turn. It errs safe: a log
|
|
251
|
+
// we relocated (move) gets a fresh mtime and therefore looks *more* active
|
|
252
|
+
// than it is, which can only delay an auto-archive, never cause a wrong one.
|
|
253
|
+
// headers 可由调用方传入复用(列表构建里已经 sp.list() 过一次,避免重复列目录)。
|
|
254
|
+
async function collectUsage(preloadedHeaders) {
|
|
255
|
+
const sizeById = new Map()
|
|
256
|
+
const mtimeById = new Map()
|
|
257
|
+
let headers = null
|
|
258
|
+
if (Array.isArray(preloadedHeaders)) headers = preloadedHeaders
|
|
259
|
+
else { try { headers = await sp.list() } catch (e) { headers = [] } }
|
|
260
|
+
if (!Array.isArray(headers)) headers = []
|
|
261
|
+
const CHUNK = 8
|
|
262
|
+
for (let i = 0; i < headers.length; i += CHUNK) {
|
|
263
|
+
await Promise.all(headers.slice(i, i + CHUNK).map(async (header) => {
|
|
264
|
+
const id = header && header.id != null ? String(header.id) : null
|
|
265
|
+
if (!id) return
|
|
266
|
+
try {
|
|
267
|
+
const loc = sp.locate(header)
|
|
268
|
+
if (!loc || typeof loc.path !== 'string' || !loc.path) return
|
|
269
|
+
const st = await stat(loc.path)
|
|
270
|
+
if (!st) return
|
|
271
|
+
if (typeof st.size === 'number') sizeById.set(id, st.size)
|
|
272
|
+
if (typeof st.mtimeMs === 'number' && st.mtimeMs > 0) mtimeById.set(id, Math.floor(st.mtimeMs))
|
|
273
|
+
} catch (e) { /* best-effort: an unreadable log just stays unknown */ }
|
|
274
|
+
}))
|
|
186
275
|
}
|
|
276
|
+
return { sizeById, mtimeById }
|
|
187
277
|
}
|
|
188
278
|
|
|
189
279
|
// Restore (unarchive) one session; throws on failure.
|
|
@@ -234,6 +324,9 @@ export function apply(ctx) {
|
|
|
234
324
|
// when the session is really gone (purge, or externally removed; the latter
|
|
235
325
|
// is caught by gcStars during list builds).
|
|
236
326
|
const stars = createStarIndex()
|
|
327
|
+
// Auto-archive settings live in their own schema-v4 store, off by default.
|
|
328
|
+
const autoArchive = createAutoArchiveStore()
|
|
329
|
+
|
|
237
330
|
async function gcStars(validIds) {
|
|
238
331
|
try {
|
|
239
332
|
const store = await stars.read()
|
|
@@ -659,18 +752,43 @@ export function apply(ctx) {
|
|
|
659
752
|
})
|
|
660
753
|
}
|
|
661
754
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
755
|
+
// 批量投影:一次调用把多条会话的标题/header 拿出来,避免逐条触发整本解码。
|
|
756
|
+
// 老 runtime 没有 readTitleSnapshots 时返回空 Map,调用方自然回退到逐条投影
|
|
757
|
+
// (功能不受影响,只是少了这层优化——插件不能假设对方的 runtime 版本)。
|
|
758
|
+
async function projectTitles(ids) {
|
|
759
|
+
const out = new Map()
|
|
760
|
+
if (!ids || !ids.length) return out
|
|
761
|
+
if (typeof sq.readTitleSnapshots !== 'function') return out
|
|
762
|
+
try {
|
|
763
|
+
const results = await sq.readTitleSnapshots(ids)
|
|
764
|
+
if (!Array.isArray(results)) return out
|
|
765
|
+
results.forEach((result, index) => {
|
|
766
|
+
const id = String(ids[index])
|
|
767
|
+
out.set(id, unwrapSnapshot(result))
|
|
768
|
+
})
|
|
769
|
+
} catch (e) { /* 批量失败:逐条回退 */ }
|
|
770
|
+
return out
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// opts.usage: expose sizeBytes + updatedAt on each item (storage analysis and
|
|
774
|
+
// the auto-archive sweep need them; the panel list does not).
|
|
775
|
+
//
|
|
776
|
+
// 性能要点(issue #1):
|
|
777
|
+
// 1. sp.list() 只调一次(原先列了两遍目录)
|
|
778
|
+
// 2. 无条件做一遍 stat——一次 stat 是微秒级,而它算出的 (mtime, size) 指纹
|
|
779
|
+
// 是元数据缓存能否跳过整本解码的前提,收益远大于成本
|
|
780
|
+
// 3. 未命中缓存的会话走**一次**批量投影(sq.readTitleSnapshots),而不是逐条
|
|
781
|
+
async function allSessionItems(opts = {}) {
|
|
782
|
+
let headers = []
|
|
665
783
|
let headersOk = false
|
|
666
784
|
try {
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
headersOk =
|
|
670
|
-
} catch (e) {
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
if (live) { try { live.list().forEach((s) => { if (!ids.includes(
|
|
785
|
+
headers = await sp.list()
|
|
786
|
+
headersOk = Array.isArray(headers)
|
|
787
|
+
if (!headersOk) headers = []
|
|
788
|
+
} catch (e) { headers = [] }
|
|
789
|
+
let live = ctx.get('sessions')
|
|
790
|
+
const ids = headers.map((h) => String(h.id))
|
|
791
|
+
if (live) { try { live.list().forEach((s) => { const sid = String(s.id); if (!ids.includes(sid)) ids.push(sid) }) } catch (e) { /* ignore */ } }
|
|
674
792
|
// Exclude sessions already moved to the recycle bin (软删除): they live in
|
|
675
793
|
// 回收站, not in 会话管理, so the panel won't re-list them after a delete.
|
|
676
794
|
let hiddenIds = new Set()
|
|
@@ -683,9 +801,19 @@ export function apply(ctx) {
|
|
|
683
801
|
try { for (const ent of w.list()) wsByPath[ent.path] = ent } catch (e) { wsByPath = {} }
|
|
684
802
|
const currentArchived = new Set((await archivedState().catch(() => ({ archivedSessionIds: [] }))).archivedSessionIds || [])
|
|
685
803
|
const items = []
|
|
804
|
+
const usage = await collectUsage(headers)
|
|
805
|
+
// 先按指纹把「缓存命中」与「需要解码」分开,只对后者做批量投影。
|
|
806
|
+
const statsById = new Map(visibleIds.map((id) => [id, { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(id) }]))
|
|
807
|
+
const { cached, missing } = metaCache.partition(visibleIds, statsById)
|
|
808
|
+
const snapshotById = await projectTitles(missing)
|
|
686
809
|
const CHUNK = 6
|
|
687
810
|
for (let i = 0; i < visibleIds.length; i += CHUNK) {
|
|
688
|
-
|
|
811
|
+
// Arrow wrapper on purpose: Array#map passes (value, index, array), and
|
|
812
|
+
// resolveOne's second and third arguments are fixed here.
|
|
813
|
+
const res2 = await Promise.all(visibleIds.slice(i, i + CHUNK).map((id) => resolveOne(id, usage, {
|
|
814
|
+
exposeUsage: !!(opts && opts.usage),
|
|
815
|
+
preloaded: snapshotById.has(id) ? snapshotById.get(id) : undefined,
|
|
816
|
+
})))
|
|
689
817
|
for (const it of res2) items.push({ ...it, archived: currentArchived.has(it.sessionId) })
|
|
690
818
|
}
|
|
691
819
|
// Annotate stars; GC only when we have a trustworthy id baseline, so a
|
|
@@ -697,28 +825,83 @@ export function apply(ctx) {
|
|
|
697
825
|
return items
|
|
698
826
|
}
|
|
699
827
|
|
|
828
|
+
// ---- Storage usage + auto-archive ---------------------------------------
|
|
829
|
+
|
|
830
|
+
// Read-only rollup: per-workspace totals plus the largest sessions. The
|
|
831
|
+
// aggregation itself is a pure function (src/storage-stats.js).
|
|
832
|
+
async function buildStorage(opts = {}) {
|
|
833
|
+
const items = await allSessionItems({ usage: true })
|
|
834
|
+
const raw = Number(opts && opts.topN)
|
|
835
|
+
const topN = Number.isInteger(raw) && raw > 0 ? Math.min(raw, MAX_STORAGE_TOP) : 10
|
|
836
|
+
return aggregateStorage(items, { topN })
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Archive conversations that have been idle past the configured window.
|
|
840
|
+
//
|
|
841
|
+
// Deliberately lazy — there is no timer. The sweep runs when the panel reads
|
|
842
|
+
// its settings (and on demand), at most once a day: a background interval
|
|
843
|
+
// would keep the host process alive and would archive conversations while
|
|
844
|
+
// nobody is looking at the panel.
|
|
845
|
+
async function autoArchiveSweep(opts = {}) {
|
|
846
|
+
const store = await autoArchive.read()
|
|
847
|
+
const days = store.settings.inactiveDays
|
|
848
|
+
if (!days) return { ok: true, skipped: 'disabled', archived: 0 }
|
|
849
|
+
const now = Date.now()
|
|
850
|
+
if (!(opts && opts.force) && autoArchive.isFresh(store, now)) {
|
|
851
|
+
return { ok: true, skipped: 'throttled', archived: 0, lastRunAt: store.lastRunAt, lastArchivedCount: store.lastArchivedCount }
|
|
852
|
+
}
|
|
853
|
+
const items = await allSessionItems({ usage: true })
|
|
854
|
+
const candidates = pickInactiveCandidates(items, {
|
|
855
|
+
inactiveDays: days,
|
|
856
|
+
skipStarred: store.settings.skipStarred,
|
|
857
|
+
activeSessionId: getActiveSessionId(ctx),
|
|
858
|
+
now,
|
|
859
|
+
})
|
|
860
|
+
let archived = 0
|
|
861
|
+
const failed = []
|
|
862
|
+
for (const sid of candidates) {
|
|
863
|
+
try {
|
|
864
|
+
const result = await archiveOne(sid)
|
|
865
|
+
if (result && result.archived) archived++
|
|
866
|
+
} catch (e) {
|
|
867
|
+
failed.push({ sessionId: sid, error: String((e && e.message) || e) })
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
await autoArchive.recordRun(archived, now)
|
|
871
|
+
return { ok: true, archived, candidates: candidates.length, failed, lastRunAt: now }
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// 侧栏权威数据:标题 + 回收站 id 集合。
|
|
875
|
+
//
|
|
876
|
+
// 标题原先「首次调用算一次就永久缓存」,日志之后再变也不会更新——标题会陈旧。
|
|
877
|
+
// 现在复用 metaCache:每次调用只 stat 一遍,日志没变直接取缓存,变了才重解码,
|
|
878
|
+
// 既不会陈旧也不会回到「每次全量解码」。
|
|
700
879
|
async function sidebarAuthority() {
|
|
701
880
|
const ids = []
|
|
702
|
-
|
|
881
|
+
let headers = []
|
|
882
|
+
try { headers = await sp.list() } catch (e) { headers = [] }
|
|
883
|
+
if (!Array.isArray(headers)) headers = []
|
|
884
|
+
for (const header of headers) ids.push(String(header.id))
|
|
703
885
|
const sessions = ctx.get('sessions')
|
|
704
|
-
try { if (sessions) sessions.list().forEach((session) => { if (!ids.includes(
|
|
886
|
+
try { if (sessions) sessions.list().forEach((session) => { const sid = String(session.id); if (!ids.includes(sid)) ids.push(sid) }) } catch (e) {}
|
|
705
887
|
const store = await readTrashStore()
|
|
706
|
-
if (
|
|
707
|
-
const
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
888
|
+
if (ids.length) {
|
|
889
|
+
const usage = await collectUsage(headers)
|
|
890
|
+
const statsById = new Map(ids.map((id) => [id, { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(id) }]))
|
|
891
|
+
const { cached, missing } = metaCache.partition(ids, statsById)
|
|
892
|
+
const snapshotById = await projectTitles(missing)
|
|
893
|
+
for (const id of ids) {
|
|
894
|
+
let meta = cached.get(id) || null
|
|
895
|
+
if (!meta) {
|
|
896
|
+
const snapshot = snapshotById.has(id)
|
|
897
|
+
? snapshotById.get(id)
|
|
898
|
+
: (typeof sq.readTitleSnapshot === 'function' ? await sq.readTitleSnapshot(id).catch(() => null) : null)
|
|
899
|
+
const next = metaFromSnapshot(snapshot)
|
|
900
|
+
metaCache.set(id, statsById.get(id), next)
|
|
901
|
+
meta = next
|
|
711
902
|
}
|
|
712
|
-
|
|
713
|
-
authorityTitlesLoaded = true
|
|
714
|
-
} else if (!authorityTitlesLoaded) {
|
|
715
|
-
for (const sid of ids) {
|
|
716
|
-
try {
|
|
717
|
-
const snapshot = await sq.readTitleSnapshot(sid)
|
|
718
|
-
if (snapshot && snapshot.title && typeof snapshot.title.title === 'string') authorityTitleCache.set(sid, snapshot.title.title)
|
|
719
|
-
} catch (e) {}
|
|
903
|
+
if (meta && meta.title) authorityTitleCache.set(id, String(meta.title))
|
|
720
904
|
}
|
|
721
|
-
authorityTitlesLoaded = true
|
|
722
905
|
}
|
|
723
906
|
return {
|
|
724
907
|
titles: Object.fromEntries(authorityTitleCache),
|
|
@@ -881,7 +1064,7 @@ export function apply(ctx) {
|
|
|
881
1064
|
const items = []
|
|
882
1065
|
const CHUNK = 6
|
|
883
1066
|
for (let i = 0; i < idStrs.length; i += CHUNK) {
|
|
884
|
-
const res2 = await Promise.all(idStrs.slice(i, i + CHUNK).map(resolveOne))
|
|
1067
|
+
const res2 = await Promise.all(idStrs.slice(i, i + CHUNK).map((id) => resolveOne(id)))
|
|
885
1068
|
items.push.apply(items, res2)
|
|
886
1069
|
}
|
|
887
1070
|
json(res, { items })
|
|
@@ -934,7 +1117,11 @@ export function apply(ctx) {
|
|
|
934
1117
|
const body = await readJsonBody(req)
|
|
935
1118
|
const sid = body && typeof body.sessionId === 'string' ? body.sessionId : null
|
|
936
1119
|
if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
|
|
937
|
-
|
|
1120
|
+
const out = await deleteOne(sid)
|
|
1121
|
+
// 日志被搬进回收站(文件已不在原处):丢弃缓存条目,避免下次 stat 失败
|
|
1122
|
+
// 时残留旧元数据。
|
|
1123
|
+
metaCache.invalidate(sid)
|
|
1124
|
+
json(res, out)
|
|
938
1125
|
} catch (e) {
|
|
939
1126
|
json(res, { ok: false, error: String((e && e.message) || e) }, errorStatus(e))
|
|
940
1127
|
}
|
|
@@ -951,7 +1138,7 @@ export function apply(ctx) {
|
|
|
951
1138
|
if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
|
|
952
1139
|
const results = []
|
|
953
1140
|
for (const sid of ids) {
|
|
954
|
-
try { results.push({ sessionId: sid, ok: true, ...(await deleteOne(sid)) }) }
|
|
1141
|
+
try { results.push({ sessionId: sid, ok: true, ...(await deleteOne(sid)) }); metaCache.invalidate(sid) }
|
|
955
1142
|
catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
|
|
956
1143
|
}
|
|
957
1144
|
json(res, { ok: true, deleted: results.filter((r) => r.ok).length, results })
|
|
@@ -1020,7 +1207,9 @@ export function apply(ctx) {
|
|
|
1020
1207
|
const body = await readJsonBody(req)
|
|
1021
1208
|
const sid = body && typeof body.sessionId === 'string' ? body.sessionId : null
|
|
1022
1209
|
if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
|
|
1023
|
-
|
|
1210
|
+
const out = await restoreFromTrash(sid)
|
|
1211
|
+
metaCache.invalidate(sid)
|
|
1212
|
+
json(res, out)
|
|
1024
1213
|
} catch (e) {
|
|
1025
1214
|
json(res, { ok: false, error: String((e && e.message) || e) }, 500)
|
|
1026
1215
|
}
|
|
@@ -1035,7 +1224,9 @@ export function apply(ctx) {
|
|
|
1035
1224
|
const body = await readJsonBody(req)
|
|
1036
1225
|
const sid = body && typeof body.sessionId === 'string' ? body.sessionId : null
|
|
1037
1226
|
if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
|
|
1038
|
-
|
|
1227
|
+
const out = await purgeFromTrash(sid)
|
|
1228
|
+
metaCache.invalidate(sid)
|
|
1229
|
+
json(res, out)
|
|
1039
1230
|
} catch (e) {
|
|
1040
1231
|
json(res, { ok: false, error: String((e && e.message) || e) }, 500)
|
|
1041
1232
|
}
|
|
@@ -1052,7 +1243,7 @@ export function apply(ctx) {
|
|
|
1052
1243
|
if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
|
|
1053
1244
|
const results = []
|
|
1054
1245
|
for (const sid of ids) {
|
|
1055
|
-
try { results.push({ sessionId: sid, ok: true, ...(await purgeFromTrash(sid)) }) }
|
|
1246
|
+
try { results.push({ sessionId: sid, ok: true, ...(await purgeFromTrash(sid)) }); metaCache.invalidate(sid) }
|
|
1056
1247
|
catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
|
|
1057
1248
|
}
|
|
1058
1249
|
json(res, { ok: true, purged: results.filter((r) => r.ok).length, results })
|
|
@@ -1163,6 +1354,9 @@ export function apply(ctx) {
|
|
|
1163
1354
|
if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
|
|
1164
1355
|
if (!target) return json(res, { ok: false, error: 'missing targetPath' }, 400)
|
|
1165
1356
|
const moved = await moveOne(sid, target)
|
|
1357
|
+
// 移动会改写日志 frame0 的 cwd:元数据(cwd)已变,主动丢弃缓存条目,
|
|
1358
|
+
// 不等 mtime 指纹自然失效(Windows 上 mtime 精度较粗,指纹可能不变)。
|
|
1359
|
+
metaCache.invalidate(sid)
|
|
1166
1360
|
// Reindex the host's in-memory sessionPath index so the sidebar
|
|
1167
1361
|
// reflects the new grouping immediately (no DSH restart needed).
|
|
1168
1362
|
// Do this before replying: drag/drop and menu clients treat a 2xx
|
|
@@ -1230,6 +1424,65 @@ export function apply(ctx) {
|
|
|
1230
1424
|
},
|
|
1231
1425
|
}))
|
|
1232
1426
|
|
|
1427
|
+
// Storage usage rollup (read-only): per-workspace totals + largest sessions.
|
|
1428
|
+
disposers.push(ctx.webServer.register({
|
|
1429
|
+
kind: 'exact',
|
|
1430
|
+
path: '/archived-sessions/storage',
|
|
1431
|
+
handler: async (req, res) => {
|
|
1432
|
+
try {
|
|
1433
|
+
const body = await readJsonBody(req)
|
|
1434
|
+
json(res, await buildStorage({ topN: body && body.topN }))
|
|
1435
|
+
} catch (e) {
|
|
1436
|
+
json(res, { error: String((e && e.message) || e) }, 500)
|
|
1437
|
+
}
|
|
1438
|
+
},
|
|
1439
|
+
}))
|
|
1440
|
+
|
|
1441
|
+
// Auto-archive settings. A plain read (no patch keys) doubles as the lazy
|
|
1442
|
+
// sweep trigger — that is how the once-a-day cleanup gets a chance to run
|
|
1443
|
+
// without a background timer.
|
|
1444
|
+
disposers.push(ctx.webServer.register({
|
|
1445
|
+
kind: 'exact',
|
|
1446
|
+
path: '/archived-sessions/auto-archive/settings',
|
|
1447
|
+
handler: async (req, res) => {
|
|
1448
|
+
try {
|
|
1449
|
+
const body = await readJsonBody(req)
|
|
1450
|
+
const patch = {}
|
|
1451
|
+
if (body && Object.prototype.hasOwnProperty.call(body, 'inactiveDays')) patch.inactiveDays = body.inactiveDays
|
|
1452
|
+
if (body && Object.prototype.hasOwnProperty.call(body, 'skipStarred')) patch.skipStarred = body.skipStarred
|
|
1453
|
+
const settings = Object.keys(patch).length
|
|
1454
|
+
? await autoArchive.update(patch)
|
|
1455
|
+
: (await autoArchive.read()).settings
|
|
1456
|
+
const sweep = await autoArchiveSweep()
|
|
1457
|
+
const store = await autoArchive.read()
|
|
1458
|
+
json(res, {
|
|
1459
|
+
ok: true,
|
|
1460
|
+
settings,
|
|
1461
|
+
lastRunAt: store.lastRunAt,
|
|
1462
|
+
lastArchivedCount: store.lastArchivedCount,
|
|
1463
|
+
sweep,
|
|
1464
|
+
})
|
|
1465
|
+
} catch (e) {
|
|
1466
|
+
json(res, { ok: false, error: String((e && e.message) || e) }, errorStatus(e))
|
|
1467
|
+
}
|
|
1468
|
+
},
|
|
1469
|
+
}))
|
|
1470
|
+
|
|
1471
|
+
// Run the auto-archive sweep right now, ignoring the once-a-day throttle.
|
|
1472
|
+
disposers.push(ctx.webServer.register({
|
|
1473
|
+
kind: 'exact',
|
|
1474
|
+
path: '/archived-sessions/auto-archive/run',
|
|
1475
|
+
handler: async (req, res) => {
|
|
1476
|
+
try {
|
|
1477
|
+
const sweep = await autoArchiveSweep({ force: true })
|
|
1478
|
+
const store = await autoArchive.read()
|
|
1479
|
+
json(res, { ok: true, ...sweep, settings: store.settings, lastRunAt: store.lastRunAt, lastArchivedCount: store.lastArchivedCount })
|
|
1480
|
+
} catch (e) {
|
|
1481
|
+
json(res, { ok: false, error: String((e && e.message) || e) }, 500)
|
|
1482
|
+
}
|
|
1483
|
+
},
|
|
1484
|
+
}))
|
|
1485
|
+
|
|
1233
1486
|
return () => { for (const d of disposers) d() }
|
|
1234
1487
|
}, 'dsh-sessions-manager: routes')
|
|
1235
1488
|
}
|
|
@@ -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
|
+
}
|