@tobycaimf/dsh-archived-sessions 1.0.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/.github/workflows/compat.yml +28 -0
- package/.github/workflows/publish.yml +29 -0
- package/LICENSE +21 -0
- package/README.en.md +75 -0
- package/README.md +75 -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/build.mjs +52 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +207 -0
- package/lib/client.js.map +7 -0
- package/lib/index.js +280 -0
- package/lib/index.js.map +7 -0
- package/package.json +44 -0
- package/scripts/check-session-health.mjs +66 -0
- package/src/client/index.jsx +204 -0
- package/src/index.js +277 -0
- package/src/zstd-frame.js +137 -0
- package/tests/host.test.js +145 -0
- package/tests/zstd-frame.test.js +148 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// dsh-archived-sessions — host half.
|
|
2
|
+
//
|
|
3
|
+
// Serves /archived-sessions/* JSON routes (list / restore / restore-many /
|
|
4
|
+
// delete / delete-many) over the host `webServer`. The browser Settings
|
|
5
|
+
// section ("归档会话") talks to these. Reads/writes the durable workspace
|
|
6
|
+
// archive set (workspaceRegistry + storageDomain), folds titles/dates/workspace
|
|
7
|
+
// tags from session persistence, and physically removes a session's log file
|
|
8
|
+
// on delete.
|
|
9
|
+
import { unlink } from 'node:fs/promises'
|
|
10
|
+
|
|
11
|
+
export const name = 'dsh-archived-sessions'
|
|
12
|
+
export const inject = ['webServer', 'workspaceRegistry', 'sessionPersistence', 'sessionQuery', 'storageDomain']
|
|
13
|
+
|
|
14
|
+
const MAX_TITLE = 80
|
|
15
|
+
|
|
16
|
+
function json(res, value, status = 200) {
|
|
17
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
18
|
+
res.end(JSON.stringify(value))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function readJsonBody(req) {
|
|
22
|
+
const chunks = []
|
|
23
|
+
let total = 0
|
|
24
|
+
for await (const chunk of req) {
|
|
25
|
+
chunks.push(chunk)
|
|
26
|
+
total += chunk.length
|
|
27
|
+
if (total > 1 << 20) return null
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
31
|
+
} catch {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseIds(body) {
|
|
37
|
+
const raw = body && body.sessionIds
|
|
38
|
+
if (!Array.isArray(raw)) return null
|
|
39
|
+
const ids = []
|
|
40
|
+
for (const v of raw) if (typeof v === 'string' && v) ids.push(v)
|
|
41
|
+
return ids
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function foldTitle(events) {
|
|
45
|
+
let found = null
|
|
46
|
+
let firstUser = null
|
|
47
|
+
for (const ev of events) {
|
|
48
|
+
if (ev.type === 'session/title' && ev.data && typeof ev.data.title === 'string' && ev.data.title.length) {
|
|
49
|
+
found = ev.data.title
|
|
50
|
+
}
|
|
51
|
+
if (firstUser === null && ev.type === 'user/message' && ev.data && Array.isArray(ev.data.content)) {
|
|
52
|
+
const txt = ev.data.content.filter((b) => b && b.type === 'text').map((b) => b.text).filter(Boolean).join(' ').trim()
|
|
53
|
+
if (txt) firstUser = txt
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return found || firstUser || null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function apply(ctx) {
|
|
60
|
+
const w = ctx.workspaceRegistry
|
|
61
|
+
const sp = ctx.sessionPersistence
|
|
62
|
+
const sq = ctx.sessionQuery
|
|
63
|
+
const dom = () => ctx.storageDomain.get('workspace')
|
|
64
|
+
|
|
65
|
+
async function archivedState() {
|
|
66
|
+
const d = dom()
|
|
67
|
+
if (!d) throw new Error('workspace domain is not open')
|
|
68
|
+
return d.global.get()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function writeArchived(nextIds) {
|
|
72
|
+
const d = dom()
|
|
73
|
+
if (!d) throw new Error('workspace domain is not open')
|
|
74
|
+
const cur = d.global.get()
|
|
75
|
+
const next = Object.assign({}, cur, { archivedSessionIds: nextIds })
|
|
76
|
+
await d.global.set(next)
|
|
77
|
+
// Keep the registry's in-memory cache in sync so the live sidebar refreshes.
|
|
78
|
+
if (w && 'state' in w) { try { w.state = next } catch (e) { /* best-effort */ } }
|
|
79
|
+
return next
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let wsByPath = {}
|
|
83
|
+
|
|
84
|
+
async function resolveOne(id) {
|
|
85
|
+
let title = null, createdAt = null, cwd = null
|
|
86
|
+
try {
|
|
87
|
+
const o = await sq.readTitleSnapshot(id)
|
|
88
|
+
if (o) {
|
|
89
|
+
if (o.title && o.title.title) title = String(o.title.title)
|
|
90
|
+
if (o.session) { cwd = o.session.cwd || null; createdAt = o.session.createdAt || null }
|
|
91
|
+
}
|
|
92
|
+
} catch (e) { /* fall back to raw log */ }
|
|
93
|
+
if (!title || !cwd) {
|
|
94
|
+
try {
|
|
95
|
+
const r = await sp.readFrom(id, 0)
|
|
96
|
+
if (r.meta) {
|
|
97
|
+
if (!cwd) cwd = r.meta.cwd || null
|
|
98
|
+
if (!createdAt) createdAt = r.meta.createdAt || null
|
|
99
|
+
}
|
|
100
|
+
if (!title && Array.isArray(r.events)) title = foldTitle(r.events)
|
|
101
|
+
} catch (e2) { /* keep what we have */ }
|
|
102
|
+
}
|
|
103
|
+
const ws = cwd ? wsByPath[cwd] : undefined
|
|
104
|
+
const workspaceGone = !!(cwd && !ws)
|
|
105
|
+
const display = title ? (String(title).length > MAX_TITLE ? String(title).slice(0, MAX_TITLE) + '…' : String(title)) : null
|
|
106
|
+
return {
|
|
107
|
+
sessionId: id,
|
|
108
|
+
title: display,
|
|
109
|
+
createdAt: createdAt || null,
|
|
110
|
+
workspacePath: cwd || null,
|
|
111
|
+
workspaceTitle: (ws && ws.title) ? ws.title : null,
|
|
112
|
+
workspaceGone: workspaceGone ? true : false,
|
|
113
|
+
hasWorkspace: !!cwd,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Restore (unarchive) one session; throws on failure.
|
|
118
|
+
async function restoreOne(sid) {
|
|
119
|
+
const state = await archivedState()
|
|
120
|
+
const list = state.archivedSessionIds.map(String)
|
|
121
|
+
if (!list.includes(sid)) return { ok: true, restored: false }
|
|
122
|
+
await writeArchived(list.filter((x) => x !== sid))
|
|
123
|
+
return { ok: true, restored: true }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Physically delete one session log + clear archive/workspace accounting;
|
|
127
|
+
// throws on failure (live sessions are rejected).
|
|
128
|
+
async function deleteOne(sid) {
|
|
129
|
+
const sessions = ctx.get('sessions')
|
|
130
|
+
if (sessions && sessions.get(sid)) {
|
|
131
|
+
throw new Error('该会话当前处于打开状态,请先切换到别的会话再删除。')
|
|
132
|
+
}
|
|
133
|
+
let removedPath = null
|
|
134
|
+
try {
|
|
135
|
+
const headers = await sp.list()
|
|
136
|
+
const header = headers.find((h) => String(h.id) === sid)
|
|
137
|
+
if (header) {
|
|
138
|
+
const loc = sp.locate(header)
|
|
139
|
+
if (loc && typeof loc.path === 'string') removedPath = loc.path
|
|
140
|
+
} else {
|
|
141
|
+
// Session not in the materialized list — use its header cwd to locate the log anyway.
|
|
142
|
+
const r = await sp.readFrom(sid, 0)
|
|
143
|
+
if (r && r.meta && r.meta.cwd) {
|
|
144
|
+
const loc = sp.locate({ id: sid, cwd: r.meta.cwd })
|
|
145
|
+
if (loc && typeof loc.path === 'string') removedPath = loc.path
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
} catch (e) { /* best-effort */ }
|
|
149
|
+
if (removedPath) {
|
|
150
|
+
try {
|
|
151
|
+
await unlink(removedPath)
|
|
152
|
+
} catch (e) {
|
|
153
|
+
if (e && e.code !== 'ENOENT') throw new Error('删除日志文件失败:' + String((e && e.message) || e))
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
for (const ent of w.list()) {
|
|
158
|
+
if (ent.sessionIds.includes(sid)) { try { await ent.detachSession(sid) } catch (e) { /* ignore */ } }
|
|
159
|
+
}
|
|
160
|
+
} catch (e) { /* ignore */ }
|
|
161
|
+
try { if (w.sessionPaths && w.sessionPaths.delete) w.sessionPaths.delete(sid) } catch (e) {}
|
|
162
|
+
try { if (w.headers && w.headers.delete) w.headers.delete(sid) } catch (e) {}
|
|
163
|
+
// NOTE: intentionally do NOT unarchive (keep the id in the archive set).
|
|
164
|
+
// Removing it from the archive set would make DSH re-show the session in the
|
|
165
|
+
// sidebar; with its workspace gone it would land in 未分组 until the index
|
|
166
|
+
// rebuilds. Keeping it hidden keeps the deleted session out of every list
|
|
167
|
+
// immediately. The stale hidden id is inert (no log -> no session resolves).
|
|
168
|
+
return { ok: true, deleted: true, removedPath }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
ctx.effect(() => {
|
|
172
|
+
const disposers = []
|
|
173
|
+
|
|
174
|
+
disposers.push(ctx.webServer.register({
|
|
175
|
+
kind: 'exact',
|
|
176
|
+
path: '/archived-sessions/list',
|
|
177
|
+
handler: async (req, res) => {
|
|
178
|
+
try {
|
|
179
|
+
const state = await archivedState()
|
|
180
|
+
const ids = state.archivedSessionIds || []
|
|
181
|
+
// Only surface archived ids that still exist (materialized log or live).
|
|
182
|
+
// Deleted sessions keep a hidden archive id but no log, so they drop out here.
|
|
183
|
+
let materialized = new Set()
|
|
184
|
+
let live = ctx.get('sessions')
|
|
185
|
+
try {
|
|
186
|
+
const headers = await sp.list()
|
|
187
|
+
materialized = new Set(headers.map((h) => String(h.id)))
|
|
188
|
+
} catch (e) { /* best-effort */ }
|
|
189
|
+
const idStrs = ids.map(String).filter((id) => materialized.has(id) || (live && live.get(id)))
|
|
190
|
+
wsByPath = {}
|
|
191
|
+
try { for (const ent of w.list()) wsByPath[ent.path] = ent } catch (e) { wsByPath = {} }
|
|
192
|
+
const items = []
|
|
193
|
+
const CHUNK = 6
|
|
194
|
+
for (let i = 0; i < idStrs.length; i += CHUNK) {
|
|
195
|
+
const res2 = await Promise.all(idStrs.slice(i, i + CHUNK).map(resolveOne))
|
|
196
|
+
items.push.apply(items, res2)
|
|
197
|
+
}
|
|
198
|
+
json(res, { items })
|
|
199
|
+
} catch (e) {
|
|
200
|
+
json(res, { error: String((e && e.message) || e) }, 500)
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
}))
|
|
204
|
+
|
|
205
|
+
disposers.push(ctx.webServer.register({
|
|
206
|
+
kind: 'exact',
|
|
207
|
+
path: '/archived-sessions/restore',
|
|
208
|
+
handler: async (req, res) => {
|
|
209
|
+
try {
|
|
210
|
+
const body = await readJsonBody(req)
|
|
211
|
+
const sid = body && typeof body.sessionId === 'string' ? body.sessionId : null
|
|
212
|
+
if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
|
|
213
|
+
json(res, await restoreOne(sid))
|
|
214
|
+
} catch (e) {
|
|
215
|
+
json(res, { ok: false, error: String((e && e.message) || e) }, 500)
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
}))
|
|
219
|
+
|
|
220
|
+
disposers.push(ctx.webServer.register({
|
|
221
|
+
kind: 'exact',
|
|
222
|
+
path: '/archived-sessions/restore-many',
|
|
223
|
+
handler: async (req, res) => {
|
|
224
|
+
try {
|
|
225
|
+
const body = await readJsonBody(req)
|
|
226
|
+
const ids = parseIds(body)
|
|
227
|
+
if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
|
|
228
|
+
const results = []
|
|
229
|
+
for (const sid of ids) {
|
|
230
|
+
try { results.push({ sessionId: sid, ok: true, ...(await restoreOne(sid)) }) }
|
|
231
|
+
catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
|
|
232
|
+
}
|
|
233
|
+
json(res, { ok: true, restored: results.filter((r) => r.ok).length, results })
|
|
234
|
+
} catch (e) {
|
|
235
|
+
json(res, { ok: false, error: String((e && e.message) || e) }, 500)
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
}))
|
|
239
|
+
|
|
240
|
+
disposers.push(ctx.webServer.register({
|
|
241
|
+
kind: 'exact',
|
|
242
|
+
path: '/archived-sessions/delete',
|
|
243
|
+
handler: async (req, res) => {
|
|
244
|
+
try {
|
|
245
|
+
const body = await readJsonBody(req)
|
|
246
|
+
const sid = body && typeof body.sessionId === 'string' ? body.sessionId : null
|
|
247
|
+
if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
|
|
248
|
+
json(res, await deleteOne(sid))
|
|
249
|
+
} catch (e) {
|
|
250
|
+
json(res, { ok: false, error: String((e && e.message) || e) }, 500)
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
}))
|
|
254
|
+
|
|
255
|
+
disposers.push(ctx.webServer.register({
|
|
256
|
+
kind: 'exact',
|
|
257
|
+
path: '/archived-sessions/delete-many',
|
|
258
|
+
handler: async (req, res) => {
|
|
259
|
+
try {
|
|
260
|
+
const body = await readJsonBody(req)
|
|
261
|
+
const ids = parseIds(body)
|
|
262
|
+
if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
|
|
263
|
+
const results = []
|
|
264
|
+
for (const sid of ids) {
|
|
265
|
+
try { results.push({ sessionId: sid, ok: true, ...(await deleteOne(sid)) }) }
|
|
266
|
+
catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
|
|
267
|
+
}
|
|
268
|
+
json(res, { ok: true, deleted: results.filter((r) => r.ok).length, results })
|
|
269
|
+
} catch (e) {
|
|
270
|
+
json(res, { ok: false, error: String((e && e.message) || e) }, 500)
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
}))
|
|
274
|
+
|
|
275
|
+
return () => { for (const d of disposers) d() }
|
|
276
|
+
}, 'dsh-archived-sessions: routes')
|
|
277
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// dsh-sessions-manager — zstd frame helpers.
|
|
2
|
+
//
|
|
3
|
+
// DSH persists session logs as a sequence of concatenated zstd frames. The
|
|
4
|
+
// FIRST frame must be exactly one line: the session header JSON (type
|
|
5
|
+
// 'session'). The persistence layer enforces this on startup
|
|
6
|
+
// (assertZstdHeaderFrame), so any corruption of frame0 takes down the whole
|
|
7
|
+
// web profile.
|
|
8
|
+
//
|
|
9
|
+
// Moving a session between workspaces requires rewriting frame0's `cwd`
|
|
10
|
+
// without re-encoding the rest of the log. That rewrite is where a bad frame
|
|
11
|
+
// boundary can silently destroy a session — hence the defensive checks here.
|
|
12
|
+
|
|
13
|
+
import zlib from 'node:zlib'
|
|
14
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
15
|
+
|
|
16
|
+
// zstd magic bytes are 28 B5 2F FD; read as a little-endian uint32 that is
|
|
17
|
+
// 0xFD2FB528 (4247762216).
|
|
18
|
+
export const ZSTD_MAGIC = 0xFD2FB528
|
|
19
|
+
|
|
20
|
+
const CHECKSUM_OPTS = { params: { [zlib.constants.ZSTD_c_checksumFlag]: 1 } }
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Locate real zstd frame boundaries in a concatenated-frame buffer.
|
|
24
|
+
*
|
|
25
|
+
* Scanning for the 4-byte magic alone produces FALSE POSITIVES: the same byte
|
|
26
|
+
* sequence can occur inside compressed data. Every candidate is therefore
|
|
27
|
+
* validated by attempting decompression; only offsets that decode are kept.
|
|
28
|
+
*
|
|
29
|
+
* @param {Buffer} buf
|
|
30
|
+
* @returns {number[]} ascending offsets of real frame starts
|
|
31
|
+
*/
|
|
32
|
+
export function findZstdFrameStarts(buf) {
|
|
33
|
+
const starts = []
|
|
34
|
+
for (let i = 0; i + 4 <= buf.length; i++) {
|
|
35
|
+
if (buf.readUInt32LE(i) !== ZSTD_MAGIC) continue
|
|
36
|
+
try {
|
|
37
|
+
// Two checks are needed, not just one:
|
|
38
|
+
// - a magic inside compressed data fails to decode and throws
|
|
39
|
+
// - a BARE 4-byte magic at the very end of the buffer decodes to an
|
|
40
|
+
// EMPTY result without throwing, so non-empty output is required too
|
|
41
|
+
// Every real frame carries at least one JSON line, so neither case can
|
|
42
|
+
// be a genuine frame start.
|
|
43
|
+
const out = zlib.zstdDecompressSync(buf.subarray(i, i + Math.min(buf.length - i, 1000000)))
|
|
44
|
+
if (out.length > 0) starts.push(i)
|
|
45
|
+
} catch (_) {
|
|
46
|
+
// Not a real frame boundary — the magic bytes occurred inside compressed data.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return starts
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Rewrite the `cwd` field of a session log's first frame, leaving all
|
|
54
|
+
* subsequent frames byte-identical.
|
|
55
|
+
*
|
|
56
|
+
* Refuses to write anything unless frame0 is a session header. A corrupted
|
|
57
|
+
* frame0 (e.g. an `agent/inbox/spliced` event) is reported as an error rather
|
|
58
|
+
* than being re-serialized back to disk — rewriting it would bake the
|
|
59
|
+
* corruption in permanently and make the file unrecoverable.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} filePath path to session.jsonl.zstd
|
|
62
|
+
* @param {string} newCwd workspace path to write into frame0
|
|
63
|
+
* @throws {Error} when the log has no zstd frame or frame0 is not a session header
|
|
64
|
+
*/
|
|
65
|
+
export function rewriteFrame0Cwd(filePath, newCwd) {
|
|
66
|
+
const buf = readFileSync(filePath)
|
|
67
|
+
const starts = findZstdFrameStarts(buf)
|
|
68
|
+
if (starts.length === 0) throw new Error('会话日志格式异常(无 zstd 帧)')
|
|
69
|
+
const end0 = starts.length > 1 ? starts[1] : buf.length
|
|
70
|
+
const frame0 = buf.subarray(starts[0], end0)
|
|
71
|
+
const text = zlib.zstdDecompressSync(frame0).toString('utf8')
|
|
72
|
+
const nl = text.indexOf('\n')
|
|
73
|
+
const line = nl >= 0 ? text.slice(0, nl) : text
|
|
74
|
+
const obj = JSON.parse(line)
|
|
75
|
+
if (obj.type !== 'session') {
|
|
76
|
+
throw new Error(`会话日志格式异常(帧0 不是 session header,实际 type=${obj.type})`)
|
|
77
|
+
}
|
|
78
|
+
if (obj.cwd === newCwd) return // already correct, no rewrite needed
|
|
79
|
+
obj.cwd = newCwd
|
|
80
|
+
const newFrame0 = zlib.zstdCompressSync(JSON.stringify(obj) + '\n', CHECKSUM_OPTS)
|
|
81
|
+
const rest = buf.subarray(end0)
|
|
82
|
+
writeFileSync(filePath, Buffer.concat([newFrame0, rest]))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Non-destructive variant of rewriteFrame0Cwd: returns the rewritten buffer
|
|
87
|
+
* instead of touching the file on disk. Used by tests.
|
|
88
|
+
*
|
|
89
|
+
* @param {Buffer} buf
|
|
90
|
+
* @param {string} newCwd
|
|
91
|
+
* @returns {Buffer} rewritten log
|
|
92
|
+
*/
|
|
93
|
+
export function rewriteFrame0CwdInMemory(buf, newCwd) {
|
|
94
|
+
const starts = findZstdFrameStarts(buf)
|
|
95
|
+
if (starts.length === 0) throw new Error('会话日志格式异常(无 zstd 帧)')
|
|
96
|
+
const end0 = starts.length > 1 ? starts[1] : buf.length
|
|
97
|
+
const frame0 = buf.subarray(starts[0], end0)
|
|
98
|
+
const text = zlib.zstdDecompressSync(frame0).toString('utf8')
|
|
99
|
+
const nl = text.indexOf('\n')
|
|
100
|
+
const line = nl >= 0 ? text.slice(0, nl) : text
|
|
101
|
+
const obj = JSON.parse(line)
|
|
102
|
+
if (obj.type !== 'session') {
|
|
103
|
+
throw new Error(`会话日志格式异常(帧0 不是 session header,实际 type=${obj.type})`)
|
|
104
|
+
}
|
|
105
|
+
obj.cwd = newCwd
|
|
106
|
+
const newFrame0 = zlib.zstdCompressSync(JSON.stringify(obj) + '\n', CHECKSUM_OPTS)
|
|
107
|
+
const rest = buf.subarray(end0)
|
|
108
|
+
return Buffer.concat([newFrame0, rest])
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Build a multi-frame session log buffer (header frame + event frames),
|
|
113
|
+
* matching the layout DSH's persistence layer writes. Used by tests.
|
|
114
|
+
*
|
|
115
|
+
* @param {object} header session header (must have type: 'session')
|
|
116
|
+
* @param {object[]} events subsequent records, one zstd frame each
|
|
117
|
+
* @returns {Buffer}
|
|
118
|
+
*/
|
|
119
|
+
export function buildSessionLog(header, events = []) {
|
|
120
|
+
const frames = [JSON.stringify(header) + '\n', ...events.map((e) => JSON.stringify(e) + '\n')]
|
|
121
|
+
return Buffer.concat(frames.map((f) => zlib.zstdCompressSync(Buffer.from(f, 'utf8'), CHECKSUM_OPTS)))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Read frame0 of a session log and return the parsed header line.
|
|
126
|
+
*
|
|
127
|
+
* @param {Buffer} buf
|
|
128
|
+
* @returns {{obj: object, lineCount: number}}
|
|
129
|
+
*/
|
|
130
|
+
export function readFrame0(buf) {
|
|
131
|
+
const starts = findZstdFrameStarts(buf)
|
|
132
|
+
if (starts.length === 0) throw new Error('会话日志格式异常(无 zstd 帧)')
|
|
133
|
+
const end0 = starts.length > 1 ? starts[1] : buf.length
|
|
134
|
+
const text = zlib.zstdDecompressSync(buf.subarray(starts[0], end0)).toString('utf8')
|
|
135
|
+
const lines = text.split('\n').filter((l) => l.length > 0)
|
|
136
|
+
return { obj: JSON.parse(lines[0]), lineCount: lines.length }
|
|
137
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { after, before, test } from 'node:test'
|
|
3
|
+
import { Readable } from 'node:stream'
|
|
4
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { dirname, join } from 'node:path'
|
|
6
|
+
import { tmpdir } from 'node:os'
|
|
7
|
+
|
|
8
|
+
let root
|
|
9
|
+
let routes
|
|
10
|
+
let domainState
|
|
11
|
+
let sessionPath
|
|
12
|
+
let liveSessions
|
|
13
|
+
|
|
14
|
+
before(async () => {
|
|
15
|
+
root = await mkdtemp(join(tmpdir(), 'dsm-test-'))
|
|
16
|
+
process.env.DSH_SESSIONS_MANAGER_TRASH_DIR = join(root, 'trash')
|
|
17
|
+
await mkdir(process.env.DSH_SESSIONS_MANAGER_TRASH_DIR, { recursive: true })
|
|
18
|
+
await writeFile(join(process.env.DSH_SESSIONS_MANAGER_TRASH_DIR, 'index.json'), JSON.stringify([{ sessionId: 'legacy-1', title: 'Legacy', deletedAt: 1 }]))
|
|
19
|
+
const { apply } = await import(`../src/index.js?test=${Date.now()}`)
|
|
20
|
+
routes = new Map()
|
|
21
|
+
domainState = { archivedSessionIds: [] }
|
|
22
|
+
// Match DSH's real persistence layout: the session id owns a directory,
|
|
23
|
+
// while the log filename itself is the generic session.jsonl.zstd.
|
|
24
|
+
sessionPath = join(root, 'known-1', 'session.jsonl.zstd')
|
|
25
|
+
await mkdir(dirname(sessionPath), { recursive: true })
|
|
26
|
+
await writeFile(sessionPath, 'test')
|
|
27
|
+
const header = { id: 'known-1', cwd: root, title: 'Known session', createdAt: Date.now() }
|
|
28
|
+
const live = { id: 'known-1', header, events: [{ type: 'session/title', data: { title: 'Latest renamed title' } }] }
|
|
29
|
+
liveSessions = new Map([['known-1', live]])
|
|
30
|
+
const sessions = {
|
|
31
|
+
get: (id) => liveSessions.get(id), list: () => [...liveSessions.values()], flush: async () => true,
|
|
32
|
+
store: new Map([['known-1', { detach: () => liveSessions.delete('known-1') }]]),
|
|
33
|
+
}
|
|
34
|
+
const ctx = {
|
|
35
|
+
workspaceRegistry: {
|
|
36
|
+
list: () => [], state: domainState,
|
|
37
|
+
archiveSession: async (sid) => { if (!domainState.archivedSessionIds.includes(sid)) domainState.archivedSessionIds.push(sid) },
|
|
38
|
+
},
|
|
39
|
+
sessionPersistence: {
|
|
40
|
+
list: async () => [header], locate: (item) => item.id === 'known-1' ? { path: sessionPath } : null,
|
|
41
|
+
readFrom: async (sid) => sid === 'known-1' ? { meta: header, events: [] } : Promise.reject(new Error('missing')),
|
|
42
|
+
},
|
|
43
|
+
sessionQuery: {
|
|
44
|
+
readTitleSnapshot: async () => ({ session: header, title: { title: 'Latest renamed title' } }),
|
|
45
|
+
readTitleSnapshots: async (ids) => ids.map(() => ({ status: 'fulfilled', value: { session: header, title: { title: 'Latest renamed title' } } })),
|
|
46
|
+
},
|
|
47
|
+
storageDomain: { get: () => ({ global: { get: () => domainState, set: async (next) => Object.assign(domainState, next) } }) },
|
|
48
|
+
webServer: { register: (route) => { routes.set(route.path, route.handler); return () => {} } },
|
|
49
|
+
get: (name) => name === 'sessions' ? sessions : null,
|
|
50
|
+
effect: (fn) => fn(),
|
|
51
|
+
}
|
|
52
|
+
apply(ctx)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
after(async () => { await rm(root, { recursive: true, force: true }) })
|
|
56
|
+
|
|
57
|
+
async function call(path, body = {}) {
|
|
58
|
+
const req = Readable.from([Buffer.from(JSON.stringify(body))])
|
|
59
|
+
let status = 200
|
|
60
|
+
let text = ''
|
|
61
|
+
const res = { writeHead: (value) => { status = value }, end: (value) => { text += value || '' } }
|
|
62
|
+
await routes.get(path)(req, res)
|
|
63
|
+
return { status, body: JSON.parse(text) }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
test('reads a legacy trash array through schema v2 API', async () => {
|
|
67
|
+
const result = await call('/archived-sessions/trash/list')
|
|
68
|
+
assert.equal(result.status, 200)
|
|
69
|
+
assert.equal(result.body.schemaVersion, 2)
|
|
70
|
+
assert.equal(result.body.items[0].sessionId, 'legacy-1')
|
|
71
|
+
assert.deepEqual(result.body.settings, { retentionDays: 0 })
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('persists a supported retention policy atomically', async () => {
|
|
75
|
+
const result = await call('/archived-sessions/trash/settings', { retentionDays: 30 })
|
|
76
|
+
assert.equal(result.status, 200)
|
|
77
|
+
assert.equal(result.body.settings.retentionDays, 30)
|
|
78
|
+
const stored = JSON.parse(await readFile(join(process.env.DSH_SESSIONS_MANAGER_TRASH_DIR, 'index.json'), 'utf8'))
|
|
79
|
+
assert.equal(stored.schemaVersion, 2)
|
|
80
|
+
assert.equal(stored.settings.retentionDays, 30)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('rejects unsafe session ids', async () => {
|
|
84
|
+
const result = await call('/archived-sessions/delete', { sessionId: '../escape' })
|
|
85
|
+
assert.equal(result.status, 400)
|
|
86
|
+
assert.match(result.body.error, /sessionId/)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('keeps activity, archive and trash transitions consistent', async () => {
|
|
90
|
+
let result = await call('/archived-sessions/archive', { sessionId: 'known-1' })
|
|
91
|
+
assert.equal(result.body.archived, true)
|
|
92
|
+
assert.deepEqual(domainState.archivedSessionIds, ['known-1'])
|
|
93
|
+
|
|
94
|
+
result = await call('/archived-sessions/restore', { sessionId: 'known-1' })
|
|
95
|
+
assert.equal(result.body.restored, true)
|
|
96
|
+
assert.deepEqual(domainState.archivedSessionIds, [])
|
|
97
|
+
|
|
98
|
+
result = await call('/archived-sessions/delete', { sessionId: 'known-1' })
|
|
99
|
+
assert.equal(result.body.trashed, true)
|
|
100
|
+
result = await call('/archived-sessions/trash/restore', { sessionId: 'known-1' })
|
|
101
|
+
assert.equal(result.body.restored, true)
|
|
102
|
+
const trash = await call('/archived-sessions/trash/list')
|
|
103
|
+
assert.equal(trash.body.items.some((item) => item.sessionId === 'known-1'), false)
|
|
104
|
+
assert.deepEqual(domainState.archivedSessionIds, [])
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('serializes concurrent archive and restore operations without duplicate ids', async () => {
|
|
108
|
+
await Promise.all([
|
|
109
|
+
call('/archived-sessions/archive', { sessionId: 'known-1' }),
|
|
110
|
+
call('/archived-sessions/archive', { sessionId: 'known-1' }),
|
|
111
|
+
])
|
|
112
|
+
assert.deepEqual(domainState.archivedSessionIds, ['known-1'])
|
|
113
|
+
await Promise.all([
|
|
114
|
+
call('/archived-sessions/restore', { sessionId: 'known-1' }),
|
|
115
|
+
call('/archived-sessions/restore', { sessionId: 'known-1' }),
|
|
116
|
+
])
|
|
117
|
+
assert.deepEqual(domainState.archivedSessionIds, [])
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('restores an archived trashed session to its pre-delete archived state', async () => {
|
|
121
|
+
await call('/archived-sessions/archive', { sessionId: 'known-1' })
|
|
122
|
+
await call('/archived-sessions/delete', { sessionId: 'known-1' })
|
|
123
|
+
const restored = await call('/archived-sessions/trash/restore', { sessionId: 'known-1' })
|
|
124
|
+
assert.equal(restored.body.restored, true)
|
|
125
|
+
assert.deepEqual(domainState.archivedSessionIds, ['known-1'])
|
|
126
|
+
|
|
127
|
+
await call('/archived-sessions/restore', { sessionId: 'known-1' })
|
|
128
|
+
assert.deepEqual(domainState.archivedSessionIds, [])
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('serves latest log-folded titles to the cold sidebar', async () => {
|
|
132
|
+
const state = await call('/archived-sessions/sidebar-state')
|
|
133
|
+
assert.equal(state.body.titles['known-1'], 'Latest renamed title')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('hard purge detaches a live session and persists an authoritative tombstone', async () => {
|
|
137
|
+
await call('/archived-sessions/delete', { sessionId: 'known-1' })
|
|
138
|
+
const purged = await call('/archived-sessions/trash/purge', { sessionId: 'known-1' })
|
|
139
|
+
assert.equal(purged.body.purged, true)
|
|
140
|
+
assert.equal(liveSessions.has('known-1'), false)
|
|
141
|
+
await assert.rejects(readFile(sessionPath))
|
|
142
|
+
const state = await call('/archived-sessions/sidebar-state')
|
|
143
|
+
assert.equal(state.body.purgedSessionIds.includes('known-1'), true)
|
|
144
|
+
assert.equal(state.body.trashedSessionIds.includes('known-1'), false)
|
|
145
|
+
})
|