dsh-sessions-manager 3.4.1 → 3.5.0
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 +6 -0
- package/README.md +6 -0
- package/lib/client.js +60 -17
- package/lib/client.js.map +2 -2
- package/lib/index.js +375 -82
- package/lib/index.js.map +4 -4
- package/package.json +1 -1
- package/src/client/index.jsx +42 -7
- package/src/compat/capabilities.js +34 -0
- package/src/compat/persistence.js +72 -0
- package/src/index.js +167 -63
- package/src/title-persist-index.js +137 -0
package/src/index.js
CHANGED
|
@@ -16,7 +16,10 @@ 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 } from './session-meta-cache.js'
|
|
19
|
+
import { createSessionMetaCache, fingerprintOf } from './session-meta-cache.js'
|
|
20
|
+
import { createTitleIndexStore } from './title-persist-index.js'
|
|
21
|
+
import { createPersistenceAdapter } from './compat/persistence.js'
|
|
22
|
+
import { detectCapabilities, requireCapability } from './compat/capabilities.js'
|
|
20
23
|
|
|
21
24
|
|
|
22
25
|
export const name = 'dsh-sessions-manager'
|
|
@@ -121,6 +124,8 @@ function foldTitle(events) {
|
|
|
121
124
|
export function apply(ctx) {
|
|
122
125
|
const w = ctx.workspaceRegistry
|
|
123
126
|
const sp = ctx.sessionPersistence
|
|
127
|
+
const persistence = createPersistenceAdapter(sp)
|
|
128
|
+
const capabilities = detectCapabilities({ persistence: sp, workspaceRegistry: w })
|
|
124
129
|
const sq = ctx.sessionQuery
|
|
125
130
|
const dom = () => ctx.storageDomain.get('workspace')
|
|
126
131
|
const authorityTitleCache = new Map()
|
|
@@ -128,6 +133,44 @@ export function apply(ctx) {
|
|
|
128
133
|
// 见 src/session-meta-cache.js 的说明:列表构建原本每条会话都要整本解压日志,
|
|
129
134
|
// 这个缓存让「日志没变」的会话直接跳过解码。
|
|
130
135
|
const metaCache = createSessionMetaCache()
|
|
136
|
+
// 持久标题索引(冷启动加速):metaCache 是进程内的,重启即空——第一次列表
|
|
137
|
+
// 仍要全库解码。索引按同样的 (mtime, size) 指纹存解码结果,指纹没变的会话
|
|
138
|
+
// 重启后也直接复用。见 src/title-persist-index.js。
|
|
139
|
+
const titleIndex = createTitleIndexStore({ dir: TRASH_DIR, file: join(TRASH_DIR, 'title-index.json') })
|
|
140
|
+
|
|
141
|
+
// P4:对「内存缓存未命中」的会话查持久索引,指纹一致才可信。
|
|
142
|
+
// 返回 Map<id, meta>;调用方应把命中条目回填 metaCache 并从 missing 里剔除。
|
|
143
|
+
async function hydrateFromPersist(ids, statsById) {
|
|
144
|
+
const hits = new Map()
|
|
145
|
+
if (!ids || !ids.length) return hits
|
|
146
|
+
let store
|
|
147
|
+
try { store = await titleIndex.entries() } catch (e) { return hits }
|
|
148
|
+
for (const id of ids) {
|
|
149
|
+
const stat = statsById.get(id)
|
|
150
|
+
const entry = store && store[id]
|
|
151
|
+
if (!stat || !entry) continue
|
|
152
|
+
const fp = fingerprintOf(stat)
|
|
153
|
+
if (fp && entry.fingerprint === fp) {
|
|
154
|
+
hits.set(id, { title: entry.title, cwd: entry.cwd, createdAt: entry.createdAt })
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return hits
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 把本批真正解码出的元数据异步回写持久索引(fire-and-forget:索引只是
|
|
161
|
+
// 加速器,写失败不影响响应,队列内部已串行化 + 原子替换)。
|
|
162
|
+
function persistDecoded(decoded, statsById) {
|
|
163
|
+
if (!decoded || !decoded.size) return
|
|
164
|
+
const batch = {}
|
|
165
|
+
const now = Date.now()
|
|
166
|
+
for (const [id, meta] of decoded) {
|
|
167
|
+
const fp = fingerprintOf(statsById.get(id))
|
|
168
|
+
if (!fp) continue
|
|
169
|
+
batch[id] = { title: meta.title, cwd: meta.cwd, createdAt: meta.createdAt, fingerprint: fp, updatedAt: now }
|
|
170
|
+
}
|
|
171
|
+
if (!Object.keys(batch).length) return
|
|
172
|
+
titleIndex.merge(batch).catch(() => {})
|
|
173
|
+
}
|
|
131
174
|
|
|
132
175
|
// 从投影快照里抽出元数据;快照缺失/异常时返回零值 meta(调用方决定兜底)。
|
|
133
176
|
function metaFromSnapshot(o) {
|
|
@@ -230,7 +273,7 @@ export function apply(ctx) {
|
|
|
230
273
|
// 且结果同样会进缓存,下一次列表就不会再走一遍)。
|
|
231
274
|
if (!meta.title || !meta.cwd) {
|
|
232
275
|
try {
|
|
233
|
-
const r = await
|
|
276
|
+
const r = await persistence.readSession(id, 0)
|
|
234
277
|
if (r.meta) {
|
|
235
278
|
if (!meta.cwd) meta.cwd = r.meta.cwd || null
|
|
236
279
|
if (!meta.createdAt) meta.createdAt = r.meta.createdAt || null
|
|
@@ -239,6 +282,8 @@ export function apply(ctx) {
|
|
|
239
282
|
} catch (e2) { /* keep what we have */ }
|
|
240
283
|
}
|
|
241
284
|
metaCache.set(key, statInfo, meta)
|
|
285
|
+
// 本条是「真解码」出来的:交给调用方回写持久标题索引(P4 冷启动加速)。
|
|
286
|
+
if (opts.collectDecoded && statInfo) opts.collectDecoded(key, meta)
|
|
242
287
|
return buildItem(key, meta, usage, opts.exposeUsage)
|
|
243
288
|
}
|
|
244
289
|
|
|
@@ -254,17 +299,19 @@ export function apply(ctx) {
|
|
|
254
299
|
async function collectUsage(preloadedHeaders) {
|
|
255
300
|
const sizeById = new Map()
|
|
256
301
|
const mtimeById = new Map()
|
|
257
|
-
let
|
|
258
|
-
if (Array.isArray(preloadedHeaders))
|
|
259
|
-
else { try {
|
|
260
|
-
if (!Array.isArray(
|
|
302
|
+
let entries = null
|
|
303
|
+
if (Array.isArray(preloadedHeaders)) entries = preloadedHeaders
|
|
304
|
+
else { try { entries = await persistence.listEntries() } catch (e) { entries = [] } }
|
|
305
|
+
if (!Array.isArray(entries)) entries = []
|
|
261
306
|
const CHUNK = 8
|
|
262
|
-
for (let i = 0; i <
|
|
263
|
-
await Promise.all(
|
|
264
|
-
const
|
|
307
|
+
for (let i = 0; i < entries.length; i += CHUNK) {
|
|
308
|
+
await Promise.all(entries.slice(i, i + CHUNK).map(async (entry) => {
|
|
309
|
+
const header = entry && entry.header ? entry.header : entry
|
|
310
|
+
const id = entry && entry.id != null ? String(entry.id) : (header && header.id != null ? String(header.id) : null)
|
|
265
311
|
if (!id) return
|
|
312
|
+
if (entry && Number.isFinite(entry.sizeBytes)) sizeById.set(id, Number(entry.sizeBytes))
|
|
266
313
|
try {
|
|
267
|
-
const loc =
|
|
314
|
+
const loc = persistence.locate(header)
|
|
268
315
|
if (!loc || typeof loc.path !== 'string' || !loc.path) return
|
|
269
316
|
const st = await stat(loc.path)
|
|
270
317
|
if (!st) return
|
|
@@ -352,17 +399,20 @@ export function apply(ctx) {
|
|
|
352
399
|
let cwd = null
|
|
353
400
|
let title = null
|
|
354
401
|
let removedPath = null
|
|
402
|
+
let persistenceEntry = null
|
|
355
403
|
try {
|
|
356
|
-
const
|
|
357
|
-
|
|
404
|
+
const entries = await persistence.listEntries()
|
|
405
|
+
const found = entries.find((entry) => entry.id === sid) || null
|
|
406
|
+
persistenceEntry = found
|
|
407
|
+
header = found ? found.header : null
|
|
358
408
|
if (header) {
|
|
359
|
-
const loc =
|
|
409
|
+
const loc = persistence.locate(header)
|
|
360
410
|
if (loc && typeof loc.path === 'string') removedPath = loc.path
|
|
361
411
|
cwd = header.cwd || null
|
|
362
412
|
title = header.title || (header.meta && header.meta.title) || null
|
|
363
413
|
}
|
|
364
414
|
if (!title) {
|
|
365
|
-
const r = await
|
|
415
|
+
const r = await persistence.readSession(sid, 0)
|
|
366
416
|
if (r && r.meta) { if (!cwd) cwd = r.meta.cwd; title = foldTitle(r.events) }
|
|
367
417
|
}
|
|
368
418
|
} catch (e) { /* best-effort */ }
|
|
@@ -377,7 +427,7 @@ export function apply(ctx) {
|
|
|
377
427
|
const entry = {
|
|
378
428
|
sessionId: sid, title: title || cwd || sid, cwd: cwd || null,
|
|
379
429
|
header: header || null, originalPath: removedPath || null,
|
|
380
|
-
sizeBytes:
|
|
430
|
+
sizeBytes: persistenceEntry && Number.isFinite(persistenceEntry.sizeBytes) ? persistenceEntry.sizeBytes : null,
|
|
381
431
|
wasArchived: archived, deletedAt: Date.now(),
|
|
382
432
|
}
|
|
383
433
|
const at = store.items.findIndex((t) => String(t.sessionId) === sid)
|
|
@@ -410,18 +460,24 @@ export function apply(ctx) {
|
|
|
410
460
|
// the original workspace dir) and detach it from any workspace so DSH drops it.
|
|
411
461
|
async function purgeFromTrash(sid) {
|
|
412
462
|
requireSessionId(sid)
|
|
463
|
+
requireCapability(capabilities, 'purge')
|
|
413
464
|
let purged = false
|
|
414
465
|
await mutateTrash(async (store) => {
|
|
415
466
|
const entry = store.items.find((t) => String(t.sessionId) === sid)
|
|
416
467
|
if (!entry) { const error = new Error('回收站中找不到该会话'); error.status = 404; throw error }
|
|
417
468
|
let target = null
|
|
418
469
|
try {
|
|
419
|
-
const
|
|
420
|
-
const current =
|
|
421
|
-
const located = current &&
|
|
470
|
+
const entries = await persistence.listEntries()
|
|
471
|
+
const current = entries.find((entry) => entry.id === sid)
|
|
472
|
+
const located = current && persistence.locate(current.header)
|
|
422
473
|
if (located && typeof located.path === 'string') target = located.path
|
|
423
474
|
} catch (e) {}
|
|
424
475
|
if (!target && typeof entry.originalPath === 'string') target = entry.originalPath
|
|
476
|
+
if (!target) {
|
|
477
|
+
const error = new Error('无法确认该会话的物理日志位置,已停止永久删除')
|
|
478
|
+
error.status = 409
|
|
479
|
+
throw error
|
|
480
|
+
}
|
|
425
481
|
// JSONL persistence stores logs as
|
|
426
482
|
// .../<sessionId>/session.jsonl.zstd
|
|
427
483
|
// Older backends may instead include the id in the filename itself.
|
|
@@ -491,6 +547,7 @@ export function apply(ctx) {
|
|
|
491
547
|
}
|
|
492
548
|
|
|
493
549
|
async function cleanupExpiredTrash() {
|
|
550
|
+
if (!capabilities.actions.purge.available) return 0
|
|
494
551
|
const store = await readTrashStore()
|
|
495
552
|
const days = store.settings.retentionDays
|
|
496
553
|
if (!days) return 0
|
|
@@ -526,6 +583,7 @@ export function apply(ctx) {
|
|
|
526
583
|
}
|
|
527
584
|
|
|
528
585
|
async function moveOne(sid, targetPath) {
|
|
586
|
+
requireCapability(capabilities, 'move')
|
|
529
587
|
// Only block the *active* conversation. ctx.sessions keeps instantiated
|
|
530
588
|
// sessions alive after you switch away, so the old check (sessions.get(sid))
|
|
531
589
|
// wrongly rejected every opened session — you could never move one you'd
|
|
@@ -536,7 +594,7 @@ export function apply(ctx) {
|
|
|
536
594
|
if (activeId != null && String(activeId) === String(sid)) {
|
|
537
595
|
throw new Error('该会话当前处于打开状态,请先切换到别的会话再移动。')
|
|
538
596
|
}
|
|
539
|
-
const r = await
|
|
597
|
+
const r = await persistence.readSession(sid, 0)
|
|
540
598
|
if (!r || !r.meta) throw new Error('无法读取该会话的日志')
|
|
541
599
|
const meta = r.meta
|
|
542
600
|
const events = r.events
|
|
@@ -620,7 +678,7 @@ export function apply(ctx) {
|
|
|
620
678
|
// disk, and WorkspaceEntity.sessionIds filters by that exact cwd. A bare
|
|
621
679
|
// rename would leave frame0 pointing at the old workspace, so reindex /
|
|
622
680
|
// restart would keep attributing the session to the wrong workspace.
|
|
623
|
-
await relocateLog(meta, newHeader)
|
|
681
|
+
if (!await relocateLog(meta, newHeader)) throw new Error('移动失败:无法确认会话日志已迁移到目标工作区')
|
|
624
682
|
// Redirect the persistence state's cwd so future appends land in newPath.
|
|
625
683
|
try {
|
|
626
684
|
const st = sp.states && sp.states.get && sp.states.get(sid)
|
|
@@ -642,7 +700,7 @@ export function apply(ctx) {
|
|
|
642
700
|
|
|
643
701
|
if (typeof sp.create !== 'function' || typeof sp.append !== 'function') {
|
|
644
702
|
// No create primitive: must relocate the existing log directly.
|
|
645
|
-
await relocateLog(meta, newHeader)
|
|
703
|
+
if (!await relocateLog(meta, newHeader)) throw new Error('移动失败:无法确认会话日志已迁移到目标工作区')
|
|
646
704
|
} else {
|
|
647
705
|
const backupPath = oldPath ? `${oldPath}.move-backup-${Date.now()}` : null
|
|
648
706
|
if (backupPath) { try { await rename(oldPath, backupPath) } catch (e) { if (e && e.code !== 'ENOENT') throw new Error('移动失败:无法备份旧的会话日志') } }
|
|
@@ -650,7 +708,7 @@ export function apply(ctx) {
|
|
|
650
708
|
try {
|
|
651
709
|
await sp.create(newHeader)
|
|
652
710
|
await sp.append(sid, events)
|
|
653
|
-
const check = await
|
|
711
|
+
const check = await persistence.readSession(sid, 0)
|
|
654
712
|
if (!check || !check.meta || check.meta.cwd !== canonical) {
|
|
655
713
|
throw new Error('移动后校验失败:会话工作目录未正确更新')
|
|
656
714
|
}
|
|
@@ -660,7 +718,7 @@ export function apply(ctx) {
|
|
|
660
718
|
// Collision: the session is already materialized in states (archived
|
|
661
719
|
// or previously opened). Fall back to physically relocating the log.
|
|
662
720
|
await restore()
|
|
663
|
-
await relocateLog(meta, newHeader)
|
|
721
|
+
if (!await relocateLog(meta, newHeader)) throw new Error('移动失败:无法确认会话日志已迁移到目标工作区')
|
|
664
722
|
} else {
|
|
665
723
|
await restore()
|
|
666
724
|
throw new Error('移动会话日志失败:' + String((e && e.message) || e))
|
|
@@ -722,10 +780,10 @@ export function apply(ctx) {
|
|
|
722
780
|
async function reindexRegistry() {
|
|
723
781
|
const reg = w
|
|
724
782
|
if (!reg || typeof reg.replaceHeaderIndex !== 'function') return false
|
|
725
|
-
let
|
|
726
|
-
try {
|
|
727
|
-
if (!
|
|
728
|
-
await reg.replaceHeaderIndex(
|
|
783
|
+
let entries = null
|
|
784
|
+
try { entries = await persistence.listEntries() } catch (e) { entries = null }
|
|
785
|
+
if (!entries || !Array.isArray(entries)) return false
|
|
786
|
+
await reg.replaceHeaderIndex(entries.map((entry) => entry.header))
|
|
729
787
|
if (typeof reg.rebuildEntities === 'function') reg.rebuildEntities()
|
|
730
788
|
return true
|
|
731
789
|
}
|
|
@@ -779,15 +837,15 @@ export function apply(ctx) {
|
|
|
779
837
|
// 是元数据缓存能否跳过整本解码的前提,收益远大于成本
|
|
780
838
|
// 3. 未命中缓存的会话走**一次**批量投影(sq.readTitleSnapshots),而不是逐条
|
|
781
839
|
async function allSessionItems(opts = {}) {
|
|
782
|
-
let
|
|
840
|
+
let entries = []
|
|
783
841
|
let headersOk = false
|
|
784
842
|
try {
|
|
785
|
-
|
|
786
|
-
headersOk = Array.isArray(
|
|
787
|
-
if (!headersOk)
|
|
788
|
-
} catch (e) {
|
|
843
|
+
entries = await persistence.listEntries()
|
|
844
|
+
headersOk = Array.isArray(entries)
|
|
845
|
+
if (!headersOk) entries = []
|
|
846
|
+
} catch (e) { entries = [] }
|
|
789
847
|
let live = ctx.get('sessions')
|
|
790
|
-
const ids =
|
|
848
|
+
const ids = entries.map((entry) => entry.id)
|
|
791
849
|
if (live) { try { live.list().forEach((s) => { const sid = String(s.id); if (!ids.includes(sid)) ids.push(sid) }) } catch (e) { /* ignore */ } }
|
|
792
850
|
// Exclude sessions already moved to the recycle bin (软删除): they live in
|
|
793
851
|
// 回收站, not in 会话管理, so the panel won't re-list them after a delete.
|
|
@@ -801,11 +859,17 @@ export function apply(ctx) {
|
|
|
801
859
|
try { for (const ent of w.list()) wsByPath[ent.path] = ent } catch (e) { wsByPath = {} }
|
|
802
860
|
const currentArchived = new Set((await archivedState().catch(() => ({ archivedSessionIds: [] }))).archivedSessionIds || [])
|
|
803
861
|
const items = []
|
|
804
|
-
const usage = await collectUsage(
|
|
862
|
+
const usage = await collectUsage(entries)
|
|
805
863
|
// 先按指纹把「缓存命中」与「需要解码」分开,只对后者做批量投影。
|
|
806
864
|
const statsById = new Map(visibleIds.map((id) => [id, { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(id) }]))
|
|
807
865
|
const { cached, missing } = metaCache.partition(visibleIds, statsById)
|
|
808
|
-
|
|
866
|
+
// P4:missing 里先查持久标题索引(冷启动跳过整本解码),命中的回填内存缓存。
|
|
867
|
+
const persisted = await hydrateFromPersist(missing, statsById)
|
|
868
|
+
for (const [id, meta] of persisted) metaCache.set(id, statsById.get(id), meta)
|
|
869
|
+
const stillMissing = missing.filter((id) => !persisted.has(id))
|
|
870
|
+
const snapshotById = await projectTitles(stillMissing)
|
|
871
|
+
const decoded = new Map()
|
|
872
|
+
const collectDecoded = (id, meta) => { decoded.set(id, meta) }
|
|
809
873
|
const CHUNK = 6
|
|
810
874
|
for (let i = 0; i < visibleIds.length; i += CHUNK) {
|
|
811
875
|
// Arrow wrapper on purpose: Array#map passes (value, index, array), and
|
|
@@ -813,9 +877,11 @@ export function apply(ctx) {
|
|
|
813
877
|
const res2 = await Promise.all(visibleIds.slice(i, i + CHUNK).map((id) => resolveOne(id, usage, {
|
|
814
878
|
exposeUsage: !!(opts && opts.usage),
|
|
815
879
|
preloaded: snapshotById.has(id) ? snapshotById.get(id) : undefined,
|
|
880
|
+
collectDecoded,
|
|
816
881
|
})))
|
|
817
882
|
for (const it of res2) items.push({ ...it, archived: currentArchived.has(it.sessionId) })
|
|
818
883
|
}
|
|
884
|
+
persistDecoded(decoded, statsById)
|
|
819
885
|
// Annotate stars; GC only when we have a trustworthy id baseline, so a
|
|
820
886
|
// failing sp.list() can never wipe the whole index.
|
|
821
887
|
let starredSet = new Set()
|
|
@@ -878,30 +944,38 @@ export function apply(ctx) {
|
|
|
878
944
|
// 既不会陈旧也不会回到「每次全量解码」。
|
|
879
945
|
async function sidebarAuthority() {
|
|
880
946
|
const ids = []
|
|
881
|
-
let
|
|
882
|
-
try {
|
|
883
|
-
if (!Array.isArray(
|
|
884
|
-
for (const
|
|
947
|
+
let entries = []
|
|
948
|
+
try { entries = await persistence.listEntries() } catch (e) { entries = [] }
|
|
949
|
+
if (!Array.isArray(entries)) entries = []
|
|
950
|
+
for (const entry of entries) ids.push(entry.id)
|
|
885
951
|
const sessions = ctx.get('sessions')
|
|
886
952
|
try { if (sessions) sessions.list().forEach((session) => { const sid = String(session.id); if (!ids.includes(sid)) ids.push(sid) }) } catch (e) {}
|
|
887
953
|
const store = await readTrashStore()
|
|
888
954
|
if (ids.length) {
|
|
889
|
-
const usage = await collectUsage(
|
|
955
|
+
const usage = await collectUsage(entries)
|
|
890
956
|
const statsById = new Map(ids.map((id) => [id, { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(id) }]))
|
|
891
957
|
const { cached, missing } = metaCache.partition(ids, statsById)
|
|
892
|
-
|
|
958
|
+
// P4:与列表构建共用持久标题索引,冷启动零解码。
|
|
959
|
+
const persisted = await hydrateFromPersist(missing, statsById)
|
|
960
|
+
for (const [id, meta] of persisted) metaCache.set(id, statsById.get(id), meta)
|
|
961
|
+
const rest = missing.filter((id) => !persisted.has(id))
|
|
962
|
+
const snapshotById = await projectTitles(rest)
|
|
963
|
+
const decoded = new Map()
|
|
964
|
+
const collectDecoded = (id, meta) => { decoded.set(id, meta) }
|
|
893
965
|
for (const id of ids) {
|
|
894
|
-
let meta = cached.get(id) || null
|
|
966
|
+
let meta = cached.get(id) || persisted.get(id) || null
|
|
895
967
|
if (!meta) {
|
|
896
968
|
const snapshot = snapshotById.has(id)
|
|
897
969
|
? snapshotById.get(id)
|
|
898
970
|
: (typeof sq.readTitleSnapshot === 'function' ? await sq.readTitleSnapshot(id).catch(() => null) : null)
|
|
899
971
|
const next = metaFromSnapshot(snapshot)
|
|
900
972
|
metaCache.set(id, statsById.get(id), next)
|
|
973
|
+
if (statsById.get(id)) collectDecoded(id, next)
|
|
901
974
|
meta = next
|
|
902
975
|
}
|
|
903
976
|
if (meta && meta.title) authorityTitleCache.set(id, String(meta.title))
|
|
904
977
|
}
|
|
978
|
+
persistDecoded(decoded, statsById)
|
|
905
979
|
}
|
|
906
980
|
return {
|
|
907
981
|
titles: Object.fromEntries(authorityTitleCache),
|
|
@@ -923,7 +997,7 @@ export function apply(ctx) {
|
|
|
923
997
|
meta = (live && live.header) || null
|
|
924
998
|
try { events = Array.isArray(live.events) ? [...live.events] : [] } catch (e) { events = [] }
|
|
925
999
|
} else {
|
|
926
|
-
const r = await
|
|
1000
|
+
const r = await persistence.readSession(sid, 0)
|
|
927
1001
|
if (!r || !r.meta) throw new Error('找不到该会话的记录(会话不存在)')
|
|
928
1002
|
meta = r.meta
|
|
929
1003
|
events = Array.isArray(r.events) ? r.events : []
|
|
@@ -932,7 +1006,7 @@ export function apply(ctx) {
|
|
|
932
1006
|
try {
|
|
933
1007
|
// rc.8 的 sessionPersistence 后端没有 artifactInfo;用 locate(meta) 拿日志
|
|
934
1008
|
// 文件真实路径后 stat 出字节数(磁盘占用)。
|
|
935
|
-
const loc =
|
|
1009
|
+
const loc = persistence.locate(meta)
|
|
936
1010
|
if (loc && typeof loc.path === 'string' && loc.path) {
|
|
937
1011
|
const st = await stat(loc.path)
|
|
938
1012
|
if (st && typeof st.size === 'number') sizeBytes = st.size
|
|
@@ -1005,7 +1079,8 @@ export function apply(ctx) {
|
|
|
1005
1079
|
const subagentSet = new Set()
|
|
1006
1080
|
try {
|
|
1007
1081
|
if (typeof sp.list === 'function') {
|
|
1008
|
-
for (const
|
|
1082
|
+
for (const entry of await persistence.listEntries()) {
|
|
1083
|
+
const h = entry.header
|
|
1009
1084
|
if (String(h.parentSession) !== String(sid)) continue
|
|
1010
1085
|
if (h.origin === 'subagent') subagentSet.add(h.id); else childrenSet.add(h.id)
|
|
1011
1086
|
}
|
|
@@ -1041,6 +1116,12 @@ export function apply(ctx) {
|
|
|
1041
1116
|
}
|
|
1042
1117
|
}))
|
|
1043
1118
|
|
|
1119
|
+
disposers.push(ctx.webServer.register({
|
|
1120
|
+
kind: 'exact',
|
|
1121
|
+
path: '/archived-sessions/capabilities',
|
|
1122
|
+
handler: async (req, res) => json(res, capabilities),
|
|
1123
|
+
}))
|
|
1124
|
+
|
|
1044
1125
|
disposers.push(ctx.webServer.register({
|
|
1045
1126
|
kind: 'exact',
|
|
1046
1127
|
path: '/archived-sessions/list',
|
|
@@ -1053,8 +1134,8 @@ export function apply(ctx) {
|
|
|
1053
1134
|
let materialized = new Set()
|
|
1054
1135
|
let live = ctx.get('sessions')
|
|
1055
1136
|
try {
|
|
1056
|
-
const
|
|
1057
|
-
materialized = new Set(
|
|
1137
|
+
const entries = await persistence.listEntries()
|
|
1138
|
+
materialized = new Set(entries.map((entry) => entry.id))
|
|
1058
1139
|
} catch (e) { /* best-effort */ }
|
|
1059
1140
|
const trashStore = await readTrashStore()
|
|
1060
1141
|
const hidden = new Set([...trashStore.items.map((item) => String(item.sessionId)), ...trashStore.purgedSessionIds.map(String)])
|
|
@@ -1100,11 +1181,11 @@ export function apply(ctx) {
|
|
|
1100
1181
|
const results = []
|
|
1101
1182
|
for (const sid of ids) {
|
|
1102
1183
|
try { results.push({ sessionId: sid, ok: true, ...(await restoreOne(sid)) }) }
|
|
1103
|
-
catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
|
|
1184
|
+
catch (e) { results.push({ sessionId: sid, ok: false, code: e && e.code, error: String((e && e.message) || e) }) }
|
|
1104
1185
|
}
|
|
1105
1186
|
json(res, { ok: true, restored: results.filter((r) => r.ok).length, results })
|
|
1106
1187
|
} catch (e) {
|
|
1107
|
-
json(res, { ok: false, error: String((e && e.message) || e) },
|
|
1188
|
+
json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
|
|
1108
1189
|
}
|
|
1109
1190
|
},
|
|
1110
1191
|
}))
|
|
@@ -1143,7 +1224,7 @@ export function apply(ctx) {
|
|
|
1143
1224
|
}
|
|
1144
1225
|
json(res, { ok: true, deleted: results.filter((r) => r.ok).length, results })
|
|
1145
1226
|
} catch (e) {
|
|
1146
|
-
json(res, { ok: false, error: String((e && e.message) || e) },
|
|
1227
|
+
json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
|
|
1147
1228
|
}
|
|
1148
1229
|
},
|
|
1149
1230
|
}))
|
|
@@ -1188,11 +1269,19 @@ export function apply(ctx) {
|
|
|
1188
1269
|
try {
|
|
1189
1270
|
const items = await readTrash()
|
|
1190
1271
|
const results = await Promise.all(items.map(async (item) => {
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1272
|
+
if (typeof item.originalPath !== 'string' || !item.originalPath) {
|
|
1273
|
+
return { sessionId: item.sessionId, status: 'unverified', originalPath: null }
|
|
1274
|
+
}
|
|
1275
|
+
const exists = await stat(item.originalPath).then(() => true).catch(() => false)
|
|
1276
|
+
return { sessionId: item.sessionId, status: exists ? 'ok' : 'missing', originalPath: item.originalPath }
|
|
1194
1277
|
}))
|
|
1195
|
-
json(res, {
|
|
1278
|
+
json(res, {
|
|
1279
|
+
ok: true,
|
|
1280
|
+
healthy: results.filter((r) => r.status === 'ok').length,
|
|
1281
|
+
missing: results.filter((r) => r.status === 'missing').length,
|
|
1282
|
+
unverified: results.filter((r) => r.status === 'unverified').length,
|
|
1283
|
+
results,
|
|
1284
|
+
})
|
|
1196
1285
|
} catch (e) {
|
|
1197
1286
|
json(res, { ok: false, error: String((e && e.message) || e) }, errorStatus(e))
|
|
1198
1287
|
}
|
|
@@ -1211,7 +1300,7 @@ export function apply(ctx) {
|
|
|
1211
1300
|
metaCache.invalidate(sid)
|
|
1212
1301
|
json(res, out)
|
|
1213
1302
|
} catch (e) {
|
|
1214
|
-
json(res, { ok: false, error: String((e && e.message) || e) },
|
|
1303
|
+
json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
|
|
1215
1304
|
}
|
|
1216
1305
|
},
|
|
1217
1306
|
}))
|
|
@@ -1226,9 +1315,11 @@ export function apply(ctx) {
|
|
|
1226
1315
|
if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
|
|
1227
1316
|
const out = await purgeFromTrash(sid)
|
|
1228
1317
|
metaCache.invalidate(sid)
|
|
1318
|
+
// 彻底删除:持久标题索引里的条目一并清掉(issue #1 P4)。
|
|
1319
|
+
titleIndex.remove([sid]).catch(() => {})
|
|
1229
1320
|
json(res, out)
|
|
1230
1321
|
} catch (e) {
|
|
1231
|
-
json(res, { ok: false, error: String((e && e.message) || e) },
|
|
1322
|
+
json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
|
|
1232
1323
|
}
|
|
1233
1324
|
},
|
|
1234
1325
|
}))
|
|
@@ -1243,12 +1334,12 @@ export function apply(ctx) {
|
|
|
1243
1334
|
if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
|
|
1244
1335
|
const results = []
|
|
1245
1336
|
for (const sid of ids) {
|
|
1246
|
-
try { results.push({ sessionId: sid, ok: true, ...(await purgeFromTrash(sid)) }); metaCache.invalidate(sid) }
|
|
1337
|
+
try { results.push({ sessionId: sid, ok: true, ...(await purgeFromTrash(sid)) }); metaCache.invalidate(sid); titleIndex.remove([sid]).catch(() => {}) }
|
|
1247
1338
|
catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
|
|
1248
1339
|
}
|
|
1249
1340
|
json(res, { ok: true, purged: results.filter((r) => r.ok).length, results })
|
|
1250
1341
|
} catch (e) {
|
|
1251
|
-
json(res, { ok: false, error: String((e && e.message) || e) },
|
|
1342
|
+
json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
|
|
1252
1343
|
}
|
|
1253
1344
|
},
|
|
1254
1345
|
}))
|
|
@@ -1298,7 +1389,7 @@ export function apply(ctx) {
|
|
|
1298
1389
|
const url = new URL(req.url, 'http://localhost')
|
|
1299
1390
|
const sid = url.searchParams.get('sessionId')
|
|
1300
1391
|
requireSessionId(sid)
|
|
1301
|
-
const r = await
|
|
1392
|
+
const r = await persistence.readSession(sid, 0)
|
|
1302
1393
|
if (!r || !r.meta) {
|
|
1303
1394
|
const error = new Error('无法读取该会话的日志')
|
|
1304
1395
|
error.status = 404
|
|
@@ -1365,7 +1456,7 @@ export function apply(ctx) {
|
|
|
1365
1456
|
try { await reindexRegistry() } catch (e) { /* best-effort */ }
|
|
1366
1457
|
json(res, { sessionId: sid, ...moved })
|
|
1367
1458
|
} catch (e) {
|
|
1368
|
-
json(res, { ok: false, error: String((e && e.message) || e) },
|
|
1459
|
+
json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
|
|
1369
1460
|
}
|
|
1370
1461
|
},
|
|
1371
1462
|
}))
|
|
@@ -1440,7 +1531,10 @@ export function apply(ctx) {
|
|
|
1440
1531
|
|
|
1441
1532
|
// Auto-archive settings. A plain read (no patch keys) doubles as the lazy
|
|
1442
1533
|
// sweep trigger — that is how the once-a-day cleanup gets a chance to run
|
|
1443
|
-
// without a background timer.
|
|
1534
|
+
// without a background timer. The sweep runs in the background so opening
|
|
1535
|
+
// the panel never waits on it (P5, issue #1): the sweep itself already
|
|
1536
|
+
// reuses the metadata caches, and this keeps the settings read latency
|
|
1537
|
+
// independent of library size.
|
|
1444
1538
|
disposers.push(ctx.webServer.register({
|
|
1445
1539
|
kind: 'exact',
|
|
1446
1540
|
path: '/archived-sessions/auto-archive/settings',
|
|
@@ -1450,10 +1544,20 @@ export function apply(ctx) {
|
|
|
1450
1544
|
const patch = {}
|
|
1451
1545
|
if (body && Object.prototype.hasOwnProperty.call(body, 'inactiveDays')) patch.inactiveDays = body.inactiveDays
|
|
1452
1546
|
if (body && Object.prototype.hasOwnProperty.call(body, 'skipStarred')) patch.skipStarred = body.skipStarred
|
|
1453
|
-
const
|
|
1547
|
+
const isPatch = Object.keys(patch).length > 0
|
|
1548
|
+
const settings = isPatch
|
|
1454
1549
|
? await autoArchive.update(patch)
|
|
1455
1550
|
: (await autoArchive.read()).settings
|
|
1456
|
-
|
|
1551
|
+
let sweep
|
|
1552
|
+
if (isPatch) {
|
|
1553
|
+
// 显式保存设置:保持「保存即生效」的同步 sweep(含刚启用时的首次归档)。
|
|
1554
|
+
sweep = await autoArchiveSweep()
|
|
1555
|
+
} else {
|
|
1556
|
+
// 面板打开的纯读取:sweep 转后台执行,打开延迟与库大小解耦
|
|
1557
|
+
//(P5,issue #1)。sweep 本身已复用元数据缓存 + 持久标题索引。
|
|
1558
|
+
void autoArchiveSweep().catch(() => {})
|
|
1559
|
+
sweep = { triggered: true }
|
|
1560
|
+
}
|
|
1457
1561
|
const store = await autoArchive.read()
|
|
1458
1562
|
json(res, {
|
|
1459
1563
|
ok: true,
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// title-persist-index.js — 会话标题/元数据的**磁盘**小索引(P4,issue #1)。
|
|
2
|
+
//
|
|
3
|
+
// metaCache(session-meta-cache.js)解决的是「进程内重复解码」;本模块解决
|
|
4
|
+
// 的是冷启动:插件重启后内存缓存为空,第一次列表构建仍要全库解码一次。
|
|
5
|
+
// 把解码出的元数据连同文件指纹原子写进一个 JSON 索引,重启后列表只需
|
|
6
|
+
// 一次索引读 + 指纹比对,指纹没变的会话零解码。
|
|
7
|
+
//
|
|
8
|
+
// 结构仿 trash 的原子写索引:{ schemaVersion, entries: { [sessionId]: entry } }
|
|
9
|
+
// entry = { title, cwd, createdAt, fingerprint, updatedAt }
|
|
10
|
+
// fingerprint 即 session-meta-cache.js 的 fingerprintOf(stat) 产出
|
|
11
|
+
// ("<mtimeMs>:<size>"),比对一致即可信任条目内容。
|
|
12
|
+
//
|
|
13
|
+
// 写入时机由调用方决定(列表构建收尾批量回写、purge 时清理),本模块只
|
|
14
|
+
// 提供:读取缓存、合并写入(串行化 + 原子替换)、按 id 删除。任何文件
|
|
15
|
+
// 损坏都按空索引处理,绝不阻塞列表构建。
|
|
16
|
+
//
|
|
17
|
+
// 单实例假设:一个进程内只 apply 一个插件实例(生产即如此),实例间的
|
|
18
|
+
// 内存副本不互相同步——跨「重启」以落盘内容为准(测试亦按此断言)。
|
|
19
|
+
|
|
20
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
21
|
+
import { dirname, join } from 'node:path'
|
|
22
|
+
|
|
23
|
+
export const TITLE_INDEX_SCHEMA_VERSION = 1
|
|
24
|
+
|
|
25
|
+
const MAX_ENTRIES = 20000
|
|
26
|
+
|
|
27
|
+
// 条目只保留可序列化且对列表有用的字段;指纹缺失的条目无法校验,直接丢弃——
|
|
28
|
+
// 宁可下次重解码,也不能把无法失效的数据当真。
|
|
29
|
+
export function normalizeEntry(raw) {
|
|
30
|
+
if (!raw || typeof raw !== 'object') return null
|
|
31
|
+
const title = typeof raw.title === 'string' ? raw.title : null
|
|
32
|
+
const cwd = typeof raw.cwd === 'string' ? raw.cwd : null
|
|
33
|
+
const createdAt = typeof raw.createdAt === 'number' ? raw.createdAt : null
|
|
34
|
+
const fingerprint = typeof raw.fingerprint === 'string' && raw.fingerprint ? raw.fingerprint : null
|
|
35
|
+
const updatedAt = typeof raw.updatedAt === 'number' ? raw.updatedAt : 0
|
|
36
|
+
if (!fingerprint || (!title && !cwd)) return null
|
|
37
|
+
return { title, cwd, createdAt, fingerprint, updatedAt }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function normalizeTitleIndex(raw) {
|
|
41
|
+
const entries = {}
|
|
42
|
+
if (raw && typeof raw === 'object' && raw.entries && typeof raw.entries === 'object') {
|
|
43
|
+
for (const [id, entry] of Object.entries(raw.entries)) {
|
|
44
|
+
if (typeof id !== 'string' || !id || id.length > 200) continue
|
|
45
|
+
const normalized = normalizeEntry(entry)
|
|
46
|
+
if (normalized) entries[id] = normalized
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { schemaVersion: TITLE_INDEX_SCHEMA_VERSION, entries }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 纯合并:right 覆盖 left 同 id 条目;截断到 MAX_ENTRIES(保留 updatedAt 新的)。
|
|
53
|
+
export function mergeEntries(left, right) {
|
|
54
|
+
const merged = { ...left }
|
|
55
|
+
for (const [id, entry] of Object.entries(right)) merged[id] = entry
|
|
56
|
+
const ids = Object.keys(merged)
|
|
57
|
+
if (ids.length > MAX_ENTRIES) {
|
|
58
|
+
ids.sort((a, b) => (merged[a].updatedAt || 0) - (merged[b].updatedAt || 0))
|
|
59
|
+
for (const id of ids.slice(0, ids.length - MAX_ENTRIES)) delete merged[id]
|
|
60
|
+
}
|
|
61
|
+
return merged
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createTitleIndexStore({ dir, file }) {
|
|
65
|
+
let cache = null
|
|
66
|
+
let chain = Promise.resolve()
|
|
67
|
+
const path = file || join(dir, 'title-index.json')
|
|
68
|
+
|
|
69
|
+
async function readRaw() {
|
|
70
|
+
try {
|
|
71
|
+
return normalizeTitleIndex(JSON.parse(await readFile(path, 'utf8')))
|
|
72
|
+
} catch (e) {
|
|
73
|
+
return normalizeTitleIndex(null)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 所有写操作串行化(仿 trash 的 mutate 队列),避免并发 merge 互相覆盖。
|
|
78
|
+
function enqueue(mutator) {
|
|
79
|
+
const operation = chain.then(async () => {
|
|
80
|
+
const store = cache || (cache = (await readRaw()).entries)
|
|
81
|
+
await mutator(store)
|
|
82
|
+
return store
|
|
83
|
+
})
|
|
84
|
+
chain = operation.catch(() => {})
|
|
85
|
+
return operation
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
// 只读:内存优先,未加载过才落盘一次。绝不抛错。
|
|
90
|
+
async entries() {
|
|
91
|
+
if (cache) return cache
|
|
92
|
+
cache = (await readRaw()).entries
|
|
93
|
+
return cache
|
|
94
|
+
},
|
|
95
|
+
// 批量合并写入(原子替换)。失败静默:索引只是加速器,坏了下次重解码。
|
|
96
|
+
async merge(batch) {
|
|
97
|
+
const right = {}
|
|
98
|
+
for (const [id, entry] of Object.entries(batch || {})) {
|
|
99
|
+
const normalized = normalizeEntry(entry)
|
|
100
|
+
if (normalized) right[String(id)] = normalized
|
|
101
|
+
}
|
|
102
|
+
if (!Object.keys(right).length) return false
|
|
103
|
+
await enqueue(async (store) => {
|
|
104
|
+
const next = mergeEntries(store, right)
|
|
105
|
+
await mkdir(dirname(path), { recursive: true })
|
|
106
|
+
const tmp = join(dirname(path), `.title-index-${process.pid}-${Date.now()}.tmp`)
|
|
107
|
+
await writeFile(tmp, JSON.stringify({ schemaVersion: TITLE_INDEX_SCHEMA_VERSION, entries: next }), { encoding: 'utf8', mode: 0o600 })
|
|
108
|
+
await rename(tmp, path)
|
|
109
|
+
cache = next
|
|
110
|
+
})
|
|
111
|
+
return true
|
|
112
|
+
},
|
|
113
|
+
async remove(ids) {
|
|
114
|
+
const wanted = new Set((ids || []).map(String))
|
|
115
|
+
if (!wanted.size) return false
|
|
116
|
+
await enqueue(async (store) => {
|
|
117
|
+
let changed = false
|
|
118
|
+
for (const id of wanted) {
|
|
119
|
+
if (id in store) { delete store[id]; changed = true }
|
|
120
|
+
}
|
|
121
|
+
if (!changed) return
|
|
122
|
+
await mkdir(dirname(path), { recursive: true })
|
|
123
|
+
const tmp = join(dirname(path), `.title-index-${process.pid}-${Date.now()}.tmp`)
|
|
124
|
+
await writeFile(tmp, JSON.stringify({ schemaVersion: TITLE_INDEX_SCHEMA_VERSION, entries: store }), { encoding: 'utf8', mode: 0o600 })
|
|
125
|
+
await rename(tmp, path)
|
|
126
|
+
})
|
|
127
|
+
return true
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 判定持久条目能否当作当前日志的解码结果:指纹一致即可(与内存缓存同一标准)。
|
|
133
|
+
export function persistEntryUsable(entry, stat) {
|
|
134
|
+
const normalized = normalizeEntry(entry)
|
|
135
|
+
if (!normalized || !stat) return null
|
|
136
|
+
return normalized.fingerprint === stat.fingerprint ? normalized : null
|
|
137
|
+
}
|