@xrn1997/dsh-session-delete 0.0.0-stage → 0.0.1
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/LICENSE +201 -0
- package/README.md +130 -3
- package/cordis.patch.yml +3 -0
- package/icon.svg +18 -0
- package/lib/client.js +2083 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +65 -0
- package/lib/index.js +876 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +91 -6
package/lib/index.js
ADDED
|
@@ -0,0 +1,876 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { mkdir, readdir, rename, rm, stat } from "node:fs/promises";
|
|
5
|
+
//#region src/shared/wire.ts
|
|
6
|
+
/**
|
|
7
|
+
* 跨半 wire 契约:`/dsh-session-delete-api` 的**前缀、四个端点段、请求字段名**——
|
|
8
|
+
* Node 半(`src/api/dispatch.ts` 注册并分发)与浏览器半(`src/client/remote.ts` 同源 fetch)
|
|
9
|
+
* 之间唯一的那份真相。两侧都 import 它,不各写一份字面量。
|
|
10
|
+
*
|
|
11
|
+
* 两条构建期硬约束(不是偏好):
|
|
12
|
+
* ① tsdown 的浏览器半纯度门拦 Node 内建与平台模块表外的 `@deepseek-ai/*` 值 import——
|
|
13
|
+
* 本文件两者都不涉及,且**零运行时依赖**(client 半把它整个 inline 进 bundle);
|
|
14
|
+
* ② 跨插件一律 `import type`——本文件干脆没有 import。
|
|
15
|
+
*
|
|
16
|
+
* 信封(`RemoteResult`)与失败形状(`RemoteFailure`)也在这里——**因为它们本来就是两半的共同契约**:
|
|
17
|
+
* Node 半按它产出(`src/api/dispatch.ts` 的失败支就是 `RemoteFailure` 那两件),浏览器半按它判别
|
|
18
|
+
* (`src/client/remote.ts` 的 `unwrapRemoteResult` / `failureOf`)。形状取自宿主网关客户端面的原文
|
|
19
|
+
* 「每次 unary 调用 resolve 成 RemoteResult、不为载体问题 reject」,但**它是本仓自己的 wire 契约**,
|
|
20
|
+
* 不是对宿主模块的 import。
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* 插件专属前缀。
|
|
24
|
+
*
|
|
25
|
+
* 为什么必须带插件名:宿主 web server 的 `register({kind:'prefix', path})` 对**重复的 (kind, path)
|
|
26
|
+
* 直接抛错**(`dsh-host-webserver` 原文 `webserver: duplicate ${route.kind} route "${route.path}"`),
|
|
27
|
+
* 撞了就是整棵插件树 boot 失败——所以这条路径是与生态的硬边界,不是显示好不好看的问题。
|
|
28
|
+
*/
|
|
29
|
+
const SESSION_DELETE_API_PREFIX = "/dsh-session-delete-api";
|
|
30
|
+
/** 四个端点的段名(相对前缀;`GET list` 读、其余三个 `POST` 写)。 */
|
|
31
|
+
const ROUTES = {
|
|
32
|
+
list: "/list",
|
|
33
|
+
delete: "/delete",
|
|
34
|
+
restore: "/restore",
|
|
35
|
+
purge: "/purge"
|
|
36
|
+
};
|
|
37
|
+
/** 请求体字段名:浏览器半发、Node 半读,同名同义(`purge` 的 `entryId` 缺席 = 清空)。 */
|
|
38
|
+
const PARAMS = {
|
|
39
|
+
sessionId: "sessionId",
|
|
40
|
+
entryId: "entryId"
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* 进程内判别「这已经是一次失败」——**按标记位认,不按 `instanceof`**(跨 realm / 跨 bundle 都不成立)。
|
|
44
|
+
* 位名与判法取自宿主网关客户端面的原文:
|
|
45
|
+
* `if (typeof value === "object" && value !== null && value.isDSHRemoteError === true && typeof value.code === "string") return value`。
|
|
46
|
+
*
|
|
47
|
+
* 线上递过来的是**一段 JSON**(标记位不跟着过线)⇒ 落到哪一端,由那端的构造处就地补上标记:
|
|
48
|
+
* 浏览器半是 `src/client/remote.ts` 的 `throwRemoteFailure`。
|
|
49
|
+
*/
|
|
50
|
+
function isDSHRemoteError(value) {
|
|
51
|
+
if (typeof value !== "object" || value === null) return false;
|
|
52
|
+
const candidate = value;
|
|
53
|
+
return candidate.isDSHRemoteError === true && typeof candidate.code === "string";
|
|
54
|
+
}
|
|
55
|
+
//#endregion
|
|
56
|
+
//#region src/verbs/errors.ts
|
|
57
|
+
/** 五个码(设计稿 §6 的表)。**唯一定义处**:`remote/map-error.ts` 从这份数组反推它的 `Code`,
|
|
58
|
+
* 免得"加了一个码只在一边加"让两张表各自漂移(漂移的后果是那个码静默降级成 `io`)。 */
|
|
59
|
+
const VERB_CODES = [
|
|
60
|
+
"live",
|
|
61
|
+
"not-found",
|
|
62
|
+
"trash-empty",
|
|
63
|
+
"project-missing",
|
|
64
|
+
"io"
|
|
65
|
+
];
|
|
66
|
+
function verbError(code, message, cause) {
|
|
67
|
+
const e = new Error(message);
|
|
68
|
+
e.code = `sessiondelete/${code}`;
|
|
69
|
+
e.cause = cause;
|
|
70
|
+
return e;
|
|
71
|
+
}
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region src/remote/map-error.ts
|
|
74
|
+
/** 插件专属远程域名(设计稿 §6):与宿主官方域(`workspace/*`、`session/*`、`gateway/*`…)不重名。 */
|
|
75
|
+
const DOMAIN = "sessiondelete";
|
|
76
|
+
/** 宿主两条类型化错误 → 我们的码(设计稿 §6 的两行映射)。
|
|
77
|
+
* 类不可 import(跨插件值 import 是红线;且宿主包与运行中的宿主不同代)⇒ 按官方构造器写死的 `this.name` 认。
|
|
78
|
+
* 证据:asar `dsh/node_modules/@deepseek-ai/dsh-workspace/lib/index.js` 原文
|
|
79
|
+
* `var WorkspaceUnknownSessionError = class extends Error { …; this.name = "WorkspaceUnknownSessionError"; }`
|
|
80
|
+
* `var WorkspaceActiveSessionError = class extends Error { …; this.name = "WorkspaceActiveSessionError"; }`。 */
|
|
81
|
+
const HOST_ERROR_CODE = {
|
|
82
|
+
WorkspaceUnknownSessionError: "not-found",
|
|
83
|
+
WorkspaceActiveSessionError: "live"
|
|
84
|
+
};
|
|
85
|
+
/** 错误与它的 `cause` 链上最近的一个宿主类型化错误码(深度有界,链坏掉不递归下去)。 */
|
|
86
|
+
function hostCodeOf(error) {
|
|
87
|
+
let current = error;
|
|
88
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
89
|
+
if (current === null || typeof current !== "object") return void 0;
|
|
90
|
+
const name = current.name;
|
|
91
|
+
if (typeof name === "string" && Object.hasOwn(HOST_ERROR_CODE, name)) return HOST_ERROR_CODE[name];
|
|
92
|
+
current = current.cause;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** 上屏原文:`Error` 取 `.message`;**带 `message` 字符串的非 Error 抛出物也照取**(否则会退化成
|
|
96
|
+
* `[object Object]`,把设计稿 §6「`message` 是上屏的原文」这条许诺落空);其余一律 `String()`。 */
|
|
97
|
+
function messageOf(error) {
|
|
98
|
+
if (error instanceof Error) return error.message;
|
|
99
|
+
if (typeof error === "object" && error !== null) {
|
|
100
|
+
const message = error.message;
|
|
101
|
+
if (typeof message === "string") return message;
|
|
102
|
+
}
|
|
103
|
+
return String(error);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* 把任意抛出物折成失败信封里的**那两件**(设计稿 §6 唯一那张映射表)。
|
|
107
|
+
*
|
|
108
|
+
* - 已经是远程失败(`isDSHRemoteError` 认得的结构标记)⇒ 原样带出它自己的 `code` / `message`,不再包一层。
|
|
109
|
+
* - 宿主类型化错误(自身或 `cause` 链上,见 {@link HOST_ERROR_CODE})⇒ 映射成 `live` / `not-found`。
|
|
110
|
+
* 放在动词码之前判:动词把宿主错误包成笼统的 `io` 并把原错误挂在 cause 上(`verbError('io', …, cause)`),
|
|
111
|
+
* 不这样看进 cause 链,§6 那两行映射就永远到不了客户端。
|
|
112
|
+
* - 动词的 `sessiondelete/<五个码>` ⇒ 原样过桥。
|
|
113
|
+
* - 其余(含别域码、前缀外的因、非 Error 抛出物)⇒ `io`,message 保留原文。
|
|
114
|
+
*
|
|
115
|
+
* **返回的是线上形状、不是 `Error`**:这份东西唯一的去处是 `src/api/dispatch.ts` 的失败信封,两种判别
|
|
116
|
+
* (`isDSHRemoteError` 与 {@link HOST_ERROR_CODE})都只用到 `code` / `message`——造一个真 `Error` 没有消费者,
|
|
117
|
+
* 而本仓的跨插件 import 红线也不许为此去引宿主的 `RemoteError` 类(实例也上不了线:信封只走这两件)。
|
|
118
|
+
*/
|
|
119
|
+
function toRemoteFailure(error) {
|
|
120
|
+
if (isDSHRemoteError(error)) return {
|
|
121
|
+
code: error.code,
|
|
122
|
+
message: String(error.message)
|
|
123
|
+
};
|
|
124
|
+
const raw = error?.code;
|
|
125
|
+
const reason = typeof raw === "string" && raw.startsWith(`${DOMAIN}/`) ? raw.slice(14) : void 0;
|
|
126
|
+
const fromVerb = reason !== void 0 && VERB_CODES.includes(reason) ? reason : void 0;
|
|
127
|
+
const code = hostCodeOf(error) ?? fromVerb ?? "io";
|
|
128
|
+
return {
|
|
129
|
+
code: `${DOMAIN}/${code}`,
|
|
130
|
+
message: messageOf(error)
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
//#endregion
|
|
134
|
+
//#region src/trash/entry.ts
|
|
135
|
+
const pad = (n, w = 2) => String(n).padStart(w, "0");
|
|
136
|
+
function makeEntryId(deletedAt, sessionDirName) {
|
|
137
|
+
const d = new Date(deletedAt);
|
|
138
|
+
return `${`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`}-${sessionDirName}`;
|
|
139
|
+
}
|
|
140
|
+
const ENTRY_ID_RE = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z-(.+)$/;
|
|
141
|
+
/** `makeEntryId` 的逆。时间戳按 UTC 拆字段再 `Date.UTC` 合回——不交给 `Date.parse`
|
|
142
|
+
* 去猜(`20260930T130500Z` 不是任何被承认的日期格式,猜出来的是 NaN)。 */
|
|
143
|
+
function parseEntryId(id) {
|
|
144
|
+
const m = ENTRY_ID_RE.exec(id);
|
|
145
|
+
if (!m) throw new Error(`bad entry id: ${id}`);
|
|
146
|
+
const [, y, mo, d, h, mi, s, sessionDirName] = m;
|
|
147
|
+
return {
|
|
148
|
+
deletedAt: Date.UTC(+y, +mo - 1, +d, +h, +mi, +s),
|
|
149
|
+
sessionDirName
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function sessionDirOf(projectDir, sessionDirName, home) {
|
|
153
|
+
return `${home}/sessions/${projectDir}/${sessionDirName}`;
|
|
154
|
+
}
|
|
155
|
+
function trashDirOf(entryId, home) {
|
|
156
|
+
return `${home}/session-trash/${entryId}`;
|
|
157
|
+
}
|
|
158
|
+
//#endregion
|
|
159
|
+
//#region src/verbs/delete-session.ts
|
|
160
|
+
function deleteSession(deps) {
|
|
161
|
+
return async function deleteSession(sessionId) {
|
|
162
|
+
if (await deps.host.isLive(sessionId)) throw verbError("live", `会话正在运行:${sessionId}`);
|
|
163
|
+
const projectDir = await deps.host.projectDirOf(sessionId);
|
|
164
|
+
if (!projectDir) throw verbError("not-found", `宿主的持久化里没有这个会话:${sessionId}`);
|
|
165
|
+
const workspaceId = await deps.host.workspaceOf(sessionId);
|
|
166
|
+
const sessionDir = sessionDirOf(projectDir, sessionId, deps.home);
|
|
167
|
+
if (!await deps.fs.exists(sessionDir)) throw verbError("not-found", `会话目录不存在:${sessionDir}`);
|
|
168
|
+
const wasArchived = await deps.host.isArchived(sessionId);
|
|
169
|
+
const deletedAt = deps.now();
|
|
170
|
+
const entryId = makeEntryId(deletedAt, sessionId);
|
|
171
|
+
const trashDir = trashDirOf(entryId, deps.home);
|
|
172
|
+
let title;
|
|
173
|
+
let sizeBytes;
|
|
174
|
+
try {
|
|
175
|
+
title = await deps.host.titleOf(sessionId);
|
|
176
|
+
sizeBytes = await deps.fs.sizeOfDir(sessionDir);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
throw verbError("io", `读取会话现场失败:${String(error)}`, error);
|
|
179
|
+
}
|
|
180
|
+
if (!wasArchived) try {
|
|
181
|
+
await deps.host.archive(sessionId, workspaceId);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
throw verbError("io", `归档失败:${String(error)}`, error);
|
|
184
|
+
}
|
|
185
|
+
if (workspaceId !== void 0) try {
|
|
186
|
+
await deps.host.detach(sessionId, workspaceId);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (!wasArchived) await deps.host.unarchive(sessionId).catch(() => {});
|
|
189
|
+
throw verbError("io", `摘账本失败:${String(error)}`, error);
|
|
190
|
+
}
|
|
191
|
+
const entry = {
|
|
192
|
+
id: entryId,
|
|
193
|
+
sessionId,
|
|
194
|
+
projectDir,
|
|
195
|
+
workspaceId: workspaceId ?? "",
|
|
196
|
+
wasArchived,
|
|
197
|
+
deletedAt,
|
|
198
|
+
title,
|
|
199
|
+
sizeBytes
|
|
200
|
+
};
|
|
201
|
+
try {
|
|
202
|
+
await deps.fs.ensureDir(`${deps.home}/session-trash`);
|
|
203
|
+
await deps.fs.rename(sessionDir, trashDir);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (workspaceId !== void 0) await deps.host.attach(sessionId, workspaceId).catch(() => {});
|
|
206
|
+
if (!wasArchived) await deps.host.unarchive(sessionId).catch(() => {});
|
|
207
|
+
throw verbError("io", `移动会话目录失败:${String(error)}`, error);
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
await deps.manifest.add(entry);
|
|
211
|
+
} catch (error) {
|
|
212
|
+
await deps.fs.rename(trashDir, sessionDir).catch(() => {});
|
|
213
|
+
if (workspaceId !== void 0) await deps.host.attach(sessionId, workspaceId).catch(() => {});
|
|
214
|
+
if (!wasArchived) await deps.host.unarchive(sessionId).catch(() => {});
|
|
215
|
+
throw verbError("io", `登记回收站条目失败:${String(error)}`, error);
|
|
216
|
+
}
|
|
217
|
+
if (await deps.host.evictLive(sessionId) !== "unknown") await deps.host.announceRemoved(sessionId);
|
|
218
|
+
return entry;
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
//#endregion
|
|
222
|
+
//#region src/verbs/purge-trash.ts
|
|
223
|
+
function purgeTrash(deps) {
|
|
224
|
+
return async function purgeTrash(entryId) {
|
|
225
|
+
const all = await deps.manifest.list();
|
|
226
|
+
const targets = entryId ? all.filter((e) => e.id === entryId) : all;
|
|
227
|
+
if (entryId && targets.length === 0) throw verbError("trash-empty", `回收站里没有这一条:${entryId}`);
|
|
228
|
+
let freedBytes = 0;
|
|
229
|
+
for (const entry of targets) {
|
|
230
|
+
const dir = trashDirOf(entry.id, deps.home);
|
|
231
|
+
if (await deps.fs.exists(dir)) {
|
|
232
|
+
freedBytes += await deps.fs.sizeOfDir(dir);
|
|
233
|
+
await deps.fs.removeDir(dir).catch((error) => {
|
|
234
|
+
throw verbError("io", `删除回收站目录失败:${String(error)}`, error);
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
await deps.manifest.remove(entry.id);
|
|
238
|
+
if (await deps.host.evictLive(entry.sessionId) !== "unknown") await deps.host.announceRemoved(entry.sessionId);
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
removed: targets.length,
|
|
242
|
+
freedBytes
|
|
243
|
+
};
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
//#endregion
|
|
247
|
+
//#region src/verbs/restore-session.ts
|
|
248
|
+
function restoreSession(deps) {
|
|
249
|
+
return async function restoreSession(entryId) {
|
|
250
|
+
const entry = (await deps.manifest.list()).find((e) => e.id === entryId);
|
|
251
|
+
if (!entry) throw verbError("trash-empty", `回收站里没有这一条:${entryId}`);
|
|
252
|
+
const trashDir = trashDirOf(entry.id, deps.home);
|
|
253
|
+
const sessionDir = sessionDirOf(entry.projectDir, parseEntryId(entry.id).sessionDirName, deps.home);
|
|
254
|
+
if (!await deps.fs.exists(trashDir)) throw verbError("trash-empty", `回收站条目已不在磁盘上:${trashDir}`);
|
|
255
|
+
const projectRoot = sessionDirOf(entry.projectDir, "", deps.home).replace(/\/$/, "");
|
|
256
|
+
if (!await deps.fs.exists(`${deps.home}/sessions`) || !await deps.fs.exists(projectRoot)) throw verbError("project-missing", `原项目目录已不存在:${projectRoot}`);
|
|
257
|
+
await deps.fs.rename(trashDir, sessionDir).catch((error) => {
|
|
258
|
+
throw verbError("io", `放回会话目录失败:${String(error)}`, error);
|
|
259
|
+
});
|
|
260
|
+
if (entry.workspaceId !== "") try {
|
|
261
|
+
await deps.host.attach(entry.sessionId, entry.workspaceId);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
await deps.fs.rename(sessionDir, trashDir).catch(() => {});
|
|
264
|
+
throw verbError("io", `挂回账本失败:${String(error)}`, error);
|
|
265
|
+
}
|
|
266
|
+
if (!entry.wasArchived) try {
|
|
267
|
+
await deps.host.unarchive(entry.sessionId);
|
|
268
|
+
} catch (error) {
|
|
269
|
+
await deps.host.archive(entry.sessionId, entry.workspaceId || void 0).catch(() => {});
|
|
270
|
+
if (entry.workspaceId !== "") await deps.host.detach(entry.sessionId, entry.workspaceId).catch(() => {});
|
|
271
|
+
await deps.fs.rename(sessionDir, trashDir).catch(() => {});
|
|
272
|
+
throw verbError("io", `取消归档失败:${String(error)}`, error);
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
await deps.manifest.remove(entry.id);
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (!entry.wasArchived) await deps.host.archive(entry.sessionId, entry.workspaceId || void 0).catch(() => {});
|
|
278
|
+
if (entry.workspaceId !== "") await deps.host.detach(entry.sessionId, entry.workspaceId).catch(() => {});
|
|
279
|
+
await deps.fs.rename(sessionDir, trashDir).catch(() => {});
|
|
280
|
+
throw verbError("io", `摘回收站条目失败:${String(error)}`, error);
|
|
281
|
+
}
|
|
282
|
+
await deps.host.announceRestored(entry.sessionId);
|
|
283
|
+
return entry;
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
//#endregion
|
|
287
|
+
//#region src/api/dispatch.ts
|
|
288
|
+
/** 请求体字节上限:这条通道只收几个 id,1MiB 是"够用且不给自己开一条内存放大器"的量级。 */
|
|
289
|
+
const JSON_BODY_MAX_BYTES = 1048576;
|
|
290
|
+
/** 回环地址的三种写法(IPv4 / IPv6 / IPv4-mapped IPv6)。 */
|
|
291
|
+
const LOOPBACK = [
|
|
292
|
+
"127.0.0.1",
|
|
293
|
+
"::1",
|
|
294
|
+
"::ffff:127.0.0.1"
|
|
295
|
+
];
|
|
296
|
+
/**
|
|
297
|
+
* 桌面壳那条路(asar `/lib/main.js` 的 `forwardWebRequest`):渲染进程从 `dsh-app://app/` 加载,
|
|
298
|
+
* 页面里对**非静态路径**的请求(`/`、`/index.html`、`/assets/*`、`/plugins/…` 之外的一切——包括
|
|
299
|
+
* 我们这条前缀路由)都由 `protocol.handle("dsh-app")` 转给宿主自己的 web server;转发前它把
|
|
300
|
+
* `host` / `origin` / `cookie` / `sec-fetch-site` 四个头**删掉**、换上一枚宿主自己的 cookie,路径与
|
|
301
|
+
* 查询串原样带走(逐字:`for (const name of ["host","origin","cookie","sec-fetch-site"]) headers.delete(name)`)。
|
|
302
|
+
* ⇒ 桌面版打到我们这条路由上的请求是**没有 `Origin`** 的,`Referer` 则保留(同源请求照常带,
|
|
303
|
+
* 页面就住在 `dsh-app://app/`)。壳自己在上游只放行 `origin ∈ {null, "dsh-app://app"}`。
|
|
304
|
+
*/
|
|
305
|
+
const DESKTOP_APP_SCHEME = "dsh-app:";
|
|
306
|
+
const DESKTOP_APP_HOST = "app";
|
|
307
|
+
/**
|
|
308
|
+
* 「这是桌面壳自己的页面吗」。**必须按 `protocol` + `host` 判,不能比 `URL.origin`**:`dsh-app:`
|
|
309
|
+
* 不是 WHATWG 的 special scheme,`new URL('dsh-app://app/').origin` 是字符串 `"null"`(Node 实测),
|
|
310
|
+
* 拿它跟字面量比永远为假 ⇒ 栅栏会把真机上的每一次请求都挡成 403,四个入口一个都不出现。
|
|
311
|
+
*
|
|
312
|
+
* 这个判据为什么不可能被外人伪造:`Origin` 与 `Referer` 都是 forbidden header,网页改不了,
|
|
313
|
+
* 只能是浏览器/壳自己带上;而 `dsh-app://app` 只可能是桌面壳自己的页面。
|
|
314
|
+
*/
|
|
315
|
+
function isDesktopShellUrl(value) {
|
|
316
|
+
try {
|
|
317
|
+
const url = new URL(value);
|
|
318
|
+
return url.protocol === DESKTOP_APP_SCHEME && url.host === DESKTOP_APP_HOST;
|
|
319
|
+
} catch {
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
function writeJson(res, status, body) {
|
|
324
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
325
|
+
res.end(JSON.stringify(body));
|
|
326
|
+
}
|
|
327
|
+
function writeOk(res, value) {
|
|
328
|
+
writeJson(res, 200, {
|
|
329
|
+
ok: true,
|
|
330
|
+
value
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
/** 失败信封:码与原文都来自 `toRemoteFailure`(本插件唯一的错误映射表),值就是线上形状本身。 */
|
|
334
|
+
function writeFailure(res, error, status = 200) {
|
|
335
|
+
writeJson(res, status, {
|
|
336
|
+
ok: false,
|
|
337
|
+
error: toRemoteFailure(error)
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* 同源栅栏(见文件头 ④)。看 `origin` 而不只看 `referer` 的理由:恶意页
|
|
342
|
+
* `<meta name="referrer" content="no-referrer">` 能让 Referer 缺席,但浏览器对跨源请求**总是**发 Origin。
|
|
343
|
+
*
|
|
344
|
+
* 放行的三种形状,各自都是"浏览器/壳自动带上、网页改不了"的那种:
|
|
345
|
+
* ① `Origin` 与 `Host` 同源(浏览器直接打开宿主那行 URL);② `Origin` 是桌面壳的 `dsh-app://app`
|
|
346
|
+
* (壳自己在上游也只放行这个 origin);③ `Referer` 是桌面壳的 `dsh-app://app`(**真机那条路**:
|
|
347
|
+
* 壳把 `Origin` 删掉了、只留下 Referer,见 {@link isDesktopShellUrl})。两者都缺席 = 本机工具 /
|
|
348
|
+
* curl(注释即此承诺)。**恶意网页拿不到 ②③ 里的任何一个**:那两个头都是 forbidden header、
|
|
349
|
+
* 页面伪造不了;而**写口(POST)在任何跨源形态下都必带 Origin** ⇒ 两个头全缺席就等于本机工具。
|
|
350
|
+
* 唯一的缝是跨源 `no-cors` 的 **GET**(Referer 被 `no-referrer` 掐掉、Origin 也不带)——它只够得到
|
|
351
|
+
* 只读的 `list`,而 no-cors 的回包是 opaque、页面读不出内容 ⇒ 不构成泄露。
|
|
352
|
+
*/
|
|
353
|
+
function isTrustedRequest(req) {
|
|
354
|
+
if (!LOOPBACK.includes(req.socket.remoteAddress ?? "")) return false;
|
|
355
|
+
const host = req.headers.host;
|
|
356
|
+
const origin = req.headers.origin;
|
|
357
|
+
if (origin !== void 0 && !isDesktopShellUrl(origin)) try {
|
|
358
|
+
if (new URL(origin).host !== host) return false;
|
|
359
|
+
} catch {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
const referer = req.headers.referer;
|
|
363
|
+
if (referer === void 0) return true;
|
|
364
|
+
if (isDesktopShellUrl(referer)) return true;
|
|
365
|
+
try {
|
|
366
|
+
return new URL(referer).host === host;
|
|
367
|
+
} catch {
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
/** 流式读 body 并解析成 JSON 对象:空体回 `null`,超限 / 非法 JSON / 不是对象一律抛(→ 失败信封)。 */
|
|
372
|
+
async function readJsonBody(req) {
|
|
373
|
+
const chunks = [];
|
|
374
|
+
let size = 0;
|
|
375
|
+
for await (const chunk of req) {
|
|
376
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
377
|
+
size += buf.length;
|
|
378
|
+
if (size > JSON_BODY_MAX_BYTES) throw new Error(`请求体超过 ${JSON_BODY_MAX_BYTES} 字节上限`);
|
|
379
|
+
chunks.push(buf);
|
|
380
|
+
}
|
|
381
|
+
const text = Buffer.concat(chunks).toString("utf8").trim();
|
|
382
|
+
if (text === "") return null;
|
|
383
|
+
let parsed;
|
|
384
|
+
try {
|
|
385
|
+
parsed = JSON.parse(text);
|
|
386
|
+
} catch (error) {
|
|
387
|
+
throw new Error(`请求体不是合法 JSON:${error instanceof Error ? error.message : String(error)}`);
|
|
388
|
+
}
|
|
389
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("请求体需为 JSON 对象");
|
|
390
|
+
return parsed;
|
|
391
|
+
}
|
|
392
|
+
/** 必填的非空字符串字段(缺、空串、类型不对都是**同一句话**:说清是哪个字段)。 */
|
|
393
|
+
function requireString(body, field) {
|
|
394
|
+
const value = body?.[field];
|
|
395
|
+
if (typeof value !== "string" || value === "") throw new Error(`请求体需带非空字符串字段 ${field}`);
|
|
396
|
+
return value;
|
|
397
|
+
}
|
|
398
|
+
/** `purge` 的 `entryId` 可选(缺席 = 清空),但在场就必须是像样的值——不把 `42` 当成"没带"。 */
|
|
399
|
+
function optionalString(body, field) {
|
|
400
|
+
const value = body?.[field];
|
|
401
|
+
if (value === void 0) return void 0;
|
|
402
|
+
if (typeof value !== "string" || value === "") throw new Error(`字段 ${field} 若在场必须是非空字符串`);
|
|
403
|
+
return value;
|
|
404
|
+
}
|
|
405
|
+
function requireMethod(method, want, route) {
|
|
406
|
+
if (method !== want) throw new Error(`方法不允许:${method}(${SESSION_DELETE_API_PREFIX}${route} 需 ${want})`);
|
|
407
|
+
}
|
|
408
|
+
/** 路由表:段名 → 一个把「方法 + 请求体」变成返回值的处理器。动词在每次调用时才取。 */
|
|
409
|
+
async function dispatch(method, rest, req, deps) {
|
|
410
|
+
if (rest === ROUTES.list) {
|
|
411
|
+
requireMethod(method, "GET", ROUTES.list);
|
|
412
|
+
return await deps.manifest.list();
|
|
413
|
+
}
|
|
414
|
+
if (rest === ROUTES.delete) {
|
|
415
|
+
requireMethod(method, "POST", ROUTES.delete);
|
|
416
|
+
return await deleteSession(deps)(requireString(await readJsonBody(req), PARAMS.sessionId));
|
|
417
|
+
}
|
|
418
|
+
if (rest === ROUTES.restore) {
|
|
419
|
+
requireMethod(method, "POST", ROUTES.restore);
|
|
420
|
+
return await restoreSession(deps)(requireString(await readJsonBody(req), PARAMS.entryId));
|
|
421
|
+
}
|
|
422
|
+
if (rest === ROUTES.purge) {
|
|
423
|
+
requireMethod(method, "POST", ROUTES.purge);
|
|
424
|
+
return await purgeTrash(deps)(optionalString(await readJsonBody(req), PARAMS.entryId));
|
|
425
|
+
}
|
|
426
|
+
throw new Error(`未知路由:${method} ${rest === "" ? SESSION_DELETE_API_PREFIX : rest}`);
|
|
427
|
+
}
|
|
428
|
+
/** 宿主 `webServer.register({kind:'prefix', path: SESSION_DELETE_API_PREFIX, handler})` 的那个 handler。 */
|
|
429
|
+
function createApiHandler(deps) {
|
|
430
|
+
return async function handle(req, res) {
|
|
431
|
+
if (!isTrustedRequest(req)) {
|
|
432
|
+
writeFailure(res, /* @__PURE__ */ new Error("非受信来源"), 403);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
try {
|
|
436
|
+
const url = new URL(req.url ?? "/", "http://dsh.internal");
|
|
437
|
+
if (!url.pathname.startsWith("/dsh-session-delete-api")) throw new Error(`未知路由:${url.pathname}`);
|
|
438
|
+
writeOk(res, await dispatch(req.method ?? "GET", url.pathname.slice(23), req, deps));
|
|
439
|
+
} catch (error) {
|
|
440
|
+
writeFailure(res, error);
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
//#endregion
|
|
445
|
+
//#region src/ports/fs-port.ts
|
|
446
|
+
/** 模块级递归:端口方法**解构出来单独调**也不能靠 `this`(`{ sizeOfDir } = port` 会让方法体的
|
|
447
|
+
* `this` 变成 undefined)。所以端口只做转发,递归在模块作用域里自调。 */
|
|
448
|
+
async function sizeOfDir(p) {
|
|
449
|
+
let total = 0;
|
|
450
|
+
for (const e of await readdir(p, { withFileTypes: true })) {
|
|
451
|
+
const child = join(p, e.name);
|
|
452
|
+
total += e.isDirectory() ? await sizeOfDir(child) : (await stat(child)).size;
|
|
453
|
+
}
|
|
454
|
+
return total;
|
|
455
|
+
}
|
|
456
|
+
function createFsPort() {
|
|
457
|
+
return {
|
|
458
|
+
async exists(p) {
|
|
459
|
+
return existsSync(p);
|
|
460
|
+
},
|
|
461
|
+
async ensureDir(p) {
|
|
462
|
+
await mkdir(p, { recursive: true });
|
|
463
|
+
},
|
|
464
|
+
async rename(from, to) {
|
|
465
|
+
await rename(from, to);
|
|
466
|
+
},
|
|
467
|
+
async removeDir(p) {
|
|
468
|
+
await rm(p, {
|
|
469
|
+
recursive: true,
|
|
470
|
+
force: true
|
|
471
|
+
});
|
|
472
|
+
},
|
|
473
|
+
sizeOfDir: (p) => sizeOfDir(p)
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
//#endregion
|
|
477
|
+
//#region src/ports/host-port.ts
|
|
478
|
+
/** live store 的最小方法面判据(同 `projectionCacheOf` 的手法:按方法面判,不按 constructor.name)。 */
|
|
479
|
+
function liveSessionsOf(ctx) {
|
|
480
|
+
const store = ctx.get("sessions");
|
|
481
|
+
if (store === void 0 || store === null) return void 0;
|
|
482
|
+
if (typeof store.get !== "function" || typeof store.liveEntryFor !== "function") return void 0;
|
|
483
|
+
return store;
|
|
484
|
+
}
|
|
485
|
+
/** 无 cwd 的会话落在 `<sessions>/_no-cwd/`(asar `dsh-session-persistence-jsonl/lib/index.js`
|
|
486
|
+
* 的 `projectDir(root, cwd)`:`if (cwd === void 0) return join(root, "_no-cwd");`)。 */
|
|
487
|
+
const NO_CWD_PROJECT_DIR = "_no-cwd";
|
|
488
|
+
/**
|
|
489
|
+
* 宿主的**项目目录名**算法(`projectKey`)逐字镜像。
|
|
490
|
+
*
|
|
491
|
+
* 为什么是镜像而不是 import:它在 `@deepseek-ai/dsh-session-persistence-jsonl` 里面,是**宿主包**,
|
|
492
|
+
* 跨插件值 import 是生态红线,它也不在本插件依赖里;宿主没有把这个名字暴露成任何服务
|
|
493
|
+
* (`stat()`/`list()` 的返回只有 `{header, revision, sizeBytes}`,`locate()` 的 `{kind:'jsonl', path}` 只挂在错误上)。
|
|
494
|
+
*
|
|
495
|
+
* 原文(asar `dsh/node_modules/@deepseek-ai/dsh-session-persistence-jsonl/lib/index.js`):
|
|
496
|
+
* `function projectKey(cwd) { … if (ch === "/" || ch === "\\" || ch === ":") { if (!separatorRun) readable += "-"; separatorRun = true; }
|
|
497
|
+
* else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) { readable += ch; separatorRun = false; }
|
|
498
|
+
* else { readable += "~" + code.toString(16).toUpperCase().padStart(4, "0"); separatorRun = false; }
|
|
499
|
+
* return \`--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--\`; }`
|
|
500
|
+
* 调用链:`locate(meta)` → `logPath(root, meta.cwd, meta.id, …)` → `projectDir(root, cwd)` → `projectKey(cwd)`。
|
|
501
|
+
* 本机实测对得上:`D:/develop/GitHub/dsh-session-delete` → `--D-develop-GitHub-dsh-session-delete--`
|
|
502
|
+
* (`~/.dsh/sessions` 下真有这个目录名;`D:/document/deepseek-harness/default-workspace` 同理)。
|
|
503
|
+
*/
|
|
504
|
+
function projectKey(cwd) {
|
|
505
|
+
let readable = "";
|
|
506
|
+
let separatorRun = false;
|
|
507
|
+
for (let i = 0; i < cwd.length; i++) {
|
|
508
|
+
const code = cwd.charCodeAt(i);
|
|
509
|
+
const ch = String.fromCharCode(code);
|
|
510
|
+
if (ch === "/" || ch === "\\" || ch === ":") {
|
|
511
|
+
if (!separatorRun) readable += "-";
|
|
512
|
+
separatorRun = true;
|
|
513
|
+
} else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) {
|
|
514
|
+
readable += ch;
|
|
515
|
+
separatorRun = false;
|
|
516
|
+
} else {
|
|
517
|
+
readable += `~${code.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
518
|
+
separatorRun = false;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return `--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--`;
|
|
522
|
+
}
|
|
523
|
+
/** 投影缓存服务的最小方法面判据(按方法面判,不按 `constructor.name` 判)。 */
|
|
524
|
+
function projectionCacheOf(ctx) {
|
|
525
|
+
const cache = ctx.get("sessionProjectionCache");
|
|
526
|
+
if (cache === void 0 || cache === null) return void 0;
|
|
527
|
+
if (typeof cache.cachedSnapshot !== "function" || typeof cache.cachedPredecessorTitle !== "function") return void 0;
|
|
528
|
+
return cache;
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* 「账本里没有这个会话」的判别串——宿主 `readSessionHeader(id)` 对未知会话抛的是**裸 `Error`**
|
|
532
|
+
* (没有类型、没有码),只认这一句。asar `dsh-workspace/lib/types/index.js` 原文(逐字):
|
|
533
|
+
* `throw new Error(\`cannot validate session '${id}': session persistence holds no such session\`)`。
|
|
534
|
+
*
|
|
535
|
+
* 为什么按消息判而不是全吞:同一个方法里 `listStoredHeaders()` / `indexHeaders()` 会抛**真存储故障**
|
|
536
|
+
* (读盘失败、账本不一致…),那是 `io` 该走的路,绝不能被当成「无此会话」。
|
|
537
|
+
* 同文件另一处近似句是有类型的那条(`cannot ${verb} session '…': live sessions and session persistence
|
|
538
|
+
* hold no such session`,`WorkspaceUnknownSessionError`)——`holds` 与 `hold`、有无 `live sessions and`
|
|
539
|
+
* 都不相同,所以这条串不会误伤它,`toRemoteFailure` 的 cause 链映射仍按 `name` 走。
|
|
540
|
+
*/
|
|
541
|
+
const NO_SUCH_SESSION = "session persistence holds no such session";
|
|
542
|
+
/** 只认上面那一句;其余(含非 Error 抛出物)一律**不算未命中**。 */
|
|
543
|
+
function isUnknownSession(error) {
|
|
544
|
+
return error instanceof Error && error.message.includes(NO_SUCH_SESSION);
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* 宿主服务层的**真实绑定**:十二个方法各自把动词层的问题翻成宿主官方服务的调用。
|
|
548
|
+
*
|
|
549
|
+
* 每个方法的依据(服务 / 方法 / 入参)与原文切片见各自的实现处注释。
|
|
550
|
+
* 出错一律**原样抛**(不在这里裹动词码):动词层把原始错误挂在 `cause` 上,
|
|
551
|
+
* `toRemoteFailure` 再按 `name` 把它映射回 `sessiondelete/<码>`(设计稿 §6 的两行映射)。
|
|
552
|
+
*/
|
|
553
|
+
function createHostPort(ctx) {
|
|
554
|
+
const host = ctx;
|
|
555
|
+
const registry = host.workspaceRegistry;
|
|
556
|
+
return {
|
|
557
|
+
/** 账本里记着这个会话的工作区:`registry.list()` 的实体 `sessionIds`(该 getter 已按 canonical cwd 过滤过)。 */
|
|
558
|
+
async workspaceOf(sessionId) {
|
|
559
|
+
return registry.list().find((workspace) => workspace.sessionIds.includes(sessionId))?.id;
|
|
560
|
+
},
|
|
561
|
+
/**
|
|
562
|
+
* 原生 cwd → 宿主项目目录名(`projectKey`);没有 cwd 时按宿主约定落到 `_no-cwd`。
|
|
563
|
+
*
|
|
564
|
+
* 「账本里没有这个会话」= **未命中**,回 `undefined`(不是抛):这正是本方法声明的类型,
|
|
565
|
+
* 也是 `delete-session.ts` 已经写好的那条分支 `if (!projectDir) throw
|
|
566
|
+
* verbError('not-found', …)` 的入口。若让它裸抛,那句话永远为假 ⇒ 设计稿 §6 的 `not-found`
|
|
567
|
+
* (「会话目录或账本条目不存在」)在这条最自然的路径上拿不到码,客户端只会看到 `sessiondelete/io`。
|
|
568
|
+
*
|
|
569
|
+
* **只吞这一种**:其余错误(真存储故障)照抛,让 `toRemoteFailure` 兜成 `io` 而不是假 `not-found`。
|
|
570
|
+
*/
|
|
571
|
+
async projectDirOf(sessionId) {
|
|
572
|
+
let header;
|
|
573
|
+
try {
|
|
574
|
+
header = await registry.readSessionHeader(sessionId);
|
|
575
|
+
} catch (error) {
|
|
576
|
+
if (isUnknownSession(error)) return void 0;
|
|
577
|
+
throw error;
|
|
578
|
+
}
|
|
579
|
+
return header.cwd === void 0 ? NO_CWD_PROJECT_DIR : projectKey(header.cwd);
|
|
580
|
+
},
|
|
581
|
+
/**
|
|
582
|
+
* 标题:走官方**投影缓存**读面(`title` 投影单元的 `wire.view` 值就是标题文本本身:
|
|
583
|
+
* 单元定义原文 `{ key: "title", stateVersion: 1, wire: { view: (state) => state } }`,
|
|
584
|
+
* 所以 `values.title` 是 `string | null`)。
|
|
585
|
+
* 契约:**尽力取、取不到返回空串、绝不因此让删除失败** ⇒ 这里吞掉一切异常。
|
|
586
|
+
*/
|
|
587
|
+
async titleOf(sessionId) {
|
|
588
|
+
try {
|
|
589
|
+
const header = await registry.readSessionHeader(sessionId);
|
|
590
|
+
const cache = projectionCacheOf(host);
|
|
591
|
+
const title = (cache?.cachedSnapshot(header, ["title"]) ?? cache?.cachedPredecessorTitle(header))?.values.title;
|
|
592
|
+
return typeof title === "string" ? title : "";
|
|
593
|
+
} catch {
|
|
594
|
+
return "";
|
|
595
|
+
}
|
|
596
|
+
},
|
|
597
|
+
/**
|
|
598
|
+
* 活跃判据 = 宿主官方那一条(设计稿 §5):`workspace/session-activity` waterfall 问一次,
|
|
599
|
+
* **空数组 = 无活动**;与宿主自己的 `archiveSession` 准入同源,不自创第二套。
|
|
600
|
+
* asar 原文:`const activity = await this.ctx.waterfall('workspace/session-activity', { sessionId }, () => Promise.resolve([]));
|
|
601
|
+
* if (activity.length > 0) throw new WorkspaceActiveSessionError(sessionId, activity);`
|
|
602
|
+
*/
|
|
603
|
+
async isLive(sessionId) {
|
|
604
|
+
return (await host.waterfall("workspace/session-activity", { sessionId }, () => Promise.resolve([]))).length > 0;
|
|
605
|
+
},
|
|
606
|
+
/** registry 全局归档集(数组,`state.archivedSessionIds`)。 */
|
|
607
|
+
async isArchived(sessionId) {
|
|
608
|
+
return registry.archivedSessionIds.includes(sessionId);
|
|
609
|
+
},
|
|
610
|
+
/**
|
|
611
|
+
* 从 `ctx.sessions`(live store)里摘掉一个会话。
|
|
612
|
+
*
|
|
613
|
+
* **为什么需要**:会话列表读面是并集——`sessionQuery.listSessions()` = 磁盘上的持久化 ∪ `ctx.sessions` 里的活体
|
|
614
|
+
* (见设计稿 §4 末)。搬走文件只清掉一侧;本机运行期打开过的会话是活体,列表照样把它交出来,
|
|
615
|
+
* 于是"删了还在"(真机实测:文件已进回收站、账本已摘,侧栏仍留着那一行)。
|
|
616
|
+
*
|
|
617
|
+
* **这是未声明为契约的内部入口**(设计稿 §4 末记了这次偏离):`liveEntryFor` / `entry.detach()` 是 store
|
|
618
|
+
* 给**会话 owner**(agent 的 effect 链)用的,宿主自己的控制器 `createOrAdopt` 只取 `agents.create()`
|
|
619
|
+
* 返回的 `.agent`、把带 `dispose` 的 handle 丢掉了,`ctx.agents` 也没有 `liveEntryFor` 那样的入口
|
|
620
|
+
* (会话 store 有、agent store 没有,逐成员面核过)⇒ 只能摘到"会话"这一层,上面那具 agent 摘不到,
|
|
621
|
+
* 它之后若走到 `flush(session)` 会撞 `liveEntryFor` 抛 `session "…" is not live in this store`。
|
|
622
|
+
*
|
|
623
|
+
* **所以判据是三态**:读不到 store / 形状不符(旧版宿主)/ detach 抛 ⇒ `unknown`(**不通告**,
|
|
624
|
+
* 因为无法断言宿主列表已经干净);内存里本来没有 ⇒ `not-live`;摘成功 ⇒ `evicted`。
|
|
625
|
+
* 任何一步都不抛:删除的持久化部分已经完成,这里不该把它翻成失败。
|
|
626
|
+
*/
|
|
627
|
+
async evictLive(sessionId) {
|
|
628
|
+
try {
|
|
629
|
+
const store = liveSessionsOf(host);
|
|
630
|
+
if (store === void 0) return "unknown";
|
|
631
|
+
const session = store.get(sessionId);
|
|
632
|
+
if (session === void 0 || session === null) return "not-live";
|
|
633
|
+
const entry = store.liveEntryFor(session);
|
|
634
|
+
if (entry === void 0 || entry === null || typeof entry.detach !== "function") return "unknown";
|
|
635
|
+
entry.detach();
|
|
636
|
+
return "evicted";
|
|
637
|
+
} catch {
|
|
638
|
+
return "unknown";
|
|
639
|
+
}
|
|
640
|
+
},
|
|
641
|
+
/**
|
|
642
|
+
* 通告客户端"这个会话没了"。
|
|
643
|
+
*
|
|
644
|
+
* **为什么需要**:客户端那一行只由两条路更新——重连时的整表重列(`handleConnected` → `refreshList`),
|
|
645
|
+
* 或宿主转发的 `api-session/*` 事件。删除一个**冷**会话时宿主不会发任何事件(`session/disposed` 只在摘掉
|
|
646
|
+
* 活体时才有),所以那一行会一直挂到下次重连为止(真机实测就是这个形状)。
|
|
647
|
+
*
|
|
648
|
+
* **为什么这样发是对的**:`api-session/removed` 是宿主**自己声明并转发**的事件
|
|
649
|
+
* (asar `dsh-api-remotes/lib/remote-events.js` 的 `API_REMOTE_FORWARDED_EVENTS` 里
|
|
650
|
+
* `{ event: "api-session/removed", mode: "emit" }`;转发器就是 `ctx.on(event, …)` 的普通监听者);
|
|
651
|
+
* 我们不带 carrier 地 emit ⇒ 全量派发到它(见 {@link HostCtxLike.emit})⇒ 客户端
|
|
652
|
+
* `ctx.remote.$on("api-session/removed", …)` 收到后 `recordMutation({kind:'remove'})` + `handleRemoved()`。
|
|
653
|
+
* **只在"宿主列表确实已经不含它"时调用**(调用方按 {@link EvictionOutcome} 判):这条通告在语义上是**如实**
|
|
654
|
+
* 的,不是障眼法。
|
|
655
|
+
*/
|
|
656
|
+
async announceRemoved(sessionId) {
|
|
657
|
+
try {
|
|
658
|
+
host.emit("api-session/removed", sessionId);
|
|
659
|
+
} catch {}
|
|
660
|
+
},
|
|
661
|
+
/**
|
|
662
|
+
* 通告客户端"这个会话回来了"(恢复用)。
|
|
663
|
+
*
|
|
664
|
+
* 摘要**取自宿主自己的会话列表**(`ctx.sessionController.list()`),不自己造一行:客户端 `handleSessionAdded`
|
|
665
|
+
* 要的是宿主 `summaryFor` 那套形状(sessionId / updatedAt / blank / projections…),照抄宿主的那一份最可靠。
|
|
666
|
+
* 取不到(控制器缺席 / 列表里没有它 / 抛错)就**一条都不发**——恢复本身已经完成,只是要等下次重连才上屏。
|
|
667
|
+
*/
|
|
668
|
+
async announceRestored(sessionId) {
|
|
669
|
+
try {
|
|
670
|
+
const controller = host.get("sessionController");
|
|
671
|
+
if (controller === void 0 || controller === null || typeof controller.list !== "function") return;
|
|
672
|
+
const listed = await controller.list();
|
|
673
|
+
const items = Array.isArray(listed) ? listed : listed?.items;
|
|
674
|
+
if (!Array.isArray(items)) return;
|
|
675
|
+
const summary = items.find((item) => item?.sessionId === sessionId);
|
|
676
|
+
if (summary === void 0) return;
|
|
677
|
+
host.emit("api-session/added", JSON.parse(JSON.stringify(summary)));
|
|
678
|
+
} catch {}
|
|
679
|
+
},
|
|
680
|
+
/** 官方 `unarchiveSession(sessionId)`:未归档是幂等 no-op。 */
|
|
681
|
+
async unarchive(sessionId) {
|
|
682
|
+
await registry.unarchiveSession(sessionId);
|
|
683
|
+
},
|
|
684
|
+
/** 摘账本:实体方法 `detachSession`(顶层没有);未知工作区**响亮失败**,不静默当成功。 */
|
|
685
|
+
async detach(sessionId, workspaceId) {
|
|
686
|
+
const workspace = registry.get(workspaceId);
|
|
687
|
+
if (workspace === void 0) throw new Error(`workspace not found (detach): ${workspaceId}`);
|
|
688
|
+
await workspace.detachSession(sessionId);
|
|
689
|
+
},
|
|
690
|
+
/** 挂账本:实体方法 `attachSession`(顶层没有)。 */
|
|
691
|
+
async attach(sessionId, workspaceId) {
|
|
692
|
+
const workspace = registry.get(workspaceId);
|
|
693
|
+
if (workspace === void 0) throw new Error(`workspace not found (attach): ${workspaceId}`);
|
|
694
|
+
await workspace.attachSession(sessionId);
|
|
695
|
+
},
|
|
696
|
+
/** 确保归档:官方 `archiveSession(sessionId)`。归档是 registry **全局**的,与工作区账目无关
|
|
697
|
+
* (JSDoc 原文 "its workspace accounting — or lack of one — is irrelevant"),所以 `workspaceId` 不用
|
|
698
|
+
* ——未分组的会话没有工作区可传,签名里它是可选的。
|
|
699
|
+
* 不带 `stopActivity` ⇒ 有活动即抛 `WorkspaceActiveSessionError`(→ `sessiondelete/live`,设计稿 §6)。 */
|
|
700
|
+
async archive(sessionId) {
|
|
701
|
+
await registry.archiveSession(sessionId);
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
//#endregion
|
|
706
|
+
//#region src/trash/manifest.ts
|
|
707
|
+
function createStorageManifest$1(domain) {
|
|
708
|
+
return {
|
|
709
|
+
async list() {
|
|
710
|
+
return (await domain.readAll()).sort((a, b) => b.deletedAt - a.deletedAt);
|
|
711
|
+
},
|
|
712
|
+
async add(e) {
|
|
713
|
+
await domain.put(e);
|
|
714
|
+
},
|
|
715
|
+
async remove(id) {
|
|
716
|
+
await domain.delete(id);
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
//#endregion
|
|
721
|
+
//#region src/index.ts
|
|
722
|
+
const name = "@xrn1997/dsh-session-delete";
|
|
723
|
+
/**
|
|
724
|
+
* 插件依赖:**只写字符串数组**(隔离数据根上实测:写成 `{ required, optional }` 会让插件停在
|
|
725
|
+
* `pending (waiting for services: required, optional)`)。
|
|
726
|
+
*
|
|
727
|
+
* 三项都是在隔离数据根上实测注入成功、且本插件真正用到的宿主服务。三个**故意不写**的:
|
|
728
|
+
* - `remote`:那是**浏览器半**的服务(asar `dsh-api-gateway/lib/client.js`:`super(ctx, "remote")`),
|
|
729
|
+
* Node 半挂的是 `ctx.typertGateway`。把它写进 Node 半的 inject 会让插件永远 pending(同 `slots` 那次的形状)。
|
|
730
|
+
* - `sessions` / `sessionProjectionCache`:前者本实现不用(活跃判据走 waterfall,见设计稿 §5);后者是**可选**服务,
|
|
731
|
+
* 官方消费方也按可选读(`ctx.get('sessionProjectionCache')?.…`),inject 一个可能没挂的服务 = 整个插件 pending。
|
|
732
|
+
* 它取不到时 `titleOf` 返回空串,正是契约允许的降级。
|
|
733
|
+
*
|
|
734
|
+
* `webServer` 是**浏览器半取数通道的承载者**(那条薄线):插件用它注册 `/dsh-session-delete-api`
|
|
735
|
+
* 前缀路由,浏览器半同源 fetch 那四个端点。它是宿主 web 组成里的硬依赖(`dsh-host-webserver` 的
|
|
736
|
+
* `super(ctx, "webServer")`),服务名与 `register({kind:'prefix', path, handler})` 的形状按
|
|
737
|
+
* `dsh-host-webserver/lib/index.js` 与 `README.md` 原文读实,不是猜的。
|
|
738
|
+
*/
|
|
739
|
+
const inject = [
|
|
740
|
+
"workspaceRegistry",
|
|
741
|
+
"storageDomain",
|
|
742
|
+
"webServer"
|
|
743
|
+
];
|
|
744
|
+
/** 本插件自己的域(设计稿 §3:介质落在 `~/.dsh/storages/`)。
|
|
745
|
+
*
|
|
746
|
+
* **名字必须带插件专属前缀**:domain 名是 `storageDomain` facility 里的**全局键**,而**一个名字在
|
|
747
|
+
* 一个 facility 里只能 `open()` 一次**(句柄归调用方所有)——别人先占了同名域,本插件会**在激活时**
|
|
748
|
+
* 抛错,整个插件不工作。前缀取 npm scope(`xrn1997`),后面接插件与内容。
|
|
749
|
+
* 名字还要过宿主的 `UNIT_NAME_RE`(asar `dsh-storage/lib/index.js` 原文 `/^[a-z][a-z0-9_]*$/`)
|
|
750
|
+
* ⇒ 只能小写字母数字下划线,`@` / `/` / `-` 都不行。 */
|
|
751
|
+
const TRASH_DOMAIN_NAME = "xrn1997_session_delete_trash";
|
|
752
|
+
const UNIT_NAME_RE = /^[a-z][a-z0-9_]*$/;
|
|
753
|
+
/**
|
|
754
|
+
* `defineDomain` / `domainTable` 的窄镜像。官方实现在 asar
|
|
755
|
+
* `dsh/node_modules/@deepseek-ai/dsh-storage-domain/lib/index.js`:
|
|
756
|
+
* `function domainTable(schema) { return { valueSchema: schema }; }`;
|
|
757
|
+
* `defineDomain(spec)` 逐项校验(`UNIT_NAME_RE`、version 为非负整数、`compatibleVersions` 必须低于 version、
|
|
758
|
+
* `layout` ∈ {single, per-record}、`invalidRecords` ∈ {backup-and-skip}、表名同样过 `UNIT_NAME_RE`)后**原样返回 spec**。
|
|
759
|
+
* 它是宿主包:跨插件值 import 是红线,它也不在本插件依赖里 ⇒ 照原文写一份本地镜像。
|
|
760
|
+
*/
|
|
761
|
+
function domainTable(schema) {
|
|
762
|
+
return { valueSchema: schema };
|
|
763
|
+
}
|
|
764
|
+
function defineDomain(spec) {
|
|
765
|
+
if (!UNIT_NAME_RE.test(spec.name)) throw new Error(`domain name '${spec.name}' must match ${UNIT_NAME_RE}`);
|
|
766
|
+
if (!Number.isInteger(spec.version) || spec.version < 0) throw new Error(`domain '${spec.name}' version must be a non-negative integer, got ${spec.version}`);
|
|
767
|
+
if (spec.layout !== void 0 && spec.layout !== "single" && spec.layout !== "per-record") throw new Error(`domain '${spec.name}' layout must be 'single' or 'per-record', got ${spec.layout}`);
|
|
768
|
+
if (spec.invalidRecords !== void 0 && spec.invalidRecords !== "backup-and-skip") throw new Error(`domain '${spec.name}' invalidRecords must be 'backup-and-skip' when present`);
|
|
769
|
+
for (const table of Object.keys(spec.tables)) if (!UNIT_NAME_RE.test(table)) throw new Error(`domain '${spec.name}' table name '${table}' must match ${UNIT_NAME_RE}`);
|
|
770
|
+
return spec;
|
|
771
|
+
}
|
|
772
|
+
/** `TrashEntry` 的字段面(`src/trash/entry.ts` 的接口逐项)。 */
|
|
773
|
+
const TRASH_ENTRY_FIELDS = {
|
|
774
|
+
id: "string",
|
|
775
|
+
sessionId: "string",
|
|
776
|
+
projectDir: "string",
|
|
777
|
+
workspaceId: "string",
|
|
778
|
+
wasArchived: "boolean",
|
|
779
|
+
deletedAt: "number",
|
|
780
|
+
title: "string",
|
|
781
|
+
sizeBytes: "number"
|
|
782
|
+
};
|
|
783
|
+
/**
|
|
784
|
+
* 回收站清单的域声明。
|
|
785
|
+
*
|
|
786
|
+
* `layout: 'per-record'` + `invalidRecords: 'backup-and-skip'`:一条坏记录被域层移开并跳过(清单是可丢数据,
|
|
787
|
+
* 不该让一份坏文档把整个域开不起来);`single` 布局下没有 `backupRecord`,一条坏记录会让 `open()` 整个失败。
|
|
788
|
+
* 同款选择:官方 `session_projcache` 域也是 `per-record` + `backup-and-skip`(其 spec 的 JSDoc 明写理由)。
|
|
789
|
+
*/
|
|
790
|
+
const trashDomainSpec = defineDomain({
|
|
791
|
+
name: TRASH_DOMAIN_NAME,
|
|
792
|
+
version: 1,
|
|
793
|
+
layout: "per-record",
|
|
794
|
+
invalidRecords: "backup-and-skip",
|
|
795
|
+
tables: { entries: domainTable({ parse(raw) {
|
|
796
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new TypeError("session trash entry record must be a plain object");
|
|
797
|
+
for (const [field, kind] of Object.entries(TRASH_ENTRY_FIELDS)) if (typeof raw[field] !== kind) throw new TypeError(`session trash entry record field "${field}" must be a ${kind}`);
|
|
798
|
+
return raw;
|
|
799
|
+
} }) }
|
|
800
|
+
});
|
|
801
|
+
/**
|
|
802
|
+
* 清单的真实绑定:官方 storage domain → `ManifestPort`(`StorageDomainLike` 在动词层保持一种读法)。
|
|
803
|
+
* 域**懒开**且只开一次(`apply` 是同步的,而 `open()` 是异步的),并挂一条 `ctx.effect` 清理:
|
|
804
|
+
* 插件卸载时关掉句柄(句柄归调用方所有,这是 `open()` 的 JSDoc 明写的契约)。
|
|
805
|
+
*/
|
|
806
|
+
function createStorageManifest(ctx) {
|
|
807
|
+
const host = ctx;
|
|
808
|
+
let opening;
|
|
809
|
+
ctx.effect(() => () => {
|
|
810
|
+
opening?.then((handle) => handle.close()).catch(() => {});
|
|
811
|
+
}, "session-delete.manifest");
|
|
812
|
+
const table = async () => {
|
|
813
|
+
opening ??= host.storageDomain.open(trashDomainSpec);
|
|
814
|
+
return (await opening).table("entries");
|
|
815
|
+
};
|
|
816
|
+
return createStorageManifest$1({
|
|
817
|
+
readAll: async () => [...(await table()).entries()].map(([, record]) => record),
|
|
818
|
+
put: async (record) => {
|
|
819
|
+
await (await table()).put(record.id, record);
|
|
820
|
+
},
|
|
821
|
+
delete: async (key) => {
|
|
822
|
+
await (await table()).delete(key);
|
|
823
|
+
}
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* 宿主数据根。官方解析器在 asar `dsh/node_modules/@deepseek-ai/dsh-home-paths/lib/index.js`:
|
|
828
|
+
* `resolveDshHome(configured, env)` 的优先级 = 显式配置 > `$DSH_HOME`(**空白视为未设**,免得非法覆盖把 home
|
|
829
|
+
* 解析成当前工作目录)> `~/.dsh`,并 `resolve()` 规范化、展开 `~/`。
|
|
830
|
+
*
|
|
831
|
+
* **只镜像后两级,且这与平台的同款设施等价**——"显式配置"那一级对插件**不可得**,三条实证:
|
|
832
|
+
* ① 平台自己的 helper `dshHomePath(...segments)` 逐字是 `join(resolveDshHome(), ...segments)`,
|
|
833
|
+
* **同样不传 `configured`**;
|
|
834
|
+
* ② `ctx.profileContext` 按 launcher 原文只装「profile 位置 / 启动 bundle 名 / 调用覆盖 / 遥测开关」,
|
|
835
|
+
* 里面没有数据根;
|
|
836
|
+
* ③ 宿主自己的服务也不是"问出来"的——它们各自声明 `Config.dshHome`,由**装配那一行的人**填。
|
|
837
|
+
* ⇒ 后果与边界(设计稿 §5):显式配置过的 profile 上我们算出的根与宿主不一致 ⇒ 文件探测落空 ⇒
|
|
838
|
+
* 删除/恢复以 `not-found` **响亮拒绝、零副作用**;**不会**动到别的树(sessions 与回收站同源 ⇒ 同卷)。
|
|
839
|
+
* 本机两条路径都因此对得上:桌面版走 `~/.dsh`(实测 profile 在其下),隔离探针靠 `$DSH_HOME`。
|
|
840
|
+
*/
|
|
841
|
+
function resolveHome(env = process.env) {
|
|
842
|
+
const fromEnv = env.DSH_HOME;
|
|
843
|
+
const raw = fromEnv !== void 0 && fromEnv.trim().length > 0 ? fromEnv : join(homedir(), ".dsh");
|
|
844
|
+
if (raw === "~") return homedir();
|
|
845
|
+
if (raw.startsWith("~/") || raw.startsWith("~\\")) return resolve(join(homedir(), raw.slice(2)));
|
|
846
|
+
return resolve(raw);
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* 装配顺序:**清单 → 端口 → 动词依赖 → 前缀路由**(`DeleteDeps` 就是前两者的合一)。
|
|
850
|
+
*
|
|
851
|
+
* 路由用 `ctx.effect` 挂、disposer 归装载它的那根 fiber:插件卸载即摘路由(`register` 返回的
|
|
852
|
+
* disposer 是官方契约)。**同一进程里只注册一次**这件事由宿主把住:重复的 `(kind, path)`
|
|
853
|
+
* 在 `register` 里直接抛(`webserver: duplicate prefix route "…"`),响亮失败而不是悄悄覆盖。
|
|
854
|
+
*/
|
|
855
|
+
function apply(ctx) {
|
|
856
|
+
const deps = {
|
|
857
|
+
fs: createFsPort(),
|
|
858
|
+
host: createHostPort(ctx),
|
|
859
|
+
manifest: createStorageManifest(ctx),
|
|
860
|
+
home: resolveHome(),
|
|
861
|
+
now: () => Date.now()
|
|
862
|
+
};
|
|
863
|
+
const webServer = ctx.webServer;
|
|
864
|
+
ctx.effect(() => {
|
|
865
|
+
const dispose = webServer.register({
|
|
866
|
+
kind: "prefix",
|
|
867
|
+
path: SESSION_DELETE_API_PREFIX,
|
|
868
|
+
handler: createApiHandler(deps)
|
|
869
|
+
});
|
|
870
|
+
return () => {
|
|
871
|
+
dispose();
|
|
872
|
+
};
|
|
873
|
+
}, "session-delete: /dsh-session-delete-api routes");
|
|
874
|
+
}
|
|
875
|
+
//#endregion
|
|
876
|
+
export { apply, createStorageManifest, inject, name };
|