@wangxiaocuo/dsh-session-trash 0.0.0-stage → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +204 -0
- package/README.md +202 -2
- package/client.js +2231 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +10 -0
- package/index.js +129 -0
- package/lib/cleanup.js +342 -0
- package/lib/eligibility.js +310 -0
- package/lib/errors.js +91 -0
- package/lib/fsops.js +433 -0
- package/lib/http.js +351 -0
- package/lib/inventory.js +426 -0
- package/lib/members.js +95 -0
- package/lib/paths.js +39 -0
- package/lib/state.js +155 -0
- package/lib/store.js +606 -0
- package/lib/trash-service.js +1591 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +68 -4
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { isSafeDirectoryPath } from './fsops.js'
|
|
4
|
+
|
|
5
|
+
/** How long a successful preflight answer is reused by the menu. */
|
|
6
|
+
export const PREFLIGHT_TTL_MS = 2000
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {{ sessionId: string, header: object }} DescendantRef
|
|
10
|
+
* @typedef {{ sessionId: string, header: object, location: { path: string }, formatStatus?: string }} PlannedMember
|
|
11
|
+
* @typedef {{ eligible: true, members: PlannedMember[] } | { eligible: false, code: string, message: string, details?: object }} PreflightResult
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} code refusal code.
|
|
16
|
+
* @param {string} message owner-facing explanation.
|
|
17
|
+
* @param {object} [details] optional structured context.
|
|
18
|
+
* @returns {{ eligible: false, code: string, message: string, details?: object }} a refusal.
|
|
19
|
+
*/
|
|
20
|
+
function refuse(code, message, details) {
|
|
21
|
+
return details === undefined ? { eligible: false, code, message } : { eligible: false, code, message, details }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Collect every stored session whose lineage descends from `sessionId`.
|
|
26
|
+
*
|
|
27
|
+
* @param {import('@deepseek-ai/dsh-session-persistence').SessionPersistence} persistence persistence service.
|
|
28
|
+
* @param {string} sessionId root session.
|
|
29
|
+
* @returns {Promise<DescendantRef[]>} descendant identity and stored header.
|
|
30
|
+
*/
|
|
31
|
+
async function descendantsFromListing(persistence, sessionId) {
|
|
32
|
+
const snapshots = await persistence.list()
|
|
33
|
+
const childrenByParent = new Map()
|
|
34
|
+
for (const snapshot of snapshots) {
|
|
35
|
+
const header = snapshot?.header
|
|
36
|
+
const parent = header?.parentSession
|
|
37
|
+
if (typeof parent !== 'string' || typeof header?.id !== 'string') continue
|
|
38
|
+
const bucket = childrenByParent.get(parent)
|
|
39
|
+
if (bucket === undefined) childrenByParent.set(parent, [header])
|
|
40
|
+
else bucket.push(header)
|
|
41
|
+
}
|
|
42
|
+
const found = []
|
|
43
|
+
const queue = [sessionId]
|
|
44
|
+
const visited = new Set([sessionId])
|
|
45
|
+
while (queue.length > 0) {
|
|
46
|
+
const current = queue.shift()
|
|
47
|
+
for (const header of childrenByParent.get(current) ?? []) {
|
|
48
|
+
if (visited.has(header.id)) continue
|
|
49
|
+
visited.add(header.id)
|
|
50
|
+
found.push({ sessionId: header.id, header })
|
|
51
|
+
queue.push(header.id)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return found
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Flatten the nested descendant tree returned by `sessionQuery.traceSession`.
|
|
59
|
+
* A grandchild whose direct parent is no longer stored still proves that the
|
|
60
|
+
* root session owns a lineage the trash must not split.
|
|
61
|
+
*
|
|
62
|
+
* @param {readonly object[]} nodes descendant nodes from the trace.
|
|
63
|
+
* @returns {DescendantRef[]} every descendant session identity and header.
|
|
64
|
+
*/
|
|
65
|
+
function collectTraceDescendants(nodes) {
|
|
66
|
+
const found = []
|
|
67
|
+
const visit = (list) => {
|
|
68
|
+
for (const node of list) {
|
|
69
|
+
const header = node?.session?.header
|
|
70
|
+
const id = header?.id ?? node?.session?.id
|
|
71
|
+
if (typeof id === 'string' && id !== '') found.push({ sessionId: id, header: header ?? {} })
|
|
72
|
+
if (Array.isArray(node?.descendants)) visit(node.descendants)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
visit(Array.isArray(nodes) ? nodes : [])
|
|
76
|
+
return found
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Decide whether one session may move into the trash. A session that owns only
|
|
81
|
+
* hidden `origin: 'subagent'` descendants may move together with them; any
|
|
82
|
+
* other descendant keeps the historical refusal.
|
|
83
|
+
*
|
|
84
|
+
* @param {{ ctx: import('@deepseek-ai/cordis').Context, store: import('./store.js').TrashStore, logger: object }} deps host services.
|
|
85
|
+
* @returns {{ evaluate: (sessionId: string) => Promise<PreflightResult>, invalidate: (sessionId?: string) => void }} the cached evaluator.
|
|
86
|
+
*/
|
|
87
|
+
export function createEligibility({ ctx, store, logger }) {
|
|
88
|
+
/** @type {Map<string, { expires: number, value: PreflightResult }>} */
|
|
89
|
+
const cache = new Map()
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {string} sessionId session to inspect.
|
|
93
|
+
* @returns {boolean} whether the session is live in this host process.
|
|
94
|
+
*/
|
|
95
|
+
function isLive(sessionId) {
|
|
96
|
+
const liveSession = typeof ctx.sessions?.get === 'function' ? ctx.sessions.get(sessionId) : undefined
|
|
97
|
+
const liveAgent = typeof ctx.agents?.get === 'function' ? ctx.agents.get(sessionId) : undefined
|
|
98
|
+
return liveSession !== undefined || liveAgent !== undefined
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @param {string} sessionId session to inspect.
|
|
103
|
+
* @returns {Promise<DescendantRef[] | { error: true }>} the complete descendant list, or a refusal marker.
|
|
104
|
+
*/
|
|
105
|
+
async function collectDescendants(sessionId) {
|
|
106
|
+
const sessionQuery = ctx.get('sessionQuery')
|
|
107
|
+
if (sessionQuery !== undefined && sessionQuery !== null && typeof sessionQuery.traceSession === 'function') {
|
|
108
|
+
try {
|
|
109
|
+
const trace = await sessionQuery.traceSession(sessionId)
|
|
110
|
+
return collectTraceDescendants(trace?.descendants ?? [])
|
|
111
|
+
} catch (error) {
|
|
112
|
+
logger.warn(`preflight lineage failed for ${sessionId}: ${error instanceof Error ? error.message : String(error)}`)
|
|
113
|
+
return { error: true }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
return await descendantsFromListing(ctx.sessionPersistence, sessionId)
|
|
118
|
+
} catch (error) {
|
|
119
|
+
logger.warn(`preflight lineage listing failed for ${sessionId}: ${error instanceof Error ? error.message : String(error)}`)
|
|
120
|
+
return { error: true }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @param {string} sessionId descendant session to validate.
|
|
126
|
+
* @returns {Promise<object>} either `{ member }` with the planned member or `{ refusal }`.
|
|
127
|
+
*/
|
|
128
|
+
async function planDescendant(sessionId) {
|
|
129
|
+
let snapshot
|
|
130
|
+
try {
|
|
131
|
+
snapshot = await ctx.sessionPersistence.stat(sessionId)
|
|
132
|
+
} catch (error) {
|
|
133
|
+
logger.warn(`preflight stat failed for child session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`)
|
|
134
|
+
return { refusal: refuse('IO_ERROR', 'A child session in this lineage could not be read.', { sessionId }) }
|
|
135
|
+
}
|
|
136
|
+
if (snapshot === undefined || snapshot === null) {
|
|
137
|
+
return {
|
|
138
|
+
refusal: refuse('SESSION_NOT_MATERIALIZED', 'A child session has no stored log yet.', { sessionId }),
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const location = snapshot.location
|
|
142
|
+
if (location === undefined || location === null || typeof location.path !== 'string') {
|
|
143
|
+
return {
|
|
144
|
+
refusal: refuse('SESSION_NOT_MATERIALIZED', 'A child session has no stored log yet.', { sessionId }),
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (location.kind !== 'jsonl') {
|
|
148
|
+
return {
|
|
149
|
+
refusal: refuse('STORAGE_UNSUPPORTED', `A child session uses the "${String(location.kind)}" storage backend, which is not supported.`, {
|
|
150
|
+
sessionId,
|
|
151
|
+
}),
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (!path.isAbsolute(location.path)) {
|
|
155
|
+
return { refusal: refuse('STORAGE_UNSUPPORTED', 'A child session log path is not absolute.', { sessionId }) }
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
member: {
|
|
159
|
+
sessionId,
|
|
160
|
+
header: snapshot.header,
|
|
161
|
+
location: { path: location.path },
|
|
162
|
+
formatStatus: snapshot.formatStatus,
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @param {string} sessionId session to inspect.
|
|
169
|
+
* @returns {Promise<PreflightResult>} the uncached verdict.
|
|
170
|
+
*/
|
|
171
|
+
async function evaluateUncached(sessionId) {
|
|
172
|
+
let snapshot
|
|
173
|
+
try {
|
|
174
|
+
snapshot = await ctx.sessionPersistence.stat(sessionId)
|
|
175
|
+
} catch (error) {
|
|
176
|
+
logger.warn(`preflight stat failed for ${sessionId}: ${error instanceof Error ? error.message : String(error)}`)
|
|
177
|
+
return refuse('IO_ERROR', 'The session storage could not be read.')
|
|
178
|
+
}
|
|
179
|
+
if (snapshot === undefined || snapshot === null) {
|
|
180
|
+
return refuse('SESSION_NOT_FOUND', 'This session is not in session storage.')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const existing = store.findEntry(sessionId)
|
|
184
|
+
if (
|
|
185
|
+
existing !== undefined &&
|
|
186
|
+
(existing.state === 'trashed' ||
|
|
187
|
+
existing.state === 'trashing' ||
|
|
188
|
+
existing.state === 'restoring' ||
|
|
189
|
+
existing.state === 'purging')
|
|
190
|
+
) {
|
|
191
|
+
return refuse('ALREADY_TRASHED', 'This session is already recorded in the trash.')
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const location = snapshot.location
|
|
195
|
+
if (location === undefined || location === null || typeof location.path !== 'string') {
|
|
196
|
+
return refuse('SESSION_NOT_MATERIALIZED', 'This session has no stored log yet, so it has nothing to remove.')
|
|
197
|
+
}
|
|
198
|
+
if (location.kind !== 'jsonl') {
|
|
199
|
+
return refuse('STORAGE_UNSUPPORTED', `The "${String(location.kind)}" session storage backend is not supported.`)
|
|
200
|
+
}
|
|
201
|
+
if (!path.isAbsolute(location.path)) {
|
|
202
|
+
return refuse('STORAGE_UNSUPPORTED', 'The session log path is not absolute.')
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (snapshot.header?.origin === 'subagent') {
|
|
206
|
+
return refuse('SESSION_SUBAGENT', 'Subagent sessions cannot be moved to the trash on their own.')
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const descendants = await collectDescendants(sessionId)
|
|
210
|
+
if (!Array.isArray(descendants)) {
|
|
211
|
+
return refuse('SESSION_LINEAGE_UNRESOLVED', 'The session lineage could not be resolved, so the session is left untouched.')
|
|
212
|
+
}
|
|
213
|
+
const visible = descendants.filter((child) => child.header?.origin !== 'subagent')
|
|
214
|
+
if (visible.length > 0) {
|
|
215
|
+
return refuse('SESSION_HAS_DESCENDANTS', 'This session still has child sessions; archive the tree instead.', {
|
|
216
|
+
descendants: visible.length,
|
|
217
|
+
sessions: visible.map((child) => child.sessionId),
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const planned = []
|
|
222
|
+
for (const child of descendants) {
|
|
223
|
+
const plannedChild = await planDescendant(child.sessionId)
|
|
224
|
+
if (plannedChild.refusal !== undefined) return plannedChild.refusal
|
|
225
|
+
planned.push(plannedChild.member)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const members = [
|
|
229
|
+
{
|
|
230
|
+
sessionId,
|
|
231
|
+
header: snapshot.header,
|
|
232
|
+
location: { path: location.path },
|
|
233
|
+
formatStatus: snapshot.formatStatus,
|
|
234
|
+
},
|
|
235
|
+
...planned,
|
|
236
|
+
]
|
|
237
|
+
|
|
238
|
+
if (isLive(sessionId)) {
|
|
239
|
+
return refuse('SESSION_LIVE', 'This session is open right now. Close it before moving it to the trash.')
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let activity
|
|
243
|
+
try {
|
|
244
|
+
activity = await ctx.waterfall('workspace/session-activity', { sessionId }, () => Promise.resolve([]))
|
|
245
|
+
} catch (error) {
|
|
246
|
+
logger.warn(`preflight activity failed for ${sessionId}: ${error instanceof Error ? error.message : String(error)}`)
|
|
247
|
+
return refuse('SESSION_ACTIVE', 'Session activity could not be checked, so the session is left untouched.')
|
|
248
|
+
}
|
|
249
|
+
if (Array.isArray(activity) && activity.length > 0) {
|
|
250
|
+
return refuse('SESSION_ACTIVE', 'This session still has running work.', {
|
|
251
|
+
activity: activity.map((item) => item?.kind ?? 'unknown'),
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Running descendants are already reported by the subagent activity
|
|
256
|
+
// provider; a live-but-idle child is the remaining unsafe case.
|
|
257
|
+
const liveChild = members.slice(1).find((member) => isLive(member.sessionId))
|
|
258
|
+
if (liveChild !== undefined) {
|
|
259
|
+
return refuse('SESSION_LIVE', 'A child session is still live in this host.', {
|
|
260
|
+
sessionId: liveChild.sessionId,
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
for (const member of members) {
|
|
265
|
+
const sessionDir = path.dirname(member.location.path)
|
|
266
|
+
let stats
|
|
267
|
+
try {
|
|
268
|
+
stats = await fs.lstat(sessionDir)
|
|
269
|
+
} catch (error) {
|
|
270
|
+
logger.warn(`preflight cannot stat ${sessionDir}: ${error instanceof Error ? error.message : String(error)}`)
|
|
271
|
+
return refuse('IO_ERROR', 'A session directory could not be read.', {
|
|
272
|
+
...(member.sessionId === sessionId ? {} : { sessionId: member.sessionId }),
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
if (!stats.isDirectory() || !isSafeDirectoryPath(sessionDir)) {
|
|
276
|
+
return refuse('UNSAFE_SESSION_DIR', 'A session directory is not a plain directory this plugin can move.', {
|
|
277
|
+
...(member.sessionId === sessionId ? {} : { sessionId: member.sessionId }),
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return { eligible: true, members }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
/**
|
|
287
|
+
* @param {string} sessionId session to inspect.
|
|
288
|
+
* @returns {Promise<PreflightResult>} the cached verdict.
|
|
289
|
+
*/
|
|
290
|
+
async evaluate(sessionId) {
|
|
291
|
+
if (store.isProtected()) {
|
|
292
|
+
return refuse('INDEX_CORRUPT', 'The trash index is unreadable, so protection mode refuses new entries.')
|
|
293
|
+
}
|
|
294
|
+
const now = Date.now()
|
|
295
|
+
const hit = cache.get(sessionId)
|
|
296
|
+
if (hit !== undefined && hit.expires > now) return hit.value
|
|
297
|
+
const value = await evaluateUncached(sessionId)
|
|
298
|
+
cache.set(sessionId, { expires: now + PREFLIGHT_TTL_MS, value })
|
|
299
|
+
return value
|
|
300
|
+
},
|
|
301
|
+
/**
|
|
302
|
+
* @param {string} [sessionId] session whose cached verdict is stale, or every session.
|
|
303
|
+
* @returns {void}
|
|
304
|
+
*/
|
|
305
|
+
invalidate(sessionId) {
|
|
306
|
+
if (sessionId === undefined) cache.clear()
|
|
307
|
+
else cache.delete(sessionId)
|
|
308
|
+
},
|
|
309
|
+
}
|
|
310
|
+
}
|
package/lib/errors.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Error codes shared by the Host API and the Client UI, with their HTTP status.
|
|
5
|
+
* `null` marks a startup-only failure that never reaches HTTP.
|
|
6
|
+
*/
|
|
7
|
+
export const ERROR_STATUS = {
|
|
8
|
+
INVALID_REQUEST: 400,
|
|
9
|
+
FORBIDDEN: 403,
|
|
10
|
+
METHOD_NOT_ALLOWED: 405,
|
|
11
|
+
SESSION_NOT_FOUND: 404,
|
|
12
|
+
SESSION_LIVE: 409,
|
|
13
|
+
SESSION_ACTIVE: 409,
|
|
14
|
+
SESSION_SUBAGENT: 409,
|
|
15
|
+
SESSION_HAS_DESCENDANTS: 409,
|
|
16
|
+
SESSION_LINEAGE_UNRESOLVED: 409,
|
|
17
|
+
SESSION_NOT_MATERIALIZED: 409,
|
|
18
|
+
STORAGE_UNSUPPORTED: 409,
|
|
19
|
+
ALREADY_TRASHED: 409,
|
|
20
|
+
TRASH_ENTRY_NOT_FOUND: 404,
|
|
21
|
+
TRASH_STATE_INVALID: 409,
|
|
22
|
+
FORGET_NOT_ALLOWED: 409,
|
|
23
|
+
TRASH_DIR_MISSING: 409,
|
|
24
|
+
RESTORE_TARGET_EXISTS: 409,
|
|
25
|
+
WORKSPACE_MISSING: 409,
|
|
26
|
+
RESTORE_VERIFY_FAILED: 500,
|
|
27
|
+
MOVE_VERIFY_FAILED: 500,
|
|
28
|
+
MOVE_CONFLICT: 409,
|
|
29
|
+
INDEX_WRITE_FAILED: 500,
|
|
30
|
+
INDEX_NOT_CORRUPT: 409,
|
|
31
|
+
UNSAFE_SESSION_DIR: 409,
|
|
32
|
+
UNSAFE_RESTORE_PATH: 409,
|
|
33
|
+
REVEAL_UNAVAILABLE: 409,
|
|
34
|
+
REVEAL_FAILED: 500,
|
|
35
|
+
LOCKED: 409,
|
|
36
|
+
INDEX_CORRUPT: 500,
|
|
37
|
+
IO_ERROR: 500,
|
|
38
|
+
HOME_UNRESOLVED: null,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A failure with a stable machine code and an owner-facing message. */
|
|
42
|
+
export class TrashError extends Error {
|
|
43
|
+
/**
|
|
44
|
+
* @param {string} code stable error code.
|
|
45
|
+
* @param {string} message owner-facing explanation.
|
|
46
|
+
* @param {object} [details] optional structured context.
|
|
47
|
+
*/
|
|
48
|
+
constructor(code, message, details) {
|
|
49
|
+
super(message)
|
|
50
|
+
this.name = 'TrashError'
|
|
51
|
+
this.code = code
|
|
52
|
+
this.details = details
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {unknown} error arbitrary thrown value.
|
|
58
|
+
* @returns {number} HTTP status for this error.
|
|
59
|
+
*/
|
|
60
|
+
export function statusOf(error) {
|
|
61
|
+
const code = error instanceof TrashError ? error.code : 'IO_ERROR'
|
|
62
|
+
return ERROR_STATUS[code] ?? 500
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {unknown} error arbitrary thrown value.
|
|
67
|
+
* @returns {{ code: string, message: string, details?: object }} the wire error shape.
|
|
68
|
+
*/
|
|
69
|
+
export function toWireError(error) {
|
|
70
|
+
if (error instanceof TrashError) {
|
|
71
|
+
return {
|
|
72
|
+
code: error.code,
|
|
73
|
+
message: error.message,
|
|
74
|
+
...(error.details === undefined ? {} : { details: error.details }),
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
78
|
+
return { code: 'IO_ERROR', message }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {string} sessionDir a session-owned directory.
|
|
83
|
+
* @returns {boolean} whether the directory name is a single safe path segment.
|
|
84
|
+
*/
|
|
85
|
+
export function isSafeSegment(sessionDir) {
|
|
86
|
+
const base = path.basename(sessionDir)
|
|
87
|
+
if (base === '' || base === '.' || base === '..') return false
|
|
88
|
+
if (base !== sessionDir.split(path.sep).pop()) return false
|
|
89
|
+
if (base.includes('/') || base.includes('\\') || base.includes('\0')) return false
|
|
90
|
+
return path.dirname(sessionDir) !== sessionDir
|
|
91
|
+
}
|