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/scripts/smoke.mjs
DELETED
|
@@ -1,382 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 宿主半区自检 —— 不需要 DSH,也不需要浏览器。
|
|
3
|
-
*
|
|
4
|
-
* 做法:用从真实 `<DSH_HOME>/storages/workspace.json` 读出的工作区/归档/置顶集合搭一个
|
|
5
|
-
* 假 `workspaceRegistry`,再用假 `ctx` 调 `apply(ctx)`,然后**用真实 `Request` 走一遍
|
|
6
|
-
* 插件注册的精确 Fetch 路由**(`/api/session-curator/<endpoint>`),信封、分发、
|
|
7
|
-
* 入参归一化一起测。
|
|
8
|
-
*
|
|
9
|
-
* 假 ctx 只提供 `connection.fetch.register`:**故意不给 `rpc`,也不给 `webServer`**,
|
|
10
|
-
* 因为那正是出过事故的路径 —— `connection.rpc.handle()` 内部拿的是 Connection 自己的
|
|
11
|
-
* ctx 去做 `owner.webServer.register(...)`,会抛
|
|
12
|
-
* `cannot get property "webServer" without inject`,插件 fiber 变 `failed`、
|
|
13
|
-
* 一条路由都挂不上,客户端每个 POST 落到 SPA 静态兜底,被回 **HTTP 405**
|
|
14
|
-
* (`transport failure for /session-manager/trash: HTTP 405`)。
|
|
15
|
-
* 谁把实现改回 `rpc.handle`,这里就会当场崩掉。
|
|
16
|
-
*
|
|
17
|
-
* `snapshot` 会真实读取 `<DSH_HOME>`(会话目录、投影缓存、回收站),所以这条路径上的
|
|
18
|
-
* 扫描、标题/cwd 投影、体积统计、错误信封与入参归一化都会被覆盖。
|
|
19
|
-
*
|
|
20
|
-
* 最后一段换到**临时 DSH_HOME**,用一个按 `dsh-workspace` 真实语义最小化实现的假 registry
|
|
21
|
-
* 真跑一遍 `trash` / `restore` / `purge`,断言的是"删除之后会话必须从侧边栏消失、且只能
|
|
22
|
-
* 出现在回收站页签":那正是"移入回收站后侧边栏未分组里还在、归档页签里也还在"的回归测试
|
|
23
|
-
* (判定规则逐字照抄 `dsh-client-ui-workspace` 的 `groupByWorkspace()`)。
|
|
24
|
-
*
|
|
25
|
-
* 用法:`node scripts/smoke.mjs`(可用 `DSH_HOME` 指定别的 home)。
|
|
26
|
-
*/
|
|
27
|
-
import { existsSync } from "node:fs";
|
|
28
|
-
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
29
|
-
import { homedir, tmpdir } from "node:os";
|
|
30
|
-
import { join, resolve } from "node:path";
|
|
31
|
-
import { apply, compareVersions, inject, name } from "../lib/index.js";
|
|
32
|
-
|
|
33
|
-
function dshHome() {
|
|
34
|
-
const configured = process.env.DSH_HOME;
|
|
35
|
-
if (typeof configured === "string" && configured.trim() !== "") return resolve(configured.trim());
|
|
36
|
-
return resolve(join(homedir(), ".dsh"));
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const home = dshHome();
|
|
40
|
-
const workspaceFile = join(home, "storages", "workspace.json");
|
|
41
|
-
|
|
42
|
-
const raw = JSON.parse(await readFile(workspaceFile, "utf8"));
|
|
43
|
-
const tables = (raw.tables && raw.tables.workspaces) || {};
|
|
44
|
-
const workspaces = Object.entries(tables).map(([id, row]) => ({
|
|
45
|
-
id,
|
|
46
|
-
path: row.path,
|
|
47
|
-
title: row.title,
|
|
48
|
-
sessionIds: Array.isArray(row.sessionIds) ? row.sessionIds : []
|
|
49
|
-
}));
|
|
50
|
-
|
|
51
|
-
const registry = {
|
|
52
|
-
archivedSessionIds: (raw.global && raw.global.archivedSessionIds) || [],
|
|
53
|
-
pinnedSessionIds: (raw.global && raw.global.pinnedSessionIds) || [],
|
|
54
|
-
list: () => workspaces,
|
|
55
|
-
resolveByPath: async (path) => workspaces.find((workspace) => workspace.path === path)
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
// ── 假 ctx:只有 Connection 公布的 Fetch 注册面 ───────────────────────────
|
|
59
|
-
const CHANNEL_PATH = "/api/session-curator";
|
|
60
|
-
const METHOD_PREFIX = "session-curator";
|
|
61
|
-
const EXPECTED_ENDPOINTS = ["snapshot", "archive", "unarchive", "pin", "unpin", "trash", "restore", "purge", "check-update"];
|
|
62
|
-
|
|
63
|
-
const routes = new Map();
|
|
64
|
-
const disposers = [];
|
|
65
|
-
const ctx = {
|
|
66
|
-
connection: {
|
|
67
|
-
fetch: {
|
|
68
|
-
register: (route) => {
|
|
69
|
-
if (routes.has(route.path)) throw new Error(`duplicate route ${route.path}`);
|
|
70
|
-
routes.set(route.path, route);
|
|
71
|
-
const dispose = () => routes.delete(route.path);
|
|
72
|
-
disposers.push(dispose);
|
|
73
|
-
return dispose;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
},
|
|
77
|
-
workspaceRegistry: registry,
|
|
78
|
-
effect: (callback) => {
|
|
79
|
-
const dispose = callback();
|
|
80
|
-
if (typeof dispose === "function") disposers.push(dispose);
|
|
81
|
-
return dispose;
|
|
82
|
-
},
|
|
83
|
-
waterfall: async () => [],
|
|
84
|
-
logger: { warn: (message) => console.log(" [warn]", message) }
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
const checks = [];
|
|
88
|
-
function check(label, condition, detail) {
|
|
89
|
-
checks.push({ label, ok: Boolean(condition), detail });
|
|
90
|
-
console.log(`${condition ? " ok " : " FAIL "} ${label}${detail === undefined ? "" : ` — ${detail}`}`);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
console.log(`plugin: ${name} inject: ${inject.join(" + ")}`);
|
|
94
|
-
console.log(`home: ${home}`);
|
|
95
|
-
console.log(`workspace.json: ${workspaces.length} workspaces, ${registry.archivedSessionIds.length} archived, ${registry.pinnedSessionIds.length} pinned\n`);
|
|
96
|
-
|
|
97
|
-
apply(ctx);
|
|
98
|
-
|
|
99
|
-
// ── 路由形状 ─────────────────────────────────────────────────────────────
|
|
100
|
-
check(
|
|
101
|
-
"registers one exact /api route per endpoint",
|
|
102
|
-
routes.size === EXPECTED_ENDPOINTS.length && EXPECTED_ENDPOINTS.every((endpoint) => routes.has(`${CHANNEL_PATH}/${endpoint}`)),
|
|
103
|
-
[...routes.keys()].join(", ")
|
|
104
|
-
);
|
|
105
|
-
check(
|
|
106
|
-
"every route is POST + buffered under the shared /api channel",
|
|
107
|
-
[...routes.values()].every((route) => route.methods.length === 1 && route.methods[0] === "POST" && route.requestBody === "buffered"),
|
|
108
|
-
[...routes.values()].map((route) => `${route.path} [${route.methods.join("|")}] ${route.requestBody}`).join(", ")
|
|
109
|
-
);
|
|
110
|
-
|
|
111
|
-
// ── 线协议:真 Request 进、真信封出(/api 前缀路由已做完信任与鉴权) ──────
|
|
112
|
-
function invokerFor(routes) {
|
|
113
|
-
return async function invoke(endpoint, payload, overrides) {
|
|
114
|
-
const route = routes.get(`${CHANNEL_PATH}/${endpoint}`);
|
|
115
|
-
if (route === undefined) throw new Error(`route not registered: ${CHANNEL_PATH}/${endpoint}`);
|
|
116
|
-
const rpcId = `smoke-${endpoint}-${Math.random().toString(16).slice(2)}`;
|
|
117
|
-
const request = new Request(`http://127.0.0.1${CHANNEL_PATH}/${endpoint}`, {
|
|
118
|
-
method: "POST",
|
|
119
|
-
headers: { "content-type": "application/json" },
|
|
120
|
-
body: JSON.stringify({
|
|
121
|
-
type: "client-request",
|
|
122
|
-
rpcId,
|
|
123
|
-
method: `${METHOD_PREFIX}/${endpoint}`,
|
|
124
|
-
payload: payload || {},
|
|
125
|
-
...(overrides || {})
|
|
126
|
-
})
|
|
127
|
-
});
|
|
128
|
-
const envelope = await (await route.fetch(request)).json();
|
|
129
|
-
if (envelope.type !== "server-response") throw new Error(`bad envelope type for ${endpoint}`);
|
|
130
|
-
if (envelope.rpcId !== rpcId) throw new Error(`rpcId mismatch for ${endpoint}`);
|
|
131
|
-
return envelope.result;
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const invoke = invokerFor(routes);
|
|
136
|
-
|
|
137
|
-
const snapshot = await invoke("snapshot", {});
|
|
138
|
-
check("snapshot succeeds", snapshot.ok, snapshot.ok ? undefined : JSON.stringify(snapshot.error));
|
|
139
|
-
if (!snapshot.ok) process.exit(1);
|
|
140
|
-
|
|
141
|
-
const value = snapshot.value;
|
|
142
|
-
check("snapshot shape", ["dshHome", "version", "trashDir", "sessionsDir", "archived", "pinned", "trashed", "totals"].every((key) => key in value));
|
|
143
|
-
check("snapshot reports the package version (config page shows it as v…)", value.version === JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")).version, String(value.version));
|
|
144
|
-
check("trash lives under storages/session-manager", value.trashDir.replace(/\\/g, "/").endsWith("/storages/session-manager/trash"), value.trashDir);
|
|
145
|
-
const trashedIds = new Set(value.trashed.map((row) => row.sessionId));
|
|
146
|
-
// 期望集合 = 归档集合 ∩ 磁盘上真实存在 ∖ 回收站:`archivedSessionIds` 是只增不减的记账,
|
|
147
|
-
// 目录已经不在(或被我们搬进回收站)的 id 不该再出现在"已归档"页签里。
|
|
148
|
-
const onDiskIds = new Set();
|
|
149
|
-
for (const slug of await readdir(join(home, "sessions"), { withFileTypes: true }).catch(() => [])) {
|
|
150
|
-
if (!slug.isDirectory()) continue;
|
|
151
|
-
for (const entry of await readdir(join(home, "sessions", slug.name), { withFileTypes: true }).catch(() => [])) {
|
|
152
|
-
if (entry.isDirectory()) onDiskIds.add(entry.name);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
const expectedArchived = registry.archivedSessionIds.filter((id) => onDiskIds.has(id) && !trashedIds.has(id));
|
|
156
|
-
check(
|
|
157
|
-
"archived rows are exactly the archived ids still on disk, minus the trash",
|
|
158
|
-
value.archived.length === expectedArchived.length && value.archived.every((row) => expectedArchived.includes(row.sessionId)),
|
|
159
|
-
`${value.archived.length} rows (expected ${expectedArchived.length}), ${value.totals.archivedBytes} B`
|
|
160
|
-
);
|
|
161
|
-
// 回归:回收站条目**只**归回收站页签。删除时 id 会故意留在归档集合里(那是 DSH 唯一
|
|
162
|
-
// "从所有分组面消失"的机制),所以这里必须有过滤,否则同一会话会同时出现在两个页签,
|
|
163
|
-
// 而"已归档"那条还是 `onDisk:false` 的幽灵行(本机真实数据踩过 5 次)。
|
|
164
|
-
check(
|
|
165
|
-
"no trashed session is also listed as archived",
|
|
166
|
-
value.archived.every((row) => !trashedIds.has(row.sessionId)),
|
|
167
|
-
value.archived.filter((row) => trashedIds.has(row.sessionId)).map((row) => row.sessionId).join(", ") || undefined
|
|
168
|
-
);
|
|
169
|
-
check("every archived row resolved its on-disk directory", value.archived.every((row) => row.onDisk), value.archived.filter((row) => !row.onDisk).map((row) => row.sessionId).join(", ") || undefined);
|
|
170
|
-
check("every archived row has a non-empty title", value.archived.every((row) => typeof row.title === "string" && row.title !== ""));
|
|
171
|
-
|
|
172
|
-
console.log("\n archived rows:");
|
|
173
|
-
for (const row of value.archived) {
|
|
174
|
-
console.log(` - ${row.title}`);
|
|
175
|
-
console.log(` ${row.sessionId} ws=${row.workspaceTitle ?? "-"} ${row.files} file(s) ${row.bytes} B slug=${row.slug}`);
|
|
176
|
-
}
|
|
177
|
-
console.log("");
|
|
178
|
-
|
|
179
|
-
// ── 坏信封:一律 200 + 错误信封(跟 Connection 自己一致) ────────────────
|
|
180
|
-
const mismatched = await invoke("snapshot", {}, { method: "session-curator/restore" });
|
|
181
|
-
check(
|
|
182
|
-
"method mismatch is rejected inside the envelope",
|
|
183
|
-
mismatched.ok === false && mismatched.error.code === "session-curator/bad-request",
|
|
184
|
-
JSON.stringify(mismatched.error)
|
|
185
|
-
);
|
|
186
|
-
|
|
187
|
-
const route = routes.get(`${CHANNEL_PATH}/snapshot`);
|
|
188
|
-
const brokenEnvelope = await (await route.fetch(new Request(`http://127.0.0.1${CHANNEL_PATH}/snapshot`, {
|
|
189
|
-
method: "POST",
|
|
190
|
-
headers: { "content-type": "application/json" },
|
|
191
|
-
body: "{not json"
|
|
192
|
-
}))).json();
|
|
193
|
-
check(
|
|
194
|
-
"non-JSON body answers a bad-request envelope instead of throwing",
|
|
195
|
-
brokenEnvelope.rpcId === "invalid-request" && brokenEnvelope.result.ok === false && brokenEnvelope.result.error.code === "session-curator/bad-request",
|
|
196
|
-
JSON.stringify(brokenEnvelope)
|
|
197
|
-
);
|
|
198
|
-
|
|
199
|
-
// ── 逐项语义 ─────────────────────────────────────────────────────────────
|
|
200
|
-
const missing = await invoke("purge", { ids: ["session-does-not-exist"] });
|
|
201
|
-
check("missing trash entry reports a per-item failure", missing.ok === true && missing.value.results.length === 1 && missing.value.results[0].ok === false, JSON.stringify(missing.value.results));
|
|
202
|
-
|
|
203
|
-
const malformed = await invoke("unarchive", { ids: "not-an-array" });
|
|
204
|
-
check("malformed ids normalize to an empty list", malformed.value.results.length === 0);
|
|
205
|
-
|
|
206
|
-
const noIds = await invoke("restore", {});
|
|
207
|
-
check("missing ids is not an error", noIds.ok === true && noIds.value.results.length === 0);
|
|
208
|
-
|
|
209
|
-
// ── 版本比较:决定配置页要不要弹"有新版本" ──────────────────────────────
|
|
210
|
-
// 直接单测纯函数,不打网络(`check-update` 端点本身由上面的路由形状覆盖)。
|
|
211
|
-
check("a higher patch counts as an upgrade", compareVersions("0.1.1", "0.1.0") === 1 && compareVersions("0.2.0", "0.1.9") === 1, `${compareVersions("0.1.1", "0.1.0")}, ${compareVersions("0.2.0", "0.1.9")}`);
|
|
212
|
-
check("the same version counts as current", compareVersions("0.1.0", "0.1.0") === 0 && compareVersions("v0.1.0", "0.1.0") === 0);
|
|
213
|
-
check("a prerelease ranks below its release", compareVersions("0.1.0-rc.1", "0.1.0") === -1 && compareVersions("0.1.0", "0.1.0-rc.1") === 1);
|
|
214
|
-
check("a lower version never looks like an upgrade", compareVersions("0.0.9", "0.1.0") === -1);
|
|
215
|
-
|
|
216
|
-
// ── 软删除语义:会话要被"藏起来",而且只出现在回收站页签 ─────────────────
|
|
217
|
-
// 这一段用**临时 DSH_HOME**(插件每次解析 home 都读 process.env.DSH_HOME),
|
|
218
|
-
// 所以可以真实地搬目录、恢复、永久删除,绝不碰真实数据。
|
|
219
|
-
console.log("\n── 软删除语义(临时 DSH_HOME) ──");
|
|
220
|
-
const realHome = process.env.DSH_HOME;
|
|
221
|
-
const tempHome = await mkdtemp(join(tmpdir(), "dsh-curator-smoke-"));
|
|
222
|
-
const SLUG = "--C-Data-Smoke--";
|
|
223
|
-
const SMOKE_CWD = "C:\\Data\\Smoke";
|
|
224
|
-
const ARCHIVED = "session-smoke-archived";
|
|
225
|
-
const ORDINARY = "session-smoke-ordinary";
|
|
226
|
-
const DOOMED = "session-smoke-doomed";
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* 侧边栏归属判定 —— 逐字照抄 `@deepseek-ai/dsh-client-ui-workspace` 的
|
|
230
|
-
* `groupByWorkspace()`:客户端 list 里认得、且不属于任何工作区、且**不在归档集合**里的会话,
|
|
231
|
-
* 会被收进 **未分组**(locale key `group.ungrouped`)。那就是用户看到的那条幽灵行。
|
|
232
|
-
*/
|
|
233
|
-
function sidebarBucket(sessionId, clientList, workspaces, archived) {
|
|
234
|
-
if (!clientList.includes(sessionId)) return "absent";
|
|
235
|
-
// 顺序照抄真实实现:先 `sessionVisible()`(默认过滤里归档会话一律不渲染,**哪怕它
|
|
236
|
-
// 还挂在某个工作区名下** —— 归档不动记账),再决定它落在哪个分组里。
|
|
237
|
-
if (archived.includes(sessionId)) return "archived";
|
|
238
|
-
if (workspaces.some((workspace) => workspace.sessionIds.includes(sessionId))) return "workspace";
|
|
239
|
-
return "ungrouped";
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function fakeWorkspace(id, path, sessionIds) {
|
|
243
|
-
return {
|
|
244
|
-
id,
|
|
245
|
-
path,
|
|
246
|
-
title: "Smoke",
|
|
247
|
-
sessionIds: [...sessionIds],
|
|
248
|
-
async attachSession(sessionId) {
|
|
249
|
-
if (!this.sessionIds.includes(sessionId)) this.sessionIds.unshift(sessionId);
|
|
250
|
-
},
|
|
251
|
-
async detachSession(sessionId) {
|
|
252
|
-
this.sessionIds = this.sessionIds.filter((value) => value !== sessionId);
|
|
253
|
-
}
|
|
254
|
-
};
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
function fakeRegistry(sessionIds) {
|
|
258
|
-
return {
|
|
259
|
-
workspaces: [fakeWorkspace("ws-smoke", SMOKE_CWD, sessionIds)],
|
|
260
|
-
archivedSessionIds: [],
|
|
261
|
-
pinnedSessionIds: [],
|
|
262
|
-
list() {
|
|
263
|
-
return this.workspaces;
|
|
264
|
-
},
|
|
265
|
-
async resolveByPath(path) {
|
|
266
|
-
return this.workspaces.find((candidate) => candidate.path === path);
|
|
267
|
-
},
|
|
268
|
-
async archiveSession(sessionId) {
|
|
269
|
-
if (!this.archivedSessionIds.includes(sessionId)) this.archivedSessionIds.push(sessionId);
|
|
270
|
-
this.pinnedSessionIds = this.pinnedSessionIds.filter((value) => value !== sessionId);
|
|
271
|
-
},
|
|
272
|
-
async unarchiveSession(sessionId) {
|
|
273
|
-
this.archivedSessionIds = this.archivedSessionIds.filter((value) => value !== sessionId);
|
|
274
|
-
},
|
|
275
|
-
async unpinSession(sessionId) {
|
|
276
|
-
this.pinnedSessionIds = this.pinnedSessionIds.filter((value) => value !== sessionId);
|
|
277
|
-
}
|
|
278
|
-
};
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/** 再挂一个插件实例(自己的路由表 + 自己的假 registry),路由仍走真实 Request。 */
|
|
282
|
-
function mount(registry) {
|
|
283
|
-
const localRoutes = new Map();
|
|
284
|
-
const localDisposers = [];
|
|
285
|
-
apply({
|
|
286
|
-
connection: {
|
|
287
|
-
fetch: {
|
|
288
|
-
register: (route) => {
|
|
289
|
-
localRoutes.set(route.path, route);
|
|
290
|
-
const dispose = () => localRoutes.delete(route.path);
|
|
291
|
-
localDisposers.push(dispose);
|
|
292
|
-
return dispose;
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
},
|
|
296
|
-
workspaceRegistry: registry,
|
|
297
|
-
effect: (callback) => {
|
|
298
|
-
const dispose = callback();
|
|
299
|
-
if (typeof dispose === "function") localDisposers.push(dispose);
|
|
300
|
-
return dispose;
|
|
301
|
-
},
|
|
302
|
-
waterfall: async () => [],
|
|
303
|
-
logger: { warn: () => {} }
|
|
304
|
-
});
|
|
305
|
-
return { disposers: localDisposers, invoke: invokerFor(localRoutes) };
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
async function seedSession(sessionId, title) {
|
|
309
|
-
await mkdir(join(tempHome, "sessions", SLUG, sessionId), { recursive: true });
|
|
310
|
-
await writeFile(join(tempHome, "sessions", SLUG, sessionId, "session.jsonl"), "{}\n", "utf8");
|
|
311
|
-
await mkdir(join(tempHome, "storages", "session_projcache", "sessions"), { recursive: true });
|
|
312
|
-
await writeFile(
|
|
313
|
-
join(tempHome, "storages", "session_projcache", "sessions", `${sessionId}.json`),
|
|
314
|
-
JSON.stringify({ record: { rows: { title: { val: title } }, identity: { cwd: SMOKE_CWD, createdAt: 1 } } }),
|
|
315
|
-
"utf8"
|
|
316
|
-
);
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
const trashDirOf = (sessionId) => join(tempHome, "storages", "session-manager", "trash", sessionId);
|
|
320
|
-
const trashMetaOf = async (sessionId) => JSON.parse(await readFile(join(trashDirOf(sessionId), "__trash.json"), "utf8"));
|
|
321
|
-
|
|
322
|
-
try {
|
|
323
|
-
process.env.DSH_HOME = tempHome;
|
|
324
|
-
await seedSession(ARCHIVED, "已归档的会话");
|
|
325
|
-
await seedSession(ORDINARY, "普通会话");
|
|
326
|
-
await seedSession(DOOMED, "待永久删除");
|
|
327
|
-
|
|
328
|
-
const registry2 = fakeRegistry([ARCHIVED, ORDINARY, DOOMED]);
|
|
329
|
-
registry2.archivedSessionIds = [ARCHIVED];
|
|
330
|
-
const plugin = mount(registry2);
|
|
331
|
-
// 客户端那份会话列表是实时快照:我们把目录搬走,它也不会自己掉 —— 三条都还在。
|
|
332
|
-
const clientList = [ARCHIVED, ORDINARY, DOOMED];
|
|
333
|
-
const bucketOf = (sessionId) => sidebarBucket(sessionId, clientList, registry2.workspaces, registry2.archivedSessionIds);
|
|
334
|
-
|
|
335
|
-
check("an archived session is hidden from the sidebar beforehand", bucketOf(ARCHIVED) === "archived", bucketOf(ARCHIVED));
|
|
336
|
-
|
|
337
|
-
const trashedArchived = await plugin.invoke("trash", { ids: [ARCHIVED] });
|
|
338
|
-
const archivedSteps = trashedArchived.value.results[0]?.steps ?? [];
|
|
339
|
-
check("trashing an archived session succeeds", trashedArchived.value.results[0]?.ok === true, JSON.stringify(trashedArchived.value.results[0]));
|
|
340
|
-
check("it is NOT unarchived on the way in (the old bug)", !archivedSteps.includes("unarchive"), `steps=[${archivedSteps.join(",")}]`);
|
|
341
|
-
check("it stays archived, so DSH keeps hiding it", registry2.archivedSessionIds.includes(ARCHIVED), `archived=[${registry2.archivedSessionIds.join(",")}]`);
|
|
342
|
-
check("the sidebar does not gain an Ungrouped ghost row", bucketOf(ARCHIVED) === "archived", bucketOf(ARCHIVED));
|
|
343
|
-
check("its directory really moved into the trash", !existsSync(join(tempHome, "sessions", SLUG, ARCHIVED)) && existsSync(join(trashDirOf(ARCHIVED), "session.jsonl")));
|
|
344
|
-
check("__trash.json records wasArchived: true", (await trashMetaOf(ARCHIVED)).wasArchived === true, JSON.stringify(await trashMetaOf(ARCHIVED)));
|
|
345
|
-
check("snapshot.archived drops it (no phantom row)", !trashedArchived.value.archived.some((row) => row.sessionId === ARCHIVED));
|
|
346
|
-
check("snapshot.trashed lists it", trashedArchived.value.trashed.some((row) => row.sessionId === ARCHIVED));
|
|
347
|
-
|
|
348
|
-
await plugin.invoke("trash", { ids: [ORDINARY] });
|
|
349
|
-
check(
|
|
350
|
-
"an ordinary session is archived on the way in (otherwise it lands in Ungrouped)",
|
|
351
|
-
registry2.archivedSessionIds.includes(ORDINARY) && bucketOf(ORDINARY) === "archived",
|
|
352
|
-
`archived=[${registry2.archivedSessionIds.join(",")}] bucket=${bucketOf(ORDINARY)}`
|
|
353
|
-
);
|
|
354
|
-
check("__trash.json records wasArchived: false", (await trashMetaOf(ORDINARY)).wasArchived === false);
|
|
355
|
-
|
|
356
|
-
const restoredOrdinary = await plugin.invoke("restore", { ids: [ORDINARY] });
|
|
357
|
-
check("restoring an ordinary session unarchives it again", !registry2.archivedSessionIds.includes(ORDINARY), JSON.stringify(restoredOrdinary.value.results[0]));
|
|
358
|
-
check("restoring re-attaches it to its original workspace", registry2.workspaces[0].sessionIds.includes(ORDINARY), registry2.workspaces[0].sessionIds.join(","));
|
|
359
|
-
|
|
360
|
-
await plugin.invoke("restore", { ids: [ARCHIVED] });
|
|
361
|
-
check("restoring a previously archived session keeps it archived", registry2.archivedSessionIds.includes(ARCHIVED));
|
|
362
|
-
check("and it is back on disk under its original slug", existsSync(join(tempHome, "sessions", SLUG, ARCHIVED)));
|
|
363
|
-
check("both restored sessions stay out of Ungrouped", [ARCHIVED, ORDINARY].every((id) => bucketOf(id) !== "ungrouped"), `${bucketOf(ARCHIVED)}, ${bucketOf(ORDINARY)}`);
|
|
364
|
-
|
|
365
|
-
await plugin.invoke("trash", { ids: [DOOMED] });
|
|
366
|
-
const purged = await plugin.invoke("purge", { ids: [DOOMED] });
|
|
367
|
-
check("purging drops the archive entry (no permanent ghost id)", !registry2.archivedSessionIds.includes(DOOMED), JSON.stringify(purged.value.results[0]));
|
|
368
|
-
|
|
369
|
-
for (const dispose of plugin.disposers.splice(0)) await dispose();
|
|
370
|
-
} finally {
|
|
371
|
-
if (realHome === undefined) delete process.env.DSH_HOME;
|
|
372
|
-
else process.env.DSH_HOME = realHome;
|
|
373
|
-
await rm(tempHome, { recursive: true, force: true });
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
// ── 卸载:路由要跟着 fiber 一起摘掉 ──────────────────────────────────────
|
|
377
|
-
for (const dispose of disposers.splice(0)) await dispose();
|
|
378
|
-
check("unloading the plugin removes every route", routes.size === 0, [...routes.keys()].join(", ") || undefined);
|
|
379
|
-
|
|
380
|
-
const failed = checks.filter((entry) => !entry.ok);
|
|
381
|
-
console.log(`\n${checks.length - failed.length}/${checks.length} checks passed`);
|
|
382
|
-
process.exit(failed.length === 0 ? 0 : 1);
|