dsh-plugin-session-cleaner 0.0.0-stage → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +209 -2
- package/cordis.patch.yml +4 -0
- package/lib/client.js +1845 -0
- package/lib/index.js +2 -0
- package/lib/remote.js +3 -0
- package/lib/typert.host.js +3 -0
- package/locale/en.json +13 -0
- package/locale/zh.json +13 -0
- package/package.json +44 -4
- package/src/cleaner.js +712 -0
- package/src/contract.cjs +96 -0
- package/src/index.js +437 -0
- package/src/remote.js +12 -0
- package/src/typert.host.js +38 -0
package/src/cleaner.js
ADDED
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-cleaner core: pure, testable logic for completely removing DSH sessions.
|
|
3
|
+
*
|
|
4
|
+
* Design commitments:
|
|
5
|
+
* - Never guess a path. Every deletion target is validated THREE times:
|
|
6
|
+
* 1. the session id matches the shipped id grammar,
|
|
7
|
+
* 2. the on-disk directory/file name equals that id exactly,
|
|
8
|
+
* 3. the normalized absolute path is contained in an allowed root.
|
|
9
|
+
* A target failing any check is skipped and reported, never deleted.
|
|
10
|
+
* - Removal order matters: stop the session BEFORE touching its files, and
|
|
11
|
+
* detach registry references BEFORE deleting files, so the UI never shows a
|
|
12
|
+
* row whose log has already vanished.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync } from 'node:fs'
|
|
16
|
+
import { open, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
17
|
+
import { homedir } from 'node:os'
|
|
18
|
+
import path from 'node:path'
|
|
19
|
+
import { zstdDecompressSync } from 'node:zlib'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Shipped session directory grammar. Three forms coexist on disk:
|
|
23
|
+
* - `session-<uuid>` — user sessions writing `session.v4.jsonl.zstd`,
|
|
24
|
+
* - `sess_<uuid>` — legacy user sessions writing `session.v3.jsonl.zstd`,
|
|
25
|
+
* - `<uuid>` — subagent / delegated child sessions.
|
|
26
|
+
* All three are valid deletion targets; anything else is not a session.
|
|
27
|
+
*/
|
|
28
|
+
const SESSION_ID = /^(?:(?:session-|sess_))?[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/
|
|
29
|
+
|
|
30
|
+
/** A session log directly inside a session directory. */
|
|
31
|
+
const LOG_NAME = /^session\.v\d+\.jsonl(?:\.zstd)?$/
|
|
32
|
+
|
|
33
|
+
export function resolveDshHome(options = {}) {
|
|
34
|
+
const candidates = [
|
|
35
|
+
options.dshHome,
|
|
36
|
+
process.env.DSH_HOME,
|
|
37
|
+
path.join(homedir(), '.dsh'),
|
|
38
|
+
]
|
|
39
|
+
for (const candidate of candidates) {
|
|
40
|
+
if (typeof candidate === 'string' && candidate.length > 0) return path.resolve(candidate)
|
|
41
|
+
}
|
|
42
|
+
throw new Error('session-cleaner: cannot resolve the DSH home directory')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isValidSessionId(id) {
|
|
46
|
+
return typeof id === 'string' && SESSION_ID.test(id)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function normalizeForCompare(target) {
|
|
50
|
+
const resolved = path.resolve(target)
|
|
51
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** True when `target` is `root` itself or lives underneath it. */
|
|
55
|
+
export function isContained(root, target) {
|
|
56
|
+
const base = normalizeForCompare(root)
|
|
57
|
+
const candidate = normalizeForCompare(target)
|
|
58
|
+
if (candidate === base) return true
|
|
59
|
+
const withSep = base.endsWith(path.sep) ? base : base + path.sep
|
|
60
|
+
return candidate.startsWith(withSep)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A capture with a bounded argument vector — never a shell string. */
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Read the first line of a session log, transparently handling zstd compression.
|
|
67
|
+
*
|
|
68
|
+
* Decompression uses Node's built-in zstd support (Node >= 23.8) rather than a
|
|
69
|
+
* `zstd` executable: header linkage backs descendant discovery, and a missing
|
|
70
|
+
* external tool would silently degrade deletion into a no-op cascade.
|
|
71
|
+
*
|
|
72
|
+
* Returns `undefined` when the header cannot be read; linkage is an
|
|
73
|
+
* optimization, so an unreadable header must never fail a scan.
|
|
74
|
+
*/
|
|
75
|
+
export async function readSessionHeader(logFile) {
|
|
76
|
+
try {
|
|
77
|
+
let text
|
|
78
|
+
if (logFile.endsWith('.zstd')) {
|
|
79
|
+
const handle = await open(logFile, 'r')
|
|
80
|
+
let compressed
|
|
81
|
+
try {
|
|
82
|
+
const info = await handle.stat()
|
|
83
|
+
compressed = Buffer.alloc(Number(info.size))
|
|
84
|
+
let offset = 0
|
|
85
|
+
while (offset < compressed.length) {
|
|
86
|
+
const { bytesRead } = await handle.read(compressed, offset, compressed.length - offset, offset)
|
|
87
|
+
if (bytesRead <= 0) break
|
|
88
|
+
offset += bytesRead
|
|
89
|
+
}
|
|
90
|
+
compressed = compressed.subarray(0, offset)
|
|
91
|
+
} finally {
|
|
92
|
+
await handle.close()
|
|
93
|
+
}
|
|
94
|
+
text = zstdDecompressSync(compressed).toString('utf8')
|
|
95
|
+
} else {
|
|
96
|
+
const handle = await open(logFile, 'r')
|
|
97
|
+
try {
|
|
98
|
+
const buffer = Buffer.alloc(64 * 1024)
|
|
99
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0)
|
|
100
|
+
text = buffer.subarray(0, bytesRead).toString('utf8')
|
|
101
|
+
} finally {
|
|
102
|
+
await handle.close()
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const firstLine = text.split('\n', 1)[0]?.trim()
|
|
106
|
+
if (!firstLine) return undefined
|
|
107
|
+
const parsed = JSON.parse(firstLine)
|
|
108
|
+
const meta = parsed?.meta ?? parsed?.header ?? parsed
|
|
109
|
+
if (!meta || typeof meta !== 'object') return undefined
|
|
110
|
+
return {
|
|
111
|
+
id: typeof meta.id === 'string' ? meta.id : undefined,
|
|
112
|
+
parent: typeof meta.parentSession === 'string' ? meta.parentSession : undefined,
|
|
113
|
+
cwd: typeof meta.cwd === 'string' ? meta.cwd : undefined,
|
|
114
|
+
title: typeof meta.title === 'string' ? meta.title : undefined,
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
return undefined
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Read the projection cache that rides beside a session log.
|
|
123
|
+
*
|
|
124
|
+
* The cache is the reliable TITLE source — the log header's title is usually
|
|
125
|
+
* empty, and the real one only ever arrives as a later `session/title` event —
|
|
126
|
+
* and it covers subagent sessions too. It also carries `identity.cwd` and
|
|
127
|
+
* `identity.createdAt`, which the header may lack. Returns `undefined` when the
|
|
128
|
+
* file is missing or malformed: like the header, this is display metadata and
|
|
129
|
+
* must never fail a scan.
|
|
130
|
+
*/
|
|
131
|
+
export async function readProjectionCache(cacheFile) {
|
|
132
|
+
try {
|
|
133
|
+
const parsed = JSON.parse(await readFile(cacheFile, 'utf8'))
|
|
134
|
+
const record = parsed?.record
|
|
135
|
+
if (record === null || typeof record !== 'object') return undefined
|
|
136
|
+
const title = record.rows?.title?.val
|
|
137
|
+
const createdAt = record.identity?.createdAt
|
|
138
|
+
const cwd = record.identity?.cwd
|
|
139
|
+
return {
|
|
140
|
+
title: typeof title === 'string' && title.length > 0 ? title : undefined,
|
|
141
|
+
createdAt: typeof createdAt === 'number' ? createdAt : undefined,
|
|
142
|
+
cwd: typeof cwd === 'string' && cwd.length > 0 ? cwd : undefined,
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
return undefined
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A line longer than this is a streaming carrier, not preview material. */
|
|
150
|
+
const MAX_TRANSCRIPT_LINE_CHARS = 1024 * 1024
|
|
151
|
+
|
|
152
|
+
/** Cheap pre-filter: only lines naming an event worth keeping get parsed. */
|
|
153
|
+
const TRANSCRIPT_INTEREST = /user\/message|assistant\/message|session\/title|"session"/
|
|
154
|
+
|
|
155
|
+
/** The zstd frame magic every frame starts with. */
|
|
156
|
+
const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Decode a multi-frame zstd log.
|
|
160
|
+
*
|
|
161
|
+
* DSH appends one zstd frame per writing episode, and BOTH of Node's zstd
|
|
162
|
+
* APIs decode only the first frame of a file (the streaming decompressor
|
|
163
|
+
* ends at the first frame boundary too), so the file is split at the frame
|
|
164
|
+
* magic and every slice decompressed on its own. A magic sequence occurring
|
|
165
|
+
* inside compressed data — astronomically rare — would slice one real frame
|
|
166
|
+
* in half; both halves then fail to decode and only that one frame is lost,
|
|
167
|
+
* which a read-only preview can tolerate (and report).
|
|
168
|
+
*/
|
|
169
|
+
function decodeZstdFrames(buffer) {
|
|
170
|
+
const offsets = []
|
|
171
|
+
for (let pos = 0; (pos = buffer.indexOf(ZSTD_MAGIC, pos)) !== -1; pos += 1) offsets.push(pos)
|
|
172
|
+
|
|
173
|
+
const chunks = []
|
|
174
|
+
let failedFrames = 0
|
|
175
|
+
for (let index = 0; index < offsets.length; index += 1) {
|
|
176
|
+
const start = offsets[index]
|
|
177
|
+
const end = index + 1 < offsets.length ? offsets[index + 1] : buffer.length
|
|
178
|
+
try {
|
|
179
|
+
chunks.push(zstdDecompressSync(buffer.subarray(start, end)))
|
|
180
|
+
} catch {
|
|
181
|
+
failedFrames += 1
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
text: chunks.length === 0 ? '' : Buffer.concat(chunks).toString('utf8'),
|
|
186
|
+
decodedFrames: chunks.length,
|
|
187
|
+
failedFrames,
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Join the text blocks of a message content array; reasoning/tool blocks drop. */
|
|
192
|
+
function joinTextBlocks(content) {
|
|
193
|
+
if (typeof content === 'string') return content
|
|
194
|
+
if (!Array.isArray(content)) return ''
|
|
195
|
+
const parts = []
|
|
196
|
+
for (const block of content) {
|
|
197
|
+
if (block?.type === 'text' && typeof block.text === 'string') parts.push(block.text)
|
|
198
|
+
}
|
|
199
|
+
return parts.join('\n\n')
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function clampCount(value, fallback) {
|
|
203
|
+
const count = Math.trunc(Number(value))
|
|
204
|
+
return Number.isFinite(count) && count > 0 ? count : fallback
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Read a session log as a display-oriented conversation transcript.
|
|
209
|
+
*
|
|
210
|
+
* Only the events a preview needs are kept: the conversation messages and the
|
|
211
|
+
* title event (the header's title is usually empty; the real one arrives as
|
|
212
|
+
* `session/title`). Messages are projected to `{ role, text, time, model? }`:
|
|
213
|
+
* text blocks are joined, turns without text (pure reasoning, tool calls) and
|
|
214
|
+
* DSH-injected context events are dropped, and both the message count and the
|
|
215
|
+
* per-message length are capped so a huge log cannot flood the panel. A
|
|
216
|
+
* missing log rejects and undecodable frames are reported as `failedFrames`
|
|
217
|
+
* instead of failing the read.
|
|
218
|
+
*/
|
|
219
|
+
export async function readSessionTranscript(logFile, options = {}) {
|
|
220
|
+
const maxMessages = clampCount(options.maxMessages, 500)
|
|
221
|
+
const maxChars = clampCount(options.maxCharsPerMessage, 8000)
|
|
222
|
+
|
|
223
|
+
const raw = await readFile(logFile)
|
|
224
|
+
let text
|
|
225
|
+
let failedFrames = 0
|
|
226
|
+
if (logFile.endsWith('.zstd')) {
|
|
227
|
+
const decoded = decodeZstdFrames(raw)
|
|
228
|
+
text = decoded.text
|
|
229
|
+
failedFrames = decoded.failedFrames
|
|
230
|
+
} else {
|
|
231
|
+
text = raw.toString('utf8')
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const collected = []
|
|
235
|
+
let title
|
|
236
|
+
let createdAt
|
|
237
|
+
|
|
238
|
+
for (const line of text.split('\n')) {
|
|
239
|
+
if (line.length > MAX_TRANSCRIPT_LINE_CHARS) continue
|
|
240
|
+
if (!TRANSCRIPT_INTEREST.test(line)) continue
|
|
241
|
+
let event
|
|
242
|
+
try { event = JSON.parse(line) } catch { continue }
|
|
243
|
+
|
|
244
|
+
if (event?.type === 'session/title') {
|
|
245
|
+
if (typeof event.data?.title === 'string' && event.data.title.length > 0) title = event.data.title
|
|
246
|
+
} else if (event?.type === 'session') {
|
|
247
|
+
const meta = event.meta ?? event.header ?? event
|
|
248
|
+
if (typeof meta?.createdAt === 'number') createdAt = meta.createdAt
|
|
249
|
+
} else if (event?.type === 'user/message' || event?.type === 'assistant/message') {
|
|
250
|
+
const fromUser = event.type === 'user/message'
|
|
251
|
+
const holder = fromUser ? event.data : (event.data?.message ?? event.data)
|
|
252
|
+
const textOfMessage = joinTextBlocks(holder?.content)
|
|
253
|
+
if (textOfMessage.trim() === '') continue
|
|
254
|
+
const entry = { role: fromUser ? 'user' : 'assistant', text: textOfMessage }
|
|
255
|
+
if (typeof event.time === 'number') entry.time = event.time
|
|
256
|
+
if (!fromUser && typeof holder?.source?.model === 'string') entry.model = holder.source.model
|
|
257
|
+
if (fromUser && holder?.source?.kind !== 'user') entry.injected = true
|
|
258
|
+
collected.push(entry)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// DSH's runtime-context snapshots and skill catalogs ride user/message
|
|
263
|
+
// events too, under a source.kind other than 'user'. When a real human
|
|
264
|
+
// turn exists they are noise; when none does, keep everything — a preview
|
|
265
|
+
// must never come back empty over a kind-value difference.
|
|
266
|
+
const hasHumanInput = collected.some((entry) => entry.injected !== true)
|
|
267
|
+
const kept = hasHumanInput ? collected.filter((entry) => entry.injected !== true) : collected
|
|
268
|
+
|
|
269
|
+
const messages = []
|
|
270
|
+
let truncated = false
|
|
271
|
+
for (const entry of kept) {
|
|
272
|
+
if (messages.length >= maxMessages) {
|
|
273
|
+
truncated = true
|
|
274
|
+
break
|
|
275
|
+
}
|
|
276
|
+
const message = {
|
|
277
|
+
role: entry.role,
|
|
278
|
+
text: entry.text.length > maxChars ? `${entry.text.slice(0, maxChars)}…` : entry.text,
|
|
279
|
+
}
|
|
280
|
+
if (entry.time !== undefined) message.time = entry.time
|
|
281
|
+
if (entry.model !== undefined) message.model = entry.model
|
|
282
|
+
messages.push(message)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return { title, createdAt, messages, totalMessages: kept.length, truncated, failedFrames }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Index every session directory under `<dshHome>/sessions/<workspaceKey>/<sessionId>/`.
|
|
290
|
+
* The directory listing is the authority: a session unknown to the listing is
|
|
291
|
+
* not on disk, and a directory whose name is not a valid session id is ignored.
|
|
292
|
+
*/
|
|
293
|
+
export async function scanSessions(roots) {
|
|
294
|
+
const records = new Map()
|
|
295
|
+
let workspaceKeys = []
|
|
296
|
+
try {
|
|
297
|
+
const entries = await readdir(roots.sessions, { withFileTypes: true })
|
|
298
|
+
workspaceKeys = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name)
|
|
299
|
+
} catch {
|
|
300
|
+
return { records, workspaceKeys, skipped: [] }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const skipped = []
|
|
304
|
+
for (const workspaceKey of workspaceKeys) {
|
|
305
|
+
const workspaceDir = path.join(roots.sessions, workspaceKey)
|
|
306
|
+
let sessionDirs
|
|
307
|
+
try {
|
|
308
|
+
sessionDirs = await readdir(workspaceDir, { withFileTypes: true })
|
|
309
|
+
} catch {
|
|
310
|
+
continue
|
|
311
|
+
}
|
|
312
|
+
for (const dir of sessionDirs) {
|
|
313
|
+
if (!dir.isDirectory()) continue
|
|
314
|
+
const sessionDir = path.join(workspaceDir, dir.name)
|
|
315
|
+
if (!isValidSessionId(dir.name)) {
|
|
316
|
+
skipped.push({ path: sessionDir, reason: 'directory name is not a valid session id' })
|
|
317
|
+
continue
|
|
318
|
+
}
|
|
319
|
+
if (!isContained(roots.sessions, sessionDir)) {
|
|
320
|
+
skipped.push({ path: sessionDir, reason: 'path escapes the sessions root' })
|
|
321
|
+
continue
|
|
322
|
+
}
|
|
323
|
+
let files = []
|
|
324
|
+
try {
|
|
325
|
+
files = (await readdir(sessionDir, { withFileTypes: true }))
|
|
326
|
+
.filter((entry) => entry.isFile())
|
|
327
|
+
.map((entry) => entry.name)
|
|
328
|
+
} catch {
|
|
329
|
+
continue
|
|
330
|
+
}
|
|
331
|
+
const logName = files.find((name) => LOG_NAME.test(name))
|
|
332
|
+
if (logName === undefined) {
|
|
333
|
+
skipped.push({ path: sessionDir, reason: 'no session log found in the session directory' })
|
|
334
|
+
continue
|
|
335
|
+
}
|
|
336
|
+
const logFile = path.join(sessionDir, logName)
|
|
337
|
+
let bytes = 0
|
|
338
|
+
try {
|
|
339
|
+
bytes = (await stat(logFile)).size
|
|
340
|
+
} catch {
|
|
341
|
+
bytes = 0
|
|
342
|
+
}
|
|
343
|
+
const header = await readSessionHeader(logFile)
|
|
344
|
+
const cache = await readProjectionCache(path.join(roots.projectionCache, `${dir.name}.json`))
|
|
345
|
+
records.set(dir.name, {
|
|
346
|
+
id: dir.name,
|
|
347
|
+
workspaceKey,
|
|
348
|
+
dir: sessionDir,
|
|
349
|
+
logFile,
|
|
350
|
+
logName,
|
|
351
|
+
bytes,
|
|
352
|
+
parent: header?.parent,
|
|
353
|
+
cwd: header?.cwd ?? cache?.cwd,
|
|
354
|
+
// The cache title beats the header's (the header's is usually empty);
|
|
355
|
+
// the header cwd is authoritative, the cache's only fills a gap.
|
|
356
|
+
title: cache?.title ?? header?.title,
|
|
357
|
+
createdAt: cache?.createdAt,
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return { records, workspaceKeys, skipped }
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Expand a set of session ids into `{ id, depth }` pairs including every
|
|
366
|
+
* descendant, following the durable `parentSession` link.
|
|
367
|
+
*/
|
|
368
|
+
export function expandWithDescendants(records, rootIds) {
|
|
369
|
+
const children = new Map()
|
|
370
|
+
for (const record of records.values()) {
|
|
371
|
+
if (typeof record.parent !== 'string' || record.parent.length === 0) continue
|
|
372
|
+
const list = children.get(record.parent)
|
|
373
|
+
if (list === undefined) children.set(record.parent, [record.id])
|
|
374
|
+
else list.push(record.id)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const seen = new Set()
|
|
378
|
+
const out = []
|
|
379
|
+
const queue = []
|
|
380
|
+
for (const id of rootIds) {
|
|
381
|
+
if (typeof id !== 'string' || seen.has(id)) continue
|
|
382
|
+
seen.add(id)
|
|
383
|
+
queue.push({ id, depth: 0 })
|
|
384
|
+
}
|
|
385
|
+
while (queue.length > 0) {
|
|
386
|
+
const current = queue.shift()
|
|
387
|
+
out.push(current)
|
|
388
|
+
for (const child of children.get(current.id) ?? []) {
|
|
389
|
+
if (seen.has(child)) continue
|
|
390
|
+
seen.add(child)
|
|
391
|
+
queue.push({ id: child, depth: current.depth + 1 })
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return out
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Read the durable workspace registry; a missing or malformed file yields an empty shape. */
|
|
398
|
+
export async function readWorkspaceRegistry(roots) {
|
|
399
|
+
try {
|
|
400
|
+
const raw = await readFile(roots.workspaceRegistry, 'utf8')
|
|
401
|
+
const parsed = JSON.parse(raw)
|
|
402
|
+
if (parsed === null || typeof parsed !== 'object') return undefined
|
|
403
|
+
return parsed
|
|
404
|
+
} catch {
|
|
405
|
+
return undefined
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Remove session ids from every place the workspace registry can hold one:
|
|
411
|
+
* each workspace's `sessionIds`, plus the global `archivedSessionIds` and
|
|
412
|
+
* `pinnedSessionIds` lists.
|
|
413
|
+
*
|
|
414
|
+
* Returns the SAME object when nothing matched, so callers can cheaply detect
|
|
415
|
+
* a no-op without a deep clone.
|
|
416
|
+
*/
|
|
417
|
+
export function detachFromRegistry(registry, ids) {
|
|
418
|
+
const remove = new Set(ids)
|
|
419
|
+
if (registry === undefined || registry === null || typeof registry !== 'object') {
|
|
420
|
+
return { registry, changes: [] }
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Collect first so an unchanged registry is never cloned.
|
|
424
|
+
const changes = []
|
|
425
|
+
const global = registry.global
|
|
426
|
+
if (global !== null && typeof global === 'object') {
|
|
427
|
+
for (const key of ['archivedSessionIds', 'pinnedSessionIds', 'sessionIds']) {
|
|
428
|
+
const list = global[key]
|
|
429
|
+
if (!Array.isArray(list)) continue
|
|
430
|
+
const removed = list.filter((id) => remove.has(id)).length
|
|
431
|
+
if (removed > 0) changes.push({ where: `global.${key}`, removed })
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
const workspaces = registry.tables?.workspaces
|
|
435
|
+
if (workspaces !== null && typeof workspaces === 'object') {
|
|
436
|
+
for (const [workspaceId, workspace] of Object.entries(workspaces)) {
|
|
437
|
+
const list = workspace?.sessionIds
|
|
438
|
+
if (!Array.isArray(list)) continue
|
|
439
|
+
const removed = list.filter((id) => remove.has(id)).length
|
|
440
|
+
if (removed > 0) changes.push({ where: `workspaces[${workspaceId}].sessionIds`, removed })
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (changes.length === 0) return { registry, changes }
|
|
444
|
+
|
|
445
|
+
const next = structuredClone(registry)
|
|
446
|
+
const nextGlobal = next.global
|
|
447
|
+
if (nextGlobal !== null && typeof nextGlobal === 'object') {
|
|
448
|
+
for (const key of ['archivedSessionIds', 'pinnedSessionIds', 'sessionIds']) {
|
|
449
|
+
const list = nextGlobal[key]
|
|
450
|
+
if (Array.isArray(list)) nextGlobal[key] = list.filter((id) => !remove.has(id))
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
const nextWorkspaces = next.tables?.workspaces
|
|
454
|
+
if (nextWorkspaces !== null && typeof nextWorkspaces === 'object') {
|
|
455
|
+
for (const workspace of Object.values(nextWorkspaces)) {
|
|
456
|
+
const list = workspace?.sessionIds
|
|
457
|
+
if (Array.isArray(list)) workspace.sessionIds = list.filter((id) => !remove.has(id))
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return { registry: next, changes }
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Write JSON atomically: a sibling temp file, then a rename over the target. */
|
|
464
|
+
export async function writeJsonAtomic(file, value) {
|
|
465
|
+
const temp = `${file}.session-cleaner.${process.pid}.tmp`
|
|
466
|
+
await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
|
467
|
+
await rename(temp, file)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Candidate paths for the optional SQLite full-text search index. */
|
|
471
|
+
export async function findSearchIndexes(roots) {
|
|
472
|
+
const found = []
|
|
473
|
+
const storages = roots.storages
|
|
474
|
+
let entries = []
|
|
475
|
+
try {
|
|
476
|
+
entries = await readdir(storages, { withFileTypes: true })
|
|
477
|
+
} catch {
|
|
478
|
+
return found
|
|
479
|
+
}
|
|
480
|
+
for (const entry of entries) {
|
|
481
|
+
if (!entry.isFile()) continue
|
|
482
|
+
if (!/\.(?:db|sqlite|sqlite3)$/i.test(entry.name)) continue
|
|
483
|
+
found.push(path.join(storages, entry.name))
|
|
484
|
+
}
|
|
485
|
+
return found
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Build a deletion plan. Pure with respect to the filesystem apart from reads.
|
|
490
|
+
*
|
|
491
|
+
* Returns `{ root, targets, descendants, registry, registryChanges, searchIndexes, skipped, totals }`.
|
|
492
|
+
*/
|
|
493
|
+
export async function planDeletion(roots, sessionIds, options = {}) {
|
|
494
|
+
const requested = [...new Set(sessionIds.filter(isValidSessionId))]
|
|
495
|
+
const rejectedIds = sessionIds.filter((id) => !isValidSessionId(id))
|
|
496
|
+
|
|
497
|
+
const { records, skipped } = await scanSessions(roots)
|
|
498
|
+
const expanded = expandWithDescendants(records, requested)
|
|
499
|
+
|
|
500
|
+
const targets = []
|
|
501
|
+
const missing = []
|
|
502
|
+
for (const entry of expanded) {
|
|
503
|
+
const record = records.get(entry.id)
|
|
504
|
+
if (record === undefined) {
|
|
505
|
+
// A requested root that is absent from the listing cannot be deleted; a
|
|
506
|
+
// descendant that is absent simply has no files.
|
|
507
|
+
if (entry.depth === 0) missing.push(entry.id)
|
|
508
|
+
continue
|
|
509
|
+
}
|
|
510
|
+
targets.push({
|
|
511
|
+
id: record.id,
|
|
512
|
+
depth: entry.depth,
|
|
513
|
+
isRoot: entry.depth === 0,
|
|
514
|
+
workspaceKey: record.workspaceKey,
|
|
515
|
+
dir: record.dir,
|
|
516
|
+
logFile: record.logFile,
|
|
517
|
+
bytes: record.bytes,
|
|
518
|
+
cwd: record.cwd,
|
|
519
|
+
title: record.title,
|
|
520
|
+
})
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const registry = await readWorkspaceRegistry(roots)
|
|
524
|
+
const projected = detachFromRegistry(registry, targets.map((target) => target.id))
|
|
525
|
+
const searchIndexes = await findSearchIndexes(roots)
|
|
526
|
+
|
|
527
|
+
const totals = targets.reduce((sum, target) => sum + target.bytes, 0)
|
|
528
|
+
return {
|
|
529
|
+
root: options.rootSessionId,
|
|
530
|
+
dshHome: roots.dshHome,
|
|
531
|
+
targets,
|
|
532
|
+
requested,
|
|
533
|
+
rejectedIds,
|
|
534
|
+
missing,
|
|
535
|
+
registryChanges: projected.changes,
|
|
536
|
+
searchIndexes,
|
|
537
|
+
skipped,
|
|
538
|
+
totals: { sessions: targets.length, bytes: totals },
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Validate one concrete path immediately before deleting it. */
|
|
543
|
+
export function assertDeletable(target, allowedRoots) {
|
|
544
|
+
const normalized = path.resolve(target)
|
|
545
|
+
if (!allowedRoots.some((root) => isContained(root, normalized))) {
|
|
546
|
+
return { ok: false, reason: `outside the allowed roots: ${normalized}` }
|
|
547
|
+
}
|
|
548
|
+
return { ok: true }
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Execute a plan. Order is deliberate and must not be reordered:
|
|
553
|
+
* 1. stop every running session,
|
|
554
|
+
* 2. delete session directories and projection caches,
|
|
555
|
+
* 3. detach registry references,
|
|
556
|
+
* 4. purge derived search indexes that can no longer be reconciled.
|
|
557
|
+
*
|
|
558
|
+
* Step 3 has two authorities. `options.detachRegistry(ids)` — injected when a
|
|
559
|
+
* live DSH registry service is reachable — detaches through the in-memory
|
|
560
|
+
* registry, which also updates the running app's workspace projection, and it
|
|
561
|
+
* runs AFTER the file deletions: the sidebar renders rows from the client's
|
|
562
|
+
* session-list snapshot, so detaching first would flash a still-listed row
|
|
563
|
+
* into Ungrouped before the client re-pulls the list. Without the service
|
|
564
|
+
* (offline runs, tests) the registry FILE is rewritten in place BEFORE the
|
|
565
|
+
* deletions, so no row can outlive its log across a restart.
|
|
566
|
+
*
|
|
567
|
+
* `stop` is injected the same way, so this module stays testable and free of
|
|
568
|
+
* runtime coupling.
|
|
569
|
+
*/
|
|
570
|
+
export async function executeDeletion(roots, plan, options = {}) {
|
|
571
|
+
const { stop, detachRegistry, now = () => Date.now() } = options
|
|
572
|
+
const allowedRoots = [roots.sessions, roots.storages]
|
|
573
|
+
const result = {
|
|
574
|
+
startedAt: now(),
|
|
575
|
+
stopped: [],
|
|
576
|
+
registryChanges: [],
|
|
577
|
+
deleted: [],
|
|
578
|
+
failed: [],
|
|
579
|
+
warnings: [],
|
|
580
|
+
searchIndexesRemoved: [],
|
|
581
|
+
searchIndexesKept: [],
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// 1. Stop each session before any file is touched. A still-running agent
|
|
585
|
+
// would keep appending to a log we just deleted, resurrecting a half
|
|
586
|
+
// session. A target that refuses to stop aborts the whole run before
|
|
587
|
+
// anything is deleted, so a partial outcome is never reported as success.
|
|
588
|
+
for (const target of plan.targets) {
|
|
589
|
+
const outcome = await stop(target.id)
|
|
590
|
+
result.stopped.push({ id: target.id, ...outcome })
|
|
591
|
+
if (outcome?.stillRunning === true && options.requireStopped !== false) {
|
|
592
|
+
result.failed.push({ id: target.id, stage: 'stop', reason: 'session is still running' })
|
|
593
|
+
return { ...result, aborted: true }
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const blocked = new Set(result.failed.map((entry) => entry.id))
|
|
598
|
+
|
|
599
|
+
// 2. Offline registry detach: rewrite the file before deleting anything, so
|
|
600
|
+
// no row survives its own log across a restart. The live-service path
|
|
601
|
+
// performs this step after the deletions instead (see the docblock).
|
|
602
|
+
if (detachRegistry === undefined) {
|
|
603
|
+
let registry = await readWorkspaceRegistry(roots)
|
|
604
|
+
if (registry !== undefined) {
|
|
605
|
+
const removable = plan.targets.filter((target) => !blocked.has(target.id)).map((target) => target.id)
|
|
606
|
+
const projected = detachFromRegistry(registry, removable)
|
|
607
|
+
if (projected.changes.length > 0) {
|
|
608
|
+
try {
|
|
609
|
+
await writeJsonAtomic(roots.workspaceRegistry, projected.registry)
|
|
610
|
+
result.registryChanges = projected.changes
|
|
611
|
+
} catch (error) {
|
|
612
|
+
result.failed.push({ id: '*', stage: 'registry', reason: String(error?.message ?? error) })
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// 3. Delete each session directory and its projection cache.
|
|
619
|
+
for (const target of plan.targets) {
|
|
620
|
+
if (blocked.has(target.id)) continue
|
|
621
|
+
const check = assertDeletable(target.dir, allowedRoots)
|
|
622
|
+
if (!check.ok) {
|
|
623
|
+
result.failed.push({ id: target.id, stage: 'validate', reason: check.reason })
|
|
624
|
+
continue
|
|
625
|
+
}
|
|
626
|
+
try {
|
|
627
|
+
await rm(target.dir, { recursive: true, force: true })
|
|
628
|
+
result.deleted.push({ id: target.id, kind: 'session-dir', path: target.dir, bytes: target.bytes })
|
|
629
|
+
} catch (error) {
|
|
630
|
+
result.failed.push({ id: target.id, stage: 'session-dir', reason: String(error?.message ?? error) })
|
|
631
|
+
continue
|
|
632
|
+
}
|
|
633
|
+
const cacheFile = path.join(roots.projectionCache, `${target.id}.json`)
|
|
634
|
+
const cacheCheck = assertDeletable(cacheFile, allowedRoots)
|
|
635
|
+
if (!cacheCheck.ok) {
|
|
636
|
+
result.failed.push({ id: target.id, stage: 'cache-validate', reason: cacheCheck.reason })
|
|
637
|
+
continue
|
|
638
|
+
}
|
|
639
|
+
if (existsSync(cacheFile)) {
|
|
640
|
+
try {
|
|
641
|
+
await rm(cacheFile, { force: true })
|
|
642
|
+
result.deleted.push({ id: target.id, kind: 'projection-cache', path: cacheFile })
|
|
643
|
+
} catch (error) {
|
|
644
|
+
result.failed.push({ id: target.id, stage: 'projection-cache', reason: String(error?.message ?? error) })
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// 3b. Live registry detach, after the deletions. Failures are REPORTED, not
|
|
650
|
+
// fatal: the files are already gone, a stale accounting entry heals on
|
|
651
|
+
// the next start (the registry prunes ids with no session header), and
|
|
652
|
+
// declaring the whole deletion failed after the rows vanished would
|
|
653
|
+
// mislead more than a warning does.
|
|
654
|
+
if (detachRegistry !== undefined) {
|
|
655
|
+
const removable = result.deleted
|
|
656
|
+
.filter((entry) => entry.kind === 'session-dir')
|
|
657
|
+
.map((entry) => entry.id)
|
|
658
|
+
if (removable.length > 0) {
|
|
659
|
+
try {
|
|
660
|
+
const detached = await detachRegistry(removable)
|
|
661
|
+
if (Array.isArray(detached?.changes)) result.registryChanges.push(...detached.changes)
|
|
662
|
+
for (const failure of Array.isArray(detached?.failures) ? detached.failures : []) {
|
|
663
|
+
result.warnings.push(`registry detach for ${failure?.id ?? '*'}: ${failure?.reason ?? 'unknown failure'}`)
|
|
664
|
+
}
|
|
665
|
+
} catch (error) {
|
|
666
|
+
result.warnings.push(`registry detach: ${String(error?.message ?? error)}`)
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// 4. Derived search indexes are disposable: removing unreconcilable ones is
|
|
672
|
+
// safe because they are rebuilt from the surviving logs on next search.
|
|
673
|
+
for (const indexFile of plan.searchIndexes) {
|
|
674
|
+
const check = assertDeletable(indexFile, allowedRoots)
|
|
675
|
+
if (!check.ok) {
|
|
676
|
+
result.searchIndexesKept.push({ path: indexFile, reason: check.reason })
|
|
677
|
+
continue
|
|
678
|
+
}
|
|
679
|
+
try {
|
|
680
|
+
const info = await stat(indexFile)
|
|
681
|
+
await rm(indexFile, { force: true })
|
|
682
|
+
result.searchIndexesRemoved.push({ path: indexFile, bytes: info.size })
|
|
683
|
+
for (const suffix of ['-wal', '-shm']) {
|
|
684
|
+
const sidecar = `${indexFile}${suffix}`
|
|
685
|
+
if (existsSync(sidecar)) await rm(sidecar, { force: true })
|
|
686
|
+
}
|
|
687
|
+
} catch (error) {
|
|
688
|
+
result.searchIndexesKept.push({ path: indexFile, reason: String(error?.message ?? error) })
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
result.finishedAt = now()
|
|
693
|
+
result.summary = {
|
|
694
|
+
sessionsDeleted: result.deleted.filter((entry) => entry.kind === 'session-dir').length,
|
|
695
|
+
cachesDeleted: result.deleted.filter((entry) => entry.kind === 'projection-cache').length,
|
|
696
|
+
bytesDeleted: result.deleted.reduce((sum, entry) => sum + (entry.bytes ?? 0), 0),
|
|
697
|
+
failures: result.failed.length,
|
|
698
|
+
}
|
|
699
|
+
return result
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** Default roots derived from a DSH home directory. */
|
|
703
|
+
export function rootsFor(dshHome) {
|
|
704
|
+
const home = path.resolve(dshHome)
|
|
705
|
+
return {
|
|
706
|
+
dshHome: home,
|
|
707
|
+
sessions: path.join(home, 'sessions'),
|
|
708
|
+
storages: path.join(home, 'storages'),
|
|
709
|
+
projectionCache: path.join(home, 'storages', 'session_projcache', 'sessions'),
|
|
710
|
+
workspaceRegistry: path.join(home, 'storages', 'workspace.json'),
|
|
711
|
+
}
|
|
712
|
+
}
|