@device1999/dsh-session-archive-delete 0.0.0-stage → 0.1.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.
Binary file
Binary file
@@ -0,0 +1,7 @@
1
+ # Bundle patch layer: 把 @device1999/dsh-session-archive-delete 插入 profile 的插件行。
2
+ # 客户端半侧走标准 dsh.client 声明(package.json 里的 dsh.client + exports["./client"]),
3
+ # 不要再用 webserver/index-inject 追加 <script>,否则同一 factory 会被注册两次,
4
+ # 触发 "duplicate factory registration",导致 web boot 失败。
5
+ - insert:
6
+ - id: '@device1999/dsh-session-archive-delete'
7
+ name: '@device1999/dsh-session-archive-delete'
package/lib/backup.js ADDED
@@ -0,0 +1,221 @@
1
+ /**
2
+ * dsh-session-archive-delete · 备份
3
+ *
4
+ * 把「一个会话」完整复制到它自己的文件夹里,逐文件校验大小与数量,通过后才返回。
5
+ * 目录结构与 delete-archived-sessions.ps1 完全一致,两边可以互相恢复:
6
+ *
7
+ * <backupRoot>\<id前8位>-<标题>\
8
+ * sessions\<工作区目录>\session-<完整id>\ 会话原始文件(原名)
9
+ * projcache\session-<完整id>.json 投影缓存(原名)
10
+ * meta\workspace.json 删除前的元数据快照
11
+ * meta\session_projcache.json
12
+ * manifest.json 清单
13
+ * RESTORE.txt / 恢复说明.txt 恢复步骤
14
+ */
15
+ import fs from 'node:fs'
16
+ import path from 'node:path'
17
+ import { listFiles, isInside, uniqueDir } from './paths.js'
18
+
19
+ /**
20
+ * 按 dateStyle 生成备份文件夹名。
21
+ * dateStyle:
22
+ * 'default' → 日期+主题 20261020_安装验证 DSH 插件
23
+ * 'id-title' → ID前8位+主题 a6ccb2ee-安装验证 DSH 插件
24
+ * 'title' → 纯主题 安装验证 DSH 插件
25
+ * 'id-title-date'→ 前八位+主题+日期 a6ccb2ee-安装验证 DSH 插件_20261020
26
+ * 日期一律紧凑格式 YYYYMMDD;重名由 uniqueDir 加 -2/-3。
27
+ */
28
+ export function folderNameByStyle(dateStyle, sessionId, title, now) {
29
+ const d = now instanceof Date ? now : new Date()
30
+ const y = d.getFullYear()
31
+ const m = String(d.getMonth() + 1).padStart(2, '0')
32
+ const day = String(d.getDate()).padStart(2, '0')
33
+ const date = '' + y + m + day
34
+ const t = safeTitle(title)
35
+ const id8 = idPrefixOf(sessionId)
36
+ switch (dateStyle) {
37
+ case 'id-title':
38
+ return (id8 ? id8 + '-' : '') + t
39
+ case 'title':
40
+ return t
41
+ case 'id-title-date':
42
+ return (id8 ? id8 + '-' : '') + t + '_' + date
43
+ case 'default':
44
+ default:
45
+ return date + '_' + t
46
+ }
47
+ }
48
+
49
+ // 内部小工具:避免与 paths.js 的命名函数耦合
50
+ import { safeFileName as _safeFileName, idPrefix as _idPrefix } from './paths.js'
51
+ function safeTitle(title) {
52
+ const s = _safeFileName(title) || '未命名'
53
+ return s
54
+ }
55
+ function idPrefixOf(sessionId) {
56
+ try {
57
+ return _idPrefix(sessionId)
58
+ } catch {
59
+ return ''
60
+ }
61
+ }
62
+
63
+ /**
64
+ * 比较源目录与备份目录:文件数一致、每个文件大小一致。
65
+ * @returns {string|null} 不一致原因;一致返回 null
66
+ */
67
+ export function verifyCopy(srcRoot, dstRoot) {
68
+ const src = listFiles(srcRoot)
69
+ for (const f of src) {
70
+ const rel = path.relative(srcRoot, f)
71
+ const dst = path.join(dstRoot, rel)
72
+ if (!fs.existsSync(dst)) return '备份缺少文件:' + rel
73
+ const a = fs.statSync(f).size
74
+ const b = fs.statSync(dst).size
75
+ if (a !== b) return '备份大小不一致:' + rel
76
+ }
77
+ const dst = listFiles(dstRoot)
78
+ if (dst.length !== src.length) return '备份文件数不一致:源 ' + src.length + ',备份 ' + dst.length
79
+ return null
80
+ }
81
+
82
+ /**
83
+ * 备份一个会话。
84
+ *
85
+ * @param {object} p
86
+ * @param {string} p.backupRoot 备份根目录(必须已解析)
87
+ * @param {string} p.sessionId 完整会话 id
88
+ * @param {string} p.sessionDir 会话目录绝对路径
89
+ * @param {string|null} p.cacheFile 投影缓存文件绝对路径
90
+ * @param {string} p.workspace 工作区目录名(sessions 下的那层)
91
+ * @param {string} p.title 标题
92
+ * @param {object} p.snapshots { workspaceJson, projcacheJson } 快照源路径
93
+ * @returns {{ok:boolean, dir?:string, name?:string, error?:string}}
94
+ */
95
+ export function backupSession(p) {
96
+ const { backupRoot, sessionId, sessionDir, cacheFile, workspace, title, snapshots, extraSessions } = p
97
+
98
+ try {
99
+ if (!backupRoot) throw new Error('备份根目录为空')
100
+ fs.mkdirSync(backupRoot, { recursive: true })
101
+ if (!isInside(backupRoot, sessionDir)) {
102
+ // sessionDir 不必在 backupRoot 下,但必须是一个真实存在的会话目录
103
+ if (!fs.existsSync(sessionDir)) throw new Error('会话目录不存在:' + sessionDir)
104
+ }
105
+
106
+ const name = folderNameByStyle(p.dateStyle, sessionId, title)
107
+ const dir = uniqueDir(backupRoot, name)
108
+
109
+ const sessionsDst = path.join(dir, 'sessions')
110
+ const cacheDst = path.join(dir, 'projcache')
111
+ const metaDst = path.join(dir, 'meta')
112
+ for (const d of [sessionsDst, cacheDst, metaDst]) fs.mkdirSync(d, { recursive: true })
113
+
114
+ // 1) 会话目录:原名复制
115
+ const leaf = path.basename(sessionDir)
116
+ const sessTarget = path.join(sessionsDst, workspace, leaf)
117
+ fs.mkdirSync(path.dirname(sessTarget), { recursive: true })
118
+ fs.cpSync(sessionDir, sessTarget, { recursive: true, force: true, errorOnExist: false })
119
+ const problem = verifyCopy(sessionDir, sessTarget)
120
+ if (problem) throw new Error(problem)
121
+
122
+ // 2) 投影缓存:原名复制
123
+ let cacheLeaf = null
124
+ if (cacheFile && fs.existsSync(cacheFile)) {
125
+ cacheLeaf = path.basename(cacheFile)
126
+ fs.copyFileSync(cacheFile, path.join(cacheDst, cacheLeaf))
127
+ const a = fs.statSync(cacheFile).size
128
+ const b = fs.statSync(path.join(cacheDst, cacheLeaf)).size
129
+ if (a !== b) throw new Error('缓存备份大小不一致')
130
+ }
131
+
132
+ // 3) 元数据快照
133
+ const metaFiles = []
134
+ for (const src of [snapshots && snapshots.workspaceJson, snapshots && snapshots.projcacheJson]) {
135
+ if (src && fs.existsSync(src)) {
136
+ const n = path.basename(src)
137
+ fs.copyFileSync(src, path.join(metaDst, n))
138
+ metaFiles.push(n)
139
+ }
140
+ }
141
+
142
+ // 4) 子代理会话:一起备份进同一个文件夹(sessions/<工作区>/<目录名> + projcache)
143
+ const subagents = []
144
+ for (const sub of extraSessions || []) {
145
+ if (!sub || !sub.dir || !fs.existsSync(sub.dir)) continue
146
+ const subLeaf = path.basename(sub.dir)
147
+ const subWs = sub.workspace || workspace
148
+ const subTarget = path.join(sessionsDst, subWs, subLeaf)
149
+ try {
150
+ fs.mkdirSync(path.dirname(subTarget), { recursive: true })
151
+ fs.cpSync(sub.dir, subTarget, { recursive: true, force: true, errorOnExist: false })
152
+ const bad = verifyCopy(sub.dir, subTarget)
153
+ if (bad) throw new Error(bad)
154
+ // 子会话的投影缓存(存在就带上)
155
+ if (sub.cacheFile && fs.existsSync(sub.cacheFile)) {
156
+ fs.copyFileSync(sub.cacheFile, path.join(cacheDst, path.basename(sub.cacheFile)))
157
+ }
158
+ subagents.push({ id: sub.id, workspace: subWs, dirName: subLeaf })
159
+ } catch (err) {
160
+ // 子会话备份失败不阻断主会话,但记录下来
161
+ subagents.push({ id: sub.id, workspace: subWs, dirName: subLeaf, error: String((err && err.message) || err) })
162
+ }
163
+ }
164
+
165
+ // 5) 清单
166
+ const fileCount = listFiles(sessTarget).length
167
+ let bytes = 0
168
+ for (const f of listFiles(sessTarget)) bytes += fs.statSync(f).size
169
+
170
+ const manifest = {
171
+ schema: 3,
172
+ createdAt: new Date().toISOString(),
173
+ generator: 'dsh-session-archive-delete',
174
+ backupDir: dir,
175
+ layout: 'one folder per session; files keep their original names',
176
+ session: {
177
+ id: sessionId,
178
+ title: title || null,
179
+ workspace,
180
+ source: sessionDir,
181
+ files: fileCount,
182
+ bytes,
183
+ cacheFile: cacheFile || null,
184
+ },
185
+ subagents: subagents.length > 0 ? subagents : undefined,
186
+ metaFiles,
187
+ }
188
+ fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8')
189
+
190
+ // 6) 恢复说明
191
+ const restore = [
192
+ 'DSH 会话备份(单会话)',
193
+ '========================',
194
+ '备份时间:' + new Date().toLocaleString(),
195
+ '会话 id :' + sessionId,
196
+ '标题 :' + (title || '(无)'),
197
+ '工作区 :' + workspace,
198
+ subagents.length > 0 ? '子代理 :' + subagents.length + ' 个(见下方说明)' : '',
199
+ '',
200
+ '如何恢复',
201
+ '--------',
202
+ '1. 完全退出 DSH。',
203
+ '2. 把 sessions\\' + workspace + '\\' + leaf + ' 复制回 ~/.dsh/sessions/' + workspace + '/',
204
+ '3. 把 projcache\\' + (cacheLeaf || '<缓存文件>') + ' 复制回 ~/.dsh/storages/session_projcache/sessions/',
205
+ '4. 在 ~/.dsh/storages/workspace.json 中把 session id 加回 global.archivedSessionIds',
206
+ '5. 启动 DSH。',
207
+ subagents.length > 0 ? '' : null,
208
+ subagents.length > 0 ? '子代理会话(同样复制回各自的 sessions\\<工作区>\\ 下):' : null,
209
+ ...subagents.map((s) => ' - sessions\\' + s.workspace + '\\' + s.dirName + (s.error ? '(备份失败:' + s.error + ')' : '')),
210
+ '',
211
+ '注意:恢复前先关闭 DSH,否则它可能用内存里的旧状态覆盖元数据。',
212
+ ]
213
+ .filter((x) => x !== null)
214
+ .join('\r\n')
215
+ fs.writeFileSync(path.join(dir, 'RESTORE.txt'), restore, 'utf8')
216
+
217
+ return { ok: true, dir, name: path.basename(dir), subagents }
218
+ } catch (err) {
219
+ return { ok: false, error: String((err && err.message) || err) }
220
+ }
221
+ }
package/lib/delete.js ADDED
@@ -0,0 +1,252 @@
1
+ /**
2
+ * dsh-session-archive-delete · 删除
3
+ *
4
+ * 删除顺序参照 dsh-session-menu 的 removeSession,尽量做到「不留半删除会话」:
5
+ * 1. 运行中 → 取消当前轮次并等待结束(最长 3 秒),不阻塞
6
+ * 2. 先 detach 运行时,避免 dispose 阶段把日志目录写回来
7
+ * 3. 删除磁盘上的会话目录(删前确认目录名与会话 id 一致)
8
+ * 4. 删除后复查 3 次,防止句柄把目录重建
9
+ * 5. 最后才清理归档账本(unarchiveSession / unpinSession)
10
+ *
11
+ * 注意:预算备份由调用方在真正删除前完成,本模块只负责「删」。
12
+ */
13
+ import fs from 'node:fs'
14
+ import path from 'node:path'
15
+ import { dshHome } from './paths.js'
16
+
17
+ const SESSION_ID_RE = /^(session-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
18
+
19
+ export function isValidSessionId(id) {
20
+ return SESSION_ID_RE.test(String(id || ''))
21
+ }
22
+
23
+ /**
24
+ * 静默停止运行中的 agent。
25
+ * @returns {boolean} 是否真的取消了一个正在运行的 agent
26
+ */
27
+ async function stopIfRunning(ctx, sessionId) {
28
+ const agents = ctx.get ? ctx.get('agents') : ctx.agents
29
+ if (!agents || typeof agents.get !== 'function') return false
30
+ const agent = agents.get(sessionId)
31
+ if (!agent) return false
32
+ try {
33
+ if (typeof agent.cancel === 'function') agent.cancel({ kind: 'user' })
34
+ } catch {}
35
+ if (typeof agent.whenIdle === 'function') {
36
+ try {
37
+ await Promise.race([agent.whenIdle(), new Promise((r) => setTimeout(r, 3000))])
38
+ } catch {}
39
+ }
40
+ return true
41
+ }
42
+
43
+ /** 从运行时摘除会话,防止 dispose 阶段回写目录 */
44
+ function detachLive(ctx, sessionId) {
45
+ const sessions = ctx.get ? ctx.get('sessions') : ctx.sessions
46
+ if (!sessions) return false
47
+ try {
48
+ const live = typeof sessions.get === 'function' ? sessions.get(sessionId) : undefined
49
+ if (!live) return false
50
+ if (typeof sessions.liveEntryFor === 'function' && typeof sessions.detachEntered === 'function') {
51
+ const entry = sessions.liveEntryFor(live)
52
+ if (entry !== undefined) {
53
+ sessions.detachEntered(entry)
54
+ return true
55
+ }
56
+ }
57
+ } catch {}
58
+ return false
59
+ }
60
+
61
+ /** 清掉归档 / 置顶账本里的记录(方法不存在时静默跳过) */
62
+ async function forgetLedger(ctx, sessionId) {
63
+ const reg = ctx.get ? ctx.get('workspaceRegistry') : ctx.workspaceRegistry
64
+ if (!reg) return false
65
+ let touched = false
66
+ for (const m of ['unarchiveSession', 'unpinSession']) {
67
+ try {
68
+ if (typeof reg[m] === 'function') {
69
+ await reg[m](sessionId)
70
+ touched = true
71
+ }
72
+ } catch {}
73
+ }
74
+ return touched
75
+ }
76
+
77
+ /**
78
+ * 直接从 workspace.json 里摘掉会话引用(B 方案:先写账本,再删文件)。
79
+ *
80
+ * 为什么必须直接改文件:宿主的内存状态在删除后仍可能 flush 一次,
81
+ * 把 sessionIds / archivedSessionIds 又写回来。先落盘摘引用,
82
+ * 删文件后即使发生 flush,也只会带着「已摘除」的状态写回。
83
+ *
84
+ * 只动这三处,其他字段一字不改:
85
+ * global.archivedSessionIds
86
+ * global.pinnedSessionIds
87
+ * tables.workspaces.*.sessionIds
88
+ *
89
+ * @returns {{ok:boolean, removed:number, error?:string}}
90
+ */
91
+ function stripWorkspaceJson(sessionId) {
92
+ const home = dshHome()
93
+ const file = path.join(home, 'storages', 'workspace.json')
94
+ if (!fs.existsSync(file)) return { ok: false, removed: 0, error: 'workspace.json 不存在' }
95
+ try {
96
+ const raw = fs.readFileSync(file, 'utf8')
97
+ const doc = JSON.parse(raw)
98
+ const leaf = sessionId.replace(/^session-/, '')
99
+ const variants = new Set([sessionId, leaf, 'session-' + leaf])
100
+ let removed = 0
101
+ const keep = (arr) => {
102
+ if (!Array.isArray(arr)) return arr
103
+ const out = arr.filter((x) => !variants.has(String(x)))
104
+ removed += arr.length - out.length
105
+ return out
106
+ }
107
+ if (doc.global) {
108
+ if (Array.isArray(doc.global.archivedSessionIds)) doc.global.archivedSessionIds = keep(doc.global.archivedSessionIds)
109
+ if (Array.isArray(doc.global.pinnedSessionIds)) doc.global.pinnedSessionIds = keep(doc.global.pinnedSessionIds)
110
+ }
111
+ const workspaces = doc.tables && doc.tables.workspaces
112
+ if (workspaces && typeof workspaces === 'object') {
113
+ for (const key of Object.keys(workspaces)) {
114
+ const ws = workspaces[key]
115
+ if (ws && Array.isArray(ws.sessionIds)) ws.sessionIds = keep(ws.sessionIds)
116
+ }
117
+ }
118
+ if (removed > 0) {
119
+ const tmp = file + '.ad-tmp'
120
+ fs.writeFileSync(tmp, JSON.stringify(doc, null, 2), 'utf8')
121
+ fs.renameSync(tmp, file)
122
+ }
123
+ return { ok: true, removed }
124
+ } catch (e) {
125
+ return { ok: false, removed: 0, error: String((e && e.message) || e) }
126
+ }
127
+ }
128
+
129
+ /**
130
+ * 同步清理投影缓存索引(storages/session_projcache.json)。
131
+ * 只删 tables.sessions 下本会话那几条,其他结构不动。
132
+ */
133
+ function stripProjCacheIndex(sessionId) {
134
+ const home = dshHome()
135
+ const file = path.join(home, 'storages', 'session_projcache.json')
136
+ if (!fs.existsSync(file)) return { ok: true, removed: 0 }
137
+ try {
138
+ const doc = JSON.parse(fs.readFileSync(file, 'utf8'))
139
+ const leaf = sessionId.replace(/^session-/, '')
140
+ const variants = new Set([sessionId, leaf, 'session-' + leaf])
141
+ let removed = 0
142
+ const sessions = doc && doc.tables && doc.tables.sessions
143
+ if (sessions && typeof sessions === 'object') {
144
+ for (const k of Object.keys(sessions)) {
145
+ if (variants.has(k)) {
146
+ delete sessions[k]
147
+ removed++
148
+ }
149
+ }
150
+ }
151
+ if (removed > 0) {
152
+ const tmp = file + '.ad-tmp'
153
+ fs.writeFileSync(tmp, JSON.stringify(doc, null, 2), 'utf8')
154
+ fs.renameSync(tmp, file)
155
+ }
156
+ return { ok: true, removed }
157
+ } catch (e) {
158
+ return { ok: false, removed: 0, error: String((e && e.message) || e) }
159
+ }
160
+ }
161
+
162
+ /**
163
+ * 删除一个会话。
164
+ *
165
+ * 执行顺序(B 方案):
166
+ * 0. 先摘掉 workspace.json 里的引用并落盘 ← 防止删除后被内存 flush 写回
167
+ * 1. 运行中 → 取消当前轮次并等待结束(最长 3 秒)
168
+ * 2. detach 运行时,避免 dispose 阶段把日志目录写回
169
+ * 3. 删磁盘会话目录(删前校验目录名与会话 id 一致)
170
+ * 4. 复查 3 次,防止句柄把目录重建
171
+ * 5. 删投影缓存文件
172
+ * 6. 再摘一次引用(兜底:上面几步耗时期间可能又被写回)
173
+ *
174
+ * @param {object} ctx 插件上下文(提供 get)
175
+ * @param {string} sessionId 完整会话 id
176
+ * @param {string} sessionDir 会话目录绝对路径(删除前会做名字校验)
177
+ * @param {string} sessionsRoot sessions 根目录(边界校验)
178
+ * @param {string|null} cacheFile 投影缓存文件
179
+ * @returns {Promise<{ok:boolean, removed?:boolean, stopped?:boolean, detached?:boolean, ledger?:boolean, unlinked?:number, error?:string}>}
180
+ */
181
+ export async function deleteSession({ ctx, sessionId, sessionDir, sessionsRoot, cacheFile }) {
182
+ if (!isValidSessionId(sessionId)) {
183
+ return { ok: false, error: 'invalid session id: ' + sessionId }
184
+ }
185
+
186
+ // 硬性边界:目录必须在 sessions 根下,且目录名就是会话 id
187
+ const resolvedDir = path.resolve(sessionDir)
188
+ const resolvedRoot = path.resolve(sessionsRoot || path.dirname(path.dirname(resolvedDir)))
189
+ if (!resolvedDir.startsWith(resolvedRoot + path.sep)) {
190
+ return { ok: false, error: '路径越界:' + resolvedDir }
191
+ }
192
+ if (path.basename(resolvedDir) !== sessionId) {
193
+ return { ok: false, error: '目录名与会话 id 不一致,拒绝删除:' + path.basename(resolvedDir) }
194
+ }
195
+
196
+ // 0) 先摘账本引用并落盘
197
+ const pre = stripWorkspaceJson(sessionId)
198
+ stripProjCacheIndex(sessionId)
199
+
200
+ const stopped = await stopIfRunning(ctx, sessionId)
201
+ const detached = detachLive(ctx, sessionId)
202
+
203
+ let removed = false
204
+ try {
205
+ if (fs.existsSync(resolvedDir)) {
206
+ fs.rmSync(resolvedDir, { recursive: true, force: true })
207
+ removed = true
208
+ }
209
+ } catch (err) {
210
+ return { ok: false, stopped, detached, error: '删除失败:' + String((err && err.message) || err) }
211
+ }
212
+
213
+ // 复查:句柄可能把目录重建
214
+ for (let i = 0; i < 3; i++) {
215
+ if (!fs.existsSync(resolvedDir)) break
216
+ await new Promise((r) => setTimeout(r, 150))
217
+ try {
218
+ fs.rmSync(resolvedDir, { recursive: true, force: true })
219
+ } catch {}
220
+ }
221
+ if (fs.existsSync(resolvedDir)) {
222
+ return { ok: false, stopped, detached, error: '目录删除后又被重建:' + resolvedDir }
223
+ }
224
+
225
+ // 缓存文件
226
+ if (cacheFile && fs.existsSync(cacheFile)) {
227
+ try {
228
+ fs.rmSync(cacheFile, { force: true })
229
+ } catch {}
230
+ }
231
+
232
+ const ledger = await forgetLedger(ctx, sessionId)
233
+
234
+ // 6) 兜底:上面耗时期间内存可能又 flush 写回,再摘一次
235
+ await new Promise((r) => setTimeout(r, 200))
236
+ const post = stripWorkspaceJson(sessionId)
237
+ stripProjCacheIndex(sessionId)
238
+ if (cacheFile && fs.existsSync(cacheFile)) {
239
+ try {
240
+ fs.rmSync(cacheFile, { force: true })
241
+ } catch {}
242
+ }
243
+
244
+ return {
245
+ ok: true,
246
+ removed,
247
+ stopped,
248
+ detached,
249
+ ledger,
250
+ unlinked: Math.max(pre.removed || 0, post.removed || 0),
251
+ }
252
+ }