@tobycaimf/dsh-archived-sessions 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +21 -8
- package/README.md +21 -8
- package/assets/screenshot-archived-sessions.png +0 -0
- package/assets/screenshot-batch-mode.png +0 -0
- package/lib/client.js +323 -44
- package/lib/client.js.map +2 -2
- package/lib/index.js +231 -1
- package/lib/index.js.map +2 -2
- package/package.json +2 -2
- package/src/client/index.jsx +333 -63
- package/src/index.js +236 -6
package/src/index.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
// dsh-archived-sessions — host half.
|
|
2
2
|
//
|
|
3
3
|
// Serves /archived-sessions/* JSON routes (list / restore / restore-many /
|
|
4
|
-
// delete / delete-many) over the host
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// on delete
|
|
9
|
-
|
|
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, unlink } from 'node:fs/promises'
|
|
11
|
+
import { basename, isAbsolute, join } from 'node:path'
|
|
12
|
+
import { homedir } from 'node:os'
|
|
10
13
|
|
|
11
14
|
export const name = 'dsh-archived-sessions'
|
|
12
15
|
export const inject = ['webServer', 'workspaceRegistry', 'sessionPersistence', 'sessionQuery', 'storageDomain']
|
|
@@ -168,6 +171,152 @@ export function apply(ctx) {
|
|
|
168
171
|
return { ok: true, deleted: true, removedPath }
|
|
169
172
|
}
|
|
170
173
|
|
|
174
|
+
// ---- "move conversation between workspaces" helper -----------------------
|
|
175
|
+
// DSH binds a conversation to the workspace whose canonical directory path
|
|
176
|
+
// equals the session's stored cwd. Moving it therefore means: (1) adopt the
|
|
177
|
+
// target path as a workspace (create if needed), (2) durably relocate the
|
|
178
|
+
// session's log so its header carries the new cwd, and (3) reassign the
|
|
179
|
+
// workspace membership (detach everywhere, attach to target). The log
|
|
180
|
+
// relocation goes through the persistence service's own encoder (handles the
|
|
181
|
+
// zstd artifact encoding) with a backup + rollback so a failure never leaves
|
|
182
|
+
// the session half-moved.
|
|
183
|
+
|
|
184
|
+
async function moveTargetWorkspace(rawPath) {
|
|
185
|
+
if (typeof rawPath !== 'string' || !rawPath.trim()) throw new Error('缺少目标工作区路径')
|
|
186
|
+
let p = String(rawPath).trim()
|
|
187
|
+
if (p.startsWith('~/')) p = join(homedir(), p.slice(2))
|
|
188
|
+
if (!isAbsolute(p)) p = join(homedir(), p)
|
|
189
|
+
let canonical = null
|
|
190
|
+
try { canonical = await realpath(p) } catch (e) { canonical = null }
|
|
191
|
+
if (canonical === null) {
|
|
192
|
+
await mkdir(p, { recursive: true })
|
|
193
|
+
canonical = await realpath(p)
|
|
194
|
+
}
|
|
195
|
+
return { canonical, entity: await w.create(canonical, basename(canonical) || 'workspace') }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function moveOne(sid, targetPath) {
|
|
199
|
+
const sessions = ctx.get('sessions')
|
|
200
|
+
if (sessions && sessions.get(sid)) {
|
|
201
|
+
throw new Error('该会话当前处于打开状态,请先切换到别的会话再移动。')
|
|
202
|
+
}
|
|
203
|
+
const r = await sp.readFrom(sid, 0)
|
|
204
|
+
if (!r || !r.meta) throw new Error('无法读取该会话的日志')
|
|
205
|
+
const meta = r.meta
|
|
206
|
+
const events = r.events
|
|
207
|
+
const oldCwd = meta.cwd || null
|
|
208
|
+
|
|
209
|
+
const { canonical, entity: target } = await moveTargetWorkspace(targetPath)
|
|
210
|
+
|
|
211
|
+
if (oldCwd) {
|
|
212
|
+
let oldCanon = null
|
|
213
|
+
try { oldCanon = await realpath(oldCwd) } catch (e) { oldCanon = null }
|
|
214
|
+
if (oldCanon === canonical) {
|
|
215
|
+
return { ok: true, already: true, workspaceId: target.id, workspaceTitle: target.title }
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const newHeader = Object.assign({}, meta, { cwd: canonical })
|
|
220
|
+
|
|
221
|
+
// 1) Back up the current log artifact before touching anything.
|
|
222
|
+
let oldPath = null
|
|
223
|
+
let backupPath = null
|
|
224
|
+
try {
|
|
225
|
+
const loc = sp.locate(meta)
|
|
226
|
+
if (loc && typeof loc.path === 'string' && loc.path) oldPath = loc.path
|
|
227
|
+
} catch (e) { oldPath = null }
|
|
228
|
+
if (oldPath) {
|
|
229
|
+
backupPath = `${oldPath}.move-backup-${Date.now()}`
|
|
230
|
+
try {
|
|
231
|
+
await rename(oldPath, backupPath)
|
|
232
|
+
} catch (e) {
|
|
233
|
+
if (e && e.code !== 'ENOENT') throw new Error('移动失败:无法备份旧的会话日志')
|
|
234
|
+
backupPath = null
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const restore = async () => {
|
|
239
|
+
if (backupPath && oldPath) {
|
|
240
|
+
try { await rename(backupPath, oldPath) } catch (e) { /* best-effort */ }
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// 2) Relocate the log through the persistence service (re-encodes header).
|
|
245
|
+
if (typeof sp.create !== 'function' || typeof sp.append !== 'function') {
|
|
246
|
+
await restore()
|
|
247
|
+
throw new Error('当前会话存储后端不支持安全移动,已中止。')
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
await sp.create(newHeader)
|
|
251
|
+
await sp.append(sid, events)
|
|
252
|
+
const check = await sp.readFrom(sid, 0)
|
|
253
|
+
if (!check || !check.meta || check.meta.cwd !== canonical) {
|
|
254
|
+
throw new Error('移动后校验失败:会话工作目录未正确更新')
|
|
255
|
+
}
|
|
256
|
+
} catch (e) {
|
|
257
|
+
await restore()
|
|
258
|
+
throw new Error('移动会话日志失败:' + String((e && e.message) || e))
|
|
259
|
+
}
|
|
260
|
+
if (backupPath) { try { await unlink(backupPath) } catch (e) { /* best-effort */ } }
|
|
261
|
+
|
|
262
|
+
// 3) Reassign workspace membership (durable records + in-memory index).
|
|
263
|
+
for (const ent of w.list()) {
|
|
264
|
+
try { await ent.detachSession(sid) } catch (e) { /* ignore */ }
|
|
265
|
+
}
|
|
266
|
+
if (w.headers && typeof w.headers.set === 'function') w.headers.set(sid, newHeader)
|
|
267
|
+
if (w.sessionPaths && typeof w.sessionPaths.set === 'function') w.sessionPaths.set(sid, canonical)
|
|
268
|
+
await target.attachSession(sid)
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
ok: true,
|
|
272
|
+
moved: true,
|
|
273
|
+
workspaceId: target.id,
|
|
274
|
+
workspaceTitle: target.title,
|
|
275
|
+
workspacePath: canonical,
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function listWorkspaces() {
|
|
280
|
+
const out = []
|
|
281
|
+
try {
|
|
282
|
+
for (const ent of w.list()) out.push({ workspaceId: ent.id, title: ent.title, path: ent.path })
|
|
283
|
+
} catch (e) { /* ignore */ }
|
|
284
|
+
return out
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Archive (hide) one session: adds its id to the durable archive set so it
|
|
288
|
+
// is dropped out of the sidebar. DSH requires the session to exist (live or
|
|
289
|
+
// persisted) — a genuine miss surfaces as an error.
|
|
290
|
+
async function archiveOne(sid) {
|
|
291
|
+
const state = await archivedState()
|
|
292
|
+
const list = state.archivedSessionIds.map(String)
|
|
293
|
+
if (list.includes(sid)) return { ok: true, archived: false }
|
|
294
|
+
await w.archiveSession(sid)
|
|
295
|
+
return { ok: true, archived: true }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function allSessionItems() {
|
|
299
|
+
let materialized = new Set()
|
|
300
|
+
let live = ctx.get('sessions')
|
|
301
|
+
try {
|
|
302
|
+
const headers = await sp.list()
|
|
303
|
+
materialized = new Set(headers.map((h) => String(h.id)))
|
|
304
|
+
} catch (e) { /* best-effort */ }
|
|
305
|
+
const ids = []
|
|
306
|
+
try { for (const header of await sp.list()) ids.push(String(header.id)) } catch (e) { /* ignore */ }
|
|
307
|
+
if (live) { try { live.list().forEach((s) => { if (!ids.includes(String(s.id))) ids.push(String(s.id)) }) } catch (e) { /* ignore */ } }
|
|
308
|
+
wsByPath = {}
|
|
309
|
+
try { for (const ent of w.list()) wsByPath[ent.path] = ent } catch (e) { wsByPath = {} }
|
|
310
|
+
const currentArchived = new Set((await archivedState().catch(() => ({ archivedSessionIds: [] }))).archivedSessionIds || [])
|
|
311
|
+
const items = []
|
|
312
|
+
const CHUNK = 6
|
|
313
|
+
for (let i = 0; i < ids.length; i += CHUNK) {
|
|
314
|
+
const res2 = await Promise.all(ids.slice(i, i + CHUNK).map(resolveOne))
|
|
315
|
+
for (const it of res2) items.push({ ...it, archived: currentArchived.has(it.sessionId) })
|
|
316
|
+
}
|
|
317
|
+
return items
|
|
318
|
+
}
|
|
319
|
+
|
|
171
320
|
ctx.effect(() => {
|
|
172
321
|
const disposers = []
|
|
173
322
|
|
|
@@ -272,6 +421,87 @@ export function apply(ctx) {
|
|
|
272
421
|
},
|
|
273
422
|
}))
|
|
274
423
|
|
|
424
|
+
// All conversations (for the "移动会话" panel).
|
|
425
|
+
disposers.push(ctx.webServer.register({
|
|
426
|
+
kind: 'exact',
|
|
427
|
+
path: '/archived-sessions/sessions',
|
|
428
|
+
handler: async (req, res) => {
|
|
429
|
+
try {
|
|
430
|
+
json(res, { items: await allSessionItems() })
|
|
431
|
+
} catch (e) {
|
|
432
|
+
json(res, { error: String((e && e.message) || e) }, 500)
|
|
433
|
+
}
|
|
434
|
+
},
|
|
435
|
+
}))
|
|
436
|
+
|
|
437
|
+
// Available target workspaces (for the move picker).
|
|
438
|
+
disposers.push(ctx.webServer.register({
|
|
439
|
+
kind: 'exact',
|
|
440
|
+
path: '/archived-sessions/workspaces',
|
|
441
|
+
handler: async (req, res) => {
|
|
442
|
+
try {
|
|
443
|
+
json(res, { items: await listWorkspaces() })
|
|
444
|
+
} catch (e) {
|
|
445
|
+
json(res, { error: String((e && e.message) || e) }, 500)
|
|
446
|
+
}
|
|
447
|
+
},
|
|
448
|
+
}))
|
|
449
|
+
|
|
450
|
+
// Move one conversation to a target workspace (existing path or a new one).
|
|
451
|
+
disposers.push(ctx.webServer.register({
|
|
452
|
+
kind: 'exact',
|
|
453
|
+
path: '/archived-sessions/move',
|
|
454
|
+
handler: async (req, res) => {
|
|
455
|
+
try {
|
|
456
|
+
const body = await readJsonBody(req)
|
|
457
|
+
const sid = body && typeof body.sessionId === 'string' ? body.sessionId : null
|
|
458
|
+
const target = body && typeof body.targetPath === 'string' ? body.targetPath : null
|
|
459
|
+
if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
|
|
460
|
+
if (!target) return json(res, { ok: false, error: 'missing targetPath' }, 400)
|
|
461
|
+
json(res, { sessionId: sid, ...(await moveOne(sid, target)) })
|
|
462
|
+
} catch (e) {
|
|
463
|
+
json(res, { ok: false, error: String((e && e.message) || e) }, 500)
|
|
464
|
+
}
|
|
465
|
+
},
|
|
466
|
+
}))
|
|
467
|
+
|
|
468
|
+
// Archive (hide) one session.
|
|
469
|
+
disposers.push(ctx.webServer.register({
|
|
470
|
+
kind: 'exact',
|
|
471
|
+
path: '/archived-sessions/archive',
|
|
472
|
+
handler: async (req, res) => {
|
|
473
|
+
try {
|
|
474
|
+
const body = await readJsonBody(req)
|
|
475
|
+
const sid = body && typeof body.sessionId === 'string' ? body.sessionId : null
|
|
476
|
+
if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
|
|
477
|
+
json(res, { sessionId: sid, ...(await archiveOne(sid)) })
|
|
478
|
+
} catch (e) {
|
|
479
|
+
json(res, { ok: false, error: String((e && e.message) || e) }, 500)
|
|
480
|
+
}
|
|
481
|
+
},
|
|
482
|
+
}))
|
|
483
|
+
|
|
484
|
+
// Archive (hide) many sessions.
|
|
485
|
+
disposers.push(ctx.webServer.register({
|
|
486
|
+
kind: 'exact',
|
|
487
|
+
path: '/archived-sessions/archive-many',
|
|
488
|
+
handler: async (req, res) => {
|
|
489
|
+
try {
|
|
490
|
+
const body = await readJsonBody(req)
|
|
491
|
+
const ids = parseIds(body)
|
|
492
|
+
if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
|
|
493
|
+
const results = []
|
|
494
|
+
for (const sid of ids) {
|
|
495
|
+
try { results.push({ sessionId: sid, ok: true, ...(await archiveOne(sid)) }) }
|
|
496
|
+
catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
|
|
497
|
+
}
|
|
498
|
+
json(res, { ok: true, archived: results.filter((r) => r.ok).length, results })
|
|
499
|
+
} catch (e) {
|
|
500
|
+
json(res, { ok: false, error: String((e && e.message) || e) }, 500)
|
|
501
|
+
}
|
|
502
|
+
},
|
|
503
|
+
}))
|
|
504
|
+
|
|
275
505
|
return () => { for (const d of disposers) d() }
|
|
276
506
|
}, 'dsh-archived-sessions: routes')
|
|
277
507
|
}
|