dsh-session-curator 0.2.0 → 0.3.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/README.md +6 -5
- package/README.zh-CN.md +6 -5
- package/lib/client.js +124 -458
- package/lib/host/delete.js +77 -0
- package/lib/host/fs-utils.js +1 -15
- package/lib/host/open.js +9 -15
- package/lib/host/paths.js +5 -13
- package/lib/host/routes.js +3 -20
- package/lib/host/session-manager.js +22 -159
- package/lib/index.js +6 -10
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +5 -5
- package/lib/host/retention.js +0 -24
- package/lib/host/trash.js +0 -279
package/lib/host/trash.js
DELETED
|
@@ -1,279 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 危险操作:软删除(移入回收站)/ 恢复 / 永久删除。
|
|
3
|
-
*
|
|
4
|
-
* 这个文件里每一条顺序都是踩过事故之后定下来的,动之前先读注释;回归测试在
|
|
5
|
-
* `scripts/smoke.mjs` 的"软删除语义(临时 DSH_HOME)"那一段。
|
|
6
|
-
*
|
|
7
|
-
* ── `__trash.json`(回收站条目的元数据)──────────────────────────────────
|
|
8
|
-
* 位置:`<DSH_HOME>/storages/session-manager/trash/<sessionId>/__trash.json`,
|
|
9
|
-
* 与会话自己的文件(`session.jsonl` 等)**同级同搬** —— 所以"恢复"就是把整个目录搬回
|
|
10
|
-
* `<DSH_HOME>/sessions/<slug>/<sessionId>/`,元数据跟着回去,搬完再删掉它。
|
|
11
|
-
*
|
|
12
|
-
* **v1**(当前,`TRASH_META_VERSION = 1`)的字段:
|
|
13
|
-
*
|
|
14
|
-
* | 字段 | 类型 | 说明 |
|
|
15
|
-
* | --- | --- | --- |
|
|
16
|
-
* | `version` | `number` | 写这份文件的 schema 版本。**最早那一版没有这个字段**(插件还叫 `dsh-session-manager` 的时代),读的时候按 v1 处理 |
|
|
17
|
-
* | `sessionId` | `string` | 会话 id;与目录名一致,冗余存一份便于人眼排错 |
|
|
18
|
-
* | `slug` | `string` | 原目录 `<DSH_HOME>/sessions/<slug>/`。**恢复的关键字段**:缺失或不是非空字符串就恢复不了,界面会按 `restorable: false` 显示"无法恢复(缺元数据)" |
|
|
19
|
-
* | `title` | `string \| null` | 删除时的标题(只用于展示) |
|
|
20
|
-
* | `cwd` | `string \| null` | 原工作区路径;恢复时拿它去 `resolveByPath()` 把会话挂回去 |
|
|
21
|
-
* | `workspaceId` | `string \| null` | 原工作区 id(展示 / 分组用,恢复**不**依赖它) |
|
|
22
|
-
* | `workspaceTitle` | `string \| null` | 原工作区标题(同上) |
|
|
23
|
-
* | `wasArchived` | `boolean` | 删除前是否已经在归档集合里;恢复时照原样还原。老条目没有这个字段,按 `false` 处理 |
|
|
24
|
-
* | `deletedAt` | `number` | 删除时间(`Date.now()`)。`0` 或缺失 = 认不出删除时间的老条目,**永远不算过期**,只能逐条永久删除(见 `retention.ts`) |
|
|
25
|
-
*
|
|
26
|
-
* **版本约定:加字段不升版**。
|
|
27
|
-
* 1. 读方必须容忍**缺字段**(老条目、以及将来加了字段之后的新条目),逐字段给默认值 ——
|
|
28
|
-
* 正因为如此,"新增一个可选字段"**不需要**动 `version`。
|
|
29
|
-
* 2. 只有**不兼容**的改动(改字段含义、挪位置、不重写就读不对)才 `+1`,并且必须同时
|
|
30
|
-
* `readTrashMeta()` 里补一段迁移:把老版本升成新版本,再往下走。
|
|
31
|
-
* 3. 读到**比自己新**的 `version` 一律**拒绝**(`readTrashMeta()` 抛错,逐项失败里带原因),
|
|
32
|
-
* 绝不按旧语义硬猜 —— 猜错的代价是"把一个会话恢复进错误的目录"。
|
|
33
|
-
*/
|
|
34
|
-
import { existsSync, promises as fsp } from "node:fs";
|
|
35
|
-
import { join } from "node:path";
|
|
36
|
-
import { messageOf, moveDir } from "./fs-utils.js";
|
|
37
|
-
import { META_FILE, PROJ_FILE, projCacheFile, sessionsRoot, trashRoot } from "./paths.js";
|
|
38
|
-
import { isExpired, retentionCutoff } from "./retention.js";
|
|
39
|
-
/** `__trash.json` 当前版本(字段表与版本约定见文件头)。 */
|
|
40
|
-
export const TRASH_META_VERSION = 1;
|
|
41
|
-
/**
|
|
42
|
-
* 软删除:把会话**藏起来**(进归档集合)+ 移出工作区记账 + 搬到回收站(含投影缓存)。
|
|
43
|
-
*
|
|
44
|
-
* 为什么是"归档"而不是"取消归档":DSH 里**唯一**能让一个会话从所有分组面
|
|
45
|
-
*(侧边栏的工作区分组、**未分组**、置顶)消失的机制,就是 `archivedSessionIds`
|
|
46
|
-
* 这个 registry-global 集合 —— 它的注释原话是 "sessions hidden from every grouping
|
|
47
|
-
* surface",而且归档**不动工作区记账**,槽位留着,所以恢复时位置不变。
|
|
48
|
-
*
|
|
49
|
-
* 反过来做(先 `unarchiveSession`)必然出幽灵行:取消归档会把会话重新暴露给
|
|
50
|
-
* 客户端那份会话列表(`ctx.sessions.list`),而它的目录虽然已经被我们搬走,
|
|
51
|
-
* 客户端 list 里那条还在 —— 侧边栏 `groupByWorkspace()` 的判定是
|
|
52
|
-
* "在 list 里 && 不属于任何工作区 && 不在归档集合里" → 落进**未分组**。
|
|
53
|
-
* 同时归档集合里已经没有它,等于谁也管不住这条幽灵行了。
|
|
54
|
-
*
|
|
55
|
-
* 原来是否归档记在 `__trash.json` 的 `wasArchived` 里,恢复时照原样还原。
|
|
56
|
-
*/
|
|
57
|
-
export async function trashSessions(host, ids) {
|
|
58
|
-
await fsp.mkdir(trashRoot(), { recursive: true });
|
|
59
|
-
const dirs = await host.scanSessionDirs();
|
|
60
|
-
return host.each(ids, async (sessionId) => {
|
|
61
|
-
const steps = [];
|
|
62
|
-
if (sessionId === process.env.DSH_SESSION_ID) {
|
|
63
|
-
throw new Error("这是当前正在使用的会话,不能删除");
|
|
64
|
-
}
|
|
65
|
-
const activity = await host.sessionActivity(sessionId);
|
|
66
|
-
if (activity.length > 0)
|
|
67
|
-
throw new Error("会话正在运行,请先停止它");
|
|
68
|
-
const info = dirs.get(sessionId);
|
|
69
|
-
if (!info)
|
|
70
|
-
throw new Error("找不到会话日志目录(可能已被删除)");
|
|
71
|
-
// 必须**趁目录还在**归档:`archiveSession` 要先通过 `sessionKnown()`
|
|
72
|
-
//(live 会话 / 头部索引 / 持久化清单),搬走之后三者都查不到了。
|
|
73
|
-
const wasArchived = host.registry.archivedSessionIds.includes(sessionId);
|
|
74
|
-
if (!wasArchived) {
|
|
75
|
-
try {
|
|
76
|
-
await host.registry.archiveSession(sessionId);
|
|
77
|
-
steps.push("archive");
|
|
78
|
-
}
|
|
79
|
-
catch (error) {
|
|
80
|
-
// 藏不住不该阻止删除:会话可能继续留在侧边栏,但数据照样进回收站。
|
|
81
|
-
steps.push(`archive-failed:${messageOf(error)}`);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
// 置顶的会话也要先取消置顶:否则它的 id 还留在 pinnedSessionIds 里,
|
|
85
|
-
// 搬进回收站之后会同时出现在"已置顶"和"回收站"两个页签。
|
|
86
|
-
if (host.registry.pinnedSessionIds.includes(sessionId)) {
|
|
87
|
-
await host.registry.unpinSession(sessionId);
|
|
88
|
-
steps.push("unpin");
|
|
89
|
-
}
|
|
90
|
-
const workspace = host.findWorkspaceOf(sessionId);
|
|
91
|
-
if (workspace) {
|
|
92
|
-
try {
|
|
93
|
-
if (typeof workspace.detachSession === "function") {
|
|
94
|
-
await workspace.detachSession(sessionId);
|
|
95
|
-
steps.push("detach");
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
catch (error) {
|
|
99
|
-
steps.push(`detach-failed:${messageOf(error)}`);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
const projection = await host.readProjection(sessionId);
|
|
103
|
-
const target = join(trashRoot(), sessionId);
|
|
104
|
-
await fsp.rm(target, { recursive: true, force: true });
|
|
105
|
-
await moveDir(info.dir, target);
|
|
106
|
-
steps.push("move");
|
|
107
|
-
const cached = projCacheFile(sessionId);
|
|
108
|
-
if (existsSync(cached)) {
|
|
109
|
-
try {
|
|
110
|
-
await fsp.rename(cached, join(target, PROJ_FILE));
|
|
111
|
-
steps.push("projcache");
|
|
112
|
-
}
|
|
113
|
-
catch {
|
|
114
|
-
// 缓存搬不动不影响会话本身
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
// 写入侧就用 `TrashMeta` 这个类型:schema 改了却忘了改字段表,tsc 当场就红。
|
|
118
|
-
const meta = {
|
|
119
|
-
version: TRASH_META_VERSION,
|
|
120
|
-
sessionId,
|
|
121
|
-
slug: info.slug,
|
|
122
|
-
title: projection.title,
|
|
123
|
-
cwd: projection.cwd,
|
|
124
|
-
workspaceId: workspace ? String(workspace.id) : null,
|
|
125
|
-
workspaceTitle: workspace ? workspace.title : null,
|
|
126
|
-
// 删除前是否已经归档 —— 恢复时要照原样还原(老条目没有这个字段,
|
|
127
|
-
// 一律按"没归档过"处理,也就是恢复成普通会话)。
|
|
128
|
-
wasArchived,
|
|
129
|
-
deletedAt: Date.now()
|
|
130
|
-
};
|
|
131
|
-
await fsp.writeFile(join(target, META_FILE), JSON.stringify(meta, null, 2), "utf8");
|
|
132
|
-
return steps;
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
|
-
/**
|
|
136
|
-
* 读一条回收站元数据(字段表与版本约定见文件头)。
|
|
137
|
-
*
|
|
138
|
-
* 三种结果:
|
|
139
|
-
* - 文件不在 / 不是 JSON 对象 / JSON 坏了 → `null`。与"没有元数据"同义:恢复不了,
|
|
140
|
-
* 但列表仍然要能显示它(`listTrash()` 会给它 `deletedAt: 0` / `restorable: false`)。
|
|
141
|
-
* - `version` 缺失(最早那一版写下的)或 ≤ 当前 → 原样返回,字段由调用方逐个校验。
|
|
142
|
-
* - `version` **比当前新** → 抛错。语义不可猜,宁可不恢复,也不能把一个会话按旧规则
|
|
143
|
-
* 塞进错误的目录(错误信息会出现在批量操作的逐项失败里)。
|
|
144
|
-
*
|
|
145
|
-
* 迁移放哪:将来真的升到 v2 时,在这里加一段"把 v1 读成 v2 形状"的转换,
|
|
146
|
-
* 然后再 `return`;`version` 的**当前**值只认 `TRASH_META_VERSION`。
|
|
147
|
-
*/
|
|
148
|
-
export async function readTrashMeta(dir) {
|
|
149
|
-
let line;
|
|
150
|
-
try {
|
|
151
|
-
line = await fsp.readFile(join(dir, META_FILE), "utf8");
|
|
152
|
-
}
|
|
153
|
-
catch {
|
|
154
|
-
return null;
|
|
155
|
-
}
|
|
156
|
-
let parsed;
|
|
157
|
-
try {
|
|
158
|
-
parsed = JSON.parse(line);
|
|
159
|
-
}
|
|
160
|
-
catch {
|
|
161
|
-
return null;
|
|
162
|
-
}
|
|
163
|
-
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
164
|
-
return null;
|
|
165
|
-
// 跨 JSON 边界的一次收窄(AGENTS.md §5 允许的那种):上面已经证明它是个对象。
|
|
166
|
-
const meta = parsed;
|
|
167
|
-
// `version` 缺失 = 最早那版(插件还叫 `dsh-session-manager` 的时代)写下的,按 v1 处理。
|
|
168
|
-
const written = typeof meta.version === "number" ? meta.version : TRASH_META_VERSION;
|
|
169
|
-
if (written > TRASH_META_VERSION) {
|
|
170
|
-
throw new Error(`这条回收站元数据是更新版本的插件写的(version ${written},本插件只认到 ${TRASH_META_VERSION}):先升级插件,再恢复`);
|
|
171
|
-
}
|
|
172
|
-
return meta;
|
|
173
|
-
}
|
|
174
|
-
/**
|
|
175
|
-
* 从回收站恢复;尽力把会话重新挂回原工作区,并把归档状态还原成删除前的样子。
|
|
176
|
-
*
|
|
177
|
-
* `trashSessions()` 会把会话塞进归档集合(藏起来),所以这里必须配对处理:
|
|
178
|
-
* - 删除前**已经归档**(`wasArchived: true`)→ 保持归档,它就回到"已归档"页签;
|
|
179
|
-
* - 其余(含老版本写下的、没有这个字段的条目)→ 取消归档,恢复成普通会话。
|
|
180
|
-
* 不还原的话,恢复出来的会话会莫名其妙地"还在已归档里"。
|
|
181
|
-
*/
|
|
182
|
-
export async function restoreSessions(host, ids) {
|
|
183
|
-
const dirs = await host.scanSessionDirs();
|
|
184
|
-
return host.each(ids, async (sessionId) => {
|
|
185
|
-
const steps = [];
|
|
186
|
-
const source = join(trashRoot(), sessionId);
|
|
187
|
-
if (!existsSync(source))
|
|
188
|
-
throw new Error("回收站里没有这个会话");
|
|
189
|
-
// 版本比插件新时这里会抛(原因进逐项失败),别的坏情况一律回 null。
|
|
190
|
-
const meta = await readTrashMeta(source);
|
|
191
|
-
if (typeof meta?.slug !== "string" || meta.slug === "") {
|
|
192
|
-
throw new Error("缺少回收站元数据,无法确定原目录");
|
|
193
|
-
}
|
|
194
|
-
if (dirs.has(sessionId))
|
|
195
|
-
throw new Error("目标会话目录已存在,请先处理同名会话");
|
|
196
|
-
const target = join(sessionsRoot(), meta.slug, sessionId);
|
|
197
|
-
await moveDir(source, target);
|
|
198
|
-
steps.push("move");
|
|
199
|
-
const cached = join(target, PROJ_FILE);
|
|
200
|
-
if (existsSync(cached)) {
|
|
201
|
-
try {
|
|
202
|
-
await fsp.rename(cached, projCacheFile(sessionId));
|
|
203
|
-
steps.push("projcache");
|
|
204
|
-
}
|
|
205
|
-
catch {
|
|
206
|
-
// 同上,非关键
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
try {
|
|
210
|
-
await fsp.rm(join(target, META_FILE), { force: true });
|
|
211
|
-
}
|
|
212
|
-
catch {
|
|
213
|
-
// 元数据残留无害
|
|
214
|
-
}
|
|
215
|
-
// 归档状态先还原,再挂回工作区:attachSession 要读得到会话头(目录已经回来了)。
|
|
216
|
-
if (meta.wasArchived !== true && host.registry.archivedSessionIds.includes(sessionId)) {
|
|
217
|
-
await host.registry.unarchiveSession(sessionId);
|
|
218
|
-
steps.push("unarchive");
|
|
219
|
-
}
|
|
220
|
-
if (typeof meta.cwd === "string" && meta.cwd !== "") {
|
|
221
|
-
try {
|
|
222
|
-
const workspace = await host.registry.resolveByPath(meta.cwd);
|
|
223
|
-
if (workspace && typeof workspace.attachSession === "function") {
|
|
224
|
-
await workspace.attachSession(sessionId);
|
|
225
|
-
steps.push("attach");
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
catch {
|
|
229
|
-
// 索引要等下次启动才会收录这个会话,挂靠失败不影响恢复
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
return steps;
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
/**
|
|
236
|
-
* 永久删除(不可恢复)。
|
|
237
|
-
*
|
|
238
|
-
* 先取消归档:`trashSessions()` 为了让会话从侧边栏消失而把它放进了归档集合,永久删除之后
|
|
239
|
-
* 再没人会去动这条 id —— 留在集合里就是一条**永久幽灵**(DSH 自身会一直以为它归档着,
|
|
240
|
-
* 界面上又找不到它)。
|
|
241
|
-
*/
|
|
242
|
-
export async function purgeSessions(host, ids) {
|
|
243
|
-
return purgeEach(host, ids);
|
|
244
|
-
}
|
|
245
|
-
/**
|
|
246
|
-
* 「清理 N 天前」:**按时间**批量永久删除。
|
|
247
|
-
*
|
|
248
|
-
* 只认回收站目录里的条目(`purgeEach()` 会再查一次 `existsSync`),并且必须先把
|
|
249
|
-
* `deletedAt` 早于截止时间的条目挑出来 —— 挑的规则与预览共用
|
|
250
|
-
* `retention.ts` 的 `isExpired()`,所以"预览到的那批"就是"真删的那批"。
|
|
251
|
-
*
|
|
252
|
-
* 为什么这里不做静默后台清理:这是**永久删除**,本插件唯一不可撤销的动作。它只能由用户
|
|
253
|
-
* 在界面上点一次、看过预览、再过一道确认弹窗之后发生(客户端侧见配置页的保留策略一行)。
|
|
254
|
-
*/
|
|
255
|
-
export async function purgeOlderThan(host, days) {
|
|
256
|
-
const cutoff = retentionCutoff(days);
|
|
257
|
-
const rows = await host.listTrash();
|
|
258
|
-
return purgeEach(host, rows.filter((row) => isExpired(row, cutoff)).map((row) => row.sessionId));
|
|
259
|
-
}
|
|
260
|
-
/**
|
|
261
|
-
* 逐项永久删除的**唯一**实现(`purge` 与 `purge-older-than` 都走它)。
|
|
262
|
-
*
|
|
263
|
-
* 顺序不能改:先 `unarchiveSession` 再 `rm`(理由见上面的注释)。
|
|
264
|
-
*/
|
|
265
|
-
async function purgeEach(host, ids) {
|
|
266
|
-
return host.each(ids, async (sessionId) => {
|
|
267
|
-
const target = join(trashRoot(), sessionId);
|
|
268
|
-
if (!existsSync(target))
|
|
269
|
-
throw new Error("回收站里没有这个会话");
|
|
270
|
-
const steps = [];
|
|
271
|
-
if (host.registry.archivedSessionIds.includes(sessionId)) {
|
|
272
|
-
await host.registry.unarchiveSession(sessionId);
|
|
273
|
-
steps.push("unarchive");
|
|
274
|
-
}
|
|
275
|
-
await fsp.rm(target, { recursive: true, force: true });
|
|
276
|
-
steps.push("purge");
|
|
277
|
-
return steps;
|
|
278
|
-
});
|
|
279
|
-
}
|