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/CHANGELOG.md +30 -0
- package/LICENSE +21 -0
- package/README.en.md +132 -0
- package/README.md +134 -2
- package/client.js +841 -0
- package/cordis.patch.yml +5 -0
- package/icon.svg +15 -0
- package/index.js +652 -0
- package/lib/paths.js +156 -0
- package/lib/trash.js +539 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +61 -4
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
|
+
}
|