@logic-dante/dsh-conversation-manager 1.5.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -0
- package/index.js +655 -492
- package/package.json +2 -1
package/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# dsh-conversation-manager
|
|
2
|
+
|
|
3
|
+
DSH(DeepSeek Harness)插件:在侧栏会话的「...」菜单里**真正删除**对话,连同磁盘上的会话数据一起清除。DSH 自带的 archive 只是把会话隐藏,本插件是真删。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
dsh plugin --profile web add github:lloggc-Dante/dsh-conversation-manager
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
装完**重启 DSH**。`--profile` 换成你实际使用的 profile 名。
|
|
12
|
+
|
|
13
|
+
卸载:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
dsh plugin --profile web remove @logic-dante/dsh-conversation-manager
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## 用法
|
|
20
|
+
|
|
21
|
+
- **删单个**:鼠标移到侧栏的会话 → 打开「...」菜单 → 点红色的「删除对话」。该行立刻从列表消失,不用刷新页面。
|
|
22
|
+
- **批量删**:点搜索框旁边的多选按钮 → 逐个勾选会话 → 点账户区上方的删除栏 → 二次确认后删除。
|
|
23
|
+
|
|
24
|
+
## 删除会做什么
|
|
25
|
+
|
|
26
|
+
删掉 `~/.dsh/sessions/<项目>/<session-id>/` 整个目录(含 `session.v4.jsonl.zstd` 日志)和 `storages/session_projcache` 里的缓存记录,并把这个 id 从所有工作区、归档与置顶列表里摘除。
|
|
27
|
+
|
|
28
|
+
**不可恢复,没有回收站。** 删除前会先停掉这个对话正在跑的工作;如果文件还被占用,会提示你先切到别的对话再删。
|
|
29
|
+
|
|
30
|
+
## 兼容性
|
|
31
|
+
|
|
32
|
+
需要 DSH 的 web 端(`dsh.client.platform: web`)。插件依赖 `connection.fetch` 路由和 `workspaceRegistry` / `sessionPersistence` 两个服务。
|
|
33
|
+
|
|
34
|
+
## English
|
|
35
|
+
|
|
36
|
+
Deletes a conversation from its sidebar "..." menu — for real, not an archive. Install with:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
dsh plugin --profile web add github:lloggc-Dante/dsh-conversation-manager
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Restart DSH afterwards. The red "Delete conversation" row removes the session directory and log, the projection-cache record, and the id from every workspace, archive and pin list. Multi-select delete lives behind the toggle beside the search box and asks for confirmation. Deletion is permanent — there is no trash.
|
package/index.js
CHANGED
|
@@ -1,492 +1,655 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-conversation-manager — host half.
|
|
3
|
-
*
|
|
4
|
-
* The sidebar gives a session only "archive", which keeps the conversation
|
|
5
|
-
* stored and merely hides it from the list. This plugin adds a real delete: one
|
|
6
|
-
* red row in a session's "..." menu (see client.js) calls the authenticated
|
|
7
|
-
* route below, and the host half removes the durable session store.
|
|
8
|
-
*
|
|
9
|
-
* POST /api/conversation-manager/delete { "sessionId": "session-…" }
|
|
10
|
-
*
|
|
11
|
-
* Effects of one call:
|
|
12
|
-
* 1. the session's own directory `<DSH_HOME>/sessions/<cwd group>/<id>/` is
|
|
13
|
-
* removed together with its `session.v4.jsonl.zstd` log;
|
|
14
|
-
* 2. its projection-cache record is removed;
|
|
15
|
-
* 3. the id is detached from every workspace and dropped from the archived /
|
|
16
|
-
* pinned id lists, so the sidebar stops listing it.
|
|
17
|
-
*
|
|
18
|
-
* Both on-disk names are escaped: the cwd group by `projectKey()` and the
|
|
19
|
-
* session directory by `encodeSegment()`. Those two escapes are mirrored below
|
|
20
|
-
* from `@deepseek-ai/dsh-session-persistence-jsonl`; the session directory is
|
|
21
|
-
* also discovered by scanning the group directories, and the exact group is
|
|
22
|
-
* derived from the session header cwd through `sessionPersistence.stat()`.
|
|
23
|
-
*
|
|
24
|
-
* Deletion is irreversible: there is no trash, exactly like `rm -rf`.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (
|
|
104
|
-
let
|
|
105
|
-
let
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
if (
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
const
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
*
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
return
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
async function
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
try {
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
1
|
+
/**
|
|
2
|
+
* dsh-conversation-manager — host half.
|
|
3
|
+
*
|
|
4
|
+
* The sidebar gives a session only "archive", which keeps the conversation
|
|
5
|
+
* stored and merely hides it from the list. This plugin adds a real delete: one
|
|
6
|
+
* red row in a session's "..." menu (see client.js) calls the authenticated
|
|
7
|
+
* route below, and the host half removes the durable session store.
|
|
8
|
+
*
|
|
9
|
+
* POST /api/conversation-manager/delete { "sessionId": "session-…" }
|
|
10
|
+
*
|
|
11
|
+
* Effects of one call:
|
|
12
|
+
* 1. the session's own directory `<DSH_HOME>/sessions/<cwd group>/<id>/` is
|
|
13
|
+
* removed together with its `session.v4.jsonl.zstd` log;
|
|
14
|
+
* 2. its projection-cache record is removed;
|
|
15
|
+
* 3. the id is detached from every workspace and dropped from the archived /
|
|
16
|
+
* pinned id lists, so the sidebar stops listing it.
|
|
17
|
+
*
|
|
18
|
+
* Both on-disk names are escaped: the cwd group by `projectKey()` and the
|
|
19
|
+
* session directory by `encodeSegment()`. Those two escapes are mirrored below
|
|
20
|
+
* from `@deepseek-ai/dsh-session-persistence-jsonl`; the session directory is
|
|
21
|
+
* also discovered by scanning the group directories, and the exact group is
|
|
22
|
+
* derived from the session header cwd through `sessionPersistence.stat()`.
|
|
23
|
+
*
|
|
24
|
+
* Deletion is irreversible: there is no trash, exactly like `rm -rf`. A deleted
|
|
25
|
+
* id is also remembered (tombstoned) for a while: a Session that is still
|
|
26
|
+
* resident can flush its log back to disk after the delete, so a small sweeper
|
|
27
|
+
* keeps removing that copy and keeps the row off every open page.
|
|
28
|
+
*/
|
|
29
|
+
import { promises as fsp } from 'node:fs';
|
|
30
|
+
import { homedir } from 'node:os';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
|
|
33
|
+
const ROUTE = '/api/conversation-manager/delete';
|
|
34
|
+
const ACTION_HEADER = 'x-dsh-conversation-manager';
|
|
35
|
+
const BODY_LIMIT = 64 * 1024;
|
|
36
|
+
/** Session ids are opaque, but they are also used as a directory name. */
|
|
37
|
+
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:@~-]{0,199}$/;
|
|
38
|
+
const SAFE_SEGMENT = /^[A-Za-z0-9._-]$/;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A deleted id is remembered for a while: a session that is still resident in
|
|
42
|
+
* the host keeps a writer alive and can flush its log back to disk, which made
|
|
43
|
+
* the row reappear after a "successful" delete. The tombstone lets the sweeper
|
|
44
|
+
* remove the recreated store again until the writer is gone.
|
|
45
|
+
*/
|
|
46
|
+
const DELETED_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
47
|
+
const ENFORCE_WINDOW_MS = 10 * 60 * 1000;
|
|
48
|
+
const ENFORCE_INTERVAL_MS = 2500;
|
|
49
|
+
|
|
50
|
+
const json = (status, value) =>
|
|
51
|
+
new Response(JSON.stringify(value), {
|
|
52
|
+
status,
|
|
53
|
+
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const fail = (status, message) => json(status, { ok: false, message });
|
|
57
|
+
|
|
58
|
+
const textOf = (error) => (error instanceof Error ? error.message : String(error));
|
|
59
|
+
|
|
60
|
+
const warn = (...args) => {
|
|
61
|
+
try {
|
|
62
|
+
console.warn('[conversation-manager]', ...args);
|
|
63
|
+
} catch {
|
|
64
|
+
/* console may be gone during shutdown */
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
class RequestError extends Error {
|
|
69
|
+
constructor(status, message) {
|
|
70
|
+
super(message);
|
|
71
|
+
this.status = status;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Resolve a host service without hard-failing when the profile does not have it. */
|
|
76
|
+
function service(ctx, name) {
|
|
77
|
+
try {
|
|
78
|
+
const found = ctx.get?.(name);
|
|
79
|
+
if (found) return found;
|
|
80
|
+
} catch {
|
|
81
|
+
/* optional access */
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
return ctx[name];
|
|
85
|
+
} catch {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function dshHome() {
|
|
91
|
+
const fromEnv = process.env.DSH_HOME;
|
|
92
|
+
return typeof fromEnv === 'string' && fromEnv.length > 0 ? fromEnv : path.join(homedir(), '.dsh');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* ------------------------------------------------------------------------- *
|
|
96
|
+
* Mirror of the persistence backend's path escapes.
|
|
97
|
+
* ------------------------------------------------------------------------- */
|
|
98
|
+
|
|
99
|
+
/** `encodeSegment()` from the jsonl persistence backend: one safe path segment. */
|
|
100
|
+
function encodeSegment(raw) {
|
|
101
|
+
if (raw.length === 0) throw new Error('cannot encode an empty path segment');
|
|
102
|
+
if (raw === '.') return '~002E';
|
|
103
|
+
if (raw === '..') return '~002E~002E';
|
|
104
|
+
let out = '';
|
|
105
|
+
for (let i = 0; i < raw.length; i += 1) {
|
|
106
|
+
const code = raw.charCodeAt(i);
|
|
107
|
+
const ch = String.fromCharCode(code);
|
|
108
|
+
if (ch !== '~' && SAFE_SEGMENT.test(ch)) out += ch;
|
|
109
|
+
else out += '~' + code.toString(16).toUpperCase().padStart(4, '0');
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** `projectKey()` from the jsonl persistence backend: the cwd group directory. */
|
|
115
|
+
function projectKey(cwd) {
|
|
116
|
+
if (cwd.length === 0) throw new Error('cannot encode an empty project path');
|
|
117
|
+
let readable = '';
|
|
118
|
+
let separatorRun = false;
|
|
119
|
+
for (let i = 0; i < cwd.length; i += 1) {
|
|
120
|
+
const code = cwd.charCodeAt(i);
|
|
121
|
+
const ch = String.fromCharCode(code);
|
|
122
|
+
if (ch === '/' || ch === '\\' || ch === ':') {
|
|
123
|
+
if (!separatorRun) readable += '-';
|
|
124
|
+
separatorRun = true;
|
|
125
|
+
} else if (ch !== '~' && SAFE_SEGMENT.test(ch)) {
|
|
126
|
+
readable += ch;
|
|
127
|
+
separatorRun = false;
|
|
128
|
+
} else {
|
|
129
|
+
readable += '~' + code.toString(16).toUpperCase().padStart(4, '0');
|
|
130
|
+
separatorRun = false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return `--${(readable.replace(/^-+/, '') || 'root').slice(0, 251)}--`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Same-origin guard: reject cross-site / cross-origin callers. */
|
|
137
|
+
function originVerdict(request) {
|
|
138
|
+
const site = request.headers.get('sec-fetch-site');
|
|
139
|
+
if (site && !['same-origin', 'none'].includes(site)) return fail(403, '拒绝跨站请求');
|
|
140
|
+
const origin = request.headers.get('origin');
|
|
141
|
+
if (origin && /^https?:/i.test(origin)) {
|
|
142
|
+
try {
|
|
143
|
+
const host = request.headers.get('host') || new URL(request.url).host;
|
|
144
|
+
if (new URL(origin).host !== host) return fail(403, '拒绝跨源请求');
|
|
145
|
+
} catch {
|
|
146
|
+
return fail(403, '无效来源');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function readJson(request) {
|
|
153
|
+
const reader = request.body?.getReader();
|
|
154
|
+
if (!reader) throw new RequestError(400, '缺少请求内容');
|
|
155
|
+
const chunks = [];
|
|
156
|
+
let size = 0;
|
|
157
|
+
try {
|
|
158
|
+
for (;;) {
|
|
159
|
+
const { value, done } = await reader.read();
|
|
160
|
+
if (done) break;
|
|
161
|
+
size += value.byteLength;
|
|
162
|
+
if (size > BODY_LIMIT) {
|
|
163
|
+
await reader.cancel();
|
|
164
|
+
throw new RequestError(413, '请求内容过大');
|
|
165
|
+
}
|
|
166
|
+
chunks.push(value);
|
|
167
|
+
}
|
|
168
|
+
} finally {
|
|
169
|
+
try {
|
|
170
|
+
reader.releaseLock();
|
|
171
|
+
} catch {
|
|
172
|
+
/* already released */
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
|
176
|
+
if (!raw) throw new RequestError(400, '请求内容为空');
|
|
177
|
+
try {
|
|
178
|
+
return JSON.parse(raw);
|
|
179
|
+
} catch {
|
|
180
|
+
throw new RequestError(400, '请求内容不是合法 JSON');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const isDirectory = async (candidate) => {
|
|
185
|
+
try {
|
|
186
|
+
return (await fsp.stat(candidate)).isDirectory();
|
|
187
|
+
} catch {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Every directory that may hold this session: the exact one derived from the
|
|
194
|
+
* header cwd, plus any group that happens to contain the escaped id.
|
|
195
|
+
*/
|
|
196
|
+
async function locateSessionDirs(ctx, sessionId) {
|
|
197
|
+
const home = dshHome();
|
|
198
|
+
const sessionsRoot = path.join(home, 'sessions');
|
|
199
|
+
const found = new Set();
|
|
200
|
+
|
|
201
|
+
const persistence = service(ctx, 'sessionPersistence');
|
|
202
|
+
if (persistence && typeof persistence.stat === 'function') {
|
|
203
|
+
try {
|
|
204
|
+
const snapshot = await persistence.stat(sessionId);
|
|
205
|
+
const cwd = snapshot?.header?.cwd;
|
|
206
|
+
if (typeof cwd === 'string' && cwd.length > 0) {
|
|
207
|
+
const group = cwd === void 0 ? '_no-cwd' : projectKey(cwd);
|
|
208
|
+
const exact = path.join(sessionsRoot, group, encodeSegment(sessionId));
|
|
209
|
+
if (await isDirectory(exact)) found.add(exact);
|
|
210
|
+
} else if (snapshot) {
|
|
211
|
+
const noCwd = path.join(sessionsRoot, '_no-cwd', encodeSegment(sessionId));
|
|
212
|
+
if (await isDirectory(noCwd)) found.add(noCwd);
|
|
213
|
+
}
|
|
214
|
+
} catch (error) {
|
|
215
|
+
warn('sessionPersistence.stat failed', sessionId, textOf(error));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
let groups = [];
|
|
220
|
+
try {
|
|
221
|
+
groups = await fsp.readdir(sessionsRoot, { withFileTypes: true });
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
224
|
+
}
|
|
225
|
+
const escaped = encodeSegment(sessionId);
|
|
226
|
+
for (const group of groups) {
|
|
227
|
+
if (!group.isDirectory()) continue;
|
|
228
|
+
for (const name of new Set([escaped, sessionId])) {
|
|
229
|
+
const candidate = path.join(sessionsRoot, group.name, name);
|
|
230
|
+
if (await isDirectory(candidate)) found.add(candidate);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return [...found];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Remove the projection-cache record that mirrors the session summary. */
|
|
237
|
+
async function removeProjectionCache(sessionId) {
|
|
238
|
+
const cacheDir = path.join(dshHome(), 'storages', 'session_projcache', 'sessions');
|
|
239
|
+
for (const name of new Set([`${sessionId}.json`, `${encodeSegment(sessionId)}.json`])) {
|
|
240
|
+
try {
|
|
241
|
+
await fsp.rm(path.join(cacheDir, name), { force: true });
|
|
242
|
+
} catch {
|
|
243
|
+
/* a stale projection cache is harmless */
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Remove the durable store plus its projection-cache record. */
|
|
249
|
+
async function removeStorage(ctx, sessionId, targets) {
|
|
250
|
+
const removed = [];
|
|
251
|
+
for (const dir of targets) {
|
|
252
|
+
try {
|
|
253
|
+
await fsp.rm(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
|
|
254
|
+
removed.push(dir);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
const busy = ['EBUSY', 'EPERM', 'EACCES', 'ENOTEMPTY'].includes(error?.code);
|
|
257
|
+
throw new RequestError(
|
|
258
|
+
409,
|
|
259
|
+
busy
|
|
260
|
+
? '会话数据仍被占用,请先停止这个对话(换一个对话再删)后重试。'
|
|
261
|
+
: '删除会话数据失败:' + textOf(error),
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
await removeProjectionCache(sessionId);
|
|
266
|
+
return { targets, removed };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Hide the row immediately. Deleting the log makes the session disappear on the
|
|
271
|
+
* next list refresh, but an already-open page keeps its last snapshot; marking
|
|
272
|
+
* the id archived makes the sidebar drop that row at once, and the journal below
|
|
273
|
+
* clears the marker again once the store is gone.
|
|
274
|
+
*/
|
|
275
|
+
async function hideSession(ctx, sessionId) {
|
|
276
|
+
const registry = service(ctx, 'workspaceRegistry');
|
|
277
|
+
if (!registry || typeof registry.archiveSession !== 'function') return false;
|
|
278
|
+
try {
|
|
279
|
+
await registry.archiveSession(sessionId, { stopActivity: true });
|
|
280
|
+
return true;
|
|
281
|
+
} catch (error) {
|
|
282
|
+
warn('archiveSession failed', sessionId, textOf(error));
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Tell every open page that this Session is gone. `api-session/removed` is a
|
|
289
|
+
* forwarded Host event (`dsh-api-remotes` allowlist) and the session client
|
|
290
|
+
* applies it as a row removal, so the sidebar drops the row without a reload and
|
|
291
|
+
* without leaving an "archived" marker behind.
|
|
292
|
+
*/
|
|
293
|
+
function announceRemoval(ctx, sessionId) {
|
|
294
|
+
try {
|
|
295
|
+
if (typeof ctx.emit !== 'function') return false;
|
|
296
|
+
ctx.emit('api-session/removed', sessionId);
|
|
297
|
+
return true;
|
|
298
|
+
} catch (error) {
|
|
299
|
+
warn('announceRemoval failed', sessionId, textOf(error));
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** `api-session/removed` relay bookkeeping for the boot sweep. */
|
|
305
|
+
const journalPath = () => path.join(dshHome(), 'storages', 'conversation-manager', 'archived.json');
|
|
306
|
+
|
|
307
|
+
async function readJournal() {
|
|
308
|
+
try {
|
|
309
|
+
const parsed = JSON.parse(await fsp.readFile(journalPath(), 'utf8'));
|
|
310
|
+
return Array.isArray(parsed) ? parsed.filter((id) => typeof id === 'string') : [];
|
|
311
|
+
} catch {
|
|
312
|
+
return [];
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function writeJournal(ids) {
|
|
317
|
+
try {
|
|
318
|
+
const file = journalPath();
|
|
319
|
+
await fsp.mkdir(path.dirname(file), { recursive: true });
|
|
320
|
+
await fsp.writeFile(file, JSON.stringify([...new Set(ids)], null, 2), 'utf8');
|
|
321
|
+
} catch (error) {
|
|
322
|
+
warn('journal write failed', textOf(error));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/* -------------------------------------------------------------------------- *
|
|
327
|
+
* Deleted-session tombstones.
|
|
328
|
+
*
|
|
329
|
+
* Deleting the store is not enough while the session is still resident: the
|
|
330
|
+
* host keeps a writer for an open conversation and flushes it again, recreating
|
|
331
|
+
* the exact directory we just removed. The row then reappears and the next
|
|
332
|
+
* delete either races the writer or finds nothing on disk. Remembering the id
|
|
333
|
+
* (and the paths we removed) lets a small sweeper keep the store deleted and
|
|
334
|
+
* keep the row off every open page until the writer is gone.
|
|
335
|
+
* -------------------------------------------------------------------------- */
|
|
336
|
+
|
|
337
|
+
const deletedPath = () => path.join(dshHome(), 'storages', 'conversation-manager', 'deleted.json');
|
|
338
|
+
/** sessionId -> { at, dirs } */
|
|
339
|
+
const deletedIds = new Map();
|
|
340
|
+
let deletedLoaded = false;
|
|
341
|
+
let enforceTimer;
|
|
342
|
+
let enforceCtx;
|
|
343
|
+
|
|
344
|
+
async function loadDeleted() {
|
|
345
|
+
if (deletedLoaded) return;
|
|
346
|
+
deletedLoaded = true;
|
|
347
|
+
let parsed;
|
|
348
|
+
try {
|
|
349
|
+
parsed = JSON.parse(await fsp.readFile(deletedPath(), 'utf8'));
|
|
350
|
+
} catch {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return;
|
|
354
|
+
const now = Date.now();
|
|
355
|
+
for (const [id, entry] of Object.entries(parsed)) {
|
|
356
|
+
if (typeof id !== 'string' || !SAFE_ID.test(id)) continue;
|
|
357
|
+
const at = typeof entry === 'number' ? entry : typeof entry?.at === 'number' ? entry.at : now;
|
|
358
|
+
if (now - at > DELETED_TTL_MS) continue;
|
|
359
|
+
const dirs = Array.isArray(entry?.dirs) ? entry.dirs.filter((value) => typeof value === 'string') : [];
|
|
360
|
+
deletedIds.set(id, { at, dirs });
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function saveDeleted() {
|
|
365
|
+
try {
|
|
366
|
+
const file = deletedPath();
|
|
367
|
+
await fsp.mkdir(path.dirname(file), { recursive: true });
|
|
368
|
+
const now = Date.now();
|
|
369
|
+
const payload = {};
|
|
370
|
+
for (const [id, entry] of [...deletedIds]) {
|
|
371
|
+
if (now - entry.at > DELETED_TTL_MS) {
|
|
372
|
+
deletedIds.delete(id);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
payload[id] = { at: entry.at, dirs: entry.dirs };
|
|
376
|
+
}
|
|
377
|
+
await fsp.writeFile(file, JSON.stringify(payload, null, 2), 'utf8');
|
|
378
|
+
} catch (error) {
|
|
379
|
+
warn('deleted journal write failed', textOf(error));
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Remember a deleted id and start the sweeper that keeps it deleted. */
|
|
384
|
+
async function rememberDeleted(ctx, sessionId, dirs) {
|
|
385
|
+
await loadDeleted();
|
|
386
|
+
const previous = deletedIds.get(sessionId);
|
|
387
|
+
const kept = new Set([...(previous?.dirs ?? []), ...(dirs ?? [])]);
|
|
388
|
+
deletedIds.set(sessionId, { at: Date.now(), dirs: [...kept] });
|
|
389
|
+
await saveDeleted();
|
|
390
|
+
startEnforcement(ctx);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function startEnforcement(ctx) {
|
|
394
|
+
if (!enforceCtx) enforceCtx = ctx;
|
|
395
|
+
if (enforceTimer) return;
|
|
396
|
+
enforceTimer = setInterval(() => {
|
|
397
|
+
enforceDeleted(enforceCtx).catch((error) => warn('deleted sweep failed', textOf(error)));
|
|
398
|
+
}, ENFORCE_INTERVAL_MS);
|
|
399
|
+
enforceTimer?.unref?.();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Remove recreated stores for tombstoned ids and keep their rows out of the list. */
|
|
403
|
+
async function enforceDeleted(ctx) {
|
|
404
|
+
await loadDeleted();
|
|
405
|
+
if (deletedIds.size === 0) return;
|
|
406
|
+
if (enforceTimer === undefined) startEnforcement(ctx);
|
|
407
|
+
const now = Date.now();
|
|
408
|
+
let changed = false;
|
|
409
|
+
let budget = 50;
|
|
410
|
+
for (const [sessionId, entry] of [...deletedIds]) {
|
|
411
|
+
if (budget-- <= 0) break;
|
|
412
|
+
if (now - entry.at > DELETED_TTL_MS) {
|
|
413
|
+
deletedIds.delete(sessionId);
|
|
414
|
+
changed = true;
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
let removed = 0;
|
|
418
|
+
if (entry.dirs.length === 0) {
|
|
419
|
+
// The store was already gone at delete time; look for a recreated one.
|
|
420
|
+
try {
|
|
421
|
+
entry.dirs = await locateSessionDirs(ctx, sessionId);
|
|
422
|
+
changed = entry.dirs.length > 0 || changed;
|
|
423
|
+
} catch (error) {
|
|
424
|
+
warn('deleted lookup failed', sessionId, textOf(error));
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
for (const dir of [...entry.dirs]) {
|
|
428
|
+
try {
|
|
429
|
+
if (!(await isDirectory(dir))) continue;
|
|
430
|
+
await fsp.rm(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
|
|
431
|
+
removed += 1;
|
|
432
|
+
} catch (error) {
|
|
433
|
+
warn('deleted removal failed', sessionId, textOf(error));
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
if (removed > 0) {
|
|
437
|
+
await removeProjectionCache(sessionId);
|
|
438
|
+
try {
|
|
439
|
+
await forgetInRegistry(ctx, sessionId);
|
|
440
|
+
} catch (error) {
|
|
441
|
+
warn('deleted detach failed', sessionId, textOf(error));
|
|
442
|
+
}
|
|
443
|
+
announceRemoval(ctx, sessionId);
|
|
444
|
+
changed = true;
|
|
445
|
+
} else if (now - entry.at < ENFORCE_WINDOW_MS) {
|
|
446
|
+
// The row can be re-announced by the host while the session is resident;
|
|
447
|
+
// keep telling the pages it is gone until that window closes.
|
|
448
|
+
announceRemoval(ctx, sessionId);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
if (changed) await saveDeleted();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Startup sweep: an id we archived while deleting may sit in the archive list
|
|
456
|
+
* after its store is gone. Unarchive those so the archived view does not keep a
|
|
457
|
+
* ghost; ids whose store still exists stay archived (the delete failed).
|
|
458
|
+
*/
|
|
459
|
+
async function pruneArchivedGhosts(ctx) {
|
|
460
|
+
const journal = await readJournal();
|
|
461
|
+
if (journal.length === 0) return;
|
|
462
|
+
const registry = service(ctx, 'workspaceRegistry');
|
|
463
|
+
const persistence = service(ctx, 'sessionPersistence');
|
|
464
|
+
if (!registry || typeof registry.unarchiveSession !== 'function') return;
|
|
465
|
+
const keep = [];
|
|
466
|
+
for (const id of journal) {
|
|
467
|
+
let exists = true;
|
|
468
|
+
try {
|
|
469
|
+
exists = Boolean(await persistence?.stat?.(id));
|
|
470
|
+
} catch {
|
|
471
|
+
exists = true;
|
|
472
|
+
}
|
|
473
|
+
if (exists) {
|
|
474
|
+
keep.push(id);
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
try {
|
|
478
|
+
await registry.unarchiveSession(id);
|
|
479
|
+
} catch (error) {
|
|
480
|
+
warn('unarchive ghost failed', id, textOf(error));
|
|
481
|
+
keep.push(id);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
await writeJournal(keep);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Drop the id from every workspace, and from the archived / pinned id lists. */
|
|
488
|
+
async function forgetInRegistry(ctx, sessionId) {
|
|
489
|
+
const registry = service(ctx, 'workspaceRegistry');
|
|
490
|
+
if (!registry || typeof registry.list !== 'function') return { detached: false, reason: 'registry-unavailable' };
|
|
491
|
+
let detached = false;
|
|
492
|
+
for (const workspace of registry.list()) {
|
|
493
|
+
const ids = workspace?.sessionIds;
|
|
494
|
+
if (!Array.isArray(ids) || !ids.includes(sessionId)) continue;
|
|
495
|
+
try {
|
|
496
|
+
await workspace.detachSession(sessionId);
|
|
497
|
+
detached = true;
|
|
498
|
+
} catch (error) {
|
|
499
|
+
warn('detachSession failed', textOf(error));
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
if (typeof registry.unpinSession === 'function') {
|
|
503
|
+
try {
|
|
504
|
+
await registry.unpinSession(sessionId);
|
|
505
|
+
} catch {
|
|
506
|
+
/* not pinned */
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return { detached };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function handleDelete(ctx, request) {
|
|
513
|
+
if (request.method !== 'POST') return fail(405, '请使用 POST');
|
|
514
|
+
const contentType = (request.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
|
|
515
|
+
if (contentType !== 'application/json' || request.headers.get(ACTION_HEADER) !== '1') {
|
|
516
|
+
return fail(403, '请从侧栏会话菜单删除对话');
|
|
517
|
+
}
|
|
518
|
+
const denied = originVerdict(request);
|
|
519
|
+
if (denied) return denied;
|
|
520
|
+
let body;
|
|
521
|
+
try {
|
|
522
|
+
body = await readJson(request);
|
|
523
|
+
} catch (error) {
|
|
524
|
+
return fail(error?.status || 400, textOf(error));
|
|
525
|
+
}
|
|
526
|
+
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId.trim() : '';
|
|
527
|
+
if (!SAFE_ID.test(sessionId) || sessionId.includes('..')) return fail(400, '无效的会话 ID');
|
|
528
|
+
try {
|
|
529
|
+
const targets = await locateSessionDirs(ctx, sessionId);
|
|
530
|
+
if (targets.length === 0) {
|
|
531
|
+
// Nothing on disk. The row may still be listed from the live session or a
|
|
532
|
+
// stale projection; forget it in the registry, keep the tombstone so a
|
|
533
|
+
// late flush cannot bring it back, and report success so the phantom row
|
|
534
|
+
// can be dismissed instead of erroring forever.
|
|
535
|
+
const cleanup = await forgetInRegistry(ctx, sessionId);
|
|
536
|
+
await removeProjectionCache(sessionId);
|
|
537
|
+
await rememberDeleted(ctx, sessionId, []);
|
|
538
|
+
const announced = announceRemoval(ctx, sessionId);
|
|
539
|
+
return json(200, {
|
|
540
|
+
ok: true,
|
|
541
|
+
sessionId,
|
|
542
|
+
missing: true,
|
|
543
|
+
removedGroups: 0,
|
|
544
|
+
removedPaths: 0,
|
|
545
|
+
swept: 0,
|
|
546
|
+
detached: cleanup.detached,
|
|
547
|
+
hidden: false,
|
|
548
|
+
archived: false,
|
|
549
|
+
announced,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
// Stop any running work and hide the row before the store goes away; a live
|
|
553
|
+
// agent would otherwise keep writing to the directory being removed.
|
|
554
|
+
const hidden = await hideSession(ctx, sessionId);
|
|
555
|
+
let storage;
|
|
556
|
+
try {
|
|
557
|
+
storage = await removeStorage(ctx, sessionId, targets);
|
|
558
|
+
} catch (error) {
|
|
559
|
+
if (hidden) {
|
|
560
|
+
try {
|
|
561
|
+
await service(ctx, 'workspaceRegistry')?.unarchiveSession?.(sessionId);
|
|
562
|
+
} catch {
|
|
563
|
+
/* keep the row hidden rather than resurrect a half-deleted row */
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
throw error;
|
|
567
|
+
}
|
|
568
|
+
const cleanup = await forgetInRegistry(ctx, sessionId);
|
|
569
|
+
// Remember the deletion: a resident session can flush its log back to disk
|
|
570
|
+
// after this point, and the sweeper removes that copy again.
|
|
571
|
+
await rememberDeleted(ctx, sessionId, storage.targets);
|
|
572
|
+
// A still-live container can recreate the store right after removal; sweep
|
|
573
|
+
// once more so "deleted" is not undone by a late flush.
|
|
574
|
+
let swept = 0;
|
|
575
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
576
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
577
|
+
const again = await locateSessionDirs(ctx, sessionId);
|
|
578
|
+
if (again.length === 0) break;
|
|
579
|
+
try {
|
|
580
|
+
await removeStorage(ctx, sessionId, again);
|
|
581
|
+
swept += again.length;
|
|
582
|
+
} catch (error) {
|
|
583
|
+
warn('sweep failed', sessionId, textOf(error));
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
// Drop the row from every open page, then clear the hiding marker so the
|
|
588
|
+
// conversation is not left sitting in the archived view.
|
|
589
|
+
const announced = announceRemoval(ctx, sessionId);
|
|
590
|
+
let archived = hidden;
|
|
591
|
+
if (hidden && announced) {
|
|
592
|
+
// Let the removal reach the page before the archive set changes again.
|
|
593
|
+
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
594
|
+
try {
|
|
595
|
+
await service(ctx, 'workspaceRegistry')?.unarchiveSession?.(sessionId);
|
|
596
|
+
archived = false;
|
|
597
|
+
} catch (error) {
|
|
598
|
+
warn('clearing archive marker failed', sessionId, textOf(error));
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
if (archived) await writeJournal([...(await readJournal()), sessionId]);
|
|
602
|
+
return json(200, {
|
|
603
|
+
ok: true,
|
|
604
|
+
sessionId,
|
|
605
|
+
removedGroups: storage.targets.length,
|
|
606
|
+
removedPaths: storage.removed.length,
|
|
607
|
+
swept,
|
|
608
|
+
detached: cleanup.detached,
|
|
609
|
+
hidden,
|
|
610
|
+
archived,
|
|
611
|
+
announced,
|
|
612
|
+
});
|
|
613
|
+
} catch (error) {
|
|
614
|
+
warn('delete failed', sessionId, textOf(error));
|
|
615
|
+
return fail(error?.status || 500, textOf(error));
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export function apply(ctx) {
|
|
620
|
+
const install = (host) => {
|
|
621
|
+
const carrier = host?.connection?.fetch;
|
|
622
|
+
if (typeof carrier?.register !== 'function') {
|
|
623
|
+
warn('connection.fetch unavailable; delete route not registered');
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
try {
|
|
627
|
+
const dispose = carrier.register({
|
|
628
|
+
path: ROUTE,
|
|
629
|
+
methods: ['POST'],
|
|
630
|
+
requestBody: 'buffered',
|
|
631
|
+
fetch: (request) => handleDelete(ctx, request),
|
|
632
|
+
});
|
|
633
|
+
if (typeof dispose?.catch === 'function') dispose.catch((error) => warn('route registration failed', textOf(error)));
|
|
634
|
+
} catch (error) {
|
|
635
|
+
warn('route registration failed', textOf(error));
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
if (typeof ctx.inject === 'function') ctx.inject(['connection'], install);
|
|
639
|
+
else install(ctx);
|
|
640
|
+
// Startup: unarchive ghosts left by an interrupted delete, then enforce the
|
|
641
|
+
// tombstones once (a resident session may have recreated its store while the
|
|
642
|
+
// app was closed) and keep the sweeper running.
|
|
643
|
+
try {
|
|
644
|
+
const timer = setTimeout(() => {
|
|
645
|
+
loadDeleted()
|
|
646
|
+
.then(() => enforceDeleted(ctx))
|
|
647
|
+
.then(() => startEnforcement(ctx))
|
|
648
|
+
.then(() => pruneArchivedGhosts(ctx))
|
|
649
|
+
.catch((error) => warn('startup sweep failed', textOf(error)));
|
|
650
|
+
}, 3000);
|
|
651
|
+
timer?.unref?.();
|
|
652
|
+
} catch (error) {
|
|
653
|
+
warn('prune scheduling failed', textOf(error));
|
|
654
|
+
}
|
|
655
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@logic-dante/dsh-conversation-manager",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"description": "Delete unwanted conversations straight from the sidebar session menu, including their on-disk session store.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -48,3 +48,4 @@
|
|
|
48
48
|
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
|