dsh-sessions-manager 3.2.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/LICENSE +21 -0
- package/README.en.md +152 -0
- package/README.md +152 -0
- package/assets/screenshot-session-details.png +0 -0
- package/assets/screenshot-session-settingsmenu.png +0 -0
- package/assets/screenshot-session-submenu.png +0 -0
- package/assets/screenshot-session-trash.png +0 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +1582 -0
- package/lib/client.js.map +7 -0
- package/lib/index.js +1159 -0
- package/lib/index.js.map +7 -0
- package/package.json +73 -0
- package/src/client/index.jsx +1466 -0
- package/src/index.js +1158 -0
- package/src/zstd-frame.js +137 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,1158 @@
|
|
|
1
|
+
// dsh-sessions-manager — host half.
|
|
2
|
+
//
|
|
3
|
+
// Serves /archived-sessions/* JSON routes (list / restore / restore-many /
|
|
4
|
+
// delete / delete-many / sessions / workspaces / move) over the host
|
|
5
|
+
// `webServer`. The browser Settings sections ("归档会话" & "移动会话") talk to
|
|
6
|
+
// these. Reads/writes the durable workspace archive set
|
|
7
|
+
// (workspaceRegistry + storageDomain), folds titles/dates/workspace tags from
|
|
8
|
+
// session persistence, physically removes a session's log file on delete, and
|
|
9
|
+
// relocates a conversation (session) between workspaces on move.
|
|
10
|
+
import { mkdir, realpath, rename, stat, unlink, writeFile } from 'node:fs/promises'
|
|
11
|
+
import { basename, dirname, isAbsolute, join } from 'node:path'
|
|
12
|
+
import { readFileSync } from 'node:fs'
|
|
13
|
+
import { homedir } from 'node:os'
|
|
14
|
+
import { rewriteFrame0Cwd } from './zstd-frame.js'
|
|
15
|
+
|
|
16
|
+
export const name = 'dsh-sessions-manager'
|
|
17
|
+
export const inject = ['webServer', 'workspaceRegistry', 'sessionPersistence', 'sessionQuery', 'storageDomain']
|
|
18
|
+
|
|
19
|
+
const MAX_TITLE = 80
|
|
20
|
+
// Recycle bin (回收站): normal deletes land here instead of being erased.
|
|
21
|
+
const TRASH_DIR = process.env.DSH_SESSIONS_MANAGER_TRASH_DIR || join(homedir(), '.dsh', 'sessions-manager-trash')
|
|
22
|
+
const TRASH_INDEX = join(TRASH_DIR, 'index.json')
|
|
23
|
+
const TRASH_SCHEMA_VERSION = 2
|
|
24
|
+
const DEFAULT_TRASH_SETTINGS = Object.freeze({ retentionDays: 0 })
|
|
25
|
+
// -- per-session detail aggregation (v2.0: 取 Zephyr-vibe buildDetails 精华) --
|
|
26
|
+
// 识别“搜索/抓取”类工具,用来收集 fetch 记录。
|
|
27
|
+
const FETCH_TOOL_RE = /search|fetch|download|browse/i
|
|
28
|
+
const MAX_FETCHES = 12 // fetch 记录上限(防响应过大)
|
|
29
|
+
const MAX_FILES = 20 // write/edit 文件列表上限
|
|
30
|
+
|
|
31
|
+
function json(res, value, status = 200) {
|
|
32
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
33
|
+
res.end(JSON.stringify(value))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function errorStatus(error) {
|
|
37
|
+
return error && Number.isInteger(error.status) ? error.status : 500
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function readJsonBody(req) {
|
|
41
|
+
const chunks = []
|
|
42
|
+
let total = 0
|
|
43
|
+
for await (const chunk of req) {
|
|
44
|
+
chunks.push(chunk)
|
|
45
|
+
total += chunk.length
|
|
46
|
+
if (total > 1 << 20) return null
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
50
|
+
} catch {
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseIds(body) {
|
|
56
|
+
const raw = body && body.sessionIds
|
|
57
|
+
if (!Array.isArray(raw)) return null
|
|
58
|
+
const ids = []
|
|
59
|
+
for (const v of raw) if (typeof v === 'string' && isSafeSessionId(v)) ids.push(v)
|
|
60
|
+
return ids
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isSafeSessionId(value) {
|
|
64
|
+
return typeof value === 'string' && value.length > 0 && value.length <= 200 && !/[\\/\0]/.test(value) && value !== '.' && value !== '..'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function requireSessionId(value) {
|
|
68
|
+
if (!isSafeSessionId(value)) {
|
|
69
|
+
const error = new Error('无效的 sessionId')
|
|
70
|
+
error.status = 400
|
|
71
|
+
throw error
|
|
72
|
+
}
|
|
73
|
+
return value
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Best-effort: figure out which conversation is the host's *currently active*
|
|
77
|
+
// one. DSH's in-memory session store (ctx.sessions) keeps EVERY instantiated
|
|
78
|
+
// session alive even after you switch away in the UI, so "is it in
|
|
79
|
+
// ctx.sessions" is NOT the same as "is it the active conversation". We probe a
|
|
80
|
+
// few known accessors for the active id; if none is available we return null
|
|
81
|
+
// and callers should treat the session as movable (the move path is
|
|
82
|
+
// crash-safe via backup+rollback and re-syncs the live object afterwards).
|
|
83
|
+
function getActiveSessionId(context) {
|
|
84
|
+
try {
|
|
85
|
+
const a = context.get('activeSession')
|
|
86
|
+
if (a != null) return (a && a.id != null) ? a.id : (typeof a === 'string' ? a : null)
|
|
87
|
+
} catch (e) { /* no such key */ }
|
|
88
|
+
try {
|
|
89
|
+
const c = context.get('currentSession')
|
|
90
|
+
if (c != null) return (c && c.id != null) ? c.id : (typeof c === 'string' ? c : null)
|
|
91
|
+
} catch (e) { /* no such key */ }
|
|
92
|
+
try {
|
|
93
|
+
const store = context.get('sessions')
|
|
94
|
+
if (store && store.active && store.active.id != null) return store.active.id
|
|
95
|
+
} catch (e) { /* no such key */ }
|
|
96
|
+
return null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function foldTitle(events) {
|
|
100
|
+
let found = null
|
|
101
|
+
let firstUser = null
|
|
102
|
+
for (const ev of events) {
|
|
103
|
+
if (ev.type === 'session/title' && ev.data && typeof ev.data.title === 'string' && ev.data.title.length) {
|
|
104
|
+
found = ev.data.title
|
|
105
|
+
}
|
|
106
|
+
if (firstUser === null && ev.type === 'user/message' && ev.data && Array.isArray(ev.data.content)) {
|
|
107
|
+
const txt = ev.data.content.filter((b) => b && b.type === 'text').map((b) => b.text).filter(Boolean).join(' ').trim()
|
|
108
|
+
if (txt) firstUser = txt
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return found || firstUser || null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function apply(ctx) {
|
|
115
|
+
const w = ctx.workspaceRegistry
|
|
116
|
+
const sp = ctx.sessionPersistence
|
|
117
|
+
const sq = ctx.sessionQuery
|
|
118
|
+
const dom = () => ctx.storageDomain.get('workspace')
|
|
119
|
+
const authorityTitleCache = new Map()
|
|
120
|
+
let authorityTitlesLoaded = false
|
|
121
|
+
|
|
122
|
+
async function archivedState() {
|
|
123
|
+
const d = dom()
|
|
124
|
+
if (!d) throw new Error('workspace domain is not open')
|
|
125
|
+
return d.global.get()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function writeArchived(nextIds) {
|
|
129
|
+
const d = dom()
|
|
130
|
+
if (!d) throw new Error('workspace domain is not open')
|
|
131
|
+
const cur = d.global.get()
|
|
132
|
+
const next = Object.assign({}, cur, { archivedSessionIds: nextIds })
|
|
133
|
+
await d.global.set(next)
|
|
134
|
+
// Keep the registry's in-memory cache in sync so the live sidebar refreshes.
|
|
135
|
+
if (w && 'state' in w) { try { w.state = next } catch (e) { /* best-effort */ } }
|
|
136
|
+
return next
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let archiveMutation = Promise.resolve()
|
|
140
|
+
function mutateArchived(mutator) {
|
|
141
|
+
const operation = archiveMutation.then(async () => {
|
|
142
|
+
const state = await archivedState()
|
|
143
|
+
const list = (state.archivedSessionIds || []).map(String)
|
|
144
|
+
const result = await mutator(list)
|
|
145
|
+
if (result.next) await writeArchived(result.next)
|
|
146
|
+
return result.value
|
|
147
|
+
})
|
|
148
|
+
archiveMutation = operation.catch(() => {})
|
|
149
|
+
return operation
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let wsByPath = {}
|
|
153
|
+
|
|
154
|
+
async function resolveOne(id) {
|
|
155
|
+
let title = null, createdAt = null, cwd = null
|
|
156
|
+
try {
|
|
157
|
+
const o = await sq.readTitleSnapshot(id)
|
|
158
|
+
if (o) {
|
|
159
|
+
if (o.title && o.title.title) title = String(o.title.title)
|
|
160
|
+
if (o.session) { cwd = o.session.cwd || null; createdAt = o.session.createdAt || null }
|
|
161
|
+
}
|
|
162
|
+
} catch (e) { /* fall back to raw log */ }
|
|
163
|
+
if (!title || !cwd) {
|
|
164
|
+
try {
|
|
165
|
+
const r = await sp.readFrom(id, 0)
|
|
166
|
+
if (r.meta) {
|
|
167
|
+
if (!cwd) cwd = r.meta.cwd || null
|
|
168
|
+
if (!createdAt) createdAt = r.meta.createdAt || null
|
|
169
|
+
}
|
|
170
|
+
if (!title && Array.isArray(r.events)) title = foldTitle(r.events)
|
|
171
|
+
} catch (e2) { /* keep what we have */ }
|
|
172
|
+
}
|
|
173
|
+
const ws = cwd ? wsByPath[cwd] : undefined
|
|
174
|
+
const workspaceGone = !!(cwd && !ws)
|
|
175
|
+
const display = title ? (String(title).length > MAX_TITLE ? String(title).slice(0, MAX_TITLE) + '…' : String(title)) : null
|
|
176
|
+
return {
|
|
177
|
+
sessionId: id,
|
|
178
|
+
title: display,
|
|
179
|
+
createdAt: createdAt || null,
|
|
180
|
+
workspacePath: cwd || null,
|
|
181
|
+
workspaceTitle: (ws && ws.title) ? ws.title : null,
|
|
182
|
+
workspaceGone: workspaceGone ? true : false,
|
|
183
|
+
hasWorkspace: !!cwd,
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Restore (unarchive) one session; throws on failure.
|
|
188
|
+
async function restoreOne(sid) {
|
|
189
|
+
requireSessionId(sid)
|
|
190
|
+
return mutateArchived((list) => list.includes(sid)
|
|
191
|
+
? { next: list.filter((x) => x !== sid), value: { ok: true, restored: true } }
|
|
192
|
+
: { next: null, value: { ok: true, restored: false } })
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---- Recycle bin (回收站) helpers ----------------------------------------
|
|
196
|
+
let trashMutation = Promise.resolve()
|
|
197
|
+
function normalizeTrashStore(raw) {
|
|
198
|
+
if (Array.isArray(raw)) return { schemaVersion: TRASH_SCHEMA_VERSION, settings: { ...DEFAULT_TRASH_SETTINGS }, items: raw, purgedSessionIds: [] }
|
|
199
|
+
const settings = raw && typeof raw.settings === 'object' ? raw.settings : {}
|
|
200
|
+
const retentionDays = Number.isInteger(settings.retentionDays) && settings.retentionDays >= 0 ? settings.retentionDays : 0
|
|
201
|
+
return {
|
|
202
|
+
schemaVersion: TRASH_SCHEMA_VERSION,
|
|
203
|
+
settings: { retentionDays },
|
|
204
|
+
items: raw && Array.isArray(raw.items) ? raw.items : [],
|
|
205
|
+
purgedSessionIds: raw && Array.isArray(raw.purgedSessionIds) ? [...new Set(raw.purgedSessionIds.filter(isSafeSessionId).map(String))] : [],
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async function readTrashStore() {
|
|
209
|
+
try { return normalizeTrashStore(JSON.parse(readFileSync(TRASH_INDEX, 'utf8'))) } catch (e) { return normalizeTrashStore(null) }
|
|
210
|
+
}
|
|
211
|
+
async function readTrash() { return (await readTrashStore()).items }
|
|
212
|
+
async function writeTrashStore(store) {
|
|
213
|
+
await mkdir(TRASH_DIR, { recursive: true })
|
|
214
|
+
const tmp = join(TRASH_DIR, `.index-${process.pid}-${Date.now()}.tmp`)
|
|
215
|
+
await writeFile(tmp, JSON.stringify(normalizeTrashStore(store), null, 2), { encoding: 'utf8', mode: 0o600 })
|
|
216
|
+
await rename(tmp, TRASH_INDEX)
|
|
217
|
+
}
|
|
218
|
+
function mutateTrash(mutator) {
|
|
219
|
+
const operation = trashMutation.then(async () => {
|
|
220
|
+
const store = await readTrashStore()
|
|
221
|
+
const result = await mutator(store)
|
|
222
|
+
await writeTrashStore(store)
|
|
223
|
+
return result
|
|
224
|
+
})
|
|
225
|
+
trashMutation = operation.catch(() => {})
|
|
226
|
+
return operation
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Soft-delete one session: record it in the recycle-bin index but KEEP its
|
|
230
|
+
// log in the original workspace directory. Moving the file out (and detaching
|
|
231
|
+
// it from the workspace) orphaned the session into DSH's "未分组" group and
|
|
232
|
+
// made restore land in 未分组 instead of the original workspace — so we leave
|
|
233
|
+
// the file where it is and let the sidebar DOM shim hide the row instead.
|
|
234
|
+
async function deleteOne(sid) {
|
|
235
|
+
requireSessionId(sid)
|
|
236
|
+
// Soft-delete is always allowed — including the currently-active conversation.
|
|
237
|
+
// The log file stays in its original workspace dir (recorded in the 回收站
|
|
238
|
+
// index below), so the live session is unaffected and the entry stays
|
|
239
|
+
// recoverable from 回收站. (Move, by contrast, physically relocates the file
|
|
240
|
+
// and still guards the active session in moveTargetWorkspace.)
|
|
241
|
+
let header = null
|
|
242
|
+
let cwd = null
|
|
243
|
+
let title = null
|
|
244
|
+
let removedPath = null
|
|
245
|
+
try {
|
|
246
|
+
const headers = await sp.list()
|
|
247
|
+
header = headers.find((h) => String(h.id) === sid) || null
|
|
248
|
+
if (header) {
|
|
249
|
+
const loc = sp.locate(header)
|
|
250
|
+
if (loc && typeof loc.path === 'string') removedPath = loc.path
|
|
251
|
+
cwd = header.cwd || null
|
|
252
|
+
title = header.title || (header.meta && header.meta.title) || null
|
|
253
|
+
}
|
|
254
|
+
if (!title) {
|
|
255
|
+
const r = await sp.readFrom(sid, 0)
|
|
256
|
+
if (r && r.meta) { if (!cwd) cwd = r.meta.cwd; title = foldTitle(r.events) }
|
|
257
|
+
}
|
|
258
|
+
} catch (e) { /* best-effort */ }
|
|
259
|
+
// Record in the trash index only — the log stays in its workspace dir.
|
|
260
|
+
if (!header && !removedPath) {
|
|
261
|
+
const error = new Error('找不到该会话')
|
|
262
|
+
error.status = 404
|
|
263
|
+
throw error
|
|
264
|
+
}
|
|
265
|
+
const archived = await mutateArchived((list) => ({ next: null, value: list.includes(sid) })).catch(() => false)
|
|
266
|
+
await mutateTrash((store) => {
|
|
267
|
+
const entry = {
|
|
268
|
+
sessionId: sid, title: title || cwd || sid, cwd: cwd || null,
|
|
269
|
+
header: header || null, originalPath: removedPath || null,
|
|
270
|
+
sizeBytes: header && typeof header.size === 'number' ? header.size : null,
|
|
271
|
+
wasArchived: archived, deletedAt: Date.now(),
|
|
272
|
+
}
|
|
273
|
+
const at = store.items.findIndex((t) => String(t.sessionId) === sid)
|
|
274
|
+
if (at >= 0) store.items[at] = entry
|
|
275
|
+
else store.items.push(entry)
|
|
276
|
+
store.purgedSessionIds = store.purgedSessionIds.filter((id) => id !== sid)
|
|
277
|
+
})
|
|
278
|
+
return { ok: true, trashed: true }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Restore a trashed session: the log never left its original workspace dir,
|
|
282
|
+
// so we just drop it from the recycle-bin index and the sidebar reveals it in
|
|
283
|
+
// its original workspace (no move / no re-attach needed).
|
|
284
|
+
async function restoreFromTrash(sid) {
|
|
285
|
+
requireSessionId(sid)
|
|
286
|
+
await mutateTrash(async (store) => {
|
|
287
|
+
const entry = store.items.find((t) => String(t.sessionId) === sid)
|
|
288
|
+
if (!entry) { const error = new Error('回收站中找不到该会话'); error.status = 404; throw error }
|
|
289
|
+
// Restore the pre-delete archive state before removing the durable trash
|
|
290
|
+
// entry. If this fails, mutateTrash does not write and the item remains
|
|
291
|
+
// recoverable instead of disappearing into an inconsistent state.
|
|
292
|
+
if (entry.wasArchived === false) await restoreOne(sid)
|
|
293
|
+
store.items = store.items.filter((t) => String(t.sessionId) !== sid)
|
|
294
|
+
store.purgedSessionIds = store.purgedSessionIds.filter((id) => id !== sid)
|
|
295
|
+
})
|
|
296
|
+
return { ok: true, restored: true }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Permanently erase a trashed session: physically delete its log (still in
|
|
300
|
+
// the original workspace dir) and detach it from any workspace so DSH drops it.
|
|
301
|
+
async function purgeFromTrash(sid) {
|
|
302
|
+
requireSessionId(sid)
|
|
303
|
+
let purged = false
|
|
304
|
+
await mutateTrash(async (store) => {
|
|
305
|
+
const entry = store.items.find((t) => String(t.sessionId) === sid)
|
|
306
|
+
if (!entry) { const error = new Error('回收站中找不到该会话'); error.status = 404; throw error }
|
|
307
|
+
let target = null
|
|
308
|
+
try {
|
|
309
|
+
const headers = await sp.list()
|
|
310
|
+
const current = headers.find((h) => String(h.id) === sid)
|
|
311
|
+
const located = current && sp.locate(current)
|
|
312
|
+
if (located && typeof located.path === 'string') target = located.path
|
|
313
|
+
} catch (e) {}
|
|
314
|
+
if (!target && typeof entry.originalPath === 'string') target = entry.originalPath
|
|
315
|
+
// JSONL persistence stores logs as
|
|
316
|
+
// .../<sessionId>/session.jsonl.zstd
|
|
317
|
+
// Older backends may instead include the id in the filename itself.
|
|
318
|
+
// Accept both layouts, but reject every unrelated path before unlink.
|
|
319
|
+
const targetOwnsSession = target && (basename(dirname(target)) === sid || basename(target).includes(sid))
|
|
320
|
+
if (target && !targetOwnsSession) {
|
|
321
|
+
const error = new Error('日志路径与会话 ID 不匹配,已停止永久删除')
|
|
322
|
+
error.status = 409
|
|
323
|
+
throw error
|
|
324
|
+
}
|
|
325
|
+
// Persist the tombstone before any irreversible work. A crash after this
|
|
326
|
+
// point may leave the trash item retryable, but can never resurrect the
|
|
327
|
+
// session in a later list baseline.
|
|
328
|
+
if (!store.purgedSessionIds.includes(sid)) store.purgedSessionIds.push(sid)
|
|
329
|
+
await writeTrashStore(store)
|
|
330
|
+
// A freshly-created or recently-opened Session can remain resident after
|
|
331
|
+
// its file is unlinked. Flush once, then use SessionStore's entered-record
|
|
332
|
+
// detach capability so DSH emits host/session-removed and the client list
|
|
333
|
+
// drops the row instead of resurrecting it from live memory.
|
|
334
|
+
try {
|
|
335
|
+
const sessions = ctx.get('sessions')
|
|
336
|
+
const liveSession = sessions && sessions.get && sessions.get(sid)
|
|
337
|
+
if (liveSession && typeof sessions.flush === 'function') await sessions.flush(liveSession)
|
|
338
|
+
const entered = sessions && sessions.store && sessions.store.get && sessions.store.get(sid)
|
|
339
|
+
if (liveSession && (!entered || typeof entered.detach !== 'function')) throw new Error('宿主未提供 live Session detach 能力')
|
|
340
|
+
if (entered && typeof entered.detach === 'function') entered.detach()
|
|
341
|
+
// session/disposed starts an asynchronous persistence retirement. Wait
|
|
342
|
+
// for it before unlinking, otherwise its final drain can race the file
|
|
343
|
+
// deletion and briefly (or permanently) republish an orphan that the
|
|
344
|
+
// official sidebar groups under “未分组”.
|
|
345
|
+
const retirement = sp && sp.retirements && sp.retirements.get && sp.retirements.get(sid)
|
|
346
|
+
if (retirement && typeof retirement.then === 'function') await retirement
|
|
347
|
+
} catch (e) {
|
|
348
|
+
const error = new Error('无法从宿主内存移除会话,已停止永久删除:' + String((e && e.message) || e))
|
|
349
|
+
error.status = 409
|
|
350
|
+
throw error
|
|
351
|
+
}
|
|
352
|
+
if (target) {
|
|
353
|
+
try { await unlink(target) } catch (e) { if (e && e.code !== 'ENOENT') throw new Error('删除文件失败:' + String((e && e.message) || e)) }
|
|
354
|
+
}
|
|
355
|
+
try { for (const ent of w.list()) { if (ent.sessionIds.includes(sid)) { try { await ent.detachSession(sid) } catch (e) {} } } } catch (e) {}
|
|
356
|
+
try { if (w.sessionPaths && w.sessionPaths.delete) w.sessionPaths.delete(sid) } catch (e) {}
|
|
357
|
+
try { if (w.headers && w.headers.delete) w.headers.delete(sid) } catch (e) {}
|
|
358
|
+
await restoreOne(sid)
|
|
359
|
+
// Rebuild from the post-unlink disk baseline before reporting success.
|
|
360
|
+
// Merely deleting the two Maps above does not notify/rebuild Workspace
|
|
361
|
+
// entities, leaving the client with an orphaned “未分组” snapshot.
|
|
362
|
+
try { await reindexRegistry() } catch (e) { /* tombstone still prevents resurrection */ }
|
|
363
|
+
store.items = store.items.filter((t) => String(t.sessionId) !== sid)
|
|
364
|
+
purged = true
|
|
365
|
+
})
|
|
366
|
+
if (!purged) throw new Error('彻底删除失败')
|
|
367
|
+
return { ok: true, purged: true }
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function trashSettings(next) {
|
|
371
|
+
if (next === undefined) return (await readTrashStore()).settings
|
|
372
|
+
const days = Number(next.retentionDays)
|
|
373
|
+
if (!Number.isInteger(days) || ![0, 7, 30, 90].includes(days)) {
|
|
374
|
+
const error = new Error('retentionDays 仅支持 0、7、30、90')
|
|
375
|
+
error.status = 400
|
|
376
|
+
throw error
|
|
377
|
+
}
|
|
378
|
+
await mutateTrash((store) => { store.settings = { retentionDays: days } })
|
|
379
|
+
return (await readTrashStore()).settings
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function cleanupExpiredTrash() {
|
|
383
|
+
const store = await readTrashStore()
|
|
384
|
+
const days = store.settings.retentionDays
|
|
385
|
+
if (!days) return 0
|
|
386
|
+
const cutoff = Date.now() - days * 86400000
|
|
387
|
+
const ids = store.items.filter((item) => Number(item.deletedAt) > 0 && Number(item.deletedAt) < cutoff).map((item) => String(item.sessionId))
|
|
388
|
+
let count = 0
|
|
389
|
+
for (const sid of ids) { try { await purgeFromTrash(sid); count++ } catch (e) {} }
|
|
390
|
+
return count
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ---- "move conversation between workspaces" helper -----------------------
|
|
394
|
+
// DSH binds a conversation to the workspace whose canonical directory path
|
|
395
|
+
// equals the session's stored cwd. Moving it therefore means: (1) adopt the
|
|
396
|
+
// target path as a workspace (create if needed), (2) durably relocate the
|
|
397
|
+
// session's log so its header carries the new cwd, and (3) reassign the
|
|
398
|
+
// workspace membership (detach everywhere, attach to target). The log
|
|
399
|
+
// relocation goes through the persistence service's own encoder (handles the
|
|
400
|
+
// zstd artifact encoding) with a backup + rollback so a failure never leaves
|
|
401
|
+
// the session half-moved.
|
|
402
|
+
|
|
403
|
+
async function moveTargetWorkspace(rawPath) {
|
|
404
|
+
if (typeof rawPath !== 'string' || !rawPath.trim()) throw new Error('缺少目标工作区路径')
|
|
405
|
+
let p = String(rawPath).trim()
|
|
406
|
+
if (p.startsWith('~/')) p = join(homedir(), p.slice(2))
|
|
407
|
+
if (!isAbsolute(p)) p = join(homedir(), p)
|
|
408
|
+
let canonical = null
|
|
409
|
+
try { canonical = await realpath(p) } catch (e) { canonical = null }
|
|
410
|
+
if (canonical === null) {
|
|
411
|
+
await mkdir(p, { recursive: true })
|
|
412
|
+
canonical = await realpath(p)
|
|
413
|
+
}
|
|
414
|
+
return { canonical, entity: await w.create(canonical, basename(canonical) || 'workspace') }
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function moveOne(sid, targetPath) {
|
|
418
|
+
// Only block the *active* conversation. ctx.sessions keeps instantiated
|
|
419
|
+
// sessions alive after you switch away, so the old check (sessions.get(sid))
|
|
420
|
+
// wrongly rejected every opened session — you could never move one you'd
|
|
421
|
+
// merely looked at. When the host exposes no active-session accessor we
|
|
422
|
+
// can't prove activeness, so we allow the move; the relocation below is
|
|
423
|
+
// crash-safe (backup + rollback) and re-syncs the live object.
|
|
424
|
+
const activeId = getActiveSessionId(ctx)
|
|
425
|
+
if (activeId != null && String(activeId) === String(sid)) {
|
|
426
|
+
throw new Error('该会话当前处于打开状态,请先切换到别的会话再移动。')
|
|
427
|
+
}
|
|
428
|
+
const r = await sp.readFrom(sid, 0)
|
|
429
|
+
if (!r || !r.meta) throw new Error('无法读取该会话的日志')
|
|
430
|
+
const meta = r.meta
|
|
431
|
+
const events = r.events
|
|
432
|
+
const oldCwd = meta.cwd || null
|
|
433
|
+
|
|
434
|
+
const { canonical, entity: target } = await moveTargetWorkspace(targetPath)
|
|
435
|
+
|
|
436
|
+
if (oldCwd) {
|
|
437
|
+
let oldCanon = null
|
|
438
|
+
try { oldCanon = await realpath(oldCwd) } catch (e) { oldCanon = null }
|
|
439
|
+
if (oldCanon === canonical) {
|
|
440
|
+
return { ok: true, already: true, workspaceId: target.id, workspaceTitle: target.title }
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const newHeader = Object.assign({}, meta, { cwd: canonical })
|
|
445
|
+
|
|
446
|
+
// 1) Decide relocation strategy. `sessionPersistence.create()` rejects
|
|
447
|
+
// ("already exists in this backend") for ANY session the host has
|
|
448
|
+
// instantiated into its in-memory `states` — and DSH instantiates *every*
|
|
449
|
+
// session it can find on disk at startup, including ARCHIVED ones. So a
|
|
450
|
+
// supposedly "closed" archived session is NOT safe for the create()+append()
|
|
451
|
+
// path; create() will throw. The only universally safe move is to physically
|
|
452
|
+
// relocate the on-disk log (rewriting frame0's cwd) and redirect the live
|
|
453
|
+
// object + persistence state. We still attempt create()+append() as the
|
|
454
|
+
// fast path for genuinely-virgin session ids, but on an already-exists
|
|
455
|
+
// collision we fall back to the relocate path. That covers live, archived,
|
|
456
|
+
// and restored sessions alike.
|
|
457
|
+
const live = ctx.get('sessions')
|
|
458
|
+
const liveObj = live && live.get && live.get(sid)
|
|
459
|
+
const isOpen = !!liveObj
|
|
460
|
+
|
|
461
|
+
const ALREADY_EXISTS_RE = /already exists in this backend/i
|
|
462
|
+
|
|
463
|
+
// Physically relocate a session's on-disk log to `newHeader`'s cwd,
|
|
464
|
+
// rewriting frame0's cwd so sp.list()/reindex attribute it correctly.
|
|
465
|
+
// Returns true if a relocation actually happened.
|
|
466
|
+
const relocateLog = async (header, newHeaderObj) => {
|
|
467
|
+
const oldPath = locatePath(header)
|
|
468
|
+
const newPath = locatePath(newHeaderObj)
|
|
469
|
+
if (!oldPath || !newPath || oldPath === newPath) return false
|
|
470
|
+
const backupPath = `${oldPath}.move-backup-${Date.now()}`
|
|
471
|
+
try {
|
|
472
|
+
// Ensure the destination project directory exists (rename does not
|
|
473
|
+
// create it). Without this, the rename silently no-ops on ENOENT and
|
|
474
|
+
// the log stays put while workspace.json is wrongly updated.
|
|
475
|
+
await mkdir(dirname(newPath), { recursive: true })
|
|
476
|
+
await rename(oldPath, backupPath) // current log to safety
|
|
477
|
+
await rewriteFrame0Cwd(backupPath, canonical) // frame0 cwd -> newPath
|
|
478
|
+
await rename(backupPath, newPath) // relocate to the new workspace dir
|
|
479
|
+
} catch (e) {
|
|
480
|
+
try { await rename(backupPath, oldPath) } catch (_) {}
|
|
481
|
+
if (e && e.code !== 'ENOENT') throw e
|
|
482
|
+
return false
|
|
483
|
+
}
|
|
484
|
+
return true
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const locatePath = (header) => {
|
|
488
|
+
let fn = null
|
|
489
|
+
try { if (typeof sp.locate === 'function') fn = sp.locate.bind(sp) } catch (e) {}
|
|
490
|
+
if (!fn && sp.backend && typeof sp.backend.locate === 'function') fn = sp.backend.locate.bind(sp.backend)
|
|
491
|
+
if (!fn) return null
|
|
492
|
+
try {
|
|
493
|
+
const loc = fn(header)
|
|
494
|
+
if (loc && typeof loc.path === 'string') return loc.path
|
|
495
|
+
if (typeof loc === 'string') return loc
|
|
496
|
+
} catch (e) {}
|
|
497
|
+
return null
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Rewriting frame0's cwd now lives in src/zstd-frame.js so it can be
|
|
501
|
+
// regression-tested directly. See that module for why frame boundaries are
|
|
502
|
+
// validated by decompression and why a non-session frame0 is rejected
|
|
503
|
+
// instead of rewritten.
|
|
504
|
+
|
|
505
|
+
if (isOpen) {
|
|
506
|
+
// Live session: relocate the on-disk log (rewriting frame0's cwd to the
|
|
507
|
+
// new path) and redirect the live object + persistence state. We must
|
|
508
|
+
// rewrite frame0, not just rename: sp.list() reads frame0's cwd from
|
|
509
|
+
// disk, and WorkspaceEntity.sessionIds filters by that exact cwd. A bare
|
|
510
|
+
// rename would leave frame0 pointing at the old workspace, so reindex /
|
|
511
|
+
// restart would keep attributing the session to the wrong workspace.
|
|
512
|
+
await relocateLog(meta, newHeader)
|
|
513
|
+
// Redirect the persistence state's cwd so future appends land in newPath.
|
|
514
|
+
try {
|
|
515
|
+
const st = sp.states && sp.states.get && sp.states.get(sid)
|
|
516
|
+
if (st && st.meta) st.meta = Object.assign({}, st.meta, { cwd: canonical })
|
|
517
|
+
} catch (e) { /* best-effort */ }
|
|
518
|
+
} else {
|
|
519
|
+
// Closed session: try the fast create()+append() path first. But DSH
|
|
520
|
+
// instantiates *all* on-disk sessions (including archived ones) into its
|
|
521
|
+
// in-memory states at startup, so create() usually throws
|
|
522
|
+
// "already exists in this backend". On that collision we fall back to a
|
|
523
|
+
// physical relocate of the existing log (rewriting frame0's cwd), which
|
|
524
|
+
// is safe and needs no create().
|
|
525
|
+
let oldPath = null
|
|
526
|
+
try {
|
|
527
|
+
const loc = locatePath(meta)
|
|
528
|
+
if (loc && typeof loc === 'string') oldPath = loc
|
|
529
|
+
else if (loc && loc.path) oldPath = loc.path
|
|
530
|
+
} catch (e) { oldPath = null }
|
|
531
|
+
|
|
532
|
+
if (typeof sp.create !== 'function' || typeof sp.append !== 'function') {
|
|
533
|
+
// No create primitive: must relocate the existing log directly.
|
|
534
|
+
await relocateLog(meta, newHeader)
|
|
535
|
+
} else {
|
|
536
|
+
const backupPath = oldPath ? `${oldPath}.move-backup-${Date.now()}` : null
|
|
537
|
+
if (backupPath) { try { await rename(oldPath, backupPath) } catch (e) { if (e && e.code !== 'ENOENT') throw new Error('移动失败:无法备份旧的会话日志') } }
|
|
538
|
+
const restore = async () => { if (backupPath) { try { await rename(backupPath, oldPath) } catch (_) {} } }
|
|
539
|
+
try {
|
|
540
|
+
await sp.create(newHeader)
|
|
541
|
+
await sp.append(sid, events)
|
|
542
|
+
const check = await sp.readFrom(sid, 0)
|
|
543
|
+
if (!check || !check.meta || check.meta.cwd !== canonical) {
|
|
544
|
+
throw new Error('移动后校验失败:会话工作目录未正确更新')
|
|
545
|
+
}
|
|
546
|
+
if (backupPath) { try { await unlink(backupPath) } catch (e) {} }
|
|
547
|
+
} catch (e) {
|
|
548
|
+
if (ALREADY_EXISTS_RE.test(String((e && e.message) || e))) {
|
|
549
|
+
// Collision: the session is already materialized in states (archived
|
|
550
|
+
// or previously opened). Fall back to physically relocating the log.
|
|
551
|
+
await restore()
|
|
552
|
+
await relocateLog(meta, newHeader)
|
|
553
|
+
} else {
|
|
554
|
+
await restore()
|
|
555
|
+
throw new Error('移动会话日志失败:' + String((e && e.message) || e))
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Keep the live (in-memory) session object consistent with the relocated
|
|
562
|
+
// log so the host doesn't keep appending to the old path. This MUST happen
|
|
563
|
+
// before attachSession(): WorkspaceEntity.attachSession() validates the
|
|
564
|
+
// session by reading live.header first, and if it still carries the old cwd
|
|
565
|
+
// the realpath check will fail on the old (now missing) directory.
|
|
566
|
+
try {
|
|
567
|
+
if (liveObj) {
|
|
568
|
+
if ('header' in liveObj) liveObj.header = newHeader
|
|
569
|
+
if ('cwd' in liveObj) liveObj.cwd = canonical
|
|
570
|
+
if ('meta' in liveObj) liveObj.meta = newHeader
|
|
571
|
+
}
|
|
572
|
+
} catch (e) { /* best-effort */ }
|
|
573
|
+
|
|
574
|
+
// 2) Reassign workspace membership (durable records + in-memory index).
|
|
575
|
+
for (const ent of w.list()) {
|
|
576
|
+
try { await ent.detachSession(sid) } catch (e) { /* ignore */ }
|
|
577
|
+
}
|
|
578
|
+
if (w.headers && typeof w.headers.set === 'function') w.headers.set(sid, newHeader)
|
|
579
|
+
if (w.sessionPaths && typeof w.sessionPaths.set === 'function') w.sessionPaths.set(sid, canonical)
|
|
580
|
+
await target.attachSession(sid)
|
|
581
|
+
|
|
582
|
+
// Verify the membership actually landed on the target workspace. DSH's
|
|
583
|
+
// WorkspaceEntity.attachSession persists asynchronously; if it silently
|
|
584
|
+
// no-ops (e.g. the session's durable cwd still points elsewhere) the UI
|
|
585
|
+
// would show "moved" while the sidebar keeps the old grouping. Fail loud
|
|
586
|
+
// instead of returning a fake success.
|
|
587
|
+
const verified = (() => {
|
|
588
|
+
try { return target.sessionIds.includes(sid) } catch (e) { return false }
|
|
589
|
+
})()
|
|
590
|
+
if (!verified) {
|
|
591
|
+
throw new Error('移动后校验失败:会话未出现在目标工作区,请重试或重启 DSH。')
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return {
|
|
595
|
+
ok: true,
|
|
596
|
+
moved: true,
|
|
597
|
+
workspaceId: target.id,
|
|
598
|
+
workspaceTitle: target.title,
|
|
599
|
+
workspacePath: canonical,
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Force the host's WorkspaceRegistry to rebuild its in-memory sessionPath
|
|
604
|
+
// index from the durable persistence headers. DSH's WorkspaceEntity.sessionIds
|
|
605
|
+
// is a *getter* that filters record.sessionIds by `host.sessionPath(id) ===
|
|
606
|
+
// record.path`; that sessionPath Map is only repopulated at startup (bootstrap
|
|
607
|
+
// + indexHeaders). So even after a successful move writes the durable cwd,
|
|
608
|
+
// the running process keeps attributing the session to its OLD workspace until
|
|
609
|
+
// a restart — unless we reindex here. Calling this right after move makes the
|
|
610
|
+
// sidebar reflect the new grouping with NO restart required.
|
|
611
|
+
async function reindexRegistry() {
|
|
612
|
+
const reg = w
|
|
613
|
+
if (!reg || typeof reg.replaceHeaderIndex !== 'function') return false
|
|
614
|
+
let headers = null
|
|
615
|
+
try { headers = await sp.list() } catch (e) { headers = null }
|
|
616
|
+
if (!headers || !Array.isArray(headers)) return false
|
|
617
|
+
await reg.replaceHeaderIndex(headers)
|
|
618
|
+
if (typeof reg.rebuildEntities === 'function') reg.rebuildEntities()
|
|
619
|
+
return true
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async function listWorkspaces() {
|
|
623
|
+
const out = []
|
|
624
|
+
try {
|
|
625
|
+
for (const ent of w.list()) out.push({ workspaceId: ent.id, title: ent.title, path: ent.path })
|
|
626
|
+
} catch (e) { /* ignore */ }
|
|
627
|
+
return out
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Archive (hide) one session: adds its id to the durable archive set so it
|
|
631
|
+
// is dropped out of the sidebar. DSH requires the session to exist (live or
|
|
632
|
+
// persisted) — a genuine miss surfaces as an error.
|
|
633
|
+
async function archiveOne(sid) {
|
|
634
|
+
requireSessionId(sid)
|
|
635
|
+
return mutateArchived(async (list) => {
|
|
636
|
+
if (list.includes(sid)) return { next: null, value: { ok: true, archived: false } }
|
|
637
|
+
await w.archiveSession(sid)
|
|
638
|
+
// archiveSession owns the durable write; keep this operation serialized
|
|
639
|
+
// with restoreOne so two requests cannot overwrite each other's state.
|
|
640
|
+
return { next: null, value: { ok: true, archived: true } }
|
|
641
|
+
})
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async function allSessionItems() {
|
|
645
|
+
let materialized = new Set()
|
|
646
|
+
let live = ctx.get('sessions')
|
|
647
|
+
try {
|
|
648
|
+
const headers = await sp.list()
|
|
649
|
+
materialized = new Set(headers.map((h) => String(h.id)))
|
|
650
|
+
} catch (e) { /* best-effort */ }
|
|
651
|
+
const ids = []
|
|
652
|
+
try { for (const header of await sp.list()) ids.push(String(header.id)) } catch (e) { /* ignore */ }
|
|
653
|
+
if (live) { try { live.list().forEach((s) => { if (!ids.includes(String(s.id))) ids.push(String(s.id)) }) } catch (e) { /* ignore */ } }
|
|
654
|
+
// Exclude sessions already moved to the recycle bin (软删除): they live in
|
|
655
|
+
// 回收站, not in 会话管理, so the panel won't re-list them after a delete.
|
|
656
|
+
let hiddenIds = new Set()
|
|
657
|
+
try {
|
|
658
|
+
const store = await readTrashStore()
|
|
659
|
+
hiddenIds = new Set([...store.items.map((t) => String(t.sessionId)), ...store.purgedSessionIds.map(String)])
|
|
660
|
+
} catch (e) {}
|
|
661
|
+
const visibleIds = ids.filter((id) => !hiddenIds.has(id))
|
|
662
|
+
wsByPath = {}
|
|
663
|
+
try { for (const ent of w.list()) wsByPath[ent.path] = ent } catch (e) { wsByPath = {} }
|
|
664
|
+
const currentArchived = new Set((await archivedState().catch(() => ({ archivedSessionIds: [] }))).archivedSessionIds || [])
|
|
665
|
+
const items = []
|
|
666
|
+
const CHUNK = 6
|
|
667
|
+
for (let i = 0; i < visibleIds.length; i += CHUNK) {
|
|
668
|
+
const res2 = await Promise.all(visibleIds.slice(i, i + CHUNK).map(resolveOne))
|
|
669
|
+
for (const it of res2) items.push({ ...it, archived: currentArchived.has(it.sessionId) })
|
|
670
|
+
}
|
|
671
|
+
return items
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
async function sidebarAuthority() {
|
|
675
|
+
const ids = []
|
|
676
|
+
try { for (const header of await sp.list()) ids.push(String(header.id)) } catch (e) {}
|
|
677
|
+
const sessions = ctx.get('sessions')
|
|
678
|
+
try { if (sessions) sessions.list().forEach((session) => { if (!ids.includes(String(session.id))) ids.push(String(session.id)) }) } catch (e) {}
|
|
679
|
+
const store = await readTrashStore()
|
|
680
|
+
if (!authorityTitlesLoaded && ids.length && typeof sq.readTitleSnapshots === 'function') {
|
|
681
|
+
const results = await sq.readTitleSnapshots(ids)
|
|
682
|
+
results.forEach((result, index) => {
|
|
683
|
+
if (result && result.status === 'fulfilled' && result.value && result.value.title && typeof result.value.title.title === 'string') {
|
|
684
|
+
authorityTitleCache.set(ids[index], result.value.title.title)
|
|
685
|
+
}
|
|
686
|
+
})
|
|
687
|
+
authorityTitlesLoaded = true
|
|
688
|
+
} else if (!authorityTitlesLoaded) {
|
|
689
|
+
for (const sid of ids) {
|
|
690
|
+
try {
|
|
691
|
+
const snapshot = await sq.readTitleSnapshot(sid)
|
|
692
|
+
if (snapshot && snapshot.title && typeof snapshot.title.title === 'string') authorityTitleCache.set(sid, snapshot.title.title)
|
|
693
|
+
} catch (e) {}
|
|
694
|
+
}
|
|
695
|
+
authorityTitlesLoaded = true
|
|
696
|
+
}
|
|
697
|
+
return {
|
|
698
|
+
titles: Object.fromEntries(authorityTitleCache),
|
|
699
|
+
trashedSessionIds: store.items.map((item) => String(item.sessionId)),
|
|
700
|
+
purgedSessionIds: store.purgedSessionIds.map(String),
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// 聚合一条会话的详情(磁盘占用 / 轮次·步数·消息数 / 工具统计 / fetch /
|
|
705
|
+
// write/edit 文件 / 血统 parent/children/subagents)。live 与持久化会话都可读。
|
|
706
|
+
// 所有统计对未知事件类型容错;fetch 与 files 做上限截断,files 用 stat 过滤
|
|
707
|
+
// 磁盘上已不存在的路径,避免详情面板列出已删除文件。
|
|
708
|
+
async function buildDetails(sid) {
|
|
709
|
+
const sessions = ctx.get('sessions')
|
|
710
|
+
const live = sessions && sessions.get(sid)
|
|
711
|
+
let meta = null
|
|
712
|
+
let events = []
|
|
713
|
+
if (live !== void 0) {
|
|
714
|
+
meta = (live && live.header) || null
|
|
715
|
+
try { events = Array.isArray(live.events) ? [...live.events] : [] } catch (e) { events = [] }
|
|
716
|
+
} else {
|
|
717
|
+
const r = await sp.readFrom(sid, 0)
|
|
718
|
+
if (!r || !r.meta) throw new Error('找不到该会话的记录(会话不存在)')
|
|
719
|
+
meta = r.meta
|
|
720
|
+
events = Array.isArray(r.events) ? r.events : []
|
|
721
|
+
}
|
|
722
|
+
let sizeBytes = null
|
|
723
|
+
try {
|
|
724
|
+
// rc.8 的 sessionPersistence 后端没有 artifactInfo;用 locate(meta) 拿日志
|
|
725
|
+
// 文件真实路径后 stat 出字节数(磁盘占用)。
|
|
726
|
+
const loc = sp.locate(meta)
|
|
727
|
+
if (loc && typeof loc.path === 'string' && loc.path) {
|
|
728
|
+
const st = await stat(loc.path)
|
|
729
|
+
if (st && typeof st.size === 'number') sizeBytes = st.size
|
|
730
|
+
}
|
|
731
|
+
} catch (e) { sizeBytes = null }
|
|
732
|
+
let lastTime = typeof meta && typeof meta.createdAt === 'number' ? meta.createdAt : 0
|
|
733
|
+
const fileSet = new Map()
|
|
734
|
+
const stats = {
|
|
735
|
+
turns: 0, steps: 0, userMessages: 0, assistantMessages: 0,
|
|
736
|
+
toolCalls: 0, attachments: 0, toolCounts: {}, fetches: [],
|
|
737
|
+
}
|
|
738
|
+
const turnSeen = new Set()
|
|
739
|
+
const stepSeen = new Set()
|
|
740
|
+
for (const ev of events) {
|
|
741
|
+
if (ev && typeof ev.time === 'number' && ev.time > lastTime) lastTime = ev.time
|
|
742
|
+
const d = (ev && ev.data && typeof ev.data === 'object') ? ev.data : {}
|
|
743
|
+
const type = ev && ev.type
|
|
744
|
+
switch (type) {
|
|
745
|
+
case 'turn/start':
|
|
746
|
+
if (typeof d.turn === 'number') turnSeen.add(d.turn)
|
|
747
|
+
break
|
|
748
|
+
case 'step/start':
|
|
749
|
+
if (typeof d.step === 'number') stepSeen.add(d.step)
|
|
750
|
+
break
|
|
751
|
+
case 'user/message':
|
|
752
|
+
stats.userMessages++
|
|
753
|
+
if (Array.isArray(d.content)) for (const b of d.content) if (b && b.type === 'image') stats.attachments++
|
|
754
|
+
break
|
|
755
|
+
case 'assistant/message':
|
|
756
|
+
stats.assistantMessages++
|
|
757
|
+
break
|
|
758
|
+
case 'tool/call': {
|
|
759
|
+
stats.toolCalls++
|
|
760
|
+
const tn = typeof d.name === 'string' && d.name ? d.name : 'tool'
|
|
761
|
+
stats.toolCounts[tn] = (stats.toolCounts[tn] || 0) + 1
|
|
762
|
+
if (FETCH_TOOL_RE.test(tn)) {
|
|
763
|
+
let query
|
|
764
|
+
try {
|
|
765
|
+
const a = typeof d.arguments === 'string' ? JSON.parse(d.arguments) : d.arguments
|
|
766
|
+
query = typeof a?.query === 'string' ? a.query : typeof a?.url === 'string' ? a.url : typeof a?.q === 'string' ? a.q : undefined
|
|
767
|
+
} catch (e) { query = undefined }
|
|
768
|
+
stats.fetches.push({ tool: tn, ...(query && query !== '' ? { query } : {}) })
|
|
769
|
+
}
|
|
770
|
+
if (tn === 'write' || tn === 'edit') {
|
|
771
|
+
let argsJ
|
|
772
|
+
try { argsJ = typeof d.arguments === 'string' ? JSON.parse(d.arguments) : d.arguments } catch (e) { break }
|
|
773
|
+
const fp = argsJ && typeof argsJ.file_path === 'string' && argsJ.file_path ? argsJ.file_path : undefined
|
|
774
|
+
if (fp !== undefined && !fileSet.has(fp)) fileSet.set(fp, tn)
|
|
775
|
+
}
|
|
776
|
+
break
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
stats.turns = turnSeen.size
|
|
781
|
+
stats.steps = stepSeen.size
|
|
782
|
+
if (stats.fetches.length > MAX_FETCHES) stats.fetches = stats.fetches.slice(0, MAX_FETCHES)
|
|
783
|
+
const fileEntries = [...fileSet.entries()].slice(0, MAX_FILES * 2)
|
|
784
|
+
const exists = await Promise.all(fileEntries.map(([p]) => stat(p).then(() => true).catch(() => false)))
|
|
785
|
+
const files = fileEntries
|
|
786
|
+
.filter((_, i) => exists[i])
|
|
787
|
+
.map(([path, tool]) => ({ path, tool }))
|
|
788
|
+
.slice(0, MAX_FILES)
|
|
789
|
+
// lineage:分叉子会话(非 subagent)与子代理(origin==='subagent'),source 去重。
|
|
790
|
+
const lineage = {
|
|
791
|
+
parentSessionId: (meta && typeof meta.parentSession === 'string') ? meta.parentSession : null,
|
|
792
|
+
children: [],
|
|
793
|
+
subagents: [],
|
|
794
|
+
}
|
|
795
|
+
const childrenSet = new Set()
|
|
796
|
+
const subagentSet = new Set()
|
|
797
|
+
try {
|
|
798
|
+
if (typeof sp.list === 'function') {
|
|
799
|
+
for (const h of await sp.list()) {
|
|
800
|
+
if (String(h.parentSession) !== String(sid)) continue
|
|
801
|
+
if (h.origin === 'subagent') subagentSet.add(h.id); else childrenSet.add(h.id)
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
} catch (e) { /* best-effort */ }
|
|
805
|
+
if (sessions) {
|
|
806
|
+
try {
|
|
807
|
+
sessions.list().forEach((s) => {
|
|
808
|
+
if (String(s.header.parentSession) !== String(sid)) return
|
|
809
|
+
if (s.header.origin === 'subagent') subagentSet.add(s.id); else childrenSet.add(s.id)
|
|
810
|
+
})
|
|
811
|
+
} catch (e) { /* best-effort */ }
|
|
812
|
+
}
|
|
813
|
+
lineage.children = [...childrenSet]
|
|
814
|
+
lineage.subagents = [...subagentSet]
|
|
815
|
+
return {
|
|
816
|
+
sessionId: sid,
|
|
817
|
+
sizeBytes,
|
|
818
|
+
createdAt: (meta && typeof meta.createdAt === 'number') ? meta.createdAt : null,
|
|
819
|
+
updatedAt: lastTime || null,
|
|
820
|
+
files,
|
|
821
|
+
stats,
|
|
822
|
+
lineage,
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
ctx.effect(() => {
|
|
827
|
+
const disposers = []
|
|
828
|
+
|
|
829
|
+
if (typeof ctx.on === 'function') disposers.push(ctx.on('session/event', (session, event) => {
|
|
830
|
+
if (event && event.type === 'session/title' && event.data && typeof event.data.title === 'string') {
|
|
831
|
+
authorityTitleCache.set(String(session.id), event.data.title)
|
|
832
|
+
}
|
|
833
|
+
}))
|
|
834
|
+
|
|
835
|
+
disposers.push(ctx.webServer.register({
|
|
836
|
+
kind: 'exact',
|
|
837
|
+
path: '/archived-sessions/list',
|
|
838
|
+
handler: async (req, res) => {
|
|
839
|
+
try {
|
|
840
|
+
const state = await archivedState()
|
|
841
|
+
const ids = state.archivedSessionIds || []
|
|
842
|
+
// Only surface archived ids that still exist (materialized log or live).
|
|
843
|
+
// Deleted sessions keep a hidden archive id but no log, so they drop out here.
|
|
844
|
+
let materialized = new Set()
|
|
845
|
+
let live = ctx.get('sessions')
|
|
846
|
+
try {
|
|
847
|
+
const headers = await sp.list()
|
|
848
|
+
materialized = new Set(headers.map((h) => String(h.id)))
|
|
849
|
+
} catch (e) { /* best-effort */ }
|
|
850
|
+
const trashStore = await readTrashStore()
|
|
851
|
+
const hidden = new Set([...trashStore.items.map((item) => String(item.sessionId)), ...trashStore.purgedSessionIds.map(String)])
|
|
852
|
+
const idStrs = ids.map(String).filter((id) => !hidden.has(id) && (materialized.has(id) || (live && live.get(id))))
|
|
853
|
+
wsByPath = {}
|
|
854
|
+
try { for (const ent of w.list()) wsByPath[ent.path] = ent } catch (e) { wsByPath = {} }
|
|
855
|
+
const items = []
|
|
856
|
+
const CHUNK = 6
|
|
857
|
+
for (let i = 0; i < idStrs.length; i += CHUNK) {
|
|
858
|
+
const res2 = await Promise.all(idStrs.slice(i, i + CHUNK).map(resolveOne))
|
|
859
|
+
items.push.apply(items, res2)
|
|
860
|
+
}
|
|
861
|
+
json(res, { items })
|
|
862
|
+
} catch (e) {
|
|
863
|
+
json(res, { error: String((e && e.message) || e) }, 500)
|
|
864
|
+
}
|
|
865
|
+
},
|
|
866
|
+
}))
|
|
867
|
+
|
|
868
|
+
disposers.push(ctx.webServer.register({
|
|
869
|
+
kind: 'exact',
|
|
870
|
+
path: '/archived-sessions/restore',
|
|
871
|
+
handler: async (req, res) => {
|
|
872
|
+
try {
|
|
873
|
+
const body = await readJsonBody(req)
|
|
874
|
+
const sid = body && typeof body.sessionId === 'string' ? body.sessionId : null
|
|
875
|
+
if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
|
|
876
|
+
json(res, await restoreOne(sid))
|
|
877
|
+
} catch (e) {
|
|
878
|
+
json(res, { ok: false, error: String((e && e.message) || e) }, errorStatus(e))
|
|
879
|
+
}
|
|
880
|
+
},
|
|
881
|
+
}))
|
|
882
|
+
|
|
883
|
+
disposers.push(ctx.webServer.register({
|
|
884
|
+
kind: 'exact',
|
|
885
|
+
path: '/archived-sessions/restore-many',
|
|
886
|
+
handler: async (req, res) => {
|
|
887
|
+
try {
|
|
888
|
+
const body = await readJsonBody(req)
|
|
889
|
+
const ids = parseIds(body)
|
|
890
|
+
if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
|
|
891
|
+
const results = []
|
|
892
|
+
for (const sid of ids) {
|
|
893
|
+
try { results.push({ sessionId: sid, ok: true, ...(await restoreOne(sid)) }) }
|
|
894
|
+
catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
|
|
895
|
+
}
|
|
896
|
+
json(res, { ok: true, restored: results.filter((r) => r.ok).length, results })
|
|
897
|
+
} catch (e) {
|
|
898
|
+
json(res, { ok: false, error: String((e && e.message) || e) }, 500)
|
|
899
|
+
}
|
|
900
|
+
},
|
|
901
|
+
}))
|
|
902
|
+
|
|
903
|
+
disposers.push(ctx.webServer.register({
|
|
904
|
+
kind: 'exact',
|
|
905
|
+
path: '/archived-sessions/delete',
|
|
906
|
+
handler: async (req, res) => {
|
|
907
|
+
try {
|
|
908
|
+
const body = await readJsonBody(req)
|
|
909
|
+
const sid = body && typeof body.sessionId === 'string' ? body.sessionId : null
|
|
910
|
+
if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
|
|
911
|
+
json(res, await deleteOne(sid))
|
|
912
|
+
} catch (e) {
|
|
913
|
+
json(res, { ok: false, error: String((e && e.message) || e) }, errorStatus(e))
|
|
914
|
+
}
|
|
915
|
+
},
|
|
916
|
+
}))
|
|
917
|
+
|
|
918
|
+
disposers.push(ctx.webServer.register({
|
|
919
|
+
kind: 'exact',
|
|
920
|
+
path: '/archived-sessions/delete-many',
|
|
921
|
+
handler: async (req, res) => {
|
|
922
|
+
try {
|
|
923
|
+
const body = await readJsonBody(req)
|
|
924
|
+
const ids = parseIds(body)
|
|
925
|
+
if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
|
|
926
|
+
const results = []
|
|
927
|
+
for (const sid of ids) {
|
|
928
|
+
try { results.push({ sessionId: sid, ok: true, ...(await deleteOne(sid)) }) }
|
|
929
|
+
catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
|
|
930
|
+
}
|
|
931
|
+
json(res, { ok: true, deleted: results.filter((r) => r.ok).length, results })
|
|
932
|
+
} catch (e) {
|
|
933
|
+
json(res, { ok: false, error: String((e && e.message) || e) }, 500)
|
|
934
|
+
}
|
|
935
|
+
},
|
|
936
|
+
}))
|
|
937
|
+
|
|
938
|
+
// ---- Recycle bin (回收站) routes ----------------------------------------
|
|
939
|
+
disposers.push(ctx.webServer.register({
|
|
940
|
+
kind: 'exact',
|
|
941
|
+
path: '/archived-sessions/trash/list',
|
|
942
|
+
handler: async (req, res) => {
|
|
943
|
+
try {
|
|
944
|
+
await cleanupExpiredTrash()
|
|
945
|
+
const list = await readTrash()
|
|
946
|
+
list.sort((a, b) => (b.deletedAt || 0) - (a.deletedAt || 0))
|
|
947
|
+
const store = await readTrashStore()
|
|
948
|
+
json(res, { schemaVersion: store.schemaVersion, settings: store.settings, purgedSessionIds: store.purgedSessionIds, items: list })
|
|
949
|
+
} catch (e) {
|
|
950
|
+
json(res, { ok: false, error: String((e && e.message) || e) }, errorStatus(e))
|
|
951
|
+
}
|
|
952
|
+
},
|
|
953
|
+
}))
|
|
954
|
+
|
|
955
|
+
disposers.push(ctx.webServer.register({
|
|
956
|
+
kind: 'exact',
|
|
957
|
+
path: '/archived-sessions/trash/settings',
|
|
958
|
+
handler: async (req, res) => {
|
|
959
|
+
try {
|
|
960
|
+
const body = await readJsonBody(req)
|
|
961
|
+
const settings = body && Object.prototype.hasOwnProperty.call(body, 'retentionDays')
|
|
962
|
+
? await trashSettings({ retentionDays: body.retentionDays })
|
|
963
|
+
: await trashSettings()
|
|
964
|
+
json(res, { ok: true, settings })
|
|
965
|
+
} catch (e) {
|
|
966
|
+
json(res, { ok: false, error: String((e && e.message) || e) }, errorStatus(e))
|
|
967
|
+
}
|
|
968
|
+
},
|
|
969
|
+
}))
|
|
970
|
+
|
|
971
|
+
disposers.push(ctx.webServer.register({
|
|
972
|
+
kind: 'exact',
|
|
973
|
+
path: '/archived-sessions/trash/verify',
|
|
974
|
+
handler: async (req, res) => {
|
|
975
|
+
try {
|
|
976
|
+
const items = await readTrash()
|
|
977
|
+
const results = await Promise.all(items.map(async (item) => {
|
|
978
|
+
let exists = false
|
|
979
|
+
if (typeof item.originalPath === 'string') exists = await stat(item.originalPath).then(() => true).catch(() => false)
|
|
980
|
+
return { sessionId: item.sessionId, status: exists ? 'ok' : 'missing', originalPath: item.originalPath || null }
|
|
981
|
+
}))
|
|
982
|
+
json(res, { ok: true, healthy: results.filter((r) => r.status === 'ok').length, missing: results.filter((r) => r.status === 'missing').length, results })
|
|
983
|
+
} catch (e) {
|
|
984
|
+
json(res, { ok: false, error: String((e && e.message) || e) }, errorStatus(e))
|
|
985
|
+
}
|
|
986
|
+
},
|
|
987
|
+
}))
|
|
988
|
+
|
|
989
|
+
disposers.push(ctx.webServer.register({
|
|
990
|
+
kind: 'exact',
|
|
991
|
+
path: '/archived-sessions/trash/restore',
|
|
992
|
+
handler: async (req, res) => {
|
|
993
|
+
try {
|
|
994
|
+
const body = await readJsonBody(req)
|
|
995
|
+
const sid = body && typeof body.sessionId === 'string' ? body.sessionId : null
|
|
996
|
+
if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
|
|
997
|
+
json(res, await restoreFromTrash(sid))
|
|
998
|
+
} catch (e) {
|
|
999
|
+
json(res, { ok: false, error: String((e && e.message) || e) }, 500)
|
|
1000
|
+
}
|
|
1001
|
+
},
|
|
1002
|
+
}))
|
|
1003
|
+
|
|
1004
|
+
disposers.push(ctx.webServer.register({
|
|
1005
|
+
kind: 'exact',
|
|
1006
|
+
path: '/archived-sessions/trash/purge',
|
|
1007
|
+
handler: async (req, res) => {
|
|
1008
|
+
try {
|
|
1009
|
+
const body = await readJsonBody(req)
|
|
1010
|
+
const sid = body && typeof body.sessionId === 'string' ? body.sessionId : null
|
|
1011
|
+
if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
|
|
1012
|
+
json(res, await purgeFromTrash(sid))
|
|
1013
|
+
} catch (e) {
|
|
1014
|
+
json(res, { ok: false, error: String((e && e.message) || e) }, 500)
|
|
1015
|
+
}
|
|
1016
|
+
},
|
|
1017
|
+
}))
|
|
1018
|
+
|
|
1019
|
+
disposers.push(ctx.webServer.register({
|
|
1020
|
+
kind: 'exact',
|
|
1021
|
+
path: '/archived-sessions/trash/purge-many',
|
|
1022
|
+
handler: async (req, res) => {
|
|
1023
|
+
try {
|
|
1024
|
+
const body = await readJsonBody(req)
|
|
1025
|
+
const ids = parseIds(body)
|
|
1026
|
+
if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
|
|
1027
|
+
const results = []
|
|
1028
|
+
for (const sid of ids) {
|
|
1029
|
+
try { results.push({ sessionId: sid, ok: true, ...(await purgeFromTrash(sid)) }) }
|
|
1030
|
+
catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
|
|
1031
|
+
}
|
|
1032
|
+
json(res, { ok: true, purged: results.filter((r) => r.ok).length, results })
|
|
1033
|
+
} catch (e) {
|
|
1034
|
+
json(res, { ok: false, error: String((e && e.message) || e) }, 500)
|
|
1035
|
+
}
|
|
1036
|
+
},
|
|
1037
|
+
}))
|
|
1038
|
+
|
|
1039
|
+
// All conversations (for the "移动会话" panel).
|
|
1040
|
+
disposers.push(ctx.webServer.register({
|
|
1041
|
+
kind: 'exact',
|
|
1042
|
+
path: '/archived-sessions/sessions',
|
|
1043
|
+
handler: async (req, res) => {
|
|
1044
|
+
try {
|
|
1045
|
+
json(res, { items: await allSessionItems() })
|
|
1046
|
+
} catch (e) {
|
|
1047
|
+
json(res, { error: String((e && e.message) || e) }, 500)
|
|
1048
|
+
}
|
|
1049
|
+
},
|
|
1050
|
+
}))
|
|
1051
|
+
|
|
1052
|
+
disposers.push(ctx.webServer.register({
|
|
1053
|
+
kind: 'exact',
|
|
1054
|
+
path: '/archived-sessions/sidebar-state',
|
|
1055
|
+
handler: async (req, res) => {
|
|
1056
|
+
try {
|
|
1057
|
+
json(res, await sidebarAuthority())
|
|
1058
|
+
} catch (e) {
|
|
1059
|
+
json(res, { error: String((e && e.message) || e) }, errorStatus(e))
|
|
1060
|
+
}
|
|
1061
|
+
},
|
|
1062
|
+
}))
|
|
1063
|
+
|
|
1064
|
+
// Available target workspaces (for the move picker).
|
|
1065
|
+
disposers.push(ctx.webServer.register({
|
|
1066
|
+
kind: 'exact',
|
|
1067
|
+
path: '/archived-sessions/workspaces',
|
|
1068
|
+
handler: async (req, res) => {
|
|
1069
|
+
try {
|
|
1070
|
+
json(res, { items: await listWorkspaces() })
|
|
1071
|
+
} catch (e) {
|
|
1072
|
+
json(res, { error: String((e && e.message) || e) }, 500)
|
|
1073
|
+
}
|
|
1074
|
+
},
|
|
1075
|
+
}))
|
|
1076
|
+
|
|
1077
|
+
// Move one conversation to a target workspace (existing path or a new one).
|
|
1078
|
+
disposers.push(ctx.webServer.register({
|
|
1079
|
+
kind: 'exact',
|
|
1080
|
+
path: '/archived-sessions/move',
|
|
1081
|
+
handler: async (req, res) => {
|
|
1082
|
+
try {
|
|
1083
|
+
const body = await readJsonBody(req)
|
|
1084
|
+
const sid = body && typeof body.sessionId === 'string' ? body.sessionId : null
|
|
1085
|
+
const target = body && typeof body.targetPath === 'string' ? body.targetPath : null
|
|
1086
|
+
if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
|
|
1087
|
+
if (!target) return json(res, { ok: false, error: 'missing targetPath' }, 400)
|
|
1088
|
+
const moved = await moveOne(sid, target)
|
|
1089
|
+
// Reindex the host's in-memory sessionPath index so the sidebar
|
|
1090
|
+
// reflects the new grouping immediately (no DSH restart needed).
|
|
1091
|
+
// Do this before replying: drag/drop and menu clients treat a 2xx
|
|
1092
|
+
// response as the commit point and must never announce success while
|
|
1093
|
+
// the sidebar still holds the old workspace index.
|
|
1094
|
+
try { await reindexRegistry() } catch (e) { /* best-effort */ }
|
|
1095
|
+
json(res, { sessionId: sid, ...moved })
|
|
1096
|
+
} catch (e) {
|
|
1097
|
+
json(res, { ok: false, error: String((e && e.message) || e) }, 500)
|
|
1098
|
+
}
|
|
1099
|
+
},
|
|
1100
|
+
}))
|
|
1101
|
+
|
|
1102
|
+
// Archive (hide) one session.
|
|
1103
|
+
disposers.push(ctx.webServer.register({
|
|
1104
|
+
kind: 'exact',
|
|
1105
|
+
path: '/archived-sessions/archive',
|
|
1106
|
+
handler: async (req, res) => {
|
|
1107
|
+
try {
|
|
1108
|
+
const body = await readJsonBody(req)
|
|
1109
|
+
const sid = body && typeof body.sessionId === 'string' ? body.sessionId : null
|
|
1110
|
+
if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
|
|
1111
|
+
json(res, { sessionId: sid, ...(await archiveOne(sid)) })
|
|
1112
|
+
} catch (e) {
|
|
1113
|
+
json(res, { ok: false, error: String((e && e.message) || e) }, 500)
|
|
1114
|
+
}
|
|
1115
|
+
},
|
|
1116
|
+
}))
|
|
1117
|
+
|
|
1118
|
+
// Archive (hide) many sessions.
|
|
1119
|
+
disposers.push(ctx.webServer.register({
|
|
1120
|
+
kind: 'exact',
|
|
1121
|
+
path: '/archived-sessions/archive-many',
|
|
1122
|
+
handler: async (req, res) => {
|
|
1123
|
+
try {
|
|
1124
|
+
const body = await readJsonBody(req)
|
|
1125
|
+
const ids = parseIds(body)
|
|
1126
|
+
if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
|
|
1127
|
+
const results = []
|
|
1128
|
+
for (const sid of ids) {
|
|
1129
|
+
try { results.push({ sessionId: sid, ok: true, ...(await archiveOne(sid)) }) }
|
|
1130
|
+
catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
|
|
1131
|
+
}
|
|
1132
|
+
json(res, { ok: true, archived: results.filter((r) => r.ok).length, results })
|
|
1133
|
+
} catch (e) {
|
|
1134
|
+
json(res, { ok: false, error: String((e && e.message) || e) }, 500)
|
|
1135
|
+
}
|
|
1136
|
+
},
|
|
1137
|
+
}))
|
|
1138
|
+
|
|
1139
|
+
// Per-session details (v2.0): disk usage, turn/step/message counts, tool
|
|
1140
|
+
// usage, fetch records, write/edit files, and lineage.
|
|
1141
|
+
disposers.push(ctx.webServer.register({
|
|
1142
|
+
kind: 'exact',
|
|
1143
|
+
path: '/archived-sessions/details',
|
|
1144
|
+
handler: async (req, res) => {
|
|
1145
|
+
try {
|
|
1146
|
+
const body = await readJsonBody(req)
|
|
1147
|
+
const sid = body && typeof body.sessionId === 'string' ? body.sessionId : null
|
|
1148
|
+
if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
|
|
1149
|
+
json(res, await buildDetails(sid))
|
|
1150
|
+
} catch (e) {
|
|
1151
|
+
json(res, { error: String((e && e.message) || e) }, (e && e.status) ? e.status : 500)
|
|
1152
|
+
}
|
|
1153
|
+
},
|
|
1154
|
+
}))
|
|
1155
|
+
|
|
1156
|
+
return () => { for (const d of disposers) d() }
|
|
1157
|
+
}, 'dsh-sessions-manager: routes')
|
|
1158
|
+
}
|