dsh-plugin-session-cleaner 0.0.0-stage → 0.2.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/src/cleaner.js ADDED
@@ -0,0 +1,676 @@
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. detach registry references,
555
+ * 3. delete session directories and projection caches,
556
+ * 4. purge derived search indexes that can no longer be reconciled.
557
+ *
558
+ * `stop` is injected so this module stays testable and free of runtime coupling.
559
+ */
560
+ export async function executeDeletion(roots, plan, options = {}) {
561
+ const { stop, now = () => Date.now() } = options
562
+ const allowedRoots = [roots.sessions, roots.storages]
563
+ const result = {
564
+ startedAt: now(),
565
+ stopped: [],
566
+ registryChanges: [],
567
+ deleted: [],
568
+ failed: [],
569
+ searchIndexesRemoved: [],
570
+ searchIndexesKept: [],
571
+ }
572
+
573
+ // 1. Stop each session before any file is touched. A still-running agent
574
+ // would keep appending to a log we just deleted, resurrecting a half
575
+ // session. A target that refuses to stop aborts the whole run before
576
+ // anything is deleted, so a partial outcome is never reported as success.
577
+ for (const target of plan.targets) {
578
+ const outcome = await stop(target.id)
579
+ result.stopped.push({ id: target.id, ...outcome })
580
+ if (outcome?.stillRunning === true && options.requireStopped !== false) {
581
+ result.failed.push({ id: target.id, stage: 'stop', reason: 'session is still running' })
582
+ return { ...result, aborted: true }
583
+ }
584
+ }
585
+
586
+ const blocked = new Set(result.failed.map((entry) => entry.id))
587
+
588
+ // 2. Detach registry references first, so no row survives its own log.
589
+ let registry = await readWorkspaceRegistry(roots)
590
+ if (registry !== undefined) {
591
+ const removable = plan.targets.filter((target) => !blocked.has(target.id)).map((target) => target.id)
592
+ const projected = detachFromRegistry(registry, removable)
593
+ if (projected.changes.length > 0) {
594
+ try {
595
+ await writeJsonAtomic(roots.workspaceRegistry, projected.registry)
596
+ registry = projected.registry
597
+ result.registryChanges = projected.changes
598
+ } catch (error) {
599
+ result.failed.push({ id: '*', stage: 'registry', reason: String(error?.message ?? error) })
600
+ }
601
+ }
602
+ }
603
+
604
+ // 3. Delete each session directory and its projection cache.
605
+ for (const target of plan.targets) {
606
+ if (blocked.has(target.id)) continue
607
+ const check = assertDeletable(target.dir, allowedRoots)
608
+ if (!check.ok) {
609
+ result.failed.push({ id: target.id, stage: 'validate', reason: check.reason })
610
+ continue
611
+ }
612
+ try {
613
+ await rm(target.dir, { recursive: true, force: true })
614
+ result.deleted.push({ id: target.id, kind: 'session-dir', path: target.dir, bytes: target.bytes })
615
+ } catch (error) {
616
+ result.failed.push({ id: target.id, stage: 'session-dir', reason: String(error?.message ?? error) })
617
+ continue
618
+ }
619
+ const cacheFile = path.join(roots.projectionCache, `${target.id}.json`)
620
+ const cacheCheck = assertDeletable(cacheFile, allowedRoots)
621
+ if (!cacheCheck.ok) {
622
+ result.failed.push({ id: target.id, stage: 'cache-validate', reason: cacheCheck.reason })
623
+ continue
624
+ }
625
+ if (existsSync(cacheFile)) {
626
+ try {
627
+ await rm(cacheFile, { force: true })
628
+ result.deleted.push({ id: target.id, kind: 'projection-cache', path: cacheFile })
629
+ } catch (error) {
630
+ result.failed.push({ id: target.id, stage: 'projection-cache', reason: String(error?.message ?? error) })
631
+ }
632
+ }
633
+ }
634
+
635
+ // 4. Derived search indexes are disposable: removing unreconcilable ones is
636
+ // safe because they are rebuilt from the surviving logs on next search.
637
+ for (const indexFile of plan.searchIndexes) {
638
+ const check = assertDeletable(indexFile, allowedRoots)
639
+ if (!check.ok) {
640
+ result.searchIndexesKept.push({ path: indexFile, reason: check.reason })
641
+ continue
642
+ }
643
+ try {
644
+ const info = await stat(indexFile)
645
+ await rm(indexFile, { force: true })
646
+ result.searchIndexesRemoved.push({ path: indexFile, bytes: info.size })
647
+ for (const suffix of ['-wal', '-shm']) {
648
+ const sidecar = `${indexFile}${suffix}`
649
+ if (existsSync(sidecar)) await rm(sidecar, { force: true })
650
+ }
651
+ } catch (error) {
652
+ result.searchIndexesKept.push({ path: indexFile, reason: String(error?.message ?? error) })
653
+ }
654
+ }
655
+
656
+ result.finishedAt = now()
657
+ result.summary = {
658
+ sessionsDeleted: result.deleted.filter((entry) => entry.kind === 'session-dir').length,
659
+ cachesDeleted: result.deleted.filter((entry) => entry.kind === 'projection-cache').length,
660
+ bytesDeleted: result.deleted.reduce((sum, entry) => sum + (entry.bytes ?? 0), 0),
661
+ failures: result.failed.length,
662
+ }
663
+ return result
664
+ }
665
+
666
+ /** Default roots derived from a DSH home directory. */
667
+ export function rootsFor(dshHome) {
668
+ const home = path.resolve(dshHome)
669
+ return {
670
+ dshHome: home,
671
+ sessions: path.join(home, 'sessions'),
672
+ storages: path.join(home, 'storages'),
673
+ projectionCache: path.join(home, 'storages', 'session_projcache', 'sessions'),
674
+ workspaceRegistry: path.join(home, 'storages', 'workspace.json'),
675
+ }
676
+ }