@tobycaimf/dsh-archived-sessions 1.0.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/lib/index.js ADDED
@@ -0,0 +1,280 @@
1
+ // src/index.js
2
+ import { unlink } from "node:fs/promises";
3
+ var name = "dsh-archived-sessions";
4
+ var inject = ["webServer", "workspaceRegistry", "sessionPersistence", "sessionQuery", "storageDomain"];
5
+ var MAX_TITLE = 80;
6
+ function json(res, value, status = 200) {
7
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
8
+ res.end(JSON.stringify(value));
9
+ }
10
+ async function readJsonBody(req) {
11
+ const chunks = [];
12
+ let total = 0;
13
+ for await (const chunk of req) {
14
+ chunks.push(chunk);
15
+ total += chunk.length;
16
+ if (total > 1 << 20) return null;
17
+ }
18
+ try {
19
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+ function parseIds(body) {
25
+ const raw = body && body.sessionIds;
26
+ if (!Array.isArray(raw)) return null;
27
+ const ids = [];
28
+ for (const v of raw) if (typeof v === "string" && v) ids.push(v);
29
+ return ids;
30
+ }
31
+ function foldTitle(events) {
32
+ let found = null;
33
+ let firstUser = null;
34
+ for (const ev of events) {
35
+ if (ev.type === "session/title" && ev.data && typeof ev.data.title === "string" && ev.data.title.length) {
36
+ found = ev.data.title;
37
+ }
38
+ if (firstUser === null && ev.type === "user/message" && ev.data && Array.isArray(ev.data.content)) {
39
+ const txt = ev.data.content.filter((b) => b && b.type === "text").map((b) => b.text).filter(Boolean).join(" ").trim();
40
+ if (txt) firstUser = txt;
41
+ }
42
+ }
43
+ return found || firstUser || null;
44
+ }
45
+ function apply(ctx) {
46
+ const w = ctx.workspaceRegistry;
47
+ const sp = ctx.sessionPersistence;
48
+ const sq = ctx.sessionQuery;
49
+ const dom = () => ctx.storageDomain.get("workspace");
50
+ async function archivedState() {
51
+ const d = dom();
52
+ if (!d) throw new Error("workspace domain is not open");
53
+ return d.global.get();
54
+ }
55
+ async function writeArchived(nextIds) {
56
+ const d = dom();
57
+ if (!d) throw new Error("workspace domain is not open");
58
+ const cur = d.global.get();
59
+ const next = Object.assign({}, cur, { archivedSessionIds: nextIds });
60
+ await d.global.set(next);
61
+ if (w && "state" in w) {
62
+ try {
63
+ w.state = next;
64
+ } catch (e) {
65
+ }
66
+ }
67
+ return next;
68
+ }
69
+ let wsByPath = {};
70
+ async function resolveOne(id) {
71
+ let title = null, createdAt = null, cwd = null;
72
+ try {
73
+ const o = await sq.readTitleSnapshot(id);
74
+ if (o) {
75
+ if (o.title && o.title.title) title = String(o.title.title);
76
+ if (o.session) {
77
+ cwd = o.session.cwd || null;
78
+ createdAt = o.session.createdAt || null;
79
+ }
80
+ }
81
+ } catch (e) {
82
+ }
83
+ if (!title || !cwd) {
84
+ try {
85
+ const r = await sp.readFrom(id, 0);
86
+ if (r.meta) {
87
+ if (!cwd) cwd = r.meta.cwd || null;
88
+ if (!createdAt) createdAt = r.meta.createdAt || null;
89
+ }
90
+ if (!title && Array.isArray(r.events)) title = foldTitle(r.events);
91
+ } catch (e2) {
92
+ }
93
+ }
94
+ const ws = cwd ? wsByPath[cwd] : void 0;
95
+ const workspaceGone = !!(cwd && !ws);
96
+ const display = title ? String(title).length > MAX_TITLE ? String(title).slice(0, MAX_TITLE) + "\u2026" : String(title) : null;
97
+ return {
98
+ sessionId: id,
99
+ title: display,
100
+ createdAt: createdAt || null,
101
+ workspacePath: cwd || null,
102
+ workspaceTitle: ws && ws.title ? ws.title : null,
103
+ workspaceGone: workspaceGone ? true : false,
104
+ hasWorkspace: !!cwd
105
+ };
106
+ }
107
+ async function restoreOne(sid) {
108
+ const state = await archivedState();
109
+ const list = state.archivedSessionIds.map(String);
110
+ if (!list.includes(sid)) return { ok: true, restored: false };
111
+ await writeArchived(list.filter((x) => x !== sid));
112
+ return { ok: true, restored: true };
113
+ }
114
+ async function deleteOne(sid) {
115
+ const sessions = ctx.get("sessions");
116
+ if (sessions && sessions.get(sid)) {
117
+ throw new Error("\u8BE5\u4F1A\u8BDD\u5F53\u524D\u5904\u4E8E\u6253\u5F00\u72B6\u6001\uFF0C\u8BF7\u5148\u5207\u6362\u5230\u522B\u7684\u4F1A\u8BDD\u518D\u5220\u9664\u3002");
118
+ }
119
+ let removedPath = null;
120
+ try {
121
+ const headers = await sp.list();
122
+ const header = headers.find((h) => String(h.id) === sid);
123
+ if (header) {
124
+ const loc = sp.locate(header);
125
+ if (loc && typeof loc.path === "string") removedPath = loc.path;
126
+ } else {
127
+ const r = await sp.readFrom(sid, 0);
128
+ if (r && r.meta && r.meta.cwd) {
129
+ const loc = sp.locate({ id: sid, cwd: r.meta.cwd });
130
+ if (loc && typeof loc.path === "string") removedPath = loc.path;
131
+ }
132
+ }
133
+ } catch (e) {
134
+ }
135
+ if (removedPath) {
136
+ try {
137
+ await unlink(removedPath);
138
+ } catch (e) {
139
+ if (e && e.code !== "ENOENT") throw new Error("\u5220\u9664\u65E5\u5FD7\u6587\u4EF6\u5931\u8D25\uFF1A" + String(e && e.message || e));
140
+ }
141
+ }
142
+ try {
143
+ for (const ent of w.list()) {
144
+ if (ent.sessionIds.includes(sid)) {
145
+ try {
146
+ await ent.detachSession(sid);
147
+ } catch (e) {
148
+ }
149
+ }
150
+ }
151
+ } catch (e) {
152
+ }
153
+ try {
154
+ if (w.sessionPaths && w.sessionPaths.delete) w.sessionPaths.delete(sid);
155
+ } catch (e) {
156
+ }
157
+ try {
158
+ if (w.headers && w.headers.delete) w.headers.delete(sid);
159
+ } catch (e) {
160
+ }
161
+ return { ok: true, deleted: true, removedPath };
162
+ }
163
+ ctx.effect(() => {
164
+ const disposers = [];
165
+ disposers.push(ctx.webServer.register({
166
+ kind: "exact",
167
+ path: "/archived-sessions/list",
168
+ handler: async (req, res) => {
169
+ try {
170
+ const state = await archivedState();
171
+ const ids = state.archivedSessionIds || [];
172
+ let materialized = /* @__PURE__ */ new Set();
173
+ let live = ctx.get("sessions");
174
+ try {
175
+ const headers = await sp.list();
176
+ materialized = new Set(headers.map((h) => String(h.id)));
177
+ } catch (e) {
178
+ }
179
+ const idStrs = ids.map(String).filter((id) => materialized.has(id) || live && live.get(id));
180
+ wsByPath = {};
181
+ try {
182
+ for (const ent of w.list()) wsByPath[ent.path] = ent;
183
+ } catch (e) {
184
+ wsByPath = {};
185
+ }
186
+ const items = [];
187
+ const CHUNK = 6;
188
+ for (let i = 0; i < idStrs.length; i += CHUNK) {
189
+ const res2 = await Promise.all(idStrs.slice(i, i + CHUNK).map(resolveOne));
190
+ items.push.apply(items, res2);
191
+ }
192
+ json(res, { items });
193
+ } catch (e) {
194
+ json(res, { error: String(e && e.message || e) }, 500);
195
+ }
196
+ }
197
+ }));
198
+ disposers.push(ctx.webServer.register({
199
+ kind: "exact",
200
+ path: "/archived-sessions/restore",
201
+ handler: async (req, res) => {
202
+ try {
203
+ const body = await readJsonBody(req);
204
+ const sid = body && typeof body.sessionId === "string" ? body.sessionId : null;
205
+ if (!sid) return json(res, { ok: false, error: "missing sessionId" }, 400);
206
+ json(res, await restoreOne(sid));
207
+ } catch (e) {
208
+ json(res, { ok: false, error: String(e && e.message || e) }, 500);
209
+ }
210
+ }
211
+ }));
212
+ disposers.push(ctx.webServer.register({
213
+ kind: "exact",
214
+ path: "/archived-sessions/restore-many",
215
+ handler: async (req, res) => {
216
+ try {
217
+ const body = await readJsonBody(req);
218
+ const ids = parseIds(body);
219
+ if (!ids || ids.length === 0) return json(res, { ok: false, error: "missing sessionIds" }, 400);
220
+ const results = [];
221
+ for (const sid of ids) {
222
+ try {
223
+ results.push({ sessionId: sid, ok: true, ...await restoreOne(sid) });
224
+ } catch (e) {
225
+ results.push({ sessionId: sid, ok: false, error: String(e && e.message || e) });
226
+ }
227
+ }
228
+ json(res, { ok: true, restored: results.filter((r) => r.ok).length, results });
229
+ } catch (e) {
230
+ json(res, { ok: false, error: String(e && e.message || e) }, 500);
231
+ }
232
+ }
233
+ }));
234
+ disposers.push(ctx.webServer.register({
235
+ kind: "exact",
236
+ path: "/archived-sessions/delete",
237
+ handler: async (req, res) => {
238
+ try {
239
+ const body = await readJsonBody(req);
240
+ const sid = body && typeof body.sessionId === "string" ? body.sessionId : null;
241
+ if (!sid) return json(res, { ok: false, error: "missing sessionId" }, 400);
242
+ json(res, await deleteOne(sid));
243
+ } catch (e) {
244
+ json(res, { ok: false, error: String(e && e.message || e) }, 500);
245
+ }
246
+ }
247
+ }));
248
+ disposers.push(ctx.webServer.register({
249
+ kind: "exact",
250
+ path: "/archived-sessions/delete-many",
251
+ handler: async (req, res) => {
252
+ try {
253
+ const body = await readJsonBody(req);
254
+ const ids = parseIds(body);
255
+ if (!ids || ids.length === 0) return json(res, { ok: false, error: "missing sessionIds" }, 400);
256
+ const results = [];
257
+ for (const sid of ids) {
258
+ try {
259
+ results.push({ sessionId: sid, ok: true, ...await deleteOne(sid) });
260
+ } catch (e) {
261
+ results.push({ sessionId: sid, ok: false, error: String(e && e.message || e) });
262
+ }
263
+ }
264
+ json(res, { ok: true, deleted: results.filter((r) => r.ok).length, results });
265
+ } catch (e) {
266
+ json(res, { ok: false, error: String(e && e.message || e) }, 500);
267
+ }
268
+ }
269
+ }));
270
+ return () => {
271
+ for (const d of disposers) d();
272
+ };
273
+ }, "dsh-archived-sessions: routes");
274
+ }
275
+ export {
276
+ apply,
277
+ inject,
278
+ name
279
+ };
280
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/index.js"],
4
+ "sourcesContent": ["// dsh-archived-sessions \u2014 host half.\n//\n// Serves /archived-sessions/* JSON routes (list / restore / restore-many /\n// delete / delete-many) over the host `webServer`. The browser Settings\n// section (\"\u5F52\u6863\u4F1A\u8BDD\") talks to these. Reads/writes the durable workspace\n// archive set (workspaceRegistry + storageDomain), folds titles/dates/workspace\n// tags from session persistence, and physically removes a session's log file\n// on delete.\nimport { unlink } from 'node:fs/promises'\n\nexport const name = 'dsh-archived-sessions'\nexport const inject = ['webServer', 'workspaceRegistry', 'sessionPersistence', 'sessionQuery', 'storageDomain']\n\nconst MAX_TITLE = 80\n\nfunction json(res, value, status = 200) {\n res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })\n res.end(JSON.stringify(value))\n}\n\nasync function readJsonBody(req) {\n const chunks = []\n let total = 0\n for await (const chunk of req) {\n chunks.push(chunk)\n total += chunk.length\n if (total > 1 << 20) return null\n }\n try {\n return JSON.parse(Buffer.concat(chunks).toString('utf8'))\n } catch {\n return null\n }\n}\n\nfunction parseIds(body) {\n const raw = body && body.sessionIds\n if (!Array.isArray(raw)) return null\n const ids = []\n for (const v of raw) if (typeof v === 'string' && v) ids.push(v)\n return ids\n}\n\nfunction foldTitle(events) {\n let found = null\n let firstUser = null\n for (const ev of events) {\n if (ev.type === 'session/title' && ev.data && typeof ev.data.title === 'string' && ev.data.title.length) {\n found = ev.data.title\n }\n if (firstUser === null && ev.type === 'user/message' && ev.data && Array.isArray(ev.data.content)) {\n const txt = ev.data.content.filter((b) => b && b.type === 'text').map((b) => b.text).filter(Boolean).join(' ').trim()\n if (txt) firstUser = txt\n }\n }\n return found || firstUser || null\n}\n\nexport function apply(ctx) {\n const w = ctx.workspaceRegistry\n const sp = ctx.sessionPersistence\n const sq = ctx.sessionQuery\n const dom = () => ctx.storageDomain.get('workspace')\n\n async function archivedState() {\n const d = dom()\n if (!d) throw new Error('workspace domain is not open')\n return d.global.get()\n }\n\n async function writeArchived(nextIds) {\n const d = dom()\n if (!d) throw new Error('workspace domain is not open')\n const cur = d.global.get()\n const next = Object.assign({}, cur, { archivedSessionIds: nextIds })\n await d.global.set(next)\n // Keep the registry's in-memory cache in sync so the live sidebar refreshes.\n if (w && 'state' in w) { try { w.state = next } catch (e) { /* best-effort */ } }\n return next\n }\n\n let wsByPath = {}\n\n async function resolveOne(id) {\n let title = null, createdAt = null, cwd = null\n try {\n const o = await sq.readTitleSnapshot(id)\n if (o) {\n if (o.title && o.title.title) title = String(o.title.title)\n if (o.session) { cwd = o.session.cwd || null; createdAt = o.session.createdAt || null }\n }\n } catch (e) { /* fall back to raw log */ }\n if (!title || !cwd) {\n try {\n const r = await sp.readFrom(id, 0)\n if (r.meta) {\n if (!cwd) cwd = r.meta.cwd || null\n if (!createdAt) createdAt = r.meta.createdAt || null\n }\n if (!title && Array.isArray(r.events)) title = foldTitle(r.events)\n } catch (e2) { /* keep what we have */ }\n }\n const ws = cwd ? wsByPath[cwd] : undefined\n const workspaceGone = !!(cwd && !ws)\n const display = title ? (String(title).length > MAX_TITLE ? String(title).slice(0, MAX_TITLE) + '\u2026' : String(title)) : null\n return {\n sessionId: id,\n title: display,\n createdAt: createdAt || null,\n workspacePath: cwd || null,\n workspaceTitle: (ws && ws.title) ? ws.title : null,\n workspaceGone: workspaceGone ? true : false,\n hasWorkspace: !!cwd,\n }\n }\n\n // Restore (unarchive) one session; throws on failure.\n async function restoreOne(sid) {\n const state = await archivedState()\n const list = state.archivedSessionIds.map(String)\n if (!list.includes(sid)) return { ok: true, restored: false }\n await writeArchived(list.filter((x) => x !== sid))\n return { ok: true, restored: true }\n }\n\n // Physically delete one session log + clear archive/workspace accounting;\n // throws on failure (live sessions are rejected).\n async function deleteOne(sid) {\n const sessions = ctx.get('sessions')\n if (sessions && sessions.get(sid)) {\n throw new Error('\u8BE5\u4F1A\u8BDD\u5F53\u524D\u5904\u4E8E\u6253\u5F00\u72B6\u6001\uFF0C\u8BF7\u5148\u5207\u6362\u5230\u522B\u7684\u4F1A\u8BDD\u518D\u5220\u9664\u3002')\n }\n let removedPath = null\n try {\n const headers = await sp.list()\n const header = headers.find((h) => String(h.id) === sid)\n if (header) {\n const loc = sp.locate(header)\n if (loc && typeof loc.path === 'string') removedPath = loc.path\n } else {\n // Session not in the materialized list \u2014 use its header cwd to locate the log anyway.\n const r = await sp.readFrom(sid, 0)\n if (r && r.meta && r.meta.cwd) {\n const loc = sp.locate({ id: sid, cwd: r.meta.cwd })\n if (loc && typeof loc.path === 'string') removedPath = loc.path\n }\n }\n } catch (e) { /* best-effort */ }\n if (removedPath) {\n try {\n await unlink(removedPath)\n } catch (e) {\n if (e && e.code !== 'ENOENT') throw new Error('\u5220\u9664\u65E5\u5FD7\u6587\u4EF6\u5931\u8D25\uFF1A' + String((e && e.message) || e))\n }\n }\n try {\n for (const ent of w.list()) {\n if (ent.sessionIds.includes(sid)) { try { await ent.detachSession(sid) } catch (e) { /* ignore */ } }\n }\n } catch (e) { /* ignore */ }\n try { if (w.sessionPaths && w.sessionPaths.delete) w.sessionPaths.delete(sid) } catch (e) {}\n try { if (w.headers && w.headers.delete) w.headers.delete(sid) } catch (e) {}\n // NOTE: intentionally do NOT unarchive (keep the id in the archive set).\n // Removing it from the archive set would make DSH re-show the session in the\n // sidebar; with its workspace gone it would land in \u672A\u5206\u7EC4 until the index\n // rebuilds. Keeping it hidden keeps the deleted session out of every list\n // immediately. The stale hidden id is inert (no log -> no session resolves).\n return { ok: true, deleted: true, removedPath }\n }\n\n ctx.effect(() => {\n const disposers = []\n\n disposers.push(ctx.webServer.register({\n kind: 'exact',\n path: '/archived-sessions/list',\n handler: async (req, res) => {\n try {\n const state = await archivedState()\n const ids = state.archivedSessionIds || []\n // Only surface archived ids that still exist (materialized log or live).\n // Deleted sessions keep a hidden archive id but no log, so they drop out here.\n let materialized = new Set()\n let live = ctx.get('sessions')\n try {\n const headers = await sp.list()\n materialized = new Set(headers.map((h) => String(h.id)))\n } catch (e) { /* best-effort */ }\n const idStrs = ids.map(String).filter((id) => materialized.has(id) || (live && live.get(id)))\n wsByPath = {}\n try { for (const ent of w.list()) wsByPath[ent.path] = ent } catch (e) { wsByPath = {} }\n const items = []\n const CHUNK = 6\n for (let i = 0; i < idStrs.length; i += CHUNK) {\n const res2 = await Promise.all(idStrs.slice(i, i + CHUNK).map(resolveOne))\n items.push.apply(items, res2)\n }\n json(res, { items })\n } catch (e) {\n json(res, { error: String((e && e.message) || e) }, 500)\n }\n },\n }))\n\n disposers.push(ctx.webServer.register({\n kind: 'exact',\n path: '/archived-sessions/restore',\n handler: async (req, res) => {\n try {\n const body = await readJsonBody(req)\n const sid = body && typeof body.sessionId === 'string' ? body.sessionId : null\n if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)\n json(res, await restoreOne(sid))\n } catch (e) {\n json(res, { ok: false, error: String((e && e.message) || e) }, 500)\n }\n },\n }))\n\n disposers.push(ctx.webServer.register({\n kind: 'exact',\n path: '/archived-sessions/restore-many',\n handler: async (req, res) => {\n try {\n const body = await readJsonBody(req)\n const ids = parseIds(body)\n if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)\n const results = []\n for (const sid of ids) {\n try { results.push({ sessionId: sid, ok: true, ...(await restoreOne(sid)) }) }\n catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }\n }\n json(res, { ok: true, restored: results.filter((r) => r.ok).length, results })\n } catch (e) {\n json(res, { ok: false, error: String((e && e.message) || e) }, 500)\n }\n },\n }))\n\n disposers.push(ctx.webServer.register({\n kind: 'exact',\n path: '/archived-sessions/delete',\n handler: async (req, res) => {\n try {\n const body = await readJsonBody(req)\n const sid = body && typeof body.sessionId === 'string' ? body.sessionId : null\n if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)\n json(res, await deleteOne(sid))\n } catch (e) {\n json(res, { ok: false, error: String((e && e.message) || e) }, 500)\n }\n },\n }))\n\n disposers.push(ctx.webServer.register({\n kind: 'exact',\n path: '/archived-sessions/delete-many',\n handler: async (req, res) => {\n try {\n const body = await readJsonBody(req)\n const ids = parseIds(body)\n if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)\n const results = []\n for (const sid of ids) {\n try { results.push({ sessionId: sid, ok: true, ...(await deleteOne(sid)) }) }\n catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }\n }\n json(res, { ok: true, deleted: results.filter((r) => r.ok).length, results })\n } catch (e) {\n json(res, { ok: false, error: String((e && e.message) || e) }, 500)\n }\n },\n }))\n\n return () => { for (const d of disposers) d() }\n }, 'dsh-archived-sessions: routes')\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@tobycaimf/dsh-archived-sessions",
3
+ "description": "DSH 设置面板「归档会话」管理器:查看、恢复、彻底删除已归档的 session,带所属工作区标签与会话日期。",
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "scripts": {
13
+ "build": "node build.mjs"
14
+ },
15
+ "devDependencies": {
16
+ "esbuild": "^0.25.0"
17
+ },
18
+ "dsh": {
19
+ "bundle": {
20
+ "patch": "./cordis.patch.yml"
21
+ },
22
+ "client": {
23
+ "inject": [
24
+ "@deepseek-ai/dsh-client-runtime",
25
+ "@deepseek-ai/dsh-client-ui-slots",
26
+ "@deepseek-ai/dsh-client-ui-settings",
27
+ "@deepseek-ai/dsh-client-locale"
28
+ ],
29
+ "platform": "web"
30
+ }
31
+ },
32
+ "peerDependencies": {
33
+ "@deepseek-ai/cordis": "^4.0.1",
34
+ "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
35
+ "@deepseek-ai/dsh-workspace": "^0.1.0-rc.6",
36
+ "@deepseek-ai/dsh-session-persistence-jsonl": "^0.1.0-rc.6",
37
+ "@deepseek-ai/dsh-session-query": "^0.1.0-rc.6",
38
+ "@deepseek-ai/dsh-storage-domain": "^0.1.0-rc.6",
39
+ "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
40
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
41
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
42
+ "react": "^18.2.0"
43
+ }
44
+ }
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ // dsh-sessions-manager — session log health check.
3
+ //
4
+ // Scans every session.jsonl.zstd under ~/.dsh/sessions and verifies that each
5
+ // log's first zstd frame is exactly one session-header line. This is the
6
+ // invariant DSH's persistence layer enforces on startup
7
+ // (assertZstdHeaderFrame): when it breaks, the workspace plugin fails to load
8
+ // and the whole web profile refuses to start.
9
+ //
10
+ // Usage: node scripts/check-session-health.mjs
11
+ // Exit: 0 = all healthy, 1 = at least one damaged log
12
+
13
+ import { readFileSync } from 'node:fs'
14
+ import { execSync } from 'node:child_process'
15
+ import { homedir } from 'node:os'
16
+ import { join } from 'node:path'
17
+ import { findZstdFrameStarts, readFrame0 } from '../src/zstd-frame.js'
18
+
19
+ const root = join(homedir(), '.dsh', 'sessions')
20
+
21
+ let files
22
+ try {
23
+ files = execSync(`find "${root}" -name "session.jsonl.zstd"`, { encoding: 'utf8' })
24
+ .trim()
25
+ .split('\n')
26
+ .filter(Boolean)
27
+ } catch (_) {
28
+ console.log('未找到会话目录:', root)
29
+ process.exit(0)
30
+ }
31
+
32
+ if (files.length === 0) {
33
+ console.log('没有找到会话日志文件')
34
+ process.exit(0)
35
+ }
36
+
37
+ let damaged = 0
38
+ for (const file of files) {
39
+ try {
40
+ const buf = readFileSync(file)
41
+ const { obj, lineCount } = readFrame0(buf)
42
+ const label = file.replace(homedir(), '~')
43
+ if (obj.type !== 'session') {
44
+ console.log(`✗ ${label}`)
45
+ console.log(` frame0 type=${obj.type}(期望 session)`)
46
+ damaged++
47
+ } else if (lineCount !== 1) {
48
+ console.log(`✗ ${label}`)
49
+ console.log(` frame0 有 ${lineCount} 行(期望 1)`)
50
+ damaged++
51
+ }
52
+ } catch (e) {
53
+ console.log(`✗ ${file.replace(homedir(), '~')}`)
54
+ console.log(` ${e.message}`)
55
+ damaged++
56
+ }
57
+ }
58
+
59
+ console.log('')
60
+ if (damaged === 0) {
61
+ console.log(`✓ 全部正常:${files.length} 个会话日志,frame0 均为单行 session header`)
62
+ process.exit(0)
63
+ }
64
+ console.log(`✗ ${damaged}/${files.length} 个会话日志损坏`)
65
+ console.log(' 损坏的日志会让 dsm web 无法启动。备份后删除对应文件即可恢复。')
66
+ process.exit(1)
@@ -0,0 +1,204 @@
1
+ /**
2
+ * dsh-archived-sessions — browser half: renders the "归档会话" Settings
3
+ * section (settings.section list slot). It talks to the host half's
4
+ * /archived-sessions/* JSON routes (list / restore / delete) by fetch,
5
+ * showing title, session date, and a workspace tag (handles deleted
6
+ * workspaces). All DOM/runtime wiring failures are logged, never thrown — a
7
+ * thrown plugin apply takes down the whole web-shell boot.
8
+ *
9
+ * @module dsh-archived-sessions/client
10
+ */
11
+
12
+ import React, { useEffect, useRef, useState } from 'react'
13
+
14
+ export const inject = ['slots']
15
+
16
+ const CSS = `
17
+ .archv{display:flex;flex-direction:column;gap:4px;max-width:800px;padding:8px 2px 28px}
18
+ .archv-head{display:flex;align-items:center;gap:10px;margin:0 0 2px}
19
+ .archv-title{font-size:16px;font-weight:650;color:var(--dsw-alias-label-primary);letter-spacing:-0.01em;margin:0}
20
+ .archv-count{font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-fill-subtle);border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:1px 8px;flex:none}
21
+ .archv-sub{font-size:12px;line-height:1.55;color:var(--dsw-alias-label-tertiary);margin:0 0 12px;max-width:64ch}
22
+ .archv-err{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:9px 12px;border:1px solid color-mix(in srgb,var(--dsw-alias-state-error-primary) 40%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 8%,transparent);border-radius:10px;color:var(--dsw-alias-state-error-primary);font-size:12px;margin-bottom:10px}
23
+ .archv-errretry{appearance:none;border:1px solid color-mix(in srgb,var(--dsw-alias-state-error-primary) 45%,transparent);background:transparent;color:inherit;border-radius:8px;padding:4px 10px;font-size:11px;cursor:pointer;flex:none}
24
+ .archv-errretry:focus-visible,.archv-btn:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px}
25
+ .archv-list{display:flex;flex-direction:column;gap:8px}
26
+ .archv-card{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:12px 14px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-fill-elevated);transition:border-color .15s ease,background-color .15s ease}
27
+ .archv-card:hover{border-color:var(--dsw-alias-border-l4)}
28
+ .archv-main{min-width:0;display:flex;flex-direction:column;gap:4px}
29
+ .archv-name{font-size:13px;font-weight:550;color:var(--dsw-alias-label-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
30
+ .archv-meta{display:flex;align-items:center;gap:7px;flex-wrap:wrap;min-width:0}
31
+ .archv-wtag{display:inline-flex;align-items:center;gap:4px;max-width:100%;font-size:11px;font-weight:500;line-height:1;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-fill-subtle);border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:3px 8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
32
+ .archv-wgone{border-style:dashed;color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 55%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 7%,transparent)}
33
+ .archv-date{font-size:11px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;flex:none}
34
+ .archv-id{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10.5px;color:var(--dsw-alias-label-tertiary);flex:none}
35
+ .archv-dot{color:var(--dsw-alias-border-l3);flex:none}
36
+ .archv-actions{display:flex;gap:8px;flex:none}
37
+ .archv-btn{appearance:none;min-height:32px;padding:0 12px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-fill-subtle);color:var(--dsw-alias-label-secondary);border-radius:9px;font-size:12px;font-weight:500;cursor:pointer;display:inline-flex;align-items:center;gap:6px;transition:background-color .15s ease,border-color .15s ease,color .15s ease}
38
+ .archv-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
39
+ .archv-btn:disabled{opacity:.5;cursor:default}
40
+ .archv-restore{min-width:62px;justify-content:center}
41
+ .archv-del{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 45%,transparent)}
42
+ .archv-del:hover:not(:disabled){background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent);color:var(--dsw-alias-state-error-primary)}
43
+ .archv-empty{display:flex;align-items:center;gap:10px;padding:20px 14px;border:1px dashed var(--dsw-alias-border-l3);border-radius:12px;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}
44
+ .archv-skel{display:flex;flex-direction:column;gap:8px}
45
+ .archv-skel-card{height:58px;border-radius:12px;background:var(--dsw-alias-fill-subtle);position:relative;overflow:hidden}
46
+ .archv-skel-card::after{content:'';position:absolute;inset:0;transform:translateX(-100%);background:linear-gradient(90deg,transparent,color-mix(in srgb,var(--dsw-alias-fill-elevated) 75%,transparent),transparent);animation:archv-shimmer 1.4s infinite}
47
+ .archv-status{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:60;background:var(--dsw-alias-fill-elevated);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);padding:9px 16px;border-radius:999px;font-size:12px;box-shadow:0 8px 24px rgb(0 0 0/.18);display:flex;align-items:center;gap:8px;animation:archv-pop .18s ease-out;max-width:min(90vw,420px)}
48
+ .archv-spin{width:12px;height:12px;border:2px solid color-mix(in srgb,var(--dsw-alias-label-secondary) 35%,transparent);border-top-color:var(--dsw-alias-label-secondary);border-radius:50%;animation:archv-rot .8s linear infinite;flex:none}
49
+ @keyframes archv-shimmer{100%{transform:translateX(100%)}}
50
+ @keyframes archv-rot{to{transform:rotate(360deg)}}
51
+ @keyframes archv-pop{from{opacity:0;transform:translateX(-50%) translateY(10px)}}
52
+ @media (prefers-reduced-motion:reduce){.archv-skel-card::after{animation:none}.archv-card,.archv-btn{transition:none}.archv-status,.archv-spin{animation:none}}
53
+ @media (max-width:560px){.archv-card{flex-direction:column;align-items:stretch;gap:10px}.archv-actions{justify-content:flex-end}}
54
+ `
55
+
56
+ function fmtDate(iso) {
57
+ if (!iso) return null
58
+ const d = new Date(iso)
59
+ if (isNaN(d.getTime())) return null
60
+ try {
61
+ return new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(d)
62
+ } catch {
63
+ return String(iso)
64
+ }
65
+ }
66
+
67
+ function pathName(p) {
68
+ if (!p) return null
69
+ const parts = String(p).replace(/\\+$/, '').split(/[/\\]/)
70
+ return parts[parts.length - 1] || p
71
+ }
72
+
73
+ async function postJSON(path, body) {
74
+ const res = await fetch(path, {
75
+ method: 'POST',
76
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
77
+ body: JSON.stringify(body || {}),
78
+ })
79
+ const data = await res.json().catch(() => null)
80
+ if (!res.ok || !data) throw new Error((data && (data.error || data.message)) || `request failed (${res.status})`)
81
+ return data
82
+ }
83
+
84
+ function ArchivedPanel() {
85
+ const [items, setItems] = useState(null)
86
+ const [busy, setBusy] = useState(null)
87
+ const [confirmId, setConfirmId] = useState(null)
88
+ const [error, setError] = useState(null)
89
+ const [toast, setToast] = useState(null)
90
+ const timer = useRef(null)
91
+
92
+ const showToast = (msg) => {
93
+ if (timer.current) clearTimeout(timer.current)
94
+ setToast(msg)
95
+ timer.current = setTimeout(() => setToast(null), 2400)
96
+ }
97
+
98
+ const refresh = () => {
99
+ setError(null)
100
+ postJSON('/archived-sessions/list', {})
101
+ .then((r) => setItems(r.items || []))
102
+ .catch((e) => setError(String((e && e.message) || e)))
103
+ }
104
+
105
+ useEffect(() => {
106
+ refresh()
107
+ return () => { if (timer.current) clearTimeout(timer.current) }
108
+ // eslint-disable-next-line react-hooks/exhaustive-deps
109
+ }, [])
110
+
111
+ const act = (action, it) => {
112
+ if (busy) return
113
+ if (action === 'delete') {
114
+ if (confirmId !== it.sessionId) { setConfirmId(it.sessionId); return }
115
+ setConfirmId(null)
116
+ } else { setConfirmId(null); setError(null) }
117
+ setBusy(it.sessionId)
118
+ postJSON('/archived-sessions/' + action, { sessionId: it.sessionId })
119
+ .then(() => {
120
+ setBusy(null)
121
+ showToast(action === 'restore' ? `已恢复「${it.title || it.sessionId}」` : `已删除 ${it.title || it.sessionId}`)
122
+ refresh()
123
+ })
124
+ .catch((e) => { setBusy(null); setConfirmId(null); setError(String((e && e.message) || e)) })
125
+ }
126
+
127
+ const workspaceTag = (it) => {
128
+ if (it.hasWorkspace && it.workspaceGone) {
129
+ return (
130
+ <span className="archv-wtag archv-wgone" title={(it.workspacePath || '') + '(原工作区已删除)'}>
131
+ 工作区已删 · {pathName(it.workspacePath) || '?'}
132
+ </span>
133
+ )
134
+ }
135
+ const wName = it.workspaceTitle || pathName(it.workspacePath)
136
+ if (wName) return <span className="archv-wtag" title={it.workspacePath || ''}>{wName}</span>
137
+ return null
138
+ }
139
+
140
+ return (
141
+ <div className="archv" role="region" aria-label="归档会话">
142
+ <style>{CSS}</style>
143
+ <div className="archv-head">
144
+ <h2 className="archv-title">归档会话</h2>
145
+ {items !== null && <span className="archv-count" aria-label={`${items.length} 个归档会话`}>{items.length}</span>}
146
+ </div>
147
+ <p className="archv-sub">
148
+ 被归档(从侧栏隐藏)的会话都在这里。恢复会把会话放回原分组;删除会彻底移除日志文件,不可恢复。
149
+ </p>
150
+ {error && (
151
+ <div className="archv-err" role="alert">
152
+ <span>{error}</span>
153
+ <button type="button" className="archv-errretry" onClick={refresh}>重试</button>
154
+ </div>
155
+ )}
156
+ {items === null ? (
157
+ <div className="archv-skel" aria-label="加载中">
158
+ {[0, 1, 2].map((i) => <div key={i} className="archv-skel-card" />)}
159
+ </div>
160
+ ) : items.length === 0 ? (
161
+ <div className="archv-empty">目前没有归档会话。在侧栏任一会话的菜单里选择「归档」即可收纳进来。</div>
162
+ ) : (
163
+ <div className="archv-list" role="list">
164
+ {items.map((it) => {
165
+ const isConfirm = confirmId === it.sessionId
166
+ const date = fmtDate(it.createdAt)
167
+ const wtag = workspaceTag(it)
168
+ return (
169
+ <div key={it.sessionId} className="archv-card" role="listitem">
170
+ <div className="archv-main">
171
+ <div className="archv-name" title={it.title || ''}>{it.title || '(无标题)'}</div>
172
+ <div className="archv-meta">
173
+ {date && <span className="archv-date">{date}</span>}
174
+ {date && wtag && <span className="archv-dot">·</span>}
175
+ {wtag}
176
+ <span className="archv-id">{it.sessionId}</span>
177
+ </div>
178
+ </div>
179
+ <div className="archv-actions">
180
+ <button type="button" className="archv-btn archv-restore" disabled={busy !== null} aria-label={'恢复 ' + (it.title || it.sessionId)} onClick={() => act('restore', it)}>
181
+ {busy === it.sessionId && <span className="archv-spin" aria-hidden="true" />}恢复
182
+ </button>
183
+ <button type="button" className="archv-btn archv-del" disabled={busy !== null} aria-label={(isConfirm ? '确认删除 ' : '删除 ') + (it.title || it.sessionId)} onClick={() => act('delete', it)}>
184
+ {isConfirm ? '确认删除?' : '删除'}
185
+ </button>
186
+ </div>
187
+ </div>
188
+ )
189
+ })}
190
+ </div>
191
+ )}
192
+ {toast && <div className="archv-status" role="status">{toast}</div>}
193
+ </div>
194
+ )
195
+ }
196
+
197
+ export function apply(ctx) {
198
+ ctx.slots.inject('settings.section', () =>
199
+ ctx.slots.register(
200
+ { name: 'settings.section', id: 'archived-sessions', order: 90, label: '归档会话' },
201
+ (props) => <ArchivedPanel {...props} />,
202
+ ),
203
+ )
204
+ }