dsh-sessions-manager 3.5.0 → 3.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -7,19 +7,21 @@
7
7
  // (workspaceRegistry + storageDomain), folds titles/dates/workspace tags from
8
8
  // session persistence, physically removes a session's log file on delete, and
9
9
  // relocates a conversation (session) between workspaces on move.
10
- import { mkdir, realpath, rename, stat, unlink, writeFile } from 'node:fs/promises'
10
+ import { mkdir, readFile, realpath, rename, stat, unlink, writeFile } from 'node:fs/promises'
11
11
  import { basename, dirname, isAbsolute, join } from 'node:path'
12
12
  import { readFileSync } from 'node:fs'
13
13
  import { homedir } from 'node:os'
14
- import { rewriteFrame0Cwd } from './zstd-frame.js'
15
- import { renderSessionMarkdown } from './markdown.js'
14
+ import { rewriteFrame0CwdInMemory, scanZstdFrames } from './zstd-frame.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
@@ -481,8 +640,10 @@ export function apply(ctx) {
481
640
  // JSONL persistence stores logs as
482
641
  // .../<sessionId>/session.jsonl.zstd
483
642
  // 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))
643
+ // pathOwnsSession accepts both layouts on POSIX and Windows separators
644
+ // and rejects every unrelated path (including id-substring collisions)
645
+ // before any unlink.
646
+ const targetOwnsSession = pathOwnsSession(target, sid)
486
647
  if (target && !targetOwnsSession) {
487
648
  const error = new Error('日志路径与会话 ID 不匹配,已停止永久删除')
488
649
  error.status = 409
@@ -515,7 +676,11 @@ export function apply(ctx) {
515
676
  error.status = 409
516
677
  throw error
517
678
  }
518
- if (target) {
679
+ if (target && persistence.kind === 'session-handle' && locatedHeader) {
680
+ // handle 时代:写所有权探测(活跃写者 409)→ 整目录删除 → 官方 stat 复核。
681
+ // 内部复用与移动同一套路径守卫;删除失败会带 status 冒泡。
682
+ await purgeSessionArtifacts(sp, sid, locatedHeader)
683
+ } else if (target) {
519
684
  try { await unlink(target) } catch (e) { if (e && e.code !== 'ENOENT') throw new Error('删除文件失败:' + String((e && e.message) || e)) }
520
685
  }
521
686
  try { for (const ent of w.list()) { if (ent.sessionIds.includes(sid)) { try { await ent.detachSession(sid) } catch (e) {} } } } catch (e) {}
@@ -637,15 +802,36 @@ export function apply(ctx) {
637
802
  const newPath = locatePath(newHeaderObj)
638
803
  if (!oldPath || !newPath || oldPath === newPath) return false
639
804
  const backupPath = `${oldPath}.move-backup-${Date.now()}`
805
+ const stagedPath = `${newPath}.move-stage-${process.pid}-${Date.now()}`
806
+ let destinationInstalled = false
640
807
  try {
641
808
  // Ensure the destination project directory exists (rename does not
642
809
  // create it). Without this, the rename silently no-ops on ENOENT and
643
810
  // the log stays put while workspace.json is wrongly updated.
644
811
  await mkdir(dirname(newPath), { recursive: true })
645
- await rename(oldPath, backupPath) // current log to safety
646
- await rewriteFrame0Cwd(backupPath, canonical) // frame0 cwd -> newPath
647
- await rename(backupPath, newPath) // relocate to the new workspace dir
812
+ try {
813
+ await stat(newPath)
814
+ throw new Error('移动失败:目标位置已存在同名会话日志')
815
+ } catch (e) {
816
+ if (e && e.code !== 'ENOENT') throw e
817
+ }
818
+ await rename(oldPath, backupPath) // keep the original byte-identical until verification succeeds
819
+ const original = await readFile(backupPath)
820
+ const originalFrames = scanZstdFrames(original).frames
821
+ if (originalFrames.length === 0) throw new Error('移动前校验失败:会话日志没有完整 zstd 帧')
822
+ const rewritten = rewriteFrame0CwdInMemory(original, canonical)
823
+ const rewrittenFrames = scanZstdFrames(rewritten).frames
824
+ if (rewrittenFrames.length !== originalFrames.length) throw new Error('移动后校验失败:会话日志帧数发生变化')
825
+ const originalTail = original.subarray(originalFrames[0].end)
826
+ const rewrittenTail = rewritten.subarray(rewrittenFrames[0].end)
827
+ if (!originalTail.equals(rewrittenTail)) throw new Error('移动后校验失败:会话事件内容发生变化')
828
+ await writeFile(stagedPath, rewritten, { mode: 0o600 })
829
+ await rename(stagedPath, newPath)
830
+ destinationInstalled = true
831
+ await unlink(backupPath)
648
832
  } catch (e) {
833
+ try { await unlink(stagedPath) } catch (_) {}
834
+ if (destinationInstalled) { try { await unlink(newPath) } catch (_) {} }
649
835
  try { await rename(backupPath, oldPath) } catch (_) {}
650
836
  if (e && e.code !== 'ENOENT') throw e
651
837
  return false
@@ -671,7 +857,24 @@ export function apply(ctx) {
671
857
  // validated by decompression and why a non-session frame0 is rejected
672
858
  // instead of rewritten.
673
859
 
674
- if (isOpen) {
860
+ if (persistence.kind === 'session-handle') {
861
+ // 读事件之后的双 revision 校验:两次采样之间 revision 仍在变,说明日志
862
+ // 还在被写入,中止而不是复制出分叉副本(读取期间的写入由 ops 内的
863
+ // rename-aside + 官方写所有权探测兜底)。
864
+ const stat1 = await persistence.statSession(sid)
865
+ if (stat1 && stat1.revision) {
866
+ const stat2 = await persistence.statSession(sid)
867
+ if (stat2 && stat2.revision !== stat1.revision) {
868
+ throw new Error('该会话在移动准备期间发生了变化,请稍后重试。')
869
+ }
870
+ }
871
+ try {
872
+ await moveSessionToCwd({ sp, sid, header: meta, canonical, events, inheritedEventCount: r.inheritedEventCount })
873
+ } catch (e) {
874
+ if (e && e.status) throw e
875
+ throw new Error('移动会话日志失败:' + String((e && e.message) || e))
876
+ }
877
+ } else if (isOpen) {
675
878
  // Live session: relocate the on-disk log (rewriting frame0's cwd to the
676
879
  // new path) and redirect the live object + persistence state. We must
677
880
  // rewrite frame0, not just rename: sp.list() reads frame0's cwd from
@@ -831,12 +1034,12 @@ export function apply(ctx) {
831
1034
  // opts.usage: expose sizeBytes + updatedAt on each item (storage analysis and
832
1035
  // the auto-archive sweep need them; the panel list does not).
833
1036
  //
834
- // 性能要点(issue #1):
1037
+ // 性能要点(issue #1 + 0.1.3-alpha 适配):
835
1038
  // 1. sp.list() 只调一次(原先列了两遍目录)
836
- // 2. 无条件做一遍 stat——一次 stat 是微秒级,而它算出的 (mtime, size) 指纹
837
- // 是元数据缓存能否跳过整本解码的前提,收益远大于成本
1039
+ // 2. 变更令牌优先来自 list 快照:legacy 走 locate+stat,SessionHandle 世代
1040
+ // 直接用 snapshot.revision(无 locate 可用,也绝不绕私有路径补 stat)
838
1041
  // 3. 未命中缓存的会话走**一次**批量投影(sq.readTitleSnapshots),而不是逐条
839
- async function allSessionItems(opts = {}) {
1042
+ async function allSessionItemsDetailed(opts = {}) {
840
1043
  let entries = []
841
1044
  let headersOk = false
842
1045
  try {
@@ -844,15 +1047,22 @@ export function apply(ctx) {
844
1047
  headersOk = Array.isArray(entries)
845
1048
  if (!headersOk) entries = []
846
1049
  } catch (e) { entries = [] }
1050
+ const entryById = new Map(entries.map((entry) => [entry.id, entry]))
847
1051
  let live = ctx.get('sessions')
848
1052
  const ids = entries.map((entry) => entry.id)
849
1053
  if (live) { try { live.list().forEach((s) => { const sid = String(s.id); if (!ids.includes(sid)) ids.push(sid) }) } catch (e) { /* ignore */ } }
850
1054
  // Exclude sessions already moved to the recycle bin (软删除): they live in
851
1055
  // 回收站, not in 会话管理, so the panel won't re-list them after a delete.
1056
+ // purged tombstone 只对「当前不存在的 id」继续隐藏:若同 id 会话后来重新
1057
+ // 出现(重建/换绑定),墓碑必须让位,不能永久压住新会话。
852
1058
  let hiddenIds = new Set()
853
1059
  try {
854
1060
  const store = await readTrashStore()
855
- hiddenIds = new Set([...store.items.map((t) => String(t.sessionId)), ...store.purgedSessionIds.map(String)])
1061
+ const present = new Set(ids)
1062
+ hiddenIds = new Set([
1063
+ ...store.items.map((t) => String(t.sessionId)),
1064
+ ...store.purgedSessionIds.map(String).filter((id) => !present.has(id)),
1065
+ ])
856
1066
  } catch (e) {}
857
1067
  const visibleIds = ids.filter((id) => !hiddenIds.has(id))
858
1068
  wsByPath = {}
@@ -861,8 +1071,11 @@ export function apply(ctx) {
861
1071
  const items = []
862
1072
  const usage = await collectUsage(entries)
863
1073
  // 先按指纹把「缓存命中」与「需要解码」分开,只对后者做批量投影。
864
- const statsById = new Map(visibleIds.map((id) => [id, { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(id) }]))
865
- const { cached, missing } = metaCache.partition(visibleIds, statsById)
1074
+ const statsById = new Map(visibleIds.map((id) => [
1075
+ id,
1076
+ (usage.statsById && usage.statsById.get(id)) || { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(id) },
1077
+ ]))
1078
+ const { missing } = metaCache.partition(visibleIds, statsById)
866
1079
  // P4:missing 里先查持久标题索引(冷启动跳过整本解码),命中的回填内存缓存。
867
1080
  const persisted = await hydrateFromPersist(missing, statsById)
868
1081
  for (const [id, meta] of persisted) metaCache.set(id, statsById.get(id), meta)
@@ -874,11 +1087,15 @@ export function apply(ctx) {
874
1087
  for (let i = 0; i < visibleIds.length; i += CHUNK) {
875
1088
  // Arrow wrapper on purpose: Array#map passes (value, index, array), and
876
1089
  // resolveOne's second and third arguments are fixed here.
877
- const res2 = await Promise.all(visibleIds.slice(i, i + CHUNK).map((id) => resolveOne(id, usage, {
878
- exposeUsage: !!(opts && opts.usage),
879
- preloaded: snapshotById.has(id) ? snapshotById.get(id) : undefined,
880
- collectDecoded,
881
- })))
1090
+ const res2 = await Promise.all(visibleIds.slice(i, i + CHUNK).map((id) => {
1091
+ const entry = entryById.get(id)
1092
+ return resolveOne(id, usage, {
1093
+ exposeUsage: !!(opts && opts.usage),
1094
+ listHeader: entry ? entry.header : null,
1095
+ preloaded: snapshotById.has(id) ? snapshotById.get(id) : undefined,
1096
+ collectDecoded,
1097
+ })
1098
+ }))
882
1099
  for (const it of res2) items.push({ ...it, archived: currentArchived.has(it.sessionId) })
883
1100
  }
884
1101
  persistDecoded(decoded, statsById)
@@ -888,7 +1105,11 @@ export function apply(ctx) {
888
1105
  try { starredSet = new Set((await stars.read()).starredSessionIds) } catch (e) {}
889
1106
  for (const it of items) it.starred = starredSet.has(String(it.sessionId))
890
1107
  if (headersOk) await gcStars(ids)
891
- return items
1108
+ return { items, usage }
1109
+ }
1110
+
1111
+ async function allSessionItems(opts = {}) {
1112
+ return (await allSessionItemsDetailed(opts)).items
892
1113
  }
893
1114
 
894
1115
  // ---- Storage usage + auto-archive ---------------------------------------
@@ -916,7 +1137,16 @@ export function apply(ctx) {
916
1137
  if (!(opts && opts.force) && autoArchive.isFresh(store, now)) {
917
1138
  return { ok: true, skipped: 'throttled', archived: 0, lastRunAt: store.lastRunAt, lastArchivedCount: store.lastArchivedCount }
918
1139
  }
919
- const items = await allSessionItems({ usage: true })
1140
+ const { items, usage } = await allSessionItemsDetailed({ usage: true })
1141
+ // SessionHandle 世代没有可靠的「最后活跃时间」(无 locate/mtime,快照也不
1142
+ // 携带事件时间)。无法证明会话闲置 → 一律跳过,绝不猜测(宁可漏归档,
1143
+ // 不能错归档)。UI 会如实展示该降级。
1144
+ if (!usage.hasActivityData) {
1145
+ return {
1146
+ ok: true, skipped: 'no-activity-data', archived: 0,
1147
+ note: '当前 DSH 版本未提供可靠的最后活跃时间,自动归档已跳过;不会基于猜测归档任何会话。',
1148
+ }
1149
+ }
920
1150
  const candidates = pickInactiveCandidates(items, {
921
1151
  inactiveDays: days,
922
1152
  skipStarred: store.settings.skipStarred,
@@ -951,9 +1181,15 @@ export function apply(ctx) {
951
1181
  const sessions = ctx.get('sessions')
952
1182
  try { if (sessions) sessions.list().forEach((session) => { const sid = String(session.id); if (!ids.includes(sid)) ids.push(sid) }) } catch (e) {}
953
1183
  const store = await readTrashStore()
1184
+ // 墓碑只对「当前不存在」的 id 继续输出;同 id 会话重新出现时必须让位。
1185
+ const present = new Set(ids)
1186
+ const activeTombstones = store.purgedSessionIds.map(String).filter((id) => !present.has(id))
954
1187
  if (ids.length) {
955
1188
  const usage = await collectUsage(entries)
956
- const statsById = new Map(ids.map((id) => [id, { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(id) }]))
1189
+ const statsById = new Map(ids.map((id) => [
1190
+ id,
1191
+ (usage.statsById && usage.statsById.get(id)) || { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(id) },
1192
+ ]))
957
1193
  const { cached, missing } = metaCache.partition(ids, statsById)
958
1194
  // P4:与列表构建共用持久标题索引,冷启动零解码。
959
1195
  const persisted = await hydrateFromPersist(missing, statsById)
@@ -965,10 +1201,15 @@ export function apply(ctx) {
965
1201
  for (const id of ids) {
966
1202
  let meta = cached.get(id) || persisted.get(id) || null
967
1203
  if (!meta) {
1204
+ const entry = entries.find((e) => e.id === id)
968
1205
  const snapshot = snapshotById.has(id)
969
1206
  ? snapshotById.get(id)
970
1207
  : (typeof sq.readTitleSnapshot === 'function' ? await sq.readTitleSnapshot(id).catch(() => null) : null)
971
1208
  const next = metaFromSnapshot(snapshot)
1209
+ if (entry && entry.header) {
1210
+ if (!next.cwd && typeof entry.header.cwd === 'string') next.cwd = entry.header.cwd
1211
+ if (!next.createdAt && entry.header.createdAt != null) next.createdAt = entry.header.createdAt
1212
+ }
972
1213
  metaCache.set(id, statsById.get(id), next)
973
1214
  if (statsById.get(id)) collectDecoded(id, next)
974
1215
  meta = next
@@ -980,7 +1221,7 @@ export function apply(ctx) {
980
1221
  return {
981
1222
  titles: Object.fromEntries(authorityTitleCache),
982
1223
  trashedSessionIds: store.items.map((item) => String(item.sessionId)),
983
- purgedSessionIds: store.purgedSessionIds.map(String),
1224
+ purgedSessionIds: activeTombstones,
984
1225
  }
985
1226
  }
986
1227
 
@@ -988,31 +1229,13 @@ export function apply(ctx) {
988
1229
  // write/edit 文件 / 血统 parent/children/subagents)。live 与持久化会话都可读。
989
1230
  // 所有统计对未知事件类型容错;fetch 与 files 做上限截断,files 用 stat 过滤
990
1231
  // 磁盘上已不存在的路径,避免详情面板列出已删除文件。
991
- async function buildDetails(sid) {
1232
+ // 持久化会话走 inspectSession 分块折叠:大日志不再整本驻留内存(0.1.3-alpha
1233
+ // 已知历史会话加载性能回退,这里避免放大它)。
1234
+ async function buildDetails(sid, signal) {
992
1235
  const sessions = ctx.get('sessions')
993
1236
  const live = sessions && sessions.get(sid)
994
1237
  let meta = null
995
- let events = []
996
- if (live !== void 0) {
997
- meta = (live && live.header) || null
998
- try { events = Array.isArray(live.events) ? [...live.events] : [] } catch (e) { events = [] }
999
- } else {
1000
- const r = await persistence.readSession(sid, 0)
1001
- if (!r || !r.meta) throw new Error('找不到该会话的记录(会话不存在)')
1002
- meta = r.meta
1003
- events = Array.isArray(r.events) ? r.events : []
1004
- }
1005
- let sizeBytes = null
1006
- try {
1007
- // rc.8 的 sessionPersistence 后端没有 artifactInfo;用 locate(meta) 拿日志
1008
- // 文件真实路径后 stat 出字节数(磁盘占用)。
1009
- const loc = persistence.locate(meta)
1010
- if (loc && typeof loc.path === 'string' && loc.path) {
1011
- const st = await stat(loc.path)
1012
- if (st && typeof st.size === 'number') sizeBytes = st.size
1013
- }
1014
- } catch (e) { sizeBytes = null }
1015
- let lastTime = typeof meta && typeof meta.createdAt === 'number' ? meta.createdAt : 0
1238
+ let lastTime = 0
1016
1239
  const fileSet = new Map()
1017
1240
  const stats = {
1018
1241
  turns: 0, steps: 0, userMessages: 0, assistantMessages: 0,
@@ -1020,7 +1243,8 @@ export function apply(ctx) {
1020
1243
  }
1021
1244
  const turnSeen = new Set()
1022
1245
  const stepSeen = new Set()
1023
- for (const ev of events) {
1246
+
1247
+ const absorb = (ev) => {
1024
1248
  if (ev && typeof ev.time === 'number' && ev.time > lastTime) lastTime = ev.time
1025
1249
  const d = (ev && ev.data && typeof ev.data === 'object') ? ev.data : {}
1026
1250
  const type = ev && ev.type
@@ -1060,8 +1284,37 @@ export function apply(ctx) {
1060
1284
  }
1061
1285
  }
1062
1286
  }
1287
+
1288
+ if (live !== void 0) {
1289
+ meta = (live && live.header) || null
1290
+ try { (Array.isArray(live.events) ? [...live.events] : []).forEach(absorb) } catch (e) { /* empty */ }
1291
+ } else {
1292
+ const summary = await persistence.inspectSession(sid, { signal, onEvents: (batch) => { for (const ev of batch) absorb(ev) } })
1293
+ if (!summary || !summary.meta) throw new Error('找不到该会话的记录(会话不存在)')
1294
+ meta = summary.meta
1295
+ }
1063
1296
  stats.turns = turnSeen.size
1064
1297
  stats.steps = stepSeen.size
1298
+ let sizeBytes = null
1299
+ if (live === void 0) {
1300
+ // SessionHandle 世代:快照直接带 sizeBytes(官方 JSONL 后端廉价提供);
1301
+ // 拿不到再退回 locate + stat(legacy)。绝不伪造 0。
1302
+ try {
1303
+ const stat = await persistence.statSession(sid)
1304
+ if (stat && Number.isFinite(stat.sizeBytes)) sizeBytes = stat.sizeBytes
1305
+ } catch (e) { /* fall through */ }
1306
+ }
1307
+ if (sizeBytes === null) {
1308
+ try {
1309
+ // rc.8 的 sessionPersistence 后端没有 artifactInfo;用 locate(meta) 拿日志
1310
+ // 文件真实路径后 stat 出字节数(磁盘占用)。
1311
+ const loc = persistence.locate(meta)
1312
+ if (loc && typeof loc.path === 'string' && loc.path) {
1313
+ const st = await stat(loc.path)
1314
+ if (st && typeof st.size === 'number') sizeBytes = st.size
1315
+ }
1316
+ } catch (e) { sizeBytes = null }
1317
+ }
1065
1318
  if (stats.fetches.length > MAX_FETCHES) stats.fetches = stats.fetches.slice(0, MAX_FETCHES)
1066
1319
  const fileEntries = [...fileSet.entries()].slice(0, MAX_FILES * 2)
1067
1320
  const exists = await Promise.all(fileEntries.map(([p]) => stat(p).then(() => true).catch(() => false)))
@@ -1100,7 +1353,7 @@ export function apply(ctx) {
1100
1353
  sessionId: sid,
1101
1354
  sizeBytes,
1102
1355
  createdAt: (meta && typeof meta.createdAt === 'number') ? meta.createdAt : null,
1103
- updatedAt: lastTime || null,
1356
+ updatedAt: Math.max(lastTime || 0, (meta && typeof meta.createdAt === 'number' ? meta.createdAt : 0)) || null,
1104
1357
  files,
1105
1358
  stats,
1106
1359
  lineage,
@@ -1138,7 +1391,14 @@ export function apply(ctx) {
1138
1391
  materialized = new Set(entries.map((entry) => entry.id))
1139
1392
  } catch (e) { /* best-effort */ }
1140
1393
  const trashStore = await readTrashStore()
1141
- const hidden = new Set([...trashStore.items.map((item) => String(item.sessionId)), ...trashStore.purgedSessionIds.map(String)])
1394
+ // 墓碑只对「当前不存在」的 id 生效;同 id 会话重新出现时让位。
1395
+ const present = new Set([
1396
+ ...materialized,
1397
+ ...ids.map(String),
1398
+ ...(live && typeof live.list === 'function' ? live.list().map((s) => String(s.id)) : []),
1399
+ ])
1400
+ const tombstones = trashStore.purgedSessionIds.map(String).filter((id) => !present.has(id))
1401
+ const hidden = new Set([...trashStore.items.map((item) => String(item.sessionId)), ...tombstones])
1142
1402
  const idStrs = ids.map(String).filter((id) => !hidden.has(id) && (materialized.has(id) || (live && live.get(id))))
1143
1403
  wsByPath = {}
1144
1404
  try { for (const ent of w.list()) wsByPath[ent.path] = ent } catch (e) { wsByPath = {} }
@@ -1381,6 +1641,11 @@ export function apply(ctx) {
1381
1641
  // Human-readable Markdown export (one session). Raw-log ZIP export is
1382
1642
  // dsh's own GET /api/session.export — we deliberately do not duplicate it
1383
1643
  // (see reports/HANDOFF-dsh-sessions-manager-roadmap.md §2.4).
1644
+ //
1645
+ // 0.1.3 兼容:日志经 inspectSession 分块读取(SessionHandle.read 的
1646
+ // offset/length 有界切片),流式渲染 Markdown,客户端断开即取消(signal),
1647
+ // 并受全局并发闸约束——大日志不再一次性整本驻留内存。
1648
+ const exportLimiter = createLimiter(2)
1384
1649
  disposers.push(ctx.webServer.register({
1385
1650
  kind: 'exact',
1386
1651
  path: '/archived-sessions/export-md',
@@ -1389,13 +1654,22 @@ export function apply(ctx) {
1389
1654
  const url = new URL(req.url, 'http://localhost')
1390
1655
  const sid = url.searchParams.get('sessionId')
1391
1656
  requireSessionId(sid)
1392
- const r = await persistence.readSession(sid, 0)
1393
- if (!r || !r.meta) {
1394
- const error = new Error('无法读取该会话的日志')
1395
- error.status = 404
1396
- throw error
1397
- }
1398
- const md = renderSessionMarkdown({ ...r.meta, id: sid }, r.events || [])
1657
+ const ac = new AbortController()
1658
+ res.on('close', () => { if (!res.writableEnded) ac.abort() })
1659
+ const md = await exportLimiter(async () => {
1660
+ const builder = createSessionMarkdownBuilder({ id: sid })
1661
+ const summary = await persistence.inspectSession(sid, {
1662
+ signal: ac.signal,
1663
+ onEvents: (batch) => builder.addEvents(batch),
1664
+ })
1665
+ if (!summary || !summary.meta) {
1666
+ const error = new Error('无法读取该会话的日志')
1667
+ error.status = 404
1668
+ throw error
1669
+ }
1670
+ // meta 在流结束后才权威(header 来自 open 回包);finish 覆写 front matter。
1671
+ return builder.finish({ ...summary.meta, id: sid })
1672
+ })
1399
1673
  res.writeHead(200, {
1400
1674
  'content-type': 'text/markdown; charset=utf-8',
1401
1675
  'content-disposition': `attachment; filename="dsh-session-${sid}.md"`,
@@ -1508,7 +1782,10 @@ export function apply(ctx) {
1508
1782
  const body = await readJsonBody(req)
1509
1783
  const sid = body && typeof body.sessionId === 'string' ? body.sessionId : null
1510
1784
  if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
1511
- json(res, await buildDetails(sid))
1785
+ // 客户端断开时取消分块读取,避免为已离开的请求继续解码整本日志。
1786
+ const ac = new AbortController()
1787
+ res.on('close', () => { if (!res.writableEnded) ac.abort() })
1788
+ json(res, await buildDetails(sid, ac.signal))
1512
1789
  } catch (e) {
1513
1790
  json(res, { error: String((e && e.message) || e) }, (e && e.status) ? e.status : 500)
1514
1791
  }