dsh-session-curator 0.1.2 → 0.2.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 +2 -2
- package/README.zh-CN.md +1 -1
- package/lib/client.js +1645 -328
- package/lib/host/fs-utils.js +44 -3
- package/lib/host/open.js +104 -0
- package/lib/host/retention.js +24 -0
- package/lib/host/routes.js +22 -1
- package/lib/host/session-manager.js +267 -20
- package/lib/host/trash.js +99 -10
- package/lib/index.js +6 -2
- package/package.json +13 -10
- package/scripts/activation.mjs +0 -358
- package/scripts/build.mjs +0 -96
- package/scripts/client.mjs +0 -691
- package/scripts/smoke.mjs +0 -382
package/lib/host/fs-utils.js
CHANGED
|
@@ -7,6 +7,30 @@ export function messageOf(error) {
|
|
|
7
7
|
return error.message;
|
|
8
8
|
return String(error);
|
|
9
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* 最多 `limit` 个任务同时在跑;返回值**按入参顺序**排列。
|
|
12
|
+
*
|
|
13
|
+
* 为什么不用 `Promise.all(items.map(...))`:快照要一次给上百个目录做 `readdir` + `stat`,
|
|
14
|
+
* 无上限并发会把磁盘打成一锅粥(Windows 上尤其明显,还会跟 DSH 自己的会话扫描抢 IO)。
|
|
15
|
+
* 这里只做"有上限的并发",超出的任务排队,单项抛错照原样往外冒(由调用方决定怎么办)。
|
|
16
|
+
*/
|
|
17
|
+
export async function mapLimit(items, limit, worker) {
|
|
18
|
+
const size = items.length;
|
|
19
|
+
const results = new Array(size);
|
|
20
|
+
if (size === 0)
|
|
21
|
+
return results;
|
|
22
|
+
const width = Math.max(1, Math.min(Math.floor(limit) || 1, size));
|
|
23
|
+
let next = 0;
|
|
24
|
+
const run = async () => {
|
|
25
|
+
while (next < size) {
|
|
26
|
+
const index = next;
|
|
27
|
+
next += 1;
|
|
28
|
+
results[index] = await worker(items[index], index);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
await Promise.all(Array.from({ length: width }, run));
|
|
32
|
+
return results;
|
|
33
|
+
}
|
|
10
34
|
/** 跨卷 rename 会抛 EXDEV,退回 copy + rm。 */
|
|
11
35
|
export async function moveDir(from, to) {
|
|
12
36
|
await fsp.mkdir(dirname(to), { recursive: true });
|
|
@@ -21,17 +45,31 @@ export async function moveDir(from, to) {
|
|
|
21
45
|
await fsp.cp(from, to, { recursive: true, force: true });
|
|
22
46
|
await fsp.rm(from, { recursive: true, force: true });
|
|
23
47
|
}
|
|
24
|
-
|
|
48
|
+
const DIR_STATS_CACHE = new Map();
|
|
49
|
+
/** 缓存上限:超过就整个清掉。磁盘目录数远小于它,这里只是防止长期运行无限增长。 */
|
|
50
|
+
const DIR_STATS_CACHE_LIMIT = 2000;
|
|
51
|
+
/** 给自检用:清掉缓存后重新统计(`scripts/smoke.mjs` 要断言"同一次快照内命中缓存")。 */
|
|
52
|
+
export function forgetDirStats() {
|
|
53
|
+
DIR_STATS_CACHE.clear();
|
|
54
|
+
}
|
|
55
|
+
/** 只统计顶层文件:会话目录里就是那几份日志。目录 mtime 没变就直接回缓存。 */
|
|
25
56
|
export async function dirStats(dir) {
|
|
26
|
-
let
|
|
27
|
-
let files = 0;
|
|
57
|
+
let mtimeMs;
|
|
28
58
|
let entries;
|
|
29
59
|
try {
|
|
60
|
+
const info = await fsp.stat(dir);
|
|
61
|
+
mtimeMs = info.mtimeMs;
|
|
30
62
|
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
31
63
|
}
|
|
32
64
|
catch {
|
|
33
65
|
return { bytes: 0, files: 0 };
|
|
34
66
|
}
|
|
67
|
+
const cached = DIR_STATS_CACHE.get(dir);
|
|
68
|
+
if (cached !== undefined && cached.mtimeMs === mtimeMs && cached.entryCount === entries.length) {
|
|
69
|
+
return { bytes: cached.bytes, files: cached.files };
|
|
70
|
+
}
|
|
71
|
+
let bytes = 0;
|
|
72
|
+
let files = 0;
|
|
35
73
|
for (const entry of entries) {
|
|
36
74
|
if (!entry.isFile())
|
|
37
75
|
continue;
|
|
@@ -44,6 +82,9 @@ export async function dirStats(dir) {
|
|
|
44
82
|
// 竞态:文件刚好消失,忽略
|
|
45
83
|
}
|
|
46
84
|
}
|
|
85
|
+
if (DIR_STATS_CACHE.size >= DIR_STATS_CACHE_LIMIT)
|
|
86
|
+
DIR_STATS_CACHE.clear();
|
|
87
|
+
DIR_STATS_CACHE.set(dir, { mtimeMs, entryCount: entries.length, bytes, files });
|
|
47
88
|
return { bytes, files };
|
|
48
89
|
}
|
|
49
90
|
export async function readProjectionFile(file) {
|
package/lib/host/open.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「复制路径 / 在资源管理器中显示」的宿主侧:**只认自己算出来的目录**。
|
|
3
|
+
*
|
|
4
|
+
* 安全边界(这一段是这个文件存在的全部理由):
|
|
5
|
+
* - 路径**不接受客户端传**:只吃 `sessionId`,由宿主自己按 `<DSH_HOME>/sessions/<slug>/<id>`
|
|
6
|
+
* 或回收站目录拼出来。客户端就算传了别的路径也没用 —— 入参里根本没有路径字段。
|
|
7
|
+
* - 拼出来之后再 `resolve()` 一次并断言它落在**允许的两个根**(`<DSH_HOME>/sessions`、
|
|
8
|
+
* 回收站根)之下。这一道是为了挡住 `../` 之类的 id(线协议的 `idsOf()` 只保证是
|
|
9
|
+
* 非空字符串,不保证它不是一个相对路径)。
|
|
10
|
+
* - 只有 `target: "reveal"` 才会真的去叫系统文件管理器,而且只用**固定的可执行名**
|
|
11
|
+
* (`explorer.exe` / `open` / `xdg-open`),参数是那个已经校验过的目录。不拼 shell 字符串,
|
|
12
|
+
* 走 `spawn` + 参数数组,所以路径里的空格 / 引号 / `&` 都不会被解释成命令。
|
|
13
|
+
*
|
|
14
|
+
* 为什么不用 `shell.openPath`(Electron 的 API):宿主半区跑在 DSH 的 Electron 主进程里,
|
|
15
|
+
* 但插件是普通 ESM 模块,拿不到 `electron` 模块(那是 DSH 自己的依赖,junction 挂载下
|
|
16
|
+
* Node 按 realpath 解析根本找不到 —— 与 AGENTS.md §5 禁止裸导入 `@deepseek-ai/*` 同一条
|
|
17
|
+
* 理由)。所以这里退到"用平台自带的文件管理器命令",零依赖。
|
|
18
|
+
*/
|
|
19
|
+
import { spawn } from "node:child_process";
|
|
20
|
+
import { existsSync } from "node:fs";
|
|
21
|
+
import { resolve, sep } from "node:path";
|
|
22
|
+
import { messageOf } from "./fs-utils.js";
|
|
23
|
+
import { sessionsRoot, trashRoot } from "./paths.js";
|
|
24
|
+
/** `path` / `reveal`:`reveal` 之外的任何值都当成 `path`(只读,最保守的那条)。 */
|
|
25
|
+
export function openTargetOf(value) {
|
|
26
|
+
return value === "reveal" ? "reveal" : "path";
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* 两个允许的根:会话目录与回收站。`resolve()` 之后比较,且要求"相等或以 `<root>+分隔符`
|
|
30
|
+
* 开头" —— 单比 `startsWith(root)` 会把 `<DSH_HOME>/sessions-evil` 放进来。
|
|
31
|
+
*/
|
|
32
|
+
function allowedRoots() {
|
|
33
|
+
return [resolve(sessionsRoot()), resolve(trashRoot())];
|
|
34
|
+
}
|
|
35
|
+
function inside(root, target) {
|
|
36
|
+
return target === root || target.startsWith(root.endsWith(sep) ? root : root + sep);
|
|
37
|
+
}
|
|
38
|
+
/** 平台对应的文件管理器命令;不支持的平台返回 null(`path` 仍然可用)。 */
|
|
39
|
+
function revealCommand() {
|
|
40
|
+
if (process.platform === "win32")
|
|
41
|
+
return { command: "explorer.exe", args: [] };
|
|
42
|
+
if (process.platform === "darwin")
|
|
43
|
+
return { command: "open", args: [] };
|
|
44
|
+
if (process.platform === "linux")
|
|
45
|
+
return { command: "xdg-open", args: [] };
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* 把文件管理器叫起来。**不等它退出**:`explorer.exe` 会立刻返回、`xdg-open` 可能一直活着,
|
|
50
|
+
* 等它就会把端点请求挂住。所以 `detached` + `stdio: "ignore"` + `unref()`,
|
|
51
|
+
* 只监听 `error`(命令不存在那种);真正"有没有打开"由用户自己看得到。
|
|
52
|
+
*/
|
|
53
|
+
function reveal(path) {
|
|
54
|
+
const command = revealCommand();
|
|
55
|
+
if (command === null)
|
|
56
|
+
return { ok: false, reason: `不支持的平台:${process.platform}` };
|
|
57
|
+
try {
|
|
58
|
+
const child = spawn(command.command, [...command.args, path], { detached: true, stdio: "ignore" });
|
|
59
|
+
child.on("error", () => {
|
|
60
|
+
// 没有文件管理器(无头环境)不该让请求失败:路径已经回了,界面提示用户手动打开。
|
|
61
|
+
});
|
|
62
|
+
child.unref();
|
|
63
|
+
return { ok: true };
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
return { ok: false, reason: messageOf(error) };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* `open` 端点:算路径 → 校验 → (可选)叫文件管理器。
|
|
71
|
+
*
|
|
72
|
+
* `sessionId` 必须能在磁盘或回收站里找到;找不到就抛错,由信封兜住(与 `describe` 一致)。
|
|
73
|
+
*/
|
|
74
|
+
export async function openSession(host, ids, target) {
|
|
75
|
+
const sessionId = ids[0];
|
|
76
|
+
if (typeof sessionId !== "string" || sessionId === "")
|
|
77
|
+
throw new Error("缺少会话 id");
|
|
78
|
+
// 磁盘优先,其次回收站:同一个 id 不可能同时在两处(删除时目录被搬走了)。
|
|
79
|
+
const dirs = await host.scanSessionDirs();
|
|
80
|
+
let path = dirs.get(sessionId)?.dir ?? null;
|
|
81
|
+
if (path === null) {
|
|
82
|
+
const inTrash = resolve(trashRoot(), sessionId);
|
|
83
|
+
if (existsSync(inTrash))
|
|
84
|
+
path = inTrash;
|
|
85
|
+
}
|
|
86
|
+
if (path === null)
|
|
87
|
+
throw new Error("找不到这个会话的日志目录(磁盘和回收站里都没有)");
|
|
88
|
+
const resolved = resolve(path);
|
|
89
|
+
const roots = allowedRoots();
|
|
90
|
+
if (!roots.some((root) => inside(root, resolved))) {
|
|
91
|
+
// 到不了这里才说明 id 里有 `../` 之类的东西 —— 直接拒绝,别去碰根目录外面的路径。
|
|
92
|
+
throw new Error("拒绝打开:这个路径不在 DSH 的会话目录里");
|
|
93
|
+
}
|
|
94
|
+
if (!existsSync(resolved))
|
|
95
|
+
throw new Error("日志目录已经不在了");
|
|
96
|
+
if (target !== "reveal")
|
|
97
|
+
return { sessionId, path: resolved, revealed: false };
|
|
98
|
+
const opened = reveal(resolved);
|
|
99
|
+
return opened.ok
|
|
100
|
+
? { sessionId, path: resolved, revealed: true }
|
|
101
|
+
: { sessionId, path: resolved, revealed: false, reason: opened.reason };
|
|
102
|
+
}
|
|
103
|
+
/** 允许的根(导出给自检断言"路径校验真的挡得住 `../`")。 */
|
|
104
|
+
export { allowedRoots, inside as insideRoot };
|
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
}
|
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
|
*
|
|
@@ -31,6 +31,7 @@ const ROUTE_METHODS = ["POST"];
|
|
|
31
31
|
/** 一个频道(`/api/session-curator`):精确路由表按完整 pathname 命中,所以每个端点一条。 */
|
|
32
32
|
export const ENDPOINTS = {
|
|
33
33
|
snapshot: (manager) => manager.snapshot(),
|
|
34
|
+
describe: (manager, payload) => manager.describe(payload.ids ?? []),
|
|
34
35
|
archive: (manager, payload) => manager.archive(payload.ids ?? [], payload.stopActivity),
|
|
35
36
|
unarchive: (manager, payload) => manager.unarchive(payload.ids ?? []),
|
|
36
37
|
pin: (manager, payload) => manager.pin(payload.ids ?? []),
|
|
@@ -38,12 +39,32 @@ export const ENDPOINTS = {
|
|
|
38
39
|
trash: (manager, payload) => manager.trash(payload.ids ?? []),
|
|
39
40
|
restore: (manager, payload) => manager.restore(payload.ids ?? []),
|
|
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)),
|
|
45
|
+
// 磁盘占用视图(只读)与"复制路径 / 在资源管理器中显示"。
|
|
46
|
+
usage: (manager) => manager.usage(),
|
|
47
|
+
// `target` 只认字面量 `"reveal"`;其余一律当 `"path"`(只读,最保守的那条)。
|
|
48
|
+
open: (manager, payload) => manager.open(payload.ids ?? [], payload.target === "reveal" ? "reveal" : "path"),
|
|
41
49
|
"check-update": (manager, payload) => manager.checkUpdate(payload.force === true)
|
|
42
50
|
};
|
|
43
51
|
/** 只接受字符串数组;其余一律忽略,避免把宿主路径当 id 传来的越权尝试。 */
|
|
44
52
|
export function idsOf(payload) {
|
|
45
53
|
return Array.isArray(payload.ids) ? payload.ids.filter((value) => typeof value === "string" && value !== "") : [];
|
|
46
54
|
}
|
|
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
|
+
}
|
|
47
68
|
/**
|
|
48
69
|
* 一个端点处理器。返回 Connection 的固定信封:处理器自身的业务失败走
|
|
49
70
|
* `{ok:false, error}`,端点内部的逐项失败则留在 `value.results` 里。
|
|
@@ -6,11 +6,25 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { promises as fsp } from "node:fs";
|
|
8
8
|
import { join } from "node:path";
|
|
9
|
-
import { dirStats, messageOf, readProjectionFile } from "./fs-utils.js";
|
|
9
|
+
import { dirStats, mapLimit, messageOf, readProjectionFile } from "./fs-utils.js";
|
|
10
10
|
import { META_FILE, dshHomePath, projCacheFile, sessionsRoot, trashRoot } from "./paths.js";
|
|
11
11
|
import { LOCAL_VERSION } from "./manifest.js";
|
|
12
12
|
import { checkForUpdate } from "./update.js";
|
|
13
|
-
import { purgeSessions, restoreSessions, trashSessions } from "./trash.js";
|
|
13
|
+
import { purgeOlderThan, purgeSessions, restoreSessions, trashSessions } from "./trash.js";
|
|
14
|
+
import { isExpired, retentionCutoff } from "./retention.js";
|
|
15
|
+
import { openSession } from "./open.js";
|
|
16
|
+
/**
|
|
17
|
+
* 文件系统侧的并发上限。快照一次要给上百个目录做 `readdir` + `stat`,串行 await 会让
|
|
18
|
+
* 每次变更都慢一拍;无上限并发又会跟 DSH 自己的会话扫描抢 IO。8 是"够快且不过分"的折中。
|
|
19
|
+
*/
|
|
20
|
+
const FS_CONCURRENCY = 8;
|
|
21
|
+
/**
|
|
22
|
+
* 活动判定的并发上限。每个会话问一次官方的 `workspace/session-activity`(瀑布钩子,
|
|
23
|
+
* 可能被别的插件挂成异步 IO),所以给得比文件系统小一些。
|
|
24
|
+
*/
|
|
25
|
+
const ACTIVITY_CONCURRENCY = 4;
|
|
26
|
+
/** `usage` 的两个榜单各回几条(客户端的文案按同一个数字算)。 */
|
|
27
|
+
export const USAGE_TOP_LIMIT = 5;
|
|
14
28
|
export class SessionManager {
|
|
15
29
|
ctx;
|
|
16
30
|
constructor(ctx) {
|
|
@@ -65,11 +79,22 @@ export class SessionManager {
|
|
|
65
79
|
}
|
|
66
80
|
return undefined;
|
|
67
81
|
}
|
|
68
|
-
|
|
82
|
+
/**
|
|
83
|
+
* 一个会话的完整只读事实。
|
|
84
|
+
*
|
|
85
|
+
* 两件事互不依赖(投影缓存、磁盘体积),所以并发拿:单条的耗时从"两次串行 IO"
|
|
86
|
+
* 降到"最慢的那一次"。`findWorkspaceOf()` 是纯内存遍历,不必并发。
|
|
87
|
+
*
|
|
88
|
+
* `active` 由调用方注入(快照会先把整批活动判定并发算完)——`sessionActivity()` 是
|
|
89
|
+
* 瀑布钩子,逐行现问会让"有没有别的插件挂了这个钩子"直接变成列表的延迟。
|
|
90
|
+
*/
|
|
91
|
+
async describeSession(sessionId, dirs, active) {
|
|
69
92
|
const info = dirs.get(sessionId);
|
|
70
|
-
const projection = await
|
|
93
|
+
const [projection, stats] = await Promise.all([
|
|
94
|
+
this.readProjection(sessionId),
|
|
95
|
+
info ? this.dirStats(info.dir) : Promise.resolve({ bytes: 0, files: 0 })
|
|
96
|
+
]);
|
|
71
97
|
const workspace = this.findWorkspaceOf(sessionId);
|
|
72
|
-
const stats = info ? await this.dirStats(info.dir) : { bytes: 0, files: 0 };
|
|
73
98
|
return {
|
|
74
99
|
sessionId,
|
|
75
100
|
title: projection.title ?? (workspace ? workspace.title : null) ?? sessionId,
|
|
@@ -80,7 +105,8 @@ export class SessionManager {
|
|
|
80
105
|
slug: info ? info.slug : null,
|
|
81
106
|
onDisk: Boolean(info),
|
|
82
107
|
bytes: stats.bytes,
|
|
83
|
-
files: stats.files
|
|
108
|
+
files: stats.files,
|
|
109
|
+
active
|
|
84
110
|
};
|
|
85
111
|
}
|
|
86
112
|
async listTrash() {
|
|
@@ -92,10 +118,11 @@ export class SessionManager {
|
|
|
92
118
|
catch {
|
|
93
119
|
return [];
|
|
94
120
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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) => {
|
|
99
126
|
const dir = join(root, entry.name);
|
|
100
127
|
let meta = null;
|
|
101
128
|
try {
|
|
@@ -105,7 +132,7 @@ export class SessionManager {
|
|
|
105
132
|
meta = null;
|
|
106
133
|
}
|
|
107
134
|
const stats = await this.dirStats(dir);
|
|
108
|
-
|
|
135
|
+
return {
|
|
109
136
|
sessionId: entry.name,
|
|
110
137
|
title: typeof meta?.title === "string" && meta.title !== "" ? meta.title : entry.name,
|
|
111
138
|
cwd: typeof meta?.cwd === "string" ? meta.cwd : null,
|
|
@@ -117,35 +144,43 @@ export class SessionManager {
|
|
|
117
144
|
bytes: stats.bytes,
|
|
118
145
|
files: stats.files,
|
|
119
146
|
restorable: typeof meta?.slug === "string" && meta.slug !== ""
|
|
120
|
-
}
|
|
121
|
-
}
|
|
147
|
+
};
|
|
148
|
+
});
|
|
122
149
|
rows.sort((left, right) => right.deletedAt - left.deletedAt);
|
|
123
150
|
return rows;
|
|
124
151
|
}
|
|
125
152
|
/** 当前归档 / 置顶 / 回收站的完整快照,附带目录位置。 */
|
|
126
153
|
async buildSnapshot() {
|
|
127
154
|
const registry = this.registry;
|
|
128
|
-
|
|
129
|
-
const trashed = await this.listTrash();
|
|
155
|
+
// 三个只读来源互不依赖:目录索引(哪些 id 在磁盘上)、回收站清单、registry 记账。
|
|
156
|
+
const [dirs, trashed] = await Promise.all([this.scanSessionDirs(), this.listTrash()]);
|
|
130
157
|
// 回收站条目**只**归回收站页签。删除时我们故意让 id 留在归档集合里(那是 DSH
|
|
131
158
|
// 唯一"从所有分组面消失"的机制,见 trash.ts),所以这里必须把交集滤掉:
|
|
132
159
|
// 否则同一个会话会同时出现在"已归档"和"回收站",而"已归档"那条还是没有目录的幽灵行。
|
|
133
160
|
const inTrash = new Set(trashed.map((row) => row.sessionId));
|
|
134
|
-
const
|
|
161
|
+
const archivedIds = [];
|
|
135
162
|
for (const sessionId of registry.archivedSessionIds) {
|
|
136
163
|
// 顺带把"目录已经不在了"的 id 也滤掉:`archivedSessionIds` 是**只增不减**的记账
|
|
137
164
|
//(DSH 自己的注释:目录没了的条目照样留在集合里),而 DSH 的侧边栏只从会话清单里
|
|
138
165
|
// 取行 —— 这种 id 在它那边本来就不显示。列出来只会得到一条 0 B、点不动的幽灵行。
|
|
139
166
|
if (inTrash.has(sessionId) || !dirs.has(sessionId))
|
|
140
167
|
continue;
|
|
141
|
-
|
|
168
|
+
archivedIds.push(sessionId);
|
|
142
169
|
}
|
|
143
|
-
const
|
|
170
|
+
const pinnedIds = [];
|
|
144
171
|
for (const sessionId of registry.pinnedSessionIds) {
|
|
145
172
|
if (inTrash.has(sessionId) || !dirs.has(sessionId))
|
|
146
173
|
continue;
|
|
147
|
-
|
|
174
|
+
pinnedIds.push(sessionId);
|
|
148
175
|
}
|
|
176
|
+
// 活动判定:两个集合一起去重问一遍(同一个 id 可能既归档又置顶)。结果既写进每行的
|
|
177
|
+
// `active`,也平铺成 `activeSessionIds` 给客户端的跨页签批量用。
|
|
178
|
+
const activeIds = await this.activeAmong([...new Set(archivedIds.concat(pinnedIds))]);
|
|
179
|
+
// 逐条取事实:并发上限里跑,顺序仍按 registry 给的顺序(行不会整片跳位)。
|
|
180
|
+
const [archived, pinned] = await Promise.all([
|
|
181
|
+
mapLimit(archivedIds, FS_CONCURRENCY, (sessionId) => this.describeSession(sessionId, dirs, activeIds.has(sessionId))),
|
|
182
|
+
mapLimit(pinnedIds, FS_CONCURRENCY, (sessionId) => this.describeSession(sessionId, dirs, activeIds.has(sessionId)))
|
|
183
|
+
]);
|
|
149
184
|
let archivedBytes = 0;
|
|
150
185
|
for (const row of archived)
|
|
151
186
|
archivedBytes += row.bytes;
|
|
@@ -166,13 +201,83 @@ export class SessionManager {
|
|
|
166
201
|
pinned: pinned.length,
|
|
167
202
|
trashed: trashed.length,
|
|
168
203
|
trashBytes
|
|
169
|
-
}
|
|
204
|
+
},
|
|
205
|
+
activeSessionIds: [...activeIds]
|
|
170
206
|
};
|
|
171
207
|
}
|
|
208
|
+
/** 一批 id 里哪些正在跑(有上限地并发问 `workspace/session-activity`)。 */
|
|
209
|
+
async activeAmong(sessionIds) {
|
|
210
|
+
const flags = await mapLimit(sessionIds, ACTIVITY_CONCURRENCY, async (sessionId) => (await this.sessionActivity(sessionId)).length > 0);
|
|
211
|
+
const active = new Set();
|
|
212
|
+
sessionIds.forEach((sessionId, index) => {
|
|
213
|
+
if (flags[index] === true)
|
|
214
|
+
active.add(sessionId);
|
|
215
|
+
});
|
|
216
|
+
return active;
|
|
217
|
+
}
|
|
172
218
|
// ── 变更操作 ─────────────────────────────────────────────────────────
|
|
173
219
|
snapshot() {
|
|
174
220
|
return this.buildSnapshot();
|
|
175
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* **单个**会话的完整事实(第十个端点,侧边栏 hover 卡片用)。
|
|
224
|
+
*
|
|
225
|
+
* 入参复用 `ids`(线协议的 `idsOf()` 只接受字符串数组):取第一条,其余忽略 ——
|
|
226
|
+
* hover 卡片一次只问一个会话,把它做成批量端点没有意义。
|
|
227
|
+
*
|
|
228
|
+
* 客户端的 hover 会在 800ms 之后触发、且**每次划过都会问**,所以这里刻意只做
|
|
229
|
+
* `listTrash()` + `scanSessionDirs()` + 单条 `dirStats()`,不做全量快照;
|
|
230
|
+
* 端到端的节流由客户端那份带 TTL 的缓存负责(`src/client/detail.ts`)。
|
|
231
|
+
*/
|
|
232
|
+
async describe(ids) {
|
|
233
|
+
const sessionId = ids[0];
|
|
234
|
+
if (typeof sessionId !== "string" || sessionId === "")
|
|
235
|
+
throw new Error("缺少会话 id");
|
|
236
|
+
const registry = this.registry;
|
|
237
|
+
const archived = registry.archivedSessionIds.includes(sessionId);
|
|
238
|
+
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
|
+
const dirs = await this.scanSessionDirs();
|
|
269
|
+
if (!dirs.has(sessionId))
|
|
270
|
+
throw new Error("找不到这个会话(日志目录不在磁盘上)");
|
|
271
|
+
const row = await this.describeSession(sessionId, dirs, active);
|
|
272
|
+
return {
|
|
273
|
+
...row,
|
|
274
|
+
archived,
|
|
275
|
+
pinned,
|
|
276
|
+
trashed: false,
|
|
277
|
+
deletedAt: null,
|
|
278
|
+
restorable: false
|
|
279
|
+
};
|
|
280
|
+
}
|
|
176
281
|
/** 逐个执行,单项失败只记录在 results 里,不影响其余项。 */
|
|
177
282
|
async each(ids, action) {
|
|
178
283
|
const results = [];
|
|
@@ -223,8 +328,150 @@ export class SessionManager {
|
|
|
223
328
|
purge(ids) {
|
|
224
329
|
return purgeSessions(this, ids);
|
|
225
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);
|
|
356
|
+
}
|
|
357
|
+
/** 复制路径 / 在资源管理器中显示(实现在 `open.ts`,那边有完整的路径校验注释)。 */
|
|
358
|
+
open(ids, target) {
|
|
359
|
+
return openSession(this, ids, target);
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* 磁盘占用视图(只读):整个 `<DSH_HOME>/sessions` 的占用合计 + 两个榜单。
|
|
363
|
+
*
|
|
364
|
+
* 与 `buildSnapshot()` 的关系:两者都读同一批目录,`dirStats()` 的 mtime 缓存让重复的
|
|
365
|
+
* 目录遍历只有第一次真的走磁盘;快照刚刚跑过的那批(归档 / 置顶)在这里直接命中缓存。
|
|
366
|
+
*
|
|
367
|
+
* 为什么要有这条端点而不是让客户端从快照里算:快照只有三个集合,**普通会话**(正在用的
|
|
368
|
+
* 那些)根本不在里面 —— 而"哪些会话最占地方"恰恰要问全部。
|
|
369
|
+
*
|
|
370
|
+
* 聚合按会话**当前所在的文件夹**(回收站条目用 `__trash.json` 里的原 cwd):
|
|
371
|
+
* 与列表分组同一套键,用户看到的"工作区"是同一个东西。
|
|
372
|
+
*/
|
|
373
|
+
async usage() {
|
|
374
|
+
const registry = this.registry;
|
|
375
|
+
const [dirs, trashed] = await Promise.all([this.scanSessionDirs(), this.listTrash()]);
|
|
376
|
+
// 磁盘上的会话 + 回收站里的会话 = 全部会话(两边 id 不会重合,删除时目录已经搬走了)。
|
|
377
|
+
const onDiskIds = [...dirs.keys()];
|
|
378
|
+
const activeIds = await this.activeAmong(onDiskIds);
|
|
379
|
+
const rows = [];
|
|
380
|
+
const push = (row) => {
|
|
381
|
+
rows.push(row);
|
|
382
|
+
};
|
|
383
|
+
await mapLimit(onDiskIds, FS_CONCURRENCY, async (sessionId) => {
|
|
384
|
+
const described = await this.describeSession(sessionId, dirs, activeIds.has(sessionId));
|
|
385
|
+
push({
|
|
386
|
+
sessionId,
|
|
387
|
+
title: described.title,
|
|
388
|
+
cwd: described.cwd,
|
|
389
|
+
workspaceTitle: described.workspaceTitle,
|
|
390
|
+
bytes: described.bytes,
|
|
391
|
+
files: described.files,
|
|
392
|
+
createdAt: described.createdAt,
|
|
393
|
+
deletedAt: null,
|
|
394
|
+
archived: registry.archivedSessionIds.includes(sessionId),
|
|
395
|
+
pinned: registry.pinnedSessionIds.includes(sessionId),
|
|
396
|
+
trashed: false,
|
|
397
|
+
active: described.active
|
|
398
|
+
});
|
|
399
|
+
});
|
|
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
|
+
let totalBytes = 0;
|
|
418
|
+
const groups = new Map();
|
|
419
|
+
for (const row of rows) {
|
|
420
|
+
totalBytes += row.bytes;
|
|
421
|
+
const key = groupKeyOfRow(row);
|
|
422
|
+
const group = groups.get(key);
|
|
423
|
+
if (group === undefined) {
|
|
424
|
+
groups.set(key, { key, title: groupTitleOf(row), count: 1, bytes: row.bytes });
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
group.count += 1;
|
|
428
|
+
group.bytes += row.bytes;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
// 榜单按"体积 / 时间"取前 N 条:客户端只画这几条,"最占地方"也不该把 191 行都发过去。
|
|
432
|
+
const largest = [...rows].sort((left, right) => right.bytes - left.bytes).slice(0, USAGE_TOP_LIMIT);
|
|
433
|
+
const oldest = [...rows]
|
|
434
|
+
// 没有创建时间的沉底(`createdAt: null`):`0` 会让它们排到最前面,看起来像"最老的"。
|
|
435
|
+
.sort((left, right) => (left.createdAt ?? Number.POSITIVE_INFINITY) - (right.createdAt ?? Number.POSITIVE_INFINITY))
|
|
436
|
+
.slice(0, USAGE_TOP_LIMIT);
|
|
437
|
+
return {
|
|
438
|
+
totalSessions: rows.length,
|
|
439
|
+
totalBytes,
|
|
440
|
+
onDiskSessions: onDiskIds.length,
|
|
441
|
+
groups: [...groups.values()].sort((left, right) => right.bytes - left.bytes),
|
|
442
|
+
largest,
|
|
443
|
+
oldest
|
|
444
|
+
};
|
|
445
|
+
}
|
|
226
446
|
/** 更新检查:只读,结果在模块级缓存(见 update.ts)。 */
|
|
227
447
|
checkUpdate(force) {
|
|
228
448
|
return checkForUpdate(force);
|
|
229
449
|
}
|
|
230
450
|
}
|
|
451
|
+
/**
|
|
452
|
+
* 聚合用的分组键:与客户端 `groups.ts` 的 `groupKeyOf()` **同一套语义**(cwd 归一化后优先,
|
|
453
|
+
* 其次 workspaceId,最后"未知目录")。两边各写一份是因为客户端那份在浏览器里、这份在宿主,
|
|
454
|
+
* 但规则必须一致 —— 否则"磁盘占用"里的工作区名会和列表分组对不上。
|
|
455
|
+
*/
|
|
456
|
+
function groupKeyOfRow(row) {
|
|
457
|
+
if (typeof row.cwd === "string" && row.cwd.trim() !== "") {
|
|
458
|
+
return `cwd:${row.cwd
|
|
459
|
+
.trim()
|
|
460
|
+
.replace(/[\\/]+$/, "")
|
|
461
|
+
.replace(/\\/g, "/")
|
|
462
|
+
.toLowerCase()}`;
|
|
463
|
+
}
|
|
464
|
+
return "unknown";
|
|
465
|
+
}
|
|
466
|
+
/** 分组标题:workspaceTitle 优先,其次路径尾段,最后交给客户端填"未知目录"。 */
|
|
467
|
+
function groupTitleOf(row) {
|
|
468
|
+
if (typeof row.workspaceTitle === "string" && row.workspaceTitle !== "")
|
|
469
|
+
return row.workspaceTitle;
|
|
470
|
+
if (typeof row.cwd === "string" && row.cwd !== "") {
|
|
471
|
+
const parts = row.cwd.split(/[\\/]/).filter((part) => part !== "");
|
|
472
|
+
if (parts.length > 0)
|
|
473
|
+
return parts[parts.length - 1];
|
|
474
|
+
return row.cwd;
|
|
475
|
+
}
|
|
476
|
+
return "";
|
|
477
|
+
}
|