dsh-conversation-recycle-bin 0.3.11 → 0.3.13
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 +454 -412
- package/README.md +8 -8
- package/client.js +75 -38
- package/lib/core/cascade.js +56 -23
- package/lib/core/file-lock.js +43 -8
- package/lib/core/policy.js +126 -119
- package/lib/core/trash.js +245 -227
- package/lib/host/api.js +182 -37
- package/lib/host/catalog.js +9 -2
- package/lib/host/gateway.js +368 -351
- package/lib/host/paths.js +136 -1
- package/lib/version.js +8 -8
- package/package.json +81 -81
package/lib/core/trash.js
CHANGED
|
@@ -1,227 +1,245 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Plugin recycle bin ("trash") manifest store.
|
|
3
|
-
*
|
|
4
|
-
* One directory per deleted conversation under `<DSH_HOME>/delete-conversation/trash/`:
|
|
5
|
-
*
|
|
6
|
-
* <trashId>/
|
|
7
|
-
* manifest.json the entry below (status, catalog snapshot, memory bundle refs)
|
|
8
|
-
* session/ moved session-log directory (hard mode only)
|
|
9
|
-
* memory/ moved memory files + per-adapter bundle.json
|
|
10
|
-
*
|
|
11
|
-
* The manifest is the recovery contract: restore reads it, the UI lists it, and
|
|
12
|
-
* the audit trail references its `trashId`. A `pending` entry is written before
|
|
13
|
-
* the transaction starts; only a committed cascade flips it to `trashed`, so a
|
|
14
|
-
* crash mid-delete leaves an inspectable but non-restorable record instead of a
|
|
15
|
-
* session silently missing from every list.
|
|
16
|
-
*/
|
|
17
|
-
import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
|
18
|
-
import { join } from 'node:path'
|
|
19
|
-
import { DeleteError } from './errors.js'
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
await
|
|
101
|
-
return
|
|
102
|
-
},
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
async
|
|
213
|
-
await
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Plugin recycle bin ("trash") manifest store.
|
|
3
|
+
*
|
|
4
|
+
* One directory per deleted conversation under `<DSH_HOME>/delete-conversation/trash/`:
|
|
5
|
+
*
|
|
6
|
+
* <trashId>/
|
|
7
|
+
* manifest.json the entry below (status, catalog snapshot, memory bundle refs)
|
|
8
|
+
* session/ moved session-log directory (hard mode only)
|
|
9
|
+
* memory/ moved memory files + per-adapter bundle.json
|
|
10
|
+
*
|
|
11
|
+
* The manifest is the recovery contract: restore reads it, the UI lists it, and
|
|
12
|
+
* the audit trail references its `trashId`. A `pending` entry is written before
|
|
13
|
+
* the transaction starts; only a committed cascade flips it to `trashed`, so a
|
|
14
|
+
* crash mid-delete leaves an inspectable but non-restorable record instead of a
|
|
15
|
+
* session silently missing from every list.
|
|
16
|
+
*/
|
|
17
|
+
import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
import { DeleteError } from './errors.js'
|
|
20
|
+
import { SESSION_ID_PATTERN } from './ids.js'
|
|
21
|
+
|
|
22
|
+
const MANIFEST = 'manifest.json'
|
|
23
|
+
const TRASH_ID_PATTERN = /^[0-9a-z]{2,40}-[0-9a-z]{2,20}$/
|
|
24
|
+
/**
|
|
25
|
+
* Manifest reads in flight during one directory scan. The work is dominated by
|
|
26
|
+
* syscalls on many small files, so a small fixed pool removes the serial
|
|
27
|
+
* round-trip chain without flooding the FS with hundreds of concurrent opens.
|
|
28
|
+
*/
|
|
29
|
+
const SCAN_CONCURRENCY = 8
|
|
30
|
+
|
|
31
|
+
/** Ordered `map` with at most `limit` promises in flight. */
|
|
32
|
+
async function mapLimit(items, limit, fn) {
|
|
33
|
+
const out = new Array(items.length)
|
|
34
|
+
let next = 0
|
|
35
|
+
const workers = Array.from({ length: Math.min(Math.max(1, limit), items.length) }, async () => {
|
|
36
|
+
while (true) {
|
|
37
|
+
const index = next
|
|
38
|
+
next += 1
|
|
39
|
+
if (index >= items.length) return
|
|
40
|
+
out[index] = await fn(items[index], index)
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
await Promise.all(workers)
|
|
44
|
+
return out
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
/** Statuses a manifest may carry. `pending` and `failed` are never listed. */
|
|
49
|
+
export const TRASH_STATUS = Object.freeze(['pending', 'trashed', 'failed', 'restoring', 'restored', 'purging'])
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `sessionId` may be absent (a memory-only entry) but must never be present and
|
|
53
|
+
* malformed: it ends up in a `join(workspaceRoot, sessionId, 'session.jsonl')`.
|
|
54
|
+
*/
|
|
55
|
+
function isSessionIdField(value) {
|
|
56
|
+
return value === undefined || value === null || (typeof value === 'string' && SESSION_ID_PATTERN.test(value))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function isTrashId(value) {
|
|
60
|
+
return typeof value === 'string' && TRASH_ID_PATTERN.test(value)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {object} options
|
|
65
|
+
* @param {string} options.rootDir absolute trash root
|
|
66
|
+
* @param {() => number} [options.now]
|
|
67
|
+
* @param {() => string} [options.newId] injectable id factory for tests
|
|
68
|
+
* @param {object} [options.fsApi]
|
|
69
|
+
*/
|
|
70
|
+
export function createTrashStore({ rootDir, now = Date.now, newId, fsApi = { mkdir, readdir, readFile, rename, rm, writeFile } }) {
|
|
71
|
+
const idFactory = newId ?? createIdFactory(now)
|
|
72
|
+
|
|
73
|
+
async function ensureRoot() {
|
|
74
|
+
await fsApi.mkdir(rootDir, { recursive: true })
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function entryDir(trashId) {
|
|
78
|
+
if (!isTrashId(trashId)) {
|
|
79
|
+
// A plain Error here escaped as `E_INTERNAL 500` and, worse, was
|
|
80
|
+
// distinguishable from `E_TRASH_NOT_FOUND 404`, which made malformed ids
|
|
81
|
+
// an existence probe. Keep it a typed, user-facing refusal.
|
|
82
|
+
throw new DeleteError('E_BAD_REQUEST', 'The recycle-bin id is malformed.', {})
|
|
83
|
+
}
|
|
84
|
+
return join(rootDir, trashId)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function writeManifestFile(dir, entry) {
|
|
88
|
+
const temporary = join(dir, `${MANIFEST}.tmp`)
|
|
89
|
+
await fsApi.writeFile(temporary, `${JSON.stringify(entry, null, 2)}\n`, 'utf8')
|
|
90
|
+
await fsApi.rename(temporary, join(dir, MANIFEST))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
rootDir,
|
|
95
|
+
|
|
96
|
+
async allocate({ sessionId }) {
|
|
97
|
+
await ensureRoot()
|
|
98
|
+
const trashId = idFactory(sessionId)
|
|
99
|
+
const dir = entryDir(trashId)
|
|
100
|
+
await fsApi.mkdir(join(dir, 'memory'), { recursive: true })
|
|
101
|
+
return { trashId, dir }
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
entryDir,
|
|
105
|
+
|
|
106
|
+
async write(entry) {
|
|
107
|
+
const dir = entryDir(entry.trashId)
|
|
108
|
+
await fsApi.mkdir(dir, { recursive: true })
|
|
109
|
+
await writeManifestFile(dir, entry)
|
|
110
|
+
return entry
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
async update(trashId, patch) {
|
|
114
|
+
const entry = await this.read(trashId)
|
|
115
|
+
// A missing entry used to be spread into `{...null}` and written back as a
|
|
116
|
+
// fresh manifest with `trashId: null`: a directory nobody could ever list,
|
|
117
|
+
// restore or discard. Callers treat `null` as "already gone".
|
|
118
|
+
if (entry === null) return null
|
|
119
|
+
const next = { ...entry, ...patch, updatedAt: now() }
|
|
120
|
+
await writeManifestFile(entryDir(trashId), next)
|
|
121
|
+
return next
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
async read(trashId) {
|
|
125
|
+
if (!isTrashId(trashId)) return null
|
|
126
|
+
const dir = entryDir(trashId)
|
|
127
|
+
let raw
|
|
128
|
+
try {
|
|
129
|
+
raw = await fsApi.readFile(join(dir, MANIFEST), 'utf8')
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (error?.code === 'ENOENT') return null
|
|
132
|
+
throw error
|
|
133
|
+
}
|
|
134
|
+
let parsed
|
|
135
|
+
try {
|
|
136
|
+
parsed = JSON.parse(raw)
|
|
137
|
+
} catch {
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
if (parsed === null || typeof parsed !== 'object' || parsed.format !== 1) return null
|
|
141
|
+
if (!TRASH_STATUS.includes(parsed.status)) return null
|
|
142
|
+
// `sessionId` is data, not truth: restore joins it into a workspace path,
|
|
143
|
+
// so a hand-edited manifest must not be able to smuggle `..` through the
|
|
144
|
+
// entry at all. A manifest that fails this is unreadable, not listable.
|
|
145
|
+
if (!isSessionIdField(parsed.sessionId)) return null
|
|
146
|
+
return parsed
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* One directory pass over the recycle bin.
|
|
151
|
+
*
|
|
152
|
+
* `listAll()` and `scanUnreadable()` used to be called together by `/trash`
|
|
153
|
+
* and each did its own `readdir` plus one `readFile` per entry, so every
|
|
154
|
+
* listing cost 2N manifest reads (measured at ~0.31 ms per entry, 156 ms at
|
|
155
|
+
* 500 entries). They now share this single pass: one `readdir`, one read per
|
|
156
|
+
* entry, bounded concurrency instead of a serial `for await`.
|
|
157
|
+
*
|
|
158
|
+
* A missing or unreadable root yields empty lists rather than throwing: the
|
|
159
|
+
* settings page must still render on a fresh install with no trash yet.
|
|
160
|
+
* @returns {Promise<{ entries: object[], unreadable: Array<{ trashId: string, reason: 'missing-manifest'|'corrupt-manifest'|'unreadable-manifest' }> }>}
|
|
161
|
+
*/
|
|
162
|
+
async scanAll() {
|
|
163
|
+
await ensureRoot()
|
|
164
|
+
let dirents = []
|
|
165
|
+
try {
|
|
166
|
+
dirents = await fsApi.readdir(rootDir, { withFileTypes: true })
|
|
167
|
+
} catch {
|
|
168
|
+
return { entries: [], unreadable: [] }
|
|
169
|
+
}
|
|
170
|
+
const names = []
|
|
171
|
+
for (const dirent of dirents) {
|
|
172
|
+
if (dirent.isDirectory() && isTrashId(dirent.name)) names.push(dirent.name)
|
|
173
|
+
}
|
|
174
|
+
const scanned = await mapLimit(names, SCAN_CONCURRENCY, async (trashId) => {
|
|
175
|
+
let raw
|
|
176
|
+
try {
|
|
177
|
+
raw = await fsApi.readFile(join(rootDir, trashId, MANIFEST), 'utf8')
|
|
178
|
+
} catch (error) {
|
|
179
|
+
return { trashId, reason: error?.code === 'ENOENT' ? 'missing-manifest' : 'unreadable-manifest' }
|
|
180
|
+
}
|
|
181
|
+
let parsed
|
|
182
|
+
try {
|
|
183
|
+
parsed = JSON.parse(raw)
|
|
184
|
+
} catch {
|
|
185
|
+
return { trashId, reason: 'corrupt-manifest' }
|
|
186
|
+
}
|
|
187
|
+
if (parsed === null || typeof parsed !== 'object' || parsed.format !== 1 || !TRASH_STATUS.includes(parsed.status)
|
|
188
|
+
|| !isSessionIdField(parsed.sessionId)) {
|
|
189
|
+
return { trashId, reason: 'corrupt-manifest' }
|
|
190
|
+
}
|
|
191
|
+
return { entry: parsed }
|
|
192
|
+
})
|
|
193
|
+
const entries = []
|
|
194
|
+
const unreadable = []
|
|
195
|
+
for (const item of scanned) {
|
|
196
|
+
if (item.entry !== undefined) entries.push(item.entry)
|
|
197
|
+
else unreadable.push({ trashId: item.trashId, reason: item.reason })
|
|
198
|
+
}
|
|
199
|
+
// Newest first. `Array.prototype.sort` is stable, so equal timestamps keep
|
|
200
|
+
// the directory order the scan produced.
|
|
201
|
+
entries.sort((left, right) => (right.deletedAt ?? 0) - (left.deletedAt ?? 0))
|
|
202
|
+
return { entries, unreadable }
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
/** Every readable manifest, newest first, regardless of status. */
|
|
206
|
+
async listAll() {
|
|
207
|
+
const { entries } = await this.scanAll()
|
|
208
|
+
return entries
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
/** Newest first. Only committed entries are returned. */
|
|
212
|
+
async list() {
|
|
213
|
+
const entries = await this.listAll()
|
|
214
|
+
return entries.filter((entry) => entry.status === 'trashed')
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Trash directories whose manifest cannot be read at all: missing
|
|
219
|
+
* (crashed between allocate and write), corrupt, or unparseable. These
|
|
220
|
+
* are invisible to restore/purge but must still be listable and
|
|
221
|
+
* discardable, otherwise a single bad manifest strands the directory on
|
|
222
|
+
* disk forever.
|
|
223
|
+
* @returns {Promise<Array<{ trashId: string, reason: 'missing-manifest'|'corrupt-manifest'|'unreadable-manifest' }>>}
|
|
224
|
+
*/
|
|
225
|
+
async scanUnreadable() {
|
|
226
|
+
const { unreadable } = await this.scanAll()
|
|
227
|
+
return unreadable
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
async remove(trashId) {
|
|
231
|
+
await fsApi.rm(entryDir(trashId), { recursive: true, force: true })
|
|
232
|
+
},
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function createIdFactory(now) {
|
|
237
|
+
let counter = 0
|
|
238
|
+
return (sessionId) => {
|
|
239
|
+
counter = (counter + 1) % 1296
|
|
240
|
+
const stamp = now().toString(36)
|
|
241
|
+
const sessionPart = String(sessionId ?? '').replace(/[^a-zA-Z0-9]/g, '').slice(0, 10).toLowerCase() || 'session'
|
|
242
|
+
const salt = `${counter.toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
|
243
|
+
return `${stamp}-${salt}${sessionPart.slice(0, 4)}`
|
|
244
|
+
}
|
|
245
|
+
}
|