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/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 { renderSessionMarkdown } from './markdown.js'
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
- // opts.preloaded:批量投影(sq.readTitleSnapshots)已经拿到的快照;传了就不再
258
- // 对同一条日志做第二次单例投影——issue 里「一条日志在单次列表里被解码两次」
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 ? { mtimeMs: usage.mtimeById.get(key), size: usage.sizeById.get(key) } : null
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
- meta = metaFromSnapshot(unwrapSnapshot(opts.preloaded))
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 { meta = metaFromSnapshot(await sq.readTitleSnapshot(id)) } catch (e) { /* fall back to raw log */ }
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
- // 兜底:投影没给出标题或 cwd 时,才回退到整本解码(这条路径本身就贵,
273
- // 且结果同样会进缓存,下一次列表就不会再走一遍)。
274
- if (!meta.title || !meta.cwd) {
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
- const r = await persistence.readSession(id, 0)
277
- if (r.meta) {
278
- if (!meta.cwd) meta.cwd = r.meta.cwd || null
279
- if (!meta.createdAt) meta.createdAt = r.meta.createdAt || null
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 && Array.isArray(r.events)) meta.title = foldTitle(r.events)
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
- // sp.locate(header) resolves the log file behind a session header; a single
293
- // stat() then yields both its size and its mtime. mtime doubles as the
294
- // session's last-activity time — appending an event rewrites the log, so the
295
- // file's last write tracks the conversation's last turn. It errs safe: a log
296
- // we relocated (move) gets a fresh mtime and therefore looks *more* active
297
- // than it is, which can only delay an auto-archive, never cause a wrong one.
298
- // headers 可由调用方传入复用(列表构建里已经 sp.list() 过一次,避免重复列目录)。
299
- async function collectUsage(preloadedHeaders) {
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(preloadedHeaders)) entries = preloadedHeaders
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) mtimeById.set(id, Math.floor(st.mtimeMs))
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
- const r = await persistence.readSession(sid, 0)
416
- if (r && r.meta) { if (!cwd) cwd = r.meta.cwd; title = foldTitle(r.events) }
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) { const error = new Error('回收站中找不到该会话'); error.status = 404; throw error }
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
- const located = current && persistence.locate(current.header)
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
- // Accept both layouts, but reject every unrelated path before unlink.
485
- const targetOwnsSession = target && (basename(dirname(target)) === sid || basename(target).includes(sid))
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 (isOpen) {
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. 无条件做一遍 stat——一次 stat 是微秒级,而它算出的 (mtime, size) 指纹
858
- // 是元数据缓存能否跳过整本解码的前提,收益远大于成本
1058
+ // 2. 变更令牌优先来自 list 快照:legacy 走 locate+stat,SessionHandle 世代
1059
+ // 直接用 snapshot.revision(无 locate 可用,也绝不绕私有路径补 stat)
859
1060
  // 3. 未命中缓存的会话走**一次**批量投影(sq.readTitleSnapshots),而不是逐条
860
- async function allSessionItems(opts = {}) {
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
- hiddenIds = new Set([...store.items.map((t) => String(t.sessionId)), ...store.purgedSessionIds.map(String)])
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) => [id, { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(id) }]))
886
- const { cached, missing } = metaCache.partition(visibleIds, statsById)
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) => resolveOne(id, usage, {
899
- exposeUsage: !!(opts && opts.usage),
900
- preloaded: snapshotById.has(id) ? snapshotById.get(id) : undefined,
901
- collectDecoded,
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 allSessionItems({ usage: true })
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) => [id, { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(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: store.purgedSessionIds.map(String),
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
- async function buildDetails(sid) {
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 events = []
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
- for (const ev of events) {
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
- const hidden = new Set([...trashStore.items.map((item) => String(item.sessionId)), ...trashStore.purgedSessionIds.map(String)])
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 r = await persistence.readSession(sid, 0)
1414
- if (!r || !r.meta) {
1415
- const error = new Error('无法读取该会话的日志')
1416
- error.status = 404
1417
- throw error
1418
- }
1419
- const md = renderSessionMarkdown({ ...r.meta, id: sid }, r.events || [])
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
- json(res, await buildDetails(sid))
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
  }