dsh-session-curator 0.1.0 → 0.1.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/scripts/smoke.mjs CHANGED
@@ -17,12 +17,18 @@
17
17
  * `snapshot` 会真实读取 `<DSH_HOME>`(会话目录、投影缓存、回收站),所以这条路径上的
18
18
  * 扫描、标题/cwd 投影、体积统计、错误信封与入参归一化都会被覆盖。
19
19
  *
20
+ * 最后一段换到**临时 DSH_HOME**,用一个按 `dsh-workspace` 真实语义最小化实现的假 registry
21
+ * 真跑一遍 `trash` / `restore` / `purge`,断言的是"删除之后会话必须从侧边栏消失、且只能
22
+ * 出现在回收站页签":那正是"移入回收站后侧边栏未分组里还在、归档页签里也还在"的回归测试
23
+ * (判定规则逐字照抄 `dsh-client-ui-workspace` 的 `groupByWorkspace()`)。
24
+ *
20
25
  * 用法:`node scripts/smoke.mjs`(可用 `DSH_HOME` 指定别的 home)。
21
26
  */
22
- import { readFile } from "node:fs/promises";
23
- import { homedir } from "node:os";
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";
24
30
  import { join, resolve } from "node:path";
25
- import { apply, inject, name } from "../lib/index.js";
31
+ import { apply, compareVersions, inject, name } from "../lib/index.js";
26
32
 
27
33
  function dshHome() {
28
34
  const configured = process.env.DSH_HOME;
@@ -52,7 +58,7 @@ const registry = {
52
58
  // ── 假 ctx:只有 Connection 公布的 Fetch 注册面 ───────────────────────────
53
59
  const CHANNEL_PATH = "/api/session-curator";
54
60
  const METHOD_PREFIX = "session-curator";
55
- const EXPECTED_ENDPOINTS = ["snapshot", "archive", "unarchive", "pin", "unpin", "trash", "restore", "purge"];
61
+ const EXPECTED_ENDPOINTS = ["snapshot", "archive", "unarchive", "pin", "unpin", "trash", "restore", "purge", "check-update"];
56
62
 
57
63
  const routes = new Map();
58
64
  const disposers = [];
@@ -103,35 +109,63 @@ check(
103
109
  );
104
110
 
105
111
  // ── 线协议:真 Request 进、真信封出(/api 前缀路由已做完信任与鉴权) ──────
106
- async function invoke(endpoint, payload, overrides) {
107
- const route = routes.get(`${CHANNEL_PATH}/${endpoint}`);
108
- if (route === undefined) throw new Error(`route not registered: ${CHANNEL_PATH}/${endpoint}`);
109
- const rpcId = `smoke-${endpoint}-${Math.random().toString(16).slice(2)}`;
110
- const request = new Request(`http://127.0.0.1${CHANNEL_PATH}/${endpoint}`, {
111
- method: "POST",
112
- headers: { "content-type": "application/json" },
113
- body: JSON.stringify({
114
- type: "client-request",
115
- rpcId,
116
- method: `${METHOD_PREFIX}/${endpoint}`,
117
- payload: payload || {},
118
- ...(overrides || {})
119
- })
120
- });
121
- const envelope = await (await route.fetch(request)).json();
122
- if (envelope.type !== "server-response") throw new Error(`bad envelope type for ${endpoint}`);
123
- if (envelope.rpcId !== rpcId) throw new Error(`rpcId mismatch for ${endpoint}`);
124
- return envelope.result;
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
+ };
125
133
  }
126
134
 
135
+ const invoke = invokerFor(routes);
136
+
127
137
  const snapshot = await invoke("snapshot", {});
128
138
  check("snapshot succeeds", snapshot.ok, snapshot.ok ? undefined : JSON.stringify(snapshot.error));
129
139
  if (!snapshot.ok) process.exit(1);
130
140
 
131
141
  const value = snapshot.value;
132
- check("snapshot shape", ["dshHome", "trashDir", "sessionsDir", "archived", "pinned", "trashed", "totals"].every((key) => key in 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));
133
144
  check("trash lives under storages/session-manager", value.trashDir.replace(/\\/g, "/").endsWith("/storages/session-manager/trash"), value.trashDir);
134
- check("archived count matches workspace.json", value.archived.length === registry.archivedSessionIds.length, `${value.archived.length} rows, ${value.totals.archivedBytes} B`);
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
+ );
135
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);
136
170
  check("every archived row has a non-empty title", value.archived.every((row) => typeof row.title === "string" && row.title !== ""));
137
171
 
@@ -172,6 +206,173 @@ check("malformed ids normalize to an empty list", malformed.value.results.length
172
206
  const noIds = await invoke("restore", {});
173
207
  check("missing ids is not an error", noIds.ok === true && noIds.value.results.length === 0);
174
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
+
175
376
  // ── 卸载:路由要跟着 fiber 一起摘掉 ──────────────────────────────────────
176
377
  for (const dispose of disposers.splice(0)) await dispose();
177
378
  check("unloading the plugin removes every route", routes.size === 0, [...routes.keys()].join(", ") || undefined);