dsh-sessions-manager 3.4.2 → 3.5.1

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,17 +7,19 @@
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'
14
+ import { rewriteFrame0CwdInMemory, scanZstdFrames } from './zstd-frame.js'
15
15
  import { renderSessionMarkdown } from './markdown.js'
16
16
  import { createStarIndex } from './star-index.js'
17
17
  import { aggregateStorage } from './storage-stats.js'
18
18
  import { createAutoArchiveStore, pickInactiveCandidates } from './auto-archive.js'
19
19
  import { createSessionMetaCache, fingerprintOf } from './session-meta-cache.js'
20
20
  import { createTitleIndexStore } from './title-persist-index.js'
21
+ import { createPersistenceAdapter } from './compat/persistence.js'
22
+ import { detectCapabilities, requireCapability } from './compat/capabilities.js'
21
23
 
22
24
 
23
25
  export const name = 'dsh-sessions-manager'
@@ -122,6 +124,8 @@ function foldTitle(events) {
122
124
  export function apply(ctx) {
123
125
  const w = ctx.workspaceRegistry
124
126
  const sp = ctx.sessionPersistence
127
+ const persistence = createPersistenceAdapter(sp)
128
+ const capabilities = detectCapabilities({ persistence: sp, workspaceRegistry: w })
125
129
  const sq = ctx.sessionQuery
126
130
  const dom = () => ctx.storageDomain.get('workspace')
127
131
  const authorityTitleCache = new Map()
@@ -269,7 +273,7 @@ export function apply(ctx) {
269
273
  // 且结果同样会进缓存,下一次列表就不会再走一遍)。
270
274
  if (!meta.title || !meta.cwd) {
271
275
  try {
272
- const r = await sp.readFrom(id, 0)
276
+ const r = await persistence.readSession(id, 0)
273
277
  if (r.meta) {
274
278
  if (!meta.cwd) meta.cwd = r.meta.cwd || null
275
279
  if (!meta.createdAt) meta.createdAt = r.meta.createdAt || null
@@ -295,17 +299,19 @@ export function apply(ctx) {
295
299
  async function collectUsage(preloadedHeaders) {
296
300
  const sizeById = new Map()
297
301
  const mtimeById = new Map()
298
- let headers = null
299
- if (Array.isArray(preloadedHeaders)) headers = preloadedHeaders
300
- else { try { headers = await sp.list() } catch (e) { headers = [] } }
301
- if (!Array.isArray(headers)) headers = []
302
+ let entries = null
303
+ if (Array.isArray(preloadedHeaders)) entries = preloadedHeaders
304
+ else { try { entries = await persistence.listEntries() } catch (e) { entries = [] } }
305
+ if (!Array.isArray(entries)) entries = []
302
306
  const CHUNK = 8
303
- for (let i = 0; i < headers.length; i += CHUNK) {
304
- await Promise.all(headers.slice(i, i + CHUNK).map(async (header) => {
305
- const id = header && header.id != null ? String(header.id) : null
307
+ for (let i = 0; i < entries.length; i += CHUNK) {
308
+ await Promise.all(entries.slice(i, i + CHUNK).map(async (entry) => {
309
+ const header = entry && entry.header ? entry.header : entry
310
+ const id = entry && entry.id != null ? String(entry.id) : (header && header.id != null ? String(header.id) : null)
306
311
  if (!id) return
312
+ if (entry && Number.isFinite(entry.sizeBytes)) sizeById.set(id, Number(entry.sizeBytes))
307
313
  try {
308
- const loc = sp.locate(header)
314
+ const loc = persistence.locate(header)
309
315
  if (!loc || typeof loc.path !== 'string' || !loc.path) return
310
316
  const st = await stat(loc.path)
311
317
  if (!st) return
@@ -393,17 +399,20 @@ export function apply(ctx) {
393
399
  let cwd = null
394
400
  let title = null
395
401
  let removedPath = null
402
+ let persistenceEntry = null
396
403
  try {
397
- const headers = await sp.list()
398
- header = headers.find((h) => String(h.id) === sid) || null
404
+ const entries = await persistence.listEntries()
405
+ const found = entries.find((entry) => entry.id === sid) || null
406
+ persistenceEntry = found
407
+ header = found ? found.header : null
399
408
  if (header) {
400
- const loc = sp.locate(header)
409
+ const loc = persistence.locate(header)
401
410
  if (loc && typeof loc.path === 'string') removedPath = loc.path
402
411
  cwd = header.cwd || null
403
412
  title = header.title || (header.meta && header.meta.title) || null
404
413
  }
405
414
  if (!title) {
406
- const r = await sp.readFrom(sid, 0)
415
+ const r = await persistence.readSession(sid, 0)
407
416
  if (r && r.meta) { if (!cwd) cwd = r.meta.cwd; title = foldTitle(r.events) }
408
417
  }
409
418
  } catch (e) { /* best-effort */ }
@@ -418,7 +427,7 @@ export function apply(ctx) {
418
427
  const entry = {
419
428
  sessionId: sid, title: title || cwd || sid, cwd: cwd || null,
420
429
  header: header || null, originalPath: removedPath || null,
421
- sizeBytes: header && typeof header.size === 'number' ? header.size : null,
430
+ sizeBytes: persistenceEntry && Number.isFinite(persistenceEntry.sizeBytes) ? persistenceEntry.sizeBytes : null,
422
431
  wasArchived: archived, deletedAt: Date.now(),
423
432
  }
424
433
  const at = store.items.findIndex((t) => String(t.sessionId) === sid)
@@ -451,18 +460,24 @@ export function apply(ctx) {
451
460
  // the original workspace dir) and detach it from any workspace so DSH drops it.
452
461
  async function purgeFromTrash(sid) {
453
462
  requireSessionId(sid)
463
+ requireCapability(capabilities, 'purge')
454
464
  let purged = false
455
465
  await mutateTrash(async (store) => {
456
466
  const entry = store.items.find((t) => String(t.sessionId) === sid)
457
467
  if (!entry) { const error = new Error('回收站中找不到该会话'); error.status = 404; throw error }
458
468
  let target = null
459
469
  try {
460
- const headers = await sp.list()
461
- const current = headers.find((h) => String(h.id) === sid)
462
- const located = current && sp.locate(current)
470
+ const entries = await persistence.listEntries()
471
+ const current = entries.find((entry) => entry.id === sid)
472
+ const located = current && persistence.locate(current.header)
463
473
  if (located && typeof located.path === 'string') target = located.path
464
474
  } catch (e) {}
465
475
  if (!target && typeof entry.originalPath === 'string') target = entry.originalPath
476
+ if (!target) {
477
+ const error = new Error('无法确认该会话的物理日志位置,已停止永久删除')
478
+ error.status = 409
479
+ throw error
480
+ }
466
481
  // JSONL persistence stores logs as
467
482
  // .../<sessionId>/session.jsonl.zstd
468
483
  // Older backends may instead include the id in the filename itself.
@@ -532,6 +547,7 @@ export function apply(ctx) {
532
547
  }
533
548
 
534
549
  async function cleanupExpiredTrash() {
550
+ if (!capabilities.actions.purge.available) return 0
535
551
  const store = await readTrashStore()
536
552
  const days = store.settings.retentionDays
537
553
  if (!days) return 0
@@ -567,6 +583,7 @@ export function apply(ctx) {
567
583
  }
568
584
 
569
585
  async function moveOne(sid, targetPath) {
586
+ requireCapability(capabilities, 'move')
570
587
  // Only block the *active* conversation. ctx.sessions keeps instantiated
571
588
  // sessions alive after you switch away, so the old check (sessions.get(sid))
572
589
  // wrongly rejected every opened session — you could never move one you'd
@@ -577,7 +594,7 @@ export function apply(ctx) {
577
594
  if (activeId != null && String(activeId) === String(sid)) {
578
595
  throw new Error('该会话当前处于打开状态,请先切换到别的会话再移动。')
579
596
  }
580
- const r = await sp.readFrom(sid, 0)
597
+ const r = await persistence.readSession(sid, 0)
581
598
  if (!r || !r.meta) throw new Error('无法读取该会话的日志')
582
599
  const meta = r.meta
583
600
  const events = r.events
@@ -620,15 +637,36 @@ export function apply(ctx) {
620
637
  const newPath = locatePath(newHeaderObj)
621
638
  if (!oldPath || !newPath || oldPath === newPath) return false
622
639
  const backupPath = `${oldPath}.move-backup-${Date.now()}`
640
+ const stagedPath = `${newPath}.move-stage-${process.pid}-${Date.now()}`
641
+ let destinationInstalled = false
623
642
  try {
624
643
  // Ensure the destination project directory exists (rename does not
625
644
  // create it). Without this, the rename silently no-ops on ENOENT and
626
645
  // the log stays put while workspace.json is wrongly updated.
627
646
  await mkdir(dirname(newPath), { recursive: true })
628
- await rename(oldPath, backupPath) // current log to safety
629
- await rewriteFrame0Cwd(backupPath, canonical) // frame0 cwd -> newPath
630
- await rename(backupPath, newPath) // relocate to the new workspace dir
647
+ try {
648
+ await stat(newPath)
649
+ throw new Error('移动失败:目标位置已存在同名会话日志')
650
+ } catch (e) {
651
+ if (e && e.code !== 'ENOENT') throw e
652
+ }
653
+ await rename(oldPath, backupPath) // keep the original byte-identical until verification succeeds
654
+ const original = await readFile(backupPath)
655
+ const originalFrames = scanZstdFrames(original).frames
656
+ if (originalFrames.length === 0) throw new Error('移动前校验失败:会话日志没有完整 zstd 帧')
657
+ const rewritten = rewriteFrame0CwdInMemory(original, canonical)
658
+ const rewrittenFrames = scanZstdFrames(rewritten).frames
659
+ if (rewrittenFrames.length !== originalFrames.length) throw new Error('移动后校验失败:会话日志帧数发生变化')
660
+ const originalTail = original.subarray(originalFrames[0].end)
661
+ const rewrittenTail = rewritten.subarray(rewrittenFrames[0].end)
662
+ if (!originalTail.equals(rewrittenTail)) throw new Error('移动后校验失败:会话事件内容发生变化')
663
+ await writeFile(stagedPath, rewritten, { mode: 0o600 })
664
+ await rename(stagedPath, newPath)
665
+ destinationInstalled = true
666
+ await unlink(backupPath)
631
667
  } catch (e) {
668
+ try { await unlink(stagedPath) } catch (_) {}
669
+ if (destinationInstalled) { try { await unlink(newPath) } catch (_) {} }
632
670
  try { await rename(backupPath, oldPath) } catch (_) {}
633
671
  if (e && e.code !== 'ENOENT') throw e
634
672
  return false
@@ -661,7 +699,7 @@ export function apply(ctx) {
661
699
  // disk, and WorkspaceEntity.sessionIds filters by that exact cwd. A bare
662
700
  // rename would leave frame0 pointing at the old workspace, so reindex /
663
701
  // restart would keep attributing the session to the wrong workspace.
664
- await relocateLog(meta, newHeader)
702
+ if (!await relocateLog(meta, newHeader)) throw new Error('移动失败:无法确认会话日志已迁移到目标工作区')
665
703
  // Redirect the persistence state's cwd so future appends land in newPath.
666
704
  try {
667
705
  const st = sp.states && sp.states.get && sp.states.get(sid)
@@ -683,7 +721,7 @@ export function apply(ctx) {
683
721
 
684
722
  if (typeof sp.create !== 'function' || typeof sp.append !== 'function') {
685
723
  // No create primitive: must relocate the existing log directly.
686
- await relocateLog(meta, newHeader)
724
+ if (!await relocateLog(meta, newHeader)) throw new Error('移动失败:无法确认会话日志已迁移到目标工作区')
687
725
  } else {
688
726
  const backupPath = oldPath ? `${oldPath}.move-backup-${Date.now()}` : null
689
727
  if (backupPath) { try { await rename(oldPath, backupPath) } catch (e) { if (e && e.code !== 'ENOENT') throw new Error('移动失败:无法备份旧的会话日志') } }
@@ -691,7 +729,7 @@ export function apply(ctx) {
691
729
  try {
692
730
  await sp.create(newHeader)
693
731
  await sp.append(sid, events)
694
- const check = await sp.readFrom(sid, 0)
732
+ const check = await persistence.readSession(sid, 0)
695
733
  if (!check || !check.meta || check.meta.cwd !== canonical) {
696
734
  throw new Error('移动后校验失败:会话工作目录未正确更新')
697
735
  }
@@ -701,7 +739,7 @@ export function apply(ctx) {
701
739
  // Collision: the session is already materialized in states (archived
702
740
  // or previously opened). Fall back to physically relocating the log.
703
741
  await restore()
704
- await relocateLog(meta, newHeader)
742
+ if (!await relocateLog(meta, newHeader)) throw new Error('移动失败:无法确认会话日志已迁移到目标工作区')
705
743
  } else {
706
744
  await restore()
707
745
  throw new Error('移动会话日志失败:' + String((e && e.message) || e))
@@ -763,10 +801,10 @@ export function apply(ctx) {
763
801
  async function reindexRegistry() {
764
802
  const reg = w
765
803
  if (!reg || typeof reg.replaceHeaderIndex !== 'function') return false
766
- let headers = null
767
- try { headers = await sp.list() } catch (e) { headers = null }
768
- if (!headers || !Array.isArray(headers)) return false
769
- await reg.replaceHeaderIndex(headers)
804
+ let entries = null
805
+ try { entries = await persistence.listEntries() } catch (e) { entries = null }
806
+ if (!entries || !Array.isArray(entries)) return false
807
+ await reg.replaceHeaderIndex(entries.map((entry) => entry.header))
770
808
  if (typeof reg.rebuildEntities === 'function') reg.rebuildEntities()
771
809
  return true
772
810
  }
@@ -820,15 +858,15 @@ export function apply(ctx) {
820
858
  // 是元数据缓存能否跳过整本解码的前提,收益远大于成本
821
859
  // 3. 未命中缓存的会话走**一次**批量投影(sq.readTitleSnapshots),而不是逐条
822
860
  async function allSessionItems(opts = {}) {
823
- let headers = []
861
+ let entries = []
824
862
  let headersOk = false
825
863
  try {
826
- headers = await sp.list()
827
- headersOk = Array.isArray(headers)
828
- if (!headersOk) headers = []
829
- } catch (e) { headers = [] }
864
+ entries = await persistence.listEntries()
865
+ headersOk = Array.isArray(entries)
866
+ if (!headersOk) entries = []
867
+ } catch (e) { entries = [] }
830
868
  let live = ctx.get('sessions')
831
- const ids = headers.map((h) => String(h.id))
869
+ const ids = entries.map((entry) => entry.id)
832
870
  if (live) { try { live.list().forEach((s) => { const sid = String(s.id); if (!ids.includes(sid)) ids.push(sid) }) } catch (e) { /* ignore */ } }
833
871
  // Exclude sessions already moved to the recycle bin (软删除): they live in
834
872
  // 回收站, not in 会话管理, so the panel won't re-list them after a delete.
@@ -842,7 +880,7 @@ export function apply(ctx) {
842
880
  try { for (const ent of w.list()) wsByPath[ent.path] = ent } catch (e) { wsByPath = {} }
843
881
  const currentArchived = new Set((await archivedState().catch(() => ({ archivedSessionIds: [] }))).archivedSessionIds || [])
844
882
  const items = []
845
- const usage = await collectUsage(headers)
883
+ const usage = await collectUsage(entries)
846
884
  // 先按指纹把「缓存命中」与「需要解码」分开,只对后者做批量投影。
847
885
  const statsById = new Map(visibleIds.map((id) => [id, { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(id) }]))
848
886
  const { cached, missing } = metaCache.partition(visibleIds, statsById)
@@ -927,15 +965,15 @@ export function apply(ctx) {
927
965
  // 既不会陈旧也不会回到「每次全量解码」。
928
966
  async function sidebarAuthority() {
929
967
  const ids = []
930
- let headers = []
931
- try { headers = await sp.list() } catch (e) { headers = [] }
932
- if (!Array.isArray(headers)) headers = []
933
- for (const header of headers) ids.push(String(header.id))
968
+ let entries = []
969
+ try { entries = await persistence.listEntries() } catch (e) { entries = [] }
970
+ if (!Array.isArray(entries)) entries = []
971
+ for (const entry of entries) ids.push(entry.id)
934
972
  const sessions = ctx.get('sessions')
935
973
  try { if (sessions) sessions.list().forEach((session) => { const sid = String(session.id); if (!ids.includes(sid)) ids.push(sid) }) } catch (e) {}
936
974
  const store = await readTrashStore()
937
975
  if (ids.length) {
938
- const usage = await collectUsage(headers)
976
+ const usage = await collectUsage(entries)
939
977
  const statsById = new Map(ids.map((id) => [id, { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(id) }]))
940
978
  const { cached, missing } = metaCache.partition(ids, statsById)
941
979
  // P4:与列表构建共用持久标题索引,冷启动零解码。
@@ -980,7 +1018,7 @@ export function apply(ctx) {
980
1018
  meta = (live && live.header) || null
981
1019
  try { events = Array.isArray(live.events) ? [...live.events] : [] } catch (e) { events = [] }
982
1020
  } else {
983
- const r = await sp.readFrom(sid, 0)
1021
+ const r = await persistence.readSession(sid, 0)
984
1022
  if (!r || !r.meta) throw new Error('找不到该会话的记录(会话不存在)')
985
1023
  meta = r.meta
986
1024
  events = Array.isArray(r.events) ? r.events : []
@@ -989,7 +1027,7 @@ export function apply(ctx) {
989
1027
  try {
990
1028
  // rc.8 的 sessionPersistence 后端没有 artifactInfo;用 locate(meta) 拿日志
991
1029
  // 文件真实路径后 stat 出字节数(磁盘占用)。
992
- const loc = sp.locate(meta)
1030
+ const loc = persistence.locate(meta)
993
1031
  if (loc && typeof loc.path === 'string' && loc.path) {
994
1032
  const st = await stat(loc.path)
995
1033
  if (st && typeof st.size === 'number') sizeBytes = st.size
@@ -1062,7 +1100,8 @@ export function apply(ctx) {
1062
1100
  const subagentSet = new Set()
1063
1101
  try {
1064
1102
  if (typeof sp.list === 'function') {
1065
- for (const h of await sp.list()) {
1103
+ for (const entry of await persistence.listEntries()) {
1104
+ const h = entry.header
1066
1105
  if (String(h.parentSession) !== String(sid)) continue
1067
1106
  if (h.origin === 'subagent') subagentSet.add(h.id); else childrenSet.add(h.id)
1068
1107
  }
@@ -1098,6 +1137,12 @@ export function apply(ctx) {
1098
1137
  }
1099
1138
  }))
1100
1139
 
1140
+ disposers.push(ctx.webServer.register({
1141
+ kind: 'exact',
1142
+ path: '/archived-sessions/capabilities',
1143
+ handler: async (req, res) => json(res, capabilities),
1144
+ }))
1145
+
1101
1146
  disposers.push(ctx.webServer.register({
1102
1147
  kind: 'exact',
1103
1148
  path: '/archived-sessions/list',
@@ -1110,8 +1155,8 @@ export function apply(ctx) {
1110
1155
  let materialized = new Set()
1111
1156
  let live = ctx.get('sessions')
1112
1157
  try {
1113
- const headers = await sp.list()
1114
- materialized = new Set(headers.map((h) => String(h.id)))
1158
+ const entries = await persistence.listEntries()
1159
+ materialized = new Set(entries.map((entry) => entry.id))
1115
1160
  } catch (e) { /* best-effort */ }
1116
1161
  const trashStore = await readTrashStore()
1117
1162
  const hidden = new Set([...trashStore.items.map((item) => String(item.sessionId)), ...trashStore.purgedSessionIds.map(String)])
@@ -1157,11 +1202,11 @@ export function apply(ctx) {
1157
1202
  const results = []
1158
1203
  for (const sid of ids) {
1159
1204
  try { results.push({ sessionId: sid, ok: true, ...(await restoreOne(sid)) }) }
1160
- catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
1205
+ catch (e) { results.push({ sessionId: sid, ok: false, code: e && e.code, error: String((e && e.message) || e) }) }
1161
1206
  }
1162
1207
  json(res, { ok: true, restored: results.filter((r) => r.ok).length, results })
1163
1208
  } catch (e) {
1164
- json(res, { ok: false, error: String((e && e.message) || e) }, 500)
1209
+ json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
1165
1210
  }
1166
1211
  },
1167
1212
  }))
@@ -1200,7 +1245,7 @@ export function apply(ctx) {
1200
1245
  }
1201
1246
  json(res, { ok: true, deleted: results.filter((r) => r.ok).length, results })
1202
1247
  } catch (e) {
1203
- json(res, { ok: false, error: String((e && e.message) || e) }, 500)
1248
+ json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
1204
1249
  }
1205
1250
  },
1206
1251
  }))
@@ -1245,11 +1290,19 @@ export function apply(ctx) {
1245
1290
  try {
1246
1291
  const items = await readTrash()
1247
1292
  const results = await Promise.all(items.map(async (item) => {
1248
- let exists = false
1249
- if (typeof item.originalPath === 'string') exists = await stat(item.originalPath).then(() => true).catch(() => false)
1250
- return { sessionId: item.sessionId, status: exists ? 'ok' : 'missing', originalPath: item.originalPath || null }
1293
+ if (typeof item.originalPath !== 'string' || !item.originalPath) {
1294
+ return { sessionId: item.sessionId, status: 'unverified', originalPath: null }
1295
+ }
1296
+ const exists = await stat(item.originalPath).then(() => true).catch(() => false)
1297
+ return { sessionId: item.sessionId, status: exists ? 'ok' : 'missing', originalPath: item.originalPath }
1251
1298
  }))
1252
- json(res, { ok: true, healthy: results.filter((r) => r.status === 'ok').length, missing: results.filter((r) => r.status === 'missing').length, results })
1299
+ json(res, {
1300
+ ok: true,
1301
+ healthy: results.filter((r) => r.status === 'ok').length,
1302
+ missing: results.filter((r) => r.status === 'missing').length,
1303
+ unverified: results.filter((r) => r.status === 'unverified').length,
1304
+ results,
1305
+ })
1253
1306
  } catch (e) {
1254
1307
  json(res, { ok: false, error: String((e && e.message) || e) }, errorStatus(e))
1255
1308
  }
@@ -1268,7 +1321,7 @@ export function apply(ctx) {
1268
1321
  metaCache.invalidate(sid)
1269
1322
  json(res, out)
1270
1323
  } catch (e) {
1271
- json(res, { ok: false, error: String((e && e.message) || e) }, 500)
1324
+ json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
1272
1325
  }
1273
1326
  },
1274
1327
  }))
@@ -1287,7 +1340,7 @@ export function apply(ctx) {
1287
1340
  titleIndex.remove([sid]).catch(() => {})
1288
1341
  json(res, out)
1289
1342
  } catch (e) {
1290
- json(res, { ok: false, error: String((e && e.message) || e) }, 500)
1343
+ json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
1291
1344
  }
1292
1345
  },
1293
1346
  }))
@@ -1307,7 +1360,7 @@ export function apply(ctx) {
1307
1360
  }
1308
1361
  json(res, { ok: true, purged: results.filter((r) => r.ok).length, results })
1309
1362
  } catch (e) {
1310
- json(res, { ok: false, error: String((e && e.message) || e) }, 500)
1363
+ json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
1311
1364
  }
1312
1365
  },
1313
1366
  }))
@@ -1357,7 +1410,7 @@ export function apply(ctx) {
1357
1410
  const url = new URL(req.url, 'http://localhost')
1358
1411
  const sid = url.searchParams.get('sessionId')
1359
1412
  requireSessionId(sid)
1360
- const r = await sp.readFrom(sid, 0)
1413
+ const r = await persistence.readSession(sid, 0)
1361
1414
  if (!r || !r.meta) {
1362
1415
  const error = new Error('无法读取该会话的日志')
1363
1416
  error.status = 404
@@ -1424,7 +1477,7 @@ export function apply(ctx) {
1424
1477
  try { await reindexRegistry() } catch (e) { /* best-effort */ }
1425
1478
  json(res, { sessionId: sid, ...moved })
1426
1479
  } catch (e) {
1427
- json(res, { ok: false, error: String((e && e.message) || e) }, 500)
1480
+ json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
1428
1481
  }
1429
1482
  },
1430
1483
  }))
package/src/zstd-frame.js CHANGED
@@ -20,33 +20,71 @@ export const ZSTD_MAGIC = 0xFD2FB528
20
20
  const CHECKSUM_OPTS = { params: { [zlib.constants.ZSTD_c_checksumFlag]: 1 } }
21
21
 
22
22
  /**
23
- * Locate real zstd frame boundaries in a concatenated-frame buffer.
24
- *
25
- * Scanning for the 4-byte magic alone produces FALSE POSITIVES: the same byte
26
- * sequence can occur inside compressed data. Every candidate is therefore
27
- * validated by attempting decompression; only offsets that decode are kept.
23
+ * Parse complete concatenated Zstandard frames without decompressing them.
24
+ * Invalid complete structure rejects; EOF inside the final frame is reported
25
+ * as torn rather than guessed from magic bytes occurring in compressed data.
28
26
  *
29
27
  * @param {Buffer} buf
30
- * @returns {number[]} ascending offsets of real frame starts
28
+ * @param {number} maxFrames
29
+ * @returns {{frames: Array<{start:number,end:number}>, tornStart?: number}}
31
30
  */
32
- export function findZstdFrameStarts(buf) {
33
- const starts = []
34
- for (let i = 0; i + 4 <= buf.length; i++) {
35
- if (buf.readUInt32LE(i) !== ZSTD_MAGIC) continue
36
- try {
37
- // Two checks are needed, not just one:
38
- // - a magic inside compressed data fails to decode and throws
39
- // - a BARE 4-byte magic at the very end of the buffer decodes to an
40
- // EMPTY result without throwing, so non-empty output is required too
41
- // Every real frame carries at least one JSON line, so neither case can
42
- // be a genuine frame start.
43
- const out = zlib.zstdDecompressSync(buf.subarray(i, i + Math.min(buf.length - i, 1000000)))
44
- if (out.length > 0) starts.push(i)
45
- } catch (_) {
46
- // Not a real frame boundary — the magic bytes occurred inside compressed data.
31
+ export function scanZstdFrames(buf, maxFrames = Number.POSITIVE_INFINITY) {
32
+ const frames = []
33
+ let offset = 0
34
+ while (offset < buf.length) {
35
+ const start = offset
36
+ if (buf.length - offset < 4) return { frames, tornStart: start }
37
+ if (buf.readUInt32LE(offset) !== ZSTD_MAGIC) {
38
+ throw new Error(`会话日志格式异常(字节 ${offset} 的 zstd magic 无效)`)
39
+ }
40
+ offset += 4
41
+ if (offset === buf.length) return { frames, tornStart: start }
42
+
43
+ const descriptor = buf.readUInt8(offset++)
44
+ if ((descriptor & 0x18) !== 0) throw new Error(`会话日志格式异常(字节 ${offset - 1} 使用保留帧头位)`)
45
+ const contentSizeFlag = descriptor >>> 6
46
+ const singleSegment = (descriptor & 0x20) !== 0
47
+ const checksum = (descriptor & 0x04) !== 0
48
+ const dictionaryFlag = descriptor & 0x03
49
+ const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag
50
+ const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag
51
+ const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes
52
+ if (buf.length - offset < remainingHeaderBytes) return { frames, tornStart: start }
53
+ offset += remainingHeaderBytes
54
+
55
+ for (;;) {
56
+ if (buf.length - offset < 3) return { frames, tornStart: start }
57
+ const blockHeader = buf.readUIntLE(offset, 3)
58
+ offset += 3
59
+ const lastBlock = (blockHeader & 1) !== 0
60
+ const blockType = (blockHeader >>> 1) & 0x03
61
+ const blockSize = blockHeader >>> 3
62
+ if (blockType === 0x03) throw new Error(`会话日志格式异常(字节 ${offset - 3} 使用保留块类型)`)
63
+ const payloadBytes = blockType === 0x01 ? 1 : blockSize
64
+ if (buf.length - offset < payloadBytes) return { frames, tornStart: start }
65
+ offset += payloadBytes
66
+ if (lastBlock) break
67
+ }
68
+ if (checksum) {
69
+ if (buf.length - offset < 4) return { frames, tornStart: start }
70
+ offset += 4
47
71
  }
72
+ frames.push({ start, end: offset })
73
+ if (frames.length === maxFrames) return { frames }
48
74
  }
49
- return starts
75
+ return { frames }
76
+ }
77
+
78
+ /** Backward-compatible frame-start view used by diagnostics and tests. */
79
+ export function findZstdFrameStarts(buf) {
80
+ return scanZstdFrames(buf).frames.map((frame) => frame.start)
81
+ }
82
+
83
+ function firstFrame(buf) {
84
+ const scan = scanZstdFrames(buf, 1)
85
+ const frame = scan.frames[0]
86
+ if (!frame) throw new Error('会话日志格式异常(无完整 zstd 帧)')
87
+ return frame
50
88
  }
51
89
 
52
90
  /**
@@ -64,10 +102,9 @@ export function findZstdFrameStarts(buf) {
64
102
  */
65
103
  export function rewriteFrame0Cwd(filePath, newCwd) {
66
104
  const buf = readFileSync(filePath)
67
- const starts = findZstdFrameStarts(buf)
68
- if (starts.length === 0) throw new Error('会话日志格式异常(无 zstd 帧)')
69
- const end0 = starts.length > 1 ? starts[1] : buf.length
70
- const frame0 = buf.subarray(starts[0], end0)
105
+ const frame = firstFrame(buf)
106
+ const end0 = frame.end
107
+ const frame0 = buf.subarray(frame.start, end0)
71
108
  const text = zlib.zstdDecompressSync(frame0).toString('utf8')
72
109
  const nl = text.indexOf('\n')
73
110
  const line = nl >= 0 ? text.slice(0, nl) : text
@@ -91,10 +128,9 @@ export function rewriteFrame0Cwd(filePath, newCwd) {
91
128
  * @returns {Buffer} rewritten log
92
129
  */
93
130
  export function rewriteFrame0CwdInMemory(buf, newCwd) {
94
- const starts = findZstdFrameStarts(buf)
95
- if (starts.length === 0) throw new Error('会话日志格式异常(无 zstd 帧)')
96
- const end0 = starts.length > 1 ? starts[1] : buf.length
97
- const frame0 = buf.subarray(starts[0], end0)
131
+ const frame = firstFrame(buf)
132
+ const end0 = frame.end
133
+ const frame0 = buf.subarray(frame.start, end0)
98
134
  const text = zlib.zstdDecompressSync(frame0).toString('utf8')
99
135
  const nl = text.indexOf('\n')
100
136
  const line = nl >= 0 ? text.slice(0, nl) : text
@@ -128,10 +164,8 @@ export function buildSessionLog(header, events = []) {
128
164
  * @returns {{obj: object, lineCount: number}}
129
165
  */
130
166
  export function readFrame0(buf) {
131
- const starts = findZstdFrameStarts(buf)
132
- if (starts.length === 0) throw new Error('会话日志格式异常(无 zstd 帧)')
133
- const end0 = starts.length > 1 ? starts[1] : buf.length
134
- const text = zlib.zstdDecompressSync(buf.subarray(starts[0], end0)).toString('utf8')
167
+ const frame = firstFrame(buf)
168
+ const text = zlib.zstdDecompressSync(buf.subarray(frame.start, frame.end)).toString('utf8')
135
169
  const lines = text.split('\n').filter((l) => l.length > 0)
136
170
  return { obj: JSON.parse(lines[0]), lineCount: lines.length }
137
171
  }