dsh-session-curator 0.1.0 → 0.1.2

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.
@@ -0,0 +1,64 @@
1
+ /** 文件系统小工具 + 错误消息归一化。 */
2
+ import { promises as fsp } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ /** 任何抛出物 → 一句人话(宿主 logger 与端点错误信封都用它)。 */
5
+ export function messageOf(error) {
6
+ if (error instanceof Error)
7
+ return error.message;
8
+ return String(error);
9
+ }
10
+ /** 跨卷 rename 会抛 EXDEV,退回 copy + rm。 */
11
+ export async function moveDir(from, to) {
12
+ await fsp.mkdir(dirname(to), { recursive: true });
13
+ try {
14
+ await fsp.rename(from, to);
15
+ return;
16
+ }
17
+ catch (error) {
18
+ if (error?.code !== "EXDEV")
19
+ throw error;
20
+ }
21
+ await fsp.cp(from, to, { recursive: true, force: true });
22
+ await fsp.rm(from, { recursive: true, force: true });
23
+ }
24
+ /** 只统计顶层文件:会话目录里就是那几份日志。 */
25
+ export async function dirStats(dir) {
26
+ let bytes = 0;
27
+ let files = 0;
28
+ let entries;
29
+ try {
30
+ entries = await fsp.readdir(dir, { withFileTypes: true });
31
+ }
32
+ catch {
33
+ return { bytes: 0, files: 0 };
34
+ }
35
+ for (const entry of entries) {
36
+ if (!entry.isFile())
37
+ continue;
38
+ try {
39
+ const stat = await fsp.stat(join(dir, entry.name));
40
+ bytes += stat.size;
41
+ files += 1;
42
+ }
43
+ catch {
44
+ // 竞态:文件刚好消失,忽略
45
+ }
46
+ }
47
+ return { bytes, files };
48
+ }
49
+ export async function readProjectionFile(file) {
50
+ try {
51
+ const parsed = JSON.parse(await fsp.readFile(file, "utf8"));
52
+ const rows = parsed?.record?.rows;
53
+ const identity = parsed?.record?.identity;
54
+ const title = rows?.title?.val;
55
+ return {
56
+ title: typeof title === "string" && title !== "" ? title : null,
57
+ cwd: typeof identity?.cwd === "string" ? identity.cwd : null,
58
+ createdAt: typeof identity?.createdAt === "number" ? identity.createdAt : null
59
+ };
60
+ }
61
+ catch {
62
+ return { title: null, cwd: null, createdAt: null };
63
+ }
64
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * 版本号与插件名:都从**自己的 manifest** 读。
3
+ *
4
+ * 路径按编译后的位置算:本文件编译成 `lib/host/manifest.js`,所以包根是它的上两级
5
+ * (无论插件是装在 `node_modules` 里还是被 junction 指过来,`import.meta.url` 都指向
6
+ * 真实文件)。名字用字面量而不是读 manifest:DSH 拿它当插件 id,一旦 manifest 读失败
7
+ * 也必须还是对的;版本号读不到就回 `null`(界面上显示成 "—"),不影响加载。
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+ /** 插件名 == DSH 的插件 id == npm 包名。 */
11
+ export const PLUGIN_NAME = "dsh-session-curator";
12
+ function readLocalVersion() {
13
+ try {
14
+ const manifest = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
15
+ return typeof manifest?.version === "string" && manifest.version !== "" ? manifest.version : null;
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ /** 本地版本(`package.json` 的 `version`)。 */
22
+ export const LOCAL_VERSION = readLocalVersion();
@@ -0,0 +1,47 @@
1
+ /**
2
+ * DSH home 解析与插件用到的全部路径。
3
+ *
4
+ * 这里刻意**不 import** `@deepseek-ai/dsh-home-paths`:那个包只在 DSH 自己的
5
+ * node_modules 里,而插件可能以 junction/symlink 形式挂在 profile 下 —— 此时 Node 会
6
+ * 按 realpath 解析裸标识符,从插件源码目录向上找不到它,插件就加载不了。语义与那个包
7
+ * 一致(`$DSH_HOME` 优先,否则 `~/.dsh`),几行就够,换来的是"插件放哪都能跑"。
8
+ */
9
+ import { homedir } from "node:os";
10
+ import { join, resolve } from "node:path";
11
+ export function resolveDshHome() {
12
+ const configured = process.env.DSH_HOME;
13
+ if (typeof configured === "string" && configured.trim() !== "") {
14
+ const expanded = configured.trim();
15
+ if (expanded === "~")
16
+ return homedir();
17
+ if (expanded.startsWith("~/") || expanded.startsWith("~\\"))
18
+ return resolve(join(homedir(), expanded.slice(2)));
19
+ return resolve(expanded);
20
+ }
21
+ return resolve(join(homedir(), ".dsh"));
22
+ }
23
+ /** 每次都重新解析:`smoke.mjs` 会在同一进程里换掉 `DSH_HOME` 来用临时 home。 */
24
+ export function dshHomePath(...segments) {
25
+ return join(resolveDshHome(), ...segments);
26
+ }
27
+ /**
28
+ * 回收站根目录:`<DSH_HOME>/storages/session-manager/trash`。
29
+ *
30
+ * 这里的 `session-manager` 是**故意不改的旧名**:插件从 `dsh-session-manager` 改名成
31
+ * `dsh-session-curator` 时,回收站里可能还躺着用户已经删掉的会话(本机就有一个)——
32
+ * 改路径会把它们变成孤儿。数据目录用历史名、其余 id 用新名,是权衡后的结果。
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
+ export function sessionsRoot() {
43
+ return dshHomePath("sessions");
44
+ }
45
+ export function projCacheFile(sessionId) {
46
+ return dshHomePath("storages", "session_projcache", "sessions", `${sessionId}.json`);
47
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * 传输 / 分发:九个端点 → 九条精确 Fetch 路由。
3
+ *
4
+ * ── 为什么**不用** `ctx.connection.rpc.handle()`(踩过的坑,改回去之前先读)──
5
+ *
6
+ * 它内部把路由交给 `owner.effect(() => owner.webServer.register(route))`,而那个
7
+ * `owner` 是 `this.ctx` —— **Connection 服务自己的 ctx**,不是读服务的插件 ctx。
8
+ * cordis 的服务调用会带 shadow(`Context[symbols.shadow]` 指向提供者作用域),所以
9
+ * 服务解析从"提供者的 fiber"开始;Connection 自己的 inject 只有 `["credentials"]`,
10
+ * 于是必然抛:
11
+ *
12
+ * cannot get property "webServer" without inject
13
+ * (@deepseek-ai/dsh-client-connection/lib/index.js:656)
14
+ *
15
+ * 结果:插件 fiber 直接变 `failed`、一条路由都没挂上,客户端每个 POST 落到 SPA 静态
16
+ * 兜底,由 `@deepseek-ai/dsh-host-frontend-static` 对非 GET/HEAD 回 **HTTP 405** ——
17
+ * 就是 `transport failure for /session-manager/trash: HTTP 405`。
18
+ * 注意:**在插件自己的 inject 里加 `webServer` 修不了它**,因为报错的那个 ctx 不是插件的。
19
+ *
20
+ * 改走同样由 Connection 公布的 `ctx.connection.fetch.register()`:精确 Fetch 路由挂在
21
+ * 共享 `/api` 前缀下,`admit()`(Host/Origin/`sec-fetch-site` + 浏览器会话 cookie)与
22
+ * 请求体上限都仍由 Connection 的 `/api` 前缀路由先执行,插件依旧零鉴权代码。
23
+ */
24
+ import { messageOf } from "./fs-utils.js";
25
+ /** DSH 的共享 API 通道:唯一带 Host/Origin 信任栅栏与浏览器会话鉴权的 HTTP 面。 */
26
+ export const API_CHANNEL = "/api";
27
+ /** 频道下属于本插件的路径前缀(`/api/session-curator/<endpoint>`)。 */
28
+ export const ROUTE_PREFIX = "session-curator";
29
+ /** 端点只读 / 只写,一律 POST(Connection 的精确路由按方法集合匹配)。 */
30
+ const ROUTE_METHODS = ["POST"];
31
+ /** 一个频道(`/api/session-curator`):精确路由表按完整 pathname 命中,所以每个端点一条。 */
32
+ export const ENDPOINTS = {
33
+ snapshot: (manager) => manager.snapshot(),
34
+ archive: (manager, payload) => manager.archive(payload.ids ?? [], payload.stopActivity),
35
+ unarchive: (manager, payload) => manager.unarchive(payload.ids ?? []),
36
+ pin: (manager, payload) => manager.pin(payload.ids ?? []),
37
+ unpin: (manager, payload) => manager.unpin(payload.ids ?? []),
38
+ trash: (manager, payload) => manager.trash(payload.ids ?? []),
39
+ restore: (manager, payload) => manager.restore(payload.ids ?? []),
40
+ purge: (manager, payload) => manager.purge(payload.ids ?? []),
41
+ "check-update": (manager, payload) => manager.checkUpdate(payload.force === true)
42
+ };
43
+ /** 只接受字符串数组;其余一律忽略,避免把宿主路径当 id 传来的越权尝试。 */
44
+ export function idsOf(payload) {
45
+ return Array.isArray(payload.ids) ? payload.ids.filter((value) => typeof value === "string" && value !== "") : [];
46
+ }
47
+ /**
48
+ * 一个端点处理器。返回 Connection 的固定信封:处理器自身的业务失败走
49
+ * `{ok:false, error}`,端点内部的逐项失败则留在 `value.results` 里。
50
+ */
51
+ export async function handle(manager, endpoint, payload) {
52
+ const run = ENDPOINTS[endpoint];
53
+ if (run === undefined) {
54
+ return { ok: false, error: { code: "session-curator/unknown-endpoint", message: `unknown endpoint ${endpoint}`, details: {} } };
55
+ }
56
+ const safe = payload !== null && typeof payload === "object" ? payload : {};
57
+ // 越过线协议边界之后客户端发来的一切都不可信:这里如实标成 EndpointPayload,
58
+ // 具体字段仍由各处理器自己判(`idsOf()` 已经归一化过 ids)。
59
+ const normalized = { ...safe, ids: idsOf(safe) };
60
+ try {
61
+ return { ok: true, value: await run(manager, normalized) };
62
+ }
63
+ catch (error) {
64
+ return { ok: false, error: { code: "session-curator/error", message: messageOf(error), details: {} } };
65
+ }
66
+ }
67
+ /** `{type:'server-response', rpcId, result}` —— 与 Connection 自己回的信封逐字一致。 */
68
+ function envelope(rpcId, result) {
69
+ return Response.json({ type: "server-response", rpcId, result });
70
+ }
71
+ function badRequest(rpcId, message) {
72
+ // 与 Connection 的 `invalidEnvelopeResponse` 一致:坏信封也回 200 + 错误信封,
73
+ // 这样客户端读到的是人话,而不是一个传输层异常。
74
+ return envelope(rpcId ?? "invalid-request", {
75
+ ok: false,
76
+ error: { code: "session-curator/bad-request", message, details: {} }
77
+ });
78
+ }
79
+ /** `/api` 已在前面做完信任与鉴权,这里只校验信封、分发、回包。 */
80
+ export async function respond(manager, endpoint, request) {
81
+ let body;
82
+ try {
83
+ body = await request.json();
84
+ }
85
+ catch {
86
+ return badRequest(null, "body is not JSON");
87
+ }
88
+ const message = body !== null && typeof body === "object" ? body : null;
89
+ const rpcId = typeof message?.rpcId === "string" ? message.rpcId : null;
90
+ if (rpcId === null || message?.type !== "client-request" || typeof message.method !== "string") {
91
+ return badRequest(rpcId, "invalid client-request message");
92
+ }
93
+ const target = `${ROUTE_PREFIX}/${endpoint}`;
94
+ if (message.method !== target) {
95
+ return badRequest(rpcId, `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(target)}`);
96
+ }
97
+ return envelope(rpcId, await handle(manager, endpoint, message.payload));
98
+ }
99
+ /** 为每个端点挂一条精确路由;注册挂在**插件自己的 fiber** 上,卸载时一起摘掉。 */
100
+ export function registerRoutes(ctx, manager) {
101
+ // 只知道"精确路由登记处"这一个面;命名避开全局 fetch。
102
+ const fetchRoutes = ctx.connection.fetch;
103
+ for (const endpoint of Object.keys(ENDPOINTS)) {
104
+ const route = {
105
+ path: `${API_CHANNEL}/${ROUTE_PREFIX}/${endpoint}`,
106
+ methods: ROUTE_METHODS,
107
+ requestBody: "buffered",
108
+ fetch: (request) => respond(manager, endpoint, request)
109
+ };
110
+ // `registerFetchRoute` 用的是调用方的 `owner.effect`,所以这里传的是插件的 ctx。
111
+ ctx.effect(() => fetchRoutes.register(route), `dsh-session-curator: ${route.path}`);
112
+ }
113
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * 会话管理器:**只读投影**(快照 / 标题 / 体积)+ 归档 / 置顶这类轻量变更。
3
+ *
4
+ * 危险操作(软删除 / 恢复 / 永久删除)在 `trash.ts` 里,那边带完整的事故注释;
5
+ * 这里只负责把磁盘与 registry 的事实汇总成快照,以及逐项执行的框架 `each()`。
6
+ */
7
+ import { promises as fsp } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { dirStats, messageOf, readProjectionFile } from "./fs-utils.js";
10
+ import { META_FILE, dshHomePath, projCacheFile, sessionsRoot, trashRoot } from "./paths.js";
11
+ import { LOCAL_VERSION } from "./manifest.js";
12
+ import { checkForUpdate } from "./update.js";
13
+ import { purgeSessions, restoreSessions, trashSessions } from "./trash.js";
14
+ export class SessionManager {
15
+ ctx;
16
+ constructor(ctx) {
17
+ this.ctx = ctx;
18
+ }
19
+ get registry() {
20
+ return this.ctx.workspaceRegistry;
21
+ }
22
+ // ── 只读投影 ─────────────────────────────────────────────────────────
23
+ /** `<DSH_HOME>/sessions/<slug>/<sessionId>` → `{ dir, slug }` 索引。 */
24
+ async scanSessionDirs() {
25
+ const root = sessionsRoot();
26
+ const map = new Map();
27
+ let slugs;
28
+ try {
29
+ slugs = await fsp.readdir(root, { withFileTypes: true });
30
+ }
31
+ catch {
32
+ return map;
33
+ }
34
+ for (const slug of slugs) {
35
+ if (!slug.isDirectory())
36
+ continue;
37
+ let ids;
38
+ try {
39
+ ids = await fsp.readdir(join(root, slug.name), { withFileTypes: true });
40
+ }
41
+ catch {
42
+ continue;
43
+ }
44
+ for (const id of ids) {
45
+ if (!id.isDirectory())
46
+ continue;
47
+ if (!map.has(id.name))
48
+ map.set(id.name, { dir: join(root, slug.name, id.name), slug: slug.name });
49
+ }
50
+ }
51
+ return map;
52
+ }
53
+ /** 投影缓存里的会话元数据(标题 / cwd / 创建时间)。缺文件时返回空字段。 */
54
+ readProjection(sessionId) {
55
+ return readProjectionFile(projCacheFile(sessionId));
56
+ }
57
+ dirStats(dir) {
58
+ return dirStats(dir);
59
+ }
60
+ findWorkspaceOf(sessionId) {
61
+ for (const workspace of this.registry.list()) {
62
+ const ids = workspace.sessionIds;
63
+ if (Array.isArray(ids) && ids.includes(sessionId))
64
+ return workspace;
65
+ }
66
+ return undefined;
67
+ }
68
+ async describeSession(sessionId, dirs) {
69
+ const info = dirs.get(sessionId);
70
+ const projection = await this.readProjection(sessionId);
71
+ const workspace = this.findWorkspaceOf(sessionId);
72
+ const stats = info ? await this.dirStats(info.dir) : { bytes: 0, files: 0 };
73
+ return {
74
+ sessionId,
75
+ title: projection.title ?? (workspace ? workspace.title : null) ?? sessionId,
76
+ cwd: projection.cwd ?? (workspace ? workspace.path : null),
77
+ createdAt: projection.createdAt ?? null,
78
+ workspaceId: workspace ? String(workspace.id) : null,
79
+ workspaceTitle: workspace ? workspace.title : null,
80
+ slug: info ? info.slug : null,
81
+ onDisk: Boolean(info),
82
+ bytes: stats.bytes,
83
+ files: stats.files
84
+ };
85
+ }
86
+ async listTrash() {
87
+ const root = trashRoot();
88
+ let entries;
89
+ try {
90
+ entries = await fsp.readdir(root, { withFileTypes: true });
91
+ }
92
+ catch {
93
+ return [];
94
+ }
95
+ const rows = [];
96
+ for (const entry of entries) {
97
+ if (!entry.isDirectory())
98
+ continue;
99
+ const dir = join(root, entry.name);
100
+ let meta = null;
101
+ try {
102
+ meta = JSON.parse(await fsp.readFile(join(dir, META_FILE), "utf8"));
103
+ }
104
+ catch {
105
+ meta = null;
106
+ }
107
+ const stats = await this.dirStats(dir);
108
+ rows.push({
109
+ sessionId: entry.name,
110
+ title: typeof meta?.title === "string" && meta.title !== "" ? meta.title : entry.name,
111
+ cwd: typeof meta?.cwd === "string" ? meta.cwd : null,
112
+ slug: typeof meta?.slug === "string" ? meta.slug : null,
113
+ // 与 describeSession 对齐:客户端按"原来的工作区文件夹"分组要用到这两个字段。
114
+ workspaceId: typeof meta?.workspaceId === "string" ? meta.workspaceId : null,
115
+ workspaceTitle: typeof meta?.workspaceTitle === "string" ? meta.workspaceTitle : null,
116
+ deletedAt: typeof meta?.deletedAt === "number" ? meta.deletedAt : 0,
117
+ bytes: stats.bytes,
118
+ files: stats.files,
119
+ restorable: typeof meta?.slug === "string" && meta.slug !== ""
120
+ });
121
+ }
122
+ rows.sort((left, right) => right.deletedAt - left.deletedAt);
123
+ return rows;
124
+ }
125
+ /** 当前归档 / 置顶 / 回收站的完整快照,附带目录位置。 */
126
+ async buildSnapshot() {
127
+ const registry = this.registry;
128
+ const dirs = await this.scanSessionDirs();
129
+ const trashed = await this.listTrash();
130
+ // 回收站条目**只**归回收站页签。删除时我们故意让 id 留在归档集合里(那是 DSH
131
+ // 唯一"从所有分组面消失"的机制,见 trash.ts),所以这里必须把交集滤掉:
132
+ // 否则同一个会话会同时出现在"已归档"和"回收站",而"已归档"那条还是没有目录的幽灵行。
133
+ const inTrash = new Set(trashed.map((row) => row.sessionId));
134
+ const archived = [];
135
+ for (const sessionId of registry.archivedSessionIds) {
136
+ // 顺带把"目录已经不在了"的 id 也滤掉:`archivedSessionIds` 是**只增不减**的记账
137
+ //(DSH 自己的注释:目录没了的条目照样留在集合里),而 DSH 的侧边栏只从会话清单里
138
+ // 取行 —— 这种 id 在它那边本来就不显示。列出来只会得到一条 0 B、点不动的幽灵行。
139
+ if (inTrash.has(sessionId) || !dirs.has(sessionId))
140
+ continue;
141
+ archived.push(await this.describeSession(sessionId, dirs));
142
+ }
143
+ const pinned = [];
144
+ for (const sessionId of registry.pinnedSessionIds) {
145
+ if (inTrash.has(sessionId) || !dirs.has(sessionId))
146
+ continue;
147
+ pinned.push(await this.describeSession(sessionId, dirs));
148
+ }
149
+ let archivedBytes = 0;
150
+ for (const row of archived)
151
+ archivedBytes += row.bytes;
152
+ let trashBytes = 0;
153
+ for (const row of trashed)
154
+ trashBytes += row.bytes;
155
+ return {
156
+ dshHome: dshHomePath(),
157
+ version: LOCAL_VERSION,
158
+ trashDir: trashRoot(),
159
+ sessionsDir: sessionsRoot(),
160
+ archived,
161
+ pinned,
162
+ trashed,
163
+ totals: {
164
+ archived: archived.length,
165
+ archivedBytes,
166
+ pinned: pinned.length,
167
+ trashed: trashed.length,
168
+ trashBytes
169
+ }
170
+ };
171
+ }
172
+ // ── 变更操作 ─────────────────────────────────────────────────────────
173
+ snapshot() {
174
+ return this.buildSnapshot();
175
+ }
176
+ /** 逐个执行,单项失败只记录在 results 里,不影响其余项。 */
177
+ async each(ids, action) {
178
+ const results = [];
179
+ for (const sessionId of ids) {
180
+ try {
181
+ const extra = await action(sessionId);
182
+ const row = { sessionId, ok: true };
183
+ if (extra)
184
+ row.steps = extra;
185
+ results.push(row);
186
+ }
187
+ catch (error) {
188
+ results.push({ sessionId, ok: false, reason: messageOf(error) });
189
+ }
190
+ }
191
+ return { results, ...(await this.buildSnapshot()) };
192
+ }
193
+ /** `stopActivity` 为 true 时先停掉会话正在跑的工作再归档。 */
194
+ archive(ids, stopActivity) {
195
+ return this.each(ids, (sessionId) => this.registry.archiveSession(sessionId, { stopActivity: Boolean(stopActivity) }));
196
+ }
197
+ unarchive(ids) {
198
+ return this.each(ids, (sessionId) => this.registry.unarchiveSession(sessionId));
199
+ }
200
+ pin(ids) {
201
+ return this.each(ids, (sessionId) => this.registry.pinSession(sessionId));
202
+ }
203
+ unpin(ids) {
204
+ return this.each(ids, (sessionId) => this.registry.unpinSession(sessionId));
205
+ }
206
+ /** 正在跑的会话不允许删除,与官方归档同一条活动判据。 */
207
+ async sessionActivity(sessionId) {
208
+ try {
209
+ const activity = await this.ctx.waterfall("workspace/session-activity", { sessionId }, () => Promise.resolve([]));
210
+ return Array.isArray(activity) ? activity : [];
211
+ }
212
+ catch {
213
+ return [];
214
+ }
215
+ }
216
+ // ── 危险操作:实现在 trash.ts(那边有完整的事故注释) ─────────────────
217
+ trash(ids) {
218
+ return trashSessions(this, ids);
219
+ }
220
+ restore(ids) {
221
+ return restoreSessions(this, ids);
222
+ }
223
+ purge(ids) {
224
+ return purgeSessions(this, ids);
225
+ }
226
+ /** 更新检查:只读,结果在模块级缓存(见 update.ts)。 */
227
+ checkUpdate(force) {
228
+ return checkForUpdate(force);
229
+ }
230
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * 危险操作:软删除(移入回收站)/ 恢复 / 永久删除。
3
+ *
4
+ * 这个文件里每一条顺序都是踩过事故之后定下来的,动之前先读注释;回归测试在
5
+ * `scripts/smoke.mjs` 的"软删除语义(临时 DSH_HOME)"那一段。
6
+ */
7
+ import { existsSync, promises as fsp } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { messageOf, moveDir } from "./fs-utils.js";
10
+ import { META_FILE, PROJ_FILE, projCacheFile, sessionsRoot, trashRoot } from "./paths.js";
11
+ /**
12
+ * 软删除:把会话**藏起来**(进归档集合)+ 移出工作区记账 + 搬到回收站(含投影缓存)。
13
+ *
14
+ * 为什么是"归档"而不是"取消归档":DSH 里**唯一**能让一个会话从所有分组面
15
+ *(侧边栏的工作区分组、**未分组**、置顶)消失的机制,就是 `archivedSessionIds`
16
+ * 这个 registry-global 集合 —— 它的注释原话是 "sessions hidden from every grouping
17
+ * surface",而且归档**不动工作区记账**,槽位留着,所以恢复时位置不变。
18
+ *
19
+ * 反过来做(先 `unarchiveSession`)必然出幽灵行:取消归档会把会话重新暴露给
20
+ * 客户端那份会话列表(`ctx.sessions.list`),而它的目录虽然已经被我们搬走,
21
+ * 客户端 list 里那条还在 —— 侧边栏 `groupByWorkspace()` 的判定是
22
+ * "在 list 里 && 不属于任何工作区 && 不在归档集合里" → 落进**未分组**。
23
+ * 同时归档集合里已经没有它,等于谁也管不住这条幽灵行了。
24
+ *
25
+ * 原来是否归档记在 `__trash.json` 的 `wasArchived` 里,恢复时照原样还原。
26
+ */
27
+ export async function trashSessions(host, ids) {
28
+ await fsp.mkdir(trashRoot(), { recursive: true });
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
+ // 必须**趁目录还在**归档:`archiveSession` 要先通过 `sessionKnown()`
42
+ //(live 会话 / 头部索引 / 持久化清单),搬走之后三者都查不到了。
43
+ const wasArchived = host.registry.archivedSessionIds.includes(sessionId);
44
+ if (!wasArchived) {
45
+ try {
46
+ await host.registry.archiveSession(sessionId);
47
+ steps.push("archive");
48
+ }
49
+ catch (error) {
50
+ // 藏不住不该阻止删除:会话可能继续留在侧边栏,但数据照样进回收站。
51
+ steps.push(`archive-failed:${messageOf(error)}`);
52
+ }
53
+ }
54
+ // 置顶的会话也要先取消置顶:否则它的 id 还留在 pinnedSessionIds 里,
55
+ // 搬进回收站之后会同时出现在"已置顶"和"回收站"两个页签。
56
+ if (host.registry.pinnedSessionIds.includes(sessionId)) {
57
+ await host.registry.unpinSession(sessionId);
58
+ steps.push("unpin");
59
+ }
60
+ const workspace = host.findWorkspaceOf(sessionId);
61
+ if (workspace) {
62
+ try {
63
+ if (typeof workspace.detachSession === "function") {
64
+ await workspace.detachSession(sessionId);
65
+ steps.push("detach");
66
+ }
67
+ }
68
+ catch (error) {
69
+ steps.push(`detach-failed:${messageOf(error)}`);
70
+ }
71
+ }
72
+ const projection = await host.readProjection(sessionId);
73
+ const target = join(trashRoot(), sessionId);
74
+ await fsp.rm(target, { recursive: true, force: true });
75
+ await moveDir(info.dir, target);
76
+ steps.push("move");
77
+ const cached = projCacheFile(sessionId);
78
+ if (existsSync(cached)) {
79
+ try {
80
+ await fsp.rename(cached, join(target, PROJ_FILE));
81
+ steps.push("projcache");
82
+ }
83
+ catch {
84
+ // 缓存搬不动不影响会话本身
85
+ }
86
+ }
87
+ await fsp.writeFile(join(target, META_FILE), JSON.stringify({
88
+ version: 1,
89
+ sessionId,
90
+ slug: info.slug,
91
+ title: projection.title,
92
+ cwd: projection.cwd,
93
+ workspaceId: workspace ? String(workspace.id) : null,
94
+ workspaceTitle: workspace ? workspace.title : null,
95
+ // 删除前是否已经归档 —— 恢复时要照原样还原(老条目没有这个字段,
96
+ // 一律按"没归档过"处理,也就是恢复成普通会话)。
97
+ wasArchived,
98
+ deletedAt: Date.now()
99
+ }, null, 2), "utf8");
100
+ return steps;
101
+ });
102
+ }
103
+ /**
104
+ * 从回收站恢复;尽力把会话重新挂回原工作区,并把归档状态还原成删除前的样子。
105
+ *
106
+ * `trashSessions()` 会把会话塞进归档集合(藏起来),所以这里必须配对处理:
107
+ * - 删除前**已经归档**(`wasArchived: true`)→ 保持归档,它就回到"已归档"页签;
108
+ * - 其余(含老版本写下的、没有这个字段的条目)→ 取消归档,恢复成普通会话。
109
+ * 不还原的话,恢复出来的会话会莫名其妙地"还在已归档里"。
110
+ */
111
+ export async function restoreSessions(host, ids) {
112
+ const dirs = await host.scanSessionDirs();
113
+ return host.each(ids, async (sessionId) => {
114
+ const steps = [];
115
+ const source = join(trashRoot(), sessionId);
116
+ if (!existsSync(source))
117
+ throw new Error("回收站里没有这个会话");
118
+ let meta = null;
119
+ try {
120
+ meta = JSON.parse(await fsp.readFile(join(source, META_FILE), "utf8"));
121
+ }
122
+ catch {
123
+ meta = null;
124
+ }
125
+ if (typeof meta?.slug !== "string" || meta.slug === "") {
126
+ throw new Error("缺少回收站元数据,无法确定原目录");
127
+ }
128
+ if (dirs.has(sessionId))
129
+ throw new Error("目标会话目录已存在,请先处理同名会话");
130
+ const target = join(sessionsRoot(), meta.slug, sessionId);
131
+ await moveDir(source, target);
132
+ steps.push("move");
133
+ const cached = join(target, PROJ_FILE);
134
+ if (existsSync(cached)) {
135
+ try {
136
+ await fsp.rename(cached, projCacheFile(sessionId));
137
+ steps.push("projcache");
138
+ }
139
+ catch {
140
+ // 同上,非关键
141
+ }
142
+ }
143
+ try {
144
+ await fsp.rm(join(target, META_FILE), { force: true });
145
+ }
146
+ catch {
147
+ // 元数据残留无害
148
+ }
149
+ // 归档状态先还原,再挂回工作区:attachSession 要读得到会话头(目录已经回来了)。
150
+ if (meta.wasArchived !== true && host.registry.archivedSessionIds.includes(sessionId)) {
151
+ await host.registry.unarchiveSession(sessionId);
152
+ steps.push("unarchive");
153
+ }
154
+ if (typeof meta.cwd === "string" && meta.cwd !== "") {
155
+ try {
156
+ const workspace = await host.registry.resolveByPath(meta.cwd);
157
+ if (workspace && typeof workspace.attachSession === "function") {
158
+ await workspace.attachSession(sessionId);
159
+ steps.push("attach");
160
+ }
161
+ }
162
+ catch {
163
+ // 索引要等下次启动才会收录这个会话,挂靠失败不影响恢复
164
+ }
165
+ }
166
+ return steps;
167
+ });
168
+ }
169
+ /**
170
+ * 永久删除(不可恢复)。
171
+ *
172
+ * 先取消归档:`trashSessions()` 为了让会话从侧边栏消失而把它放进了归档集合,永久删除之后
173
+ * 再没人会去动这条 id —— 留在集合里就是一条**永久幽灵**(DSH 自身会一直以为它归档着,
174
+ * 界面上又找不到它)。
175
+ */
176
+ export async function purgeSessions(host, ids) {
177
+ return host.each(ids, async (sessionId) => {
178
+ const target = join(trashRoot(), sessionId);
179
+ if (!existsSync(target))
180
+ throw new Error("回收站里没有这个会话");
181
+ const steps = [];
182
+ if (host.registry.archivedSessionIds.includes(sessionId)) {
183
+ await host.registry.unarchiveSession(sessionId);
184
+ steps.push("unarchive");
185
+ }
186
+ await fsp.rm(target, { recursive: true, force: true });
187
+ steps.push("purge");
188
+ return steps;
189
+ });
190
+ }