@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/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
+ })