dsh-sessions-manager 3.4.2 → 3.5.0
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 -0
- package/README.md +6 -0
- package/lib/client.js +60 -17
- package/lib/client.js.map +2 -2
- package/lib/index.js +182 -58
- package/lib/index.js.map +4 -4
- package/package.json +1 -1
- package/src/client/index.jsx +42 -7
- package/src/compat/capabilities.js +34 -0
- package/src/compat/persistence.js +72 -0
- package/src/index.js +87 -55
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-sessions-manager",
|
|
3
3
|
"description": "DSH 设置面板会话管理器:归档 / 恢复 / 彻底删除 / 移动到其他工作区,带工作区标签与会话日期;统一「会话管理」面板。Session manager for the DeepSeek Harness settings panel — archive / restore / permanently delete / move sessions across workspaces, with workspace tags & session dates in one unified panel.",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.5.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
package/src/client/index.jsx
CHANGED
|
@@ -219,6 +219,7 @@ function installSettingsNavIcons(ctx) {
|
|
|
219
219
|
function SessionPanel({ workspacesSvc }) {
|
|
220
220
|
const [sessions, setSessions] = useState(null)
|
|
221
221
|
const [workspaces, setWorkspaces] = useState([])
|
|
222
|
+
const [capabilities, setCapabilities] = useState(null)
|
|
222
223
|
const initialPrefs = useRef(loadPanelPrefs()).current
|
|
223
224
|
// 注意:'storage' 已从可持久化取值中移除(它不再是视图),旧版残留的
|
|
224
225
|
// filter='storage' 会自动回落到 'all',避免落到一个已不存在的界面。
|
|
@@ -276,14 +277,16 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
276
277
|
Promise.all([
|
|
277
278
|
postJSON('/archived-sessions/sessions', {}),
|
|
278
279
|
postJSON('/archived-sessions/workspaces', {}),
|
|
280
|
+
postJSON('/archived-sessions/capabilities', {}),
|
|
279
281
|
])
|
|
280
|
-
.then(([s, works]) => {
|
|
282
|
+
.then(([s, works, caps]) => {
|
|
281
283
|
// Permanently-purged sessions must never re-appear here, even if DSH's
|
|
282
284
|
// in-memory session index still lists them after the file was unlinked.
|
|
283
285
|
const purged = dsmLoadPurged()
|
|
284
286
|
const visible = (s.items || []).filter((x) => !purged.has(String(x.sessionId)))
|
|
285
287
|
setSessions(visible)
|
|
286
288
|
setWorkspaces(works.items || [])
|
|
289
|
+
setCapabilities(caps || null)
|
|
287
290
|
setSelected({})
|
|
288
291
|
setDelTarget(null)
|
|
289
292
|
setConfirmBatch(false)
|
|
@@ -393,6 +396,9 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
393
396
|
const w = workspaces.find((x) => x.workspaceId === id)
|
|
394
397
|
return w ? w.path : ''
|
|
395
398
|
}
|
|
399
|
+
const actionCapability = (name) => (capabilities && capabilities.actions && capabilities.actions[name]) || { available: false, reason: '正在检查当前 DSH 的兼容能力…' }
|
|
400
|
+
const canPurge = actionCapability('purge')
|
|
401
|
+
const canMove = actionCapability('move')
|
|
396
402
|
|
|
397
403
|
const archivedList = sessions ? sessions.filter((x) => x.archived) : []
|
|
398
404
|
const activeList = sessions ? sessions.filter((x) => !x.archived) : []
|
|
@@ -473,7 +479,12 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
473
479
|
const verifyTrash = () => {
|
|
474
480
|
setTrashBusy('__verify')
|
|
475
481
|
postJSON('/archived-sessions/trash/verify', {})
|
|
476
|
-
.then((r) => {
|
|
482
|
+
.then((r) => {
|
|
483
|
+
setTrashBusy(null); setTrashCheck(r)
|
|
484
|
+
if (r.missing) showToast(`发现 ${r.missing} 条日志缺失`)
|
|
485
|
+
else if (r.unverified) showToast(`${r.unverified} 条日志位置由当前 Runtime 管理,无法直接核验`)
|
|
486
|
+
else showToast('回收站校验通过')
|
|
487
|
+
})
|
|
477
488
|
.catch((e) => { setTrashBusy(null); setError(String((e && e.message) || e)) })
|
|
478
489
|
}
|
|
479
490
|
|
|
@@ -554,6 +565,7 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
554
565
|
}
|
|
555
566
|
|
|
556
567
|
const doMove = (it) => {
|
|
568
|
+
if (!canMove.available) { setError(canMove.reason || '当前环境不支持跨工作区移动'); return }
|
|
557
569
|
const targetPath = moveMode === 'new' ? newPath.trim() : wsPath(targetWs)
|
|
558
570
|
if (!targetPath) { setError('请选择已有工作区或输入新的目标目录路径'); return }
|
|
559
571
|
setBusy(it.sessionId)
|
|
@@ -591,6 +603,7 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
591
603
|
}
|
|
592
604
|
|
|
593
605
|
const openMoveFor = (it) => {
|
|
606
|
+
if (!canMove.available) { setError(canMove.reason || '当前环境不支持跨工作区移动'); return }
|
|
594
607
|
if (openMove === it.sessionId) { setOpenMove(null); return }
|
|
595
608
|
setTargetWs(workspaces.length ? (targetWs || workspaces[0].workspaceId) : '')
|
|
596
609
|
setMoveMode('existing')
|
|
@@ -663,15 +676,20 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
663
676
|
>⋯</button>
|
|
664
677
|
{openMenu === it.sessionId && (
|
|
665
678
|
<div className="more-menu" role="menu">
|
|
666
|
-
{items.map(([id, label]) =>
|
|
679
|
+
{items.map(([id, label]) => {
|
|
680
|
+
const blocked = id === 'move' && !canMove.available
|
|
681
|
+
return (
|
|
667
682
|
<button
|
|
668
683
|
key={id}
|
|
669
684
|
type="button"
|
|
670
685
|
role="menuitem"
|
|
671
686
|
className={'more-item' + (id === 'delete' ? ' more-item-danger' : '')}
|
|
687
|
+
disabled={blocked}
|
|
688
|
+
title={blocked ? canMove.reason : undefined}
|
|
672
689
|
onClick={() => runMenu(id, it)}
|
|
673
690
|
>{label}</button>
|
|
674
|
-
|
|
691
|
+
)
|
|
692
|
+
})}
|
|
675
693
|
</div>
|
|
676
694
|
)}
|
|
677
695
|
</div>
|
|
@@ -1034,9 +1052,10 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
1034
1052
|
</select>
|
|
1035
1053
|
</div>
|
|
1036
1054
|
<div className="sess-field"><label>数据检查</label><button type="button" className="archv-btn" disabled={trashBusy !== null} onClick={verifyTrash}>{trashBusy === '__verify' ? '校验中…' : '校验日志完整性'}</button></div>
|
|
1037
|
-
<div className="sess-field"><label>永久清理</label><button type="button" className="archv-btn archv-del" disabled={trashBusy !== null || !trash.length} onClick={() => setPurgeTarget('__all')}>清空回收站</button></div>
|
|
1055
|
+
<div className="sess-field"><label>永久清理</label><button type="button" className="archv-btn archv-del" disabled={trashBusy !== null || !trash.length || !canPurge.available} title={!canPurge.available ? canPurge.reason : undefined} onClick={() => setPurgeTarget('__all')}>清空回收站</button></div>
|
|
1038
1056
|
</div>
|
|
1039
|
-
{
|
|
1057
|
+
{!canPurge.available && <div className="archv-empty" role="status">{canPurge.reason}</div>}
|
|
1058
|
+
{trashCheck && <div className={trashCheck.missing ? 'archv-err' : 'archv-empty'} role="status">校验完成:{trashCheck.healthy} 条正常,{trashCheck.missing} 条日志缺失,{trashCheck.unverified || 0} 条无法直接核验。</div>}
|
|
1040
1059
|
{trash.length === 0 ? (
|
|
1041
1060
|
<div className="dsm-trash-empty">回收站为空。删除的会话会先进入这里,可恢复或彻底删除。</div>
|
|
1042
1061
|
) : (
|
|
@@ -1047,7 +1066,7 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
1047
1066
|
<span className="dsm-trash-date">{fmtDate(t.deletedAt)}</span>
|
|
1048
1067
|
<span className="dsm-trash-actions">
|
|
1049
1068
|
<button type="button" className="archv-btn" disabled={trashBusy !== null} onClick={() => restoreTrash(t.sessionId)}>恢复</button>
|
|
1050
|
-
<button type="button" className="archv-btn archv-del" disabled={trashBusy !== null} onClick={() => setPurgeTarget(t.sessionId)}>彻底删除</button>
|
|
1069
|
+
<button type="button" className="archv-btn archv-del" disabled={trashBusy !== null || !canPurge.available} title={!canPurge.available ? canPurge.reason : undefined} onClick={() => setPurgeTarget(t.sessionId)}>彻底删除</button>
|
|
1051
1070
|
</span>
|
|
1052
1071
|
</div>
|
|
1053
1072
|
))}
|
|
@@ -1169,6 +1188,14 @@ let dsmTrashIds = null
|
|
|
1169
1188
|
let dsmServerPurgedIds = new Set()
|
|
1170
1189
|
let dsmAuthoritativeTitles = new Map()
|
|
1171
1190
|
let dsmTrashTick = 0
|
|
1191
|
+
let dsmCapabilities = null
|
|
1192
|
+
async function dsmLoadCapabilities() {
|
|
1193
|
+
try { dsmCapabilities = await postJSON('/archived-sessions/capabilities', {}) } catch (e) { /* unknown stays safely unavailable */ }
|
|
1194
|
+
return dsmCapabilities
|
|
1195
|
+
}
|
|
1196
|
+
function dsmActionCapability(name) {
|
|
1197
|
+
return dsmCapabilities && dsmCapabilities.actions && dsmCapabilities.actions[name]
|
|
1198
|
+
}
|
|
1172
1199
|
async function dsmLoadTrashIds() {
|
|
1173
1200
|
try {
|
|
1174
1201
|
const r = await postJSON('/archived-sessions/sidebar-state', {})
|
|
@@ -1385,6 +1412,11 @@ function installSidebarSessionMenuAug() {
|
|
|
1385
1412
|
const move = mk('移动会话', ICON_MOVE, false)
|
|
1386
1413
|
const del = mk('删除会话', ICON_DEL, true)
|
|
1387
1414
|
const mark = mk(dsmLoadManual().has(info.id) ? '标记已读' : '标记未读', ICON_UNREAD, false)
|
|
1415
|
+
const moveCapability = dsmActionCapability('move')
|
|
1416
|
+
if (!moveCapability || !moveCapability.available) {
|
|
1417
|
+
move.btn.disabled = true
|
|
1418
|
+
move.btn.title = (moveCapability && moveCapability.reason) || '正在检查当前版本的移动能力'
|
|
1419
|
+
}
|
|
1388
1420
|
if (mark.btn.firstChild) mark.btn.firstChild.style.color = 'var(--dsw-alias-state-business-primary)'
|
|
1389
1421
|
const chev = document.createElement('span')
|
|
1390
1422
|
chev.style.cssText = 'margin-left:auto;flex:none;color:var(--dsw-alias-label-tertiary);font-size:14px;line-height:1'
|
|
@@ -1647,6 +1679,8 @@ function installSidebarWorkspaceDrag() {
|
|
|
1647
1679
|
}, true)
|
|
1648
1680
|
|
|
1649
1681
|
document.addEventListener('dragstart', (event) => {
|
|
1682
|
+
const moveCapability = dsmActionCapability('move')
|
|
1683
|
+
if (!moveCapability || !moveCapability.available) return
|
|
1650
1684
|
const row = eventRow(event)
|
|
1651
1685
|
const item = row && sessionForRow(row)
|
|
1652
1686
|
if (!item || moving) return
|
|
@@ -1729,6 +1763,7 @@ function installSidebarWorkspaceDrag() {
|
|
|
1729
1763
|
}
|
|
1730
1764
|
|
|
1731
1765
|
export function apply(ctx) {
|
|
1766
|
+
dsmLoadCapabilities()
|
|
1732
1767
|
installSettingsNavIcons(ctx)
|
|
1733
1768
|
installSidebarSessionMenuAug()
|
|
1734
1769
|
installSidebarStatusDots()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
function action(available, reason = null) {
|
|
2
|
+
return { available: !!available, reason: available ? null : reason }
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function detectCapabilities({ persistence, workspaceRegistry }) {
|
|
6
|
+
const handleApi = !!(persistence && typeof persistence.open === 'function')
|
|
7
|
+
const legacyRead = !!(persistence && typeof persistence.readFrom === 'function')
|
|
8
|
+
const legacyLocate = !!(persistence && (typeof persistence.locate === 'function'
|
|
9
|
+
|| (persistence.backend && typeof persistence.backend.locate === 'function')))
|
|
10
|
+
const workspaceInternals = !!(workspaceRegistry
|
|
11
|
+
&& workspaceRegistry.headers && workspaceRegistry.sessionPaths
|
|
12
|
+
&& typeof workspaceRegistry.replaceHeaderIndex === 'function')
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
persistence: handleApi ? 'session-handle' : 'legacy',
|
|
16
|
+
actions: {
|
|
17
|
+
read: action(legacyRead || handleApi, '当前 DSH 未提供可识别的会话读取接口'),
|
|
18
|
+
archive: action(!!(workspaceRegistry && typeof workspaceRegistry.archiveSession === 'function'), '当前 DSH 未提供归档接口'),
|
|
19
|
+
trash: action(legacyRead || handleApi, '当前 DSH 无法读取会话,不能安全移入回收站'),
|
|
20
|
+
restoreTrash: action(true),
|
|
21
|
+
purge: action(!handleApi && legacyLocate, '当前 DSH 版本尚未提供经过验证的安全永久删除能力'),
|
|
22
|
+
move: action(!handleApi && legacyRead && legacyLocate && workspaceInternals, '当前 DSH 版本尚未提供经过验证的跨工作区迁移能力'),
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function requireCapability(capabilities, name) {
|
|
28
|
+
const value = capabilities && capabilities.actions && capabilities.actions[name]
|
|
29
|
+
if (value && value.available) return
|
|
30
|
+
const error = new Error((value && value.reason) || `当前环境不支持 ${name}`)
|
|
31
|
+
error.status = 409
|
|
32
|
+
error.code = 'DSM_CAPABILITY_UNAVAILABLE'
|
|
33
|
+
throw error
|
|
34
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Compatibility boundary for the two DSH persistence generations supported by
|
|
2
|
+
// dsh-sessions-manager. Business code consumes normalized headers and complete
|
|
3
|
+
// inspections; it never needs to know whether DSH returned a legacy header or
|
|
4
|
+
// a handle-era SessionPersistenceSnapshot.
|
|
5
|
+
|
|
6
|
+
function asHeader(value) {
|
|
7
|
+
if (!value || typeof value !== 'object') return null
|
|
8
|
+
const candidate = value.header && typeof value.header === 'object' ? value.header : value
|
|
9
|
+
return candidate.id == null ? null : candidate
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function normalizePersistenceEntry(value) {
|
|
13
|
+
const header = asHeader(value)
|
|
14
|
+
if (!header) return null
|
|
15
|
+
const snapshot = value && value.header === header ? value : null
|
|
16
|
+
return {
|
|
17
|
+
header,
|
|
18
|
+
snapshot,
|
|
19
|
+
id: String(header.id),
|
|
20
|
+
sizeBytes: snapshot && Number.isFinite(snapshot.sizeBytes) ? Number(snapshot.sizeBytes) : null,
|
|
21
|
+
eventCount: snapshot && Number.isSafeInteger(snapshot.eventCount) ? snapshot.eventCount : null,
|
|
22
|
+
revision: snapshot ? snapshot.revision : null,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function normalizePersistenceList(values) {
|
|
27
|
+
if (!Array.isArray(values)) return []
|
|
28
|
+
return values.map(normalizePersistenceEntry).filter(Boolean)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createPersistenceAdapter(service) {
|
|
32
|
+
if (!service || typeof service.list !== 'function') throw new TypeError('sessionPersistence.list is required')
|
|
33
|
+
|
|
34
|
+
async function listEntries(options) {
|
|
35
|
+
return normalizePersistenceList(await service.list(options))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function readSession(id, offset = 0) {
|
|
39
|
+
if (typeof service.readFrom === 'function') {
|
|
40
|
+
const result = await service.readFrom(id, offset)
|
|
41
|
+
return {
|
|
42
|
+
meta: result && result.meta ? result.meta : null,
|
|
43
|
+
inheritedEventCount: result && Number.isSafeInteger(result.inheritedEventCount) ? result.inheritedEventCount : 0,
|
|
44
|
+
events: result && Array.isArray(result.events) ? result.events : [],
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (typeof service.open !== 'function') throw new Error('当前 DSH 持久化服务不支持读取会话')
|
|
48
|
+
const handle = await service.open(id, 'read')
|
|
49
|
+
if (!handle || typeof handle.read !== 'function' || typeof handle.close !== 'function') {
|
|
50
|
+
try { if (handle && typeof handle.close === 'function') await handle.close() } catch {}
|
|
51
|
+
throw new Error('DSH 返回了无效的 SessionHandle')
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const events = await handle.read(offset)
|
|
55
|
+
return {
|
|
56
|
+
meta: handle.header || handle.meta || null,
|
|
57
|
+
inheritedEventCount: Number.isSafeInteger(handle.inheritedEventCount) ? handle.inheritedEventCount : 0,
|
|
58
|
+
events: Array.isArray(events) ? events : [...(events || [])],
|
|
59
|
+
}
|
|
60
|
+
} finally {
|
|
61
|
+
await handle.close()
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function locate(header) {
|
|
66
|
+
if (typeof service.locate === 'function') return service.locate(header)
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const kind = typeof service.open === 'function' ? 'session-handle' : 'legacy'
|
|
71
|
+
return { kind, listEntries, readSession, locate }
|
|
72
|
+
}
|
package/src/index.js
CHANGED
|
@@ -18,6 +18,8 @@ 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
|
|
@@ -661,7 +678,7 @@ export function apply(ctx) {
|
|
|
661
678
|
// disk, and WorkspaceEntity.sessionIds filters by that exact cwd. A bare
|
|
662
679
|
// rename would leave frame0 pointing at the old workspace, so reindex /
|
|
663
680
|
// restart would keep attributing the session to the wrong workspace.
|
|
664
|
-
await relocateLog(meta, newHeader)
|
|
681
|
+
if (!await relocateLog(meta, newHeader)) throw new Error('移动失败:无法确认会话日志已迁移到目标工作区')
|
|
665
682
|
// Redirect the persistence state's cwd so future appends land in newPath.
|
|
666
683
|
try {
|
|
667
684
|
const st = sp.states && sp.states.get && sp.states.get(sid)
|
|
@@ -683,7 +700,7 @@ export function apply(ctx) {
|
|
|
683
700
|
|
|
684
701
|
if (typeof sp.create !== 'function' || typeof sp.append !== 'function') {
|
|
685
702
|
// No create primitive: must relocate the existing log directly.
|
|
686
|
-
await relocateLog(meta, newHeader)
|
|
703
|
+
if (!await relocateLog(meta, newHeader)) throw new Error('移动失败:无法确认会话日志已迁移到目标工作区')
|
|
687
704
|
} else {
|
|
688
705
|
const backupPath = oldPath ? `${oldPath}.move-backup-${Date.now()}` : null
|
|
689
706
|
if (backupPath) { try { await rename(oldPath, backupPath) } catch (e) { if (e && e.code !== 'ENOENT') throw new Error('移动失败:无法备份旧的会话日志') } }
|
|
@@ -691,7 +708,7 @@ export function apply(ctx) {
|
|
|
691
708
|
try {
|
|
692
709
|
await sp.create(newHeader)
|
|
693
710
|
await sp.append(sid, events)
|
|
694
|
-
const check = await
|
|
711
|
+
const check = await persistence.readSession(sid, 0)
|
|
695
712
|
if (!check || !check.meta || check.meta.cwd !== canonical) {
|
|
696
713
|
throw new Error('移动后校验失败:会话工作目录未正确更新')
|
|
697
714
|
}
|
|
@@ -701,7 +718,7 @@ export function apply(ctx) {
|
|
|
701
718
|
// Collision: the session is already materialized in states (archived
|
|
702
719
|
// or previously opened). Fall back to physically relocating the log.
|
|
703
720
|
await restore()
|
|
704
|
-
await relocateLog(meta, newHeader)
|
|
721
|
+
if (!await relocateLog(meta, newHeader)) throw new Error('移动失败:无法确认会话日志已迁移到目标工作区')
|
|
705
722
|
} else {
|
|
706
723
|
await restore()
|
|
707
724
|
throw new Error('移动会话日志失败:' + String((e && e.message) || e))
|
|
@@ -763,10 +780,10 @@ export function apply(ctx) {
|
|
|
763
780
|
async function reindexRegistry() {
|
|
764
781
|
const reg = w
|
|
765
782
|
if (!reg || typeof reg.replaceHeaderIndex !== 'function') return false
|
|
766
|
-
let
|
|
767
|
-
try {
|
|
768
|
-
if (!
|
|
769
|
-
await reg.replaceHeaderIndex(
|
|
783
|
+
let entries = null
|
|
784
|
+
try { entries = await persistence.listEntries() } catch (e) { entries = null }
|
|
785
|
+
if (!entries || !Array.isArray(entries)) return false
|
|
786
|
+
await reg.replaceHeaderIndex(entries.map((entry) => entry.header))
|
|
770
787
|
if (typeof reg.rebuildEntities === 'function') reg.rebuildEntities()
|
|
771
788
|
return true
|
|
772
789
|
}
|
|
@@ -820,15 +837,15 @@ export function apply(ctx) {
|
|
|
820
837
|
// 是元数据缓存能否跳过整本解码的前提,收益远大于成本
|
|
821
838
|
// 3. 未命中缓存的会话走**一次**批量投影(sq.readTitleSnapshots),而不是逐条
|
|
822
839
|
async function allSessionItems(opts = {}) {
|
|
823
|
-
let
|
|
840
|
+
let entries = []
|
|
824
841
|
let headersOk = false
|
|
825
842
|
try {
|
|
826
|
-
|
|
827
|
-
headersOk = Array.isArray(
|
|
828
|
-
if (!headersOk)
|
|
829
|
-
} catch (e) {
|
|
843
|
+
entries = await persistence.listEntries()
|
|
844
|
+
headersOk = Array.isArray(entries)
|
|
845
|
+
if (!headersOk) entries = []
|
|
846
|
+
} catch (e) { entries = [] }
|
|
830
847
|
let live = ctx.get('sessions')
|
|
831
|
-
const ids =
|
|
848
|
+
const ids = entries.map((entry) => entry.id)
|
|
832
849
|
if (live) { try { live.list().forEach((s) => { const sid = String(s.id); if (!ids.includes(sid)) ids.push(sid) }) } catch (e) { /* ignore */ } }
|
|
833
850
|
// Exclude sessions already moved to the recycle bin (软删除): they live in
|
|
834
851
|
// 回收站, not in 会话管理, so the panel won't re-list them after a delete.
|
|
@@ -842,7 +859,7 @@ export function apply(ctx) {
|
|
|
842
859
|
try { for (const ent of w.list()) wsByPath[ent.path] = ent } catch (e) { wsByPath = {} }
|
|
843
860
|
const currentArchived = new Set((await archivedState().catch(() => ({ archivedSessionIds: [] }))).archivedSessionIds || [])
|
|
844
861
|
const items = []
|
|
845
|
-
const usage = await collectUsage(
|
|
862
|
+
const usage = await collectUsage(entries)
|
|
846
863
|
// 先按指纹把「缓存命中」与「需要解码」分开,只对后者做批量投影。
|
|
847
864
|
const statsById = new Map(visibleIds.map((id) => [id, { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(id) }]))
|
|
848
865
|
const { cached, missing } = metaCache.partition(visibleIds, statsById)
|
|
@@ -927,15 +944,15 @@ export function apply(ctx) {
|
|
|
927
944
|
// 既不会陈旧也不会回到「每次全量解码」。
|
|
928
945
|
async function sidebarAuthority() {
|
|
929
946
|
const ids = []
|
|
930
|
-
let
|
|
931
|
-
try {
|
|
932
|
-
if (!Array.isArray(
|
|
933
|
-
for (const
|
|
947
|
+
let entries = []
|
|
948
|
+
try { entries = await persistence.listEntries() } catch (e) { entries = [] }
|
|
949
|
+
if (!Array.isArray(entries)) entries = []
|
|
950
|
+
for (const entry of entries) ids.push(entry.id)
|
|
934
951
|
const sessions = ctx.get('sessions')
|
|
935
952
|
try { if (sessions) sessions.list().forEach((session) => { const sid = String(session.id); if (!ids.includes(sid)) ids.push(sid) }) } catch (e) {}
|
|
936
953
|
const store = await readTrashStore()
|
|
937
954
|
if (ids.length) {
|
|
938
|
-
const usage = await collectUsage(
|
|
955
|
+
const usage = await collectUsage(entries)
|
|
939
956
|
const statsById = new Map(ids.map((id) => [id, { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(id) }]))
|
|
940
957
|
const { cached, missing } = metaCache.partition(ids, statsById)
|
|
941
958
|
// P4:与列表构建共用持久标题索引,冷启动零解码。
|
|
@@ -980,7 +997,7 @@ export function apply(ctx) {
|
|
|
980
997
|
meta = (live && live.header) || null
|
|
981
998
|
try { events = Array.isArray(live.events) ? [...live.events] : [] } catch (e) { events = [] }
|
|
982
999
|
} else {
|
|
983
|
-
const r = await
|
|
1000
|
+
const r = await persistence.readSession(sid, 0)
|
|
984
1001
|
if (!r || !r.meta) throw new Error('找不到该会话的记录(会话不存在)')
|
|
985
1002
|
meta = r.meta
|
|
986
1003
|
events = Array.isArray(r.events) ? r.events : []
|
|
@@ -989,7 +1006,7 @@ export function apply(ctx) {
|
|
|
989
1006
|
try {
|
|
990
1007
|
// rc.8 的 sessionPersistence 后端没有 artifactInfo;用 locate(meta) 拿日志
|
|
991
1008
|
// 文件真实路径后 stat 出字节数(磁盘占用)。
|
|
992
|
-
const loc =
|
|
1009
|
+
const loc = persistence.locate(meta)
|
|
993
1010
|
if (loc && typeof loc.path === 'string' && loc.path) {
|
|
994
1011
|
const st = await stat(loc.path)
|
|
995
1012
|
if (st && typeof st.size === 'number') sizeBytes = st.size
|
|
@@ -1062,7 +1079,8 @@ export function apply(ctx) {
|
|
|
1062
1079
|
const subagentSet = new Set()
|
|
1063
1080
|
try {
|
|
1064
1081
|
if (typeof sp.list === 'function') {
|
|
1065
|
-
for (const
|
|
1082
|
+
for (const entry of await persistence.listEntries()) {
|
|
1083
|
+
const h = entry.header
|
|
1066
1084
|
if (String(h.parentSession) !== String(sid)) continue
|
|
1067
1085
|
if (h.origin === 'subagent') subagentSet.add(h.id); else childrenSet.add(h.id)
|
|
1068
1086
|
}
|
|
@@ -1098,6 +1116,12 @@ export function apply(ctx) {
|
|
|
1098
1116
|
}
|
|
1099
1117
|
}))
|
|
1100
1118
|
|
|
1119
|
+
disposers.push(ctx.webServer.register({
|
|
1120
|
+
kind: 'exact',
|
|
1121
|
+
path: '/archived-sessions/capabilities',
|
|
1122
|
+
handler: async (req, res) => json(res, capabilities),
|
|
1123
|
+
}))
|
|
1124
|
+
|
|
1101
1125
|
disposers.push(ctx.webServer.register({
|
|
1102
1126
|
kind: 'exact',
|
|
1103
1127
|
path: '/archived-sessions/list',
|
|
@@ -1110,8 +1134,8 @@ export function apply(ctx) {
|
|
|
1110
1134
|
let materialized = new Set()
|
|
1111
1135
|
let live = ctx.get('sessions')
|
|
1112
1136
|
try {
|
|
1113
|
-
const
|
|
1114
|
-
materialized = new Set(
|
|
1137
|
+
const entries = await persistence.listEntries()
|
|
1138
|
+
materialized = new Set(entries.map((entry) => entry.id))
|
|
1115
1139
|
} catch (e) { /* best-effort */ }
|
|
1116
1140
|
const trashStore = await readTrashStore()
|
|
1117
1141
|
const hidden = new Set([...trashStore.items.map((item) => String(item.sessionId)), ...trashStore.purgedSessionIds.map(String)])
|
|
@@ -1157,11 +1181,11 @@ export function apply(ctx) {
|
|
|
1157
1181
|
const results = []
|
|
1158
1182
|
for (const sid of ids) {
|
|
1159
1183
|
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) }) }
|
|
1184
|
+
catch (e) { results.push({ sessionId: sid, ok: false, code: e && e.code, error: String((e && e.message) || e) }) }
|
|
1161
1185
|
}
|
|
1162
1186
|
json(res, { ok: true, restored: results.filter((r) => r.ok).length, results })
|
|
1163
1187
|
} catch (e) {
|
|
1164
|
-
json(res, { ok: false, error: String((e && e.message) || e) },
|
|
1188
|
+
json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
|
|
1165
1189
|
}
|
|
1166
1190
|
},
|
|
1167
1191
|
}))
|
|
@@ -1200,7 +1224,7 @@ export function apply(ctx) {
|
|
|
1200
1224
|
}
|
|
1201
1225
|
json(res, { ok: true, deleted: results.filter((r) => r.ok).length, results })
|
|
1202
1226
|
} catch (e) {
|
|
1203
|
-
json(res, { ok: false, error: String((e && e.message) || e) },
|
|
1227
|
+
json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
|
|
1204
1228
|
}
|
|
1205
1229
|
},
|
|
1206
1230
|
}))
|
|
@@ -1245,11 +1269,19 @@ export function apply(ctx) {
|
|
|
1245
1269
|
try {
|
|
1246
1270
|
const items = await readTrash()
|
|
1247
1271
|
const results = await Promise.all(items.map(async (item) => {
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1272
|
+
if (typeof item.originalPath !== 'string' || !item.originalPath) {
|
|
1273
|
+
return { sessionId: item.sessionId, status: 'unverified', originalPath: null }
|
|
1274
|
+
}
|
|
1275
|
+
const exists = await stat(item.originalPath).then(() => true).catch(() => false)
|
|
1276
|
+
return { sessionId: item.sessionId, status: exists ? 'ok' : 'missing', originalPath: item.originalPath }
|
|
1251
1277
|
}))
|
|
1252
|
-
json(res, {
|
|
1278
|
+
json(res, {
|
|
1279
|
+
ok: true,
|
|
1280
|
+
healthy: results.filter((r) => r.status === 'ok').length,
|
|
1281
|
+
missing: results.filter((r) => r.status === 'missing').length,
|
|
1282
|
+
unverified: results.filter((r) => r.status === 'unverified').length,
|
|
1283
|
+
results,
|
|
1284
|
+
})
|
|
1253
1285
|
} catch (e) {
|
|
1254
1286
|
json(res, { ok: false, error: String((e && e.message) || e) }, errorStatus(e))
|
|
1255
1287
|
}
|
|
@@ -1268,7 +1300,7 @@ export function apply(ctx) {
|
|
|
1268
1300
|
metaCache.invalidate(sid)
|
|
1269
1301
|
json(res, out)
|
|
1270
1302
|
} catch (e) {
|
|
1271
|
-
json(res, { ok: false, error: String((e && e.message) || e) },
|
|
1303
|
+
json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
|
|
1272
1304
|
}
|
|
1273
1305
|
},
|
|
1274
1306
|
}))
|
|
@@ -1287,7 +1319,7 @@ export function apply(ctx) {
|
|
|
1287
1319
|
titleIndex.remove([sid]).catch(() => {})
|
|
1288
1320
|
json(res, out)
|
|
1289
1321
|
} catch (e) {
|
|
1290
|
-
json(res, { ok: false, error: String((e && e.message) || e) },
|
|
1322
|
+
json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
|
|
1291
1323
|
}
|
|
1292
1324
|
},
|
|
1293
1325
|
}))
|
|
@@ -1307,7 +1339,7 @@ export function apply(ctx) {
|
|
|
1307
1339
|
}
|
|
1308
1340
|
json(res, { ok: true, purged: results.filter((r) => r.ok).length, results })
|
|
1309
1341
|
} catch (e) {
|
|
1310
|
-
json(res, { ok: false, error: String((e && e.message) || e) },
|
|
1342
|
+
json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
|
|
1311
1343
|
}
|
|
1312
1344
|
},
|
|
1313
1345
|
}))
|
|
@@ -1357,7 +1389,7 @@ export function apply(ctx) {
|
|
|
1357
1389
|
const url = new URL(req.url, 'http://localhost')
|
|
1358
1390
|
const sid = url.searchParams.get('sessionId')
|
|
1359
1391
|
requireSessionId(sid)
|
|
1360
|
-
const r = await
|
|
1392
|
+
const r = await persistence.readSession(sid, 0)
|
|
1361
1393
|
if (!r || !r.meta) {
|
|
1362
1394
|
const error = new Error('无法读取该会话的日志')
|
|
1363
1395
|
error.status = 404
|
|
@@ -1424,7 +1456,7 @@ export function apply(ctx) {
|
|
|
1424
1456
|
try { await reindexRegistry() } catch (e) { /* best-effort */ }
|
|
1425
1457
|
json(res, { sessionId: sid, ...moved })
|
|
1426
1458
|
} catch (e) {
|
|
1427
|
-
json(res, { ok: false, error: String((e && e.message) || e) },
|
|
1459
|
+
json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
|
|
1428
1460
|
}
|
|
1429
1461
|
},
|
|
1430
1462
|
}))
|