dsh-sessions-manager 3.5.1 → 3.5.3
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 +12 -2
- package/README.md +12 -2
- package/lib/client.js +121 -90
- package/lib/client.js.map +2 -2
- package/lib/index.js +834 -221
- package/lib/index.js.map +4 -4
- package/package.json +1 -1
- package/src/client/index.jsx +154 -86
- package/src/client/logic.js +5 -1
- package/src/compat/capabilities.js +64 -8
- package/src/compat/persistence.js +139 -18
- package/src/handle-era-ops.js +185 -0
- package/src/handle-era-paths.js +126 -0
- package/src/index.js +367 -92
- package/src/markdown.js +122 -71
- package/src/path-guard.js +59 -0
- package/src/session-meta-cache.js +40 -13
- package/src/title-persist-index.js +6 -1
package/src/index.js
CHANGED
|
@@ -12,14 +12,16 @@ import { basename, dirname, isAbsolute, join } from 'node:path'
|
|
|
12
12
|
import { readFileSync } from 'node:fs'
|
|
13
13
|
import { homedir } from 'node:os'
|
|
14
14
|
import { rewriteFrame0CwdInMemory, scanZstdFrames } from './zstd-frame.js'
|
|
15
|
-
import {
|
|
15
|
+
import { createSessionMarkdownBuilder } 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'
|
|
19
|
+
import { createSessionMetaCache, fingerprintOf, isPersistableFingerprint } from './session-meta-cache.js'
|
|
20
20
|
import { createTitleIndexStore } from './title-persist-index.js'
|
|
21
21
|
import { createPersistenceAdapter } from './compat/persistence.js'
|
|
22
22
|
import { detectCapabilities, requireCapability } from './compat/capabilities.js'
|
|
23
|
+
import { pathOwnsSession } from './path-guard.js'
|
|
24
|
+
import { purgeSessionArtifacts, moveSessionToCwd } from './handle-era-ops.js'
|
|
23
25
|
|
|
24
26
|
|
|
25
27
|
export const name = 'dsh-sessions-manager'
|
|
@@ -47,6 +49,22 @@ function errorStatus(error) {
|
|
|
47
49
|
return error && Number.isInteger(error.status) ? error.status : 500
|
|
48
50
|
}
|
|
49
51
|
|
|
52
|
+
// 极简并发闸:整本日志读取(详情 / 导出)同时最多 max 个在跑,排队等待。
|
|
53
|
+
// 防止批量导出把宿主 CPU/内存打满(SessionHandle 世代逐块解码仍是 CPU 活)。
|
|
54
|
+
function createLimiter(max) {
|
|
55
|
+
let active = 0
|
|
56
|
+
const queue = []
|
|
57
|
+
return async function run(fn) {
|
|
58
|
+
if (active >= max) await new Promise((resolve) => queue.push(resolve))
|
|
59
|
+
active++
|
|
60
|
+
try { return await fn() } finally {
|
|
61
|
+
active--
|
|
62
|
+
const next = queue.shift()
|
|
63
|
+
if (next) next()
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
50
68
|
async function readJsonBody(req) {
|
|
51
69
|
const chunks = []
|
|
52
70
|
let total = 0
|
|
@@ -140,6 +158,7 @@ export function apply(ctx) {
|
|
|
140
158
|
|
|
141
159
|
// P4:对「内存缓存未命中」的会话查持久索引,指纹一致才可信。
|
|
142
160
|
// 返回 Map<id, meta>;调用方应把命中条目回填 metaCache 并从 missing 里剔除。
|
|
161
|
+
// revision 指纹(SessionHandle 世代)跳过持久索引:跨进程无意义。
|
|
143
162
|
async function hydrateFromPersist(ids, statsById) {
|
|
144
163
|
const hits = new Map()
|
|
145
164
|
if (!ids || !ids.length) return hits
|
|
@@ -150,6 +169,7 @@ export function apply(ctx) {
|
|
|
150
169
|
const entry = store && store[id]
|
|
151
170
|
if (!stat || !entry) continue
|
|
152
171
|
const fp = fingerprintOf(stat)
|
|
172
|
+
if (!isPersistableFingerprint(fp)) continue
|
|
153
173
|
if (fp && entry.fingerprint === fp) {
|
|
154
174
|
hits.set(id, { title: entry.title, cwd: entry.cwd, createdAt: entry.createdAt })
|
|
155
175
|
}
|
|
@@ -159,13 +179,15 @@ export function apply(ctx) {
|
|
|
159
179
|
|
|
160
180
|
// 把本批真正解码出的元数据异步回写持久索引(fire-and-forget:索引只是
|
|
161
181
|
// 加速器,写失败不影响响应,队列内部已串行化 + 原子替换)。
|
|
182
|
+
// ⚠️ revision 指纹(SessionHandle 世代)绝不落盘:它只在当前 service
|
|
183
|
+
// 实例内有意义,跨进程比较无意义,误用会把陈旧数据当新鲜数据。
|
|
162
184
|
function persistDecoded(decoded, statsById) {
|
|
163
185
|
if (!decoded || !decoded.size) return
|
|
164
186
|
const batch = {}
|
|
165
187
|
const now = Date.now()
|
|
166
188
|
for (const [id, meta] of decoded) {
|
|
167
189
|
const fp = fingerprintOf(statsById.get(id))
|
|
168
|
-
if (!fp) continue
|
|
190
|
+
if (!isPersistableFingerprint(fp)) continue
|
|
169
191
|
batch[id] = { title: meta.title, cwd: meta.cwd, createdAt: meta.createdAt, fingerprint: fp, updatedAt: now }
|
|
170
192
|
}
|
|
171
193
|
if (!Object.keys(batch).length) return
|
|
@@ -250,35 +272,58 @@ export function apply(ctx) {
|
|
|
250
272
|
// Resolve one session's display metadata.
|
|
251
273
|
//
|
|
252
274
|
// 成本模型(issue #1):下面的解码路径会把整本 .jsonl.zstd 逐帧解压、逐行
|
|
253
|
-
// JSON.parse,只为折叠出标题——大库上一次全表要几秒阻塞式 CPU
|
|
254
|
-
// (mtime, size)
|
|
255
|
-
//
|
|
275
|
+
// JSON.parse,只为折叠出标题——大库上一次全表要几秒阻塞式 CPU。日志内容没变
|
|
276
|
+
// 就意味着折叠结果不可能变(legacy 用 (mtime, size) 文件指纹;SessionHandle
|
|
277
|
+
// 世代用官方 snapshot.revision),命中即直接复用,跳过整本解码。
|
|
256
278
|
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
279
|
+
// 0.1.3-alpha 兼容(避免放大官方已知的历史会话加载性能回退):
|
|
280
|
+
// - cwd/createdAt 优先来自 list() 快照的 snapshot.header;
|
|
281
|
+
// - 标题优先来自批量 readTitleSnapshots;
|
|
282
|
+
// - **标题缺失绝不单独触发整本日志解码**——无标题就显示「(无标题)」。
|
|
283
|
+
// 只有在拿不到 cwd(工作区归属失效)或 runtime 完全没有标题投影能力时
|
|
284
|
+
// 才回退到日志解码,且该解码走 inspectSession 分块折叠,不做整本驻留。
|
|
260
285
|
async function resolveOne(id, usage, opts = {}) {
|
|
261
286
|
const key = String(id)
|
|
262
|
-
const statInfo = usage ?
|
|
287
|
+
const statInfo = usage ? (usage.statsById ? usage.statsById.get(key) : null)
|
|
288
|
+
|| { mtimeMs: usage.mtimeById && usage.mtimeById.get(key), size: usage.sizeById && usage.sizeById.get(key) } : null
|
|
263
289
|
const cached = metaCache.get(key, statInfo)
|
|
264
290
|
if (cached) return buildItem(key, cached, usage, opts.exposeUsage)
|
|
265
291
|
|
|
266
292
|
let meta = { title: null, cwd: null, createdAt: null }
|
|
293
|
+
// 第一来源:list() 返回的 SessionPersistenceSnapshot.header(0.1.3+ 官方
|
|
294
|
+
// 契约里 header 携带 cwd/createdAt,无需任何日志读取)。
|
|
295
|
+
if (opts.listHeader) {
|
|
296
|
+
if (typeof opts.listHeader.cwd === 'string') meta.cwd = opts.listHeader.cwd
|
|
297
|
+
if (opts.listHeader.createdAt != null) meta.createdAt = opts.listHeader.createdAt
|
|
298
|
+
}
|
|
267
299
|
if (opts.preloaded !== undefined) {
|
|
268
|
-
|
|
300
|
+
const projected = metaFromSnapshot(unwrapSnapshot(opts.preloaded))
|
|
301
|
+
if (projected.title) meta.title = projected.title
|
|
302
|
+
if (!meta.cwd && projected.cwd) meta.cwd = projected.cwd
|
|
303
|
+
if (!meta.createdAt && projected.createdAt) meta.createdAt = projected.createdAt
|
|
269
304
|
} else if (typeof sq.readTitleSnapshot === 'function') {
|
|
270
|
-
try {
|
|
305
|
+
try {
|
|
306
|
+
const projected = metaFromSnapshot(await sq.readTitleSnapshot(id))
|
|
307
|
+
if (projected.title) meta.title = projected.title
|
|
308
|
+
if (!meta.cwd && projected.cwd) meta.cwd = projected.cwd
|
|
309
|
+
if (!meta.createdAt && projected.createdAt) meta.createdAt = projected.createdAt
|
|
310
|
+
} catch (e) { /* fall through */ }
|
|
271
311
|
}
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
|
|
312
|
+
// cwd 缺失 → 工作区归属失效,值得一次解码兜底(cwd 在 header 里,通常
|
|
313
|
+
// 快照已带回,这里只在快照缺 cwd 时发生)。runtime 完全没有标题投影能力
|
|
314
|
+
// 时(老后端无 readTitleSnapshot),解码同时兜底标题。
|
|
315
|
+
const projectionAvailable = typeof sq.readTitleSnapshot === 'function' || typeof sq.readTitleSnapshots === 'function'
|
|
316
|
+
if (!meta.cwd || (!meta.title && !projectionAvailable)) {
|
|
275
317
|
try {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
if (!
|
|
279
|
-
|
|
318
|
+
let foldedTitle = null
|
|
319
|
+
const summary = await persistence.inspectSession(key, {
|
|
320
|
+
onEvents: (events) => { if (!foldedTitle) foldedTitle = foldTitle(events) },
|
|
321
|
+
})
|
|
322
|
+
if (summary && summary.meta) {
|
|
323
|
+
if (!meta.cwd) meta.cwd = summary.meta.cwd || null
|
|
324
|
+
if (!meta.createdAt) meta.createdAt = summary.meta.createdAt || null
|
|
280
325
|
}
|
|
281
|
-
if (!meta.title &&
|
|
326
|
+
if (!meta.title && foldedTitle) meta.title = foldedTitle
|
|
282
327
|
} catch (e2) { /* keep what we have */ }
|
|
283
328
|
}
|
|
284
329
|
metaCache.set(key, statInfo, meta)
|
|
@@ -287,20 +332,25 @@ export function apply(ctx) {
|
|
|
287
332
|
return buildItem(key, meta, usage, opts.exposeUsage)
|
|
288
333
|
}
|
|
289
334
|
|
|
290
|
-
// Disk usage + last-write time for every session, in one pass.
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
|
|
335
|
+
// Disk usage + last-write time for every session, in one pass. Also produces
|
|
336
|
+
// the per-id change token (`statsById`) that drives the metadata cache:
|
|
337
|
+
// - SessionHandle 世代(0.1.3+):公共服务不再暴露 locate/raw 路径,
|
|
338
|
+
// snapshot.revision(list 一次就带回)就是官方唯一变更令牌;
|
|
339
|
+
// - legacy:沿用 sp.locate + 一次 stat 的 (mtime, size) 文件指纹。
|
|
340
|
+
// mtime doubles as the session's last-activity time — appending an event
|
|
341
|
+
// rewrites the log, so the file's last write tracks the conversation's last
|
|
342
|
+
// turn. It errs safe: a log we relocated (move) gets a fresh mtime and
|
|
343
|
+
// therefore looks *more* active than it is, which can only delay an
|
|
344
|
+
// auto-archive, never cause a wrong one. Handle-era runtimes provide no
|
|
345
|
+
// activity timestamp at all; auto-archive must then skip instead of guessing
|
|
346
|
+
// (see autoArchiveSweep).
|
|
347
|
+
// entries 可由调用方传入复用(列表构建里已经 sp.list() 过一次,避免重复列目录)。
|
|
348
|
+
async function collectUsage(preloadedEntries) {
|
|
300
349
|
const sizeById = new Map()
|
|
301
350
|
const mtimeById = new Map()
|
|
351
|
+
const statsById = new Map()
|
|
302
352
|
let entries = null
|
|
303
|
-
if (Array.isArray(
|
|
353
|
+
if (Array.isArray(preloadedEntries)) entries = preloadedEntries
|
|
304
354
|
else { try { entries = await persistence.listEntries() } catch (e) { entries = [] } }
|
|
305
355
|
if (!Array.isArray(entries)) entries = []
|
|
306
356
|
const CHUNK = 8
|
|
@@ -309,6 +359,13 @@ export function apply(ctx) {
|
|
|
309
359
|
const header = entry && entry.header ? entry.header : entry
|
|
310
360
|
const id = entry && entry.id != null ? String(entry.id) : (header && header.id != null ? String(header.id) : null)
|
|
311
361
|
if (!id) return
|
|
362
|
+
// SessionHandle 世代:snapshot(header/revision/sizeBytes)是权威轻量
|
|
363
|
+
// 观察,绝不再绕道私有磁盘路径补 stat。
|
|
364
|
+
if (entry && typeof entry.revision === 'string' && entry.revision) {
|
|
365
|
+
if (Number.isFinite(entry.sizeBytes)) sizeById.set(id, Number(entry.sizeBytes))
|
|
366
|
+
statsById.set(id, { revision: entry.revision })
|
|
367
|
+
return
|
|
368
|
+
}
|
|
312
369
|
if (entry && Number.isFinite(entry.sizeBytes)) sizeById.set(id, Number(entry.sizeBytes))
|
|
313
370
|
try {
|
|
314
371
|
const loc = persistence.locate(header)
|
|
@@ -316,11 +373,14 @@ export function apply(ctx) {
|
|
|
316
373
|
const st = await stat(loc.path)
|
|
317
374
|
if (!st) return
|
|
318
375
|
if (typeof st.size === 'number') sizeById.set(id, st.size)
|
|
319
|
-
if (typeof st.mtimeMs === 'number' && st.mtimeMs > 0)
|
|
376
|
+
if (typeof st.mtimeMs === 'number' && st.mtimeMs > 0) {
|
|
377
|
+
mtimeById.set(id, Math.floor(st.mtimeMs))
|
|
378
|
+
statsById.set(id, { mtimeMs: Math.floor(st.mtimeMs), size: typeof st.size === 'number' ? st.size : undefined })
|
|
379
|
+
}
|
|
320
380
|
} catch (e) { /* best-effort: an unreadable log just stays unknown */ }
|
|
321
381
|
}))
|
|
322
382
|
}
|
|
323
|
-
return { sizeById, mtimeById }
|
|
383
|
+
return { sizeById, mtimeById, statsById, hasActivityData: mtimeById.size > 0 }
|
|
324
384
|
}
|
|
325
385
|
|
|
326
386
|
// Restore (unarchive) one session; throws on failure.
|
|
@@ -412,8 +472,25 @@ export function apply(ctx) {
|
|
|
412
472
|
title = header.title || (header.meta && header.meta.title) || null
|
|
413
473
|
}
|
|
414
474
|
if (!title) {
|
|
415
|
-
|
|
416
|
-
|
|
475
|
+
// 标题兜底优先走单会话标题投影(快照级,不读日志);投影也没有时才
|
|
476
|
+
// 分块解码日志折叠标题(inspectSession 分块,不整本驻留内存)。
|
|
477
|
+
if (typeof sq.readTitleSnapshot === 'function') {
|
|
478
|
+
try {
|
|
479
|
+
const snap = unwrapSnapshot(await sq.readTitleSnapshot(sid))
|
|
480
|
+
if (snap && snap.title && snap.title.title) title = String(snap.title.title)
|
|
481
|
+
if (snap && snap.session) { if (!cwd) cwd = snap.session.cwd || null }
|
|
482
|
+
} catch (e) { /* fall through */ }
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
if (!title) {
|
|
486
|
+
try {
|
|
487
|
+
let folded = null
|
|
488
|
+
const summary = await persistence.inspectSession(sid, {
|
|
489
|
+
onEvents: (events) => { if (!folded) folded = foldTitle(events) },
|
|
490
|
+
})
|
|
491
|
+
if (summary && summary.meta && !cwd) cwd = summary.meta.cwd || null
|
|
492
|
+
title = folded
|
|
493
|
+
} catch (e) { /* best-effort */ }
|
|
417
494
|
}
|
|
418
495
|
} catch (e) { /* best-effort */ }
|
|
419
496
|
// Record in the trash index only — the log stays in its workspace dir.
|
|
@@ -438,22 +515,104 @@ export function apply(ctx) {
|
|
|
438
515
|
return { ok: true, trashed: true }
|
|
439
516
|
}
|
|
440
517
|
|
|
518
|
+
// 恢复前的底层校验(0.1.3 契约下 restoreIndexedSession 不再无条件可用):
|
|
519
|
+
// 1. 底层 stored session 仍存在(live / stat / list 三级判定);
|
|
520
|
+
// 2. 日志文件仍在原处(软删除不动文件,originalPath 丢失即外部破坏);
|
|
521
|
+
// 3. 工作区丢失不算失败——会话仍可恢复,UI 以 workspaceGone 提示。
|
|
522
|
+
// 返回 { ok:true, workspaceGone, verified } 或 { ok:false, status, code, message }。
|
|
523
|
+
// verified=false 表示无法核验日志文件(SessionHandle 世代无 locate),按
|
|
524
|
+
// 索引为准放行,但绝不假装校验过。
|
|
525
|
+
async function verifyTrashRestore(sid) {
|
|
526
|
+
const sessions = ctx.get('sessions')
|
|
527
|
+
if (sessions && sessions.get && sessions.get(sid)) {
|
|
528
|
+
return { ok: true, workspaceGone: false, verified: true }
|
|
529
|
+
}
|
|
530
|
+
let exists = false
|
|
531
|
+
let header = null
|
|
532
|
+
try {
|
|
533
|
+
const stat = await persistence.statSession(sid)
|
|
534
|
+
if (stat) { exists = true; header = stat.header }
|
|
535
|
+
} catch (e) { /* stat 缺失或失败:落入 legacy 判定 */ }
|
|
536
|
+
if (!exists) {
|
|
537
|
+
try {
|
|
538
|
+
const entries = await persistence.listEntries()
|
|
539
|
+
const found = entries.find((entry) => entry.id === sid)
|
|
540
|
+
if (found) { exists = true; header = found.header }
|
|
541
|
+
} catch (e) { /* list 失败:继续走错误分支 */ }
|
|
542
|
+
}
|
|
543
|
+
if (!exists) {
|
|
544
|
+
const store = await readTrashStore()
|
|
545
|
+
if (store.purgedSessionIds.map(String).includes(sid)) {
|
|
546
|
+
return { ok: false, status: 410, code: 'DSM_SESSION_PURGED', message: '该会话已彻底删除,无法从回收站恢复' }
|
|
547
|
+
}
|
|
548
|
+
return { ok: false, status: 409, code: 'DSM_SESSION_MISSING', message: '底层会话已不存在(可能被外部删除或重建),无法恢复' }
|
|
549
|
+
}
|
|
550
|
+
// 软删除把日志留在原工作区目录;originalPath 消失 = 外部破坏。
|
|
551
|
+
// 无法核验(无 locate / 无记录)时放行但如实标注。
|
|
552
|
+
let verified = false
|
|
553
|
+
const store = await readTrashStore()
|
|
554
|
+
const entry = store.items.find((t) => String(t.sessionId) === sid)
|
|
555
|
+
let originalPath = entry && typeof entry.originalPath === 'string' ? entry.originalPath : null
|
|
556
|
+
if (!originalPath && header) {
|
|
557
|
+
const loc = persistence.locate(header)
|
|
558
|
+
if (loc && typeof loc.path === 'string') originalPath = loc.path
|
|
559
|
+
}
|
|
560
|
+
if (originalPath) {
|
|
561
|
+
const existsOnDisk = await stat(originalPath).then(() => true).catch(() => false)
|
|
562
|
+
if (!existsOnDisk) {
|
|
563
|
+
return { ok: false, status: 409, code: 'DSM_SESSION_LOG_MISSING', message: '回收站索引仍记录该会话,但其日志文件已消失(可能被外部移动或删除)' }
|
|
564
|
+
}
|
|
565
|
+
verified = true
|
|
566
|
+
}
|
|
567
|
+
let workspaceGone = false
|
|
568
|
+
const cwd = (header && header.cwd) || (entry && entry.cwd) || null
|
|
569
|
+
if (cwd) {
|
|
570
|
+
try { workspaceGone = !w.list().some((ent) => ent.path === cwd) } catch (e) { workspaceGone = false }
|
|
571
|
+
}
|
|
572
|
+
return { ok: true, workspaceGone, verified }
|
|
573
|
+
}
|
|
574
|
+
|
|
441
575
|
// Restore a trashed session: the log never left its original workspace dir,
|
|
442
576
|
// so we just drop it from the recycle-bin index and the sidebar reveals it in
|
|
443
|
-
// its original workspace (no move / no re-attach needed).
|
|
577
|
+
// its original workspace (no move / no re-attach needed). Repeated restores
|
|
578
|
+
// fail with an accurate 404 — there is no second entry to restore.
|
|
444
579
|
async function restoreFromTrash(sid) {
|
|
445
580
|
requireSessionId(sid)
|
|
581
|
+
requireCapability(capabilities, 'restoreIndexedSession')
|
|
582
|
+
let outcome = null
|
|
446
583
|
await mutateTrash(async (store) => {
|
|
447
584
|
const entry = store.items.find((t) => String(t.sessionId) === sid)
|
|
448
|
-
if (!entry) {
|
|
585
|
+
if (!entry) {
|
|
586
|
+
if (store.purgedSessionIds.map(String).includes(sid)) {
|
|
587
|
+
const error = new Error('该会话已彻底删除,无法从回收站恢复')
|
|
588
|
+
error.status = 410
|
|
589
|
+
error.code = 'DSM_SESSION_PURGED'
|
|
590
|
+
throw error
|
|
591
|
+
}
|
|
592
|
+
const error = new Error('回收站中找不到该会话(可能已恢复过)')
|
|
593
|
+
error.status = 404
|
|
594
|
+
error.code = 'DSM_TRASH_NOT_FOUND'
|
|
595
|
+
throw error
|
|
596
|
+
}
|
|
597
|
+
// Verify BEFORE removing the durable entry: if verification fails the
|
|
598
|
+
// mutator throws, mutateTrash does not write, and the item remains
|
|
599
|
+
// recoverable instead of disappearing into an inconsistent state.
|
|
600
|
+
const verification = await verifyTrashRestore(sid)
|
|
601
|
+
if (!verification.ok) {
|
|
602
|
+
const error = new Error(verification.message)
|
|
603
|
+
error.status = verification.status
|
|
604
|
+
error.code = verification.code
|
|
605
|
+
throw error
|
|
606
|
+
}
|
|
449
607
|
// Restore the pre-delete archive state before removing the durable trash
|
|
450
608
|
// entry. If this fails, mutateTrash does not write and the item remains
|
|
451
609
|
// recoverable instead of disappearing into an inconsistent state.
|
|
452
610
|
if (entry.wasArchived === false) await restoreOne(sid)
|
|
453
611
|
store.items = store.items.filter((t) => String(t.sessionId) !== sid)
|
|
454
612
|
store.purgedSessionIds = store.purgedSessionIds.filter((id) => id !== sid)
|
|
613
|
+
outcome = { ok: true, restored: true, workspaceGone: verification.workspaceGone, verified: verification.verified }
|
|
455
614
|
})
|
|
456
|
-
return { ok: true, restored: true }
|
|
615
|
+
return outcome || { ok: true, restored: true }
|
|
457
616
|
}
|
|
458
617
|
|
|
459
618
|
// Permanently erase a trashed session: physically delete its log (still in
|
|
@@ -466,12 +625,31 @@ export function apply(ctx) {
|
|
|
466
625
|
const entry = store.items.find((t) => String(t.sessionId) === sid)
|
|
467
626
|
if (!entry) { const error = new Error('回收站中找不到该会话'); error.status = 404; throw error }
|
|
468
627
|
let target = null
|
|
628
|
+
let locatedHeader = null
|
|
469
629
|
try {
|
|
470
630
|
const entries = await persistence.listEntries()
|
|
471
631
|
const current = entries.find((entry) => entry.id === sid)
|
|
472
|
-
|
|
632
|
+
locatedHeader = current ? current.header : null
|
|
633
|
+
// locateVerified:legacy 走官方 locate;handle 时代官方收走了 locate,
|
|
634
|
+
// 改由三层守卫推导(root → 目录结构 → id 归属),失败返回 null。
|
|
635
|
+
// 比仅靠回收站条目里的 originalPath 更可靠:originalPath 过期或缺失时
|
|
636
|
+
// 仍能从当前存储布局重新推导。
|
|
637
|
+
const located = current ? await persistence.locateVerified(current.header) : null
|
|
473
638
|
if (located && typeof located.path === 'string') target = located.path
|
|
474
639
|
} catch (e) {}
|
|
640
|
+
// 幽灵记录兜底:会话不在 list 里(后端索引滞后/索引缺失)但日志仍在盘上。
|
|
641
|
+
// 用 statSession 拿官方 header,再走守卫推导,避免退化成「只删单文件」
|
|
642
|
+
// 或直接 409 拒绝。
|
|
643
|
+
if (!locatedHeader && typeof persistence.statSession === 'function') {
|
|
644
|
+
try {
|
|
645
|
+
const snap = await persistence.statSession(sid)
|
|
646
|
+
if (snap && snap.header) {
|
|
647
|
+
locatedHeader = snap.header
|
|
648
|
+
const located = await persistence.locateVerified(snap.header)
|
|
649
|
+
if (located && typeof located.path === 'string') target = located.path
|
|
650
|
+
}
|
|
651
|
+
} catch (e) {}
|
|
652
|
+
}
|
|
475
653
|
if (!target && typeof entry.originalPath === 'string') target = entry.originalPath
|
|
476
654
|
if (!target) {
|
|
477
655
|
const error = new Error('无法确认该会话的物理日志位置,已停止永久删除')
|
|
@@ -481,8 +659,10 @@ export function apply(ctx) {
|
|
|
481
659
|
// JSONL persistence stores logs as
|
|
482
660
|
// .../<sessionId>/session.jsonl.zstd
|
|
483
661
|
// Older backends may instead include the id in the filename itself.
|
|
484
|
-
//
|
|
485
|
-
|
|
662
|
+
// pathOwnsSession accepts both layouts on POSIX and Windows separators
|
|
663
|
+
// and rejects every unrelated path (including id-substring collisions)
|
|
664
|
+
// before any unlink.
|
|
665
|
+
const targetOwnsSession = pathOwnsSession(target, sid)
|
|
486
666
|
if (target && !targetOwnsSession) {
|
|
487
667
|
const error = new Error('日志路径与会话 ID 不匹配,已停止永久删除')
|
|
488
668
|
error.status = 409
|
|
@@ -515,7 +695,11 @@ export function apply(ctx) {
|
|
|
515
695
|
error.status = 409
|
|
516
696
|
throw error
|
|
517
697
|
}
|
|
518
|
-
if (target) {
|
|
698
|
+
if (target && persistence.kind === 'session-handle' && locatedHeader) {
|
|
699
|
+
// handle 时代:写所有权探测(活跃写者 409)→ 整目录删除 → 官方 stat 复核。
|
|
700
|
+
// 内部复用与移动同一套路径守卫;删除失败会带 status 冒泡。
|
|
701
|
+
await purgeSessionArtifacts(sp, sid, locatedHeader)
|
|
702
|
+
} else if (target) {
|
|
519
703
|
try { await unlink(target) } catch (e) { if (e && e.code !== 'ENOENT') throw new Error('删除文件失败:' + String((e && e.message) || e)) }
|
|
520
704
|
}
|
|
521
705
|
try { for (const ent of w.list()) { if (ent.sessionIds.includes(sid)) { try { await ent.detachSession(sid) } catch (e) {} } } } catch (e) {}
|
|
@@ -692,7 +876,24 @@ export function apply(ctx) {
|
|
|
692
876
|
// validated by decompression and why a non-session frame0 is rejected
|
|
693
877
|
// instead of rewritten.
|
|
694
878
|
|
|
695
|
-
if (
|
|
879
|
+
if (persistence.kind === 'session-handle') {
|
|
880
|
+
// 读事件之后的双 revision 校验:两次采样之间 revision 仍在变,说明日志
|
|
881
|
+
// 还在被写入,中止而不是复制出分叉副本(读取期间的写入由 ops 内的
|
|
882
|
+
// rename-aside + 官方写所有权探测兜底)。
|
|
883
|
+
const stat1 = await persistence.statSession(sid)
|
|
884
|
+
if (stat1 && stat1.revision) {
|
|
885
|
+
const stat2 = await persistence.statSession(sid)
|
|
886
|
+
if (stat2 && stat2.revision !== stat1.revision) {
|
|
887
|
+
throw new Error('该会话在移动准备期间发生了变化,请稍后重试。')
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
try {
|
|
891
|
+
await moveSessionToCwd({ sp, sid, header: meta, canonical, events, inheritedEventCount: r.inheritedEventCount })
|
|
892
|
+
} catch (e) {
|
|
893
|
+
if (e && e.status) throw e
|
|
894
|
+
throw new Error('移动会话日志失败:' + String((e && e.message) || e))
|
|
895
|
+
}
|
|
896
|
+
} else if (isOpen) {
|
|
696
897
|
// Live session: relocate the on-disk log (rewriting frame0's cwd to the
|
|
697
898
|
// new path) and redirect the live object + persistence state. We must
|
|
698
899
|
// rewrite frame0, not just rename: sp.list() reads frame0's cwd from
|
|
@@ -852,12 +1053,12 @@ export function apply(ctx) {
|
|
|
852
1053
|
// opts.usage: expose sizeBytes + updatedAt on each item (storage analysis and
|
|
853
1054
|
// the auto-archive sweep need them; the panel list does not).
|
|
854
1055
|
//
|
|
855
|
-
// 性能要点(issue #1
|
|
1056
|
+
// 性能要点(issue #1 + 0.1.3-alpha 适配):
|
|
856
1057
|
// 1. sp.list() 只调一次(原先列了两遍目录)
|
|
857
|
-
// 2.
|
|
858
|
-
//
|
|
1058
|
+
// 2. 变更令牌优先来自 list 快照:legacy 走 locate+stat,SessionHandle 世代
|
|
1059
|
+
// 直接用 snapshot.revision(无 locate 可用,也绝不绕私有路径补 stat)
|
|
859
1060
|
// 3. 未命中缓存的会话走**一次**批量投影(sq.readTitleSnapshots),而不是逐条
|
|
860
|
-
async function
|
|
1061
|
+
async function allSessionItemsDetailed(opts = {}) {
|
|
861
1062
|
let entries = []
|
|
862
1063
|
let headersOk = false
|
|
863
1064
|
try {
|
|
@@ -865,15 +1066,22 @@ export function apply(ctx) {
|
|
|
865
1066
|
headersOk = Array.isArray(entries)
|
|
866
1067
|
if (!headersOk) entries = []
|
|
867
1068
|
} catch (e) { entries = [] }
|
|
1069
|
+
const entryById = new Map(entries.map((entry) => [entry.id, entry]))
|
|
868
1070
|
let live = ctx.get('sessions')
|
|
869
1071
|
const ids = entries.map((entry) => entry.id)
|
|
870
1072
|
if (live) { try { live.list().forEach((s) => { const sid = String(s.id); if (!ids.includes(sid)) ids.push(sid) }) } catch (e) { /* ignore */ } }
|
|
871
1073
|
// Exclude sessions already moved to the recycle bin (软删除): they live in
|
|
872
1074
|
// 回收站, not in 会话管理, so the panel won't re-list them after a delete.
|
|
1075
|
+
// purged tombstone 只对「当前不存在的 id」继续隐藏:若同 id 会话后来重新
|
|
1076
|
+
// 出现(重建/换绑定),墓碑必须让位,不能永久压住新会话。
|
|
873
1077
|
let hiddenIds = new Set()
|
|
874
1078
|
try {
|
|
875
1079
|
const store = await readTrashStore()
|
|
876
|
-
|
|
1080
|
+
const present = new Set(ids)
|
|
1081
|
+
hiddenIds = new Set([
|
|
1082
|
+
...store.items.map((t) => String(t.sessionId)),
|
|
1083
|
+
...store.purgedSessionIds.map(String).filter((id) => !present.has(id)),
|
|
1084
|
+
])
|
|
877
1085
|
} catch (e) {}
|
|
878
1086
|
const visibleIds = ids.filter((id) => !hiddenIds.has(id))
|
|
879
1087
|
wsByPath = {}
|
|
@@ -882,8 +1090,11 @@ export function apply(ctx) {
|
|
|
882
1090
|
const items = []
|
|
883
1091
|
const usage = await collectUsage(entries)
|
|
884
1092
|
// 先按指纹把「缓存命中」与「需要解码」分开,只对后者做批量投影。
|
|
885
|
-
const statsById = new Map(visibleIds.map((id) => [
|
|
886
|
-
|
|
1093
|
+
const statsById = new Map(visibleIds.map((id) => [
|
|
1094
|
+
id,
|
|
1095
|
+
(usage.statsById && usage.statsById.get(id)) || { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(id) },
|
|
1096
|
+
]))
|
|
1097
|
+
const { missing } = metaCache.partition(visibleIds, statsById)
|
|
887
1098
|
// P4:missing 里先查持久标题索引(冷启动跳过整本解码),命中的回填内存缓存。
|
|
888
1099
|
const persisted = await hydrateFromPersist(missing, statsById)
|
|
889
1100
|
for (const [id, meta] of persisted) metaCache.set(id, statsById.get(id), meta)
|
|
@@ -895,11 +1106,15 @@ export function apply(ctx) {
|
|
|
895
1106
|
for (let i = 0; i < visibleIds.length; i += CHUNK) {
|
|
896
1107
|
// Arrow wrapper on purpose: Array#map passes (value, index, array), and
|
|
897
1108
|
// resolveOne's second and third arguments are fixed here.
|
|
898
|
-
const res2 = await Promise.all(visibleIds.slice(i, i + CHUNK).map((id) =>
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
1109
|
+
const res2 = await Promise.all(visibleIds.slice(i, i + CHUNK).map((id) => {
|
|
1110
|
+
const entry = entryById.get(id)
|
|
1111
|
+
return resolveOne(id, usage, {
|
|
1112
|
+
exposeUsage: !!(opts && opts.usage),
|
|
1113
|
+
listHeader: entry ? entry.header : null,
|
|
1114
|
+
preloaded: snapshotById.has(id) ? snapshotById.get(id) : undefined,
|
|
1115
|
+
collectDecoded,
|
|
1116
|
+
})
|
|
1117
|
+
}))
|
|
903
1118
|
for (const it of res2) items.push({ ...it, archived: currentArchived.has(it.sessionId) })
|
|
904
1119
|
}
|
|
905
1120
|
persistDecoded(decoded, statsById)
|
|
@@ -909,7 +1124,11 @@ export function apply(ctx) {
|
|
|
909
1124
|
try { starredSet = new Set((await stars.read()).starredSessionIds) } catch (e) {}
|
|
910
1125
|
for (const it of items) it.starred = starredSet.has(String(it.sessionId))
|
|
911
1126
|
if (headersOk) await gcStars(ids)
|
|
912
|
-
return items
|
|
1127
|
+
return { items, usage }
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
async function allSessionItems(opts = {}) {
|
|
1131
|
+
return (await allSessionItemsDetailed(opts)).items
|
|
913
1132
|
}
|
|
914
1133
|
|
|
915
1134
|
// ---- Storage usage + auto-archive ---------------------------------------
|
|
@@ -937,7 +1156,16 @@ export function apply(ctx) {
|
|
|
937
1156
|
if (!(opts && opts.force) && autoArchive.isFresh(store, now)) {
|
|
938
1157
|
return { ok: true, skipped: 'throttled', archived: 0, lastRunAt: store.lastRunAt, lastArchivedCount: store.lastArchivedCount }
|
|
939
1158
|
}
|
|
940
|
-
const items = await
|
|
1159
|
+
const { items, usage } = await allSessionItemsDetailed({ usage: true })
|
|
1160
|
+
// SessionHandle 世代没有可靠的「最后活跃时间」(无 locate/mtime,快照也不
|
|
1161
|
+
// 携带事件时间)。无法证明会话闲置 → 一律跳过,绝不猜测(宁可漏归档,
|
|
1162
|
+
// 不能错归档)。UI 会如实展示该降级。
|
|
1163
|
+
if (!usage.hasActivityData) {
|
|
1164
|
+
return {
|
|
1165
|
+
ok: true, skipped: 'no-activity-data', archived: 0,
|
|
1166
|
+
note: '当前 DSH 版本未提供可靠的最后活跃时间,自动归档已跳过;不会基于猜测归档任何会话。',
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
941
1169
|
const candidates = pickInactiveCandidates(items, {
|
|
942
1170
|
inactiveDays: days,
|
|
943
1171
|
skipStarred: store.settings.skipStarred,
|
|
@@ -972,9 +1200,15 @@ export function apply(ctx) {
|
|
|
972
1200
|
const sessions = ctx.get('sessions')
|
|
973
1201
|
try { if (sessions) sessions.list().forEach((session) => { const sid = String(session.id); if (!ids.includes(sid)) ids.push(sid) }) } catch (e) {}
|
|
974
1202
|
const store = await readTrashStore()
|
|
1203
|
+
// 墓碑只对「当前不存在」的 id 继续输出;同 id 会话重新出现时必须让位。
|
|
1204
|
+
const present = new Set(ids)
|
|
1205
|
+
const activeTombstones = store.purgedSessionIds.map(String).filter((id) => !present.has(id))
|
|
975
1206
|
if (ids.length) {
|
|
976
1207
|
const usage = await collectUsage(entries)
|
|
977
|
-
const statsById = new Map(ids.map((id) => [
|
|
1208
|
+
const statsById = new Map(ids.map((id) => [
|
|
1209
|
+
id,
|
|
1210
|
+
(usage.statsById && usage.statsById.get(id)) || { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(id) },
|
|
1211
|
+
]))
|
|
978
1212
|
const { cached, missing } = metaCache.partition(ids, statsById)
|
|
979
1213
|
// P4:与列表构建共用持久标题索引,冷启动零解码。
|
|
980
1214
|
const persisted = await hydrateFromPersist(missing, statsById)
|
|
@@ -986,10 +1220,15 @@ export function apply(ctx) {
|
|
|
986
1220
|
for (const id of ids) {
|
|
987
1221
|
let meta = cached.get(id) || persisted.get(id) || null
|
|
988
1222
|
if (!meta) {
|
|
1223
|
+
const entry = entries.find((e) => e.id === id)
|
|
989
1224
|
const snapshot = snapshotById.has(id)
|
|
990
1225
|
? snapshotById.get(id)
|
|
991
1226
|
: (typeof sq.readTitleSnapshot === 'function' ? await sq.readTitleSnapshot(id).catch(() => null) : null)
|
|
992
1227
|
const next = metaFromSnapshot(snapshot)
|
|
1228
|
+
if (entry && entry.header) {
|
|
1229
|
+
if (!next.cwd && typeof entry.header.cwd === 'string') next.cwd = entry.header.cwd
|
|
1230
|
+
if (!next.createdAt && entry.header.createdAt != null) next.createdAt = entry.header.createdAt
|
|
1231
|
+
}
|
|
993
1232
|
metaCache.set(id, statsById.get(id), next)
|
|
994
1233
|
if (statsById.get(id)) collectDecoded(id, next)
|
|
995
1234
|
meta = next
|
|
@@ -1001,7 +1240,7 @@ export function apply(ctx) {
|
|
|
1001
1240
|
return {
|
|
1002
1241
|
titles: Object.fromEntries(authorityTitleCache),
|
|
1003
1242
|
trashedSessionIds: store.items.map((item) => String(item.sessionId)),
|
|
1004
|
-
purgedSessionIds:
|
|
1243
|
+
purgedSessionIds: activeTombstones,
|
|
1005
1244
|
}
|
|
1006
1245
|
}
|
|
1007
1246
|
|
|
@@ -1009,31 +1248,13 @@ export function apply(ctx) {
|
|
|
1009
1248
|
// write/edit 文件 / 血统 parent/children/subagents)。live 与持久化会话都可读。
|
|
1010
1249
|
// 所有统计对未知事件类型容错;fetch 与 files 做上限截断,files 用 stat 过滤
|
|
1011
1250
|
// 磁盘上已不存在的路径,避免详情面板列出已删除文件。
|
|
1012
|
-
|
|
1251
|
+
// 持久化会话走 inspectSession 分块折叠:大日志不再整本驻留内存(0.1.3-alpha
|
|
1252
|
+
// 已知历史会话加载性能回退,这里避免放大它)。
|
|
1253
|
+
async function buildDetails(sid, signal) {
|
|
1013
1254
|
const sessions = ctx.get('sessions')
|
|
1014
1255
|
const live = sessions && sessions.get(sid)
|
|
1015
1256
|
let meta = null
|
|
1016
|
-
let
|
|
1017
|
-
if (live !== void 0) {
|
|
1018
|
-
meta = (live && live.header) || null
|
|
1019
|
-
try { events = Array.isArray(live.events) ? [...live.events] : [] } catch (e) { events = [] }
|
|
1020
|
-
} else {
|
|
1021
|
-
const r = await persistence.readSession(sid, 0)
|
|
1022
|
-
if (!r || !r.meta) throw new Error('找不到该会话的记录(会话不存在)')
|
|
1023
|
-
meta = r.meta
|
|
1024
|
-
events = Array.isArray(r.events) ? r.events : []
|
|
1025
|
-
}
|
|
1026
|
-
let sizeBytes = null
|
|
1027
|
-
try {
|
|
1028
|
-
// rc.8 的 sessionPersistence 后端没有 artifactInfo;用 locate(meta) 拿日志
|
|
1029
|
-
// 文件真实路径后 stat 出字节数(磁盘占用)。
|
|
1030
|
-
const loc = persistence.locate(meta)
|
|
1031
|
-
if (loc && typeof loc.path === 'string' && loc.path) {
|
|
1032
|
-
const st = await stat(loc.path)
|
|
1033
|
-
if (st && typeof st.size === 'number') sizeBytes = st.size
|
|
1034
|
-
}
|
|
1035
|
-
} catch (e) { sizeBytes = null }
|
|
1036
|
-
let lastTime = typeof meta && typeof meta.createdAt === 'number' ? meta.createdAt : 0
|
|
1257
|
+
let lastTime = 0
|
|
1037
1258
|
const fileSet = new Map()
|
|
1038
1259
|
const stats = {
|
|
1039
1260
|
turns: 0, steps: 0, userMessages: 0, assistantMessages: 0,
|
|
@@ -1041,7 +1262,8 @@ export function apply(ctx) {
|
|
|
1041
1262
|
}
|
|
1042
1263
|
const turnSeen = new Set()
|
|
1043
1264
|
const stepSeen = new Set()
|
|
1044
|
-
|
|
1265
|
+
|
|
1266
|
+
const absorb = (ev) => {
|
|
1045
1267
|
if (ev && typeof ev.time === 'number' && ev.time > lastTime) lastTime = ev.time
|
|
1046
1268
|
const d = (ev && ev.data && typeof ev.data === 'object') ? ev.data : {}
|
|
1047
1269
|
const type = ev && ev.type
|
|
@@ -1081,8 +1303,37 @@ export function apply(ctx) {
|
|
|
1081
1303
|
}
|
|
1082
1304
|
}
|
|
1083
1305
|
}
|
|
1306
|
+
|
|
1307
|
+
if (live !== void 0) {
|
|
1308
|
+
meta = (live && live.header) || null
|
|
1309
|
+
try { (Array.isArray(live.events) ? [...live.events] : []).forEach(absorb) } catch (e) { /* empty */ }
|
|
1310
|
+
} else {
|
|
1311
|
+
const summary = await persistence.inspectSession(sid, { signal, onEvents: (batch) => { for (const ev of batch) absorb(ev) } })
|
|
1312
|
+
if (!summary || !summary.meta) throw new Error('找不到该会话的记录(会话不存在)')
|
|
1313
|
+
meta = summary.meta
|
|
1314
|
+
}
|
|
1084
1315
|
stats.turns = turnSeen.size
|
|
1085
1316
|
stats.steps = stepSeen.size
|
|
1317
|
+
let sizeBytes = null
|
|
1318
|
+
if (live === void 0) {
|
|
1319
|
+
// SessionHandle 世代:快照直接带 sizeBytes(官方 JSONL 后端廉价提供);
|
|
1320
|
+
// 拿不到再退回 locate + stat(legacy)。绝不伪造 0。
|
|
1321
|
+
try {
|
|
1322
|
+
const stat = await persistence.statSession(sid)
|
|
1323
|
+
if (stat && Number.isFinite(stat.sizeBytes)) sizeBytes = stat.sizeBytes
|
|
1324
|
+
} catch (e) { /* fall through */ }
|
|
1325
|
+
}
|
|
1326
|
+
if (sizeBytes === null) {
|
|
1327
|
+
try {
|
|
1328
|
+
// rc.8 的 sessionPersistence 后端没有 artifactInfo;用 locate(meta) 拿日志
|
|
1329
|
+
// 文件真实路径后 stat 出字节数(磁盘占用)。
|
|
1330
|
+
const loc = persistence.locate(meta)
|
|
1331
|
+
if (loc && typeof loc.path === 'string' && loc.path) {
|
|
1332
|
+
const st = await stat(loc.path)
|
|
1333
|
+
if (st && typeof st.size === 'number') sizeBytes = st.size
|
|
1334
|
+
}
|
|
1335
|
+
} catch (e) { sizeBytes = null }
|
|
1336
|
+
}
|
|
1086
1337
|
if (stats.fetches.length > MAX_FETCHES) stats.fetches = stats.fetches.slice(0, MAX_FETCHES)
|
|
1087
1338
|
const fileEntries = [...fileSet.entries()].slice(0, MAX_FILES * 2)
|
|
1088
1339
|
const exists = await Promise.all(fileEntries.map(([p]) => stat(p).then(() => true).catch(() => false)))
|
|
@@ -1121,7 +1372,7 @@ export function apply(ctx) {
|
|
|
1121
1372
|
sessionId: sid,
|
|
1122
1373
|
sizeBytes,
|
|
1123
1374
|
createdAt: (meta && typeof meta.createdAt === 'number') ? meta.createdAt : null,
|
|
1124
|
-
updatedAt: lastTime || null,
|
|
1375
|
+
updatedAt: Math.max(lastTime || 0, (meta && typeof meta.createdAt === 'number' ? meta.createdAt : 0)) || null,
|
|
1125
1376
|
files,
|
|
1126
1377
|
stats,
|
|
1127
1378
|
lineage,
|
|
@@ -1159,7 +1410,14 @@ export function apply(ctx) {
|
|
|
1159
1410
|
materialized = new Set(entries.map((entry) => entry.id))
|
|
1160
1411
|
} catch (e) { /* best-effort */ }
|
|
1161
1412
|
const trashStore = await readTrashStore()
|
|
1162
|
-
|
|
1413
|
+
// 墓碑只对「当前不存在」的 id 生效;同 id 会话重新出现时让位。
|
|
1414
|
+
const present = new Set([
|
|
1415
|
+
...materialized,
|
|
1416
|
+
...ids.map(String),
|
|
1417
|
+
...(live && typeof live.list === 'function' ? live.list().map((s) => String(s.id)) : []),
|
|
1418
|
+
])
|
|
1419
|
+
const tombstones = trashStore.purgedSessionIds.map(String).filter((id) => !present.has(id))
|
|
1420
|
+
const hidden = new Set([...trashStore.items.map((item) => String(item.sessionId)), ...tombstones])
|
|
1163
1421
|
const idStrs = ids.map(String).filter((id) => !hidden.has(id) && (materialized.has(id) || (live && live.get(id))))
|
|
1164
1422
|
wsByPath = {}
|
|
1165
1423
|
try { for (const ent of w.list()) wsByPath[ent.path] = ent } catch (e) { wsByPath = {} }
|
|
@@ -1402,6 +1660,11 @@ export function apply(ctx) {
|
|
|
1402
1660
|
// Human-readable Markdown export (one session). Raw-log ZIP export is
|
|
1403
1661
|
// dsh's own GET /api/session.export — we deliberately do not duplicate it
|
|
1404
1662
|
// (see reports/HANDOFF-dsh-sessions-manager-roadmap.md §2.4).
|
|
1663
|
+
//
|
|
1664
|
+
// 0.1.3 兼容:日志经 inspectSession 分块读取(SessionHandle.read 的
|
|
1665
|
+
// offset/length 有界切片),流式渲染 Markdown,客户端断开即取消(signal),
|
|
1666
|
+
// 并受全局并发闸约束——大日志不再一次性整本驻留内存。
|
|
1667
|
+
const exportLimiter = createLimiter(2)
|
|
1405
1668
|
disposers.push(ctx.webServer.register({
|
|
1406
1669
|
kind: 'exact',
|
|
1407
1670
|
path: '/archived-sessions/export-md',
|
|
@@ -1410,13 +1673,22 @@ export function apply(ctx) {
|
|
|
1410
1673
|
const url = new URL(req.url, 'http://localhost')
|
|
1411
1674
|
const sid = url.searchParams.get('sessionId')
|
|
1412
1675
|
requireSessionId(sid)
|
|
1413
|
-
const
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1676
|
+
const ac = new AbortController()
|
|
1677
|
+
res.on('close', () => { if (!res.writableEnded) ac.abort() })
|
|
1678
|
+
const md = await exportLimiter(async () => {
|
|
1679
|
+
const builder = createSessionMarkdownBuilder({ id: sid })
|
|
1680
|
+
const summary = await persistence.inspectSession(sid, {
|
|
1681
|
+
signal: ac.signal,
|
|
1682
|
+
onEvents: (batch) => builder.addEvents(batch),
|
|
1683
|
+
})
|
|
1684
|
+
if (!summary || !summary.meta) {
|
|
1685
|
+
const error = new Error('无法读取该会话的日志')
|
|
1686
|
+
error.status = 404
|
|
1687
|
+
throw error
|
|
1688
|
+
}
|
|
1689
|
+
// meta 在流结束后才权威(header 来自 open 回包);finish 覆写 front matter。
|
|
1690
|
+
return builder.finish({ ...summary.meta, id: sid })
|
|
1691
|
+
})
|
|
1420
1692
|
res.writeHead(200, {
|
|
1421
1693
|
'content-type': 'text/markdown; charset=utf-8',
|
|
1422
1694
|
'content-disposition': `attachment; filename="dsh-session-${sid}.md"`,
|
|
@@ -1529,7 +1801,10 @@ export function apply(ctx) {
|
|
|
1529
1801
|
const body = await readJsonBody(req)
|
|
1530
1802
|
const sid = body && typeof body.sessionId === 'string' ? body.sessionId : null
|
|
1531
1803
|
if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
|
|
1532
|
-
|
|
1804
|
+
// 客户端断开时取消分块读取,避免为已离开的请求继续解码整本日志。
|
|
1805
|
+
const ac = new AbortController()
|
|
1806
|
+
res.on('close', () => { if (!res.writableEnded) ac.abort() })
|
|
1807
|
+
json(res, await buildDetails(sid, ac.signal))
|
|
1533
1808
|
} catch (e) {
|
|
1534
1809
|
json(res, { error: String((e && e.message) || e) }, (e && e.status) ? e.status : 500)
|
|
1535
1810
|
}
|