dsh-conversation-recycle-bin 0.2.1 → 0.2.2
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/AUDIT-2026-10-17-desktop-web.md +333 -0
- package/CHANGELOG.md +51 -0
- package/INSTALL.md +2 -1
- package/README.md +16 -12
- package/client.js +197 -26
- package/config.schema.json +1 -1
- package/lib/core/cascade.js +53 -19
- package/lib/core/config.js +4 -1
- package/lib/core/errors.js +5 -2
- package/lib/core/file-lock.js +151 -0
- package/lib/core/memory/generic-json.js +2 -2
- package/lib/core/memory/json-index-adapter.js +182 -87
- package/lib/core/memory/lingshu.js +10 -4
- package/lib/core/memory/none.js +4 -2
- package/lib/core/memory/registry.js +9 -6
- package/lib/core/policy.js +4 -4
- package/lib/core/redact.js +66 -0
- package/lib/host/api.js +252 -55
- package/lib/host/catalog.js +101 -36
- package/lib/host/gateway.js +19 -5
- package/lib/host/paths.js +13 -2
- package/lib/version.js +1 -1
- package/package.json +2 -3
- package/locale/en.json +0 -63
- package/locale/zh.json +0 -63
|
@@ -9,21 +9,41 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Deletion is a MOVE, never an unlink: node content files and full index
|
|
11
11
|
* entries are copied into the recycle-bin bundle, the live index is rewritten
|
|
12
|
-
* atomically, and
|
|
13
|
-
*
|
|
14
|
-
* error leaves this module, so the caller's transaction sees an all-or-nothing
|
|
15
|
-
* memory stage.
|
|
12
|
+
* atomically, and a plugin-private lock is held for the whole
|
|
13
|
+
* read-modify-write.
|
|
16
14
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
15
|
+
* ## Why the lock is plugin-private
|
|
16
|
+
*
|
|
17
|
+
* The store's own `<index>.lock` belongs to md_cg and is a permanent zero-byte
|
|
18
|
+
* file guarded by an OS byte-range lock (`msvcrt.locking` on Windows, `flock`
|
|
19
|
+
* on POSIX). Its existence is NOT a lock, and removing it silently breaks the
|
|
20
|
+
* store's own mutual exclusion. This adapter therefore never opens, writes or
|
|
21
|
+
* deletes that file; it uses `<index>.conversation-recycle-bin.lock`, which
|
|
22
|
+
* only coordinates this plugin's Host processes. The store lock is left to
|
|
23
|
+
* md_cg.
|
|
24
|
+
*
|
|
25
|
+
* ## Index generations
|
|
26
|
+
*
|
|
27
|
+
* md_cg persists an index as a snapshot (`_index.json`) plus append-only
|
|
28
|
+
* shards under `_index_log/`, replaying the shards over the snapshot on every
|
|
29
|
+
* load. When `indexLogDir` is configured (the 灵枢 preset), this adapter
|
|
30
|
+
* appends its own records to a fresh shard: tombstones (`e: null`) for removed
|
|
31
|
+
* nodes and the updated records of peers whose references were stripped. That
|
|
32
|
+
* keeps a later snapshot+log replay from resurrecting a deleted node. The
|
|
33
|
+
* adapter still writes the snapshot itself so its own reads (counts, preview)
|
|
34
|
+
* are immediately correct.
|
|
35
|
+
*
|
|
36
|
+
* Limitations are explicit: a md_cg process that already holds the node in its
|
|
37
|
+
* unflushed `_dirty` set can still re-expose it until that process reloads; and
|
|
38
|
+
* a node updated by another writer after our tombstone wins. Callers must treat
|
|
39
|
+
* this as a best-effort offline adapter, not as md_cg's own `forget` protocol.
|
|
22
40
|
*/
|
|
23
41
|
import * as defaultFs from 'node:fs/promises'
|
|
42
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
24
43
|
import { dirname, isAbsolute, join, resolve, sep } from 'node:path'
|
|
25
44
|
import { resolveJsonPointer } from '../config.js'
|
|
26
45
|
import { DeleteError } from '../errors.js'
|
|
46
|
+
import { createFileLock } from '../file-lock.js'
|
|
27
47
|
|
|
28
48
|
const REFERENCE_FIELDS = ['edges', 'depends_on', 'derived_from']
|
|
29
49
|
const REFERENCE_KEYS = ['id', 'target', 'to', 'nodeId', 'node', 'targetId', 'target_id', 'ref']
|
|
@@ -56,6 +76,8 @@ function validateRelativeContentPath(rootDir, relative) {
|
|
|
56
76
|
return target
|
|
57
77
|
}
|
|
58
78
|
|
|
79
|
+
const sleepDefault = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
80
|
+
|
|
59
81
|
/**
|
|
60
82
|
* @param {object} options
|
|
61
83
|
* @param {string} options.id stable adapter id
|
|
@@ -65,8 +87,11 @@ function validateRelativeContentPath(rootDir, relative) {
|
|
|
65
87
|
* @param {string} [options.sessionField]
|
|
66
88
|
* @param {string} [options.nodesPointer]
|
|
67
89
|
* @param {string|null} [options.contentField]
|
|
68
|
-
* @param {
|
|
90
|
+
* @param {string|null} [options.indexLogDir] md_cg `_index_log` directory; null = no shard records
|
|
69
91
|
* @param {(index: any) => string | null} [options.validateIndex] return an error string to mark the store unsupported
|
|
92
|
+
* @param {number} [options.lockTimeoutMs]
|
|
93
|
+
* @param {number} [options.staleLockMs]
|
|
94
|
+
* @param {number} [options.lockHeartbeatMs]
|
|
70
95
|
* @param {() => number} [options.now]
|
|
71
96
|
* @param {object} [options.fsApi]
|
|
72
97
|
*/
|
|
@@ -79,22 +104,40 @@ export function createJsonIndexMemoryAdapter(options) {
|
|
|
79
104
|
sessionField = 'session',
|
|
80
105
|
nodesPointer = '/nodes',
|
|
81
106
|
contentField = 'path',
|
|
82
|
-
|
|
107
|
+
indexLogDir = null,
|
|
83
108
|
validateIndex = null,
|
|
84
|
-
|
|
85
|
-
lockTimeoutMs = 6_000,
|
|
109
|
+
lockTimeoutMs = 15_000,
|
|
86
110
|
staleLockMs = 120_000,
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
ownerlessLockStaleMs = 5_000,
|
|
111
|
+
lockHeartbeatMs = 5_000,
|
|
112
|
+
now = Date.now,
|
|
113
|
+
sleep = sleepDefault,
|
|
91
114
|
fsApi = defaultFs,
|
|
92
115
|
} = options
|
|
93
116
|
|
|
94
|
-
|
|
117
|
+
// Deliberately NOT `<indexFile>.lock`: that file belongs to md_cg.
|
|
118
|
+
const lockPath = `${indexFile}.conversation-recycle-bin.lock`
|
|
95
119
|
const bundleFileName = 'bundle.json'
|
|
120
|
+
const lock = createFileLock({
|
|
121
|
+
lockPath,
|
|
122
|
+
timeoutMs: lockTimeoutMs,
|
|
123
|
+
staleMs: staleLockMs,
|
|
124
|
+
heartbeatMs: lockHeartbeatMs,
|
|
125
|
+
now,
|
|
126
|
+
sleep,
|
|
127
|
+
fsApi,
|
|
128
|
+
})
|
|
96
129
|
|
|
97
|
-
|
|
130
|
+
function withLock(operation) {
|
|
131
|
+
return lock.acquire().then(async (release) => {
|
|
132
|
+
try {
|
|
133
|
+
return await operation()
|
|
134
|
+
} finally {
|
|
135
|
+
await release()
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function readSnapshot() {
|
|
98
141
|
const raw = await fsApi.readFile(indexFile, 'utf8')
|
|
99
142
|
let index
|
|
100
143
|
try {
|
|
@@ -115,7 +158,12 @@ export function createJsonIndexMemoryAdapter(options) {
|
|
|
115
158
|
if (nodes === null || typeof nodes !== 'object') {
|
|
116
159
|
throw new DeleteError('E_MEMORY_FAILED', `Memory index has no node map at ${nodesPointer}`, { indexFile, nodesPointer })
|
|
117
160
|
}
|
|
118
|
-
return { index, nodes }
|
|
161
|
+
return { index, nodes, sig: createHash('sha256').update(raw).digest('hex') }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function readIndex() {
|
|
165
|
+
const snapshot = await readSnapshot()
|
|
166
|
+
return { index: snapshot.index, nodes: snapshot.nodes }
|
|
119
167
|
}
|
|
120
168
|
|
|
121
169
|
async function writeIndexAtomic(index) {
|
|
@@ -124,65 +172,62 @@ export function createJsonIndexMemoryAdapter(options) {
|
|
|
124
172
|
await fsApi.rename(temporary, indexFile)
|
|
125
173
|
}
|
|
126
174
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
let handle = null
|
|
131
|
-
// A zero-byte lock is the crashed-writer signal, and the grace that proves
|
|
132
|
-
// it must fit INSIDE the wait budget. A content-bearing lock keeps the
|
|
133
|
-
// caller's (shorter) timeout, because it is a live writer, not a corpse.
|
|
134
|
-
let waitBudgetMs = lockTimeoutMs
|
|
135
|
-
while (handle === null) {
|
|
136
|
-
try {
|
|
137
|
-
handle = await fsApi.open(lockFile, 'wx')
|
|
138
|
-
} catch (error) {
|
|
139
|
-
if (error?.code !== 'EEXIST') throw error
|
|
140
|
-
let stale = false
|
|
141
|
-
try {
|
|
142
|
-
const info = await fsApi.stat(lockFile)
|
|
143
|
-
stale = now() - info.mtimeMs > staleLockMs
|
|
144
|
-
if (info.size === 0) {
|
|
145
|
-
waitBudgetMs = Math.max(waitBudgetMs, ownerlessLockStaleMs + 500)
|
|
146
|
-
if (!stale && now() - info.mtimeMs > ownerlessLockStaleMs) stale = true
|
|
147
|
-
}
|
|
148
|
-
} catch {
|
|
149
|
-
stale = false
|
|
150
|
-
}
|
|
151
|
-
if (stale) {
|
|
152
|
-
await fsApi.rm(lockFile, { force: true }).catch(() => {})
|
|
153
|
-
continue
|
|
154
|
-
}
|
|
155
|
-
if (now() - started > waitBudgetMs) {
|
|
156
|
-
throw new DeleteError('E_MEMORY_FAILED', 'The memory store is busy; try again later.', { lockFile })
|
|
157
|
-
}
|
|
158
|
-
await new Promise((resolvePromise) => setTimeout(resolvePromise, 25))
|
|
159
|
-
continue
|
|
160
|
-
}
|
|
161
|
-
// The file now exists. A failed owner write must not leave a lock this
|
|
162
|
-
// process will never release (the exact zero-byte lock that blocked every
|
|
163
|
-
// later delete until it aged out).
|
|
164
|
-
try {
|
|
165
|
-
await handle.writeFile(`${process.pid}\n${now()}\n`, 'utf8')
|
|
166
|
-
} catch (error) {
|
|
167
|
-
try {
|
|
168
|
-
await handle.close()
|
|
169
|
-
} catch {
|
|
170
|
-
// already broken; the lock file is removed below either way
|
|
171
|
-
}
|
|
172
|
-
await fsApi.rm(lockFile, { force: true }).catch(() => {})
|
|
173
|
-
throw error
|
|
174
|
-
}
|
|
175
|
-
}
|
|
175
|
+
async function newestLogMtimeMs() {
|
|
176
|
+
if (indexLogDir === null) return 0
|
|
177
|
+
let entries
|
|
176
178
|
try {
|
|
177
|
-
|
|
178
|
-
}
|
|
179
|
+
entries = await fsApi.readdir(indexLogDir, { withFileTypes: true })
|
|
180
|
+
} catch {
|
|
181
|
+
return 0
|
|
182
|
+
}
|
|
183
|
+
let newest = 0
|
|
184
|
+
for (const entry of entries) {
|
|
185
|
+
if (!entry.isFile() || !entry.name.endsWith('.log')) continue
|
|
179
186
|
try {
|
|
180
|
-
await
|
|
187
|
+
const info = await fsApi.stat(join(indexLogDir, entry.name))
|
|
188
|
+
if (info.mtimeMs > newest) newest = info.mtimeMs
|
|
181
189
|
} catch {
|
|
182
|
-
//
|
|
190
|
+
// A shard cleared by a concurrent compact is simply absent.
|
|
183
191
|
}
|
|
184
|
-
await fsApi.rm(lockFile, { force: true }).catch(() => {})
|
|
185
192
|
}
|
|
193
|
+
return newest
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Append records to a fresh md_cg shard.
|
|
198
|
+
*
|
|
199
|
+
* The shard format is the one `md_cg.fsutil.ShardedLog` writes:
|
|
200
|
+
* `{ id, e, _t, _s }` per line. `e: null` is a tombstone; a dict is an
|
|
201
|
+
* upsert. A fresh shard avoids sharing a write point with md_cg, and `_t`
|
|
202
|
+
* starts after the newest existing shard so the records replay last.
|
|
203
|
+
*
|
|
204
|
+
* @param {Array<{ id: string, e: object | null }>} records
|
|
205
|
+
* @returns {Promise<string|null>} shard path, or null when disabled/empty
|
|
206
|
+
*/
|
|
207
|
+
async function appendIndexLog(records) {
|
|
208
|
+
if (indexLogDir === null || records.length === 0) return null
|
|
209
|
+
await fsApi.mkdir(indexLogDir, { recursive: true })
|
|
210
|
+
const newest = await newestLogMtimeMs()
|
|
211
|
+
const base = Math.max(now() / 1000, newest / 1000 + 0.001)
|
|
212
|
+
const shardPath = join(indexLogDir, `${process.pid}-${randomBytes(4).toString('hex')}.log`)
|
|
213
|
+
const lines = records.map((record, index) => JSON.stringify({
|
|
214
|
+
id: record.id,
|
|
215
|
+
e: record.e,
|
|
216
|
+
_t: base + index / 1_000_000,
|
|
217
|
+
_s: index + 1,
|
|
218
|
+
}))
|
|
219
|
+
try {
|
|
220
|
+
await fsApi.writeFile(shardPath, `${lines.join('\n')}\n`, 'utf8')
|
|
221
|
+
return shardPath
|
|
222
|
+
} catch (error) {
|
|
223
|
+
await fsApi.rm(shardPath, { force: true }).catch(() => {})
|
|
224
|
+
throw error
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function removeQuietly(target) {
|
|
229
|
+
if (target === null || target === undefined) return
|
|
230
|
+
await fsApi.rm(target, { recursive: true, force: true }).catch(() => {})
|
|
186
231
|
}
|
|
187
232
|
|
|
188
233
|
return {
|
|
@@ -190,6 +235,7 @@ export function createJsonIndexMemoryAdapter(options) {
|
|
|
190
235
|
label,
|
|
191
236
|
indexFile,
|
|
192
237
|
rootDir,
|
|
238
|
+
lockPath,
|
|
193
239
|
|
|
194
240
|
async probe() {
|
|
195
241
|
try {
|
|
@@ -213,7 +259,7 @@ export function createJsonIndexMemoryAdapter(options) {
|
|
|
213
259
|
const { nodes } = await readIndex()
|
|
214
260
|
const wanted = new Set(sessionIds)
|
|
215
261
|
const counts = new Map()
|
|
216
|
-
for (const
|
|
262
|
+
for (const wantedId of wanted) counts.set(wantedId, 0)
|
|
217
263
|
let protectedCount = 0
|
|
218
264
|
for (const node of Object.values(nodes)) {
|
|
219
265
|
if (node === null || typeof node !== 'object') continue
|
|
@@ -245,20 +291,27 @@ export function createJsonIndexMemoryAdapter(options) {
|
|
|
245
291
|
links: [],
|
|
246
292
|
})
|
|
247
293
|
|
|
248
|
-
// Fast path: a conversation with nothing to move must not
|
|
249
|
-
|
|
250
|
-
// used to make "delete this conversation" fail with "memory store is
|
|
251
|
-
// busy" even though the dialog showed zero linked memories.
|
|
252
|
-
const before = await readIndex()
|
|
294
|
+
// Fast path: a conversation with nothing to move must not take the lock.
|
|
295
|
+
const before = await readSnapshot()
|
|
253
296
|
const hasLinked = Object.values(before.nodes).some(
|
|
254
297
|
(node) => node !== null && typeof node === 'object' && node[sessionField] === sessionId && !isProtected(node),
|
|
255
298
|
)
|
|
256
299
|
if (!hasLinked) return emptyBundle()
|
|
257
300
|
|
|
258
301
|
return withLock(async () => {
|
|
259
|
-
const
|
|
260
|
-
//
|
|
261
|
-
//
|
|
302
|
+
const current = await readSnapshot()
|
|
303
|
+
// Another writer changed the snapshot while we waited for the lock.
|
|
304
|
+
// Recomputing against `current` would silently miss a node added after
|
|
305
|
+
// `before`; refusing is the only safe answer.
|
|
306
|
+
if (current.sig !== before.sig) {
|
|
307
|
+
throw new DeleteError('E_MEMORY_FAILED', 'The memory index changed while waiting; retry the deletion.', {
|
|
308
|
+
indexFile,
|
|
309
|
+
retryable: true,
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
const { index, nodes } = current
|
|
313
|
+
// Snapshot before ANY mutation: compensation must put the original
|
|
314
|
+
// index back byte-for-byte in meaning (key order preserved).
|
|
262
315
|
const pristine = structuredClone(index)
|
|
263
316
|
const bundleDir = join(targetDir, 'memory', id)
|
|
264
317
|
const filesDir = join(bundleDir, 'files')
|
|
@@ -274,6 +327,7 @@ export function createJsonIndexMemoryAdapter(options) {
|
|
|
274
327
|
|
|
275
328
|
const movedFiles = []
|
|
276
329
|
let indexWritten = false
|
|
330
|
+
let logShard = null
|
|
277
331
|
try {
|
|
278
332
|
await fsApi.mkdir(filesDir, { recursive: true })
|
|
279
333
|
for (const nodeId of ids) {
|
|
@@ -321,15 +375,27 @@ export function createJsonIndexMemoryAdapter(options) {
|
|
|
321
375
|
bundle.bundlePath = bundleFile
|
|
322
376
|
// Persist the restore bundle BEFORE removing the entries from the
|
|
323
377
|
// live index: a crash in between leaves a recoverable bundle plus an
|
|
324
|
-
// index that still lists its nodes, which is inspectable.
|
|
325
|
-
// order could drop entries whose only copy was the rewritten index.
|
|
378
|
+
// index that still lists its nodes, which is inspectable.
|
|
326
379
|
await fsApi.writeFile(bundleFile, `${JSON.stringify(bundle, null, 2)}\n`, 'utf8')
|
|
327
380
|
await writeIndexAtomic(index)
|
|
328
381
|
indexWritten = true
|
|
382
|
+
|
|
383
|
+
// Snapshot taken. Now make the deletion survive a md_cg
|
|
384
|
+
// snapshot+log replay: tombstones for the removed nodes and the
|
|
385
|
+
// updated records of the peers whose references we stripped.
|
|
386
|
+
const records = [...ids].map((nodeId) => ({ id: nodeId, e: null }))
|
|
387
|
+
const seenPeers = new Set()
|
|
388
|
+
for (const link of bundle.links) {
|
|
389
|
+
if (seenPeers.has(link.nodeId) || nodes[link.nodeId] === undefined) continue
|
|
390
|
+
seenPeers.add(link.nodeId)
|
|
391
|
+
records.push({ id: link.nodeId, e: nodes[link.nodeId] })
|
|
392
|
+
}
|
|
393
|
+
logShard = await appendIndexLog(records)
|
|
329
394
|
return bundle
|
|
330
395
|
} catch (error) {
|
|
331
|
-
//
|
|
396
|
+
// Compensation inside the memory stage: the caller must see either a
|
|
332
397
|
// fully moved session or an untouched store.
|
|
398
|
+
await removeQuietly(logShard)
|
|
333
399
|
if (indexWritten) {
|
|
334
400
|
await writeIndexAtomic(pristine).catch(() => {})
|
|
335
401
|
}
|
|
@@ -337,6 +403,10 @@ export function createJsonIndexMemoryAdapter(options) {
|
|
|
337
403
|
await fsApi.mkdir(dirname(moved.from), { recursive: true }).catch(() => {})
|
|
338
404
|
await fsApi.rename(moved.to, moved.from).catch(() => {})
|
|
339
405
|
}
|
|
406
|
+
// A bundle written before the failure is not referenced by any
|
|
407
|
+
// manifest; leaving it behind made the failed entry impossible to
|
|
408
|
+
// discard (the discard guard sees files the manifest does not know).
|
|
409
|
+
await removeQuietly(bundleDir)
|
|
340
410
|
throw error instanceof DeleteError
|
|
341
411
|
? error
|
|
342
412
|
: new DeleteError('E_MEMORY_FAILED', 'Failed to move memory entries to the recycle bin.', {
|
|
@@ -352,7 +422,9 @@ export function createJsonIndexMemoryAdapter(options) {
|
|
|
352
422
|
async restoreBundle(bundle) {
|
|
353
423
|
if (bundle === null || typeof bundle !== 'object' || !Array.isArray(bundle.entries) || bundle.entries.length === 0) return
|
|
354
424
|
await withLock(async () => {
|
|
355
|
-
const
|
|
425
|
+
const current = await readSnapshot()
|
|
426
|
+
const { index, nodes } = current
|
|
427
|
+
const pristine = structuredClone(index)
|
|
356
428
|
const alreadyRestored = new Set()
|
|
357
429
|
for (const entry of bundle.entries) {
|
|
358
430
|
const destination = entry.storedFile !== null && entry.originalPath !== null && rootDir !== null
|
|
@@ -383,6 +455,8 @@ export function createJsonIndexMemoryAdapter(options) {
|
|
|
383
455
|
}
|
|
384
456
|
}
|
|
385
457
|
const movedBack = []
|
|
458
|
+
let indexWritten = false
|
|
459
|
+
let logShard = null
|
|
386
460
|
try {
|
|
387
461
|
for (const entry of bundle.entries) {
|
|
388
462
|
if (alreadyRestored.has(entry.nodeId)) continue
|
|
@@ -412,7 +486,28 @@ export function createJsonIndexMemoryAdapter(options) {
|
|
|
412
486
|
}
|
|
413
487
|
}
|
|
414
488
|
await writeIndexAtomic(index)
|
|
489
|
+
indexWritten = true
|
|
490
|
+
|
|
491
|
+
// Mirror the restored records into md_cg's shard log so a later
|
|
492
|
+
// snapshot+log replay keeps them (a stale tombstone would otherwise
|
|
493
|
+
// win). Peers whose links came back are re-recorded too.
|
|
494
|
+
const records = []
|
|
495
|
+
for (const entry of bundle.entries) {
|
|
496
|
+
if (alreadyRestored.has(entry.nodeId)) continue
|
|
497
|
+
records.push({ id: entry.nodeId, e: entry.node })
|
|
498
|
+
}
|
|
499
|
+
const seenPeers = new Set()
|
|
500
|
+
for (const link of bundle.links ?? []) {
|
|
501
|
+
if (seenPeers.has(link.nodeId) || nodes[link.nodeId] === undefined) continue
|
|
502
|
+
seenPeers.add(link.nodeId)
|
|
503
|
+
records.push({ id: link.nodeId, e: nodes[link.nodeId] })
|
|
504
|
+
}
|
|
505
|
+
logShard = await appendIndexLog(records)
|
|
415
506
|
} catch (error) {
|
|
507
|
+
await removeQuietly(logShard)
|
|
508
|
+
if (indexWritten) {
|
|
509
|
+
await writeIndexAtomic(pristine).catch(() => {})
|
|
510
|
+
}
|
|
416
511
|
for (const moved of movedBack.reverse()) {
|
|
417
512
|
await fsApi.rename(moved.from, moved.to).catch(() => {})
|
|
418
513
|
}
|
|
@@ -15,15 +15,21 @@ import { createJsonIndexMemoryAdapter } from './json-index-adapter.js'
|
|
|
15
15
|
export function createLingshuAdapter({ rootDir, now, fsApi }) {
|
|
16
16
|
return createJsonIndexMemoryAdapter({
|
|
17
17
|
id: 'lingshu',
|
|
18
|
-
label:
|
|
18
|
+
// Neutral label: the Client localizes by adapter id, so a hard-coded
|
|
19
|
+
// Chinese/English mix here showed up untranslated in the other language.
|
|
20
|
+
label: 'lingshu (md_cg)',
|
|
19
21
|
indexFile: join(rootDir, '_index.json'),
|
|
20
22
|
rootDir,
|
|
21
23
|
sessionField: 'session',
|
|
22
24
|
nodesPointer: '/nodes',
|
|
23
25
|
contentField: 'path',
|
|
24
|
-
// md_cg
|
|
25
|
-
//
|
|
26
|
-
|
|
26
|
+
// md_cg persists the index as `_index.json` + append-only shards under
|
|
27
|
+
// `_index_log/` and replays the shards on every load. Our tombstones and
|
|
28
|
+
// restored-node records go into a fresh shard so that replay cannot
|
|
29
|
+
// resurrect a deleted node. We deliberately do NOT touch the store's
|
|
30
|
+
// `_index.json.lock`: that is md_cg's permanent OS-lock file, not a
|
|
31
|
+
// presence lock.
|
|
32
|
+
indexLogDir: join(rootDir, '_index_log'),
|
|
27
33
|
validateIndex: (index) => {
|
|
28
34
|
if (index.schema === 2) return null
|
|
29
35
|
return `unsupported md_cg index schema ${String(index.schema)} (expected 2)`
|
package/lib/core/memory/none.js
CHANGED
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
export function createNoneAdapter() {
|
|
10
10
|
return {
|
|
11
11
|
id: 'none',
|
|
12
|
-
label:
|
|
12
|
+
// Neutral label + a stable reason key: the Client localizes both by id, so
|
|
13
|
+
// an English sentence here used to appear untranslated in a Chinese UI.
|
|
14
|
+
label: 'none',
|
|
13
15
|
async probe() {
|
|
14
|
-
return { available: false, reason: '
|
|
16
|
+
return { available: false, reason: 'no-memory-store', storePath: null }
|
|
15
17
|
},
|
|
16
18
|
async countSessions(sessionIds) {
|
|
17
19
|
const counts = new Map()
|
|
@@ -163,6 +163,7 @@ export function createMemoryService({ adapters, config, now = Date.now }) {
|
|
|
163
163
|
/** Restore every adapter bundle recorded in a trash manifest memory summary. */
|
|
164
164
|
async restoreEntry(summary) {
|
|
165
165
|
if (summary === null || typeof summary !== 'object' || !Array.isArray(summary.adapters)) return
|
|
166
|
+
const consumed = []
|
|
166
167
|
for (const entry of [...summary.adapters].reverse()) {
|
|
167
168
|
if (typeof entry.bundlePath !== 'string' || entry.bundlePath === '') continue
|
|
168
169
|
const adapter = byId.get(entry.id)
|
|
@@ -174,12 +175,14 @@ export function createMemoryService({ adapters, config, now = Date.now }) {
|
|
|
174
175
|
}
|
|
175
176
|
const bundle = JSON.parse(await readFile(entry.bundlePath, 'utf8'))
|
|
176
177
|
await adapter.restoreBundle(bundle)
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
178
|
+
consumed.push(entry.bundlePath)
|
|
179
|
+
}
|
|
180
|
+
// Remove the consumed bundles only after EVERY adapter succeeded. An
|
|
181
|
+
// eager per-adapter removal made a later adapter's failure
|
|
182
|
+
// unretryable: the first bundle was already gone while the manifest still
|
|
183
|
+
// referenced it.
|
|
184
|
+
for (const bundlePath of consumed) {
|
|
185
|
+
await rm(dirname(bundlePath), { recursive: true, force: true }).catch(() => {})
|
|
183
186
|
}
|
|
184
187
|
},
|
|
185
188
|
}
|
package/lib/core/policy.js
CHANGED
|
@@ -19,10 +19,10 @@ import { DeleteError } from './errors.js'
|
|
|
19
19
|
export function normalizePathForCompare(input) {
|
|
20
20
|
if (typeof input !== 'string' || input === '') return ''
|
|
21
21
|
let value = input.replace(/\\/g, '/').replace(/\/+$/g, '')
|
|
22
|
-
//
|
|
23
|
-
// POSIX paths stay case-sensitive so
|
|
24
|
-
// the filesystem's own identity rules.
|
|
25
|
-
if (/^[A-Za-z]:\//.test(value)) value = value.toLowerCase()
|
|
22
|
+
// Windows paths compare case-insensitively: drive-lettered paths and UNC
|
|
23
|
+
// roots (`\\server\share`, `\\?\C:\...`). POSIX paths stay case-sensitive so
|
|
24
|
+
// a root check cannot become broader than the filesystem's own identity rules.
|
|
25
|
+
if (/^[A-Za-z]:\//.test(value) || value.startsWith('//')) value = value.toLowerCase()
|
|
26
26
|
return value
|
|
27
27
|
}
|
|
28
28
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redact host filesystem paths from error details before they cross the HTTP
|
|
3
|
+
* boundary.
|
|
4
|
+
*
|
|
5
|
+
* The Client contract is "ids, counts and status — no filesystem handles".
|
|
6
|
+
* Deleting and memory failures legitimately carry diagnostic paths in their
|
|
7
|
+
* `details`/`cause`; those are useful in the Host log, not in the browser.
|
|
8
|
+
*/
|
|
9
|
+
import { basename } from 'node:path'
|
|
10
|
+
|
|
11
|
+
const PATH_KEYS = new Set([
|
|
12
|
+
'indexFile',
|
|
13
|
+
'lockFile',
|
|
14
|
+
'bundlePath',
|
|
15
|
+
'filePath',
|
|
16
|
+
'path',
|
|
17
|
+
'from',
|
|
18
|
+
'to',
|
|
19
|
+
'cwd',
|
|
20
|
+
'trashDir',
|
|
21
|
+
'auditFile',
|
|
22
|
+
'dataDir',
|
|
23
|
+
'rootDir',
|
|
24
|
+
])
|
|
25
|
+
|
|
26
|
+
const WINDOWS_ABSOLUTE = /(?:[A-Za-z]:[\\/]|\\\\)[^\s"'<>|]+/g
|
|
27
|
+
const POSIX_ABSOLUTE = /(^|[\s("'=])\/(?:[^\s"'<>/]+\/)+[^\s"'<>]+/g
|
|
28
|
+
|
|
29
|
+
function shortPath(value) {
|
|
30
|
+
const normalized = String(value).replace(/\\/g, '/').replace(/\/+$/g, '')
|
|
31
|
+
if (normalized === '') return ''
|
|
32
|
+
return basename(normalized)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function redactText(value) {
|
|
36
|
+
if (typeof value !== 'string') return value
|
|
37
|
+
return value
|
|
38
|
+
.replace(WINDOWS_ABSOLUTE, (match) => shortPath(match))
|
|
39
|
+
.replace(POSIX_ABSOLUTE, (match, prefix) => `${prefix}${shortPath(match.slice(prefix.length))}`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Recursively redact path-like values. Unknown keys are preserved so the
|
|
44
|
+
* Client can still branch on structured fields (counts, ids, codes).
|
|
45
|
+
* @param {unknown} value
|
|
46
|
+
* @returns {unknown}
|
|
47
|
+
*/
|
|
48
|
+
export function redactDetails(value) {
|
|
49
|
+
if (value === null || value === undefined) return value
|
|
50
|
+
if (typeof value === 'string') return redactText(value)
|
|
51
|
+
if (Array.isArray(value)) return value.map((item) => (typeof item === 'string' ? redactText(item) : redactDetails(item)))
|
|
52
|
+
if (typeof value !== 'object') return value
|
|
53
|
+
const result = {}
|
|
54
|
+
for (const [key, item] of Object.entries(value)) {
|
|
55
|
+
if (PATH_KEYS.has(key) && typeof item === 'string') {
|
|
56
|
+
result[key] = shortPath(item)
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
if (key === 'cause' || key === 'message') {
|
|
60
|
+
result[key] = typeof item === 'string' ? redactText(item) : redactDetails(item)
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
result[key] = typeof item === 'string' ? item : redactDetails(item)
|
|
64
|
+
}
|
|
65
|
+
return result
|
|
66
|
+
}
|