@murchey/dsh-session-manager 0.0.0-stage → 0.1.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 +674 -0
- package/README.md +190 -3
- package/client.js +857 -0
- package/cordis.patch.yml +11 -0
- package/icon.svg +16 -0
- package/index.js +407 -0
- package/lib/core.js +1074 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +64 -3
package/lib/core.js
ADDED
|
@@ -0,0 +1,1074 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-shredder core: discover and permanently remove the on-disk footprint
|
|
3
|
+
* of DSH sessions.
|
|
4
|
+
*
|
|
5
|
+
* The DSH session-persistence seam deliberately has no delete API, so removal
|
|
6
|
+
* is filesystem work. The rules encoded here:
|
|
7
|
+
*
|
|
8
|
+
* - only ever touch paths below the configured data directory;
|
|
9
|
+
* - refuse the current session and any session live in this process;
|
|
10
|
+
* - delete the session's whole project/session directory (every historical
|
|
11
|
+
* format generation), its projection-cache record, and its record in the
|
|
12
|
+
* legacy cache seed document;
|
|
13
|
+
* - delete an attachment object only when a complete reference scan of every
|
|
14
|
+
* surviving session proves no survivor uses it.
|
|
15
|
+
*
|
|
16
|
+
* Nothing here asks for approval; the caller owns that gate.
|
|
17
|
+
*/
|
|
18
|
+
import { promises as fs } from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
|
|
22
|
+
/** Session ids: harness chats use `session-<token>`; subagent chats use a bare UUID. */
|
|
23
|
+
const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
24
|
+
/** Content-addressed attachment ids. */
|
|
25
|
+
const ATTACHMENT_ID_PATTERN = /^sha256:([a-f0-9]{64})$/;
|
|
26
|
+
/** One path-safe character in the persistence backend's encoding. */
|
|
27
|
+
const SAFE_CODE_UNIT = /^[A-Za-z0-9._-]$/;
|
|
28
|
+
|
|
29
|
+
/** A user-visible, attributable purge failure. */
|
|
30
|
+
export class PurgeError extends Error {
|
|
31
|
+
constructor(message, code = 'SESSION_PURGE_FAILED') {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = 'PurgeError';
|
|
34
|
+
this.code = code;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Encode one raw string as a single safe path segment, matching the JSONL
|
|
40
|
+
* persistence backend so a computed directory always names that backend's
|
|
41
|
+
* directory. Injectable over all JS strings (including lone surrogates).
|
|
42
|
+
*
|
|
43
|
+
* @param {string} raw
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
export function encodeSegment(raw) {
|
|
47
|
+
if (typeof raw !== 'string' || raw.length === 0) throw new PurgeError('cannot encode an empty path segment', 'INVALID_SESSION_ID');
|
|
48
|
+
if (raw === '.') return '~002E';
|
|
49
|
+
if (raw === '..') return '~002E~002E';
|
|
50
|
+
let out = '';
|
|
51
|
+
for (let i = 0; i < raw.length; i++) {
|
|
52
|
+
const code = raw.charCodeAt(i);
|
|
53
|
+
const ch = String.fromCharCode(code);
|
|
54
|
+
if (ch !== '~' && SAFE_CODE_UNIT.test(ch)) out += ch;
|
|
55
|
+
else out += `~${code.toString(16).toUpperCase().padStart(4, '0')}`;
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Inverse of {@link encodeSegment} for reading project directory names.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} segment
|
|
64
|
+
* @returns {string}
|
|
65
|
+
*/
|
|
66
|
+
export function decodeSegment(segment) {
|
|
67
|
+
let out = '';
|
|
68
|
+
for (let i = 0; i < segment.length; i++) {
|
|
69
|
+
const ch = segment[i];
|
|
70
|
+
if (ch === '~' && /^[0-9A-Fa-f]{4}$/.test(segment.slice(i + 1, i + 5))) {
|
|
71
|
+
out += String.fromCharCode(parseInt(segment.slice(i + 1, i + 5), 16));
|
|
72
|
+
i += 4;
|
|
73
|
+
} else {
|
|
74
|
+
out += ch;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Resolve and validate plugin configuration into absolute defaults.
|
|
82
|
+
*
|
|
83
|
+
* @param {Record<string, unknown>} [config]
|
|
84
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
85
|
+
* @param {() => string} [homedir]
|
|
86
|
+
* @returns {{
|
|
87
|
+
* dataDir: string,
|
|
88
|
+
* sessionsRoot: string,
|
|
89
|
+
* storagesRoot: string,
|
|
90
|
+
* projectionCacheRoot: string,
|
|
91
|
+
* projectionCacheSeed: string,
|
|
92
|
+
* attachmentsRoot: string,
|
|
93
|
+
* requestImageCacheRoot: string,
|
|
94
|
+
* requireApproval: boolean,
|
|
95
|
+
* secureOverwrite: boolean,
|
|
96
|
+
* purgeAttachments: boolean,
|
|
97
|
+
* clearRequestImageCache: boolean,
|
|
98
|
+
* attachmentMinAgeMs: number,
|
|
99
|
+
* attachmentScanMaxEvents: number,
|
|
100
|
+
* listLimit: number,
|
|
101
|
+
* maxTargets: number
|
|
102
|
+
* }}
|
|
103
|
+
*/
|
|
104
|
+
export function resolveOptions(config = {}, env = process.env, homedir = os.homedir) {
|
|
105
|
+
if (config === null || typeof config !== 'object' || Array.isArray(config)) {
|
|
106
|
+
throw new PurgeError('plugin config must be an object', 'INVALID_CONFIG');
|
|
107
|
+
}
|
|
108
|
+
const home = typeof config.dataDir === 'string' && config.dataDir.trim() !== ''
|
|
109
|
+
? path.resolve(config.dataDir)
|
|
110
|
+
: path.resolve(env.DSH_HOME || path.join(homedir(), '.dsh'));
|
|
111
|
+
const sessionsRoot = absolute(config.sessionsRoot, path.join(home, 'sessions'), 'sessionsRoot');
|
|
112
|
+
const storagesRoot = absolute(config.storagesRoot, path.join(home, 'storages'), 'storagesRoot');
|
|
113
|
+
const projectionCacheRoot = absolute(
|
|
114
|
+
config.projectionCacheRoot,
|
|
115
|
+
path.join(storagesRoot, 'session_projcache', 'sessions'),
|
|
116
|
+
'projectionCacheRoot',
|
|
117
|
+
);
|
|
118
|
+
const attachmentsRoot = absolute(
|
|
119
|
+
config.attachmentsRoot,
|
|
120
|
+
path.join(home, 'attachments', 'v1'),
|
|
121
|
+
'attachmentsRoot',
|
|
122
|
+
);
|
|
123
|
+
const requestImageCacheRoot = absolute(
|
|
124
|
+
config.requestImageCacheRoot,
|
|
125
|
+
path.join(home, 'cache', 'attachments', 'request-images'),
|
|
126
|
+
'requestImageCacheRoot',
|
|
127
|
+
);
|
|
128
|
+
for (const [label, root] of [['dataDir', home], ['sessionsRoot', sessionsRoot], ['storagesRoot', storagesRoot]]) {
|
|
129
|
+
if (path.parse(root).root === root) throw new PurgeError(`${label} must not be a filesystem root`, 'INVALID_CONFIG');
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
dataDir: home,
|
|
133
|
+
sessionsRoot,
|
|
134
|
+
storagesRoot,
|
|
135
|
+
projectionCacheRoot,
|
|
136
|
+
projectionCacheSeed: path.join(storagesRoot, 'session_projcache.json'),
|
|
137
|
+
attachmentsRoot,
|
|
138
|
+
requestImageCacheRoot,
|
|
139
|
+
requireApproval: config.requireApproval !== false,
|
|
140
|
+
secureOverwrite: config.secureOverwrite === true,
|
|
141
|
+
purgeAttachments: config.purgeAttachments !== false,
|
|
142
|
+
clearRequestImageCache: config.clearRequestImageCache !== false,
|
|
143
|
+
attachmentMinAgeMs: integerAtLeast(config.attachmentMinAgeMs, 10 * 60_000, 0),
|
|
144
|
+
attachmentScanMaxEvents: integerAtLeast(config.attachmentScanMaxEvents, 50_000, 100),
|
|
145
|
+
listLimit: integerAtLeast(config.listLimit, 50, 1),
|
|
146
|
+
maxTargets: integerAtLeast(config.maxTargets, 50, 1),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function absolute(value, fallback, label) {
|
|
151
|
+
if (value === undefined) return fallback;
|
|
152
|
+
if (typeof value !== 'string' || value.trim() === '') throw new PurgeError(`${label} must be a non-empty path string`, 'INVALID_CONFIG');
|
|
153
|
+
return path.resolve(value);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function integerAtLeast(value, fallback, min) {
|
|
157
|
+
if (value === undefined) return fallback;
|
|
158
|
+
if (!Number.isInteger(value) || value < min) throw new PurgeError(`expected an integer >= ${min}, got ${String(value)}`, 'INVALID_CONFIG');
|
|
159
|
+
return value;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Parse and validate one session id before any filesystem work.
|
|
164
|
+
*
|
|
165
|
+
* @param {unknown} id
|
|
166
|
+
* @returns {string}
|
|
167
|
+
*/
|
|
168
|
+
export function assertSessionId(id) {
|
|
169
|
+
if (typeof id !== 'string' || !SESSION_ID_PATTERN.test(id)) {
|
|
170
|
+
throw new PurgeError(`invalid session id: ${JSON.stringify(id)} (expected a safe session id such as session-<token> or a subagent UUID)`, 'INVALID_SESSION_ID');
|
|
171
|
+
}
|
|
172
|
+
return id;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Human byte count, e.g. `1.4 MiB`. */
|
|
176
|
+
export function formatBytes(bytes) {
|
|
177
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
|
178
|
+
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
|
|
179
|
+
let value = bytes;
|
|
180
|
+
let unit = 0;
|
|
181
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
182
|
+
value /= 1024;
|
|
183
|
+
unit++;
|
|
184
|
+
}
|
|
185
|
+
return `${value >= 10 || unit === 0 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Whether `target` is strictly below `root` after resolution. */
|
|
189
|
+
export function isInside(root, target) {
|
|
190
|
+
const relative = path.relative(path.resolve(root), path.resolve(target));
|
|
191
|
+
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function lstatOrUndefined(target) {
|
|
195
|
+
try {
|
|
196
|
+
return await fs.lstat(target);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (error && error.code === 'ENOENT') return undefined;
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function statOrUndefined(target) {
|
|
204
|
+
try {
|
|
205
|
+
return await fs.stat(target);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (error && error.code === 'ENOENT') return undefined;
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Walk one directory without following symlinks.
|
|
214
|
+
*
|
|
215
|
+
* @param {string} root
|
|
216
|
+
* @returns {Promise<{ files: Array<{path: string, size: number, symlink: boolean, mtimeMs: number}>, directories: string[], bytes: number }>}
|
|
217
|
+
*/
|
|
218
|
+
export async function walkTree(root) {
|
|
219
|
+
const files = [];
|
|
220
|
+
const directories = [root];
|
|
221
|
+
let bytes = 0;
|
|
222
|
+
|
|
223
|
+
async function visit(directory) {
|
|
224
|
+
let entries;
|
|
225
|
+
try {
|
|
226
|
+
entries = await fs.readdir(directory, { withFileTypes: true });
|
|
227
|
+
} catch (error) {
|
|
228
|
+
if (error && error.code === 'ENOENT') return;
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
for (const entry of entries) {
|
|
232
|
+
const target = path.join(directory, entry.name);
|
|
233
|
+
const stat = await lstatOrUndefined(target);
|
|
234
|
+
if (stat === undefined) continue;
|
|
235
|
+
if (stat.isSymbolicLink()) {
|
|
236
|
+
files.push({ path: target, size: stat.size, symlink: true, mtimeMs: stat.mtimeMs });
|
|
237
|
+
} else if (stat.isDirectory()) {
|
|
238
|
+
directories.push(target);
|
|
239
|
+
await visit(target);
|
|
240
|
+
} else if (stat.isFile()) {
|
|
241
|
+
files.push({ path: target, size: stat.size, symlink: false, mtimeMs: stat.mtimeMs });
|
|
242
|
+
bytes += stat.size;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (await statOrUndefined(root)) {
|
|
248
|
+
const rootStat = await lstatOrUndefined(root);
|
|
249
|
+
if (rootStat === undefined || !rootStat.isDirectory()) return { files, directories, bytes };
|
|
250
|
+
await visit(root);
|
|
251
|
+
}
|
|
252
|
+
return { files, directories, bytes };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Index every session directory the persistence backend owns below `root`.
|
|
257
|
+
* The backend's on-disk shape is `<root>/<project>/<encoded-session-id>/`.
|
|
258
|
+
*
|
|
259
|
+
* @param {string} root
|
|
260
|
+
* @returns {Promise<Map<string, string[]>>} session id -> session directories
|
|
261
|
+
*/
|
|
262
|
+
export async function indexSessionDirs(root) {
|
|
263
|
+
const index = new Map();
|
|
264
|
+
let projects;
|
|
265
|
+
try {
|
|
266
|
+
projects = await fs.readdir(root, { withFileTypes: true });
|
|
267
|
+
} catch (error) {
|
|
268
|
+
if (error && error.code === 'ENOENT') return index;
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
for (const project of projects) {
|
|
272
|
+
if (!project.isDirectory()) continue;
|
|
273
|
+
const projectDir = path.join(root, project.name);
|
|
274
|
+
const projectStat = await lstatOrUndefined(projectDir);
|
|
275
|
+
if (projectStat === undefined || !projectStat.isDirectory()) continue;
|
|
276
|
+
let entries;
|
|
277
|
+
try {
|
|
278
|
+
entries = await fs.readdir(projectDir, { withFileTypes: true });
|
|
279
|
+
} catch (error) {
|
|
280
|
+
if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) continue;
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
283
|
+
for (const entry of entries) {
|
|
284
|
+
if (!entry.isDirectory()) continue;
|
|
285
|
+
const directory = path.join(projectDir, entry.name);
|
|
286
|
+
const directoryStat = await lstatOrUndefined(directory);
|
|
287
|
+
if (directoryStat === undefined || !directoryStat.isDirectory()) continue;
|
|
288
|
+
const id = decodeSegment(entry.name);
|
|
289
|
+
const list = index.get(id);
|
|
290
|
+
if (list === undefined) index.set(id, [directory]);
|
|
291
|
+
else list.push(directory);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return index;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Project the identity fields needed for cache-record matching.
|
|
299
|
+
*
|
|
300
|
+
* @param {{ id: string, header?: any } | undefined} snapshot
|
|
301
|
+
*/
|
|
302
|
+
function headerFacts(snapshot) {
|
|
303
|
+
return snapshot?.header === undefined
|
|
304
|
+
? undefined
|
|
305
|
+
: {
|
|
306
|
+
createdAt: snapshot.header.createdAt,
|
|
307
|
+
cwd: snapshot.header.cwd,
|
|
308
|
+
parentSession: snapshot.header.parentSession,
|
|
309
|
+
delegationDepth: snapshot.header.delegationDepth,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function safeTitle(ctx, id) {
|
|
314
|
+
const query = ctx.get?.('sessionQuery');
|
|
315
|
+
if (query === undefined || typeof query.readTitle !== 'function') return undefined;
|
|
316
|
+
try {
|
|
317
|
+
const snapshot = await query.readTitle(id);
|
|
318
|
+
return typeof snapshot?.title === 'string' ? snapshot.title : undefined;
|
|
319
|
+
} catch {
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function liveAgentIds(ctx) {
|
|
325
|
+
const agents = ctx.get?.('agents');
|
|
326
|
+
if (agents === undefined || typeof agents.list !== 'function') return new Set();
|
|
327
|
+
try {
|
|
328
|
+
return new Set(agents.list().map((agent) => agent.id));
|
|
329
|
+
} catch {
|
|
330
|
+
return new Set();
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Read the workspace registry's global archive set without requiring it.
|
|
336
|
+
* The set hides sessions from the default sidebar view; the storage page
|
|
337
|
+
* still lists them so they remain discoverable and deletable.
|
|
338
|
+
*/
|
|
339
|
+
function archivedSessionIds(ctx) {
|
|
340
|
+
try {
|
|
341
|
+
const registry = ctx.get?.('workspaceRegistry');
|
|
342
|
+
const ids = registry?.archivedSessionIds;
|
|
343
|
+
return new Set(Array.isArray(ids) ? ids : []);
|
|
344
|
+
} catch {
|
|
345
|
+
return new Set();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function listSnapshots(persistence, signal) {
|
|
350
|
+
try {
|
|
351
|
+
return await persistence.list(signal === undefined ? undefined : { signal });
|
|
352
|
+
} catch (error) {
|
|
353
|
+
throw new PurgeError(`cannot list stored sessions: ${error?.message ?? String(error)}`, 'SESSION_LIST_FAILED');
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Enumerate every stored and on-disk session for the list/plan surfaces.
|
|
359
|
+
*
|
|
360
|
+
* @param {any} ctx
|
|
361
|
+
* @param {ReturnType<typeof resolveOptions>} options
|
|
362
|
+
* @param {{ limit?: number, signal?: AbortSignal }} [request]
|
|
363
|
+
*/
|
|
364
|
+
export async function listSessions(ctx, options, request = {}) {
|
|
365
|
+
const persistence = ctx.sessionPersistence;
|
|
366
|
+
const snapshots = await listSnapshots(persistence, request.signal);
|
|
367
|
+
const byId = new Map(snapshots.map((snapshot) => [snapshot.header.id, snapshot]));
|
|
368
|
+
const dirIndex = await indexSessionDirs(options.sessionsRoot);
|
|
369
|
+
const live = liveAgentIds(ctx);
|
|
370
|
+
const archived = archivedSessionIds(ctx);
|
|
371
|
+
const currentSessionId = request.currentSessionId;
|
|
372
|
+
const limit = Number.isInteger(request.limit)
|
|
373
|
+
? Math.min(Math.max(request.limit, 1), 500)
|
|
374
|
+
: options.listLimit;
|
|
375
|
+
|
|
376
|
+
const ids = new Set([...byId.keys(), ...dirIndex.keys()]);
|
|
377
|
+
const rows = [];
|
|
378
|
+
for (const id of ids) {
|
|
379
|
+
const snapshot = byId.get(id);
|
|
380
|
+
const dirs = dirIndex.get(id) ?? [];
|
|
381
|
+
let bytes = 0;
|
|
382
|
+
let latestMtime = 0;
|
|
383
|
+
for (const dir of dirs) {
|
|
384
|
+
let tree;
|
|
385
|
+
try {
|
|
386
|
+
tree = await walkTree(dir);
|
|
387
|
+
} catch {
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
bytes += tree.bytes;
|
|
391
|
+
for (const file of tree.files) latestMtime = Math.max(latestMtime, file.mtimeMs);
|
|
392
|
+
}
|
|
393
|
+
rows.push({
|
|
394
|
+
id,
|
|
395
|
+
title: await safeTitle(ctx, id),
|
|
396
|
+
cwd: snapshot?.header?.cwd,
|
|
397
|
+
createdAt: snapshot?.header?.createdAt,
|
|
398
|
+
parentSession: snapshot?.header?.parentSession,
|
|
399
|
+
eventCount: snapshot?.eventCount,
|
|
400
|
+
bytes,
|
|
401
|
+
logDirs: dirs,
|
|
402
|
+
stored: snapshot !== undefined,
|
|
403
|
+
orphan: snapshot === undefined,
|
|
404
|
+
live: live.has(id),
|
|
405
|
+
archived: archived.has(id),
|
|
406
|
+
current: id === currentSessionId,
|
|
407
|
+
latestMtime: latestMtime || undefined,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
rows.sort((left, right) => {
|
|
411
|
+
if (left.current !== right.current) return left.current ? -1 : 1;
|
|
412
|
+
return (right.createdAt ?? 0) - (left.createdAt ?? 0) || right.bytes - left.bytes;
|
|
413
|
+
});
|
|
414
|
+
const totalBytes = rows.reduce((sum, row) => sum + row.bytes, 0);
|
|
415
|
+
return {
|
|
416
|
+
total: rows.length,
|
|
417
|
+
totalBytes,
|
|
418
|
+
shown: rows.slice(0, limit),
|
|
419
|
+
hidden: Math.max(0, rows.length - limit),
|
|
420
|
+
sessionsRoot: options.sessionsRoot,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Build the deletion plan for a set of ids: exact directories, cache records,
|
|
426
|
+
* sizes, and every reason the purge must refuse.
|
|
427
|
+
*
|
|
428
|
+
* @param {any} ctx
|
|
429
|
+
* @param {ReturnType<typeof resolveOptions>} options
|
|
430
|
+
* @param {string[]} ids
|
|
431
|
+
* @param {{ currentSessionId?: string, signal?: AbortSignal }} [request]
|
|
432
|
+
*/
|
|
433
|
+
export async function planPurge(ctx, options, ids, request = {}) {
|
|
434
|
+
const unique = [...new Set(ids.map(assertSessionId))];
|
|
435
|
+
if (unique.length === 0) throw new PurgeError('at least one session id is required', 'INVALID_SESSION_ID');
|
|
436
|
+
if (unique.length > options.maxTargets) {
|
|
437
|
+
throw new PurgeError(`at most ${options.maxTargets} sessions may be purged in one call`, 'TOO_MANY_TARGETS');
|
|
438
|
+
}
|
|
439
|
+
const persistence = ctx.sessionPersistence;
|
|
440
|
+
const snapshots = await listSnapshots(persistence, request.signal);
|
|
441
|
+
const byId = new Map(snapshots.map((snapshot) => [snapshot.header.id, snapshot]));
|
|
442
|
+
const dirIndex = await indexSessionDirs(options.sessionsRoot);
|
|
443
|
+
const live = liveAgentIds(ctx);
|
|
444
|
+
const now = Date.now();
|
|
445
|
+
const targets = [];
|
|
446
|
+
const blockers = [];
|
|
447
|
+
const warnings = [];
|
|
448
|
+
let totalBytes = 0;
|
|
449
|
+
|
|
450
|
+
for (const id of unique) {
|
|
451
|
+
if (request.signal?.aborted) throw new PurgeError('purge cancelled', 'ABORTED');
|
|
452
|
+
const snapshot = byId.get(id);
|
|
453
|
+
const dirs = dirIndex.get(id) ?? [];
|
|
454
|
+
const cacheFiles = [];
|
|
455
|
+
const cachePath = path.join(options.projectionCacheRoot, `${id}.json`);
|
|
456
|
+
if (await statOrUndefined(cachePath)) cacheFiles.push(cachePath);
|
|
457
|
+
if (snapshot !== undefined && id.startsWith('session-')) {
|
|
458
|
+
const barePath = path.join(options.projectionCacheRoot, `${id.slice('session-'.length)}.json`);
|
|
459
|
+
if (await recordMatches(barePath, snapshot.header)) cacheFiles.push(barePath);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
let bytes = 0;
|
|
463
|
+
let latestMtime = 0;
|
|
464
|
+
const directories = [];
|
|
465
|
+
for (const dir of dirs) {
|
|
466
|
+
let tree;
|
|
467
|
+
try {
|
|
468
|
+
tree = await walkTree(dir);
|
|
469
|
+
} catch (error) {
|
|
470
|
+
warnings.push(`${id}: cannot read ${dir}: ${error?.message ?? String(error)}`);
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
bytes += tree.bytes;
|
|
474
|
+
directories.push(...tree.directories);
|
|
475
|
+
for (const file of tree.files) latestMtime = Math.max(latestMtime, file.mtimeMs);
|
|
476
|
+
}
|
|
477
|
+
for (const file of cacheFiles) {
|
|
478
|
+
const stat = await lstatOrUndefined(file);
|
|
479
|
+
if (stat !== undefined) bytes += stat.size;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const reasons = [];
|
|
483
|
+
if (dirs.some((dir) => !isInside(options.sessionsRoot, dir))) {
|
|
484
|
+
reasons.push('a session directory escapes the configured sessions root');
|
|
485
|
+
}
|
|
486
|
+
if (cacheFiles.some((file) => !isInside(options.projectionCacheRoot, file))) {
|
|
487
|
+
reasons.push('a cache record escapes the configured projection-cache root');
|
|
488
|
+
}
|
|
489
|
+
if (request.currentSessionId !== undefined && id === request.currentSessionId) {
|
|
490
|
+
reasons.push('this is the current session; switch to another session to delete it');
|
|
491
|
+
}
|
|
492
|
+
if (live.has(id)) reasons.push('a live agent still owns this session in this process');
|
|
493
|
+
if (dirs.length === 0 && cacheFiles.length === 0 && snapshot === undefined) {
|
|
494
|
+
reasons.push('no session data found on disk or in persistence for this id');
|
|
495
|
+
}
|
|
496
|
+
if (latestMtime !== 0 && now - latestMtime < 60_000) {
|
|
497
|
+
warnings.push(`${id}: the log was written ${Math.round((now - latestMtime) / 1000)}s ago; another DSH process may still have it open`);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const target = {
|
|
501
|
+
id,
|
|
502
|
+
title: await safeTitle(ctx, id),
|
|
503
|
+
header: headerFacts(snapshot),
|
|
504
|
+
dirs,
|
|
505
|
+
directories,
|
|
506
|
+
cacheFiles,
|
|
507
|
+
bytes,
|
|
508
|
+
createdAt: snapshot?.header?.createdAt,
|
|
509
|
+
cwd: snapshot?.header?.cwd,
|
|
510
|
+
stored: snapshot !== undefined,
|
|
511
|
+
live: live.has(id),
|
|
512
|
+
current: request.currentSessionId !== undefined && id === request.currentSessionId,
|
|
513
|
+
blockers: reasons,
|
|
514
|
+
};
|
|
515
|
+
totalBytes += bytes;
|
|
516
|
+
targets.push(target);
|
|
517
|
+
if (reasons.length > 0) blockers.push({ id, reasons });
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return { targets, blockers, warnings, totalBytes, sessionsRoot: options.sessionsRoot };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Permanently delete every planned artifact for `ids`. Re-plans first, then
|
|
525
|
+
* refuses on any blocker. Attachment removal is a global reference-scan and
|
|
526
|
+
* is skipped conservatively when any surviving log cannot be read.
|
|
527
|
+
*
|
|
528
|
+
* @param {any} ctx
|
|
529
|
+
* @param {ReturnType<typeof resolveOptions>} options
|
|
530
|
+
* @param {string[]} ids
|
|
531
|
+
* @param {{
|
|
532
|
+
* currentSessionId?: string,
|
|
533
|
+
* signal?: AbortSignal,
|
|
534
|
+
* purgeAttachments?: boolean,
|
|
535
|
+
* secureOverwrite?: boolean,
|
|
536
|
+
* }} [request]
|
|
537
|
+
*/
|
|
538
|
+
export async function purgeSessions(ctx, options, ids, request = {}) {
|
|
539
|
+
const plan = await planPurge(ctx, options, ids, {
|
|
540
|
+
currentSessionId: request.currentSessionId,
|
|
541
|
+
signal: request.signal,
|
|
542
|
+
});
|
|
543
|
+
if (plan.blockers.length > 0) {
|
|
544
|
+
const lines = plan.blockers.map((entry) => `${entry.id}: ${entry.reasons.join('; ')}`);
|
|
545
|
+
throw new PurgeError(`purge refused:\n${lines.join('\n')}`, 'BLOCKED');
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const secureOverwrite = request.secureOverwrite ?? options.secureOverwrite;
|
|
549
|
+
const purgeAttachments = request.purgeAttachments ?? options.purgeAttachments;
|
|
550
|
+
const warnings = [...plan.warnings];
|
|
551
|
+
|
|
552
|
+
// Plan attachment removal before touching the logs. Nothing is deleted here,
|
|
553
|
+
// so a cancellation or a newly live target cannot leave a half-purged session.
|
|
554
|
+
let attachmentPlan = {
|
|
555
|
+
enabled: false,
|
|
556
|
+
objects: [],
|
|
557
|
+
protected: 0,
|
|
558
|
+
keptShared: 0,
|
|
559
|
+
skipped: undefined,
|
|
560
|
+
};
|
|
561
|
+
if (purgeAttachments && plan.targets.length > 0) {
|
|
562
|
+
attachmentPlan = await planAttachmentObjects(ctx, options, plan.targets, {
|
|
563
|
+
signal: request.signal,
|
|
564
|
+
warnings,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// The reference scan can take minutes; a target may have become live since
|
|
569
|
+
// the first plan. Re-refuse before deleting anything.
|
|
570
|
+
const liveNow = liveAgentIds(ctx);
|
|
571
|
+
const becameLive = plan.targets.filter((target) => liveNow.has(target.id));
|
|
572
|
+
if (becameLive.length > 0) {
|
|
573
|
+
throw new PurgeError(
|
|
574
|
+
`purge refused: session(s) became live during planning: ${becameLive.map((target) => target.id).join(', ')}`,
|
|
575
|
+
'BLOCKED',
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const report = {
|
|
580
|
+
targets: [],
|
|
581
|
+
deletedFiles: 0,
|
|
582
|
+
deletedBytes: 0,
|
|
583
|
+
attachments: {
|
|
584
|
+
enabled: attachmentPlan.enabled,
|
|
585
|
+
deleted: 0,
|
|
586
|
+
bytes: 0,
|
|
587
|
+
protected: attachmentPlan.protected,
|
|
588
|
+
keptShared: attachmentPlan.keptShared,
|
|
589
|
+
skipped: attachmentPlan.skipped,
|
|
590
|
+
},
|
|
591
|
+
warnings,
|
|
592
|
+
verified: true,
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
for (const target of plan.targets) {
|
|
596
|
+
if (request.signal?.aborted) throw new PurgeError('purge cancelled', 'ABORTED');
|
|
597
|
+
const targetReport = {
|
|
598
|
+
id: target.id,
|
|
599
|
+
title: target.title,
|
|
600
|
+
bytes: target.bytes,
|
|
601
|
+
files: 0,
|
|
602
|
+
cacheFiles: 0,
|
|
603
|
+
errors: [],
|
|
604
|
+
};
|
|
605
|
+
for (const dir of target.dirs) {
|
|
606
|
+
const outcome = await shredTree(dir, { secureOverwrite });
|
|
607
|
+
targetReport.files += outcome.files;
|
|
608
|
+
targetReport.errors.push(...outcome.errors);
|
|
609
|
+
report.deletedBytes += outcome.bytes;
|
|
610
|
+
}
|
|
611
|
+
for (const file of target.cacheFiles) {
|
|
612
|
+
const outcome = await shredFile(file, { secureOverwrite });
|
|
613
|
+
targetReport.cacheFiles++;
|
|
614
|
+
targetReport.files++;
|
|
615
|
+
report.deletedBytes += outcome.bytes;
|
|
616
|
+
targetReport.errors.push(...outcome.errors);
|
|
617
|
+
}
|
|
618
|
+
// Remove directories that survived a partial tree removal.
|
|
619
|
+
for (const dir of target.directories.slice().sort((left, right) => right.length - left.length)) {
|
|
620
|
+
try {
|
|
621
|
+
await fs.rmdir(dir);
|
|
622
|
+
} catch (error) {
|
|
623
|
+
if (error && (error.code === 'ENOENT' || error.code === 'ENOTEMPTY' || error.code === 'EEXIST')) continue;
|
|
624
|
+
targetReport.errors.push(`${dir}: ${error?.message ?? String(error)}`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
report.deletedFiles += targetReport.files;
|
|
628
|
+
report.targets.push(targetReport);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Only delete attachments once every target log and cache record is gone: a
|
|
632
|
+
// surviving log still references its attachments.
|
|
633
|
+
let removalClean = true;
|
|
634
|
+
for (const planned of plan.targets) {
|
|
635
|
+
for (const dir of planned.dirs) if (await statOrUndefined(dir)) removalClean = false;
|
|
636
|
+
for (const file of planned.cacheFiles) if (await statOrUndefined(file)) removalClean = false;
|
|
637
|
+
}
|
|
638
|
+
let attemptedAttachments = [];
|
|
639
|
+
if (attachmentPlan.enabled && attachmentPlan.objects.length > 0) {
|
|
640
|
+
if (!removalClean) {
|
|
641
|
+
report.attachments.skipped = 'target-removal-incomplete';
|
|
642
|
+
warnings.push('attachment objects were retained because the session log removal was incomplete');
|
|
643
|
+
} else {
|
|
644
|
+
attemptedAttachments = attachmentPlan.objects;
|
|
645
|
+
const outcome = await deleteAttachmentObjects(attemptedAttachments, { secureOverwrite, warnings });
|
|
646
|
+
report.attachments.deleted = outcome.deleted;
|
|
647
|
+
report.attachments.bytes = outcome.bytes;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// The request-image cache is content-addressed by transform, not by
|
|
652
|
+
// attachment, so variants of a deleted image cannot be attributed. Clearing
|
|
653
|
+
// the whole derived cache is safe (it regenerates on demand) and removes
|
|
654
|
+
// those copies.
|
|
655
|
+
report.requestImageCache = { cleared: false, files: 0, bytes: 0 };
|
|
656
|
+
if (report.attachments.deleted > 0 && options.clearRequestImageCache) {
|
|
657
|
+
report.requestImageCache = await clearRequestImageCache(options, warnings, secureOverwrite);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const legacy = await stripLegacyCacheSeed(options, plan.targets).catch((error) => {
|
|
661
|
+
warnings.push(`legacy projection cache cleanup failed: ${error?.message ?? String(error)}`);
|
|
662
|
+
return { changed: false, removed: 0, failed: true, malformed: false };
|
|
663
|
+
});
|
|
664
|
+
if (legacy.changed) report.deletedFiles += legacy.removed;
|
|
665
|
+
if (legacy.failed) report.verified = false;
|
|
666
|
+
if (legacy.malformed) {
|
|
667
|
+
report.verified = false;
|
|
668
|
+
warnings.push('the legacy projection-cache seed could not be parsed; its records were left untouched');
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Verify every planned artifact is gone; a partial removal must not report success.
|
|
672
|
+
for (let index = 0; index < report.targets.length; index++) {
|
|
673
|
+
const targetReport = report.targets[index];
|
|
674
|
+
const planned = plan.targets[index];
|
|
675
|
+
for (const dir of planned.dirs) {
|
|
676
|
+
if (await statOrUndefined(dir)) {
|
|
677
|
+
report.verified = false;
|
|
678
|
+
targetReport.errors.push(`directory still present after purge: ${dir}`);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
for (const file of planned.cacheFiles) {
|
|
682
|
+
if (await statOrUndefined(file)) {
|
|
683
|
+
report.verified = false;
|
|
684
|
+
targetReport.errors.push(`cache record still present after purge: ${file}`);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
if (targetReport.errors.length > 0) report.verified = false;
|
|
688
|
+
}
|
|
689
|
+
for (const object of attemptedAttachments) {
|
|
690
|
+
if (await statOrUndefined(object.file)) {
|
|
691
|
+
report.verified = false;
|
|
692
|
+
warnings.push(`attachment still present after purge: ${object.id}`);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
if (report.requestImageCache.cleared && (await statOrUndefined(options.requestImageCacheRoot))) {
|
|
696
|
+
report.verified = false;
|
|
697
|
+
warnings.push(`request-image cache still present after purge: ${options.requestImageCacheRoot}`);
|
|
698
|
+
}
|
|
699
|
+
return { ...report, plan };
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Decision support for the approval gate: a short human sentence with count,
|
|
704
|
+
* byte total, and a bounded title list.
|
|
705
|
+
*/
|
|
706
|
+
export function describePlan(plan) {
|
|
707
|
+
const count = plan.targets.length;
|
|
708
|
+
const titles = plan.targets
|
|
709
|
+
.map((target) => target.title ?? target.id)
|
|
710
|
+
.slice(0, 5)
|
|
711
|
+
.join(', ');
|
|
712
|
+
const suffix = plan.targets.length > 5 ? `, +${plan.targets.length - 5} more` : '';
|
|
713
|
+
return `${count} session${count === 1 ? '' : 's'} (${formatBytes(plan.totalBytes)}): ${titles}${suffix}`;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Decide which attachment objects belong only to the targets. This performs
|
|
718
|
+
* the full reference scan but deletes nothing; `purgeSessions` calls the
|
|
719
|
+
* deletion only after every target log is gone. Any unreadable surviving log
|
|
720
|
+
* suppresses all object deletion: a false "unreferenced" verdict would break
|
|
721
|
+
* another session.
|
|
722
|
+
*/
|
|
723
|
+
async function planAttachmentObjects(ctx, options, targets, request) {
|
|
724
|
+
const persistence = ctx.sessionPersistence;
|
|
725
|
+
const warnings = request.warnings;
|
|
726
|
+
const result = {
|
|
727
|
+
enabled: true,
|
|
728
|
+
objects: [],
|
|
729
|
+
protected: 0,
|
|
730
|
+
keptShared: 0,
|
|
731
|
+
skipped: undefined,
|
|
732
|
+
};
|
|
733
|
+
const targetIds = new Set(targets.map((target) => target.id));
|
|
734
|
+
const candidates = new Set();
|
|
735
|
+
let candidatesComplete = true;
|
|
736
|
+
|
|
737
|
+
for (const target of targets) {
|
|
738
|
+
if (request.signal?.aborted) throw new PurgeError('purge cancelled', 'ABORTED');
|
|
739
|
+
try {
|
|
740
|
+
const scan = await scanAttachmentIds(persistence, target.id, {
|
|
741
|
+
signal: request.signal,
|
|
742
|
+
maxEvents: options.attachmentScanMaxEvents,
|
|
743
|
+
});
|
|
744
|
+
for (const id of scan.ids) candidates.add(id);
|
|
745
|
+
if (!scan.complete) {
|
|
746
|
+
candidatesComplete = false;
|
|
747
|
+
warnings.push(`${target.id}: attachment reference scan reached its event cap; unknown attachments are retained`);
|
|
748
|
+
}
|
|
749
|
+
} catch (error) {
|
|
750
|
+
candidatesComplete = false;
|
|
751
|
+
warnings.push(`${target.id}: attachment reference scan failed (${error?.message ?? String(error)}); attachments are retained`);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
if (candidates.size === 0) {
|
|
756
|
+
result.skipped = 'no-attachments-referenced';
|
|
757
|
+
return result;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
let snapshots;
|
|
761
|
+
try {
|
|
762
|
+
snapshots = await persistence.list(request.signal === undefined ? undefined : { signal: request.signal });
|
|
763
|
+
} catch (error) {
|
|
764
|
+
warnings.push(`attachment reference scan could not list surviving sessions (${error?.message ?? String(error)}); all attachments retained`);
|
|
765
|
+
result.skipped = 'survivor-list-failed';
|
|
766
|
+
return result;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const referenced = new Set();
|
|
770
|
+
let survivorsComplete = true;
|
|
771
|
+
for (const snapshot of snapshots) {
|
|
772
|
+
if (targetIds.has(snapshot.header.id)) continue;
|
|
773
|
+
try {
|
|
774
|
+
const scan = await scanAttachmentIds(persistence, snapshot.header.id, {
|
|
775
|
+
signal: request.signal,
|
|
776
|
+
maxEvents: options.attachmentScanMaxEvents,
|
|
777
|
+
});
|
|
778
|
+
for (const id of scan.ids) referenced.add(id);
|
|
779
|
+
if (!scan.complete) survivorsComplete = false;
|
|
780
|
+
} catch (error) {
|
|
781
|
+
survivorsComplete = false;
|
|
782
|
+
warnings.push(`${snapshot.header.id}: attachment reference scan failed (${error?.message ?? String(error)})`);
|
|
783
|
+
}
|
|
784
|
+
if (!survivorsComplete) break;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
if (!survivorsComplete) {
|
|
788
|
+
result.skipped = 'survivor-scan-incomplete';
|
|
789
|
+
warnings.push('attachment objects were retained because a surviving session could not be scanned completely');
|
|
790
|
+
return result;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const now = Date.now();
|
|
794
|
+
for (const attachmentId of candidates) {
|
|
795
|
+
if (referenced.has(attachmentId)) {
|
|
796
|
+
result.keptShared++;
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
const match = ATTACHMENT_ID_PATTERN.exec(attachmentId);
|
|
800
|
+
if (match === null) continue;
|
|
801
|
+
const hex = match[1];
|
|
802
|
+
const file = path.join(options.attachmentsRoot, 'objects', hex.slice(0, 2), hex);
|
|
803
|
+
if (!isInside(options.attachmentsRoot, file)) continue;
|
|
804
|
+
let stat;
|
|
805
|
+
try {
|
|
806
|
+
stat = await lstatOrUndefined(file);
|
|
807
|
+
} catch (error) {
|
|
808
|
+
warnings.push(`attachment ${attachmentId}: ${error?.message ?? String(error)}`);
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
if (stat === undefined) continue;
|
|
812
|
+
if (now - stat.mtimeMs < options.attachmentMinAgeMs) {
|
|
813
|
+
result.protected++;
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
result.objects.push({ id: attachmentId, file, bytes: stat.size });
|
|
817
|
+
}
|
|
818
|
+
if (!candidatesComplete) result.skipped = result.skipped ?? 'target-scan-incomplete';
|
|
819
|
+
return result;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
async function deleteAttachmentObjects(objects, request) {
|
|
823
|
+
let deleted = 0;
|
|
824
|
+
let bytes = 0;
|
|
825
|
+
for (const object of objects) {
|
|
826
|
+
const outcome = await shredFile(object.file, { secureOverwrite: request.secureOverwrite });
|
|
827
|
+
if (outcome.errors.length === 0) {
|
|
828
|
+
deleted++;
|
|
829
|
+
bytes += outcome.bytes;
|
|
830
|
+
} else {
|
|
831
|
+
request.warnings.push(...outcome.errors);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
return { deleted, bytes };
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Remove the derived request-image cache. It is keyed by a transform hash
|
|
839
|
+
* rather than by attachment id, so variants of a deleted image cannot be
|
|
840
|
+
* selected individually; the directory is regenerable and safe to drop.
|
|
841
|
+
*/
|
|
842
|
+
async function clearRequestImageCache(options, warnings, secureOverwrite) {
|
|
843
|
+
const root = options.requestImageCacheRoot;
|
|
844
|
+
const guarded = [options.sessionsRoot, options.storagesRoot, options.projectionCacheRoot, options.attachmentsRoot];
|
|
845
|
+
if (
|
|
846
|
+
!isInside(options.dataDir, root) ||
|
|
847
|
+
guarded.some(
|
|
848
|
+
(protectedRoot) => root === protectedRoot || isInside(root, protectedRoot) || isInside(protectedRoot, root),
|
|
849
|
+
)
|
|
850
|
+
) {
|
|
851
|
+
warnings.push(`request-image cache at ${root} overlaps another data root; it was left untouched`);
|
|
852
|
+
return { cleared: false, files: 0, bytes: 0 };
|
|
853
|
+
}
|
|
854
|
+
const stat = await lstatOrUndefined(root);
|
|
855
|
+
if (stat === undefined) return { cleared: false, files: 0, bytes: 0 };
|
|
856
|
+
if (!stat.isDirectory()) {
|
|
857
|
+
warnings.push(`request-image cache at ${root} is not a directory; it was left untouched`);
|
|
858
|
+
return { cleared: false, files: 0, bytes: 0 };
|
|
859
|
+
}
|
|
860
|
+
let tree;
|
|
861
|
+
try {
|
|
862
|
+
tree = await walkTree(root);
|
|
863
|
+
} catch (error) {
|
|
864
|
+
warnings.push(`request-image cache could not be read (${error?.message ?? String(error)}); it was left untouched`);
|
|
865
|
+
return { cleared: false, files: 0, bytes: 0 };
|
|
866
|
+
}
|
|
867
|
+
for (const file of tree.files) {
|
|
868
|
+
const outcome = await shredFile(file.path, { secureOverwrite });
|
|
869
|
+
warnings.push(...outcome.errors);
|
|
870
|
+
}
|
|
871
|
+
try {
|
|
872
|
+
await fs.rm(root, { recursive: true, force: true, maxRetries: 2 });
|
|
873
|
+
} catch (error) {
|
|
874
|
+
warnings.push(`request-image cache could not be removed (${error?.message ?? String(error)})`);
|
|
875
|
+
return { cleared: false, files: 0, bytes: 0 };
|
|
876
|
+
}
|
|
877
|
+
return { cleared: true, files: tree.files.length, bytes: tree.bytes };
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Read one session through the persistence seam and collect every
|
|
882
|
+
* `attachmentId` value. Reads in bounded slices; reports `complete: false`
|
|
883
|
+
* when the event cap is reached.
|
|
884
|
+
*
|
|
885
|
+
* @param {any} persistence
|
|
886
|
+
* @param {string} id
|
|
887
|
+
* @param {{ signal?: AbortSignal, maxEvents: number }} request
|
|
888
|
+
*/
|
|
889
|
+
async function scanAttachmentIds(persistence, id, request) {
|
|
890
|
+
const ids = new Set();
|
|
891
|
+
let seen = 0;
|
|
892
|
+
let complete = true;
|
|
893
|
+
const handle = await persistence.open(id, 'read', request.signal === undefined ? undefined : { signal: request.signal });
|
|
894
|
+
try {
|
|
895
|
+
let cursor = 0;
|
|
896
|
+
while (seen < request.maxEvents) {
|
|
897
|
+
if (request.signal?.aborted) throw new PurgeError('purge cancelled', 'ABORTED');
|
|
898
|
+
const length = Math.min(200, request.maxEvents - seen);
|
|
899
|
+
const page = await handle.read(cursor, length, request.signal === undefined ? undefined : { signal: request.signal });
|
|
900
|
+
if (!page.events || page.events.length === 0) break;
|
|
901
|
+
for (const event of page.events) {
|
|
902
|
+
seen++;
|
|
903
|
+
if (!collectAttachmentIds(event, ids)) complete = false;
|
|
904
|
+
}
|
|
905
|
+
const last = page.events[page.events.length - 1];
|
|
906
|
+
cursor = Number(last.seq) + 1;
|
|
907
|
+
}
|
|
908
|
+
if (seen >= request.maxEvents) complete = false;
|
|
909
|
+
} finally {
|
|
910
|
+
await handle.close();
|
|
911
|
+
}
|
|
912
|
+
return { ids, complete, events: seen };
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function collectAttachmentIds(value, out) {
|
|
916
|
+
const stack = [value];
|
|
917
|
+
let visited = 0;
|
|
918
|
+
while (stack.length > 0 && visited < 200_000) {
|
|
919
|
+
const node = stack.pop();
|
|
920
|
+
visited++;
|
|
921
|
+
if (node === null || typeof node !== 'object') continue;
|
|
922
|
+
if (Array.isArray(node)) {
|
|
923
|
+
for (const item of node) stack.push(item);
|
|
924
|
+
continue;
|
|
925
|
+
}
|
|
926
|
+
for (const [key, child] of Object.entries(node)) {
|
|
927
|
+
if (key === 'attachmentId' && typeof child === 'string' && ATTACHMENT_ID_PATTERN.test(child)) out.add(child);
|
|
928
|
+
else if (child !== null && typeof child === 'object') stack.push(child);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
return stack.length === 0;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Delete the legacy whole-unit cache seed's records for purged sessions. The
|
|
936
|
+
* per-record tree is authoritative; this file is only an initialization
|
|
937
|
+
* source, so editing it cannot race the cache's normal writes.
|
|
938
|
+
*/
|
|
939
|
+
async function stripLegacyCacheSeed(options, targets) {
|
|
940
|
+
let raw;
|
|
941
|
+
try {
|
|
942
|
+
raw = await fs.readFile(options.projectionCacheSeed, 'utf8');
|
|
943
|
+
} catch (error) {
|
|
944
|
+
if (error && error.code === 'ENOENT') return { changed: false, removed: 0, failed: false, malformed: false };
|
|
945
|
+
throw error;
|
|
946
|
+
}
|
|
947
|
+
let document;
|
|
948
|
+
try {
|
|
949
|
+
document = JSON.parse(raw);
|
|
950
|
+
} catch {
|
|
951
|
+
return { changed: false, removed: 0, failed: false, malformed: true };
|
|
952
|
+
}
|
|
953
|
+
const table = document?.tables?.sessions;
|
|
954
|
+
if (table === undefined || table === null || typeof table !== 'object') {
|
|
955
|
+
return { changed: false, removed: 0, failed: false, malformed: false };
|
|
956
|
+
}
|
|
957
|
+
let removed = 0;
|
|
958
|
+
for (const target of targets) {
|
|
959
|
+
if (Object.hasOwn(table, target.id)) {
|
|
960
|
+
delete table[target.id];
|
|
961
|
+
removed++;
|
|
962
|
+
}
|
|
963
|
+
if (target.header !== undefined && target.id.startsWith('session-')) {
|
|
964
|
+
const bare = target.id.slice('session-'.length);
|
|
965
|
+
if (Object.hasOwn(table, bare) && legacyRecordMatches(table[bare], target.header)) {
|
|
966
|
+
delete table[bare];
|
|
967
|
+
removed++;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
if (removed === 0) return { changed: false, removed: 0, failed: false, malformed: false };
|
|
972
|
+
const directory = path.dirname(options.projectionCacheSeed);
|
|
973
|
+
const temporary = path.join(directory, `.${path.basename(options.projectionCacheSeed)}.${process.pid}.${Date.now()}.tmp`);
|
|
974
|
+
const stat = await statOrUndefined(options.projectionCacheSeed);
|
|
975
|
+
await fs.writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: stat?.mode ?? 0o600 });
|
|
976
|
+
await fs.rename(temporary, options.projectionCacheSeed);
|
|
977
|
+
return { changed: true, removed, failed: false, malformed: false };
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
async function recordMatches(file, header) {
|
|
981
|
+
try {
|
|
982
|
+
const document = JSON.parse(await fs.readFile(file, 'utf8'));
|
|
983
|
+
return legacyRecordMatches(document?.record, header);
|
|
984
|
+
} catch {
|
|
985
|
+
return false;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function legacyRecordMatches(record, header) {
|
|
990
|
+
const identity = record?.identity;
|
|
991
|
+
if (identity === undefined || identity === null) return false;
|
|
992
|
+
if (header?.createdAt === undefined || identity.createdAt !== header.createdAt) return false;
|
|
993
|
+
return (identity.cwd ?? undefined) === (header.cwd ?? undefined);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/** Shred and remove one complete session directory tree. */
|
|
997
|
+
async function shredTree(directory, options) {
|
|
998
|
+
const report = { files: 0, bytes: 0, errors: [] };
|
|
999
|
+
let tree;
|
|
1000
|
+
try {
|
|
1001
|
+
tree = await walkTree(directory);
|
|
1002
|
+
} catch (error) {
|
|
1003
|
+
report.errors.push(`${directory}: ${error?.message ?? String(error)}`);
|
|
1004
|
+
return report;
|
|
1005
|
+
}
|
|
1006
|
+
for (const file of tree.files) {
|
|
1007
|
+
const outcome = await shredFile(file.path, options);
|
|
1008
|
+
report.files++;
|
|
1009
|
+
report.bytes += outcome.bytes;
|
|
1010
|
+
report.errors.push(...outcome.errors);
|
|
1011
|
+
}
|
|
1012
|
+
try {
|
|
1013
|
+
await fs.rm(directory, { recursive: true, force: true, maxRetries: 2 });
|
|
1014
|
+
} catch (error) {
|
|
1015
|
+
report.errors.push(`${directory}: ${error?.message ?? String(error)}`);
|
|
1016
|
+
}
|
|
1017
|
+
return report;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* Overwrite (when enabled) and unlink one file. Symlinks are unlinked, never
|
|
1022
|
+
* followed. Read-only attachments are made writable first; a failed overwrite
|
|
1023
|
+
* still unlinks and reports the failure.
|
|
1024
|
+
*/
|
|
1025
|
+
async function shredFile(file, options) {
|
|
1026
|
+
const report = { bytes: 0, errors: [] };
|
|
1027
|
+
let stat;
|
|
1028
|
+
try {
|
|
1029
|
+
stat = await fs.lstat(file);
|
|
1030
|
+
} catch (error) {
|
|
1031
|
+
if (error && error.code === 'ENOENT') return report;
|
|
1032
|
+
report.errors.push(`${file}: ${error?.message ?? String(error)}`);
|
|
1033
|
+
return report;
|
|
1034
|
+
}
|
|
1035
|
+
if (stat.isSymbolicLink()) {
|
|
1036
|
+
try {
|
|
1037
|
+
await fs.unlink(file);
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
report.errors.push(`${file}: ${error?.message ?? String(error)}`);
|
|
1040
|
+
}
|
|
1041
|
+
return report;
|
|
1042
|
+
}
|
|
1043
|
+
if (!stat.isFile()) {
|
|
1044
|
+
report.errors.push(`${file}: not a regular file`);
|
|
1045
|
+
return report;
|
|
1046
|
+
}
|
|
1047
|
+
report.bytes = stat.size;
|
|
1048
|
+
if (options.secureOverwrite && stat.size > 0) {
|
|
1049
|
+
try {
|
|
1050
|
+
await fs.chmod(file, 0o600).catch(() => undefined);
|
|
1051
|
+
const handle = await fs.open(file, 'r+');
|
|
1052
|
+
try {
|
|
1053
|
+
const chunk = Buffer.alloc(Math.min(1024 * 1024, Math.max(1, stat.size)));
|
|
1054
|
+
let position = 0;
|
|
1055
|
+
while (position < stat.size) {
|
|
1056
|
+
const length = Math.min(chunk.length, stat.size - position);
|
|
1057
|
+
await handle.write(chunk, 0, length, position);
|
|
1058
|
+
position += length;
|
|
1059
|
+
}
|
|
1060
|
+
await handle.sync();
|
|
1061
|
+
} finally {
|
|
1062
|
+
await handle.close();
|
|
1063
|
+
}
|
|
1064
|
+
} catch (error) {
|
|
1065
|
+
report.errors.push(`${file}: overwrite failed before removal: ${error?.message ?? String(error)}`);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
try {
|
|
1069
|
+
await fs.unlink(file);
|
|
1070
|
+
} catch (error) {
|
|
1071
|
+
if (!(error && error.code === 'ENOENT')) report.errors.push(`${file}: ${error?.message ?? String(error)}`);
|
|
1072
|
+
}
|
|
1073
|
+
return report;
|
|
1074
|
+
}
|