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/README.en.md +12 -4
- package/README.md +12 -4
- package/lib/client.js +130 -92
- package/lib/client.js.map +2 -2
- package/lib/index.js +904 -241
- package/lib/index.js.map +4 -4
- package/package.json +1 -1
- package/src/client/index.jsx +154 -90
- package/src/client/logic.js +13 -1
- package/src/compat/capabilities.js +64 -8
- package/src/compat/persistence.js +137 -18
- package/src/handle-era-ops.js +185 -0
- package/src/handle-era-paths.js +126 -0
- package/src/index.js +373 -96
- 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/zstd-frame.js +68 -34
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 {
|
|
15
|
-
import {
|
|
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
|
-
//
|
|
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
|
|
@@ -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
|
-
//
|
|
485
|
-
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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 (
|
|
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.
|
|
837
|
-
//
|
|
1039
|
+
// 2. 变更令牌优先来自 list 快照:legacy 走 locate+stat,SessionHandle 世代
|
|
1040
|
+
// 直接用 snapshot.revision(无 locate 可用,也绝不绕私有路径补 stat)
|
|
838
1041
|
// 3. 未命中缓存的会话走**一次**批量投影(sq.readTitleSnapshots),而不是逐条
|
|
839
|
-
async function
|
|
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
|
-
|
|
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) => [
|
|
865
|
-
|
|
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) =>
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
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
|
|
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) => [
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
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
|
-
|
|
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
|
}
|