dsh-plugin-delete-session 0.0.0-stage → 1.0.8
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/CHANGELOG.md +30 -0
- package/LICENSE +21 -0
- package/README.en.md +132 -0
- package/README.md +134 -2
- package/client.js +841 -0
- package/cordis.patch.yml +5 -0
- package/icon.svg +15 -0
- package/index.js +652 -0
- package/lib/paths.js +156 -0
- package/lib/trash.js +539 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +61 -4
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Session trash">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="st-bg" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
|
|
4
|
+
<stop offset="0" stop-color="#2F6F8F"/>
|
|
5
|
+
<stop offset="1" stop-color="#173B52"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<rect width="64" height="64" rx="15" fill="url(#st-bg)"/>
|
|
9
|
+
<path d="M20 22.5h24" fill="none" stroke="#EAF6FB" stroke-width="3.2" stroke-linecap="round"/>
|
|
10
|
+
<path d="M26.5 22.5v-2.2c0-1.2 1-2.3 2.3-2.3h6.4c1.3 0 2.3 1.1 2.3 2.3v2.2" fill="none" stroke="#EAF6FB" stroke-width="3.2" stroke-linecap="round"/>
|
|
11
|
+
<path d="M22.8 22.5l1.5 22c.1 1.5 1.3 2.7 2.8 2.7h9.8c1.5 0 2.7-1.2 2.8-2.7l1.5-22" fill="none" stroke="#EAF6FB" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
12
|
+
<path d="M28.4 30v10.4M35.6 30v10.4" fill="none" stroke="#9FD3E8" stroke-width="3.2" stroke-linecap="round"/>
|
|
13
|
+
<circle cx="49" cy="15" r="9" fill="#F2B34C"/>
|
|
14
|
+
<path d="M49 10.6v4.7l3 1.8" fill="none" stroke="#4A3208" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
15
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of the session-trash bundle.
|
|
3
|
+
*
|
|
4
|
+
* It owns the durable work behind the UI's "delete conversation" action:
|
|
5
|
+
*
|
|
6
|
+
* 1. stop the session's running work and archive it so the row disappears;
|
|
7
|
+
* 2. detach it from its workspace's durable account;
|
|
8
|
+
* 3. copy its JSONL log directory and projection checkpoint into
|
|
9
|
+
* `<DSH_HOME>/session-trash/<sessionId>/`, then remove the originals;
|
|
10
|
+
* 4. unregister the deleted live Session/Agent so an ungrouped row cannot
|
|
11
|
+
* survive in the sidebar;
|
|
12
|
+
* 5. keep that copy for the configured retention window and purge it for good
|
|
13
|
+
* afterwards, or restore it when the user asks.
|
|
14
|
+
*
|
|
15
|
+
* The browser reaches this half through the exact HTTP route
|
|
16
|
+
* `/session-trash-api`, guarded by Connection's Host/Origin fence and browser
|
|
17
|
+
* credential check - the same admission every `/api` request passes.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { Buffer } from 'node:buffer'
|
|
21
|
+
import { realpath } from 'node:fs/promises'
|
|
22
|
+
import os from 'node:os'
|
|
23
|
+
import path from 'node:path'
|
|
24
|
+
|
|
25
|
+
import { isSafeSessionId, samePath } from './lib/paths.js'
|
|
26
|
+
import { SessionTrash } from './lib/trash.js'
|
|
27
|
+
|
|
28
|
+
const CHANNEL = '/session-trash-api'
|
|
29
|
+
const DEFAULT_RETENTION_DAYS = 7
|
|
30
|
+
const DEFAULT_PURGE_INTERVAL_MS = 60 * 60 * 1000
|
|
31
|
+
const MAX_TITLE_LENGTH = 500
|
|
32
|
+
const MAX_REQUEST_BYTES = 64 * 1024
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Required Host services: the browser transport (for Host/Origin and browser
|
|
36
|
+
* authentication checks) and the session log store. `webServer` is obtained
|
|
37
|
+
* through `ctx.inject` below, so a profile without a Web carrier simply keeps
|
|
38
|
+
* the plugin inactive instead of failing.
|
|
39
|
+
*/
|
|
40
|
+
export const inject = ['connection', 'sessionPersistence']
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx host context.
|
|
44
|
+
* @param {Record<string, unknown> | undefined} config bundle row config.
|
|
45
|
+
*/
|
|
46
|
+
export function apply(ctx, config = {}) {
|
|
47
|
+
const logger = {
|
|
48
|
+
info: (message) => ctx.logger?.info?.(message),
|
|
49
|
+
warn: (message) => ctx.logger?.warn?.(message),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const home = firstString(config.homeDir)
|
|
53
|
+
|| firstString(process.env.DSH_HOME)
|
|
54
|
+
|| path.join(os.homedir(), '.dsh')
|
|
55
|
+
const persistence = ctx.sessionPersistence
|
|
56
|
+
const sessionsRoot = firstString(config.sessionsRoot)
|
|
57
|
+
|| firstString(persistence?.root)
|
|
58
|
+
|| path.join(home, 'sessions')
|
|
59
|
+
const projectionRoot = firstString(config.projectionCacheDir)
|
|
60
|
+
|| path.join(home, 'storages', 'session_projcache', 'sessions')
|
|
61
|
+
const trashRoot = firstString(config.trashRoot)
|
|
62
|
+
|| path.join(home, 'session-trash')
|
|
63
|
+
const retentionDays = clampNumber(config.retentionDays, DEFAULT_RETENTION_DAYS, 1, 3650)
|
|
64
|
+
const purgeIntervalMs = clampNumber(config.purgeIntervalMs, DEFAULT_PURGE_INTERVAL_MS, 60_000, 24 * 60 * 60 * 1000)
|
|
65
|
+
|
|
66
|
+
const trash = new SessionTrash({
|
|
67
|
+
sessionsRoot,
|
|
68
|
+
trashRoot,
|
|
69
|
+
projectionRoot,
|
|
70
|
+
retentionMs: retentionDays * 24 * 60 * 60 * 1000,
|
|
71
|
+
logger,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
logger.info(`session-trash: sessions root '${sessionsRoot}', trash root '${trashRoot}', retention ${retentionDays} day(s)`)
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Permanently remove expired entries and drop their stale archive markers.
|
|
78
|
+
*
|
|
79
|
+
* @returns {Promise<string[]>} purged session ids.
|
|
80
|
+
*/
|
|
81
|
+
const purgeExpired = async () => {
|
|
82
|
+
const entries = await trash.list()
|
|
83
|
+
const now = Date.now()
|
|
84
|
+
const purged = []
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
if (entry.expiresAt > now) continue
|
|
87
|
+
const sessionId = String(entry.sessionId)
|
|
88
|
+
try {
|
|
89
|
+
// Re-read inside the serialized mutation: a concurrent restore/delete
|
|
90
|
+
// may have replaced this entry between list() and purge().
|
|
91
|
+
const removed = await trash.run(async () => {
|
|
92
|
+
const current = await trash.read(sessionId)
|
|
93
|
+
if (current === undefined || current.expiresAt > now) return false
|
|
94
|
+
await trash.purge(current)
|
|
95
|
+
return true
|
|
96
|
+
})
|
|
97
|
+
if (!removed) continue
|
|
98
|
+
purged.push(sessionId)
|
|
99
|
+
if (!(await storedSessionExists(persistence, sessionId))) {
|
|
100
|
+
detachDeletedLiveState(ctx, sessionId)
|
|
101
|
+
await unarchiveQuietly(ctx, sessionId)
|
|
102
|
+
}
|
|
103
|
+
} catch (error) {
|
|
104
|
+
logger.warn(`session-trash: cannot purge '${sessionId}': ${messageOf(error)}`)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (purged.length > 0) {
|
|
108
|
+
logger.info(`session-trash: purged ${purged.length} expired session(s)`)
|
|
109
|
+
}
|
|
110
|
+
return purged
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Delete one session.
|
|
115
|
+
*
|
|
116
|
+
* @param {{ sessionId?: unknown, title?: unknown }} payload request body.
|
|
117
|
+
* @returns {Promise<Record<string, unknown>>} deletion outcome.
|
|
118
|
+
*/
|
|
119
|
+
const deleteSession = async (payload) => {
|
|
120
|
+
const sessionId = requireSessionId(payload)
|
|
121
|
+
const title = normalizeTitle(payload.title)
|
|
122
|
+
const sessions = ctx.get('sessions')
|
|
123
|
+
const live = sessions?.get(sessionId)
|
|
124
|
+
let header = live?.header
|
|
125
|
+
if (header === undefined) {
|
|
126
|
+
const snapshot = await persistence.stat(sessionId)
|
|
127
|
+
header = snapshot?.header
|
|
128
|
+
}
|
|
129
|
+
if (header === undefined) {
|
|
130
|
+
throw coded('not-found', '找不到这个对话,它可能已经被删除了')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Refuse before doing any workspace or archive work: a genuinely running
|
|
134
|
+
// Agent turn is the only state delete must not touch. Everything else
|
|
135
|
+
// (idle, resident, background jobs waiting on a stopped turn) can be
|
|
136
|
+
// archived and removed without the old multi-second live-store wait.
|
|
137
|
+
if (agentStatus(ctx.get('agents')?.get(sessionId)) === 'running') {
|
|
138
|
+
logger.warn(`session-trash: refusing to delete running '${sessionId}'`)
|
|
139
|
+
throw coded('running', '这个对话仍在运行中,请先停止任务再删除')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const workspace = await findWorkspace(ctx, header.cwd, sessionId)
|
|
143
|
+
const registry = ctx.get('workspaceRegistry')
|
|
144
|
+
let wasArchived = false
|
|
145
|
+
try {
|
|
146
|
+
wasArchived = registry?.archivedSessionIds?.includes(sessionId) === true
|
|
147
|
+
} catch {
|
|
148
|
+
// An unreadable archive set is treated as "not archived"; the archive
|
|
149
|
+
// call below still fails closed if it cannot write the marker.
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Archive next: it hides the row through the archive set before any file
|
|
153
|
+
// moves and stops background work that is not an Agent turn. The client
|
|
154
|
+
// also clears an open conversation when its id enters that set.
|
|
155
|
+
let archived = false
|
|
156
|
+
const rollbackArchive = async () => {
|
|
157
|
+
// Only remove the marker this request added; an already-archived
|
|
158
|
+
// Session must stay archived when a later step fails.
|
|
159
|
+
if (archived && !wasArchived) await unarchiveQuietly(ctx, sessionId)
|
|
160
|
+
}
|
|
161
|
+
if (registry !== undefined) {
|
|
162
|
+
try {
|
|
163
|
+
await registry.archiveSession(sessionId, { stopActivity: true })
|
|
164
|
+
archived = true
|
|
165
|
+
} catch (error) {
|
|
166
|
+
// Do not detach or remove files when the archive write failed: without
|
|
167
|
+
// the archive marker the still-live Session can reappear after a
|
|
168
|
+
// refresh, and the user would have lost the log for a half-delete.
|
|
169
|
+
// archiveSession can also reject after committing the marker (a stop
|
|
170
|
+
// listener failure), so make the rollback explicit and idempotent.
|
|
171
|
+
logger.warn(`session-trash: archive before delete failed for '${sessionId}': ${messageOf(error)}`)
|
|
172
|
+
if (!wasArchived) await unarchiveQuietly(ctx, sessionId)
|
|
173
|
+
throw coded('archive-failed', '无法归档这个对话,删除已取消')
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// A turn can win the race with the pre-check above; archive's stop gate
|
|
178
|
+
// then makes it short-lived, but do not delete underneath it.
|
|
179
|
+
if (agentStatus(ctx.get('agents')?.get(sessionId)) === 'running') {
|
|
180
|
+
logger.warn(`session-trash: refusing to delete running '${sessionId}' (after archive)`)
|
|
181
|
+
await rollbackArchive()
|
|
182
|
+
throw coded('running', '这个对话仍在运行中,请先停止任务再删除')
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Remove the durable workspace account so the row cannot come back when a
|
|
186
|
+
// stale snapshot is refreshed. Remember it so a failed copy can restore it.
|
|
187
|
+
let detached = false
|
|
188
|
+
if (workspace !== undefined) {
|
|
189
|
+
try {
|
|
190
|
+
await workspace.detachSession(sessionId)
|
|
191
|
+
detached = true
|
|
192
|
+
} catch (error) {
|
|
193
|
+
logger.warn(`session-trash: detaching '${sessionId}' from workspace failed: ${messageOf(error)}`)
|
|
194
|
+
await rollbackArchive()
|
|
195
|
+
throw coded('detach-failed', '无法从工作区移除这个对话,删除已取消')
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
await persistence.flush()
|
|
201
|
+
} catch (error) {
|
|
202
|
+
// Capturing a log whose latest buffered events failed to reach disk would
|
|
203
|
+
// silently lose them. Put the Session back before touching the files.
|
|
204
|
+
logger.warn(`session-trash: session flush before delete failed: ${messageOf(error)}`)
|
|
205
|
+
if (detached) {
|
|
206
|
+
try {
|
|
207
|
+
await workspace.attachSession(sessionId)
|
|
208
|
+
} catch (attachError) {
|
|
209
|
+
logger.warn(`session-trash: re-attaching '${sessionId}' after a failed flush failed: ${messageOf(attachError)}`)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (archived) await rollbackArchive()
|
|
213
|
+
throw coded('flush-failed', '无法写入会话日志,删除已取消')
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// A late append can recreate the directory behind the copy; verify the
|
|
217
|
+
// source twice, not just when the first capture returned a non-null entry.
|
|
218
|
+
await delay(120)
|
|
219
|
+
let entry
|
|
220
|
+
try {
|
|
221
|
+
entry = await trash.run(() => trash.capture({
|
|
222
|
+
sessionId,
|
|
223
|
+
title,
|
|
224
|
+
cwd: header.cwd,
|
|
225
|
+
}))
|
|
226
|
+
if (await trash.sourceExists(sessionId, header.cwd)) {
|
|
227
|
+
await delay(400)
|
|
228
|
+
if (await trash.sourceExists(sessionId, header.cwd)) {
|
|
229
|
+
entry = await trash.run(() => trash.capture({
|
|
230
|
+
sessionId,
|
|
231
|
+
title,
|
|
232
|
+
cwd: header.cwd,
|
|
233
|
+
}))
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
} catch (error) {
|
|
237
|
+
// Keep the Session where it was whenever a complete trash copy is not
|
|
238
|
+
// guaranteed; trash.capture keeps a usable entry if an original removal
|
|
239
|
+
// failed part-way, so the user can still restore or purge it.
|
|
240
|
+
if (detached) {
|
|
241
|
+
try {
|
|
242
|
+
await workspace.attachSession(sessionId)
|
|
243
|
+
} catch (attachError) {
|
|
244
|
+
logger.warn(`session-trash: re-attaching '${sessionId}' after a failed delete failed: ${messageOf(attachError)}`)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (archived) await rollbackArchive()
|
|
248
|
+
throw error
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// The durable files are gone, but DSH can still hold this Session and its
|
|
252
|
+
// idle Agent in the Host registries. For a workspace row, detaching the
|
|
253
|
+
// workspace already removes it; an ungrouped row has no workspace account,
|
|
254
|
+
// so it would stay in the sidebar (and in the "show archived" filter)
|
|
255
|
+
// pointing at a conversation whose log no longer exists. There is no public
|
|
256
|
+
// Agent close API, so unregister the exact live entries only after the log
|
|
257
|
+
// has been moved successfully. `session/disposed` also releases the JSONL
|
|
258
|
+
// writer; the archived marker remains so a refresh cannot surface a ghost.
|
|
259
|
+
detachDeletedLiveState(ctx, sessionId)
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
sessionId,
|
|
263
|
+
title,
|
|
264
|
+
archived,
|
|
265
|
+
empty: entry === null,
|
|
266
|
+
entry,
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Restore one trashed session.
|
|
272
|
+
*
|
|
273
|
+
* @param {{ sessionId?: unknown }} payload request body.
|
|
274
|
+
* @returns {Promise<Record<string, unknown>>} restore outcome.
|
|
275
|
+
*/
|
|
276
|
+
const restoreSession = async (payload) => {
|
|
277
|
+
const sessionId = requireSessionId(payload)
|
|
278
|
+
const outcome = await trash.run(async () => {
|
|
279
|
+
const current = await trash.read(sessionId)
|
|
280
|
+
if (current === undefined) return { kind: 'missing' }
|
|
281
|
+
if (typeof current.expiresAt === 'number' && current.expiresAt <= Date.now()) {
|
|
282
|
+
await trash.purge(current)
|
|
283
|
+
return { kind: 'expired' }
|
|
284
|
+
}
|
|
285
|
+
await trash.restore(current)
|
|
286
|
+
return { kind: 'restored', entry: current }
|
|
287
|
+
})
|
|
288
|
+
if (outcome.kind === 'missing') {
|
|
289
|
+
throw coded('no-entry', '回收站里没有这个对话,可能已经恢复或已过期')
|
|
290
|
+
}
|
|
291
|
+
if (outcome.kind === 'expired') {
|
|
292
|
+
// The copy is gone; do not unarchive here unless there is no durable log
|
|
293
|
+
// left at all. In that case the live row is only a ghost and must go too.
|
|
294
|
+
if (!(await storedSessionExists(persistence, sessionId))) {
|
|
295
|
+
detachDeletedLiveState(ctx, sessionId)
|
|
296
|
+
await unarchiveQuietly(ctx, sessionId)
|
|
297
|
+
}
|
|
298
|
+
throw coded('no-entry', '这个对话已超过保留期,无法恢复')
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const entry = outcome.entry
|
|
302
|
+
await unarchiveQuietly(ctx, sessionId)
|
|
303
|
+
if (typeof entry.cwd === 'string' && entry.cwd !== '') {
|
|
304
|
+
const workspace = await findWorkspace(ctx, entry.cwd, sessionId)
|
|
305
|
+
if (workspace !== undefined) {
|
|
306
|
+
try {
|
|
307
|
+
await workspace.attachSession(sessionId)
|
|
308
|
+
} catch (error) {
|
|
309
|
+
logger.warn(`session-trash: restoring '${sessionId}' left it ungrouped: ${messageOf(error)}`)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return { sessionId, restored: true }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Permanently remove one trashed session, skipping the remaining window.
|
|
318
|
+
*
|
|
319
|
+
* @param {{ sessionId?: unknown }} payload request body.
|
|
320
|
+
* @returns {Promise<Record<string, unknown>>} purge outcome.
|
|
321
|
+
*/
|
|
322
|
+
const purgeSession = async (payload) => {
|
|
323
|
+
const sessionId = requireSessionId(payload)
|
|
324
|
+
const purged = await trash.run(async () => {
|
|
325
|
+
const current = await trash.read(sessionId)
|
|
326
|
+
if (current === undefined) return false
|
|
327
|
+
await trash.purge(current)
|
|
328
|
+
return true
|
|
329
|
+
})
|
|
330
|
+
if (!purged) {
|
|
331
|
+
// Never unarchive an id just because its copy is gone: the Session may
|
|
332
|
+
// still exist on disk, and unarchiving would make it visible again.
|
|
333
|
+
return { sessionId, purged: false }
|
|
334
|
+
}
|
|
335
|
+
// A live Session whose log was already deleted has no row left to account
|
|
336
|
+
// for. `sessionStillExists` includes the live store, so test persistence
|
|
337
|
+
// directly before deciding whether the archive and live entries can go.
|
|
338
|
+
const stored = await storedSessionExists(persistence, sessionId)
|
|
339
|
+
if (!stored) {
|
|
340
|
+
detachDeletedLiveState(ctx, sessionId)
|
|
341
|
+
await unarchiveQuietly(ctx, sessionId)
|
|
342
|
+
}
|
|
343
|
+
return { sessionId, purged: true }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Endpoint dispatcher for the dedicated RPC channel.
|
|
348
|
+
*
|
|
349
|
+
* @param {string} endpoint endpoint name.
|
|
350
|
+
* @param {unknown} payload request body.
|
|
351
|
+
* @param {AbortSignal | undefined} signal cancellation.
|
|
352
|
+
* @returns {Promise<unknown>} endpoint value.
|
|
353
|
+
*/
|
|
354
|
+
const dispatch = async (endpoint, payload, signal) => {
|
|
355
|
+
signal?.throwIfAborted()
|
|
356
|
+
const body = isRecord(payload) ? payload : {}
|
|
357
|
+
switch (endpoint) {
|
|
358
|
+
case 'info':
|
|
359
|
+
return {
|
|
360
|
+
retentionDays,
|
|
361
|
+
purgeIntervalMs,
|
|
362
|
+
}
|
|
363
|
+
case 'list':
|
|
364
|
+
return { entries: await trash.list() }
|
|
365
|
+
case 'delete':
|
|
366
|
+
return await deleteSession(body)
|
|
367
|
+
case 'restore':
|
|
368
|
+
return await restoreSession(body)
|
|
369
|
+
case 'purge':
|
|
370
|
+
return await purgeSession(body)
|
|
371
|
+
case 'purge-expired': {
|
|
372
|
+
const purged = await purgeExpired()
|
|
373
|
+
return { purged }
|
|
374
|
+
}
|
|
375
|
+
default:
|
|
376
|
+
throw coded('unknown-endpoint', `unknown session-trash endpoint: ${endpoint}`)
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const handleRequest = async (webCtx, req, res) => {
|
|
381
|
+
const write = (status, value) => {
|
|
382
|
+
if (res.headersSent === true) return
|
|
383
|
+
res.writeHead(status, {
|
|
384
|
+
'content-type': 'application/json; charset=utf-8',
|
|
385
|
+
'cache-control': 'no-store',
|
|
386
|
+
})
|
|
387
|
+
res.end(JSON.stringify(value))
|
|
388
|
+
}
|
|
389
|
+
try {
|
|
390
|
+
if (req.method !== 'POST') {
|
|
391
|
+
res.writeHead(405, { allow: 'POST' })
|
|
392
|
+
res.end()
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
const rejection = webCtx.connection.requestRejection(req)
|
|
396
|
+
if (rejection !== undefined) {
|
|
397
|
+
res.writeHead(rejection)
|
|
398
|
+
res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
const request = await readJsonRequest(req)
|
|
402
|
+
if (!isRecord(request) || typeof request.method !== 'string' || request.method === '') {
|
|
403
|
+
write(400, { ok: false, error: { code: 'bad-request', message: 'invalid session-trash request', details: {} } })
|
|
404
|
+
return
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
const value = await dispatch(request.method, request.payload, undefined)
|
|
408
|
+
write(200, { ok: true, value })
|
|
409
|
+
} catch (error) {
|
|
410
|
+
write(200, {
|
|
411
|
+
ok: false,
|
|
412
|
+
error: {
|
|
413
|
+
code: typeof error?.code === 'string' ? error.code : 'session-trash/failed',
|
|
414
|
+
message: messageOf(error),
|
|
415
|
+
details: {},
|
|
416
|
+
},
|
|
417
|
+
})
|
|
418
|
+
}
|
|
419
|
+
} catch (error) {
|
|
420
|
+
write(400, {
|
|
421
|
+
ok: false,
|
|
422
|
+
error: { code: 'bad-request', message: messageOf(error), details: {} },
|
|
423
|
+
})
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// The route lives on the Web carrier, but the plugin itself does not require
|
|
428
|
+
// one: `ctx.inject` waits until a Web composition provides it, and registers
|
|
429
|
+
// inside that scoped context so the route is owned by this plugin's fiber.
|
|
430
|
+
ctx.inject(['webServer', 'connection'], (webCtx) => {
|
|
431
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
432
|
+
kind: 'exact',
|
|
433
|
+
path: CHANNEL,
|
|
434
|
+
handler: (req, res) => handleRequest(webCtx, req, res),
|
|
435
|
+
}), 'session-trash: http route')
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
const unarchiveExpiredMarkers = async () => {
|
|
439
|
+
const registry = ctx.get('workspaceRegistry')
|
|
440
|
+
if (registry === undefined) return
|
|
441
|
+
let ids
|
|
442
|
+
try {
|
|
443
|
+
ids = [...registry.archivedSessionIds]
|
|
444
|
+
} catch {
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
for (const sessionId of ids) {
|
|
448
|
+
// A resident Session may be a deleted one kept hidden on purpose.
|
|
449
|
+
if (ctx.get('sessions')?.get(sessionId) !== undefined) continue
|
|
450
|
+
try {
|
|
451
|
+
if ((await persistence.stat(sessionId)) !== undefined) continue
|
|
452
|
+
} catch {
|
|
453
|
+
// A read failure is not evidence that the session is gone.
|
|
454
|
+
continue
|
|
455
|
+
}
|
|
456
|
+
try {
|
|
457
|
+
if ((await trash.read(sessionId)) !== undefined) continue
|
|
458
|
+
} catch {
|
|
459
|
+
continue
|
|
460
|
+
}
|
|
461
|
+
await unarchiveQuietly(ctx, sessionId)
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
ctx.effect(() => {
|
|
466
|
+
let stopped = false
|
|
467
|
+
const runPurge = () => {
|
|
468
|
+
if (stopped) return
|
|
469
|
+
void purgeExpired().catch((error) => {
|
|
470
|
+
logger.warn(`session-trash: scheduled purge failed: ${messageOf(error)}`)
|
|
471
|
+
})
|
|
472
|
+
}
|
|
473
|
+
void trash.init().then(async () => {
|
|
474
|
+
await unarchiveExpiredMarkers().catch((error) => {
|
|
475
|
+
logger.warn(`session-trash: archive-marker cleanup failed: ${messageOf(error)}`)
|
|
476
|
+
})
|
|
477
|
+
runPurge()
|
|
478
|
+
}).catch((error) => {
|
|
479
|
+
logger.warn(`session-trash: cannot initialize trash root '${trashRoot}': ${messageOf(error)}`)
|
|
480
|
+
})
|
|
481
|
+
const timer = setInterval(runPurge, purgeIntervalMs)
|
|
482
|
+
timer.unref?.()
|
|
483
|
+
return () => {
|
|
484
|
+
stopped = true
|
|
485
|
+
clearInterval(timer)
|
|
486
|
+
}
|
|
487
|
+
}, 'session-trash: retention purge')
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async function sessionStillExists(ctx, persistence, sessionId) {
|
|
491
|
+
if (ctx.get('sessions')?.get(sessionId) !== undefined) return true
|
|
492
|
+
return storedSessionExists(persistence, sessionId)
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Whether the durable log for a session still exists. Read failures are treated
|
|
497
|
+
* as "still there" so cleanup never detaches a Session whose files are only
|
|
498
|
+
* temporarily unreadable.
|
|
499
|
+
*
|
|
500
|
+
* @param {{ stat: (sessionId: string) => Promise<unknown> }} persistence session persistence service.
|
|
501
|
+
* @param {string} sessionId session id.
|
|
502
|
+
* @returns {Promise<boolean>} true when a persisted log remains.
|
|
503
|
+
*/
|
|
504
|
+
async function storedSessionExists(persistence, sessionId) {
|
|
505
|
+
try {
|
|
506
|
+
return (await persistence.stat(sessionId)) !== undefined
|
|
507
|
+
} catch {
|
|
508
|
+
// A read failure is treated as "still there" so cleanup never resurrects.
|
|
509
|
+
return true
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Drop a deleted Session and its idle Agent from the Host registries.
|
|
515
|
+
*
|
|
516
|
+
* DSH has no public "close this Agent" capability; the lifecycle handle that
|
|
517
|
+
* owns the full factory teardown is not exposed to plugins. Once the log has
|
|
518
|
+
* been moved into the trash, leaving the live entries registered is what makes
|
|
519
|
+
* an ungrouped row survive deletion. Unregistering the exact entries removes
|
|
520
|
+
* the row immediately and, through `session/disposed`, releases the session
|
|
521
|
+
* writer. The Agent's scoped fiber stays parked until process exit, exactly as
|
|
522
|
+
* it did before this call; it can no longer be addressed by the sidebar or the
|
|
523
|
+
* session API.
|
|
524
|
+
*
|
|
525
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx host context.
|
|
526
|
+
* @param {string} sessionId deleted session id.
|
|
527
|
+
*/
|
|
528
|
+
function detachDeletedLiveState(ctx, sessionId) {
|
|
529
|
+
const sessions = ctx.get('sessions')
|
|
530
|
+
const agents = ctx.get('agents')
|
|
531
|
+
try {
|
|
532
|
+
agents?.get?.(sessionId)?.cancel?.({ kind: 'disposed' })
|
|
533
|
+
} catch {
|
|
534
|
+
// Cancelling idle work is best-effort; unregistering still has to happen.
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Session first: `session/disposed` makes the API publish a removal, and its
|
|
538
|
+
// persistence listener closes the file writer. Detaching the Agent afterwards
|
|
539
|
+
// cannot re-add a row because the session store no longer has it.
|
|
540
|
+
try {
|
|
541
|
+
const entry = sessions?.store?.get?.(sessionId)
|
|
542
|
+
if (entry !== undefined && typeof sessions?.detachEntered === 'function') {
|
|
543
|
+
sessions.detachEntered(entry)
|
|
544
|
+
}
|
|
545
|
+
} catch (error) {
|
|
546
|
+
ctx.logger?.warn?.(`session-trash: could not detach deleted session '${sessionId}': ${messageOf(error)}`)
|
|
547
|
+
}
|
|
548
|
+
try {
|
|
549
|
+
const entry = agents?.store?.get?.(sessionId)
|
|
550
|
+
if (entry !== undefined && typeof agents?.detachEntered === 'function') {
|
|
551
|
+
agents.detachEntered(entry)
|
|
552
|
+
}
|
|
553
|
+
} catch (error) {
|
|
554
|
+
ctx.logger?.warn?.(`session-trash: could not detach idle agent '${sessionId}': ${messageOf(error)}`)
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function findWorkspace(ctx, cwd, sessionId) {
|
|
559
|
+
const registry = ctx.get('workspaceRegistry')
|
|
560
|
+
if (registry === undefined || typeof cwd !== 'string' || cwd === '') return undefined
|
|
561
|
+
let canonical = cwd
|
|
562
|
+
try {
|
|
563
|
+
canonical = await realpath(cwd)
|
|
564
|
+
} catch {
|
|
565
|
+
// A missing directory is still comparable by its recorded spelling.
|
|
566
|
+
}
|
|
567
|
+
const candidates = registry.list().filter((workspace) => samePath(workspace.path, canonical))
|
|
568
|
+
return candidates.find((workspace) => {
|
|
569
|
+
if (typeof sessionId !== 'string') return false
|
|
570
|
+
try {
|
|
571
|
+
return Array.isArray(workspace.sessionIds) && workspace.sessionIds.includes(sessionId)
|
|
572
|
+
} catch {
|
|
573
|
+
return false
|
|
574
|
+
}
|
|
575
|
+
}) ?? candidates[0]
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function agentStatus(agent) {
|
|
579
|
+
if (agent === undefined) return 'none'
|
|
580
|
+
try {
|
|
581
|
+
return typeof agent.status === 'string' ? agent.status : 'unknown'
|
|
582
|
+
} catch {
|
|
583
|
+
return 'unknown'
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
async function unarchiveQuietly(ctx, sessionId) {
|
|
588
|
+
const registry = ctx.get('workspaceRegistry')
|
|
589
|
+
if (registry === undefined) return
|
|
590
|
+
try {
|
|
591
|
+
await registry.unarchiveSession(sessionId)
|
|
592
|
+
} catch {
|
|
593
|
+
// The id may already be absent; unarchiving is an idempotent no-op.
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function requireSessionId(payload) {
|
|
598
|
+
const value = payload?.sessionId
|
|
599
|
+
if (!isSafeSessionId(value)) {
|
|
600
|
+
throw coded('bad-id', '无效的对话 ID')
|
|
601
|
+
}
|
|
602
|
+
return value
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function normalizeTitle(value) {
|
|
606
|
+
if (typeof value !== 'string') return ''
|
|
607
|
+
return value.trim().slice(0, MAX_TITLE_LENGTH)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function firstString(value) {
|
|
611
|
+
return typeof value === 'string' && value.trim() !== '' ? value : undefined
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function clampNumber(value, fallback, minimum, maximum) {
|
|
615
|
+
const number = typeof value === 'number' ? value : Number(value)
|
|
616
|
+
if (!Number.isFinite(number)) return fallback
|
|
617
|
+
return Math.min(maximum, Math.max(minimum, number))
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function isRecord(value) {
|
|
621
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function messageOf(error) {
|
|
625
|
+
return error instanceof Error ? error.message : String(error)
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function coded(code, message) {
|
|
629
|
+
const error = new Error(message)
|
|
630
|
+
error.code = code
|
|
631
|
+
return error
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function readJsonRequest(req) {
|
|
635
|
+
const chunks = []
|
|
636
|
+
let size = 0
|
|
637
|
+
for await (const chunk of req) {
|
|
638
|
+
size += chunk.length
|
|
639
|
+
if (size > MAX_REQUEST_BYTES) throw coded('too-large', 'request body is too large')
|
|
640
|
+
chunks.push(chunk)
|
|
641
|
+
}
|
|
642
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
643
|
+
if (text.trim() === '') return {}
|
|
644
|
+
return JSON.parse(text)
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function delay(ms) {
|
|
648
|
+
return new Promise((resolve) => {
|
|
649
|
+
setTimeout(resolve, ms)
|
|
650
|
+
})
|
|
651
|
+
}
|
|
652
|
+
|