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
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 危险操作:**物理删除**(不可恢复)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不再是"软删除到回收站":回收站与归档在做同一件事(把会话从侧边栏藏起来、之后还能
|
|
5
|
+
* 找回来),而归档是 DSH 自带的机制、不动磁盘、随时可逆。所以删除就做成**真正的删除**:
|
|
6
|
+
* 删掉 `<DSH_HOME>/sessions/<slug>/<sessionId>/` 与它的投影缓存,不可撤销。想留退路的
|
|
7
|
+
* 用户在确认弹窗里会被明确提示改用归档。
|
|
8
|
+
*
|
|
9
|
+
* 顺序不能随便改(与旧 `purge` 同一条教训):**先把记账清干净,再删目录**。
|
|
10
|
+
* - `archivedSessionIds` / `pinnedSessionIds` 是持久化的集合,删除后还留着 id 就是一条
|
|
11
|
+
* "DSH 一直以为它归档着、界面上又找不到"的永久幽灵;
|
|
12
|
+
* - 工作区记账(`sessionIds`)同样要摘掉,否则侧边栏的"未分组"里会留一条点不开的幽灵行;
|
|
13
|
+
* - 目录最后删。万一 `rm` 失败(占用 / 权限),这一项在 `results[]` 里报失败,会话还在
|
|
14
|
+
* 磁盘上,只是已经被取消归档 —— 用户仍然看得到它,可以重试,不会凭空丢数据。
|
|
15
|
+
*
|
|
16
|
+
* 正在跑的会话与**当前正在使用的会话**一律拒绝删除:前者由官方的
|
|
17
|
+
* `workspace/session-activity` 判定(与官方归档同一条判据),后者是
|
|
18
|
+
* `process.env.DSH_SESSION_ID`。
|
|
19
|
+
*/
|
|
20
|
+
import { promises as fsp } from "node:fs";
|
|
21
|
+
import { messageOf } from "./fs-utils.js";
|
|
22
|
+
import { projCacheFile } from "./paths.js";
|
|
23
|
+
/**
|
|
24
|
+
* 逐项物理删除:会话目录 + 投影缓存,并把归档 / 置顶 / 工作区三处记账清干净。
|
|
25
|
+
*
|
|
26
|
+
* 单项失败只留在 `results[]` 里,不影响其余项(`each()` 的语义)。
|
|
27
|
+
*/
|
|
28
|
+
export async function deleteSessions(host, ids) {
|
|
29
|
+
const dirs = await host.scanSessionDirs();
|
|
30
|
+
return host.each(ids, async (sessionId) => {
|
|
31
|
+
const steps = [];
|
|
32
|
+
if (sessionId === process.env.DSH_SESSION_ID) {
|
|
33
|
+
throw new Error("这是当前正在使用的会话,不能删除");
|
|
34
|
+
}
|
|
35
|
+
const activity = await host.sessionActivity(sessionId);
|
|
36
|
+
if (activity.length > 0)
|
|
37
|
+
throw new Error("会话正在运行,请先停止它");
|
|
38
|
+
const info = dirs.get(sessionId);
|
|
39
|
+
if (!info)
|
|
40
|
+
throw new Error("找不到会话日志目录(可能已被删除)");
|
|
41
|
+
// 三处记账先清:见文件头"顺序不能随便改"。
|
|
42
|
+
if (host.registry.archivedSessionIds.includes(sessionId)) {
|
|
43
|
+
await host.registry.unarchiveSession(sessionId);
|
|
44
|
+
steps.push("unarchive");
|
|
45
|
+
}
|
|
46
|
+
if (host.registry.pinnedSessionIds.includes(sessionId)) {
|
|
47
|
+
await host.registry.unpinSession(sessionId);
|
|
48
|
+
steps.push("unpin");
|
|
49
|
+
}
|
|
50
|
+
const workspace = host.findWorkspaceOf(sessionId);
|
|
51
|
+
if (workspace) {
|
|
52
|
+
try {
|
|
53
|
+
if (typeof workspace.detachSession === "function") {
|
|
54
|
+
await workspace.detachSession(sessionId);
|
|
55
|
+
steps.push("detach");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
// 摘不掉记账不该阻止删除:目录没了之后 DSH 自己也会把它清掉。
|
|
60
|
+
steps.push(`detach-failed:${messageOf(error)}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// 会话目录整个删掉(这是唯一不可逆的一步)。
|
|
64
|
+
await fsp.rm(info.dir, { recursive: true, force: true });
|
|
65
|
+
steps.push("delete");
|
|
66
|
+
// 投影缓存是 DSH 自己的文件(`storages/session_projcache/sessions/<id>.json`):
|
|
67
|
+
// 会话都没了,留着一份标题 / cwd 的缓存只会在下次重建列表时对不上。
|
|
68
|
+
try {
|
|
69
|
+
await fsp.rm(projCacheFile(sessionId), { force: true });
|
|
70
|
+
steps.push("projcache");
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// 缓存删不掉不影响会话本身
|
|
74
|
+
}
|
|
75
|
+
return steps;
|
|
76
|
+
});
|
|
77
|
+
}
|
package/lib/host/fs-utils.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** 文件系统小工具 + 错误消息归一化。 */
|
|
2
2
|
import { promises as fsp } from "node:fs";
|
|
3
|
-
import {
|
|
3
|
+
import { join } from "node:path";
|
|
4
4
|
/** 任何抛出物 → 一句人话(宿主 logger 与端点错误信封都用它)。 */
|
|
5
5
|
export function messageOf(error) {
|
|
6
6
|
if (error instanceof Error)
|
|
@@ -31,20 +31,6 @@ export async function mapLimit(items, limit, worker) {
|
|
|
31
31
|
await Promise.all(Array.from({ length: width }, run));
|
|
32
32
|
return results;
|
|
33
33
|
}
|
|
34
|
-
/** 跨卷 rename 会抛 EXDEV,退回 copy + rm。 */
|
|
35
|
-
export async function moveDir(from, to) {
|
|
36
|
-
await fsp.mkdir(dirname(to), { recursive: true });
|
|
37
|
-
try {
|
|
38
|
-
await fsp.rename(from, to);
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
catch (error) {
|
|
42
|
-
if (error?.code !== "EXDEV")
|
|
43
|
-
throw error;
|
|
44
|
-
}
|
|
45
|
-
await fsp.cp(from, to, { recursive: true, force: true });
|
|
46
|
-
await fsp.rm(from, { recursive: true, force: true });
|
|
47
|
-
}
|
|
48
34
|
const DIR_STATS_CACHE = new Map();
|
|
49
35
|
/** 缓存上限:超过就整个清掉。磁盘目录数远小于它,这里只是防止长期运行无限增长。 */
|
|
50
36
|
const DIR_STATS_CACHE_LIMIT = 2000;
|
package/lib/host/open.js
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 安全边界(这一段是这个文件存在的全部理由):
|
|
5
5
|
* - 路径**不接受客户端传**:只吃 `sessionId`,由宿主自己按 `<DSH_HOME>/sessions/<slug>/<id>`
|
|
6
|
-
*
|
|
7
|
-
* - 拼出来之后再 `resolve()`
|
|
8
|
-
*
|
|
6
|
+
* 拼出来。客户端就算传了别的路径也没用 —— 入参里根本没有路径字段。
|
|
7
|
+
* - 拼出来之后再 `resolve()` 一次并断言它落在**允许的根**(`<DSH_HOME>/sessions`)
|
|
8
|
+
* 之下。这一道是为了挡住 `../` 之类的 id(线协议的 `idsOf()` 只保证是
|
|
9
9
|
* 非空字符串,不保证它不是一个相对路径)。
|
|
10
10
|
* - 只有 `target: "reveal"` 才会真的去叫系统文件管理器,而且只用**固定的可执行名**
|
|
11
11
|
* (`explorer.exe` / `open` / `xdg-open`),参数是那个已经校验过的目录。不拼 shell 字符串,
|
|
@@ -20,17 +20,17 @@ import { spawn } from "node:child_process";
|
|
|
20
20
|
import { existsSync } from "node:fs";
|
|
21
21
|
import { resolve, sep } from "node:path";
|
|
22
22
|
import { messageOf } from "./fs-utils.js";
|
|
23
|
-
import { sessionsRoot
|
|
23
|
+
import { sessionsRoot } from "./paths.js";
|
|
24
24
|
/** `path` / `reveal`:`reveal` 之外的任何值都当成 `path`(只读,最保守的那条)。 */
|
|
25
25
|
export function openTargetOf(value) {
|
|
26
26
|
return value === "reveal" ? "reveal" : "path";
|
|
27
27
|
}
|
|
28
28
|
/**
|
|
29
|
-
*
|
|
29
|
+
* 允许的根:整个会话目录。`resolve()` 之后比较,且要求"相等或以 `<root>+分隔符`
|
|
30
30
|
* 开头" —— 单比 `startsWith(root)` 会把 `<DSH_HOME>/sessions-evil` 放进来。
|
|
31
31
|
*/
|
|
32
32
|
function allowedRoots() {
|
|
33
|
-
return [resolve(sessionsRoot())
|
|
33
|
+
return [resolve(sessionsRoot())];
|
|
34
34
|
}
|
|
35
35
|
function inside(root, target) {
|
|
36
36
|
return target === root || target.startsWith(root.endsWith(sep) ? root : root + sep);
|
|
@@ -69,22 +69,16 @@ function reveal(path) {
|
|
|
69
69
|
/**
|
|
70
70
|
* `open` 端点:算路径 → 校验 → (可选)叫文件管理器。
|
|
71
71
|
*
|
|
72
|
-
* `sessionId`
|
|
72
|
+
* `sessionId` 必须能在磁盘上找到;找不到就抛错,由信封兜住(与 `describe` 一致)。
|
|
73
73
|
*/
|
|
74
74
|
export async function openSession(host, ids, target) {
|
|
75
75
|
const sessionId = ids[0];
|
|
76
76
|
if (typeof sessionId !== "string" || sessionId === "")
|
|
77
77
|
throw new Error("缺少会话 id");
|
|
78
|
-
// 磁盘优先,其次回收站:同一个 id 不可能同时在两处(删除时目录被搬走了)。
|
|
79
78
|
const dirs = await host.scanSessionDirs();
|
|
80
|
-
|
|
81
|
-
if (path === null) {
|
|
82
|
-
const inTrash = resolve(trashRoot(), sessionId);
|
|
83
|
-
if (existsSync(inTrash))
|
|
84
|
-
path = inTrash;
|
|
85
|
-
}
|
|
79
|
+
const path = dirs.get(sessionId)?.dir ?? null;
|
|
86
80
|
if (path === null)
|
|
87
|
-
throw new Error("
|
|
81
|
+
throw new Error("找不到这个会话的日志目录(磁盘上不存在)");
|
|
88
82
|
const resolved = resolve(path);
|
|
89
83
|
const roots = allowedRoots();
|
|
90
84
|
if (!roots.some((root) => inside(root, resolved))) {
|
package/lib/host/paths.js
CHANGED
|
@@ -25,20 +25,12 @@ export function dshHomePath(...segments) {
|
|
|
25
25
|
return join(resolveDshHome(), ...segments);
|
|
26
26
|
}
|
|
27
27
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
28
|
+
* 历史遗留:本插件 0.2.0 之前把"软删除"的会话搬到
|
|
29
|
+
* `<DSH_HOME>/storages/session-manager/trash`(目录名 `session-manager` 是更早的包名)。
|
|
30
|
+
* 回收站功能已在 0.3.0 移除,删除改成物理删除,所以插件**不再读写**那个目录:
|
|
31
|
+
* 里面可能还有用户当年软删除的会话,插件不主动迁移也不删除它们;需要时人工把
|
|
32
|
+
* `trash/<sessionId>/` 搬回 `sessions/<slug>/<sessionId>/` 即可。
|
|
33
33
|
*/
|
|
34
|
-
export const TRASH_SUBPATH = ["storages", "session-manager", "trash"];
|
|
35
|
-
/** 回收站条目的元数据:原 slug / cwd / 标题 / 删除时间 / 删除前是否已归档。 */
|
|
36
|
-
export const META_FILE = "__trash.json";
|
|
37
|
-
/** 从投影缓存搬进来的那份文件(恢复时要搬回原位)。 */
|
|
38
|
-
export const PROJ_FILE = "__projcache.json";
|
|
39
|
-
export function trashRoot() {
|
|
40
|
-
return dshHomePath(...TRASH_SUBPATH);
|
|
41
|
-
}
|
|
42
34
|
export function sessionsRoot() {
|
|
43
35
|
return dshHomePath("sessions");
|
|
44
36
|
}
|
package/lib/host/routes.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 传输 /
|
|
2
|
+
* 传输 / 分发:十个端点 → 十条精确 Fetch 路由。
|
|
3
3
|
*
|
|
4
4
|
* ── 为什么**不用** `ctx.connection.rpc.handle()`(踩过的坑,改回去之前先读)──
|
|
5
5
|
*
|
|
@@ -36,12 +36,8 @@ export const ENDPOINTS = {
|
|
|
36
36
|
unarchive: (manager, payload) => manager.unarchive(payload.ids ?? []),
|
|
37
37
|
pin: (manager, payload) => manager.pin(payload.ids ?? []),
|
|
38
38
|
unpin: (manager, payload) => manager.unpin(payload.ids ?? []),
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
purge: (manager, payload) => manager.purge(payload.ids ?? []),
|
|
42
|
-
// 保留策略的两条都只吃 `days`(不是 id 列表):预览只读,清理是永久删除。
|
|
43
|
-
"trash-stats": (manager, payload) => manager.trashStats(daysOf(payload)),
|
|
44
|
-
"purge-older-than": (manager, payload) => manager.purgeOlderThan(daysOf(payload)),
|
|
39
|
+
// 物理删除:不可恢复,确认弹窗由客户端把关(见 components/dialog-host.ts)。
|
|
40
|
+
delete: (manager, payload) => manager.delete(payload.ids ?? []),
|
|
45
41
|
// 磁盘占用视图(只读)与"复制路径 / 在资源管理器中显示"。
|
|
46
42
|
usage: (manager) => manager.usage(),
|
|
47
43
|
// `target` 只认字面量 `"reveal"`;其余一律当 `"path"`(只读,最保守的那条)。
|
|
@@ -52,19 +48,6 @@ export const ENDPOINTS = {
|
|
|
52
48
|
export function idsOf(payload) {
|
|
53
49
|
return Array.isArray(payload.ids) ? payload.ids.filter((value) => typeof value === "string" && value !== "") : [];
|
|
54
50
|
}
|
|
55
|
-
/**
|
|
56
|
-
* 保留天数:越界 / 非数字一律退回 **30 天**(界面的默认值)。
|
|
57
|
-
*
|
|
58
|
-
* 这里只做"是不是个正常数字"的粗校验,真正的夹取在 `retention.ts` 的
|
|
59
|
-
* `retentionCutoff()`(预览与真删共用那一份)。为什么退回 30 而不是 0:`0` 会让截止时间
|
|
60
|
-
* 变成"此刻",等于把整个回收站当成过期 —— 缺字段的请求绝不能有这种后果。
|
|
61
|
-
*/
|
|
62
|
-
export function daysOf(payload) {
|
|
63
|
-
const value = payload.days;
|
|
64
|
-
if (typeof value !== "number" || !Number.isFinite(value))
|
|
65
|
-
return 30;
|
|
66
|
-
return Math.min(Math.max(Math.floor(value), 1), 3650);
|
|
67
|
-
}
|
|
68
51
|
/**
|
|
69
52
|
* 一个端点处理器。返回 Connection 的固定信封:处理器自身的业务失败走
|
|
70
53
|
* `{ok:false, error}`,端点内部的逐项失败则留在 `value.results` 里。
|
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 会话管理器:**只读投影**(快照 / 标题 / 体积)+ 归档 / 置顶这类轻量变更。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 危险操作(物理删除)在 `delete.ts` 里,那边带完整的事故注释;
|
|
5
5
|
* 这里只负责把磁盘与 registry 的事实汇总成快照,以及逐项执行的框架 `each()`。
|
|
6
6
|
*/
|
|
7
7
|
import { promises as fsp } from "node:fs";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { dirStats, mapLimit, messageOf, readProjectionFile } from "./fs-utils.js";
|
|
10
|
-
import {
|
|
10
|
+
import { dshHomePath, projCacheFile, sessionsRoot } from "./paths.js";
|
|
11
11
|
import { LOCAL_VERSION } from "./manifest.js";
|
|
12
12
|
import { checkForUpdate } from "./update.js";
|
|
13
|
-
import {
|
|
14
|
-
import { isExpired, retentionCutoff } from "./retention.js";
|
|
13
|
+
import { deleteSessions } from "./delete.js";
|
|
15
14
|
import { openSession } from "./open.js";
|
|
16
15
|
/**
|
|
17
16
|
* 文件系统侧的并发上限。快照一次要给上百个目录做 `readdir` + `stat`,串行 await 会让
|
|
@@ -109,67 +108,23 @@ export class SessionManager {
|
|
|
109
108
|
active
|
|
110
109
|
};
|
|
111
110
|
}
|
|
112
|
-
|
|
113
|
-
const root = trashRoot();
|
|
114
|
-
let entries;
|
|
115
|
-
try {
|
|
116
|
-
entries = await fsp.readdir(root, { withFileTypes: true });
|
|
117
|
-
}
|
|
118
|
-
catch {
|
|
119
|
-
return [];
|
|
120
|
-
}
|
|
121
|
-
// 每条都要读 `__trash.json` + 遍历目录:串行时条目一多就明显卡(回收站常驻几十条)。
|
|
122
|
-
// 这里**故意**用最宽松的读法(坏 JSON / 缺字段 / 连版本都比插件新,都只是"字段缺失"):
|
|
123
|
-
// 一次列表不该因为某一条元数据坏了就整个炸掉。要按 schema 校验、并且会拒绝未知版本的是
|
|
124
|
-
// `trash.ts` 的 `readTrashMeta()`(恢复那条路,见那边的注释)。
|
|
125
|
-
const rows = await mapLimit(entries.filter((entry) => entry.isDirectory()), FS_CONCURRENCY, async (entry) => {
|
|
126
|
-
const dir = join(root, entry.name);
|
|
127
|
-
let meta = null;
|
|
128
|
-
try {
|
|
129
|
-
meta = JSON.parse(await fsp.readFile(join(dir, META_FILE), "utf8"));
|
|
130
|
-
}
|
|
131
|
-
catch {
|
|
132
|
-
meta = null;
|
|
133
|
-
}
|
|
134
|
-
const stats = await this.dirStats(dir);
|
|
135
|
-
return {
|
|
136
|
-
sessionId: entry.name,
|
|
137
|
-
title: typeof meta?.title === "string" && meta.title !== "" ? meta.title : entry.name,
|
|
138
|
-
cwd: typeof meta?.cwd === "string" ? meta.cwd : null,
|
|
139
|
-
slug: typeof meta?.slug === "string" ? meta.slug : null,
|
|
140
|
-
// 与 describeSession 对齐:客户端按"原来的工作区文件夹"分组要用到这两个字段。
|
|
141
|
-
workspaceId: typeof meta?.workspaceId === "string" ? meta.workspaceId : null,
|
|
142
|
-
workspaceTitle: typeof meta?.workspaceTitle === "string" ? meta.workspaceTitle : null,
|
|
143
|
-
deletedAt: typeof meta?.deletedAt === "number" ? meta.deletedAt : 0,
|
|
144
|
-
bytes: stats.bytes,
|
|
145
|
-
files: stats.files,
|
|
146
|
-
restorable: typeof meta?.slug === "string" && meta.slug !== ""
|
|
147
|
-
};
|
|
148
|
-
});
|
|
149
|
-
rows.sort((left, right) => right.deletedAt - left.deletedAt);
|
|
150
|
-
return rows;
|
|
151
|
-
}
|
|
152
|
-
/** 当前归档 / 置顶 / 回收站的完整快照,附带目录位置。 */
|
|
111
|
+
/** 当前归档 / 置顶的完整快照,附带目录位置。 */
|
|
153
112
|
async buildSnapshot() {
|
|
154
113
|
const registry = this.registry;
|
|
155
|
-
//
|
|
156
|
-
const
|
|
157
|
-
// 回收站条目**只**归回收站页签。删除时我们故意让 id 留在归档集合里(那是 DSH
|
|
158
|
-
// 唯一"从所有分组面消失"的机制,见 trash.ts),所以这里必须把交集滤掉:
|
|
159
|
-
// 否则同一个会话会同时出现在"已归档"和"回收站",而"已归档"那条还是没有目录的幽灵行。
|
|
160
|
-
const inTrash = new Set(trashed.map((row) => row.sessionId));
|
|
114
|
+
// 目录索引(哪些 id 真的在磁盘上)是唯一的只读来源;registry 记账可能包含已经删掉的 id。
|
|
115
|
+
const dirs = await this.scanSessionDirs();
|
|
161
116
|
const archivedIds = [];
|
|
162
117
|
for (const sessionId of registry.archivedSessionIds) {
|
|
163
|
-
//
|
|
118
|
+
// 把"目录已经不在了"的 id 滤掉:`archivedSessionIds` 是**只增不减**的记账
|
|
164
119
|
//(DSH 自己的注释:目录没了的条目照样留在集合里),而 DSH 的侧边栏只从会话清单里
|
|
165
120
|
// 取行 —— 这种 id 在它那边本来就不显示。列出来只会得到一条 0 B、点不动的幽灵行。
|
|
166
|
-
if (
|
|
121
|
+
if (!dirs.has(sessionId))
|
|
167
122
|
continue;
|
|
168
123
|
archivedIds.push(sessionId);
|
|
169
124
|
}
|
|
170
125
|
const pinnedIds = [];
|
|
171
126
|
for (const sessionId of registry.pinnedSessionIds) {
|
|
172
|
-
if (
|
|
127
|
+
if (!dirs.has(sessionId))
|
|
173
128
|
continue;
|
|
174
129
|
pinnedIds.push(sessionId);
|
|
175
130
|
}
|
|
@@ -184,23 +139,16 @@ export class SessionManager {
|
|
|
184
139
|
let archivedBytes = 0;
|
|
185
140
|
for (const row of archived)
|
|
186
141
|
archivedBytes += row.bytes;
|
|
187
|
-
let trashBytes = 0;
|
|
188
|
-
for (const row of trashed)
|
|
189
|
-
trashBytes += row.bytes;
|
|
190
142
|
return {
|
|
191
143
|
dshHome: dshHomePath(),
|
|
192
144
|
version: LOCAL_VERSION,
|
|
193
|
-
trashDir: trashRoot(),
|
|
194
145
|
sessionsDir: sessionsRoot(),
|
|
195
146
|
archived,
|
|
196
147
|
pinned,
|
|
197
|
-
trashed,
|
|
198
148
|
totals: {
|
|
199
149
|
archived: archived.length,
|
|
200
150
|
archivedBytes,
|
|
201
|
-
pinned: pinned.length
|
|
202
|
-
trashed: trashed.length,
|
|
203
|
-
trashBytes
|
|
151
|
+
pinned: pinned.length
|
|
204
152
|
},
|
|
205
153
|
activeSessionIds: [...activeIds]
|
|
206
154
|
};
|
|
@@ -220,14 +168,14 @@ export class SessionManager {
|
|
|
220
168
|
return this.buildSnapshot();
|
|
221
169
|
}
|
|
222
170
|
/**
|
|
223
|
-
*
|
|
171
|
+
* **单个**会话的完整事实(侧边栏 hover 卡片用)。
|
|
224
172
|
*
|
|
225
173
|
* 入参复用 `ids`(线协议的 `idsOf()` 只接受字符串数组):取第一条,其余忽略 ——
|
|
226
174
|
* hover 卡片一次只问一个会话,把它做成批量端点没有意义。
|
|
227
175
|
*
|
|
228
176
|
* 客户端的 hover 会在 800ms 之后触发、且**每次划过都会问**,所以这里刻意只做
|
|
229
|
-
* `
|
|
230
|
-
*
|
|
177
|
+
* `scanSessionDirs()` + 单条 `dirStats()`,不做全量快照;端到端的节流由客户端那份
|
|
178
|
+
* 带 TTL 的缓存负责(`src/client/detail.ts`)。
|
|
231
179
|
*/
|
|
232
180
|
async describe(ids) {
|
|
233
181
|
const sessionId = ids[0];
|
|
@@ -236,46 +184,14 @@ export class SessionManager {
|
|
|
236
184
|
const registry = this.registry;
|
|
237
185
|
const archived = registry.archivedSessionIds.includes(sessionId);
|
|
238
186
|
const pinned = registry.pinnedSessionIds.includes(sessionId);
|
|
239
|
-
// 活动判定与回收站清单互不依赖:一起等。回收站里的条目不可能正在跑(目录都搬走了),
|
|
240
|
-
// 所以只有磁盘上那条路要用它的结果。
|
|
241
|
-
const [trashed, activeList] = await Promise.all([
|
|
242
|
-
this.listTrash().then((rows) => rows.find((row) => row.sessionId === sessionId)),
|
|
243
|
-
this.sessionActivity(sessionId)
|
|
244
|
-
]);
|
|
245
|
-
const active = activeList.length > 0;
|
|
246
|
-
if (trashed) {
|
|
247
|
-
return {
|
|
248
|
-
sessionId,
|
|
249
|
-
title: trashed.title,
|
|
250
|
-
cwd: trashed.cwd,
|
|
251
|
-
workspaceId: trashed.workspaceId,
|
|
252
|
-
workspaceTitle: trashed.workspaceTitle,
|
|
253
|
-
slug: trashed.slug,
|
|
254
|
-
// `__trash.json` 只记删除时间,恢复时才用得上创建时间,所以这里不硬凑。
|
|
255
|
-
createdAt: null,
|
|
256
|
-
bytes: trashed.bytes,
|
|
257
|
-
files: trashed.files,
|
|
258
|
-
onDisk: false,
|
|
259
|
-
archived,
|
|
260
|
-
pinned,
|
|
261
|
-
// 回收站条目:目录已经搬走,谈不上"正在跑"。
|
|
262
|
-
active: false,
|
|
263
|
-
trashed: true,
|
|
264
|
-
deletedAt: trashed.deletedAt,
|
|
265
|
-
restorable: trashed.restorable
|
|
266
|
-
};
|
|
267
|
-
}
|
|
268
187
|
const dirs = await this.scanSessionDirs();
|
|
269
188
|
if (!dirs.has(sessionId))
|
|
270
189
|
throw new Error("找不到这个会话(日志目录不在磁盘上)");
|
|
271
|
-
const row = await this.describeSession(sessionId, dirs,
|
|
190
|
+
const row = await this.describeSession(sessionId, dirs, (await this.sessionActivity(sessionId)).length > 0);
|
|
272
191
|
return {
|
|
273
192
|
...row,
|
|
274
193
|
archived,
|
|
275
|
-
pinned
|
|
276
|
-
trashed: false,
|
|
277
|
-
deletedAt: null,
|
|
278
|
-
restorable: false
|
|
194
|
+
pinned
|
|
279
195
|
};
|
|
280
196
|
}
|
|
281
197
|
/** 逐个执行,单项失败只记录在 results 里,不影响其余项。 */
|
|
@@ -318,41 +234,9 @@ export class SessionManager {
|
|
|
318
234
|
return [];
|
|
319
235
|
}
|
|
320
236
|
}
|
|
321
|
-
// ──
|
|
322
|
-
|
|
323
|
-
return
|
|
324
|
-
}
|
|
325
|
-
restore(ids) {
|
|
326
|
-
return restoreSessions(this, ids);
|
|
327
|
-
}
|
|
328
|
-
purge(ids) {
|
|
329
|
-
return purgeSessions(this, ids);
|
|
330
|
-
}
|
|
331
|
-
/**
|
|
332
|
-
* 回收站保留策略的**预览**(只读):多少条、多少字节会被"清理 N 天前"带走。
|
|
333
|
-
*
|
|
334
|
-
* 只统计**真正能清掉**的条目 —— 老版本留下的、没有 `__trash.json`(`deletedAt` 为 0 或
|
|
335
|
-
* 缺 slug)的条目不算在内:它们连删除时间都不知道,`purge-older-than` 也一定会跳过,
|
|
336
|
-
* 预览要是把它们算进去,用户点确认后就会发现"说好的 5 条只删了 3 条"。
|
|
337
|
-
*/
|
|
338
|
-
async trashStats(days) {
|
|
339
|
-
const rows = await this.listTrash();
|
|
340
|
-
const cutoff = retentionCutoff(days);
|
|
341
|
-
let count = 0;
|
|
342
|
-
let bytes = 0;
|
|
343
|
-
let totalBytes = 0;
|
|
344
|
-
for (const row of rows) {
|
|
345
|
-
totalBytes += row.bytes;
|
|
346
|
-
if (isExpired(row, cutoff)) {
|
|
347
|
-
count += 1;
|
|
348
|
-
bytes += row.bytes;
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
return { days, cutoff, count, bytes, totalCount: rows.length, totalBytes };
|
|
352
|
-
}
|
|
353
|
-
/** 「清理 N 天前」:与预览同一套判据,逐项失败不影响其余项。 */
|
|
354
|
-
purgeOlderThan(days) {
|
|
355
|
-
return purgeOlderThan(this, days);
|
|
237
|
+
// ── 危险操作:物理删除,实现在 delete.ts(那边有完整的事故注释) ─────
|
|
238
|
+
delete(ids) {
|
|
239
|
+
return deleteSessions(this, ids);
|
|
356
240
|
}
|
|
357
241
|
/** 复制路径 / 在资源管理器中显示(实现在 `open.ts`,那边有完整的路径校验注释)。 */
|
|
358
242
|
open(ids, target) {
|
|
@@ -364,16 +248,15 @@ export class SessionManager {
|
|
|
364
248
|
* 与 `buildSnapshot()` 的关系:两者都读同一批目录,`dirStats()` 的 mtime 缓存让重复的
|
|
365
249
|
* 目录遍历只有第一次真的走磁盘;快照刚刚跑过的那批(归档 / 置顶)在这里直接命中缓存。
|
|
366
250
|
*
|
|
367
|
-
*
|
|
251
|
+
* 为什么要有这条端点而不是让客户端从快照里算:快照只有两个集合,**普通会话**(正在用的
|
|
368
252
|
* 那些)根本不在里面 —— 而"哪些会话最占地方"恰恰要问全部。
|
|
369
253
|
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
254
|
+
* 聚合按会话**当前所在的文件夹**(投影缓存里的 cwd):与列表分组同一套键,用户看到的
|
|
255
|
+
* "工作区"是同一个东西。
|
|
372
256
|
*/
|
|
373
257
|
async usage() {
|
|
374
258
|
const registry = this.registry;
|
|
375
|
-
const
|
|
376
|
-
// 磁盘上的会话 + 回收站里的会话 = 全部会话(两边 id 不会重合,删除时目录已经搬走了)。
|
|
259
|
+
const dirs = await this.scanSessionDirs();
|
|
377
260
|
const onDiskIds = [...dirs.keys()];
|
|
378
261
|
const activeIds = await this.activeAmong(onDiskIds);
|
|
379
262
|
const rows = [];
|
|
@@ -390,30 +273,11 @@ export class SessionManager {
|
|
|
390
273
|
bytes: described.bytes,
|
|
391
274
|
files: described.files,
|
|
392
275
|
createdAt: described.createdAt,
|
|
393
|
-
deletedAt: null,
|
|
394
276
|
archived: registry.archivedSessionIds.includes(sessionId),
|
|
395
277
|
pinned: registry.pinnedSessionIds.includes(sessionId),
|
|
396
|
-
trashed: false,
|
|
397
278
|
active: described.active
|
|
398
279
|
});
|
|
399
280
|
});
|
|
400
|
-
for (const row of trashed) {
|
|
401
|
-
push({
|
|
402
|
-
sessionId: row.sessionId,
|
|
403
|
-
title: row.title,
|
|
404
|
-
cwd: row.cwd,
|
|
405
|
-
workspaceTitle: row.workspaceTitle,
|
|
406
|
-
bytes: row.bytes,
|
|
407
|
-
files: row.files,
|
|
408
|
-
createdAt: null,
|
|
409
|
-
deletedAt: row.deletedAt > 0 ? row.deletedAt : null,
|
|
410
|
-
archived: registry.archivedSessionIds.includes(row.sessionId),
|
|
411
|
-
pinned: false,
|
|
412
|
-
trashed: true,
|
|
413
|
-
// 目录都搬进回收站了,谈不上"正在跑"。
|
|
414
|
-
active: false
|
|
415
|
-
});
|
|
416
|
-
}
|
|
417
281
|
let totalBytes = 0;
|
|
418
282
|
const groups = new Map();
|
|
419
283
|
for (const row of rows) {
|
|
@@ -437,7 +301,6 @@ export class SessionManager {
|
|
|
437
301
|
return {
|
|
438
302
|
totalSessions: rows.length,
|
|
439
303
|
totalBytes,
|
|
440
|
-
onDiskSessions: onDiskIds.length,
|
|
441
304
|
groups: [...groups.values()].sort((left, right) => right.bytes - left.bytes),
|
|
442
305
|
largest,
|
|
443
306
|
oldest
|
package/lib/index.js
CHANGED
|
@@ -2,15 +2,12 @@
|
|
|
2
2
|
* dsh-session-curator — host 半区入口。
|
|
3
3
|
*
|
|
4
4
|
* 提供三块能力:
|
|
5
|
-
* 1.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* 2. 回收站管理:列出 / 恢复 / 永久删除。
|
|
12
|
-
* 3. 归档会话管理:列出全部归档会话(标题、目录、体积),支持批量取消归档、置顶,
|
|
13
|
-
* 以及直接删除。归档/取消归档/置顶全部走官方 `ctx.workspaceRegistry`。
|
|
5
|
+
* 1. 会话删除:**物理删除**(不可恢复)—— 先清掉归档 / 置顶 / 工作区三处记账,再删掉
|
|
6
|
+
* `<DSH_HOME>/sessions/<slug>/<sessionId>/` 与它的投影缓存。想留退路的用户走归档:
|
|
7
|
+
* 归档同样能让会话从侧边栏消失,而且随时能找回来。
|
|
8
|
+
* 2. 归档会话管理:列出全部归档会话(标题、目录、体积),支持批量取消归档、置顶、删除。
|
|
9
|
+
* 3. 置顶会话管理:同一套列表与动作;归档 / 取消归档 / 置顶全部走官方 `ctx.workspaceRegistry`。
|
|
10
|
+
* 另有三个辅助端点:磁盘占用视图(只读)、复制路径 / 在资源管理器中显示、更新检查。
|
|
14
11
|
*
|
|
15
12
|
* 对客户端暴露一组挂在共享 `/api` 通道下的精确 Fetch 路由(
|
|
16
13
|
* `ctx.connection.fetch.register`):信任检查与会话鉴权由 Connection 的 `/api`
|
|
@@ -33,6 +30,5 @@ export function apply(ctx) {
|
|
|
33
30
|
// 纯函数 / 纯工具,给 `scripts/smoke.mjs` 直接单测(插件运行时用不到)。
|
|
34
31
|
export { compareVersions } from "./host/update.js";
|
|
35
32
|
export { dirStats, forgetDirStats } from "./host/fs-utils.js";
|
|
36
|
-
export { isExpired, retentionCutoff } from "./host/retention.js";
|
|
37
33
|
// `open` 的路径校验单独导出:自检要直接断言那道判据,不必真的去调系统 shell。
|
|
38
34
|
export { insideRoot } from "./host/open.js";
|
package/locale/en.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Session manager",
|
|
4
|
-
"description": "
|
|
4
|
+
"description": "Permanently delete sessions and manage archived or pinned ones, grouped by their original workspace folder."
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/zh.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-session-curator",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "DeepSeek Harness plugin:
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "DeepSeek Harness plugin: permanently delete sessions and manage archived/pinned ones, grouped by their original workspace folder. | DSH 插件:永久删除会话,并按原来的工作区文件夹管理已归档 / 已置顶会话。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"icon": "./icon.svg",
|
|
@@ -43,8 +43,7 @@
|
|
|
43
43
|
"session-management",
|
|
44
44
|
"curator",
|
|
45
45
|
"archive",
|
|
46
|
-
"
|
|
47
|
-
"soft-delete",
|
|
46
|
+
"delete",
|
|
48
47
|
"workspace",
|
|
49
48
|
"i18n"
|
|
50
49
|
],
|
|
@@ -119,7 +118,8 @@
|
|
|
119
118
|
"build": "node scripts/build.mjs",
|
|
120
119
|
"typecheck": "tsc -p tsconfig.host.json && tsc -p tsconfig.client.json",
|
|
121
120
|
"test": "pnpm run build && node scripts/artifact.mjs && node scripts/client.mjs && node scripts/smoke.mjs && node scripts/activation.mjs",
|
|
122
|
-
"check": "pnpm run typecheck && pnpm run format:check && node --check lib/index.js && node --check lib/client.js && node --check scripts/artifact.mjs && node --check scripts/check-profile.mjs && node --check scripts/client.mjs",
|
|
121
|
+
"check": "pnpm run typecheck && pnpm run format:check && node --check lib/index.js && node --check lib/client.js && node --check scripts/artifact.mjs && node --check scripts/check-profile.mjs && node --check scripts/client.mjs && node --check scripts/lib-sync.mjs",
|
|
122
|
+
"check:lib": "node scripts/lib-sync.mjs",
|
|
123
123
|
"format": "prettier --write .",
|
|
124
124
|
"format:check": "prettier --check .",
|
|
125
125
|
"release": "node scripts/release.mjs",
|
package/lib/host/retention.js
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 保留天数 → 截止时间戳:**严格早于**它的条目才算过期。
|
|
3
|
-
*
|
|
4
|
-
* 上限 3650 天(10 年)只是防呆:`days` 来自线协议,客户端算错、或有人手工构造请求时,
|
|
5
|
-
* 别让它退化成"把整个回收站清空";`NaN` / 负数一律夹到 1 天(而不是 0:0 会让截止时间
|
|
6
|
-
* 变成"此刻",语义上等于清空)。
|
|
7
|
-
*/
|
|
8
|
-
export function retentionCutoff(days) {
|
|
9
|
-
const safe = Number.isFinite(days) ? Math.min(Math.max(Math.floor(days), 1), 3650) : 1;
|
|
10
|
-
return Date.now() - safe * 24 * 60 * 60 * 1000;
|
|
11
|
-
}
|
|
12
|
-
/**
|
|
13
|
-
* 一条回收站条目该不该被"清理 N 天前"带走。
|
|
14
|
-
*
|
|
15
|
-
* 三个条件缺一不可:
|
|
16
|
-
* - `restorable`:老版本留下的、没有 `__trash.json` 的条目连原 slug 都不知道,
|
|
17
|
-
* `deletedAt` 也就无从谈起(`listTrash()` 会给它 0)。它们只能由用户在界面上
|
|
18
|
-
* 逐条「永久删除」,绝不能被一个按时间的批量操作顺手带走;
|
|
19
|
-
* - `deletedAt > 0`:同上,缺删除时间的条目不参与时间判断;
|
|
20
|
-
* - `deletedAt < cutoff`:**严格**早于截止时间,边界那一条留着。
|
|
21
|
-
*/
|
|
22
|
-
export function isExpired(row, cutoff) {
|
|
23
|
-
return row.restorable && row.deletedAt > 0 && row.deletedAt < cutoff;
|
|
24
|
-
}
|