@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/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 `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'
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
  }