better-dsh-session-deletetool 0.4.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/CHANGELOG.md +299 -0
- package/LICENSE +21 -0
- package/README.md +267 -0
- package/README.zh-CN.md +171 -0
- package/client.js +1466 -0
- package/cordis.patch.yml +4 -0
- package/host.js +1431 -0
- package/icon.svg +6 -0
- package/package.json +62 -0
package/host.js
ADDED
|
@@ -0,0 +1,1431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* better-dsh-session-deletetool — Host half.
|
|
3
|
+
*
|
|
4
|
+
* DSH can only archive a conversation: `ctx.workspaceRegistry.archiveSession()`
|
|
5
|
+
* hides a Session from the sidebar and keeps every artifact. The persistence
|
|
6
|
+
* seam has no deletion API either — `@deepseek-ai/dsh-session-persistence-jsonl`
|
|
7
|
+
* documents "Nothing deletes session files — logs accumulate under `root` until
|
|
8
|
+
* removed externally; the seam has no deletion API".
|
|
9
|
+
*
|
|
10
|
+
* This plugin adds the missing delete. For one session id it removes:
|
|
11
|
+
* 1. the Session's own artifact directory (every format generation in it),
|
|
12
|
+
* 2. its account in every Workspace record, plus archive and pin membership,
|
|
13
|
+
* 3. its projection-cache checkpoint record,
|
|
14
|
+
* then emits `api-session/removed` so connected Clients drop the row — the same
|
|
15
|
+
* event the shipped Session controller emits when a Session is disposed.
|
|
16
|
+
*
|
|
17
|
+
* Descendants are selected, not assumed, and the family is read from both durable
|
|
18
|
+
* relations because neither one alone is complete:
|
|
19
|
+
*
|
|
20
|
+
* - **The subagent catalog** — each Session's own `subagentCatalog` projection,
|
|
21
|
+
* the parent-owned record of the Sessions it spawned — walked from the target
|
|
22
|
+
* downwards, so a subagent is listed under the Session that spawned it rather
|
|
23
|
+
* than under the target. (The service's `subagents.listDescendants` is asked as
|
|
24
|
+
* well; it needs the live Session store, so it can fail wholesale, which is
|
|
25
|
+
* exactly the case the per-parent walk survives.)
|
|
26
|
+
* - **The header lineage** (`SessionHeader.parentSession`) — the relation the
|
|
27
|
+
* catalog has no row for at all, which is what a forked conversation is, plus
|
|
28
|
+
* its own view of subagent Sessions and the project directory each child's
|
|
29
|
+
* header carries for display naming.
|
|
30
|
+
*
|
|
31
|
+
* `GET /inspect` returns that family as a list — id, kind, depth, parent, title,
|
|
32
|
+
* whether it is open, and what it is running — and `POST /delete` takes the ids
|
|
33
|
+
* the user ticked. The selection is validated against the Host's own walk, so a
|
|
34
|
+
* crafted request can only ever name Sessions in this lineage. Descendants are
|
|
35
|
+
* removed deepest first, and the family is gathered before anything is removed.
|
|
36
|
+
*
|
|
37
|
+
* `GET /catalog` answers the bulk view: every Session the corpus holds, grouped
|
|
38
|
+
* by the Workspace that owns its `cwd` the way the sidebar groups them, with the
|
|
39
|
+
* one fact the page cannot derive — which rows are a parent Session and which are
|
|
40
|
+
* a spawned/forked child — carried per row. `POST /delete-batch` is the same
|
|
41
|
+
* single-Session delete applied to a list of (root, chosen descendants) pairs, so
|
|
42
|
+
* the batch path shares the selection validation, the activity gate, and the
|
|
43
|
+
* removal order with the single path instead of restating them.
|
|
44
|
+
*
|
|
45
|
+
* What may block a delete is RUNNING WORK, never mere residency, and the question
|
|
46
|
+
* is asked once per Session in the delete set: the shipped admission answers for
|
|
47
|
+
* the Session it is asked about, so a descendant's running turn or background job
|
|
48
|
+
* would be invisible if only the target were asked. A Session the Host still holds
|
|
49
|
+
* open (`ctx.sessions` / `ctx.agents`) is deleted anyway: measured on this
|
|
50
|
+
* platform, `rm` succeeds while the append handle is open, the directory entry
|
|
51
|
+
* disappears at once, and later appends land in the unlinked file instead of
|
|
52
|
+
* resurrecting it. The gate itself is DSH's own archive admission — the
|
|
53
|
+
* `workspace/session-activity` waterfall the shipped archive uses, answered by the
|
|
54
|
+
* Agent registry (a running turn), the job registry, the Subagent runtime, and
|
|
55
|
+
* Schedule. Pass `stop: true` to stop that work first, exactly as
|
|
56
|
+
* `archiveSession(id, { stopActivity: true })` does. Each deleted Session's own
|
|
57
|
+
* shells are closed too: terminals belong to no admission family, and the service
|
|
58
|
+
* only reaps them once the Agent is released.
|
|
59
|
+
*
|
|
60
|
+
* The Client half reaches this half over two same-origin HTTP routes on
|
|
61
|
+
* `ctx.webServer`, the transport the installed community plugin `dshmarket`
|
|
62
|
+
* uses for its own UI→Host calls: a build-free plain-JavaScript bundle cannot
|
|
63
|
+
* declare a typed `ctx.remote` namespace, because those need generated Typert
|
|
64
|
+
* descriptors.
|
|
65
|
+
*/
|
|
66
|
+
import { rm } from 'node:fs/promises'
|
|
67
|
+
import { dirname } from 'node:path'
|
|
68
|
+
|
|
69
|
+
/** Services activation waits for; everything else is looked up per request. */
|
|
70
|
+
export const inject = ['webServer']
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The pure halves of the bulk routes, exported only for a test run.
|
|
74
|
+
*
|
|
75
|
+
* The Loader reads `apply` and `inject`; a test imports this module directly and
|
|
76
|
+
* sets `globalThis.__DSD_TEST__` before it does.
|
|
77
|
+
*/
|
|
78
|
+
export const __test = globalThis.__DSD_TEST__ === true
|
|
79
|
+
? { buildCatalog, catalogSessions, normalizeRoots, workspaceLabel }
|
|
80
|
+
: undefined
|
|
81
|
+
|
|
82
|
+
const DELETE_PATH = '/better-dsh-session-deletetool/delete'
|
|
83
|
+
const INSPECT_PATH = '/better-dsh-session-deletetool/inspect'
|
|
84
|
+
const CATALOG_PATH = '/better-dsh-session-deletetool/catalog'
|
|
85
|
+
const BATCH_PATH = '/better-dsh-session-deletetool/delete-batch'
|
|
86
|
+
|
|
87
|
+
/** Session ids are opaque strings, but never paths: keep them to safe segments. */
|
|
88
|
+
const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Request-body ceiling. The delete body carries the ticked descendant ids, and
|
|
92
|
+
* the cap it is validated against is MAX_DESCENDANTS, so this has to hold a few
|
|
93
|
+
* hundred of them.
|
|
94
|
+
*/
|
|
95
|
+
const MAX_BODY_BYTES = 65536
|
|
96
|
+
|
|
97
|
+
const JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
|
98
|
+
|
|
99
|
+
/** The families `workspace/session-activity` can report, in the reader's words. */
|
|
100
|
+
const ACTIVITY_LABELS = {
|
|
101
|
+
turn: '进行中的回合',
|
|
102
|
+
subagent: '运行中的子智能体',
|
|
103
|
+
job: '运行中的后台任务',
|
|
104
|
+
schedule: '生效中的定时提醒',
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** How many descendant Sessions one delete request may carry before it aborts. */
|
|
108
|
+
const MAX_DESCENDANTS = 200
|
|
109
|
+
|
|
110
|
+
/** How many descendants the state report lists for selection; beyond this only the count is shown. */
|
|
111
|
+
const MAX_LISTED_DESCENDANTS = 300
|
|
112
|
+
|
|
113
|
+
/** How many roots one bulk delete request may carry before it refuses. */
|
|
114
|
+
const MAX_BATCH_ROOTS = 200
|
|
115
|
+
|
|
116
|
+
/** How deep the lineage walk may go; a guard against a hand-edited header cycle. */
|
|
117
|
+
const MAX_LINEAGE_DEPTH = 64
|
|
118
|
+
|
|
119
|
+
/** A refusal carrying the HTTP status, stable code, and details the Client reports. */
|
|
120
|
+
class DeleteRefusal extends Error {
|
|
121
|
+
constructor(status, code, message, details) {
|
|
122
|
+
super(message)
|
|
123
|
+
this.name = 'DeleteRefusal'
|
|
124
|
+
this.status = status
|
|
125
|
+
this.code = code
|
|
126
|
+
this.details = details ?? {}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Register the delete, inspect, catalog and batch routes.
|
|
132
|
+
* @param {object} ctx - Host Cordis context.
|
|
133
|
+
*/
|
|
134
|
+
export function apply(ctx) {
|
|
135
|
+
const onDelete = (request, response) => {
|
|
136
|
+
void serveDelete(ctx, request, response).catch((error) => {
|
|
137
|
+
ctx.logger?.warn?.(`[better-dsh-session-deletetool] route failure: ${messageOf(error)}`)
|
|
138
|
+
send(response, 500, { ok: false, code: 'internal', message: messageOf(error) })
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
const onInspect = (request, response) => {
|
|
142
|
+
void serveInspect(ctx, request, response).catch((error) => {
|
|
143
|
+
ctx.logger?.warn?.(`[better-dsh-session-deletetool] route failure: ${messageOf(error)}`)
|
|
144
|
+
send(response, 500, { ok: false, code: 'internal', message: messageOf(error) })
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
const onCatalog = (request, response) => {
|
|
148
|
+
void serveCatalog(ctx, request, response).catch((error) => {
|
|
149
|
+
ctx.logger?.warn?.(`[better-dsh-session-deletetool] route failure: ${messageOf(error)}`)
|
|
150
|
+
send(response, 500, { ok: false, code: 'internal', message: messageOf(error) })
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
const onBatch = (request, response) => {
|
|
154
|
+
void serveBatch(ctx, request, response).catch((error) => {
|
|
155
|
+
ctx.logger?.warn?.(`[better-dsh-session-deletetool] route failure: ${messageOf(error)}`)
|
|
156
|
+
send(response, 500, { ok: false, code: 'internal', message: messageOf(error) })
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
ctx.effect(
|
|
160
|
+
() => ctx.webServer.register({ kind: 'exact', path: DELETE_PATH, handler: onDelete }),
|
|
161
|
+
'better-dsh-session-deletetool: delete route',
|
|
162
|
+
)
|
|
163
|
+
ctx.effect(
|
|
164
|
+
() => ctx.webServer.register({ kind: 'exact', path: INSPECT_PATH, handler: onInspect }),
|
|
165
|
+
'better-dsh-session-deletetool: inspect route',
|
|
166
|
+
)
|
|
167
|
+
ctx.effect(
|
|
168
|
+
() => ctx.webServer.register({ kind: 'exact', path: CATALOG_PATH, handler: onCatalog }),
|
|
169
|
+
'better-dsh-session-deletetool: catalog route',
|
|
170
|
+
)
|
|
171
|
+
ctx.effect(
|
|
172
|
+
() => ctx.webServer.register({ kind: 'exact', path: BATCH_PATH, handler: onBatch }),
|
|
173
|
+
'better-dsh-session-deletetool: batch route',
|
|
174
|
+
)
|
|
175
|
+
ctx.logger?.info?.(`[better-dsh-session-deletetool] routes mounted at ${DELETE_PATH}, ${INSPECT_PATH}, ${CATALOG_PATH} and ${BATCH_PATH}`)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Own one delete request end to end.
|
|
180
|
+
* @param {object} ctx - Host Cordis context.
|
|
181
|
+
* @param {import('node:http').IncomingMessage} request - the request.
|
|
182
|
+
* @param {import('node:http').ServerResponse} response - the response.
|
|
183
|
+
*/
|
|
184
|
+
async function serveDelete(ctx, request, response) {
|
|
185
|
+
if (request.method !== 'POST') {
|
|
186
|
+
send(response, 405, { ok: false, code: 'method-not-allowed', message: 'only POST is accepted' })
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
if (!sameOrigin(request)) {
|
|
190
|
+
send(response, 403, { ok: false, code: 'untrusted-origin', message: 'the request did not come from this Harness page' })
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
let body
|
|
194
|
+
try {
|
|
195
|
+
body = JSON.parse(await readBody(request))
|
|
196
|
+
} catch (error) {
|
|
197
|
+
send(response, 400, { ok: false, code: 'bad-request', message: messageOf(error) })
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId.trim() : ''
|
|
201
|
+
if (!SESSION_ID.test(sessionId)) {
|
|
202
|
+
send(response, 400, { ok: false, code: 'bad-request', message: 'sessionId is missing or malformed' })
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
send(response, 200, await deleteSession(ctx, sessionId, {
|
|
207
|
+
stop: body?.stop === true,
|
|
208
|
+
descendants: body?.descendants,
|
|
209
|
+
}))
|
|
210
|
+
} catch (error) {
|
|
211
|
+
answer(ctx, response, error, sessionId)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Own one inspect request: report what the page cannot see by itself.
|
|
217
|
+
* @param {object} ctx - Host Cordis context.
|
|
218
|
+
* @param {import('node:http').IncomingMessage} request - the request.
|
|
219
|
+
* @param {import('node:http').ServerResponse} response - the response.
|
|
220
|
+
*/
|
|
221
|
+
async function serveInspect(ctx, request, response) {
|
|
222
|
+
if (request.method !== 'GET') {
|
|
223
|
+
send(response, 405, { ok: false, code: 'method-not-allowed', message: 'only GET is accepted' })
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
if (!sameOrigin(request)) {
|
|
227
|
+
send(response, 403, { ok: false, code: 'untrusted-origin', message: 'the request did not come from this Harness page' })
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
let sessionId = ''
|
|
231
|
+
try {
|
|
232
|
+
sessionId = (new URL(request.url ?? '/', 'http://127.0.0.1').searchParams.get('sessionId') ?? '').trim()
|
|
233
|
+
} catch {
|
|
234
|
+
// A malformed request URL is reported as a malformed id below.
|
|
235
|
+
}
|
|
236
|
+
if (!SESSION_ID.test(sessionId)) {
|
|
237
|
+
send(response, 400, { ok: false, code: 'bad-request', message: 'sessionId is missing or malformed' })
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
send(response, 200, await inspectSession(ctx, sessionId))
|
|
242
|
+
} catch (error) {
|
|
243
|
+
answer(ctx, response, error, sessionId)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Own one catalog request: hand the bulk dialog every Session there is, grouped
|
|
249
|
+
* the way the sidebar groups them.
|
|
250
|
+
* @param {object} ctx - Host Cordis context.
|
|
251
|
+
* @param {import('node:http').IncomingMessage} request - the request.
|
|
252
|
+
* @param {import('node:http').ServerResponse} response - the response.
|
|
253
|
+
*/
|
|
254
|
+
async function serveCatalog(ctx, request, response) {
|
|
255
|
+
if (request.method !== 'GET') {
|
|
256
|
+
send(response, 405, { ok: false, code: 'method-not-allowed', message: 'only GET is accepted' })
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
if (!sameOrigin(request)) {
|
|
260
|
+
send(response, 403, { ok: false, code: 'untrusted-origin', message: 'the request did not come from this Harness page' })
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
send(response, 200, await buildCatalog(ctx))
|
|
265
|
+
} catch (error) {
|
|
266
|
+
answer(ctx, response, error, 'catalog')
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Own one bulk delete request: the same delete, once per selected root.
|
|
272
|
+
*
|
|
273
|
+
* Each root keeps its own selection, so the bulk dialog can carry the
|
|
274
|
+
* per-family checkbox state the single dialog offers. A root that fails is
|
|
275
|
+
* reported as a failure line and the remaining roots are still attempted: a
|
|
276
|
+
* batch is a sequence of independent deletes, not one transaction.
|
|
277
|
+
* @param {object} ctx - Host Cordis context.
|
|
278
|
+
* @param {import('node:http').IncomingMessage} request - the request.
|
|
279
|
+
* @param {import('node:http').ServerResponse} response - the response.
|
|
280
|
+
*/
|
|
281
|
+
async function serveBatch(ctx, request, response) {
|
|
282
|
+
if (request.method !== 'POST') {
|
|
283
|
+
send(response, 405, { ok: false, code: 'method-not-allowed', message: 'only POST is accepted' })
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
if (!sameOrigin(request)) {
|
|
287
|
+
send(response, 403, { ok: false, code: 'untrusted-origin', message: 'the request did not come from this Harness page' })
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
let body
|
|
291
|
+
try {
|
|
292
|
+
body = JSON.parse(await readBody(request))
|
|
293
|
+
} catch (error) {
|
|
294
|
+
send(response, 400, { ok: false, code: 'bad-request', message: messageOf(error) })
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
const roots = normalizeRoots(body?.roots)
|
|
298
|
+
if (roots.length === 0) {
|
|
299
|
+
send(response, 400, {
|
|
300
|
+
ok: false,
|
|
301
|
+
code: 'bad-request',
|
|
302
|
+
message: 'roots 必须是非空的 { sessionId, descendants } 列表。',
|
|
303
|
+
})
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
if (roots.length > MAX_BATCH_ROOTS) {
|
|
307
|
+
send(response, 400, {
|
|
308
|
+
ok: false,
|
|
309
|
+
code: 'too-many-roots',
|
|
310
|
+
message: `一次最多处理 ${MAX_BATCH_ROOTS} 个会话,请分批删除。`,
|
|
311
|
+
})
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const stop = body?.stop === true
|
|
316
|
+
const removed = []
|
|
317
|
+
const failed = []
|
|
318
|
+
for (const root of roots) {
|
|
319
|
+
try {
|
|
320
|
+
removed.push(await deleteSession(ctx, root.sessionId, { stop, descendants: root.descendants }))
|
|
321
|
+
} catch (error) {
|
|
322
|
+
const refusal = error instanceof DeleteRefusal
|
|
323
|
+
? error
|
|
324
|
+
: new DeleteRefusal(500, 'delete-failed', messageOf(error))
|
|
325
|
+
ctx.logger?.warn?.(`[better-dsh-session-deletetool] batch ${root.sessionId}: ${refusal.code}: ${refusal.message}`)
|
|
326
|
+
failed.push({ sessionId: root.sessionId, code: refusal.code, message: refusal.message })
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
send(response, 200, {
|
|
330
|
+
ok: failed.length === 0,
|
|
331
|
+
roots: roots.map((root) => root.sessionId),
|
|
332
|
+
removed,
|
|
333
|
+
failed,
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Read the caller's root list into `{ sessionId, descendants }` pairs.
|
|
339
|
+
*
|
|
340
|
+
* A root without a `descendants` array means "every descendant of it", which is
|
|
341
|
+
* what the bulk dialog sends for a row ticked as a whole family; an empty array
|
|
342
|
+
* means the row alone. Anything malformed is dropped here, and the caller
|
|
343
|
+
* reports the resulting empty list.
|
|
344
|
+
* @param {unknown} value - the request's `roots` field.
|
|
345
|
+
* @returns {Array<{ sessionId: string, descendants?: string[] }>} the roots.
|
|
346
|
+
*/
|
|
347
|
+
function normalizeRoots(value) {
|
|
348
|
+
if (!Array.isArray(value)) return []
|
|
349
|
+
const roots = []
|
|
350
|
+
const seen = new Set()
|
|
351
|
+
for (const raw of value) {
|
|
352
|
+
const sessionId = typeof raw?.sessionId === 'string' ? raw.sessionId.trim() : ''
|
|
353
|
+
if (!SESSION_ID.test(sessionId) || seen.has(sessionId)) continue
|
|
354
|
+
seen.add(sessionId)
|
|
355
|
+
const descendants = Array.isArray(raw?.descendants)
|
|
356
|
+
? raw.descendants
|
|
357
|
+
.filter((id) => typeof id === 'string' && SESSION_ID.test(id.trim()))
|
|
358
|
+
.map((id) => id.trim())
|
|
359
|
+
: undefined
|
|
360
|
+
roots.push({ sessionId, ...(descendants === undefined ? {} : { descendants }) })
|
|
361
|
+
}
|
|
362
|
+
return roots
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Build the bulk view: every known Session, grouped by its owning Workspace.
|
|
367
|
+
*
|
|
368
|
+
* Grouping follows the sidebar. A Session belongs to the Workspace whose
|
|
369
|
+
* directory matches its header `cwd`, so a Session whose `cwd` no Workspace owns
|
|
370
|
+
* lands in one "未归类" group. Within a group, a Session that another listed
|
|
371
|
+
* Session of the same group names as `parentSession` is indented under it; a
|
|
372
|
+
* Session whose parent is not listed here stays a root.
|
|
373
|
+
*
|
|
374
|
+
* Each row carries the one fact the page cannot derive by itself: whether the
|
|
375
|
+
* Session was spawned as a subagent or forked off another Session, whether it
|
|
376
|
+
* has children in this group, and how many subagent and derived Sessions hang
|
|
377
|
+
* off it, so the dialog can offer the same per-family selection the single
|
|
378
|
+
* dialog offers.
|
|
379
|
+
* @param {object} ctx - Host Cordis context.
|
|
380
|
+
* @returns {Promise<object>} `{ ok, workspaces, totals }`.
|
|
381
|
+
*/
|
|
382
|
+
async function buildCatalog(ctx) {
|
|
383
|
+
const query = ctx.get('sessionQuery')
|
|
384
|
+
if (query === undefined || typeof query.listSessions !== 'function') {
|
|
385
|
+
throw new DeleteRefusal(503, 'query-unavailable', 'sessionQuery is not mounted in this profile')
|
|
386
|
+
}
|
|
387
|
+
const records = await query.listSessions()
|
|
388
|
+
const headers = []
|
|
389
|
+
for (const record of records ?? []) {
|
|
390
|
+
const header = record?.header
|
|
391
|
+
if (header !== undefined && typeof header.id === 'string' && SESSION_ID.test(header.id)) headers.push(header)
|
|
392
|
+
}
|
|
393
|
+
await attachTitles(ctx, headers)
|
|
394
|
+
|
|
395
|
+
const registry = ctx.get('workspaceRegistry')
|
|
396
|
+
const workspaces = registry !== undefined && typeof registry.list === 'function' ? registry.list() : []
|
|
397
|
+
const rank = new Map()
|
|
398
|
+
/** Session id → owning Workspace id, taken from that Workspace's own account. */
|
|
399
|
+
const ownerOf = new Map()
|
|
400
|
+
for (const workspace of workspaces) {
|
|
401
|
+
const id = String(workspace?.id ?? '')
|
|
402
|
+
if (id !== '' && !rank.has(id)) rank.set(id, rank.size)
|
|
403
|
+
for (const sessionId of workspace?.sessionIds ?? []) {
|
|
404
|
+
const key = String(sessionId)
|
|
405
|
+
if (!ownerOf.has(key)) ownerOf.set(key, id)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const groups = new Map()
|
|
410
|
+
for (const header of headers) {
|
|
411
|
+
const owned = ownerOf.get(header.id)
|
|
412
|
+
const workspace = owned === undefined
|
|
413
|
+
? undefined
|
|
414
|
+
: workspaces.find((entry) => String(entry?.id ?? '') === owned)
|
|
415
|
+
const cwd = typeof header.cwd === 'string' && header.cwd !== '' ? header.cwd : undefined
|
|
416
|
+
// The Workspace account leads and the canonical directory follows, the
|
|
417
|
+
// precedence the sidebar's own grouping uses; anything else is ungrouped.
|
|
418
|
+
const key = workspace !== undefined ? `w:${owned}` : cwd === undefined ? 'ungrouped' : `c:${cwd}`
|
|
419
|
+
if (!groups.has(key)) groups.set(key, { workspace, cwd, members: [] })
|
|
420
|
+
groups.get(key).members.push(header)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const sections = []
|
|
424
|
+
for (const [key, group] of groups) {
|
|
425
|
+
if (key === 'ungrouped') continue
|
|
426
|
+
sections.push({
|
|
427
|
+
key,
|
|
428
|
+
workspaceId: group.workspace === undefined ? null : String(group.workspace.id),
|
|
429
|
+
title: group.workspace === undefined ? workspaceLabel(group.cwd) : String(group.workspace.title ?? ''),
|
|
430
|
+
path: group.workspace === undefined ? String(group.cwd ?? '') : String(group.workspace.path ?? ''),
|
|
431
|
+
sessions: await catalogSessions(ctx, group.members),
|
|
432
|
+
})
|
|
433
|
+
}
|
|
434
|
+
sections.sort((left, right) => sectionRank(rank, left) - sectionRank(rank, right) || left.title.localeCompare(right.title))
|
|
435
|
+
|
|
436
|
+
const ungrouped = groups.get('ungrouped')
|
|
437
|
+
if (ungrouped !== undefined) {
|
|
438
|
+
sections.push({
|
|
439
|
+
key: 'ungrouped',
|
|
440
|
+
workspaceId: null,
|
|
441
|
+
title: '未归类',
|
|
442
|
+
path: '',
|
|
443
|
+
sessions: await catalogSessions(ctx, ungrouped.members),
|
|
444
|
+
})
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return {
|
|
448
|
+
ok: true,
|
|
449
|
+
workspaces: sections,
|
|
450
|
+
totals: {
|
|
451
|
+
workspaces: sections.length,
|
|
452
|
+
sessions: headers.length,
|
|
453
|
+
ungrouped: ungrouped === undefined ? 0 : ungrouped.members.length,
|
|
454
|
+
},
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Where a section sits in the registry's durable order; unknown or absent ids last. */
|
|
459
|
+
function sectionRank(rank, section) {
|
|
460
|
+
if (section.workspaceId === null || section.workspaceId === undefined) return Number.MAX_SAFE_INTEGER
|
|
461
|
+
return rank.has(section.workspaceId) ? rank.get(section.workspaceId) : Number.MAX_SAFE_INTEGER - 1
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* One group's rows: lineage depth, parent/child flags and family counts.
|
|
466
|
+
* @param {object} ctx - Host Cordis context.
|
|
467
|
+
* @param {object[]} members - the group's headers.
|
|
468
|
+
* @returns {Promise<object[]>} the rows, parents before their children.
|
|
469
|
+
*/
|
|
470
|
+
async function catalogSessions(ctx, members) {
|
|
471
|
+
const inGroup = new Set(members.map((header) => header.id))
|
|
472
|
+
const childrenOf = new Map()
|
|
473
|
+
for (const header of members) {
|
|
474
|
+
const parent = typeof header.parentSession === 'string' ? header.parentSession : undefined
|
|
475
|
+
// A parent outside this group is not rendered here, so the row stays a root:
|
|
476
|
+
// indenting it under a row that does not exist would read as a lost row.
|
|
477
|
+
if (parent === undefined || !inGroup.has(parent) || parent === header.id) continue
|
|
478
|
+
if (!childrenOf.has(parent)) childrenOf.set(parent, [])
|
|
479
|
+
childrenOf.get(parent).push(header)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const depthOf = new Map()
|
|
483
|
+
const depthOfId = (id, guard) => {
|
|
484
|
+
if (depthOf.has(id)) return depthOf.get(id)
|
|
485
|
+
if (guard.has(id)) return 0
|
|
486
|
+
guard.add(id)
|
|
487
|
+
const header = members.find((entry) => entry.id === id)
|
|
488
|
+
const parent = typeof header?.parentSession === 'string' ? header.parentSession : undefined
|
|
489
|
+
const value = parent === undefined || !inGroup.has(parent) || parent === id ? 0 : depthOfId(parent, guard) + 1
|
|
490
|
+
depthOf.set(id, Math.min(value, MAX_LINEAGE_DEPTH))
|
|
491
|
+
return depthOf.get(id)
|
|
492
|
+
}
|
|
493
|
+
for (const header of members) depthOfId(header.id, new Set())
|
|
494
|
+
|
|
495
|
+
const rows = []
|
|
496
|
+
for (const header of members) {
|
|
497
|
+
const parent = typeof header.parentSession === 'string' && inGroup.has(header.parentSession) && header.parentSession !== header.id
|
|
498
|
+
? header.parentSession
|
|
499
|
+
: undefined
|
|
500
|
+
const children = childrenOf.get(header.id) ?? []
|
|
501
|
+
const runtime = runtimeOf(ctx, header.id)
|
|
502
|
+
const activity = await catalogActivity(ctx, header.id, runtime)
|
|
503
|
+
// Display name, resolved here so the row never reads as untitled: the
|
|
504
|
+
// durable title when the log carries one, else the project directory's final
|
|
505
|
+
// segment, the same order the sidebar's own rows use.
|
|
506
|
+
const named = typeof header.title === 'string' && header.title.trim() !== ''
|
|
507
|
+
? header.title
|
|
508
|
+
: workspaceTitleOf(header.cwd)
|
|
509
|
+
rows.push({
|
|
510
|
+
id: header.id,
|
|
511
|
+
kind: header.origin === 'subagent' ? DESCENDANT_KINDS.subagent : parent === undefined ? 'root' : DESCENDANT_KINDS.derived,
|
|
512
|
+
depth: depthOf.get(header.id) ?? 0,
|
|
513
|
+
createdAt: typeof header.createdAt === 'number' ? header.createdAt : 0,
|
|
514
|
+
hasChildren: children.length > 0,
|
|
515
|
+
family: children.length,
|
|
516
|
+
subagents: children.filter((child) => child.origin === 'subagent').length,
|
|
517
|
+
derived: children.filter((child) => child.origin !== 'subagent').length,
|
|
518
|
+
...(parent === undefined ? {} : { parentId: parent }),
|
|
519
|
+
...(named === '' ? {} : { title: named }),
|
|
520
|
+
...(typeof header.cwd === 'string' && header.cwd !== '' ? { cwd: header.cwd } : {}),
|
|
521
|
+
...(header.isSeeded === true ? { seeded: true } : {}),
|
|
522
|
+
...(header.origin === 'subagent' ? { origin: 'subagent' } : {}),
|
|
523
|
+
...(typeof header.agentPreset === 'string' && header.agentPreset !== '' ? { agentPreset: header.agentPreset } : {}),
|
|
524
|
+
...runtime,
|
|
525
|
+
activity,
|
|
526
|
+
})
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Parents before their children, so a family reads as one block; each level is
|
|
530
|
+
// newest-first, the order the sidebar's own list uses.
|
|
531
|
+
const newestFirst = (left, right) => right.createdAt - left.createdAt || left.id.localeCompare(right.id)
|
|
532
|
+
const sorted = []
|
|
533
|
+
const emitted = new Set()
|
|
534
|
+
const walk = (parentId) => {
|
|
535
|
+
const children = rows.filter((row) => row.parentId === parentId).sort(newestFirst)
|
|
536
|
+
for (const child of children) {
|
|
537
|
+
if (emitted.has(child.id)) continue
|
|
538
|
+
emitted.add(child.id)
|
|
539
|
+
sorted.push(child)
|
|
540
|
+
walk(child.id)
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
const roots = rows.filter((row) => row.parentId === undefined).sort(newestFirst)
|
|
544
|
+
for (const root of roots) {
|
|
545
|
+
if (emitted.has(root.id)) continue
|
|
546
|
+
emitted.add(root.id)
|
|
547
|
+
sorted.push(root)
|
|
548
|
+
walk(root.id)
|
|
549
|
+
}
|
|
550
|
+
for (const row of rows.sort(newestFirst)) {
|
|
551
|
+
if (emitted.has(row.id)) continue
|
|
552
|
+
emitted.add(row.id)
|
|
553
|
+
sorted.push(row)
|
|
554
|
+
}
|
|
555
|
+
return sorted
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* The display label for a directory no Workspace owns: its final segment, the
|
|
560
|
+
* way the registry titles a Workspace it creates.
|
|
561
|
+
* @param {string|undefined} path - a directory path.
|
|
562
|
+
* @returns {string} the label.
|
|
563
|
+
*/
|
|
564
|
+
function workspaceLabel(path) {
|
|
565
|
+
const cleaned = String(path ?? '').replace(/[\\/]+$/, '')
|
|
566
|
+
if (cleaned === '') return '未归类'
|
|
567
|
+
const segment = cleaned.slice(Math.max(cleaned.lastIndexOf('/'), cleaned.lastIndexOf('\\')) + 1)
|
|
568
|
+
return segment === '' ? cleaned : segment
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Delete the Session the user acted on, the descendants they selected, and
|
|
573
|
+
* everything keyed by those ids.
|
|
574
|
+
* @param {object} ctx - Host Cordis context.
|
|
575
|
+
* @param {string} sessionId - the Session the user acted on.
|
|
576
|
+
* @param {{ stop: boolean, descendants?: string[] }} options - whether to stop
|
|
577
|
+
* running work first, and which descendants to take; omitting `descendants`
|
|
578
|
+
* means every one of them.
|
|
579
|
+
* @returns {Promise<object>} the removal report.
|
|
580
|
+
*/
|
|
581
|
+
async function deleteSession(ctx, sessionId, options) {
|
|
582
|
+
const persistence = ctx.get('sessionPersistence')
|
|
583
|
+
if (persistence === undefined || typeof persistence.stat !== 'function') {
|
|
584
|
+
throw new DeleteRefusal(503, 'persistence-unavailable', 'sessionPersistence is not mounted in this profile')
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const snapshot = await persistence.stat(sessionId)
|
|
588
|
+
const header = snapshot?.header
|
|
589
|
+
if (header === undefined) {
|
|
590
|
+
throw new DeleteRefusal(404, 'session-not-found', `找不到会话 ${sessionId} 的持久化记录。`)
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const artifactDirectory = locateDirectory(persistence, header)
|
|
594
|
+
if (artifactDirectory === undefined) {
|
|
595
|
+
throw new DeleteRefusal(501, 'no-artifact', '当前持久化后端不提供会话工件路径,无法删除文件。')
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const runtime = runtimeOf(ctx, sessionId)
|
|
599
|
+
const removal = { stoppedActivity: false, warnings: [], terminalsKilled: 0 }
|
|
600
|
+
|
|
601
|
+
// The family is gathered first and the caller's selection is validated against
|
|
602
|
+
// it, so a crafted request can never name a Session outside this lineage.
|
|
603
|
+
const gathered = await gatherDescendants(ctx, sessionId, removal.warnings)
|
|
604
|
+
const selected = selectDescendants(gathered, options.descendants)
|
|
605
|
+
if (selected.length > MAX_DESCENDANTS) {
|
|
606
|
+
throw new DeleteRefusal(
|
|
607
|
+
409,
|
|
608
|
+
'too-many-descendants',
|
|
609
|
+
`一次最多删除 ${MAX_DESCENDANTS} 个子会话,当前选中 ${selected.length} 个,请分批删除。`,
|
|
610
|
+
)
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Every Session in the set is asked about its own work. The shipped admission
|
|
614
|
+
// answers for the Session it is asked about, so a descendant's running turn or
|
|
615
|
+
// background job would be invisible if only the target were asked.
|
|
616
|
+
const targets = [sessionId, ...selected.map((entry) => entry.id)]
|
|
617
|
+
const busy = await gatherActivities(ctx, targets)
|
|
618
|
+
if (busy.size > 0 && options.stop !== true) {
|
|
619
|
+
const busyList = [...busy.entries()].map(([id, activity]) => ({ sessionId: id, activity }))
|
|
620
|
+
const labels = [...new Set(busyList.flatMap((entry) => entry.activity.map((item) => item.label)))].join('、')
|
|
621
|
+
throw new DeleteRefusal(
|
|
622
|
+
409,
|
|
623
|
+
'session-active',
|
|
624
|
+
busy.size === 1 && busy.has(sessionId)
|
|
625
|
+
? `这个会话还有未结束的工作(${labels})。确认后会先停掉它们再删除。`
|
|
626
|
+
: `选中的会话里还有未结束的工作(${labels},共 ${busy.size} 个会话)。确认后会先停掉它们再删除。`,
|
|
627
|
+
{ activity: busy.get(sessionId) ?? [], activeSessions: busyList },
|
|
628
|
+
)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Running work goes first, the way the shipped archive's stopActivity does.
|
|
632
|
+
if (busy.size > 0) {
|
|
633
|
+
for (const id of busy.keys()) await stopSessionActivity(ctx, id, removal)
|
|
634
|
+
removal.stoppedActivity = true
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// A deleted Session's shells go with it. Terminals are not part of the shipped
|
|
638
|
+
// archive admission, and the service's own owner cleanup only fires once the
|
|
639
|
+
// Agent is released, which can be long after the log is gone.
|
|
640
|
+
for (const id of targets) removal.terminalsKilled += await killOwnedTerminals(ctx, id, removal)
|
|
641
|
+
|
|
642
|
+
// Descendants are Sessions of their own with their own logs: deepest first, so
|
|
643
|
+
// no child outlives its parent by more than one call.
|
|
644
|
+
const removedDescendants = []
|
|
645
|
+
for (const child of selected) {
|
|
646
|
+
const report = await removeSession(ctx, child.id)
|
|
647
|
+
report.kind = child.kind
|
|
648
|
+
removedDescendants.push(report)
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const removed = await removeSession(ctx, sessionId, { artifactDirectory })
|
|
652
|
+
|
|
653
|
+
return {
|
|
654
|
+
ok: true,
|
|
655
|
+
sessionId,
|
|
656
|
+
removed,
|
|
657
|
+
descendants: removedDescendants,
|
|
658
|
+
kept: gathered.length - selected.length,
|
|
659
|
+
stoppedActivity: removal.stoppedActivity,
|
|
660
|
+
terminalsKilled: removal.terminalsKilled,
|
|
661
|
+
warnings: removal.warnings,
|
|
662
|
+
runtime,
|
|
663
|
+
activity: busy.get(sessionId) ?? [],
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Remove one Session's artifacts, workspace account and projection checkpoint,
|
|
669
|
+
* then tell connected Clients to drop its row.
|
|
670
|
+
* @param {object} ctx - Host Cordis context.
|
|
671
|
+
* @param {string} sessionId - the Session to remove.
|
|
672
|
+
* @param {{ artifactDirectory?: string }} known - an already-resolved artifact path.
|
|
673
|
+
* @returns {Promise<object>} the per-Session removal report.
|
|
674
|
+
*/
|
|
675
|
+
async function removeSession(ctx, sessionId, known = {}) {
|
|
676
|
+
const report = {
|
|
677
|
+
sessionId,
|
|
678
|
+
artifactDirectory: null,
|
|
679
|
+
removed: false,
|
|
680
|
+
workspaces: 0,
|
|
681
|
+
projectionCheckpoint: false,
|
|
682
|
+
warnings: [],
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
let directory = known.artifactDirectory
|
|
686
|
+
if (directory === undefined) {
|
|
687
|
+
const persistence = ctx.get('sessionPersistence')
|
|
688
|
+
const snapshot = persistence === undefined || typeof persistence.stat !== 'function'
|
|
689
|
+
? undefined
|
|
690
|
+
: await persistence.stat(sessionId)
|
|
691
|
+
const header = snapshot?.header
|
|
692
|
+
if (header === undefined) report.warnings.push('没有持久化记录,可能已经被删除')
|
|
693
|
+
else directory = locateDirectory(persistence, header)
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// The Session's own directory: the current log, every retained historical
|
|
697
|
+
// generation, and any future session-local artifact live in it.
|
|
698
|
+
if (directory !== undefined) {
|
|
699
|
+
report.artifactDirectory = directory
|
|
700
|
+
await rm(directory, { recursive: true, force: true })
|
|
701
|
+
report.removed = true
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Workspace accounting: the sidebar groups Sessions by Workspace records, and
|
|
705
|
+
// the registry-global archive/pin sets are Session id arrays.
|
|
706
|
+
await detachFromWorkspaces(ctx, sessionId, report)
|
|
707
|
+
|
|
708
|
+
// The projection checkpoint is a fold shortcut and disposable; leaving it
|
|
709
|
+
// would keep a record for a Session that no longer exists.
|
|
710
|
+
report.projectionCheckpoint = await dropProjectionCheckpoint(ctx, sessionId, report)
|
|
711
|
+
|
|
712
|
+
// Connected Clients drop the row from their session list, exactly as they do
|
|
713
|
+
// when the Session controller reports a disposed Session.
|
|
714
|
+
ctx.emit('api-session/removed', sessionId)
|
|
715
|
+
|
|
716
|
+
return report
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* The kind labels a descendant can carry into the report and the dialog.
|
|
721
|
+
* `subagent` is a Session the Subagent runtime spawned (`origin: 'subagent'`);
|
|
722
|
+
* `derived` is any other child on the lineage, a conversation forked off this
|
|
723
|
+
* one, which DSH records as `parentSession` plus `isSeeded`.
|
|
724
|
+
*/
|
|
725
|
+
const DESCENDANT_KINDS = { subagent: 'subagent', derived: 'derived' }
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Turn a caller's selection into the descendant entries to delete.
|
|
729
|
+
*
|
|
730
|
+
* The selection is validated against the lineage the Host itself gathered, so a
|
|
731
|
+
* crafted request can only ever name Sessions in this family. An omitted field
|
|
732
|
+
* means "all of them"; an empty array means "only the Session itself".
|
|
733
|
+
* @param {Array<{ id: string, depth: number, kind: string }>} gathered - the family.
|
|
734
|
+
* @param {unknown} requested - the ids the Client sent, when it sent any.
|
|
735
|
+
* @returns {Array<{ id: string, depth: number, kind: string }>} deepest first.
|
|
736
|
+
*/
|
|
737
|
+
function selectDescendants(gathered, requested) {
|
|
738
|
+
if (requested === undefined || requested === null) return gathered
|
|
739
|
+
if (!Array.isArray(requested)) {
|
|
740
|
+
throw new DeleteRefusal(400, 'bad-request', 'descendants 必须是会话 id 数组。')
|
|
741
|
+
}
|
|
742
|
+
const byId = new Map(gathered.map((entry) => [entry.id, entry]))
|
|
743
|
+
const seen = new Set()
|
|
744
|
+
const selected = []
|
|
745
|
+
for (const raw of requested) {
|
|
746
|
+
const id = typeof raw === 'string' ? raw.trim() : ''
|
|
747
|
+
if (id === '' || seen.has(id)) continue
|
|
748
|
+
const entry = byId.get(id)
|
|
749
|
+
if (entry === undefined) {
|
|
750
|
+
throw new DeleteRefusal(
|
|
751
|
+
400,
|
|
752
|
+
'unknown-descendant',
|
|
753
|
+
`${id} 不是这个会话的子会话,已中止,没有删除任何东西。`,
|
|
754
|
+
{ sessionId: id },
|
|
755
|
+
)
|
|
756
|
+
}
|
|
757
|
+
seen.add(id)
|
|
758
|
+
selected.push(entry)
|
|
759
|
+
}
|
|
760
|
+
selected.sort((left, right) => right.depth - left.depth || left.id.localeCompare(right.id))
|
|
761
|
+
return selected
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Ask every Session in a delete set about its own running work.
|
|
766
|
+
*
|
|
767
|
+
* The shipped admission answers per Session, so this must be one call per id: a
|
|
768
|
+
* descendant's running turn or background job is not reported when the target is
|
|
769
|
+
* asked.
|
|
770
|
+
* @param {object} ctx - Host Cordis context.
|
|
771
|
+
* @param {string[]} ids - the Sessions in the delete set.
|
|
772
|
+
* @returns {Promise<Map<string, object[]>>} only the busy ones, described.
|
|
773
|
+
*/
|
|
774
|
+
async function gatherActivities(ctx, ids) {
|
|
775
|
+
const busy = new Map()
|
|
776
|
+
for (const id of ids) {
|
|
777
|
+
const activity = describeActivity(await sessionActivity(ctx, id))
|
|
778
|
+
if (activity.length > 0) busy.set(id, activity)
|
|
779
|
+
}
|
|
780
|
+
return busy
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Read the descendant set without applying the delete cap, for the state report.
|
|
785
|
+
*
|
|
786
|
+
* Two durable sources, merged by the closest depth:
|
|
787
|
+
*
|
|
788
|
+
* 1. **The subagent catalog**, read per parent through the parent Session's own
|
|
789
|
+
* `subagentCatalog` projection, walked from the target downwards. This is the
|
|
790
|
+
* relation that carries no header field: every row is a Session the parent
|
|
791
|
+
* spawned, and walking it is what puts a subagent under the child that
|
|
792
|
+
* spawned it rather than under the target. The service's own
|
|
793
|
+
* `subagents.listDescendants` is asked as well, as a second opinion, because
|
|
794
|
+
* this walk reads sessions one at a time and a single unreadable parent must
|
|
795
|
+
* not take its whole branch with it.
|
|
796
|
+
* 2. **The header lineage** (`SessionHeader.parentSession`), which covers forked
|
|
797
|
+
* conversations — the relation that never appears in any catalog — plus its
|
|
798
|
+
* own view of subagent sessions, and the project directory each child's header
|
|
799
|
+
* carries for display naming.
|
|
800
|
+
*
|
|
801
|
+
* The result is the reachable family: a child is included when the lineage or a
|
|
802
|
+
* catalog links it to the target, or to another child that is already included.
|
|
803
|
+
* A Session whose parent header names an id outside the corpus is not reachable
|
|
804
|
+
* this way, yet it can still be a live fork of the target, so it is included too
|
|
805
|
+
* — deleting it is no worse than deleting it through the fork's own row.
|
|
806
|
+
* @param {object} ctx - Host Cordis context.
|
|
807
|
+
* @param {string} sessionId - the Session whose descendants are wanted.
|
|
808
|
+
* @param {string[]} warnings - collector for recoverable failures.
|
|
809
|
+
* @returns {Promise<Array<{ id: string, depth: number, kind: string }>>} deepest first.
|
|
810
|
+
*/
|
|
811
|
+
async function gatherDescendants(ctx, sessionId, warnings) {
|
|
812
|
+
const collected = new Map()
|
|
813
|
+
/** The header lineage, indexed by parent, for the reachability pass. */
|
|
814
|
+
const childrenByParent = new Map()
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* One discovered child, keyed by id. The closest depth wins, a subagent label
|
|
818
|
+
* outranks a derived one, and the first parent that named it is kept for
|
|
819
|
+
* indentation. The project directory comes only from the lineage walk, which
|
|
820
|
+
* reads it off the child's own header; the catalog carries no path, so a child
|
|
821
|
+
* known only through the catalog falls back to its title and then its id.
|
|
822
|
+
*/
|
|
823
|
+
const add = (rawId, depth, kind, parentId, cwd) => {
|
|
824
|
+
if (typeof rawId !== 'string' || rawId === sessionId || !SESSION_ID.test(rawId)) return
|
|
825
|
+
const parent = typeof parentId === 'string' && SESSION_ID.test(parentId) ? parentId : undefined
|
|
826
|
+
const path = typeof cwd === 'string' && cwd !== '' ? cwd : undefined
|
|
827
|
+
const existing = collected.get(rawId)
|
|
828
|
+
if (existing === undefined) {
|
|
829
|
+
collected.set(rawId, {
|
|
830
|
+
id: rawId,
|
|
831
|
+
depth,
|
|
832
|
+
kind,
|
|
833
|
+
...(parent === undefined ? {} : { parentId: parent }),
|
|
834
|
+
...(path === undefined ? {} : { cwd: path }),
|
|
835
|
+
})
|
|
836
|
+
return
|
|
837
|
+
}
|
|
838
|
+
existing.depth = Math.min(existing.depth, depth)
|
|
839
|
+
if (kind === DESCENDANT_KINDS.subagent) existing.kind = DESCENDANT_KINDS.subagent
|
|
840
|
+
if (existing.parentId === undefined && parent !== undefined) existing.parentId = parent
|
|
841
|
+
if (existing.cwd === undefined && path !== undefined) existing.cwd = path
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// 1. The header lineage every Session records, indexed by parent.
|
|
845
|
+
const query = ctx.get('sessionQuery')
|
|
846
|
+
const known = new Set([sessionId])
|
|
847
|
+
if (query === undefined || typeof query.listSessions !== 'function') {
|
|
848
|
+
warnings.push('sessionQuery 服务不可用,派生对话与子智能体的血缘没有读到')
|
|
849
|
+
} else {
|
|
850
|
+
try {
|
|
851
|
+
for (const record of await query.listSessions()) {
|
|
852
|
+
const header = record?.header
|
|
853
|
+
if (header === undefined || typeof header.id !== 'string' || !SESSION_ID.test(header.id)) continue
|
|
854
|
+
known.add(header.id)
|
|
855
|
+
const parent = typeof header.parentSession === 'string' ? header.parentSession : undefined
|
|
856
|
+
if (parent === undefined) continue
|
|
857
|
+
if (!childrenByParent.has(parent)) childrenByParent.set(parent, [])
|
|
858
|
+
childrenByParent.get(parent).push(header)
|
|
859
|
+
}
|
|
860
|
+
} catch (error) {
|
|
861
|
+
warnings.push(`读取会话血缘失败,派生对话与子智能体的血缘没有读到:${messageOf(error)}`)
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// 2. The subagent catalog, walked from the target down through each parent's own
|
|
866
|
+
// direct children, so every subagent hangs under the Session that spawned it.
|
|
867
|
+
const walk = await walkSubagentCatalog(ctx, sessionId, warnings)
|
|
868
|
+
for (const node of walk) {
|
|
869
|
+
add(node.id, node.depth, DESCENDANT_KINDS.subagent, node.parentId, node.cwd)
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// 3. The catalog service's own descendant list, merged for depth and coverage:
|
|
873
|
+
// it reaches children the per-parent walk could not read, and it cannot
|
|
874
|
+
// invent an id this family has never seen.
|
|
875
|
+
const subagents = ctx.get('subagents')
|
|
876
|
+
if (subagents === undefined || typeof subagents.listDescendants !== 'function') {
|
|
877
|
+
warnings.push('subagents 服务不可用,子智能体名册没有读到')
|
|
878
|
+
} else {
|
|
879
|
+
try {
|
|
880
|
+
const entries = await subagents.listDescendants(sessionId)
|
|
881
|
+
if (Array.isArray(entries)) {
|
|
882
|
+
for (const entry of entries) {
|
|
883
|
+
add(
|
|
884
|
+
entry?.id,
|
|
885
|
+
typeof entry?.depth === 'number' ? entry.depth : 1,
|
|
886
|
+
DESCENDANT_KINDS.subagent,
|
|
887
|
+
typeof entry?.parentId === 'string' ? entry.parentId : undefined,
|
|
888
|
+
)
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
} catch (error) {
|
|
892
|
+
warnings.push(`读取子智能体名册失败:${messageOf(error)}`)
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// 4. The lineage's own children of every reachable Session: subagent Sessions
|
|
897
|
+
// whose catalog row is gone, and forked conversations, which have no row at
|
|
898
|
+
// all. Breadth-first, so each child's depth is its distance from the target.
|
|
899
|
+
const reachable = new Set([sessionId])
|
|
900
|
+
let frontier = [sessionId]
|
|
901
|
+
for (let depth = 1; frontier.length > 0 && depth <= MAX_LINEAGE_DEPTH; depth += 1) {
|
|
902
|
+
const next = []
|
|
903
|
+
for (const parentId of frontier) {
|
|
904
|
+
for (const header of childrenByParent.get(parentId) ?? []) {
|
|
905
|
+
if (reachable.has(header.id)) continue
|
|
906
|
+
reachable.add(header.id)
|
|
907
|
+
const knownChild = collected.get(header.id)
|
|
908
|
+
add(
|
|
909
|
+
header.id,
|
|
910
|
+
depth,
|
|
911
|
+
header.origin === 'subagent' ? DESCENDANT_KINDS.subagent : DESCENDANT_KINDS.derived,
|
|
912
|
+
parentId,
|
|
913
|
+
header.cwd,
|
|
914
|
+
)
|
|
915
|
+
// The lineage found this child where the catalog did not, so the catalog
|
|
916
|
+
// walk never descended through it: queue it so its own subagents follow.
|
|
917
|
+
if (knownChild === undefined) next.push(header.id)
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
frontier = next
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// 5. A fork whose parent header names an id the corpus no longer holds, yet
|
|
924
|
+
// which carries the target's own project directory: unreachable by structure,
|
|
925
|
+
// still part of this family in practice.
|
|
926
|
+
try {
|
|
927
|
+
const root = ctx.get('sessionPersistence')
|
|
928
|
+
const rootHeader = root === undefined || typeof root.stat !== 'function'
|
|
929
|
+
? undefined
|
|
930
|
+
: (await root.stat(sessionId))?.header
|
|
931
|
+
const rootCwd = typeof rootHeader?.cwd === 'string' ? rootHeader.cwd : undefined
|
|
932
|
+
if (rootCwd !== undefined) {
|
|
933
|
+
for (const [, list] of childrenByParent) {
|
|
934
|
+
for (const header of list) {
|
|
935
|
+
if (reachable.has(header.id) || header.cwd !== rootCwd) continue
|
|
936
|
+
const parent = typeof header.parentSession === 'string' ? header.parentSession : ''
|
|
937
|
+
if (known.has(parent)) continue
|
|
938
|
+
reachable.add(header.id)
|
|
939
|
+
add(header.id, 1, DESCENDANT_KINDS.derived, undefined, header.cwd)
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
} catch {
|
|
944
|
+
// An unreadable root header only costs the orphan fork, which is optional.
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const list = [...collected.values()]
|
|
948
|
+
list.sort((left, right) => right.depth - left.depth || left.id.localeCompare(right.id))
|
|
949
|
+
return attachTitles(ctx, list)
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* Walk the durable subagent catalog from one root, deepest structure preserved.
|
|
954
|
+
*
|
|
955
|
+
* Each level is read from the parent Session's own `subagentCatalog` projection —
|
|
956
|
+
* the parent-owned record of the Sessions it spawned — so a subagent is placed
|
|
957
|
+
* under the Session that actually spawned it. The walk is iterative and
|
|
958
|
+
* cycle-guarded: a hand-edited catalog cannot make it loop, and one unreadable
|
|
959
|
+
* parent costs that parent's branch rather than the whole walk.
|
|
960
|
+
* @param {object} ctx - Host Cordis context.
|
|
961
|
+
* @param {string} rootId - the Session whose descendants are wanted.
|
|
962
|
+
* @param {string[]} warnings - collector for recoverable failures.
|
|
963
|
+
* @returns {Promise<Array<{ id: string, depth: number, parentId: string }>>} the rows.
|
|
964
|
+
*/
|
|
965
|
+
async function walkSubagentCatalog(ctx, rootId, warnings) {
|
|
966
|
+
const query = ctx.get('sessionQuery')
|
|
967
|
+
if (query === undefined || typeof query.observeSession !== 'function') {
|
|
968
|
+
warnings.push('sessionQuery 服务不可用,子智能体目录没有读到')
|
|
969
|
+
return []
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
const rows = []
|
|
973
|
+
const visited = new Set([rootId])
|
|
974
|
+
let frontier = [{ id: rootId, depth: 0 }]
|
|
975
|
+
while (frontier.length > 0) {
|
|
976
|
+
const next = []
|
|
977
|
+
for (const parent of frontier) {
|
|
978
|
+
let observation
|
|
979
|
+
try {
|
|
980
|
+
observation = await query.observeSession(parent.id)
|
|
981
|
+
} catch (error) {
|
|
982
|
+
warnings.push(`读取会话 ${parent.id} 的子智能体目录失败:${messageOf(error)}`)
|
|
983
|
+
continue
|
|
984
|
+
}
|
|
985
|
+
let entries
|
|
986
|
+
try {
|
|
987
|
+
entries = observation?.projections?.values?.subagentCatalog
|
|
988
|
+
} finally {
|
|
989
|
+
// A lease pins its cached preparation until it is disposed, and the only
|
|
990
|
+
// disposer is `Symbol.dispose` — the observation carries no `release()`.
|
|
991
|
+
// Calling it in a `finally` keeps the walk from pinning every parent it
|
|
992
|
+
// read; without the symbol (an older backend) there is nothing to free.
|
|
993
|
+
const release = observation?.[Symbol.dispose]
|
|
994
|
+
if (typeof release === 'function') {
|
|
995
|
+
try {
|
|
996
|
+
release.call(observation)
|
|
997
|
+
} catch {
|
|
998
|
+
// A lease that refuses to free is the Host's business, not ours.
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
if (!Array.isArray(entries)) {
|
|
1003
|
+
warnings.push(`会话 ${parent.id} 的子智能体目录没有读到`)
|
|
1004
|
+
continue
|
|
1005
|
+
}
|
|
1006
|
+
for (const entry of entries) {
|
|
1007
|
+
const id = typeof entry?.id === 'string' ? entry.id : ''
|
|
1008
|
+
if (!SESSION_ID.test(id) || visited.has(id)) continue
|
|
1009
|
+
visited.add(id)
|
|
1010
|
+
const depth = parent.depth + 1
|
|
1011
|
+
if (depth > MAX_LINEAGE_DEPTH) continue
|
|
1012
|
+
rows.push({ id, depth, parentId: parent.id })
|
|
1013
|
+
next.push({ id, depth })
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
frontier = next
|
|
1017
|
+
}
|
|
1018
|
+
return rows
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* The final non-empty segment of a directory path, POSIX or Windows.
|
|
1023
|
+
*
|
|
1024
|
+
* The same reading the shipped client's `workspaceTitleOf` uses, kept in step
|
|
1025
|
+
* with it so a Session's display name here matches the sidebar's.
|
|
1026
|
+
* @param {string|undefined} path - the directory path.
|
|
1027
|
+
* @returns {string} the final segment, or an empty string.
|
|
1028
|
+
*/
|
|
1029
|
+
function workspaceTitleOf(path) {
|
|
1030
|
+
if (typeof path !== 'string') return ''
|
|
1031
|
+
const trimmed = path.replace(/[/\\]+$/, '')
|
|
1032
|
+
const separator = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
|
|
1033
|
+
return trimmed.slice(separator + 1)
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* Fill in each entry's title, so the dialogs list names instead of ids.
|
|
1038
|
+
*
|
|
1039
|
+
* Two sources, in the order the sidebar itself uses: the durable title the
|
|
1040
|
+
* Session log carries, then the final segment of its project directory, then the
|
|
1041
|
+
* raw id. The folded snapshot is `{ session, title: { title, … } }`, and the
|
|
1042
|
+
* query service reports a Session with no title event as fulfilled with no
|
|
1043
|
+
* `title`, so a Session that was never renamed still reads as its directory
|
|
1044
|
+
* rather than as "untitled".
|
|
1045
|
+
* @param {object} ctx - Host Cordis context.
|
|
1046
|
+
* @param {Array<object>} entries - the gathered entries, each with `id` and
|
|
1047
|
+
* optionally `cwd`.
|
|
1048
|
+
* @returns {Promise<Array<object>>} the same entries, with `title` where known.
|
|
1049
|
+
*/
|
|
1050
|
+
async function attachTitles(ctx, entries) {
|
|
1051
|
+
if (entries.length === 0) return entries
|
|
1052
|
+
const titled = new Map()
|
|
1053
|
+
const query = ctx.get('sessionQuery')
|
|
1054
|
+
if (query !== undefined && typeof query.readTitleSnapshots === 'function') {
|
|
1055
|
+
try {
|
|
1056
|
+
const results = await query.readTitleSnapshots(entries.map((entry) => entry.id))
|
|
1057
|
+
if (Array.isArray(results)) {
|
|
1058
|
+
for (const result of results) {
|
|
1059
|
+
if (result?.status !== 'fulfilled') continue
|
|
1060
|
+
const id = result.value?.session?.id
|
|
1061
|
+
// The snapshot nests the folded title one level down; a Session whose
|
|
1062
|
+
// log holds no title event reports fulfilled without it.
|
|
1063
|
+
const title = result.value?.title?.title
|
|
1064
|
+
if (typeof id === 'string' && typeof title === 'string' && title.trim() !== '') titled.set(id, title)
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
} catch {
|
|
1068
|
+
// Titles are optional: the directory and the id still name the row.
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
for (const entry of entries) {
|
|
1072
|
+
const title = titled.get(entry.id)
|
|
1073
|
+
if (title !== undefined) {
|
|
1074
|
+
entry.title = title
|
|
1075
|
+
continue
|
|
1076
|
+
}
|
|
1077
|
+
const directory = workspaceTitleOf(entry.cwd)
|
|
1078
|
+
if (directory !== '') entry.title = directory
|
|
1079
|
+
}
|
|
1080
|
+
return entries
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Read one Session's state without changing anything.
|
|
1085
|
+
* @param {object} ctx - Host Cordis context.
|
|
1086
|
+
* @param {string} sessionId - the Session to inspect.
|
|
1087
|
+
* @returns {Promise<object>} the state the Client dialog reports.
|
|
1088
|
+
*/
|
|
1089
|
+
async function inspectSession(ctx, sessionId) {
|
|
1090
|
+
const persistence = ctx.get('sessionPersistence')
|
|
1091
|
+
const snapshot = persistence === undefined || typeof persistence.stat !== 'function'
|
|
1092
|
+
? undefined
|
|
1093
|
+
: await persistence.stat(sessionId)
|
|
1094
|
+
const header = snapshot?.header
|
|
1095
|
+
// Why a branch may be missing is carried back to the dialog: the family is read
|
|
1096
|
+
// from two sources, and one of them failing silently is indistinguishable from a
|
|
1097
|
+
// Session that genuinely has no children.
|
|
1098
|
+
const warnings = []
|
|
1099
|
+
const descendants = await gatherDescendants(ctx, sessionId, warnings)
|
|
1100
|
+
const listed = descendants.slice(0, MAX_LISTED_DESCENDANTS)
|
|
1101
|
+
// Only the Sessions the dialog can show are asked about their work: each answer
|
|
1102
|
+
// costs one admission walk, and beyond the listing nothing can be selected.
|
|
1103
|
+
const busy = await gatherActivities(ctx, [sessionId, ...listed.map((entry) => entry.id)])
|
|
1104
|
+
const subagents = descendants.filter((entry) => entry.kind === DESCENDANT_KINDS.subagent).length
|
|
1105
|
+
return {
|
|
1106
|
+
ok: true,
|
|
1107
|
+
sessionId,
|
|
1108
|
+
stored: header !== undefined,
|
|
1109
|
+
artifactDirectory: header === undefined ? undefined : locateDirectory(persistence, header),
|
|
1110
|
+
...runtimeOf(ctx, sessionId),
|
|
1111
|
+
activity: busy.get(sessionId) ?? [],
|
|
1112
|
+
warnings,
|
|
1113
|
+
descendants: {
|
|
1114
|
+
count: descendants.length,
|
|
1115
|
+
subagents,
|
|
1116
|
+
derived: descendants.length - subagents,
|
|
1117
|
+
truncated: descendants.length > listed.length,
|
|
1118
|
+
maxDeletable: MAX_DESCENDANTS,
|
|
1119
|
+
items: listed.map((entry) => ({
|
|
1120
|
+
id: entry.id,
|
|
1121
|
+
kind: entry.kind,
|
|
1122
|
+
depth: entry.depth,
|
|
1123
|
+
...(entry.parentId === undefined ? {} : { parentId: entry.parentId }),
|
|
1124
|
+
...(entry.title === undefined ? {} : { title: entry.title }),
|
|
1125
|
+
...runtimeOf(ctx, entry.id),
|
|
1126
|
+
activity: busy.get(entry.id) ?? [],
|
|
1127
|
+
})),
|
|
1128
|
+
},
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* Close the terminals a Session owns.
|
|
1134
|
+
*
|
|
1135
|
+
* Terminals are part of no shipped admission family, and the service's own owner
|
|
1136
|
+
* cleanup fires when the Agent is released, which can be long after the log is
|
|
1137
|
+
* gone; a shell left running for a deleted conversation helps nobody.
|
|
1138
|
+
* @param {object} ctx - Host Cordis context.
|
|
1139
|
+
* @param {string} sessionId - the Session whose shells close.
|
|
1140
|
+
* @param {object} removal - report being filled in.
|
|
1141
|
+
* @returns {Promise<number>} how many terminals were closed.
|
|
1142
|
+
*/
|
|
1143
|
+
async function killOwnedTerminals(ctx, sessionId, removal) {
|
|
1144
|
+
const terminals = ctx.get('terminals')
|
|
1145
|
+
if (terminals === undefined || typeof terminals.list !== 'function' || typeof terminals.kill !== 'function') return 0
|
|
1146
|
+
|
|
1147
|
+
let owner
|
|
1148
|
+
try {
|
|
1149
|
+
const agents = ctx.get('agents')
|
|
1150
|
+
owner = typeof agents?.get === 'function' ? agents.get(sessionId) : undefined
|
|
1151
|
+
} catch {
|
|
1152
|
+
owner = undefined
|
|
1153
|
+
}
|
|
1154
|
+
// `list` matches the exact owner object, and a cold Session owns no terminal.
|
|
1155
|
+
if (owner === undefined) return 0
|
|
1156
|
+
|
|
1157
|
+
let killed = 0
|
|
1158
|
+
try {
|
|
1159
|
+
for (const snapshot of terminals.list(owner) ?? []) {
|
|
1160
|
+
const id = typeof snapshot?.sessionId === 'string' ? snapshot.sessionId : ''
|
|
1161
|
+
if (id === '') continue
|
|
1162
|
+
try {
|
|
1163
|
+
if (await terminals.kill(owner, id, 'session deleted')) killed += 1
|
|
1164
|
+
} catch (error) {
|
|
1165
|
+
removal.warnings.push(`关闭终端 ${id} 失败:${messageOf(error)}`)
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
} catch (error) {
|
|
1169
|
+
removal.warnings.push(`读取终端列表失败:${messageOf(error)}`)
|
|
1170
|
+
}
|
|
1171
|
+
return killed
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* Whether the Host still holds the Session open, and whether its Agent runs.
|
|
1176
|
+
* Residency never blocks a delete; it is reported so the dialog can say so.
|
|
1177
|
+
* @param {object} ctx - Host Cordis context.
|
|
1178
|
+
* @param {string} sessionId - the Session to describe.
|
|
1179
|
+
* @returns {{ open: boolean, agent: boolean, running: boolean }} the runtime facts.
|
|
1180
|
+
*/
|
|
1181
|
+
function runtimeOf(ctx, sessionId) {
|
|
1182
|
+
let open = false
|
|
1183
|
+
let agent = false
|
|
1184
|
+
let running = false
|
|
1185
|
+
try {
|
|
1186
|
+
const sessions = ctx.get('sessions')
|
|
1187
|
+
if (typeof sessions?.get === 'function') open = sessions.get(sessionId) !== undefined
|
|
1188
|
+
} catch {
|
|
1189
|
+
// A service that cannot answer leaves the fact false.
|
|
1190
|
+
}
|
|
1191
|
+
try {
|
|
1192
|
+
const agents = ctx.get('agents')
|
|
1193
|
+
if (typeof agents?.get === 'function') {
|
|
1194
|
+
const live = agents.get(sessionId)
|
|
1195
|
+
agent = live !== undefined
|
|
1196
|
+
running = live?.status === 'running'
|
|
1197
|
+
}
|
|
1198
|
+
} catch {
|
|
1199
|
+
// Same here.
|
|
1200
|
+
}
|
|
1201
|
+
return { open, agent, running }
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* Ask the composed providers what still runs for this Session, through DSH's own
|
|
1206
|
+
* archive-admission waterfall.
|
|
1207
|
+
* @param {object} ctx - Host Cordis context.
|
|
1208
|
+
* @param {string} sessionId - the Session to ask about.
|
|
1209
|
+
* @returns {Promise<object[]>} the reported families, in listener order.
|
|
1210
|
+
*/
|
|
1211
|
+
async function sessionActivity(ctx, sessionId) {
|
|
1212
|
+
if (typeof ctx.waterfall !== 'function') return []
|
|
1213
|
+
const value = await ctx.waterfall('workspace/session-activity', { sessionId }, () => Promise.resolve([]))
|
|
1214
|
+
return Array.isArray(value) ? value : []
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/**
|
|
1218
|
+
* The activity of one Session as the bulk dialog shows it.
|
|
1219
|
+
*
|
|
1220
|
+
* The bulk list asks about every Session at once, and each answer costs one
|
|
1221
|
+
* admission walk, so a Session the Host holds neither open nor in an Agent is
|
|
1222
|
+
* reported idle without asking: such a Session cannot have work to stop, and a
|
|
1223
|
+
* long list stays one pass over the corpus.
|
|
1224
|
+
* @param {object} ctx - Host Cordis context.
|
|
1225
|
+
* @param {string} sessionId - the Session to ask about.
|
|
1226
|
+
* @param {{ open: boolean, agent: boolean, running: boolean }} runtime - its runtime facts.
|
|
1227
|
+
* @returns {Promise<object[]|null>} the described families, or null when idle.
|
|
1228
|
+
*/
|
|
1229
|
+
async function catalogActivity(ctx, sessionId, runtime) {
|
|
1230
|
+
if (runtime.open !== true && runtime.agent !== true && runtime.running !== true) return null
|
|
1231
|
+
const described = describeActivity(await sessionActivity(ctx, sessionId))
|
|
1232
|
+
return described.length === 0 ? null : described
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/**
|
|
1236
|
+
* Stop this Session's running work the way the user's own stop actions do.
|
|
1237
|
+
* @param {object} ctx - Host Cordis context.
|
|
1238
|
+
* @param {string} sessionId - the Session whose work stops.
|
|
1239
|
+
* @param {object} removal - report being filled in.
|
|
1240
|
+
* @returns {Promise<void>} resolution once every provider was asked.
|
|
1241
|
+
*/
|
|
1242
|
+
async function stopSessionActivity(ctx, sessionId, removal) {
|
|
1243
|
+
if (typeof ctx.parallel !== 'function') {
|
|
1244
|
+
removal.warnings.push('ctx.parallel is unavailable; running work was not stopped')
|
|
1245
|
+
return
|
|
1246
|
+
}
|
|
1247
|
+
try {
|
|
1248
|
+
await ctx.parallel('workspace/session-stop', { sessionId })
|
|
1249
|
+
} catch (error) {
|
|
1250
|
+
removal.warnings.push(`stopping running work failed: ${messageOf(error)}`)
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/**
|
|
1255
|
+
* Project the activity entries into lossless JSON for the Client.
|
|
1256
|
+
* @param {object[]} entries - raw `workspace/session-activity` entries.
|
|
1257
|
+
* @returns {object[]} `{ kind, label, items }` rows.
|
|
1258
|
+
*/
|
|
1259
|
+
function describeActivity(entries) {
|
|
1260
|
+
return entries.map((entry) => {
|
|
1261
|
+
const kind = typeof entry?.kind === 'string' && entry.kind !== '' ? entry.kind : 'unknown'
|
|
1262
|
+
const items = Array.isArray(entry?.items) ? entry.items : []
|
|
1263
|
+
return {
|
|
1264
|
+
kind,
|
|
1265
|
+
label: ACTIVITY_LABELS[kind] ?? kind,
|
|
1266
|
+
items: items.slice(0, 20).map((item) => ({
|
|
1267
|
+
id: item?.id === undefined ? '' : String(item.id),
|
|
1268
|
+
label: typeof item?.label === 'string' ? item.label : '',
|
|
1269
|
+
})),
|
|
1270
|
+
}
|
|
1271
|
+
})
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/**
|
|
1275
|
+
* The Session's own directory, through the persistence backend's artifact hook.
|
|
1276
|
+
* @param {object} persistence - the mounted sessionPersistence service.
|
|
1277
|
+
* @param {object} header - the stored Session header.
|
|
1278
|
+
* @returns {string|undefined} the directory, or undefined without that hook.
|
|
1279
|
+
*/
|
|
1280
|
+
function locateDirectory(persistence, header) {
|
|
1281
|
+
if (typeof persistence.locate !== 'function') return undefined
|
|
1282
|
+
try {
|
|
1283
|
+
const location = persistence.locate(header)
|
|
1284
|
+
if (location !== undefined && typeof location.path === 'string' && location.path !== '') {
|
|
1285
|
+
return dirname(location.path)
|
|
1286
|
+
}
|
|
1287
|
+
} catch {
|
|
1288
|
+
// A backend that refuses to locate is a backend whose files stay untouched.
|
|
1289
|
+
}
|
|
1290
|
+
return undefined
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/**
|
|
1294
|
+
* Drop the id from every Workspace account and from the registry-wide sets.
|
|
1295
|
+
* @param {object} ctx - Host Cordis context.
|
|
1296
|
+
* @param {string} sessionId - the Session to forget.
|
|
1297
|
+
* @param {object} removal - report being filled in.
|
|
1298
|
+
* @returns {Promise<void>} resolution after the durable writes.
|
|
1299
|
+
*/
|
|
1300
|
+
async function detachFromWorkspaces(ctx, sessionId, removal) {
|
|
1301
|
+
const registry = ctx.get('workspaceRegistry')
|
|
1302
|
+
if (registry === undefined) {
|
|
1303
|
+
removal.warnings.push('workspaceRegistry is not mounted; the Workspace record was left untouched')
|
|
1304
|
+
return
|
|
1305
|
+
}
|
|
1306
|
+
try {
|
|
1307
|
+
if (typeof registry.list === 'function') {
|
|
1308
|
+
for (const workspace of registry.list()) {
|
|
1309
|
+
if (typeof workspace?.detachSession !== 'function') continue
|
|
1310
|
+
await workspace.detachSession(sessionId)
|
|
1311
|
+
removal.workspaces += 1
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
if (typeof registry.unarchiveSession === 'function') await registry.unarchiveSession(sessionId)
|
|
1315
|
+
if (typeof registry.unpinSession === 'function') await registry.unpinSession(sessionId)
|
|
1316
|
+
} catch (error) {
|
|
1317
|
+
removal.warnings.push(`workspace cleanup failed: ${messageOf(error)}`)
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/**
|
|
1322
|
+
* Delete the projection-cache record for one Session.
|
|
1323
|
+
* @param {object} ctx - Host Cordis context.
|
|
1324
|
+
* @param {string} sessionId - the Session whose checkpoint goes away.
|
|
1325
|
+
* @param {object} removal - report being filled in.
|
|
1326
|
+
* @returns {Promise<boolean>} whether a record was dropped.
|
|
1327
|
+
*/
|
|
1328
|
+
async function dropProjectionCheckpoint(ctx, sessionId, removal) {
|
|
1329
|
+
const storageDomain = ctx.get('storageDomain')
|
|
1330
|
+
if (typeof storageDomain?.get !== 'function') {
|
|
1331
|
+
removal.warnings.push('storageDomain is not mounted; the projection checkpoint was left untouched')
|
|
1332
|
+
return false
|
|
1333
|
+
}
|
|
1334
|
+
try {
|
|
1335
|
+
const domain = storageDomain.get('session_projcache')
|
|
1336
|
+
const table = typeof domain?.table === 'function' ? domain.table('sessions') : undefined
|
|
1337
|
+
if (typeof table?.delete !== 'function') return false
|
|
1338
|
+
return (await table.delete(sessionId)) === true
|
|
1339
|
+
} catch (error) {
|
|
1340
|
+
removal.warnings.push(`projection-cache cleanup failed: ${messageOf(error)}`)
|
|
1341
|
+
return false
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/**
|
|
1346
|
+
* Report one failure with its status, code, and details.
|
|
1347
|
+
* @param {object} ctx - Host Cordis context.
|
|
1348
|
+
* @param {import('node:http').ServerResponse} response - the response.
|
|
1349
|
+
* @param {unknown} error - the caught value.
|
|
1350
|
+
* @param {string} sessionId - the Session the request named.
|
|
1351
|
+
*/
|
|
1352
|
+
function answer(ctx, response, error, sessionId) {
|
|
1353
|
+
const refusal = error instanceof DeleteRefusal
|
|
1354
|
+
? error
|
|
1355
|
+
: new DeleteRefusal(500, 'delete-failed', messageOf(error))
|
|
1356
|
+
ctx.logger?.warn?.(`[better-dsh-session-deletetool] ${sessionId}: ${refusal.code}: ${refusal.message}`)
|
|
1357
|
+
send(response, refusal.status, { ok: false, code: refusal.code, message: refusal.message, ...refusal.details })
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
/**
|
|
1361
|
+
* Whether the request came from this Harness page rather than another site.
|
|
1362
|
+
*
|
|
1363
|
+
* The rebinding defence is the `Host` header: a page on `evil.com` aimed at
|
|
1364
|
+
* 127.0.0.1 sends a matching Origin/Host pair, so only `Host` can tell the
|
|
1365
|
+
* attack apart. An absent `Host` or `Origin` is not a cross-site request — the
|
|
1366
|
+
* Desktop build's proxy strips both, and only a non-page client omits them.
|
|
1367
|
+
* @param {import('node:http').IncomingMessage} request - the request.
|
|
1368
|
+
* @returns {boolean} whether the request may proceed.
|
|
1369
|
+
*/
|
|
1370
|
+
function sameOrigin(request) {
|
|
1371
|
+
const host = request.headers.host
|
|
1372
|
+
if (host !== undefined && !loopbackAuthority(host)) return false
|
|
1373
|
+
if (request.headers['sec-fetch-site'] === 'cross-site') return false
|
|
1374
|
+
const origin = request.headers.origin
|
|
1375
|
+
if (origin === undefined) return true
|
|
1376
|
+
try {
|
|
1377
|
+
return new URL(origin).host === host
|
|
1378
|
+
} catch {
|
|
1379
|
+
return false
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
/**
|
|
1384
|
+
* Whether a `Host` header names a loopback authority.
|
|
1385
|
+
* @param {string} host - the request's Host header.
|
|
1386
|
+
* @returns {boolean} whether it is loopback.
|
|
1387
|
+
*/
|
|
1388
|
+
function loopbackAuthority(host) {
|
|
1389
|
+
const lower = host.toLowerCase()
|
|
1390
|
+
const name = lower.startsWith('[') ? lower.slice(0, lower.indexOf(']') + 1) : lower.split(':')[0]
|
|
1391
|
+
return name === '127.0.0.1' || name === 'localhost' || name === '[::1]'
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
/**
|
|
1395
|
+
* Read a size-capped JSON request body.
|
|
1396
|
+
* @param {import('node:http').IncomingMessage} request - the request.
|
|
1397
|
+
* @returns {Promise<string>} the decoded body.
|
|
1398
|
+
*/
|
|
1399
|
+
async function readBody(request) {
|
|
1400
|
+
const chunks = []
|
|
1401
|
+
let size = 0
|
|
1402
|
+
for await (const chunk of request) {
|
|
1403
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
1404
|
+
size += buffer.length
|
|
1405
|
+
if (size > MAX_BODY_BYTES) throw new Error('request body too large')
|
|
1406
|
+
chunks.push(buffer)
|
|
1407
|
+
}
|
|
1408
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/**
|
|
1412
|
+
* Write one JSON response.
|
|
1413
|
+
* @param {import('node:http').ServerResponse} response - the response.
|
|
1414
|
+
* @param {number} status - HTTP status.
|
|
1415
|
+
* @param {object} payload - JSON body.
|
|
1416
|
+
*/
|
|
1417
|
+
function send(response, status, payload) {
|
|
1418
|
+
if (response.headersSent || response.writableEnded) return
|
|
1419
|
+
const body = JSON.stringify(payload)
|
|
1420
|
+
response.writeHead(status, { ...JSON_HEADERS, 'content-length': Buffer.byteLength(body) })
|
|
1421
|
+
response.end(body)
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
/**
|
|
1425
|
+
* Read one error's text.
|
|
1426
|
+
* @param {unknown} error - the caught value.
|
|
1427
|
+
* @returns {string} its message.
|
|
1428
|
+
*/
|
|
1429
|
+
function messageOf(error) {
|
|
1430
|
+
return error instanceof Error ? error.message : String(error)
|
|
1431
|
+
}
|