@device1999/dsh-session-archive-delete 0.0.0-stage → 0.1.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/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +153 -2
- package/assets/client.js +1083 -0
- package/cordis.patch.yml +6 -0
- package/lib/backup.js +221 -0
- package/lib/delete.js +252 -0
- package/lib/index.js +822 -0
- package/lib/paths.js +105 -0
- package/lib/store.js +77 -0
- package/lib/subtree.js +138 -0
- package/package.json +58 -5
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Bundle patch layer: 把 dsh-session-archive-delete 插入 profile 的插件行。
|
|
2
|
+
# 客户端脚本不走 dsh.client,而由宿主半通过 webserver/index-inject 以 <script> 注入
|
|
3
|
+
# (与 dsh-local-patches / dsh1024 同一种机制)。
|
|
4
|
+
- insert:
|
|
5
|
+
- id: dsh-session-archive-delete
|
|
6
|
+
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
|
+
}
|