dsh-plugin-delete-session 0.0.0-stage → 1.0.8

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/lib/paths.js ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Path helpers that mirror the JSONL session backend's on-disk layout:
3
+ *
4
+ * <sessionsRoot>/<projectKey(cwd)>/<encodeSegment(sessionId)>/
5
+ *
6
+ * The backend encodes both the project directory and the session id so that an
7
+ * arbitrary header value can never escape the configured root. The same rules
8
+ * are reproduced here (they are a small, stable part of the storage format)
9
+ * so this plugin can address one session directory without scanning the whole
10
+ * history tree.
11
+ */
12
+
13
+ import path from 'node:path'
14
+
15
+ const SAFE_SEGMENT = /^[A-Za-z0-9._-]$/
16
+
17
+ /**
18
+ * Encode an arbitrary string as one safe path segment, injectively.
19
+ *
20
+ * @param {string} raw value to encode.
21
+ * @returns {string} filesystem-safe segment.
22
+ */
23
+ export function encodeSegment(raw) {
24
+ if (typeof raw !== 'string' || raw.length === 0) {
25
+ throw new TypeError('cannot encode an empty path segment')
26
+ }
27
+ if (raw === '.') return '~002E'
28
+ if (raw === '..') return '~002E~002E'
29
+ let out = ''
30
+ for (let index = 0; index < raw.length; index += 1) {
31
+ const code = raw.charCodeAt(index)
32
+ const char = String.fromCharCode(code)
33
+ if (char !== '~' && SAFE_SEGMENT.test(char)) out += char
34
+ else out += `~${code.toString(16).toUpperCase().padStart(4, '0')}`
35
+ }
36
+ return out
37
+ }
38
+
39
+ /**
40
+ * Build the readable project directory key for a recorded cwd.
41
+ *
42
+ * @param {string} cwd session's recorded directory.
43
+ * @returns {string} project directory name under the sessions root.
44
+ */
45
+ export function projectKey(cwd) {
46
+ if (typeof cwd !== 'string' || cwd.length === 0) {
47
+ throw new TypeError('cannot encode an empty project path')
48
+ }
49
+ let readable = ''
50
+ let separatorRun = false
51
+ for (let index = 0; index < cwd.length; index += 1) {
52
+ const code = cwd.charCodeAt(index)
53
+ const char = String.fromCharCode(code)
54
+ if (char === '/' || char === '\\' || char === ':') {
55
+ if (!separatorRun) readable += '-'
56
+ separatorRun = true
57
+ } else if (char !== '~' && SAFE_SEGMENT.test(char)) {
58
+ readable += char
59
+ separatorRun = false
60
+ } else {
61
+ readable += `~${code.toString(16).toUpperCase().padStart(4, '0')}`
62
+ separatorRun = false
63
+ }
64
+ }
65
+ return `--${(readable.replace(/^-+/, '') || 'root').slice(0, 251)}--`
66
+ }
67
+
68
+ /**
69
+ * Directory that owns one session's log artifacts.
70
+ *
71
+ * @param {string} root configured sessions root.
72
+ * @param {string | undefined} cwd recorded session directory.
73
+ * @param {string} id session id.
74
+ * @returns {string} absolute session directory.
75
+ */
76
+ export function sessionDir(root, cwd, id) {
77
+ const project = cwd === undefined || cwd === ''
78
+ ? '_no-cwd'
79
+ : projectKey(cwd)
80
+ return path.join(root, project, encodeSegment(id))
81
+ }
82
+
83
+ /**
84
+ * Whether a string is safe as one path segment on this host.
85
+ *
86
+ * Rejects the traversal spellings and every character that can change how a
87
+ * joined path is interpreted. Encoding still happens at the filesystem
88
+ * boundary; this check exists so a metadata value cannot reach a raw path join
89
+ * (notably the projection-cache filename) in the first place.
90
+ *
91
+ * @param {unknown} value candidate segment.
92
+ * @returns {boolean} true for one ordinary path segment.
93
+ */
94
+ export function isSafePathSegment(value) {
95
+ return typeof value === 'string'
96
+ && value !== ''
97
+ && value !== '.'
98
+ && value !== '..'
99
+ && !/[\\/\u0000-\u001F]/.test(value)
100
+ }
101
+
102
+ /**
103
+ * Validate a stored or requested session id.
104
+ *
105
+ * @param {unknown} value candidate session id.
106
+ * @returns {boolean} true when the id is one safe segment.
107
+ */
108
+ export function isSafeSessionId(value) {
109
+ return isSafePathSegment(value)
110
+ && value.length <= 512
111
+ && !/[<>:"|?*]/.test(value)
112
+ }
113
+
114
+ /**
115
+ * Validate a recorded project-directory name from trash metadata.
116
+ *
117
+ * @param {unknown} value candidate project directory name.
118
+ * @returns {boolean} true when the name is one safe directory segment.
119
+ */
120
+ export function isSafeProjectName(value) {
121
+ return isSafePathSegment(value)
122
+ && value.length <= 255
123
+ && !/[<>:"|?*]/.test(value)
124
+ }
125
+
126
+ /**
127
+ * Whether `child` names the same path as, or a path below, `parent`.
128
+ *
129
+ * @param {string} parent candidate ancestor.
130
+ * @param {string} child candidate descendant.
131
+ * @returns {boolean} true when containment holds.
132
+ */
133
+ export function isInside(parent, child) {
134
+ const relative = path.relative(path.resolve(parent), path.resolve(child))
135
+ return relative === ''
136
+ || (relative !== '..'
137
+ && !relative.startsWith(`..${path.sep}`)
138
+ && !path.isAbsolute(relative))
139
+ }
140
+
141
+ /**
142
+ * Compare two absolute paths the way the host platform does.
143
+ *
144
+ * @param {string} left path.
145
+ * @param {string} right path.
146
+ * @returns {boolean} true when they name the same location.
147
+ */
148
+ export function samePath(left, right) {
149
+ if (typeof left !== 'string' || typeof right !== 'string') return false
150
+ if (left === right) return true
151
+ const normalize = (value) => {
152
+ const resolved = path.resolve(value).replace(/[\\/]+$/, '')
153
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved
154
+ }
155
+ return normalize(left) === normalize(right)
156
+ }
package/lib/trash.js ADDED
@@ -0,0 +1,539 @@
1
+ /**
2
+ * Filesystem-backed recycle bin for session logs.
3
+ *
4
+ * A deleted session is copied as a whole into
5
+ * `<trashRoot>/<encodeSegment(sessionId)>/` together with its projection-cache
6
+ * checkpoint and a small metadata file, and only then is the original removed.
7
+ * Restoring copies the artifacts back and drops the trash entry. Entries older
8
+ * than the retention window are purged permanently.
9
+ */
10
+
11
+ import { cp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
12
+ import path from 'node:path'
13
+
14
+ import { encodeSegment, isInside, isSafeProjectName, isSafeSessionId, sessionDir } from './paths.js'
15
+
16
+ const META_FILE = 'meta.json'
17
+ const LOG_DIR = 'log'
18
+ const PROJECTION_FILE = 'projection.json'
19
+ const META_VERSION = 1
20
+
21
+ export class SessionTrash {
22
+ /**
23
+ * @param {object} options
24
+ * @param {string} options.sessionsRoot configured session-log root.
25
+ * @param {string} options.trashRoot directory that keeps deleted copies.
26
+ * @param {string} options.projectionRoot projection-cache directory.
27
+ * @param {number} options.retentionMs retention window in milliseconds.
28
+ * @param {{ warn: (message: string) => void, info: (message: string) => void }} [options.logger]
29
+ */
30
+ constructor(options) {
31
+ this.sessionsRoot = path.resolve(options.sessionsRoot)
32
+ this.trashRoot = path.resolve(options.trashRoot)
33
+ this.projectionRoot = path.resolve(options.projectionRoot)
34
+ this.retentionMs = options.retentionMs
35
+ this.logger = options.logger ?? { warn: () => {}, info: () => {} }
36
+ this.queue = Promise.resolve()
37
+ }
38
+
39
+ /** @returns {Promise<void>} resolves once the trash directory exists. */
40
+ async init() {
41
+ await mkdir(this.trashRoot, { recursive: true })
42
+ }
43
+
44
+ /**
45
+ * Serialize mutating operations so two UI actions cannot interleave a copy
46
+ * and a purge for the same entry.
47
+ *
48
+ * @template T
49
+ * @param {() => Promise<T>} operation queued work.
50
+ * @returns {Promise<T>} the operation result.
51
+ */
52
+ run(operation) {
53
+ const result = this.queue.then(operation, operation)
54
+ this.queue = result.then(() => {}, () => {})
55
+ return result
56
+ }
57
+
58
+ /**
59
+ * Directory holding one trashed session.
60
+ *
61
+ * @param {string} sessionId session id.
62
+ * @returns {string} absolute entry directory.
63
+ */
64
+ entryDir(sessionId) {
65
+ return path.join(this.trashRoot, encodeSegment(sessionId))
66
+ }
67
+
68
+ /**
69
+ * Original log directory for a session.
70
+ *
71
+ * @param {string} sessionId session id.
72
+ * @param {string | undefined} cwd recorded directory.
73
+ * @returns {string} absolute source directory.
74
+ */
75
+ sourceDir(sessionId, cwd) {
76
+ const source = sessionDir(this.sessionsRoot, cwd, sessionId)
77
+ if (!isInside(this.sessionsRoot, source)) {
78
+ throw new Error(`refusing to address a session directory outside the sessions root: ${source}`)
79
+ }
80
+ return source
81
+ }
82
+
83
+ /**
84
+ * Current projection-cache checkpoint for a session.
85
+ *
86
+ * @param {string} sessionId session id.
87
+ * @returns {string} absolute cache file path.
88
+ */
89
+ projectionFile(sessionId) {
90
+ return path.join(this.projectionRoot, `${sessionId}.json`)
91
+ }
92
+
93
+ /**
94
+ * List live trash entries, newest first. Invalid or half-written entries are
95
+ * skipped rather than breaking the whole recycle bin.
96
+ *
97
+ * @returns {Promise<Array<Record<string, unknown>>>} known entries.
98
+ */
99
+ async list() {
100
+ let names
101
+ try {
102
+ names = await readdir(this.trashRoot, { withFileTypes: true })
103
+ } catch (error) {
104
+ if (isMissing(error)) return []
105
+ throw error
106
+ }
107
+ const entries = []
108
+ for (const dirent of names) {
109
+ if (!dirent.isDirectory()) continue
110
+ const dir = path.join(this.trashRoot, dirent.name)
111
+ try {
112
+ const meta = await this.readMeta(dir)
113
+ if (meta === undefined) continue
114
+ entries.push(this.present(meta))
115
+ } catch (error) {
116
+ this.logger.warn(`session-trash: cannot read entry '${dirent.name}': ${messageOf(error)}`)
117
+ }
118
+ }
119
+ entries.sort((left, right) => right.deletedAt - left.deletedAt)
120
+ return entries
121
+ }
122
+
123
+ /**
124
+ * Read one entry by session id.
125
+ *
126
+ * @param {string} sessionId session id.
127
+ * @returns {Promise<Record<string, unknown> | undefined>} entry, when present.
128
+ */
129
+ async read(sessionId) {
130
+ const dir = this.entryDir(sessionId)
131
+ const meta = await this.readMeta(dir)
132
+ return meta === undefined ? undefined : this.present(meta)
133
+ }
134
+
135
+ /**
136
+ * Locate the artifacts for one session. The recorded cwd normally names the
137
+ * project directory, but a stale header, a migrated root, or a session whose
138
+ * cwd changed can leave it elsewhere; fall back to the encoded id under any
139
+ * project directory before concluding that no log exists.
140
+ *
141
+ * @param {string} sessionId session id.
142
+ * @param {string | undefined} cwd recorded directory.
143
+ * @returns {Promise<{ dir: string, project: string } | undefined>} source.
144
+ */
145
+ async findSource(sessionId, cwd) {
146
+ const expected = this.sourceDir(sessionId, cwd)
147
+ if (await isDirectory(expected)) {
148
+ return { dir: expected, project: path.basename(path.dirname(expected)) }
149
+ }
150
+ const encoded = encodeSegment(sessionId)
151
+ let projects
152
+ try {
153
+ projects = await readdir(this.sessionsRoot, { withFileTypes: true })
154
+ } catch {
155
+ return undefined
156
+ }
157
+ for (const project of projects) {
158
+ if (!project.isDirectory()) continue
159
+ const candidate = path.join(this.sessionsRoot, project.name, encoded)
160
+ if (!isInside(this.sessionsRoot, candidate)) continue
161
+ if (await isDirectory(candidate)) return { dir: candidate, project: project.name }
162
+ }
163
+ return undefined
164
+ }
165
+
166
+ /**
167
+ * Whether the original log directory still exists.
168
+ *
169
+ * @param {string} sessionId session id.
170
+ * @param {string | undefined} cwd recorded directory.
171
+ * @returns {Promise<boolean>} true when artifacts remain at the source.
172
+ */
173
+ async sourceExists(sessionId, cwd) {
174
+ return (await this.findSource(sessionId, cwd)) !== undefined
175
+ }
176
+
177
+ /**
178
+ * Copy a session's artifacts into the trash and remove the originals.
179
+ *
180
+ * @param {object} input
181
+ * @param {string} input.sessionId session id.
182
+ * @param {string | undefined} input.title display title snapshot.
183
+ * @param {string | undefined} input.cwd recorded directory.
184
+ * @returns {Promise<Record<string, unknown> | null>} the stored entry, or null
185
+ * when the session has no artifacts yet (a blank session).
186
+ */
187
+ async capture(input) {
188
+ const { sessionId, title, cwd } = input
189
+ const source = await this.findSource(sessionId, cwd)
190
+ const entryDir = this.entryDir(sessionId)
191
+ const hadLog = source !== undefined
192
+ const projection = this.projectionFile(sessionId)
193
+ const hasProjection = await isFile(projection)
194
+
195
+ if (!hadLog && !hasProjection) return null
196
+
197
+ // A projection cache without its log cannot reconstruct a conversation.
198
+ // Remove the orphaned cache and report the session as empty instead of
199
+ // advertising an entry that can never be restored.
200
+ if (!hadLog) {
201
+ try {
202
+ await rm(projection, { force: true })
203
+ } catch (error) {
204
+ this.logger.warn(`session-trash: cannot remove orphaned projection for '${sessionId}': ${messageOf(error)}`)
205
+ }
206
+ return null
207
+ }
208
+
209
+ // A previous entry for the same id can only be a stale leftover; replace it
210
+ // rather than merging two generations of the same session.
211
+ await rm(entryDir, { recursive: true, force: true })
212
+ await mkdir(entryDir, { recursive: true })
213
+
214
+ let metaWritten = false
215
+ let storedMeta
216
+ try {
217
+ if (hadLog) {
218
+ await cp(source.dir, path.join(entryDir, LOG_DIR), {
219
+ recursive: true,
220
+ errorOnExist: true,
221
+ force: false,
222
+ preserveTimestamps: true,
223
+ })
224
+ }
225
+ if (hasProjection) {
226
+ await cp(projection, path.join(entryDir, PROJECTION_FILE), {
227
+ errorOnExist: true,
228
+ force: false,
229
+ })
230
+ }
231
+ const now = Date.now()
232
+ const meta = {
233
+ version: META_VERSION,
234
+ sessionId,
235
+ title: typeof title === 'string' ? title : '',
236
+ cwd: typeof cwd === 'string' && cwd !== '' ? cwd : undefined,
237
+ project: source?.project,
238
+ deletedAt: now,
239
+ expiresAt: now + this.retentionMs,
240
+ hadLog,
241
+ hasProjection,
242
+ bytes: hadLog ? await dirSize(path.join(entryDir, LOG_DIR)) : 0,
243
+ }
244
+ await this.writeMeta(entryDir, meta)
245
+ metaWritten = true
246
+ storedMeta = meta
247
+ // Only now is the copy complete enough to be restorable. If removing an
248
+ // original fails part-way, the entry MUST stay: deleting it here would
249
+ // discard the only complete copy after the log directory was already
250
+ // (partially) removed. The caller reports the failure and keeps the
251
+ // session archived/detached so the copy can be restored or purged.
252
+ if (hadLog) await rm(source.dir, { recursive: true, force: true })
253
+ if (hasProjection) await rm(projection, { force: true })
254
+ return this.present(meta)
255
+ } catch (error) {
256
+ if (!metaWritten) {
257
+ // The copy never completed, so no original has been removed yet; a
258
+ // partial entry must not claim to be restorable.
259
+ await rm(entryDir, { recursive: true, force: true }).catch(() => {})
260
+ } else {
261
+ // Removing an original can fail after it was already gone (notably a
262
+ // projection cache that vanished concurrently). If neither original
263
+ // remains, the complete copy in the trash IS the deletion; report
264
+ // success instead of rolling the row back with a missing log.
265
+ const sourceRemains = hadLog ? await exists(source.dir) : false
266
+ const projectionRemains = hasProjection ? await exists(projection) : false
267
+ if (sourceRemains && hadLog) {
268
+ // One retry covers a transient Windows sharing violation; the trash
269
+ // copy is already complete, so removing the remaining source is safe.
270
+ try {
271
+ await rm(source.dir, { recursive: true, force: true })
272
+ } catch {
273
+ // Keep the entry and let the caller roll the row back.
274
+ }
275
+ }
276
+ const stillThere = hadLog ? await exists(source.dir) : false
277
+ if (!stillThere && storedMeta !== undefined) {
278
+ // The log is the authoritative artifact; if it is gone, the session
279
+ // is deleted even when a derived projection cache could not be
280
+ // removed. Never roll the row back with a missing log.
281
+ if (projectionRemains) {
282
+ this.logger.warn(`session-trash: deleted '${sessionId}' but left its projection cache behind`)
283
+ }
284
+ return this.present(storedMeta)
285
+ }
286
+ this.logger.warn(`session-trash: kept entry '${sessionId}' because removing its originals failed: ${messageOf(error)}`)
287
+ }
288
+ throw error
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Copy one entry's artifacts back to their original location and drop the
294
+ * entry. The caller owns the workspace/archive bookkeeping.
295
+ *
296
+ * @param {Record<string, unknown>} entry entry returned by list/read/capture.
297
+ * @returns {Promise<string>} the restored session directory, when a log was kept.
298
+ */
299
+ async restore(entry) {
300
+ const sessionId = String(entry.sessionId)
301
+ if (!isSafeSessionId(sessionId)) {
302
+ throw coded('lost-data', '回收站条目的会话 ID 无效,无法恢复')
303
+ }
304
+ const cwd = typeof entry.cwd === 'string' && entry.cwd !== '' ? entry.cwd : undefined
305
+ const project = typeof entry.project === 'string' && entry.project !== ''
306
+ ? entry.project
307
+ : undefined
308
+ if (project !== undefined && !isSafeProjectName(project)) {
309
+ throw coded('lost-data', '回收站条目的项目目录无效,无法恢复')
310
+ }
311
+ const destination = project === undefined
312
+ ? this.sourceDir(sessionId, cwd)
313
+ : path.join(this.sessionsRoot, project, encodeSegment(sessionId))
314
+ if (!isInside(this.sessionsRoot, destination)) {
315
+ throw new Error(`refusing to restore outside the sessions root: ${destination}`)
316
+ }
317
+ const entryDir = this.entryDir(sessionId)
318
+ if (!entry.hadLog) {
319
+ throw coded('lost-data', '回收站中没有会话日志,无法恢复')
320
+ }
321
+
322
+ const destinationExisted = await exists(destination)
323
+ try {
324
+ if (entry.hadLog) {
325
+ const sourceLog = path.join(entryDir, LOG_DIR)
326
+ if (!(await isDirectory(sourceLog))) {
327
+ throw coded('lost-data', '回收站中的对话数据已丢失,无法恢复')
328
+ }
329
+ if (destinationExisted) {
330
+ throw coded('conflict', '原位置已存在同名对话数据,已取消恢复')
331
+ }
332
+ await mkdir(path.dirname(destination), { recursive: true })
333
+ await cp(sourceLog, destination, {
334
+ recursive: true,
335
+ errorOnExist: true,
336
+ force: false,
337
+ preserveTimestamps: true,
338
+ })
339
+ }
340
+
341
+ if (entry.hasProjection) {
342
+ const source = path.join(entryDir, PROJECTION_FILE)
343
+ if (await isFile(source)) {
344
+ try {
345
+ await mkdir(this.projectionRoot, { recursive: true })
346
+ // The projection cache is derived data. Replace any leftover from
347
+ // an interrupted earlier delete/restore instead of failing the
348
+ // whole restore with a conflict that has no user-visible target.
349
+ const target = this.projectionFile(sessionId)
350
+ await rm(target, { recursive: true, force: true })
351
+ await cp(source, target, { force: true })
352
+ } catch (error) {
353
+ // The log is authoritative; a stale/missing projection is rebuilt
354
+ // by DSH on demand. Do not roll back a successful log restore.
355
+ this.logger.warn(`session-trash: restoring projection for '${sessionId}' failed: ${messageOf(error)}`)
356
+ if (!entry.hadLog) throw error
357
+ }
358
+ }
359
+ }
360
+ } catch (error) {
361
+ // Never leave a half-restored destination behind: a retry would then hit
362
+ // the conflict guard while the trash entry still exists.
363
+ if (!destinationExisted && entry.hadLog) {
364
+ await rm(destination, { recursive: true, force: true }).catch(() => {})
365
+ }
366
+ throw error
367
+ }
368
+
369
+ // A leftover entry is a duplicate, not a failed restore. Keep the restored
370
+ // session usable and let the user purge the duplicate later.
371
+ try {
372
+ await rm(entryDir, { recursive: true, force: true })
373
+ } catch (error) {
374
+ this.logger.warn(`session-trash: restored '${sessionId}' but could not remove its trash entry: ${messageOf(error)}`)
375
+ }
376
+ return entry.hadLog ? destination : ''
377
+ }
378
+
379
+ /**
380
+ * Permanently remove one entry.
381
+ *
382
+ * @param {Record<string, unknown>} entry entry to delete.
383
+ * @returns {Promise<void>} resolution after removal.
384
+ */
385
+ async purge(entry) {
386
+ await rm(this.entryDir(String(entry.sessionId)), { recursive: true, force: true })
387
+ }
388
+
389
+ /**
390
+ * Permanently remove entries whose retention window has elapsed.
391
+ *
392
+ * @param {number} [now] clock override for tests.
393
+ * @returns {Promise<string[]>} purged session ids.
394
+ */
395
+ async purgeExpired(now = Date.now()) {
396
+ const entries = await this.list()
397
+ const purged = []
398
+ for (const entry of entries) {
399
+ const sessionId = String(entry.sessionId)
400
+ try {
401
+ const removed = await this.run(async () => {
402
+ const current = await this.read(sessionId)
403
+ if (current === undefined || current.expiresAt > now) return false
404
+ await this.purge(current)
405
+ return true
406
+ })
407
+ if (removed) purged.push(sessionId)
408
+ } catch (error) {
409
+ this.logger.warn(`session-trash: cannot purge '${sessionId}': ${messageOf(error)}`)
410
+ }
411
+ }
412
+ return purged
413
+ }
414
+
415
+ async readMeta(dir) {
416
+ let text
417
+ try {
418
+ text = await readFile(path.join(dir, META_FILE), 'utf8')
419
+ } catch (error) {
420
+ if (isMissing(error)) return undefined
421
+ throw error
422
+ }
423
+ let value
424
+ try {
425
+ value = JSON.parse(text)
426
+ } catch {
427
+ return undefined
428
+ }
429
+ if (
430
+ !value || typeof value !== 'object'
431
+ || value.version !== META_VERSION
432
+ || !isSafeSessionId(value.sessionId)
433
+ || typeof value.deletedAt !== 'number' || !Number.isFinite(value.deletedAt)
434
+ || typeof value.expiresAt !== 'number' || !Number.isFinite(value.expiresAt)
435
+ || value.expiresAt < value.deletedAt
436
+ || typeof value.hadLog !== 'boolean'
437
+ || typeof value.hasProjection !== 'boolean'
438
+ || (value.title !== undefined && typeof value.title !== 'string')
439
+ || (value.cwd !== undefined && typeof value.cwd !== 'string')
440
+ || (value.project !== undefined && !isSafeProjectName(value.project))
441
+ || (value.bytes !== undefined
442
+ && (typeof value.bytes !== 'number' || !Number.isFinite(value.bytes) || value.bytes < 0))
443
+ ) {
444
+ return undefined
445
+ }
446
+ // The directory name is the authoritative identity. A copied/renamed
447
+ // metadata file must not make `read(id)` or `purge(entry)` address a
448
+ // different session's directory.
449
+ if (encodeSegment(value.sessionId) !== path.basename(dir)) {
450
+ return undefined
451
+ }
452
+ return value
453
+ }
454
+
455
+ async writeMeta(dir, meta) {
456
+ const target = path.join(dir, META_FILE)
457
+ const temporary = `${target}.tmp`
458
+ await writeFile(temporary, `${JSON.stringify(meta, null, 2)}\n`, 'utf8')
459
+ await rename(temporary, target)
460
+ }
461
+
462
+ present(meta) {
463
+ return {
464
+ sessionId: meta.sessionId,
465
+ title: meta.title ?? '',
466
+ cwd: meta.cwd,
467
+ project: meta.project,
468
+ deletedAt: meta.deletedAt,
469
+ expiresAt: meta.expiresAt,
470
+ hadLog: meta.hadLog,
471
+ hasProjection: meta.hasProjection,
472
+ bytes: meta.bytes ?? 0,
473
+ }
474
+ }
475
+ }
476
+
477
+ async function dirSize(target) {
478
+ let total = 0
479
+ let entries
480
+ try {
481
+ entries = await readdir(target, { withFileTypes: true })
482
+ } catch {
483
+ return 0
484
+ }
485
+ for (const dirent of entries) {
486
+ const child = path.join(target, dirent.name)
487
+ if (dirent.isDirectory()) total += await dirSize(child)
488
+ else {
489
+ try {
490
+ total += (await stat(child)).size
491
+ } catch {
492
+ // A vanishing file contributes nothing; the size is display-only.
493
+ }
494
+ }
495
+ }
496
+ return total
497
+ }
498
+
499
+ async function exists(target) {
500
+ try {
501
+ await stat(target)
502
+ return true
503
+ } catch (error) {
504
+ if (isMissing(error)) return false
505
+ throw error
506
+ }
507
+ }
508
+
509
+ async function isDirectory(target) {
510
+ try {
511
+ return (await stat(target)).isDirectory()
512
+ } catch (error) {
513
+ if (isMissing(error)) return false
514
+ throw error
515
+ }
516
+ }
517
+
518
+ async function isFile(target) {
519
+ try {
520
+ return (await stat(target)).isFile()
521
+ } catch (error) {
522
+ if (isMissing(error)) return false
523
+ throw error
524
+ }
525
+ }
526
+
527
+ function isMissing(error) {
528
+ return Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')
529
+ }
530
+
531
+ function messageOf(error) {
532
+ return error instanceof Error ? error.message : String(error)
533
+ }
534
+
535
+ function coded(code, message) {
536
+ const error = new Error(message)
537
+ error.code = code
538
+ return error
539
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Session Trash",
4
+ "description": "Adds a delete action to every session menu. Deleting asks for confirmation, removes the session from the list, and keeps a copy for seven days so it can be restored."
5
+ }
6
+ }