@sunshaobei/dsh-session-manager 0.0.0-stage → 0.6.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/LICENSE +21 -0
- package/README.md +115 -2
- package/README.zh.md +115 -0
- package/client.css +1613 -0
- package/client.js +2189 -0
- package/cordis.patch.yml +16 -0
- package/index.js +78 -0
- package/package.json +61 -4
- package/packages/session-trash-host/lib/index.js +1904 -0
- package/packages/session-trash-host/lib/types/index.d.ts +32 -0
- package/packages/session-trash-host/package.json +26 -0
|
@@ -0,0 +1,1904 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Trash Host plugin
|
|
3
|
+
*
|
|
4
|
+
* Extends the running DSH instance with:
|
|
5
|
+
* - SessionPersistence.delete(id) — physical log deletion
|
|
6
|
+
* - SessionProjectionCache.remove(id) — cache record cleanup
|
|
7
|
+
* - WorkspaceRegistry.unarchiveSession(sessionId) — restore from archive
|
|
8
|
+
* - WorkspaceRegistry.permanentlyDeleteSession(sessionId) — full cleanup
|
|
9
|
+
* - WorkspaceRegistry.emptyArchivedSessions() — batch permanent delete
|
|
10
|
+
* - WorkspaceRegistry.listArchivedSessions() — recycle-bin listing
|
|
11
|
+
* - Typert RPC methods: workspace.unarchiveSession,
|
|
12
|
+
* workspace.permanentlyDeleteSession, workspace.emptyArchivedSessions,
|
|
13
|
+
* workspace.listArchivedSessions
|
|
14
|
+
*
|
|
15
|
+
* This plugin patches the existing services at init time. It is designed
|
|
16
|
+
* to be loaded via cordis.patch.yml and works with the compiled DSH packages.
|
|
17
|
+
*/
|
|
18
|
+
import { rm, readdir, rmdir, stat, readFile, open as openFile } from 'node:fs/promises';
|
|
19
|
+
import { dirname, join } from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
// Robust Service base class fallback for standalone testing environment
|
|
23
|
+
let ServiceClass;
|
|
24
|
+
try {
|
|
25
|
+
const cordis = await import('@deepseek-ai/cordis');
|
|
26
|
+
ServiceClass = cordis.Service;
|
|
27
|
+
} catch {
|
|
28
|
+
ServiceClass = class Service {
|
|
29
|
+
static init = Symbol.for('cordis.init');
|
|
30
|
+
constructor(ctx, name) {
|
|
31
|
+
this.ctx = ctx;
|
|
32
|
+
this.name = name;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* ------------------------------------------------------------------ */
|
|
38
|
+
/* Helper: Broadcast session state change */
|
|
39
|
+
/* ------------------------------------------------------------------ */
|
|
40
|
+
|
|
41
|
+
function notifyArchivedSessionsChanged(ctx, archivedSessionIds) {
|
|
42
|
+
try {
|
|
43
|
+
ctx.emit?.('workspace/archived-sessions-changed', { archivedSessionIds });
|
|
44
|
+
// The browser-side refresh rides the STOCK workspace broadcast: the
|
|
45
|
+
// in-box api-proxy detects the archived-set change and pushes
|
|
46
|
+
// `host/archived-sessions-changed` over the connection itself.
|
|
47
|
+
} catch (error) {
|
|
48
|
+
ctx.logger?.warn?.('session-trash-host: failed to broadcast archived sessions change: %s', error.message);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* ------------------------------------------------------------------ */
|
|
53
|
+
/* Dual-cohort helpers (DSH 0.1.1-rc.2 / 0.1.2-rc.1 / 0.1.5-rc.1) */
|
|
54
|
+
/* ------------------------------------------------------------------ */
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Normalize one item from persistence.list() across host cohorts.
|
|
58
|
+
* 0.1.1/0.1.2 list() returns SessionHeader[] (the header itself); 0.1.5
|
|
59
|
+
* returns SessionPersistenceSnapshot[] ({ header, revision, ... }). Both
|
|
60
|
+
* expose the durable SessionHeader, just at different depths.
|
|
61
|
+
* @param {object|undefined} item
|
|
62
|
+
* @returns {object|undefined} the SessionHeader (with .id/.title/.cwd).
|
|
63
|
+
*/
|
|
64
|
+
function normalizeStoredHeader(item) {
|
|
65
|
+
if (!item) return undefined;
|
|
66
|
+
if (typeof item.id === 'string') return item; // older cohort: raw header
|
|
67
|
+
if (item.header && typeof item.header.id === 'string') return item.header; // 0.1.5 snapshot
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Normalize one persistence.list() item to `{ header, sizeBytes }` across
|
|
73
|
+
* cohorts. `sizeBytes` is a free field of the 0.1.5+ listing snapshot;
|
|
74
|
+
* normalizeStoredHeader unwrapped it away, which is why the recycle bin used
|
|
75
|
+
* to `stat()` every log just to recover a number the header listing already
|
|
76
|
+
* carried.
|
|
77
|
+
* @param {object|undefined} item
|
|
78
|
+
* @returns {{header: object, sizeBytes: number|undefined}|undefined}
|
|
79
|
+
*/
|
|
80
|
+
function normalizeStoredSnapshot(item) {
|
|
81
|
+
if (!item) return undefined;
|
|
82
|
+
if (item.header && typeof item.header.id === 'string') {
|
|
83
|
+
const size = item.sizeBytes;
|
|
84
|
+
return { header: item.header, sizeBytes: typeof size === 'number' ? size : undefined };
|
|
85
|
+
}
|
|
86
|
+
if (typeof item.id === 'string') return { header: item, sizeBytes: undefined };
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Read the listing facts the recycle bin needs straight out of the Session
|
|
92
|
+
* projection cache — DSH's documented ZERO-I/O listing read
|
|
93
|
+
* (`SessionProjectionCache.cachedSnapshot`, "the zero-I/O listing read").
|
|
94
|
+
* The stock session list (`api-session-controller.projectionsFor`) reads the
|
|
95
|
+
* same faces; this is not a private API.
|
|
96
|
+
*
|
|
97
|
+
* WHY THIS REPLACES THE LOG READ (v0.4.4):
|
|
98
|
+
*
|
|
99
|
+
* Until v0.4.3 the list derived `turnCount` and a fallback title by opening
|
|
100
|
+
* every archived session's log and decoding its whole JSONL/Zstd frame
|
|
101
|
+
* chain (`readSessionEvents`). Measured on the reference store (569 MB, 474
|
|
102
|
+
* sessions, p50 416 KB compressed, max 30 MB): a 6.7 MB log (3728 frames,
|
|
103
|
+
* 21.4 MB plaintext, 6493 events) cost ~198 ms of decompress+parse, and the
|
|
104
|
+
* median band 10-19 ms — per session, per request, on the web server's own
|
|
105
|
+
* thread. Decoding the first frame alone (the header) measured ~1 ms, which
|
|
106
|
+
* is what the stock listing pays.
|
|
107
|
+
*
|
|
108
|
+
* The header carries NO title — SessionHeader has had no `title` field since
|
|
109
|
+
* 0.2 — so the projection cache is also the only correct title source: the
|
|
110
|
+
* same `title` unit DSH renders in the sidebar.
|
|
111
|
+
*
|
|
112
|
+
* TWO FACES, AND WHY BOTH ARE NEEDED:
|
|
113
|
+
*
|
|
114
|
+
* A checkpoint is bound to the Session FORMAT GENERATION that folded it.
|
|
115
|
+
* `cachedSnapshot` accepts only a record at the host's CURRENT generation
|
|
116
|
+
* (`currentLifecycleMatches`: stored formatVersion === header.version), while
|
|
117
|
+
* `cachedPredecessorTitle` accepts a strictly older one. That is not
|
|
118
|
+
* academic: on the reference store the host moved from format 3 to 4 and the
|
|
119
|
+
* cache still held 481 v3 records plus 39 with no generation, against only 4
|
|
120
|
+
* v4 ones. Reading `cachedSnapshot` alone would have rejected ~99% of the
|
|
121
|
+
* corpus and silently blanked nearly every title. The predecessor face is
|
|
122
|
+
* the one DSH ships for exactly this upgrade window.
|
|
123
|
+
*
|
|
124
|
+
* It exposes the TITLE ONLY, by design — "format normalization can change
|
|
125
|
+
* their current meaning" is the cache's own note about other predecessor
|
|
126
|
+
* projections — so a pre-upgrade `turns` count is deliberately NOT readable.
|
|
127
|
+
* A missing `turns` therefore means UNKNOWN, never zero: callers must not
|
|
128
|
+
* render it as 0.
|
|
129
|
+
*
|
|
130
|
+
* @param {object|undefined} cache ctx.sessionProjectionCache
|
|
131
|
+
* @param {object} header stored SessionHeader (the lifecycle identity witness)
|
|
132
|
+
* @returns {{title: string|undefined, turns: number|undefined}}
|
|
133
|
+
*/
|
|
134
|
+
function readCachedSessionFacts(cache, header) {
|
|
135
|
+
const facts = { title: undefined, turns: undefined };
|
|
136
|
+
if (!header || !cache) return facts;
|
|
137
|
+
|
|
138
|
+
/** Fold one viewed block in, never overwriting an already-known fact. */
|
|
139
|
+
const take = (block) => {
|
|
140
|
+
const values = block?.values;
|
|
141
|
+
if (!values || typeof values !== 'object') return;
|
|
142
|
+
if (facts.title === undefined) {
|
|
143
|
+
const title = values.title;
|
|
144
|
+
if (typeof title === 'string' && title.length > 0) facts.title = title;
|
|
145
|
+
}
|
|
146
|
+
if (facts.turns === undefined) {
|
|
147
|
+
const turns = values.sessionStats?.turns;
|
|
148
|
+
if (typeof turns === 'number' && Number.isFinite(turns)) facts.turns = turns;
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
if (typeof cache.cachedSnapshot === 'function') {
|
|
154
|
+
take(cache.cachedSnapshot(header, ['title', 'sessionStats']));
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
// A cache miss or contract drift must never fail the list.
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
if (typeof cache.cachedPredecessorTitle === 'function') {
|
|
161
|
+
take(cache.cachedPredecessorTitle(header));
|
|
162
|
+
}
|
|
163
|
+
} catch {
|
|
164
|
+
// Same: the row renders without the cached facts.
|
|
165
|
+
}
|
|
166
|
+
return facts;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Resolve one session's physical log path across host cohorts.
|
|
171
|
+
*
|
|
172
|
+
* Priority:
|
|
173
|
+
* 1. persistence.findLog(id) — older host service method (0.1.1/0.1.2:
|
|
174
|
+
* a path string; 0.1.5: a generation
|
|
175
|
+
* descriptor — both normalized here).
|
|
176
|
+
* 2. persistence.locate({id}) — older service API; still present on the
|
|
177
|
+
* jsonl backend in 0.1.5 ({kind, path}).
|
|
178
|
+
* 3. physical directory scan — the stable on-disk contract across every
|
|
179
|
+
* 0.1.x host: ~/.dsh/sessions/<project>/<sessionId>/session*.jsonl*.
|
|
180
|
+
* This is host-API-independent and also the
|
|
181
|
+
* only mechanism that works when the service
|
|
182
|
+
* stops forwarding locate (0.1.5).
|
|
183
|
+
* @param {object|undefined} persistence ctx.sessionPersistence
|
|
184
|
+
* @param {string} sessionId
|
|
185
|
+
* @returns {Promise<string|undefined>} resolved log path, or undefined.
|
|
186
|
+
*/
|
|
187
|
+
async function resolveSessionLogPath(persistence, sessionId) {
|
|
188
|
+
// findLog is the AUTHORITATIVE resolver on older hosts: a returned path —
|
|
189
|
+
// even when the file is already gone — means this session has a log
|
|
190
|
+
// location (permanentlyDeleteSession then keeps the workspace slot); a
|
|
191
|
+
// missing answer means the session is a ghost with no log known at all.
|
|
192
|
+
// Its answer is therefore returned verbatim. locate() is a CANDIDATE path
|
|
193
|
+
// computation that never checks existence, so a located candidate counts
|
|
194
|
+
// only when the file is actually there. The physical scan returns only
|
|
195
|
+
// files that exist.
|
|
196
|
+
try {
|
|
197
|
+
if (typeof persistence?.findLog === 'function') {
|
|
198
|
+
const found = await persistence.findLog(sessionId);
|
|
199
|
+
// 0.1.1/0.1.2 return the log path directly; 0.1.5 returns a generation
|
|
200
|
+
// descriptor ({ sourcePath, sourceVersion, currentPath }) — verified
|
|
201
|
+
// against the real 0.1.5 jsonl backend in the sandbox harness.
|
|
202
|
+
const resolved = typeof found === 'string'
|
|
203
|
+
? found
|
|
204
|
+
: (found?.currentPath ?? found?.sourcePath);
|
|
205
|
+
if (typeof resolved === 'string' && resolved) return resolved;
|
|
206
|
+
}
|
|
207
|
+
} catch { /* fall through */ }
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
if (typeof persistence?.locate === 'function') {
|
|
211
|
+
const loc = persistence.locate({ id: sessionId });
|
|
212
|
+
const candidate = loc?.path ?? (typeof loc === 'string' ? loc : undefined);
|
|
213
|
+
if (candidate && await fileExists(candidate)) return candidate;
|
|
214
|
+
}
|
|
215
|
+
} catch { /* fall through */ }
|
|
216
|
+
|
|
217
|
+
return scanSessionLogPath(sessionId);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** True when the path names a regular file on disk. */
|
|
221
|
+
async function fileExists(path) {
|
|
222
|
+
if (!path) return false;
|
|
223
|
+
try {
|
|
224
|
+
return (await stat(path)).isFile();
|
|
225
|
+
} catch {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Physical directory scan for a session log file. Layout (all 0.1.x hosts):
|
|
232
|
+
* ~/.dsh/sessions/<workspace-path-encoded>/<sessionId>/session.jsonl
|
|
233
|
+
* ~/.dsh/sessions/<workspace-path-encoded>/<sessionId>/session.v{1..3}.jsonl
|
|
234
|
+
* plus the optional .zstd compression suffix. Recognises both the legacy
|
|
235
|
+
* plain name and the 0.1.5 versioned generations.
|
|
236
|
+
* @param {string} sessionId
|
|
237
|
+
* @returns {Promise<string|undefined>}
|
|
238
|
+
*/
|
|
239
|
+
async function scanSessionLogPath(sessionId) {
|
|
240
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
241
|
+
if (!home) return undefined;
|
|
242
|
+
const root = join(home, '.dsh', 'sessions');
|
|
243
|
+
try {
|
|
244
|
+
const projects = await readdir(root);
|
|
245
|
+
for (const project of projects) {
|
|
246
|
+
const sessionDir = join(root, project, sessionId);
|
|
247
|
+
const names = await readdir(sessionDir).catch(() => []);
|
|
248
|
+
const logName = names.find((n) => /^session(?:.v\d+)?\.jsonl(?:\.zstd)?$/.test(n));
|
|
249
|
+
if (logName) return join(sessionDir, logName);
|
|
250
|
+
}
|
|
251
|
+
} catch { /* best effort */ }
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Session ids whose event read failed and was reported once, so a broken
|
|
257
|
+
* artifact doesn't turn every recycle-bin refresh into a log flood.
|
|
258
|
+
*/
|
|
259
|
+
const readFailureReported = new Set();
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Read one stored session's events across host cohorts.
|
|
263
|
+
* 1. persistence.inspect(id) — older host (0.1.1/0.1.2),
|
|
264
|
+
* returns { events }.
|
|
265
|
+
* 2. persistence.open(id, 'read') → handle.read() → { events } — 0.1.5
|
|
266
|
+
* handle API.
|
|
267
|
+
*
|
|
268
|
+
* WHY THE ACCESS ARGUMENT IS MANDATORY (v0.4.1):
|
|
269
|
+
*
|
|
270
|
+
* `SessionPersistence.open(id, access, options)` does NOT default to read.
|
|
271
|
+
* The 0.1.5 jsonl backend branches on `access === "read"`; anything else —
|
|
272
|
+
* including `undefined` — falls through to the single-writer path
|
|
273
|
+
* (`tracker.claimWrite(id)` + a cross-process lease). Reading a session the
|
|
274
|
+
* host itself still has open (the normal case for the conversation the user
|
|
275
|
+
* just deleted, and for any session whose agent is idle but loaded) then
|
|
276
|
+
* throws SessionAlreadyOwnedError, and a session with no durable artifact
|
|
277
|
+
* yet throws NotFound. Both were swallowed below, so `listArchivedSessions`
|
|
278
|
+
* reported `turnCount: 0` and the preview route returned `messages: []`
|
|
279
|
+
* for exactly the sessions that were being viewed — while untouched
|
|
280
|
+
* sessions listed fine. A successful write open would also have claimed
|
|
281
|
+
* write ownership and taken an exclusive lease just to read.
|
|
282
|
+
*
|
|
283
|
+
* The older cohort has no `open()` at all (it is `inspect`-only), so the
|
|
284
|
+
* extra argument is inert there.
|
|
285
|
+
*
|
|
286
|
+
* @param {object|undefined} persistence ctx.sessionPersistence
|
|
287
|
+
* @param {string} sessionId
|
|
288
|
+
* @returns {Promise<Array|undefined>} the event array, or undefined.
|
|
289
|
+
*/
|
|
290
|
+
async function readSessionEvents(persistence, sessionId) {
|
|
291
|
+
let failure;
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
if (typeof persistence?.inspect === 'function') {
|
|
295
|
+
const stored = await persistence.inspect(sessionId);
|
|
296
|
+
if (stored && Array.isArray(stored.events)) return stored.events;
|
|
297
|
+
}
|
|
298
|
+
} catch (error) {
|
|
299
|
+
failure = error;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
if (typeof persistence?.open === 'function') {
|
|
304
|
+
const handle = await persistence.open(sessionId, 'read');
|
|
305
|
+
try {
|
|
306
|
+
const result = await handle.read();
|
|
307
|
+
if (result && Array.isArray(result.events)) return result.events;
|
|
308
|
+
} finally {
|
|
309
|
+
if (typeof handle?.close === 'function') await handle.close().catch(() => {});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
} catch (error) {
|
|
313
|
+
failure = error;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Best effort: the caller renders a 0-turn row instead of failing the list.
|
|
317
|
+
// Report the underlying reason ONCE per session so a future contract drift
|
|
318
|
+
// is diagnosable instead of showing up as a silently empty recycle bin.
|
|
319
|
+
if (failure && !readFailureReported.has(sessionId) && readFailureReported.size < 200) {
|
|
320
|
+
readFailureReported.add(sessionId);
|
|
321
|
+
console?.warn?.(`session-trash-host: could not read events for "${sessionId}": ${failure.message ?? failure}`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return undefined;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/* ------------------------------------------------------------------ */
|
|
328
|
+
/* Patch: SessionPersistence.delete(id) */
|
|
329
|
+
/* Adds physical deletion and memory index cleanup. */
|
|
330
|
+
/* ------------------------------------------------------------------ */
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Marker installed on every service method this bundle patches, carrying the
|
|
334
|
+
* owning install's token. cordis unloads a plugin fiber WITHOUT unwinding
|
|
335
|
+
* methods previously patched onto shared service objects, so a later instance
|
|
336
|
+
* must be able to (a) recognise a patch left behind by an earlier fiber,
|
|
337
|
+
* (b) replace it with one closing over the live context, and (c) on unload
|
|
338
|
+
* remove only its own methods — never a newer instance's.
|
|
339
|
+
*/
|
|
340
|
+
const TRASH_PATCH = Symbol('dsh-session-trash.patch');
|
|
341
|
+
/** Kept on our persistence.delete wrapper: the pristine function to restore. */
|
|
342
|
+
const TRASH_PATCH_ORIGINAL = Symbol('dsh-session-trash.patchOriginal');
|
|
343
|
+
/** v0.3.1's untagged wrapper marker — still detected so its (context-free,
|
|
344
|
+
* fully functional) wrapper is kept instead of nested. */
|
|
345
|
+
const PERSISTENCE_DELETE_PATCHED = Symbol('dsh-session-trash.persistenceDeletePatched');
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Patch SessionPersistence to add a durable `delete` method.
|
|
349
|
+
* @param {import('@deepseek-ai/dsh-session-persistence').SessionPersistence} persistence
|
|
350
|
+
* @returns a disposer restoring the pristine delete, or null when an existing
|
|
351
|
+
* wrapper (ours or ≤v0.3.1's, both context-free) was kept as-is.
|
|
352
|
+
*/
|
|
353
|
+
function patchSessionPersistenceDelete(persistence) {
|
|
354
|
+
// A wrapper is already installed — by this version, a newer one, or a ≤v0.3.1
|
|
355
|
+
// fiber that leaked it (PERSISTENCE_DELETE_PATCHED, kept for that detection).
|
|
356
|
+
// The wrapper touches no context, so the stale one keeps working; wrapping it
|
|
357
|
+
// again would only nest duplicate physical deletions.
|
|
358
|
+
if (persistence.delete?.[TRASH_PATCH] || persistence.delete?.[PERSISTENCE_DELETE_PATCHED]) return null;
|
|
359
|
+
const originalDelete = persistence.delete;
|
|
360
|
+
const token = Symbol('dsh-session-trash.deletePatch');
|
|
361
|
+
persistence.delete = async function deleteSession(id) {
|
|
362
|
+
if (typeof originalDelete === 'function') {
|
|
363
|
+
try {
|
|
364
|
+
await originalDelete.call(this, id);
|
|
365
|
+
} catch { /* best effort */ }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
try {
|
|
369
|
+
const logPath = await resolveSessionLogPath(this, id);
|
|
370
|
+
if (logPath) {
|
|
371
|
+
const sessionDir = dirname(logPath);
|
|
372
|
+
await rm(sessionDir, { recursive: true, force: true });
|
|
373
|
+
const projectDir = dirname(sessionDir);
|
|
374
|
+
try {
|
|
375
|
+
const entries = await readdir(projectDir);
|
|
376
|
+
if (entries.length === 0) await rmdir(projectDir);
|
|
377
|
+
} catch { /* best effort */ }
|
|
378
|
+
}
|
|
379
|
+
} catch (error) {
|
|
380
|
+
console?.warn?.('session-trash-host: physical log delete failed: %s', error.message);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Clear internal memory maps or caches if present
|
|
384
|
+
if (this.cache && typeof this.cache.delete === 'function') this.cache.delete(id);
|
|
385
|
+
if (this.byId && typeof this.byId.delete === 'function') this.byId.delete(id);
|
|
386
|
+
if (this._headers && typeof this._headers.delete === 'function') this._headers.delete(id);
|
|
387
|
+
};
|
|
388
|
+
Object.defineProperty(persistence.delete, TRASH_PATCH, { value: token });
|
|
389
|
+
Object.defineProperty(persistence.delete, TRASH_PATCH_ORIGINAL, { value: originalDelete });
|
|
390
|
+
// Restore the pristine delete on fiber unload — unless a newer install has
|
|
391
|
+
// since replaced the wrapper (token mismatch).
|
|
392
|
+
return () => {
|
|
393
|
+
if (persistence.delete?.[TRASH_PATCH] !== token) return;
|
|
394
|
+
if (originalDelete === undefined) delete persistence.delete;
|
|
395
|
+
else persistence.delete = originalDelete;
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/* ------------------------------------------------------------------ */
|
|
400
|
+
/* Patch: SessionProjectionCache.remove(id) */
|
|
401
|
+
/* Adds fail-soft cache record removal. */
|
|
402
|
+
/* ------------------------------------------------------------------ */
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Patch the SessionProjectionCache to add a `remove` method.
|
|
406
|
+
* @param {import('@deepseek-ai/dsh-session-projection-cache').SessionProjectionCache} cache
|
|
407
|
+
* @returns a disposer removing our method, or null when an existing remove
|
|
408
|
+
* (foreign, or a context-free ≤v0.3.1 leftover doing the same job) was kept.
|
|
409
|
+
*/
|
|
410
|
+
function patchProjectionCacheRemove(cache) {
|
|
411
|
+
const existing = cache.remove;
|
|
412
|
+
if (typeof existing === 'function' && !existing[TRASH_PATCH]) return null;
|
|
413
|
+
const token = Symbol('dsh-session-trash.cachePatch');
|
|
414
|
+
/**
|
|
415
|
+
* Remove one session's cached checkpoint record durably (fail-soft).
|
|
416
|
+
* @param {import('@deepseek-ai/dsh-session').SessionId} id
|
|
417
|
+
* @returns {Promise<void>}
|
|
418
|
+
*/
|
|
419
|
+
cache.remove = async function remove(id) {
|
|
420
|
+
const table = this.requireTable?.();
|
|
421
|
+
if (table) {
|
|
422
|
+
try {
|
|
423
|
+
await table.delete(id);
|
|
424
|
+
} catch (error) {
|
|
425
|
+
console?.warn?.('session-trash-host: remove(%s) projection cache failed: %s', id, error.message);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (typeof this.markClean === 'function') {
|
|
430
|
+
this.markClean(id);
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
Object.defineProperty(cache.remove, TRASH_PATCH, { value: token });
|
|
434
|
+
// Remove our method on fiber unload — unless a newer install replaced it.
|
|
435
|
+
return () => {
|
|
436
|
+
if (cache.remove?.[TRASH_PATCH] === token) delete cache.remove;
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/* ------------------------------------------------------------------ */
|
|
441
|
+
/* Sub-session lineage: child enumeration and cascade deletion */
|
|
442
|
+
/* ------------------------------------------------------------------ */
|
|
443
|
+
/*
|
|
444
|
+
* A subagent session is a SIBLING directory of the session that spawned it
|
|
445
|
+
* (~/.dsh/sessions/<project>/<childId>/) and its id NEVER enters the workspace
|
|
446
|
+
* registry — record.sessionIds holds root sessions only. The parent link lives
|
|
447
|
+
* in the child's own header (`origin: "subagent"`, `parentSession`,
|
|
448
|
+
* `delegationDepth`) and nowhere else, so every fact below is derived from that
|
|
449
|
+
* header. A store that cannot be enumerated degrades to "no children" instead
|
|
450
|
+
* of failing the request: a missing badge beats a wrong one.
|
|
451
|
+
*/
|
|
452
|
+
|
|
453
|
+
/** Log names inside one session directory (legacy plain + 0.1.5 generations). */
|
|
454
|
+
const SESSION_LOG_FILE = /^session(?:\.v\d+)?\.jsonl(?:\.zstd)?$/;
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Is this session's agent working right now? The ONLY liveness witness this
|
|
458
|
+
* bundle trusts: `session.lock` outlives the session that wrote it, so its
|
|
459
|
+
* presence proves nothing, while the agents registry drops an entry once the
|
|
460
|
+
* session unloads. An absent entry is a session that is simply not loaded.
|
|
461
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
462
|
+
* @param {string} sessionId
|
|
463
|
+
* @returns {boolean}
|
|
464
|
+
*/
|
|
465
|
+
function isSessionLive(ctx, sessionId) {
|
|
466
|
+
const agents = ctx.get('agents');
|
|
467
|
+
const agent = typeof agents?.get === 'function' ? agents.get(sessionId) : undefined;
|
|
468
|
+
return agent !== undefined && agent.status !== 'idle';
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Keep the non-empty string ids a request body may carry. */
|
|
472
|
+
function normalizeSessionIds(value) {
|
|
473
|
+
return Array.isArray(value) ? value.filter((id) => typeof id === 'string' && id) : [];
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* One lineage index entry: the parent link the cascade walks, plus the header
|
|
478
|
+
* the display facts are read from.
|
|
479
|
+
* @param {object|undefined} item a listing record — the header itself, a
|
|
480
|
+
* 0.1.5 snapshot wrapping it, or a sessionQuery record whose `header` holds it.
|
|
481
|
+
* @returns {{id: string, parentSession: string|undefined, delegationDepth: number,
|
|
482
|
+
* header: object, sizeBytes: number|undefined, logPath: string|undefined}|undefined}
|
|
483
|
+
*/
|
|
484
|
+
function toLineageEntry(item) {
|
|
485
|
+
const header = item?.header && typeof item.header.id === 'string' ? item.header : item;
|
|
486
|
+
if (!header || typeof header.id !== 'string') return undefined;
|
|
487
|
+
const size = typeof item.sizeBytes === 'number' ? item.sizeBytes
|
|
488
|
+
: (typeof header.sizeBytes === 'number' ? header.sizeBytes : undefined);
|
|
489
|
+
const parentSession = typeof header.parentSession === 'string' && header.parentSession
|
|
490
|
+
? header.parentSession
|
|
491
|
+
: undefined;
|
|
492
|
+
// A session that names a parent is at least depth 1; 0 would claim it is a
|
|
493
|
+
// root session, and the panel renders this number.
|
|
494
|
+
const delegationDepth = Number.isFinite(header.delegationDepth) ? header.delegationDepth : (parentSession ? 1 : 0);
|
|
495
|
+
return {
|
|
496
|
+
id: header.id,
|
|
497
|
+
parentSession,
|
|
498
|
+
delegationDepth,
|
|
499
|
+
header,
|
|
500
|
+
sizeBytes: size,
|
|
501
|
+
// Only the physical rung knows the path up front; carrying it saves the
|
|
502
|
+
// size fallback a second resolution round.
|
|
503
|
+
logPath: typeof item.logPath === 'string' ? item.logPath : undefined,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function indexLineage(items) {
|
|
508
|
+
const entries = new Map();
|
|
509
|
+
for (const item of items) {
|
|
510
|
+
const entry = toLineageEntry(item);
|
|
511
|
+
if (entry) entries.set(entry.id, entry);
|
|
512
|
+
}
|
|
513
|
+
return entries;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** The header inside an inspect()/read() result, across cohort shapes. */
|
|
517
|
+
function firstHeader(result) {
|
|
518
|
+
const candidate = result?.header ?? (Array.isArray(result?.events) ? result.events[0] : undefined);
|
|
519
|
+
return candidate && typeof candidate.id === 'string' ? candidate : undefined;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Read one session's header record. The decode rides the existing header
|
|
524
|
+
* convention (persistence.inspect → open(id, 'read')) because a .zstd log
|
|
525
|
+
* cannot be read as text; an uncompressed log is read directly, bounded to its
|
|
526
|
+
* first line.
|
|
527
|
+
* @returns {Promise<object|undefined>} the stored header record.
|
|
528
|
+
*/
|
|
529
|
+
async function readSessionHeaderRecord(persistence, sessionId, logPath) {
|
|
530
|
+
try {
|
|
531
|
+
if (typeof persistence?.inspect === 'function') {
|
|
532
|
+
const header = firstHeader(await persistence.inspect(sessionId));
|
|
533
|
+
if (header) return header;
|
|
534
|
+
}
|
|
535
|
+
} catch { /* the handle API is the next rung */ }
|
|
536
|
+
|
|
537
|
+
try {
|
|
538
|
+
if (typeof persistence?.open === 'function') {
|
|
539
|
+
const handle = await persistence.open(sessionId, 'read');
|
|
540
|
+
try {
|
|
541
|
+
const header = firstHeader(await handle.read());
|
|
542
|
+
if (header) return header;
|
|
543
|
+
} finally {
|
|
544
|
+
if (typeof handle?.close === 'function') await handle.close().catch(() => {});
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
} catch { /* an uncompressed log is still readable below */ }
|
|
548
|
+
|
|
549
|
+
if (logPath.endsWith('.zstd')) return undefined;
|
|
550
|
+
try {
|
|
551
|
+
const handle = await openFile(logPath, 'r');
|
|
552
|
+
try {
|
|
553
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
554
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
555
|
+
const parsed = JSON.parse(buffer.subarray(0, bytesRead).toString('utf8').split('\n', 1)[0]);
|
|
556
|
+
return parsed && typeof parsed === 'object' ? parsed : undefined;
|
|
557
|
+
} finally {
|
|
558
|
+
await handle.close();
|
|
559
|
+
}
|
|
560
|
+
} catch {
|
|
561
|
+
return undefined;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Physical fallback rung: enumerate ~/.dsh/sessions/<project>/<sessionId>/ and
|
|
567
|
+
* read each session's first record. A directory whose header stays unreadable
|
|
568
|
+
* still ENTERS the index under its directory name — it is a session with a log
|
|
569
|
+
* on disk, and that is exactly what decides whether a child's parent is a live
|
|
570
|
+
* row or an orphan.
|
|
571
|
+
* @returns {Promise<Array|undefined>} listing items, or undefined when the
|
|
572
|
+
* session store itself cannot be listed.
|
|
573
|
+
*/
|
|
574
|
+
async function scanSessionDirectories(persistence) {
|
|
575
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
576
|
+
if (!home) return undefined;
|
|
577
|
+
const root = join(home, '.dsh', 'sessions');
|
|
578
|
+
let projects;
|
|
579
|
+
try {
|
|
580
|
+
projects = await readdir(root);
|
|
581
|
+
} catch {
|
|
582
|
+
return undefined;
|
|
583
|
+
}
|
|
584
|
+
const items = [];
|
|
585
|
+
for (const project of projects) {
|
|
586
|
+
const projectDir = join(root, project);
|
|
587
|
+
for (const sessionId of await readdir(projectDir).catch(() => [])) {
|
|
588
|
+
const sessionDir = join(projectDir, sessionId);
|
|
589
|
+
const names = await readdir(sessionDir).catch(() => []);
|
|
590
|
+
const logName = names.find((name) => SESSION_LOG_FILE.test(name));
|
|
591
|
+
if (!logName) continue;
|
|
592
|
+
const logPath = join(sessionDir, logName);
|
|
593
|
+
const read = await readSessionHeaderRecord(persistence, sessionId, logPath);
|
|
594
|
+
const header = read && typeof read.id === 'string' ? read : { id: sessionId };
|
|
595
|
+
const sizeBytes = await stat(logPath).then((s) => s.size).catch(() => undefined);
|
|
596
|
+
items.push({ header, sizeBytes, logPath });
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return items;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Index every stored session by id, walking the cohort ladder:
|
|
604
|
+
* sessionQuery.listSessions() → persistence.list() → physical scan. Only a rung
|
|
605
|
+
* that THROWS is skipped; `degraded` therefore means every rung threw, and the
|
|
606
|
+
* caller reports empty groups rather than guessing.
|
|
607
|
+
* @returns {Promise<{entries: Map<string, object>, degraded: boolean}>}
|
|
608
|
+
*/
|
|
609
|
+
async function collectLineage(ctx, persistence) {
|
|
610
|
+
const query = ctx.get('sessionQuery');
|
|
611
|
+
if (typeof query?.listSessions === 'function') {
|
|
612
|
+
try {
|
|
613
|
+
const records = await query.listSessions();
|
|
614
|
+
if (Array.isArray(records)) return { entries: indexLineage(records), degraded: false };
|
|
615
|
+
} catch { /* the listing below is the next rung */ }
|
|
616
|
+
}
|
|
617
|
+
try {
|
|
618
|
+
const listed = await persistence?.list?.();
|
|
619
|
+
if (Array.isArray(listed)) return { entries: indexLineage(listed), degraded: false };
|
|
620
|
+
} catch { /* the physical scan below is the last rung */ }
|
|
621
|
+
const scanned = await scanSessionDirectories(persistence);
|
|
622
|
+
if (scanned) return { entries: indexLineage(scanned), degraded: false };
|
|
623
|
+
return { entries: new Map(), degraded: true };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function descendantId(value) {
|
|
627
|
+
if (typeof value === 'string') return value;
|
|
628
|
+
if (typeof value?.id === 'string') return value.id;
|
|
629
|
+
if (typeof value?.header?.id === 'string') return value.header.id;
|
|
630
|
+
return undefined;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Every descendant id of one session, at FULL depth, excluding the session
|
|
635
|
+
* itself. sessionQuery.traceSession owns the walk when it exists (it also
|
|
636
|
+
* answers for a missing ancestor); otherwise the header index is walked
|
|
637
|
+
* recursively.
|
|
638
|
+
* @returns {Promise<string[]>}
|
|
639
|
+
*/
|
|
640
|
+
async function collectDescendantIds(ctx, persistence, sessionId) {
|
|
641
|
+
const query = ctx.get('sessionQuery');
|
|
642
|
+
if (typeof query?.traceSession === 'function') {
|
|
643
|
+
try {
|
|
644
|
+
const trace = await query.traceSession(sessionId);
|
|
645
|
+
if (Array.isArray(trace?.descendants)) {
|
|
646
|
+
const ids = trace.descendants.map(descendantId).filter((id) => id && id !== sessionId);
|
|
647
|
+
return [...new Set(ids)];
|
|
648
|
+
}
|
|
649
|
+
} catch { /* a partial lineage must not fail the delete: derive it below */ }
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const { entries } = await collectLineage(ctx, persistence);
|
|
653
|
+
const childrenOf = new Map();
|
|
654
|
+
for (const entry of entries.values()) {
|
|
655
|
+
if (!entry.parentSession) continue;
|
|
656
|
+
const bucket = childrenOf.get(entry.parentSession);
|
|
657
|
+
if (bucket) bucket.push(entry.id); else childrenOf.set(entry.parentSession, [entry.id]);
|
|
658
|
+
}
|
|
659
|
+
const ids = [];
|
|
660
|
+
const seen = new Set([sessionId]);
|
|
661
|
+
const queue = [...(childrenOf.get(sessionId) ?? [])];
|
|
662
|
+
while (queue.length > 0) {
|
|
663
|
+
const id = queue.shift();
|
|
664
|
+
// A corrupt parent link that cycles back must not loop forever.
|
|
665
|
+
if (seen.has(id)) continue;
|
|
666
|
+
seen.add(id);
|
|
667
|
+
ids.push(id);
|
|
668
|
+
queue.push(...(childrenOf.get(id) ?? []));
|
|
669
|
+
}
|
|
670
|
+
return ids;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** Workspace-record session ids: the second way a child's parent can resolve. */
|
|
674
|
+
function collectRegistrySessionIds(registry) {
|
|
675
|
+
const ids = new Set();
|
|
676
|
+
const entities = registry?.entities;
|
|
677
|
+
if (entities) {
|
|
678
|
+
for (const entity of entities.values()) {
|
|
679
|
+
for (const sid of entity.record?.sessionIds ?? []) ids.add(sid);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return ids;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** Byte size of a session log, or undefined when no such file exists. */
|
|
686
|
+
async function logFileSize(path) {
|
|
687
|
+
if (!path) return undefined;
|
|
688
|
+
try {
|
|
689
|
+
const info = await stat(path);
|
|
690
|
+
return info.isFile() ? info.size : undefined;
|
|
691
|
+
} catch {
|
|
692
|
+
return undefined;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* The display row for one child session.
|
|
698
|
+
*
|
|
699
|
+
* WHY A SIZE FALLBACK EXISTS: `sizeBytes` is a field of the persistence
|
|
700
|
+
* listing only — the kernel's `sessionQuery.listSessions()` projects
|
|
701
|
+
* `{ header, live, persisted }` and carries no size at all — so on a host that
|
|
702
|
+
* HAS sessionQuery (the normal case) every row would otherwise report
|
|
703
|
+
* `fileSize: null` and the panel could not show a single size. The fallback
|
|
704
|
+
* measures ONLY the rows this report is about to return (never the whole
|
|
705
|
+
* store), and a session with no log stays `null` rather than claiming 0 bytes.
|
|
706
|
+
*/
|
|
707
|
+
async function childInfo(ctx, persistence, projectionCache, entry) {
|
|
708
|
+
const cached = readCachedSessionFacts(projectionCache, entry.header);
|
|
709
|
+
const headerTitle = typeof entry.header.title === 'string' && entry.header.title && entry.header.title !== entry.id
|
|
710
|
+
? entry.header.title
|
|
711
|
+
: undefined;
|
|
712
|
+
const sizeBytes = typeof entry.sizeBytes === 'number'
|
|
713
|
+
? entry.sizeBytes
|
|
714
|
+
: await logFileSize(entry.logPath ?? await resolveSessionLogPath(persistence, entry.id));
|
|
715
|
+
return {
|
|
716
|
+
id: entry.id,
|
|
717
|
+
title: headerTitle ?? cached.title ?? null,
|
|
718
|
+
fileSize: typeof sizeBytes === 'number' ? sizeBytes : null,
|
|
719
|
+
createdAt: typeof entry.header.createdAt === 'number' ? entry.header.createdAt : null,
|
|
720
|
+
delegationDepth: entry.delegationDepth,
|
|
721
|
+
live: isSessionLive(ctx, entry.id),
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Build the /children report: sub-sessions grouped under the parent they name,
|
|
727
|
+
* and orphans grouped under a parent id that resolves to nothing (neither a log
|
|
728
|
+
* nor a workspace-registry session id) so the panel can render its "残壳" rows.
|
|
729
|
+
* `sessionIds` narrows the report to those parents; omitting it reports the
|
|
730
|
+
* whole store.
|
|
731
|
+
* @returns {Promise<{groups: object, orphans: object, scannedAt: number,
|
|
732
|
+
* sessionCount: number, degraded?: true}>}
|
|
733
|
+
*/
|
|
734
|
+
async function buildChildrenReport(ctx, persistence, sessionIds) {
|
|
735
|
+
const { entries, degraded } = await collectLineage(ctx, persistence);
|
|
736
|
+
const report = { groups: {}, orphans: {}, scannedAt: Date.now(), sessionCount: entries.size };
|
|
737
|
+
if (degraded) return { ...report, degraded: true };
|
|
738
|
+
|
|
739
|
+
const filter = Array.isArray(sessionIds) ? new Set(normalizeSessionIds(sessionIds)) : undefined;
|
|
740
|
+
const registryIds = collectRegistrySessionIds(ctx.workspaceRegistry);
|
|
741
|
+
const projectionCache = ctx.get('sessionProjectionCache');
|
|
742
|
+
for (const entry of entries.values()) {
|
|
743
|
+
const parentId = entry.parentSession;
|
|
744
|
+
if (!parentId) continue;
|
|
745
|
+
if (filter && !filter.has(parentId)) continue;
|
|
746
|
+
const bucket = entries.has(parentId) || registryIds.has(parentId) ? report.groups : report.orphans;
|
|
747
|
+
(bucket[parentId] ??= []).push(await childInfo(ctx, persistence, projectionCache, entry));
|
|
748
|
+
}
|
|
749
|
+
for (const bucket of [report.groups, report.orphans]) {
|
|
750
|
+
for (const list of Object.values(bucket)) {
|
|
751
|
+
list.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0) || a.id.localeCompare(b.id));
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
return report;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/** Drop one session's live entry from the sessions store, when it has one. */
|
|
758
|
+
function detachLiveSession(ctx, sessionId) {
|
|
759
|
+
try {
|
|
760
|
+
const sessions = ctx.get('sessions');
|
|
761
|
+
const session = typeof sessions?.get === 'function' ? sessions.get(sessionId) : undefined;
|
|
762
|
+
if (session !== undefined && typeof sessions.liveEntryFor === 'function') {
|
|
763
|
+
const entry = sessions.liveEntryFor(session);
|
|
764
|
+
if (entry && typeof entry.detach === 'function') entry.detach();
|
|
765
|
+
}
|
|
766
|
+
} catch {
|
|
767
|
+
// not live in the store — nothing to detach
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* GHOST cleanup: a session with NO log on disk must DISAPPEAR, not come back as
|
|
773
|
+
* an ungrouped row, so it is detached from every workspace record that lists it.
|
|
774
|
+
*/
|
|
775
|
+
async function detachFromWorkspaces(registry, sessionId) {
|
|
776
|
+
const entities = registry?.entities;
|
|
777
|
+
if (!entities) return;
|
|
778
|
+
for (const entity of entities.values()) {
|
|
779
|
+
if (entity.record?.sessionIds?.includes(sessionId)) {
|
|
780
|
+
try {
|
|
781
|
+
await entity.detachSession(sessionId);
|
|
782
|
+
} catch {
|
|
783
|
+
// best effort
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Remove one session's durable artifacts: its log directory, its projection
|
|
791
|
+
* cache record, its live-store entry, and — when no log existed at all — its
|
|
792
|
+
* workspace slots. Failures propagate: the cascade reports them per id instead
|
|
793
|
+
* of rolling back.
|
|
794
|
+
*/
|
|
795
|
+
async function removeSessionArtifacts(ctx, persistence, projectionCache, registry, sessionId) {
|
|
796
|
+
const logPath = await resolveSessionLogPath(persistence, sessionId);
|
|
797
|
+
if (logPath) {
|
|
798
|
+
try {
|
|
799
|
+
const sessionDir = dirname(logPath);
|
|
800
|
+
await rm(sessionDir, { recursive: true, force: true });
|
|
801
|
+
const projectDir = dirname(sessionDir);
|
|
802
|
+
try {
|
|
803
|
+
const entries = await readdir(projectDir);
|
|
804
|
+
if (entries.length === 0) await rmdir(projectDir);
|
|
805
|
+
} catch { /* best effort */ }
|
|
806
|
+
} catch (error) {
|
|
807
|
+
throw new Error(`failed to delete session log for '${sessionId}'`, { cause: error });
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (typeof projectionCache?.remove === 'function') await projectionCache.remove(sessionId);
|
|
812
|
+
|
|
813
|
+
detachLiveSession(ctx, sessionId);
|
|
814
|
+
if (!logPath) await detachFromWorkspaces(registry, sessionId);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Purge rows of sessions through the registry's row-aware permanent delete.
|
|
819
|
+
* Every failure is reported per ROW and never rolled back, and a row that
|
|
820
|
+
* failed midway contributes the ids it did remove so the counts stay true.
|
|
821
|
+
* `errors` is an alias of `failed` kept for the released client builds (they
|
|
822
|
+
* read `.sessionId` and the SESSION_RUNNING `code`).
|
|
823
|
+
*/
|
|
824
|
+
async function purgeSessionRows(registry, sessionIds, excludeIds) {
|
|
825
|
+
const rows = normalizeSessionIds(sessionIds);
|
|
826
|
+
const exclude = normalizeSessionIds(excludeIds);
|
|
827
|
+
const deleted = [];
|
|
828
|
+
const kept = [];
|
|
829
|
+
const failed = [];
|
|
830
|
+
const errors = [];
|
|
831
|
+
for (const sessionId of rows) {
|
|
832
|
+
try {
|
|
833
|
+
const result = await registry.permanentlyDeleteSession(sessionId, { excludeIds: exclude });
|
|
834
|
+
deleted.push(...result.deleted);
|
|
835
|
+
kept.push(...result.kept);
|
|
836
|
+
} catch (error) {
|
|
837
|
+
deleted.push(...(Array.isArray(error?.deleted) ? error.deleted : []));
|
|
838
|
+
const message = String(error?.message ?? error);
|
|
839
|
+
failed.push({ sessionId, message });
|
|
840
|
+
errors.push({ sessionId, code: error?.code ?? 'ERROR', message });
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
return {
|
|
844
|
+
deletedCount: deleted.length,
|
|
845
|
+
keptCount: kept.length,
|
|
846
|
+
deleted,
|
|
847
|
+
kept,
|
|
848
|
+
failed,
|
|
849
|
+
errors,
|
|
850
|
+
total: rows.length,
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/* ------------------------------------------------------------------ */
|
|
855
|
+
/* Patch: WorkspaceRegistry — unarchive / permanentlyDelete / empty */
|
|
856
|
+
/* ------------------------------------------------------------------ */
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Patch the WorkspaceRegistry with new methods.
|
|
860
|
+
*
|
|
861
|
+
* v0.3.2: methods left on the shared registry by an EARLIER fiber (uninstall
|
|
862
|
+
* + reinstall without a restart) must be replaced, not reused — their
|
|
863
|
+
* closures capture the dead context, so listArchivedSessions() silently
|
|
864
|
+
* lost turn counts while the routes (rebuilt per mount) kept working. Every
|
|
865
|
+
* installed method is tagged with this install's TRASH_PATCH token; a fresh
|
|
866
|
+
* instance overwrites whatever stale methods it finds, and the returned
|
|
867
|
+
* disposer removes only methods still carrying this install's token.
|
|
868
|
+
*
|
|
869
|
+
* @param {import('@deepseek-ai/dsh-workspace').WorkspaceRegistry} registry
|
|
870
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
871
|
+
* @returns a disposer removing every method installed here, or null when the
|
|
872
|
+
* registry is missing.
|
|
873
|
+
*/
|
|
874
|
+
function patchWorkspaceRegistry(registry, ctx) {
|
|
875
|
+
if (!registry) return null;
|
|
876
|
+
const token = Symbol('dsh-session-trash.registryPatch');
|
|
877
|
+
// Snapshot the services the patched methods need while THIS fiber is live.
|
|
878
|
+
// The methods are installed on the SHARED registry object and may outlive
|
|
879
|
+
// this fiber (a pre-v0.3.2 install could leak them); resolving services
|
|
880
|
+
// through ctx at call time would then hit the dead-fiber guard
|
|
881
|
+
// ("cannot get required service ... in inactive context") and silently
|
|
882
|
+
// zero the turn counts. ctx.get(...) is a safe non-throwing accessor, so
|
|
883
|
+
// the remaining per-call lookups below stay dynamic on purpose.
|
|
884
|
+
const persistence = ctx.sessionPersistence;
|
|
885
|
+
// Snapshotted exactly like persistence, and for the same reason: these
|
|
886
|
+
// methods live on the SHARED registry and can outlive this fiber, so a
|
|
887
|
+
// call-time `ctx.get('sessionProjectionCache')` would degrade to
|
|
888
|
+
// `undefined` on a dead context and silently zero every turn count — the
|
|
889
|
+
// v0.3.2 production symptom, just moved into the projection read. The
|
|
890
|
+
// service is in `static inject`, so it is present while this runs.
|
|
891
|
+
const projectionCache = ctx.get('sessionProjectionCache');
|
|
892
|
+
const runInQueue = (fn) => {
|
|
893
|
+
if (typeof registry.enqueueOperation === 'function') {
|
|
894
|
+
return registry.enqueueOperation(fn);
|
|
895
|
+
}
|
|
896
|
+
return fn();
|
|
897
|
+
};
|
|
898
|
+
|
|
899
|
+
// The registry's OWN methods, captured before this patch replaces them.
|
|
900
|
+
// Unwrapping TRASH_PATCH_ORIGINAL matters on an in-process re-mount
|
|
901
|
+
// (loader update, uninstall → install): the registry is shared, so the
|
|
902
|
+
// previous install's patch would otherwise be read back as "the original"
|
|
903
|
+
// and the patch would recurse into itself forever.
|
|
904
|
+
const currentArchive = registry.archiveSession;
|
|
905
|
+
const currentUnarchive = registry.unarchiveSession;
|
|
906
|
+
const originalArchive = currentArchive?.[TRASH_PATCH_ORIGINAL] ?? currentArchive;
|
|
907
|
+
const originalUnarchive = currentUnarchive?.[TRASH_PATCH_ORIGINAL] ?? currentUnarchive;
|
|
908
|
+
|
|
909
|
+
// ── archiveSession ────────────────────────────────────────────────
|
|
910
|
+
//
|
|
911
|
+
// TWO CALLERS, ONE ARGUMENT SLOT (v0.4.4):
|
|
912
|
+
//
|
|
913
|
+
// DSH calls `WorkspaceRegistry.archiveSession(sessionId, options)` — its
|
|
914
|
+
// api-workspace-controller passes `{ stopActivity: true }` or `{}`. This
|
|
915
|
+
// plugin's own /archive route passes a display TITLE string instead. The
|
|
916
|
+
// pre-0.4.4 patch treated the second argument as a title, so DSH's `{}`
|
|
917
|
+
// was truthy and got stored as one: the recycle bin then read that object
|
|
918
|
+
// back as `savedTitle`, and rendering an object as a React child throws.
|
|
919
|
+
// Any session archived from DSH's own hover button therefore broke the
|
|
920
|
+
// panel, and the `{}` entries still sit in workspace.json. Split the two
|
|
921
|
+
// shapes explicitly and never store a non-string.
|
|
922
|
+
//
|
|
923
|
+
// The pre-0.4.4 patch also REPLACED the registry method outright, silently
|
|
924
|
+
// dropping four invariants DSH maintains there: the session-existence
|
|
925
|
+
// check, the `workspace/session-activity` gate that refuses to archive a
|
|
926
|
+
// session that is still working, `stopActivity` itself (so the "stop and
|
|
927
|
+
// archive" confirmation stopped nothing), and the pin drop that keeps
|
|
928
|
+
// pinning and archival mutually exclusive. Delegate to the registry's own
|
|
929
|
+
// method and layer only the display facts on top.
|
|
930
|
+
const parseArchiveArgs = (options) => {
|
|
931
|
+
if (typeof options === 'string') return { title: options || undefined, dshOptions: {} };
|
|
932
|
+
if (options && typeof options === 'object') return { title: undefined, dshOptions: options };
|
|
933
|
+
return { title: undefined, dshOptions: {} };
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Pre-0.2 cohorts ship no registry archive API at all — this plugin is what
|
|
938
|
+
* provides one there. The write mirrors the durable shape of
|
|
939
|
+
* WorkspaceRegistry.archiveSession (archive set + dropped pin) minus the
|
|
940
|
+
* activity gate those cohorts do not expose.
|
|
941
|
+
*/
|
|
942
|
+
const archiveWithoutRegistryApi = (sessionId) => runInQueue(async () => {
|
|
943
|
+
const state = registry.requireState?.();
|
|
944
|
+
if (!state) return;
|
|
945
|
+
const currentArchived = state.archivedSessionIds ?? [];
|
|
946
|
+
if (currentArchived.includes(sessionId)) return;
|
|
947
|
+
const next = { ...state, archivedSessionIds: [...currentArchived, sessionId] };
|
|
948
|
+
if (Array.isArray(state.pinnedSessionIds)) {
|
|
949
|
+
next.pinnedSessionIds = state.pinnedSessionIds.filter((id) => id !== sessionId);
|
|
950
|
+
}
|
|
951
|
+
await registry.setState(next);
|
|
952
|
+
notifyArchivedSessionsChanged(ctx, next.archivedSessionIds);
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
/** Persist the recycle bin's display facts for one archived session. */
|
|
956
|
+
const writeArchivedMeta = (sessionId, title) => runInQueue(async () => {
|
|
957
|
+
const state = registry.requireState?.();
|
|
958
|
+
if (!state) return;
|
|
959
|
+
if (!(state.archivedSessionIds ?? []).includes(sessionId)) return;
|
|
960
|
+
const current = state.archivedMeta ?? {};
|
|
961
|
+
const previous = current[sessionId];
|
|
962
|
+
const resolved = title ?? (typeof previous?.title === 'string' ? previous.title : undefined);
|
|
963
|
+
const at = typeof previous?.at === 'number' ? previous.at : Date.now();
|
|
964
|
+
const next = { ...current, [sessionId]: resolved === undefined ? { at } : { at, title: resolved } };
|
|
965
|
+
await registry.setState({ ...state, archivedMeta: next });
|
|
966
|
+
notifyArchivedSessionsChanged(ctx, state.archivedSessionIds);
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
/** Drop one archived session's display facts (unarchive or purge). */
|
|
970
|
+
const dropArchivedMeta = (sessionId) => runInQueue(async () => {
|
|
971
|
+
const state = registry.requireState?.();
|
|
972
|
+
if (!state) return;
|
|
973
|
+
const current = state.archivedMeta ?? {};
|
|
974
|
+
if (!(sessionId in current)) return;
|
|
975
|
+
const next = { ...current };
|
|
976
|
+
delete next[sessionId];
|
|
977
|
+
await registry.setState({ ...state, archivedMeta: next });
|
|
978
|
+
notifyArchivedSessionsChanged(ctx, state.archivedSessionIds ?? []);
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
registry.archiveSession = function archiveSession(sessionId, options) {
|
|
982
|
+
const { title, dshOptions } = parseArchiveArgs(options);
|
|
983
|
+
// The registry's own method opens its own mutation-queue slot, so this
|
|
984
|
+
// call must stay OUTSIDE runInQueue: enqueueOperation chains on the
|
|
985
|
+
// current tail, and wrapping a queued call in another queued call
|
|
986
|
+
// deadlocks the registry.
|
|
987
|
+
const archive = typeof originalArchive === 'function'
|
|
988
|
+
? originalArchive.call(registry, sessionId, dshOptions)
|
|
989
|
+
: archiveWithoutRegistryApi(sessionId);
|
|
990
|
+
return archive.then(() => writeArchivedMeta(sessionId, title));
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
// ── unarchiveSession ──────────────────────────────────────────────
|
|
994
|
+
registry.unarchiveSession = function unarchiveSession(sessionId) {
|
|
995
|
+
const unarchive = typeof originalUnarchive === 'function'
|
|
996
|
+
? originalUnarchive.call(registry, sessionId)
|
|
997
|
+
: runInQueue(async () => {
|
|
998
|
+
const state = registry.requireState?.();
|
|
999
|
+
if (!state || !(state.archivedSessionIds ?? []).includes(sessionId)) return;
|
|
1000
|
+
const nextArchived = state.archivedSessionIds.filter((id) => id !== sessionId);
|
|
1001
|
+
await registry.setState({ ...state, archivedSessionIds: nextArchived });
|
|
1002
|
+
notifyArchivedSessionsChanged(ctx, nextArchived);
|
|
1003
|
+
});
|
|
1004
|
+
// Restoring used to leave the session's stored title behind forever.
|
|
1005
|
+
return unarchive.then(() => dropArchivedMeta(sessionId));
|
|
1006
|
+
};
|
|
1007
|
+
|
|
1008
|
+
/** Drop the deleted ids from the archive set and its display metadata. */
|
|
1009
|
+
const purgeArchivedSlots = (sessionIds) => runInQueue(async () => {
|
|
1010
|
+
const state = registry.requireState?.();
|
|
1011
|
+
if (!state) return false;
|
|
1012
|
+
const archived = state.archivedSessionIds ?? [];
|
|
1013
|
+
const removing = sessionIds.filter((id) => archived.includes(id));
|
|
1014
|
+
if (removing.length === 0) return false;
|
|
1015
|
+
// Drop the display facts in the SAME write: a purged session leaves no row
|
|
1016
|
+
// to render, and its archivedMeta entry would otherwise sit in the durable
|
|
1017
|
+
// record forever.
|
|
1018
|
+
const nextMeta = { ...(state.archivedMeta ?? {}) };
|
|
1019
|
+
for (const id of removing) delete nextMeta[id];
|
|
1020
|
+
await registry.setState({
|
|
1021
|
+
...state,
|
|
1022
|
+
archivedSessionIds: archived.filter((id) => !removing.includes(id)),
|
|
1023
|
+
archivedMeta: nextMeta,
|
|
1024
|
+
});
|
|
1025
|
+
return true;
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
// ── permanentlyDeleteSession ──────────────────────────────────────
|
|
1029
|
+
//
|
|
1030
|
+
// A ROW, NOT AN ID (v0.5.0): a subagent session is a SIBLING directory of
|
|
1031
|
+
// the session that spawned it and never appears in the workspace registry
|
|
1032
|
+
// (record.sessionIds holds root sessions only), so deleting the parent alone
|
|
1033
|
+
// used to leave its whole child tree behind on disk. One row is therefore
|
|
1034
|
+
// "the session + ALL its descendants, minus excludeIds".
|
|
1035
|
+
//
|
|
1036
|
+
// A live descendant refuses the WHOLE row before a single file is removed,
|
|
1037
|
+
// and excluding a running child does NOT rescue the row: the store must
|
|
1038
|
+
// never be mutated underneath a running agent.
|
|
1039
|
+
//
|
|
1040
|
+
// @param {string} sessionId the row's primary session.
|
|
1041
|
+
// @param {{excludeIds?: string[]}} [options] descendants to spare.
|
|
1042
|
+
// @returns {Promise<{deleted: string[], kept: string[]}>}
|
|
1043
|
+
registry.permanentlyDeleteSession = async function permanentlyDeleteSession(sessionId, options) {
|
|
1044
|
+
// The proven published-plugin liveness check: only an ACTIVE agent blocks
|
|
1045
|
+
// deletion, and it names the offending session.
|
|
1046
|
+
if (isSessionLive(ctx, sessionId)) {
|
|
1047
|
+
const err = new Error(`会话正在运行中,请等待结束后再删除`);
|
|
1048
|
+
err.code = 'SESSION_RUNNING';
|
|
1049
|
+
throw err;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const exclude = new Set(normalizeSessionIds(options?.excludeIds));
|
|
1053
|
+
const descendants = await collectDescendantIds(ctx, persistence, sessionId);
|
|
1054
|
+
|
|
1055
|
+
const running = descendants.filter((id) => isSessionLive(ctx, id)).sort();
|
|
1056
|
+
if (running.length > 0) {
|
|
1057
|
+
const err = new Error(`会话正在运行中,请等待结束后再删除(运行中的子会话:${running.join('、')})`);
|
|
1058
|
+
err.code = 'SESSION_RUNNING';
|
|
1059
|
+
err.runningSessionIds = running;
|
|
1060
|
+
throw err;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
const targets = [sessionId, ...descendants.filter((id) => !exclude.has(id))];
|
|
1064
|
+
const kept = descendants.filter((id) => exclude.has(id));
|
|
1065
|
+
|
|
1066
|
+
const deleted = [];
|
|
1067
|
+
let failure;
|
|
1068
|
+
for (const id of targets) {
|
|
1069
|
+
try {
|
|
1070
|
+
await removeSessionArtifacts(ctx, persistence, projectionCache, registry, id);
|
|
1071
|
+
} catch (error) {
|
|
1072
|
+
// NO ROLLBACK: the ids already removed stay removed, and the caller is
|
|
1073
|
+
// handed exactly which ones they were so its counts stay true.
|
|
1074
|
+
error.deleted = deleted;
|
|
1075
|
+
failure = error;
|
|
1076
|
+
break;
|
|
1077
|
+
}
|
|
1078
|
+
deleted.push(id);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
if (await purgeArchivedSlots(deleted)) {
|
|
1082
|
+
notifyArchivedSessionsChanged(ctx, registry.requireState?.()?.archivedSessionIds ?? []);
|
|
1083
|
+
}
|
|
1084
|
+
if (failure) throw failure;
|
|
1085
|
+
return { deleted, kept };
|
|
1086
|
+
};
|
|
1087
|
+
|
|
1088
|
+
// ── emptyArchivedSessions ─────────────────────────────────────────
|
|
1089
|
+
registry.emptyArchivedSessions = async function emptyArchivedSessions(options) {
|
|
1090
|
+
const archivedIds = [...(registry.requireState?.()?.archivedSessionIds ?? [])];
|
|
1091
|
+
// Every row cascades to its sub-sessions exactly like /purge: emptying the
|
|
1092
|
+
// recycle bin must not leave orphaned child directories behind.
|
|
1093
|
+
const result = await purgeSessionRows(registry, archivedIds, normalizeSessionIds(options?.excludeIds));
|
|
1094
|
+
|
|
1095
|
+
if (result.deletedCount > 0) {
|
|
1096
|
+
notifyArchivedSessionsChanged(ctx, registry.requireState?.()?.archivedSessionIds ?? []);
|
|
1097
|
+
}
|
|
1098
|
+
if (result.failed.length > 0 && result.deletedCount === 0) {
|
|
1099
|
+
throw new Error(`failed to empty recycle bin: ${result.failed[0].message}`);
|
|
1100
|
+
}
|
|
1101
|
+
return result;
|
|
1102
|
+
};
|
|
1103
|
+
|
|
1104
|
+
// ── listArchivedSessions ──────────────────────────────────────────
|
|
1105
|
+
//
|
|
1106
|
+
// ZERO LOG I/O (v0.4.4). Until v0.4.3 every row opened its session log and
|
|
1107
|
+
// decoded the entire Zstd frame chain just to count `turn/start` events and
|
|
1108
|
+
// to derive a title from the first user message. On the reference store
|
|
1109
|
+
// that meant ~0.2 s of header listing plus 10-198 ms PER archived session,
|
|
1110
|
+
// on the web server's own thread, on every open and on every archive /
|
|
1111
|
+
// unarchive / purge broadcast. Every fact the panel shows now comes from
|
|
1112
|
+
// durable state or the projection cache:
|
|
1113
|
+
//
|
|
1114
|
+
// title archivedMeta.title → projection-cache `title` → legacy
|
|
1115
|
+
// archivedTitles (string entries only) → '未命名会话'
|
|
1116
|
+
// turnCount projection-cache `sessionStats.turns`
|
|
1117
|
+
// fileSize the header listing's own sizeBytes
|
|
1118
|
+
// archivedAt archivedMeta.at — the REAL archive time. The old value was
|
|
1119
|
+
// the log's mtime, i.e. last ACTIVITY, which the panel
|
|
1120
|
+
// labelled "最近删除" and sorted by.
|
|
1121
|
+
registry.listArchivedSessions = async function listArchivedSessions() {
|
|
1122
|
+
const state = registry.requireState?.();
|
|
1123
|
+
if (!state) return [];
|
|
1124
|
+
|
|
1125
|
+
const archivedIds = state.archivedSessionIds ?? [];
|
|
1126
|
+
if (archivedIds.length === 0) return [];
|
|
1127
|
+
|
|
1128
|
+
const archivedMeta = state.archivedMeta ?? {};
|
|
1129
|
+
const legacyTitles = state.archivedTitles ?? {};
|
|
1130
|
+
|
|
1131
|
+
// ONE listing for the whole set. Its snapshot also carries sizeBytes —
|
|
1132
|
+
// the field the panel used to stat() every log to recover.
|
|
1133
|
+
let snapshots = [];
|
|
1134
|
+
try {
|
|
1135
|
+
snapshots = ((await persistence?.list?.()) ?? []).map(normalizeStoredSnapshot).filter(Boolean);
|
|
1136
|
+
} catch {
|
|
1137
|
+
// Listing failed: every row still renders from state alone.
|
|
1138
|
+
}
|
|
1139
|
+
const byId = new Map(snapshots.map((snapshot) => [snapshot.header.id, snapshot]));
|
|
1140
|
+
|
|
1141
|
+
const workspaceBySession = new Map();
|
|
1142
|
+
const entities = registry.entities;
|
|
1143
|
+
if (entities) {
|
|
1144
|
+
for (const entity of entities.values()) {
|
|
1145
|
+
for (const sid of entity.record?.sessionIds ?? []) {
|
|
1146
|
+
if (archivedIds.includes(sid) && !workspaceBySession.has(sid)) {
|
|
1147
|
+
workspaceBySession.set(sid, {
|
|
1148
|
+
workspacePath: entity.record.path,
|
|
1149
|
+
workspaceTitle: entity.record.title,
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// WHY ONE PATH RESOLUTION PER ROW IS BOUNDED: the hover card shows where a
|
|
1157
|
+
// session actually LIVES, and that path is only knowable through the same
|
|
1158
|
+
// resolver ladder permanentlyDeleteSession already walks per session —
|
|
1159
|
+
// findLog (a service query), locate() (pure path computation, no I/O) and,
|
|
1160
|
+
// only when neither answers, a directory scan. It never decodes a log, so
|
|
1161
|
+
// the listing still OPENS nothing (inspect/open stay at zero) and the
|
|
1162
|
+
// store-wide header listing above is still the ONE listing.
|
|
1163
|
+
//
|
|
1164
|
+
// findLog may name a location whose file is already gone (documented cohort
|
|
1165
|
+
// behaviour), while this field promises a path ON DISK: one fileExists
|
|
1166
|
+
// stat — the predicate locate() itself goes through — settles that, and a
|
|
1167
|
+
// session with no log at all reports null instead of a stale location.
|
|
1168
|
+
const items = await Promise.all(archivedIds.map(async (sessionId) => {
|
|
1169
|
+
const snapshot = byId.get(sessionId);
|
|
1170
|
+
const header = snapshot?.header;
|
|
1171
|
+
const ws = workspaceBySession.get(sessionId);
|
|
1172
|
+
const meta = archivedMeta[sessionId];
|
|
1173
|
+
const cached = readCachedSessionFacts(projectionCache, header);
|
|
1174
|
+
// `archivedTitles` predates archivedMeta and is also where the pre-0.4.4
|
|
1175
|
+
// options-object bug parked its `{}` values: only strings are titles.
|
|
1176
|
+
const legacyTitle = typeof legacyTitles[sessionId] === 'string' ? legacyTitles[sessionId] : undefined;
|
|
1177
|
+
|
|
1178
|
+
const title = meta?.title
|
|
1179
|
+
|| cached.title
|
|
1180
|
+
|| legacyTitle
|
|
1181
|
+
|| (header?.title && header.title !== sessionId ? header.title : undefined)
|
|
1182
|
+
|| '未命名会话';
|
|
1183
|
+
|
|
1184
|
+
const resolvedLogPath = await resolveSessionLogPath(persistence, sessionId);
|
|
1185
|
+
const logPath = resolvedLogPath && (await fileExists(resolvedLogPath)) ? resolvedLogPath : null;
|
|
1186
|
+
|
|
1187
|
+
return {
|
|
1188
|
+
sessionId,
|
|
1189
|
+
title,
|
|
1190
|
+
cwd: header?.cwd,
|
|
1191
|
+
workspacePath: ws?.workspacePath || ws?.cwd,
|
|
1192
|
+
workspaceTitle: ws?.workspaceTitle,
|
|
1193
|
+
// Rows archived before archivedMeta existed fall back to the header's
|
|
1194
|
+
// createdAt: stable and monotonic without touching the disk.
|
|
1195
|
+
archivedAt: typeof meta?.at === 'number' ? meta.at : (header?.createdAt ?? 0),
|
|
1196
|
+
fileSize: snapshot?.sizeBytes ?? 0,
|
|
1197
|
+
// null, NOT 0, when the cache cannot serve a current-generation
|
|
1198
|
+
// `sessionStats` row: an unknown count and a zero count are different
|
|
1199
|
+
// facts, and the panel must not print "0 轮对话" over a long session
|
|
1200
|
+
// whose checkpoint predates a format upgrade. The client omits the
|
|
1201
|
+
// chip for null.
|
|
1202
|
+
turnCount: typeof cached.turns === 'number' ? cached.turns : null,
|
|
1203
|
+
logPath,
|
|
1204
|
+
};
|
|
1205
|
+
}));
|
|
1206
|
+
|
|
1207
|
+
// 按工作区顺序排列(与左侧 sidebar 一致),每个工作区内按时间降序排列
|
|
1208
|
+
const workspaceOrder = [];
|
|
1209
|
+
const workspaceGrouped = new Map();
|
|
1210
|
+
if (entities) {
|
|
1211
|
+
for (const entity of entities.values()) {
|
|
1212
|
+
const path = entity.record.path;
|
|
1213
|
+
workspaceOrder.push(path);
|
|
1214
|
+
workspaceGrouped.set(path, { path, title: entity.record.title, items: [] });
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
for (const item of items) {
|
|
1218
|
+
const key = item.workspacePath || item.cwd || '其他工作区';
|
|
1219
|
+
if (!workspaceGrouped.has(key)) {
|
|
1220
|
+
workspaceGrouped.set(key, { path: key, title: item.workspaceTitle || '其他项目', items: [] });
|
|
1221
|
+
workspaceOrder.push(key);
|
|
1222
|
+
}
|
|
1223
|
+
workspaceGrouped.get(key).items.push(item);
|
|
1224
|
+
}
|
|
1225
|
+
const ordered = [];
|
|
1226
|
+
for (const path of workspaceOrder) {
|
|
1227
|
+
const group = workspaceGrouped.get(path);
|
|
1228
|
+
if (!group || group.items.length === 0) continue;
|
|
1229
|
+
group.items.sort((a, b) => (b.archivedAt || 0) - (a.archivedAt || 0));
|
|
1230
|
+
ordered.push(...group.items);
|
|
1231
|
+
}
|
|
1232
|
+
return ordered;
|
|
1233
|
+
};
|
|
1234
|
+
|
|
1235
|
+
// Tag every method installed above with this install's token, then hand
|
|
1236
|
+
// back a disposer that removes exactly these methods on fiber unload —
|
|
1237
|
+
// but only those a newer instance has not since replaced (token check).
|
|
1238
|
+
const INSTALLED = [
|
|
1239
|
+
'archiveSession',
|
|
1240
|
+
'unarchiveSession',
|
|
1241
|
+
'permanentlyDeleteSession',
|
|
1242
|
+
'emptyArchivedSessions',
|
|
1243
|
+
'listArchivedSessions',
|
|
1244
|
+
];
|
|
1245
|
+
// The two patched registry methods ride ON TOP of the registry's own
|
|
1246
|
+
// implementations, so a later install must be able to unwrap them.
|
|
1247
|
+
const ORIGINALS = {
|
|
1248
|
+
archiveSession: originalArchive,
|
|
1249
|
+
unarchiveSession: originalUnarchive,
|
|
1250
|
+
};
|
|
1251
|
+
for (const name of INSTALLED) {
|
|
1252
|
+
if (typeof registry[name] === 'function') {
|
|
1253
|
+
Object.defineProperty(registry[name], TRASH_PATCH, { value: token });
|
|
1254
|
+
if (typeof ORIGINALS[name] === 'function') {
|
|
1255
|
+
Object.defineProperty(registry[name], TRASH_PATCH_ORIGINAL, { value: ORIGINALS[name] });
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
return () => {
|
|
1260
|
+
for (const name of INSTALLED) {
|
|
1261
|
+
if (registry[name]?.[TRASH_PATCH] === token) delete registry[name];
|
|
1262
|
+
}
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/* ------------------------------------------------------------------ */
|
|
1267
|
+
/* HTTP routes (the browser transport) */
|
|
1268
|
+
/* ------------------------------------------------------------------ */
|
|
1269
|
+
/*
|
|
1270
|
+
* The DSH host `/api` gateway only dispatches to its BUILD-TIME unary
|
|
1271
|
+
* route table (`UNARY_ROUTES`), so a plugin's runtime-registered typert
|
|
1272
|
+
* endpoints are never reachable from the browser (`HTTP 404`). The
|
|
1273
|
+
* supported extension path is registering routes directly on the
|
|
1274
|
+
* `webServer` service — exactly what published plugins (dsh-session-delete)
|
|
1275
|
+
* do. Routes live under /api/session-trash/* and non-GET requests must
|
|
1276
|
+
* carry the `x-dsh-plugin: session-trash` custom header as a minimal CSRF
|
|
1277
|
+
* defense (a custom header triggers a CORS preflight, which this server
|
|
1278
|
+
* never answers).
|
|
1279
|
+
*/
|
|
1280
|
+
|
|
1281
|
+
const ROUTE_PREFIX = '/api/session-trash';
|
|
1282
|
+
const PLUGIN_HEADER = 'x-dsh-plugin';
|
|
1283
|
+
const PLUGIN_HEADER_VALUE = 'session-trash';
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* Absolute path of the browser stylesheet shipped at the bundle root.
|
|
1287
|
+
* Resolved relative to this module (…/packages/session-trash-host/lib/),
|
|
1288
|
+
* which works for both npm-installed bundles and `plugin add link:`
|
|
1289
|
+
* development checkouts.
|
|
1290
|
+
*/
|
|
1291
|
+
const CLIENT_CSS_PATH = fileURLToPath(new URL('../../../client.css', import.meta.url));
|
|
1292
|
+
|
|
1293
|
+
function httpError(statusCode, code, message) {
|
|
1294
|
+
return Object.assign(new Error(message), { statusCode, code });
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/**
|
|
1298
|
+
* Translate a `WorkspaceRegistry.archiveSession` refusal into an HTTP error.
|
|
1299
|
+
*
|
|
1300
|
+
* The registry owns two checks this plugin no longer shadows: the
|
|
1301
|
+
* session-existence check and the `workspace/session-activity` gate, which
|
|
1302
|
+
* refuses to archive a session that any provider still reports as working.
|
|
1303
|
+
* Both are ordinary refusals, so without this mapping they would reach the
|
|
1304
|
+
* browser as an opaque 500.
|
|
1305
|
+
*
|
|
1306
|
+
* Matching on `name` (not `instanceof`) is deliberate: the registry classes
|
|
1307
|
+
* are not a dependency of this bundle, and both set `this.name` explicitly.
|
|
1308
|
+
*
|
|
1309
|
+
* @param {unknown} error - the thrown registry refusal.
|
|
1310
|
+
* @returns {Error} an httpError, or the original error when unrecognized.
|
|
1311
|
+
*/
|
|
1312
|
+
function mapArchiveRefusal(error) {
|
|
1313
|
+
if (error?.name === 'WorkspaceActiveSessionError') {
|
|
1314
|
+
return httpError(409, 'SESSION_RUNNING', '会话正在运行中,请先停止后再移入回收站');
|
|
1315
|
+
}
|
|
1316
|
+
if (error?.name === 'WorkspaceUnknownSessionError') {
|
|
1317
|
+
return httpError(404, 'UNKNOWN_SESSION', String(error?.message ?? error));
|
|
1318
|
+
}
|
|
1319
|
+
return error;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
function sendJson(res, status, body) {
|
|
1323
|
+
res.writeHead(status, {
|
|
1324
|
+
'content-type': 'application/json; charset=utf-8',
|
|
1325
|
+
'cache-control': 'no-store',
|
|
1326
|
+
});
|
|
1327
|
+
res.end(JSON.stringify(body));
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
async function readBody(req) {
|
|
1331
|
+
const chunks = [];
|
|
1332
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
1333
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
1334
|
+
if (!text.trim()) return {};
|
|
1335
|
+
let data;
|
|
1336
|
+
try {
|
|
1337
|
+
data = JSON.parse(text);
|
|
1338
|
+
} catch {
|
|
1339
|
+
throw httpError(400, 'INVALID_BODY', 'request body is not valid JSON');
|
|
1340
|
+
}
|
|
1341
|
+
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
|
|
1342
|
+
throw httpError(400, 'INVALID_BODY', 'request body must be a JSON object');
|
|
1343
|
+
}
|
|
1344
|
+
return data;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
function ok(res, data) {
|
|
1348
|
+
sendJson(res, 200, { ok: true, data });
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/**
|
|
1352
|
+
* Wrap one route handler: CSRF header check for non-GET, then dispatch;
|
|
1353
|
+
* every thrown error becomes a JSON error envelope.
|
|
1354
|
+
*/
|
|
1355
|
+
function guard(handler) {
|
|
1356
|
+
return async (req, res) => {
|
|
1357
|
+
try {
|
|
1358
|
+
if (req.method !== 'GET' && req.headers[PLUGIN_HEADER] !== PLUGIN_HEADER_VALUE) {
|
|
1359
|
+
throw httpError(403, 'FORBIDDEN', `missing ${PLUGIN_HEADER} header`);
|
|
1360
|
+
}
|
|
1361
|
+
await handler(req, res);
|
|
1362
|
+
} catch (error) {
|
|
1363
|
+
sendJson(res, error?.statusCode ?? 500, {
|
|
1364
|
+
ok: false,
|
|
1365
|
+
error: { code: error?.code ?? 'INTERNAL', message: String(error?.message ?? error) },
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/**
|
|
1372
|
+
* Register the /api/session-trash/* routes on the webServer service.
|
|
1373
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
1374
|
+
* @returns a disposer that unregisters every route, or null when the host
|
|
1375
|
+
* composition has no webServer (e.g. the standalone test harness).
|
|
1376
|
+
*/
|
|
1377
|
+
function registerRoutes(ctx) {
|
|
1378
|
+
const webServer = ctx.webServer;
|
|
1379
|
+
if (!webServer || typeof webServer.register !== 'function') return null;
|
|
1380
|
+
|
|
1381
|
+
const registry = ctx.workspaceRegistry;
|
|
1382
|
+
// Snapshot the injected services while the owning fiber is ACTIVE. The
|
|
1383
|
+
// route handlers below only live as long as the fiber (their disposers are
|
|
1384
|
+
// wired through ctx.effect), so resolving them per-request via ctx would
|
|
1385
|
+
// re-trap the dead-fiber guard on every call; the mount-time references are
|
|
1386
|
+
// the stable contract.
|
|
1387
|
+
const persistence = ctx.sessionPersistence;
|
|
1388
|
+
const disposers = [];
|
|
1389
|
+
|
|
1390
|
+
/** Broadcast the current archive set after any mutation. */
|
|
1391
|
+
function broadcastArchived() {
|
|
1392
|
+
const archivedSessionIds = registry?.requireState?.()?.archivedSessionIds ?? [];
|
|
1393
|
+
notifyArchivedSessionsChanged(ctx, archivedSessionIds);
|
|
1394
|
+
return archivedSessionIds;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/** All sessions: persistence headers ∪ workspace membership, with archive flag. */
|
|
1398
|
+
async function allSessions() {
|
|
1399
|
+
const headers = ((await persistence?.list?.()) ?? []).map(normalizeStoredHeader).filter(Boolean);
|
|
1400
|
+
const archived = registry?.requireState?.()?.archivedSessionIds ?? [];
|
|
1401
|
+
const byId = new Map(headers.map((h) => [h.id, h]));
|
|
1402
|
+
const wsBySession = new Map();
|
|
1403
|
+
const entities = registry?.entities;
|
|
1404
|
+
if (entities) {
|
|
1405
|
+
for (const entity of entities.values()) {
|
|
1406
|
+
for (const sid of entity.record?.sessionIds ?? []) {
|
|
1407
|
+
if (!wsBySession.has(sid)) {
|
|
1408
|
+
wsBySession.set(sid, {
|
|
1409
|
+
workspacePath: entity.record.path,
|
|
1410
|
+
workspaceTitle: entity.record.title,
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
const ids = new Set([...byId.keys(), ...wsBySession.keys()]);
|
|
1417
|
+
return [...ids]
|
|
1418
|
+
.map((sessionId) => {
|
|
1419
|
+
const header = byId.get(sessionId);
|
|
1420
|
+
const ws = wsBySession.get(sessionId);
|
|
1421
|
+
return {
|
|
1422
|
+
sessionId,
|
|
1423
|
+
title: header?.title ?? ws?.workspaceTitle ?? String(sessionId),
|
|
1424
|
+
cwd: header?.cwd,
|
|
1425
|
+
workspacePath: ws?.workspacePath,
|
|
1426
|
+
workspaceTitle: ws?.workspaceTitle,
|
|
1427
|
+
archived: archived.includes(sessionId),
|
|
1428
|
+
};
|
|
1429
|
+
})
|
|
1430
|
+
.sort((a, b) => (a.title ?? '').localeCompare(b.title ?? ''));
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
const routes = [
|
|
1434
|
+
// GET list — archived sessions (the recycle bin data source).
|
|
1435
|
+
{
|
|
1436
|
+
kind: 'exact',
|
|
1437
|
+
path: `${ROUTE_PREFIX}/list`,
|
|
1438
|
+
handler: guard(async (req, res) => {
|
|
1439
|
+
if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: { code: 'METHOD', message: 'GET only' } });
|
|
1440
|
+
const items = (await registry?.listArchivedSessions?.()) ?? [];
|
|
1441
|
+
ok(res, { items });
|
|
1442
|
+
}),
|
|
1443
|
+
},
|
|
1444
|
+
// GET sessions — every session with its workspace grouping + archive flag.
|
|
1445
|
+
{
|
|
1446
|
+
kind: 'exact',
|
|
1447
|
+
path: `${ROUTE_PREFIX}/sessions`,
|
|
1448
|
+
handler: guard(async (req, res) => {
|
|
1449
|
+
if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: { code: 'METHOD', message: 'GET only' } });
|
|
1450
|
+
ok(res, { items: await allSessions() });
|
|
1451
|
+
}),
|
|
1452
|
+
},
|
|
1453
|
+
// POST archive { sessionId, title } — move one session into the recycle bin.
|
|
1454
|
+
{
|
|
1455
|
+
kind: 'exact',
|
|
1456
|
+
path: `${ROUTE_PREFIX}/archive`,
|
|
1457
|
+
handler: guard(async (req, res) => {
|
|
1458
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: { code: 'METHOD', message: 'POST only' } });
|
|
1459
|
+
const { sessionId, title } = await readBody(req);
|
|
1460
|
+
if (!sessionId) throw httpError(400, 'BAD_REQUEST', 'sessionId is required');
|
|
1461
|
+
const agents = ctx.get('agents');
|
|
1462
|
+
const agent = typeof agents?.get === 'function' ? agents.get(sessionId) : undefined;
|
|
1463
|
+
if (agent !== undefined && agent.status !== 'idle') {
|
|
1464
|
+
throw httpError(400, 'SESSION_RUNNING', '会话正在运行中,无法删除');
|
|
1465
|
+
}
|
|
1466
|
+
try {
|
|
1467
|
+
// Only a string may become the durable display title: the registry
|
|
1468
|
+
// itself passes an OPTIONS object in this argument slot, and storing
|
|
1469
|
+
// that object is what corrupted archivedTitles before v0.4.4.
|
|
1470
|
+
await registry?.archiveSession?.(sessionId, typeof title === 'string' ? title : undefined);
|
|
1471
|
+
} catch (error) {
|
|
1472
|
+
throw mapArchiveRefusal(error);
|
|
1473
|
+
}
|
|
1474
|
+
ok(res, { sessionId, archivedSessionIds: broadcastArchived() });
|
|
1475
|
+
}),
|
|
1476
|
+
},
|
|
1477
|
+
// POST unarchive { sessionId } — restore one session from the recycle bin.
|
|
1478
|
+
{
|
|
1479
|
+
kind: 'exact',
|
|
1480
|
+
path: `${ROUTE_PREFIX}/unarchive`,
|
|
1481
|
+
handler: guard(async (req, res) => {
|
|
1482
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: { code: 'METHOD', message: 'POST only' } });
|
|
1483
|
+
const { sessionId } = await readBody(req);
|
|
1484
|
+
if (!sessionId) throw httpError(400, 'BAD_REQUEST', 'sessionId is required');
|
|
1485
|
+
await registry?.unarchiveSession?.(sessionId);
|
|
1486
|
+
ok(res, { sessionId, archivedSessionIds: broadcastArchived() });
|
|
1487
|
+
}),
|
|
1488
|
+
},
|
|
1489
|
+
// POST children { sessionIds? } — the sub-session rows of the given parent
|
|
1490
|
+
// sessions (the whole store when the list is omitted): children whose
|
|
1491
|
+
// parent resolves, plus the orphans whose parent id resolves to nothing.
|
|
1492
|
+
{
|
|
1493
|
+
kind: 'exact',
|
|
1494
|
+
path: `${ROUTE_PREFIX}/children`,
|
|
1495
|
+
handler: guard(async (req, res) => {
|
|
1496
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: { code: 'METHOD', message: 'POST only' } });
|
|
1497
|
+
const { sessionIds } = await readBody(req);
|
|
1498
|
+
ok(res, await buildChildrenReport(ctx, persistence, sessionIds));
|
|
1499
|
+
}),
|
|
1500
|
+
},
|
|
1501
|
+
// POST purge { sessionIds: [], excludeIds?: [] } — permanently delete the
|
|
1502
|
+
// given sessions AND their full-depth sub-session trees.
|
|
1503
|
+
{
|
|
1504
|
+
kind: 'exact',
|
|
1505
|
+
path: `${ROUTE_PREFIX}/purge`,
|
|
1506
|
+
handler: guard(async (req, res) => {
|
|
1507
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: { code: 'METHOD', message: 'POST only' } });
|
|
1508
|
+
const { sessionIds, excludeIds } = await readBody(req);
|
|
1509
|
+
const ids = normalizeSessionIds(sessionIds);
|
|
1510
|
+
if (ids.length === 0) throw httpError(400, 'BAD_REQUEST', 'sessionIds[] is required');
|
|
1511
|
+
const result = await purgeSessionRows(registry, ids, excludeIds);
|
|
1512
|
+
if (result.deletedCount > 0) broadcastArchived();
|
|
1513
|
+
ok(res, result);
|
|
1514
|
+
}),
|
|
1515
|
+
},
|
|
1516
|
+
// GET messages — read and parse messages for session preview modal.
|
|
1517
|
+
{
|
|
1518
|
+
kind: 'exact',
|
|
1519
|
+
path: `${ROUTE_PREFIX}/messages`,
|
|
1520
|
+
handler: guard(async (req, res) => {
|
|
1521
|
+
if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: { code: 'METHOD', message: 'GET only' } });
|
|
1522
|
+
if (!req.url) throw httpError(400, 'BAD_REQUEST', 'missing request URL');
|
|
1523
|
+
const url = new URL(req.url, 'http://localhost');
|
|
1524
|
+
const sessionId = url.searchParams.get('sessionId');
|
|
1525
|
+
if (!sessionId) throw httpError(400, 'BAD_REQUEST', 'sessionId is required');
|
|
1526
|
+
|
|
1527
|
+
const logPath = await resolveSessionLogPath(persistence, sessionId);
|
|
1528
|
+
const messages = [];
|
|
1529
|
+
|
|
1530
|
+
/** 提取用户消息中的真实对话内容,过滤掉系统上下文、skills 等注入内容 */
|
|
1531
|
+
function extractUserText(raw) {
|
|
1532
|
+
if (!raw) return '';
|
|
1533
|
+
// content 是 ContentBlock[],每个 block 有 type 和 text 字段
|
|
1534
|
+
if (Array.isArray(raw)) {
|
|
1535
|
+
return raw
|
|
1536
|
+
.filter((part) => part && part.type === 'text' && typeof part.text === 'string')
|
|
1537
|
+
.map((part) => part.text)
|
|
1538
|
+
.filter(Boolean)
|
|
1539
|
+
.join('\n')
|
|
1540
|
+
.trim();
|
|
1541
|
+
}
|
|
1542
|
+
if (typeof raw !== 'string') return '';
|
|
1543
|
+
let str = raw;
|
|
1544
|
+
// 优先提取 <USER_REQUEST> 内的内容(用户真实输入)
|
|
1545
|
+
const userReqMatch = str.match(/<USER_REQUEST>([\s\S]*?)<\/USER_REQUEST>/i);
|
|
1546
|
+
if (userReqMatch) return userReqMatch[1].trim();
|
|
1547
|
+
// 去掉已知的 XML 标签
|
|
1548
|
+
str = str.replace(/<ADDITIONAL_METADATA>[\s\S]*?<\/ADDITIONAL_METADATA>/gi, '');
|
|
1549
|
+
str = str.replace(/<USER_SETTINGS_CHANGE>[\s\S]*?<\/USER_SETTINGS_CHANGE>/gi, '');
|
|
1550
|
+
str = str.replace(/<SKILL>[\s\S]*?<\/SKILL>/gi, '');
|
|
1551
|
+
str = str.replace(/<SYSTEM_CONTEXT>[\s\S]*?<\/SYSTEM_CONTEXT>/gi, '');
|
|
1552
|
+
str = str.replace(/<WORKSPACE>[\s\S]*?<\/WORKSPACE>/gi, '');
|
|
1553
|
+
// 去掉所有剩余的 HTML/XML 标签
|
|
1554
|
+
str = str.replace(/<[^>]+>/g, '');
|
|
1555
|
+
return str.trim();
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
/** 提取助手消息中的真实回复内容,过滤掉 reasoning 思考块、tool-call 等 */
|
|
1559
|
+
function extractAssistantText(content) {
|
|
1560
|
+
if (!content) return '';
|
|
1561
|
+
// content 是 ContentBlock[],过滤出 type 为 'text' 的块(排除 reasoning/tool-call/tool-result)
|
|
1562
|
+
if (Array.isArray(content)) {
|
|
1563
|
+
return content
|
|
1564
|
+
.filter((part) => part && part.type === 'text' && typeof part.text === 'string')
|
|
1565
|
+
.map((part) => part.text)
|
|
1566
|
+
.filter(Boolean)
|
|
1567
|
+
.join('\n')
|
|
1568
|
+
.trim();
|
|
1569
|
+
}
|
|
1570
|
+
// 内容为字符串:去掉 thinking 块(兼容旧格式)
|
|
1571
|
+
if (typeof content === 'string') {
|
|
1572
|
+
let str = content;
|
|
1573
|
+
str = str.replace(/```thinking[\s\S]*?```/g, '');
|
|
1574
|
+
str = str.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '');
|
|
1575
|
+
return str.trim();
|
|
1576
|
+
}
|
|
1577
|
+
return '';
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
function parseRole(obj) {
|
|
1581
|
+
if (!obj) return null;
|
|
1582
|
+
const raw = String(obj.role || obj.type || obj.kind || obj.event || '').toUpperCase();
|
|
1583
|
+
if (raw.includes('USER') || raw.includes('HUMAN') || raw.includes('INPUT')) return 'user';
|
|
1584
|
+
if (raw.includes('ASSISTANT') || raw.includes('PLANNER') || raw.includes('RESPONSE') || raw.includes('MODEL') || raw.includes('AGENT') || raw.includes('OUTPUT')) return 'assistant';
|
|
1585
|
+
return null;
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
function cleanText(raw) {
|
|
1589
|
+
if (typeof raw !== 'string') return '';
|
|
1590
|
+
let str = raw;
|
|
1591
|
+
str = str.replace(/<USER_REQUEST>([\s\S]*?)<\/USER_REQUEST>/gi, '$1');
|
|
1592
|
+
str = str.replace(/<ADDITIONAL_METADATA>[\s\S]*?<\/ADDITIONAL_METADATA>/gi, '');
|
|
1593
|
+
str = str.replace(/<USER_SETTINGS_CHANGE>[\s\S]*?<\/USER_SETTINGS_CHANGE>/gi, '');
|
|
1594
|
+
return str.trim();
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
function extractAnyText(val) {
|
|
1598
|
+
if (!val) return '';
|
|
1599
|
+
if (typeof val === 'string') return cleanText(val);
|
|
1600
|
+
if (Array.isArray(val)) {
|
|
1601
|
+
return val.map(extractAnyText).filter(Boolean).join('\n');
|
|
1602
|
+
}
|
|
1603
|
+
if (typeof val === 'object') {
|
|
1604
|
+
if (typeof val.text === 'string') return cleanText(val.text);
|
|
1605
|
+
if (val.content) return extractAnyText(val.content);
|
|
1606
|
+
if (typeof val.value === 'string') return cleanText(val.value);
|
|
1607
|
+
if (typeof val.prompt === 'string') return cleanText(val.prompt);
|
|
1608
|
+
if (typeof val.message === 'string') return cleanText(val.message);
|
|
1609
|
+
if (val.payload) return extractAnyText(val.payload);
|
|
1610
|
+
if (val.data) return extractAnyText(val.data);
|
|
1611
|
+
}
|
|
1612
|
+
return '';
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
function parseContent(obj) {
|
|
1616
|
+
if (!obj) return '';
|
|
1617
|
+
return extractAnyText(obj.content ?? obj.text ?? obj.prompt ?? obj.message ?? obj.query ?? obj.payload ?? obj);
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
// 优先读取已解压的会话事件(DSH 默认启用 Zstd 压缩,直接 readFile 读到的是
|
|
1621
|
+
// 二进制数据)。0.1.5 起 inspect 移除,改用 open(id, 'read') → handle.read()
|
|
1622
|
+
// (见 readSessionEvents)。
|
|
1623
|
+
try {
|
|
1624
|
+
const events = await readSessionEvents(persistence, sessionId);
|
|
1625
|
+
if (Array.isArray(events)) {
|
|
1626
|
+
for (const event of events) {
|
|
1627
|
+
if (event.type === 'user/message') {
|
|
1628
|
+
// 只保留 source.kind === 'user' 的真实用户输入,
|
|
1629
|
+
// 排除 plugin 注入的上下文(skills、系统上下文、工具结果等)
|
|
1630
|
+
const sourceKind = event.data?.source?.kind;
|
|
1631
|
+
if (sourceKind && sourceKind !== 'user') continue;
|
|
1632
|
+
// 用户消息:提取 ContentBlock[] 中的 text 块,过滤掉系统上下文
|
|
1633
|
+
const content = extractUserText(event.data?.content);
|
|
1634
|
+
if (content) {
|
|
1635
|
+
messages.push({
|
|
1636
|
+
role: 'user',
|
|
1637
|
+
content,
|
|
1638
|
+
timestamp: event.createdAt || Date.now(),
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
} else if (event.type === 'assistant/message') {
|
|
1642
|
+
// 助手消息:只取 text 块,过滤掉 reasoning 思考块、tool-call 等
|
|
1643
|
+
const content = extractAssistantText(event.data?.message?.content);
|
|
1644
|
+
if (content) {
|
|
1645
|
+
messages.push({
|
|
1646
|
+
role: 'assistant',
|
|
1647
|
+
content,
|
|
1648
|
+
timestamp: event.createdAt || Date.now(),
|
|
1649
|
+
});
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
} catch {
|
|
1655
|
+
// inspect 失败后回退到直接读取日志文件(兼容未压缩的日志)
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
// 回退方案:直接读取日志文件(仅当事件读取未返回任何消息时)
|
|
1659
|
+
if (messages.length === 0) {
|
|
1660
|
+
// logPath 已由 resolveSessionLogPath 解析(findLog → locate → 目录扫描);
|
|
1661
|
+
// 此处只保留最后的广谱候选路径扫描(兼容活跃日志、回收站归档日志及 profiles 目录)。
|
|
1662
|
+
let resolvedLogPath = logPath;
|
|
1663
|
+
|
|
1664
|
+
if (!resolvedLogPath) {
|
|
1665
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
1666
|
+
const candidates = [
|
|
1667
|
+
join(home, '.dsh', 'profiles', 'web', 'trash', `${sessionId}.jsonl`),
|
|
1668
|
+
join(home, '.dsh', 'profiles', 'default', 'trash', `${sessionId}.jsonl`),
|
|
1669
|
+
join(home, '.dsh', 'trash', `${sessionId}.jsonl`),
|
|
1670
|
+
join(home, '.dsh', 'logs', `${sessionId}.jsonl`),
|
|
1671
|
+
join(process.cwd(), 'trash', `${sessionId}.jsonl`),
|
|
1672
|
+
];
|
|
1673
|
+
for (const p of candidates) {
|
|
1674
|
+
if (!p) continue;
|
|
1675
|
+
const exists = await stat(p).then((s) => s.isFile()).catch(() => false);
|
|
1676
|
+
if (exists) {
|
|
1677
|
+
resolvedLogPath = p;
|
|
1678
|
+
break;
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
if (resolvedLogPath) {
|
|
1684
|
+
try {
|
|
1685
|
+
const content = await readFile(resolvedLogPath, 'utf8');
|
|
1686
|
+
const lines = content.split('\n');
|
|
1687
|
+
for (const line of lines) {
|
|
1688
|
+
if (!line.trim()) continue;
|
|
1689
|
+
try {
|
|
1690
|
+
const obj = JSON.parse(line);
|
|
1691
|
+
const typeStr = String(obj.type || '').toUpperCase();
|
|
1692
|
+
if (typeStr.includes('SETTINGS') || typeStr.includes('NOTIFICATION')) continue;
|
|
1693
|
+
|
|
1694
|
+
const role = parseRole(obj);
|
|
1695
|
+
const text = parseContent(obj);
|
|
1696
|
+
if (role && text) {
|
|
1697
|
+
messages.push({
|
|
1698
|
+
role,
|
|
1699
|
+
content: text,
|
|
1700
|
+
timestamp: obj.timestamp || obj.time || obj.created_at || Date.now(),
|
|
1701
|
+
});
|
|
1702
|
+
} else if (Array.isArray(obj.messages)) {
|
|
1703
|
+
for (const m of obj.messages) {
|
|
1704
|
+
const r = parseRole(m);
|
|
1705
|
+
const t = parseContent(m);
|
|
1706
|
+
if (r && t) {
|
|
1707
|
+
messages.push({
|
|
1708
|
+
role: r,
|
|
1709
|
+
content: t,
|
|
1710
|
+
timestamp: m.timestamp || m.time || Date.now(),
|
|
1711
|
+
});
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
} catch {}
|
|
1716
|
+
}
|
|
1717
|
+
// Fallback: 如果广谱识别未匹配到 user/assistant
|
|
1718
|
+
if (messages.length === 0) {
|
|
1719
|
+
for (const line of lines) {
|
|
1720
|
+
if (!line.trim()) continue;
|
|
1721
|
+
try {
|
|
1722
|
+
const obj = JSON.parse(line);
|
|
1723
|
+
const typeStr = String(obj.type || '').toUpperCase();
|
|
1724
|
+
if (typeStr.includes('SETTINGS') || typeStr.includes('NOTIFICATION')) continue;
|
|
1725
|
+
const text = parseContent(obj);
|
|
1726
|
+
if (text) {
|
|
1727
|
+
messages.push({
|
|
1728
|
+
role: String(obj.type || obj.role || 'system').toUpperCase().includes('USER') ? 'user' : 'assistant',
|
|
1729
|
+
content: text,
|
|
1730
|
+
timestamp: obj.timestamp || obj.time || Date.now(),
|
|
1731
|
+
});
|
|
1732
|
+
}
|
|
1733
|
+
} catch {}
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
} catch {}
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
ok(res, { sessionId, messages });
|
|
1741
|
+
}),
|
|
1742
|
+
},
|
|
1743
|
+
// POST purge-workspace { workspacePath, sessionIds?, excludeIds? } — permanently delete all archived sessions in a workspace.
|
|
1744
|
+
{
|
|
1745
|
+
kind: 'exact',
|
|
1746
|
+
path: `${ROUTE_PREFIX}/purge-workspace`,
|
|
1747
|
+
handler: guard(async (req, res) => {
|
|
1748
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: { code: 'METHOD', message: 'POST only' } });
|
|
1749
|
+
const { workspacePath, sessionIds, excludeIds } = await readBody(req);
|
|
1750
|
+
let targetIds = normalizeSessionIds(sessionIds);
|
|
1751
|
+
|
|
1752
|
+
if (targetIds.length === 0 && workspacePath) {
|
|
1753
|
+
const archived = (await registry?.listArchivedSessions?.()) ?? [];
|
|
1754
|
+
targetIds = archived.filter((item) => item.workspacePath === workspacePath || item.cwd === workspacePath).map((item) => item.sessionId);
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
if (targetIds.length === 0) {
|
|
1758
|
+
return ok(res, { deletedCount: 0, keptCount: 0, deleted: [], kept: [], failed: [], errors: [], total: 0 });
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
const result = await purgeSessionRows(registry, targetIds, excludeIds);
|
|
1762
|
+
if (result.deletedCount > 0) broadcastArchived();
|
|
1763
|
+
ok(res, result);
|
|
1764
|
+
}),
|
|
1765
|
+
},
|
|
1766
|
+
// POST empty { excludeIds? } — permanently delete every archived session.
|
|
1767
|
+
{
|
|
1768
|
+
kind: 'exact',
|
|
1769
|
+
path: `${ROUTE_PREFIX}/empty`,
|
|
1770
|
+
handler: guard(async (req, res) => {
|
|
1771
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: { code: 'METHOD', message: 'POST only' } });
|
|
1772
|
+
const { excludeIds } = await readBody(req);
|
|
1773
|
+
const result = await registry.emptyArchivedSessions({ excludeIds: normalizeSessionIds(excludeIds) });
|
|
1774
|
+
broadcastArchived();
|
|
1775
|
+
ok(res, result);
|
|
1776
|
+
}),
|
|
1777
|
+
},
|
|
1778
|
+
// GET client.css — the plugin stylesheet (light/dark adaptive design
|
|
1779
|
+
// tokens). client.js injects it as a <link rel="stylesheet">. Read
|
|
1780
|
+
// lazily per request so stylesheet edits show up after a browser
|
|
1781
|
+
// refresh without restarting the profile process.
|
|
1782
|
+
{
|
|
1783
|
+
kind: 'exact',
|
|
1784
|
+
path: `${ROUTE_PREFIX}/client.css`,
|
|
1785
|
+
handler: guard(async (req, res) => {
|
|
1786
|
+
if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: { code: 'METHOD', message: 'GET only' } });
|
|
1787
|
+
let css;
|
|
1788
|
+
try {
|
|
1789
|
+
css = await readFile(CLIENT_CSS_PATH, 'utf8');
|
|
1790
|
+
} catch {
|
|
1791
|
+
throw httpError(404, 'CSS_NOT_FOUND', `client.css is missing at ${CLIENT_CSS_PATH}`);
|
|
1792
|
+
}
|
|
1793
|
+
res.writeHead(200, {
|
|
1794
|
+
'content-type': 'text/css; charset=utf-8',
|
|
1795
|
+
'cache-control': 'no-cache',
|
|
1796
|
+
});
|
|
1797
|
+
res.end(css);
|
|
1798
|
+
}),
|
|
1799
|
+
},
|
|
1800
|
+
];
|
|
1801
|
+
|
|
1802
|
+
for (const route of routes) {
|
|
1803
|
+
try {
|
|
1804
|
+
disposers.push(webServer.register(route));
|
|
1805
|
+
} catch (error) {
|
|
1806
|
+
// The webServer rejects duplicate paths. The only way one of OUR paths
|
|
1807
|
+
// can already be taken is a stale bundle that leaked its routes on
|
|
1808
|
+
// fiber unload (<= v0.3.0 stored the disposer where cordis never calls
|
|
1809
|
+
// it). That leaked state only clears on a profile-process restart, so
|
|
1810
|
+
// fail with an actionable message instead of a bare duplicate error.
|
|
1811
|
+
throw new Error(
|
|
1812
|
+
`session-trash-host: cannot register ${route.path}: ${error?.message ?? error}`
|
|
1813
|
+
+ ' — a route leaked by a previous instance still occupies this path;'
|
|
1814
|
+
+ ' restart dsh web to clean up',
|
|
1815
|
+
{ cause: error },
|
|
1816
|
+
);
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
return () => {
|
|
1820
|
+
for (const dispose of disposers) {
|
|
1821
|
+
try {
|
|
1822
|
+
dispose();
|
|
1823
|
+
} catch {
|
|
1824
|
+
// best effort
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
};
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
/* ------------------------------------------------------------------ */
|
|
1831
|
+
/* SessionTrashHost — Cordis plugin entry point */
|
|
1832
|
+
/* ------------------------------------------------------------------ */
|
|
1833
|
+
|
|
1834
|
+
export class SessionTrashHost extends ServiceClass {
|
|
1835
|
+
static inject = [
|
|
1836
|
+
'sessionPersistence',
|
|
1837
|
+
'sessionProjectionCache',
|
|
1838
|
+
'workspaceRegistry',
|
|
1839
|
+
'webServer',
|
|
1840
|
+
];
|
|
1841
|
+
|
|
1842
|
+
#routesDisposer;
|
|
1843
|
+
|
|
1844
|
+
constructor(ctx) {
|
|
1845
|
+
super(ctx, 'sessionTrashHost');
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
async [ServiceClass.init || Symbol.for('cordis.init')]() {
|
|
1849
|
+
const ctx = this.ctx;
|
|
1850
|
+
|
|
1851
|
+
// v0.3.2: every service patch is now REVERSIBLE. Each installer returns a
|
|
1852
|
+
// disposer bound to this fiber, and a stale patch left behind by an
|
|
1853
|
+
// earlier fiber is detected via its TRASH_PATCH tag and replaced with one
|
|
1854
|
+
// closing over the live context. Before this, uninstalling + reinstalling
|
|
1855
|
+
// without a restart left the registry methods of the DEAD fiber in place
|
|
1856
|
+
// (the old 'typeof x !== function' guards refused to re-patch), so the
|
|
1857
|
+
// recycle-bin list kept calling listArchivedSessions() whose closure held
|
|
1858
|
+
// an inactive ctx — ctx.sessionPersistence threw inside the per-session
|
|
1859
|
+
// try/catch and every turn count silently read 0 — while the preview
|
|
1860
|
+
// (routes rebuilt per mount, snapshotting services) worked fine.
|
|
1861
|
+
const undoPatches = [];
|
|
1862
|
+
|
|
1863
|
+
const persistence = ctx.sessionPersistence;
|
|
1864
|
+
if (persistence) undoPatches.push(patchSessionPersistenceDelete(persistence));
|
|
1865
|
+
|
|
1866
|
+
const cache = ctx.get('sessionProjectionCache');
|
|
1867
|
+
if (cache) undoPatches.push(patchProjectionCacheRemove(cache));
|
|
1868
|
+
|
|
1869
|
+
const registry = ctx.workspaceRegistry;
|
|
1870
|
+
if (registry) undoPatches.push(patchWorkspaceRegistry(registry, ctx));
|
|
1871
|
+
|
|
1872
|
+
const disposeRoutes = registerRoutes(ctx);
|
|
1873
|
+
const disposeAll = () => {
|
|
1874
|
+
try { disposeRoutes?.(); } catch { /* best effort */ }
|
|
1875
|
+
for (const undo of undoPatches) {
|
|
1876
|
+
try { undo?.(); } catch { /* best effort */ }
|
|
1877
|
+
}
|
|
1878
|
+
};
|
|
1879
|
+
|
|
1880
|
+
// v0.3.1 fix, extended: bind BOTH the routes and the service patches to
|
|
1881
|
+
// THIS fiber's lifecycle — cordis's "register() disposer contract".
|
|
1882
|
+
// @deepseek-ai/cordis@4 never calls a class instance's dispose(); only
|
|
1883
|
+
// effects collected via ctx.effect unwind on fiber unload. Without this
|
|
1884
|
+
// any in-process reload (reinstalling the plugin without restarting dsh
|
|
1885
|
+
// web, a loader config update, a dependency service restart) leaked the
|
|
1886
|
+
// old routes bound to the now-inactive context — ctx.sessionPersistence
|
|
1887
|
+
// then threw "cannot get required service ... in inactive context" and
|
|
1888
|
+
// the reloaded instance died on webServer.register's duplicate-path
|
|
1889
|
+
// throw. disposeAll is idempotent (token checks), so the defensive
|
|
1890
|
+
// dispose() below can share it.
|
|
1891
|
+
this.#routesDisposer = disposeAll;
|
|
1892
|
+
|
|
1893
|
+
if (typeof ctx.effect === 'function') {
|
|
1894
|
+
ctx.effect(() => disposeAll, 'session-trash-host: /api/session-trash/* routes + service patches');
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
dispose() {
|
|
1899
|
+
this.#routesDisposer?.();
|
|
1900
|
+
this.#routesDisposer = undefined;
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
export default SessionTrashHost;
|