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/README.en.md +6 -2
- package/README.md +6 -2
- package/lib/client.js +78 -21
- package/lib/client.js.map +2 -2
- package/lib/index.js +272 -85
- package/lib/index.js.map +4 -4
- package/package.json +1 -1
- package/src/client/index.jsx +58 -14
- package/src/client/logic.js +8 -0
- package/src/compat/capabilities.js +34 -0
- package/src/compat/persistence.js +72 -0
- package/src/index.js +113 -60
- package/src/zstd-frame.js +68 -34
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 {
|
|
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
|
|
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
|
|
299
|
-
if (Array.isArray(preloadedHeaders))
|
|
300
|
-
else { try {
|
|
301
|
-
if (!Array.isArray(
|
|
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 <
|
|
304
|
-
await Promise.all(
|
|
305
|
-
const
|
|
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 =
|
|
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
|
|
398
|
-
|
|
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 =
|
|
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
|
|
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:
|
|
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
|
|
461
|
-
const current =
|
|
462
|
-
const located = 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
|
|
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
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
|
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
|
|
767
|
-
try {
|
|
768
|
-
if (!
|
|
769
|
-
await reg.replaceHeaderIndex(
|
|
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
|
|
861
|
+
let entries = []
|
|
824
862
|
let headersOk = false
|
|
825
863
|
try {
|
|
826
|
-
|
|
827
|
-
headersOk = Array.isArray(
|
|
828
|
-
if (!headersOk)
|
|
829
|
-
} catch (e) {
|
|
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 =
|
|
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(
|
|
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
|
|
931
|
-
try {
|
|
932
|
-
if (!Array.isArray(
|
|
933
|
-
for (const
|
|
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(
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
1114
|
-
materialized = new Set(
|
|
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) },
|
|
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) },
|
|
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
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
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, {
|
|
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) },
|
|
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) },
|
|
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) },
|
|
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
|
|
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) },
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
* @
|
|
28
|
+
* @param {number} maxFrames
|
|
29
|
+
* @returns {{frames: Array<{start:number,end:number}>, tornStart?: number}}
|
|
31
30
|
*/
|
|
32
|
-
export function
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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
|
|
68
|
-
|
|
69
|
-
const
|
|
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
|
|
95
|
-
|
|
96
|
-
const
|
|
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
|
|
132
|
-
|
|
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
|
}
|