dsh-memu-memory 1.1.1 → 1.1.3
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/AGENTS.md +8 -5
- package/README.md +22 -5
- package/README.zh.md +21 -4
- package/docs/contributing.md +5 -3
- package/docs/env-and-commands.md +13 -0
- package/docs/host-api.md +35 -3
- package/docs/memory-semantics.md +41 -1
- package/docs/troubleshooting.md +395 -350
- package/lib/activity-log.js +190 -0
- package/lib/backup.js +1103 -0
- package/lib/binding-diagnostics.js +630 -0
- package/lib/client.js +703 -50
- package/lib/curator.js +81 -37
- package/lib/cycle.js +3 -0
- package/lib/embedding-catalog.js +763 -0
- package/lib/embedding-download.js +407 -0
- package/lib/embedding-runtime.js +710 -0
- package/lib/engine.js +2 -2
- package/lib/index.js +61 -4
- package/lib/inject.js +195 -49
- package/lib/local-embedding-entry.js +145 -0
- package/lib/local-embedding-manager.js +249 -0
- package/lib/local-embedding-protocol.js +173 -0
- package/lib/local-embedding-worker.js +652 -0
- package/lib/local-vector-service.js +283 -0
- package/lib/memory.js +385 -45
- package/lib/memu-config.js +10 -22
- package/lib/memu.js +53 -19
- package/lib/paths.js +24 -0
- package/lib/preset-scope.js +121 -0
- package/lib/private.js +139 -13
- package/lib/rpc.js +45 -4
- package/lib/state.js +109 -161
- package/lib/tools.js +9 -5
- package/lib/vector-migration.js +878 -0
- package/lib/vector-profile.js +941 -0
- package/package.json +3 -2
- package/scripts/acceptance-local.mjs +5 -4
- package/scripts/chain-matrix.mjs +527 -0
- package/scripts/check-all.mjs +19 -0
- package/scripts/check-doc-drift.mjs +353 -0
- package/scripts/diagnose-bindings.mjs +176 -0
- package/scripts/local-runtime-install.mjs +142 -0
- package/scripts/local-runtime-verify.mjs +56 -0
- package/scripts/local-vector-verify.mjs +93 -0
- package/scripts/recall-bench.mjs +813 -0
- package/scripts/repetition-ab-scenarios.mjs +93 -0
- package/scripts/repetition-detector-snapshot.mjs +314 -0
- package/scripts/repetition-eval-live.mjs +360 -0
- package/scripts/repetition-eval.mjs +702 -0
package/lib/backup.js
ADDED
|
@@ -0,0 +1,1103 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { spawnSync } from 'node:child_process'
|
|
5
|
+
import {
|
|
6
|
+
backupsDir, dataDir, ensureDir, indexDir, isSafeId, librariesDir,
|
|
7
|
+
readJson, warn, withFileLock, writeJson,
|
|
8
|
+
} from './paths.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 本地向量施工文档 §14.9 - safe latest-three backup retention.
|
|
12
|
+
*
|
|
13
|
+
* One rule, one place. Every managed backup of the current data root is created
|
|
14
|
+
* by createBackup(), published atomically, and only then may the oldest
|
|
15
|
+
* complete backups beyond BACKUP_KEEP be deleted. Create, delete, restore and
|
|
16
|
+
* prune share a single lock, so the safety backup a restore just made can never
|
|
17
|
+
* collect the very directory that restore is reading from.
|
|
18
|
+
*
|
|
19
|
+
* Invariants:
|
|
20
|
+
* 1. A failed or interrupted creation deletes nothing. The backup is built in
|
|
21
|
+
* backups/.staging/<name>.<pid>.<rand>, hashed file by file, given its
|
|
22
|
+
* manifest last (the completion marker) and only then renamed into place.
|
|
23
|
+
* 2. Only directories that prove themselves complete are ever deleted.
|
|
24
|
+
* Corrupt, half-written, foreign, linked or unknown entries are reported
|
|
25
|
+
* and left exactly where they are.
|
|
26
|
+
* 3. Ordering is the verified manifest createdAt plus a unique id - never an
|
|
27
|
+
* mtime that a copy or a clock change can reorder.
|
|
28
|
+
* 4. A lease pins the directory a restore (or migration) is about to read, so
|
|
29
|
+
* prune cannot remove it mid-flight; going briefly over the limit is
|
|
30
|
+
* allowed and is reclaimed as soon as the operation finishes.
|
|
31
|
+
* 5. A recursive delete re-checks realpath containment and refuses links
|
|
32
|
+
* before it removes a single byte.
|
|
33
|
+
* 6. SQLite is snapshotted only by SQLite's own backup API, run by the
|
|
34
|
+
* plugin's isolated engine/<version>/venv interpreter. A live db+wal+shm is
|
|
35
|
+
* never copied by hand, and a global Python is never borrowed: if the
|
|
36
|
+
* managed engine cannot prove a consistent snapshot, the backup fails
|
|
37
|
+
* instead of publishing a copy that would restore as silent corruption.
|
|
38
|
+
* Only a zero-byte placeholder (no page, no WAL frame) may be copied.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
export const BACKUP_KEEP = 3
|
|
42
|
+
export const BACKUP_SCHEMA = 2
|
|
43
|
+
|
|
44
|
+
// Timestamp name of a published backup, optionally suffixed for uniqueness
|
|
45
|
+
// within the same millisecond. Anything else in backups/ is not a managed
|
|
46
|
+
// backup and is never counted or deleted by retention.
|
|
47
|
+
const MANAGED_NAME = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}-[0-9]{2}-[0-9]{2}-[0-9]{3}Z(-[A-Za-z0-9]+)*$/
|
|
48
|
+
const SQLITE_MAGIC = 'SQLite format 3' + String.fromCharCode(0)
|
|
49
|
+
const BACKSLASH = String.fromCharCode(92)
|
|
50
|
+
const NEWLINE = String.fromCharCode(10)
|
|
51
|
+
const STAGING_TTL_MS = 30 * 60 * 1000
|
|
52
|
+
const LEASE_TTL_MS = 15 * 60 * 1000
|
|
53
|
+
const RETRY_MS = 8
|
|
54
|
+
const PYTHON_TIMEOUT_MS = 120000
|
|
55
|
+
const PROBE_TIMEOUT_MS = 20000
|
|
56
|
+
|
|
57
|
+
const stagingRoot = () => path.join(backupsDir(), '.staging')
|
|
58
|
+
const leaseFile = () => path.join(backupsDir(), '.leases.json')
|
|
59
|
+
const lockAnchor = () => path.join(backupsDir(), '.backup')
|
|
60
|
+
const stateFile = () => path.join(dataDir(), 'state.json')
|
|
61
|
+
const globalDbFile = () => path.join(dataDir(), 'global.sqlite3')
|
|
62
|
+
const vectorActiveFile = () => path.join(indexDir(), 'vector-active.json')
|
|
63
|
+
const engineCurrentFile = () => path.join(dataDir(), 'engine', 'current.json')
|
|
64
|
+
|
|
65
|
+
function isoStamp() { return new Date().toISOString() }
|
|
66
|
+
function pause(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) }
|
|
67
|
+
|
|
68
|
+
function statOrNull(file) {
|
|
69
|
+
try { return fs.lstatSync(file) } catch { return null }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readdirSafe(dir, options) {
|
|
73
|
+
try { return fs.readdirSync(dir, options) } catch { return [] }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function realpathOrNull(file) {
|
|
77
|
+
try { return fs.realpathSync.native(file) } catch { return null }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function samePath(a, b) {
|
|
81
|
+
const left = path.resolve(String(a))
|
|
82
|
+
const right = path.resolve(String(b))
|
|
83
|
+
return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readJsonQuiet(file) {
|
|
87
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return null }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function sha256File(file) {
|
|
91
|
+
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function fileSize(file) {
|
|
95
|
+
const info = statOrNull(file)
|
|
96
|
+
return info ? info.size : -1
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function hasSqliteHeader(file) {
|
|
100
|
+
let fd
|
|
101
|
+
try {
|
|
102
|
+
fd = fs.openSync(file, 'r')
|
|
103
|
+
const buffer = Buffer.alloc(SQLITE_MAGIC.length)
|
|
104
|
+
const read = fs.readSync(fd, buffer, 0, buffer.length, 0)
|
|
105
|
+
return read === buffer.length && buffer.toString('latin1') === SQLITE_MAGIC
|
|
106
|
+
} catch {
|
|
107
|
+
return false
|
|
108
|
+
} finally {
|
|
109
|
+
if (fd !== undefined) { try { fs.closeSync(fd) } catch {} }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------- lock & leases
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The one lock that coordinates create/delete/restore/prune. It is reentrant
|
|
117
|
+
* inside this process (withFileLock keeps a held set), so a restore may create
|
|
118
|
+
* its own safety backup and prune while still holding it.
|
|
119
|
+
*/
|
|
120
|
+
export function withBackupLock(action) {
|
|
121
|
+
ensureDir(backupsDir())
|
|
122
|
+
return withFileLock(lockAnchor(), action)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function pidAlive(pid) {
|
|
126
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false
|
|
127
|
+
if (pid === process.pid) return true
|
|
128
|
+
try { process.kill(pid, 0); return true } catch (error) { return error?.code === 'EPERM' }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function readLeases() {
|
|
132
|
+
const raw = readJson(leaseFile(), {})
|
|
133
|
+
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Leases of operations that are still running right now. */
|
|
137
|
+
export function activeLeases() {
|
|
138
|
+
const now = Date.now()
|
|
139
|
+
const live = {}
|
|
140
|
+
for (const [name, lease] of Object.entries(readLeases())) {
|
|
141
|
+
if (!lease || typeof lease !== 'object') continue
|
|
142
|
+
if (now - Number(lease.at || 0) > LEASE_TTL_MS) continue
|
|
143
|
+
if (!pidAlive(Number(lease.pid))) continue
|
|
144
|
+
live[name] = lease
|
|
145
|
+
}
|
|
146
|
+
return live
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function writeLease(name, lease) {
|
|
150
|
+
const leases = readLeases()
|
|
151
|
+
if (lease) leases[name] = lease
|
|
152
|
+
else delete leases[name]
|
|
153
|
+
writeJson(leaseFile(), leases)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ------------------------------------------------------------- sqlite snapshots
|
|
157
|
+
//
|
|
158
|
+
// One rule: a live SQLite database is only ever snapshotted by SQLite itself,
|
|
159
|
+
// through the plugin's own isolated interpreter (engine/<version>/venv). There
|
|
160
|
+
// is no global-Python path and no byte-copy fallback. A copy of a live database
|
|
161
|
+
// - or of a database plus its WAL - is torn the moment a writer commits between
|
|
162
|
+
// the two reads, and a torn copy restores as silent corruption, which is worse
|
|
163
|
+
// than no backup at all. So when the managed engine cannot produce a provable
|
|
164
|
+
// snapshot, the whole backup FAILS and nothing is published.
|
|
165
|
+
//
|
|
166
|
+
// The one file that may be copied verbatim is a zero-byte placeholder: no page
|
|
167
|
+
// was ever written and there are no WAL frames, so there is nothing to tear.
|
|
168
|
+
|
|
169
|
+
function engineRoot() { return path.join(dataDir(), 'engine') }
|
|
170
|
+
|
|
171
|
+
function engineCurrent() {
|
|
172
|
+
const raw = readJsonQuiet(engineCurrentFile())
|
|
173
|
+
if (!raw || typeof raw !== 'object') return null
|
|
174
|
+
return {
|
|
175
|
+
version: raw.version || null,
|
|
176
|
+
schemaVersion: raw.schemaVersion || null,
|
|
177
|
+
python: typeof raw.python === 'string' && raw.python ? raw.python : null,
|
|
178
|
+
venv: typeof raw.venv === 'string' && raw.venv ? raw.venv : null,
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function venvPythonPath(venv) {
|
|
183
|
+
return process.platform === 'win32'
|
|
184
|
+
? path.join(venv, 'Scripts', 'python.exe')
|
|
185
|
+
: path.join(venv, 'bin', 'python3')
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** True when both paths exist and `child` resolves inside `parent`. */
|
|
189
|
+
function insideDir(child, parent) {
|
|
190
|
+
if (typeof child !== 'string' || !child) return false
|
|
191
|
+
const left = realpathOrNull(child)
|
|
192
|
+
const right = realpathOrNull(parent)
|
|
193
|
+
if (!left || !right) return false
|
|
194
|
+
const a = process.platform === 'win32' ? left.toLowerCase() : left
|
|
195
|
+
const b = process.platform === 'win32' ? right.toLowerCase() : right
|
|
196
|
+
return a === b || a.startsWith(b.endsWith(path.sep) ? b : b + path.sep)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Every interpreter the plugin itself installed: whatever `engine/current.json`
|
|
201
|
+
* points at first (that file is only written after the engine was verified
|
|
202
|
+
* against the real databases), then any engine/<version>/venv* directory found
|
|
203
|
+
* on disk, newest first. The on-disk scan is what makes an engine repair
|
|
204
|
+
* possible: while a new engine installs, its venv is still `venv.tmp-*` and
|
|
205
|
+
* current.json still points at the broken one.
|
|
206
|
+
*/
|
|
207
|
+
function engineVenvCandidates() {
|
|
208
|
+
const root = engineRoot()
|
|
209
|
+
const out = []
|
|
210
|
+
const seen = new Set()
|
|
211
|
+
const add = (venv, version, source) => {
|
|
212
|
+
if (typeof venv !== 'string' || !venv) return
|
|
213
|
+
const key = process.platform === 'win32' ? venv.toLowerCase() : venv
|
|
214
|
+
if (seen.has(key)) return
|
|
215
|
+
seen.add(key)
|
|
216
|
+
out.push({ venv, version: version || null, source })
|
|
217
|
+
}
|
|
218
|
+
const current = engineCurrent()
|
|
219
|
+
add(current?.venv, current?.version, 'engine/current.json')
|
|
220
|
+
const mtimeOf = (file) => { const info = statOrNull(file); return info ? info.mtimeMs : 0 }
|
|
221
|
+
const versions = readdirSafe(root, { withFileTypes: true })
|
|
222
|
+
.filter((item) => item.isDirectory())
|
|
223
|
+
.map((item) => ({ name: item.name, at: mtimeOf(path.join(root, item.name)) }))
|
|
224
|
+
.sort((a, b) => b.at - a.at)
|
|
225
|
+
for (const version of versions) {
|
|
226
|
+
const dir = path.join(root, version.name)
|
|
227
|
+
const venvs = readdirSafe(dir, { withFileTypes: true })
|
|
228
|
+
.filter((item) => item.isDirectory() && item.name.indexOf('venv') === 0)
|
|
229
|
+
.map((item) => ({ name: item.name, at: mtimeOf(path.join(dir, item.name)) }))
|
|
230
|
+
.sort((a, b) => b.at - a.at)
|
|
231
|
+
for (const venv of venvs) add(path.join(dir, venv.name), version.name, 'engine/' + version.name + '/' + venv.name)
|
|
232
|
+
}
|
|
233
|
+
return out
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// The probe answers two questions at once: does this interpreter have sqlite3,
|
|
237
|
+
// and is it really the isolated venv (sys.prefix) rather than a shim that hands
|
|
238
|
+
// the work to a global Python.
|
|
239
|
+
const PYTHON_PROBE_SCRIPT = [
|
|
240
|
+
'import json, sys',
|
|
241
|
+
'out = {"ok": False, "error": "unknown"}',
|
|
242
|
+
'try:',
|
|
243
|
+
' import sqlite3',
|
|
244
|
+
' out = {"ok": True, "prefix": sys.prefix, "sqlite": getattr(sqlite3, "sqlite_version", "unknown")}',
|
|
245
|
+
'except Exception as error:',
|
|
246
|
+
' out = {"ok": False, "prefix": sys.prefix, "error": str(error)}',
|
|
247
|
+
'sys.stdout.write(json.dumps(out))',
|
|
248
|
+
].join(NEWLINE)
|
|
249
|
+
|
|
250
|
+
const SQLITE_SNAPSHOT_SCRIPT = [
|
|
251
|
+
'import json, sqlite3, sys',
|
|
252
|
+
'src, dst = sys.argv[1], sys.argv[2]',
|
|
253
|
+
'out = {"ok": False, "error": "unknown"}',
|
|
254
|
+
'try:',
|
|
255
|
+
' source = sqlite3.connect(src, timeout=10)',
|
|
256
|
+
' try:',
|
|
257
|
+
' target = sqlite3.connect(dst)',
|
|
258
|
+
' try:',
|
|
259
|
+
' source.backup(target)',
|
|
260
|
+
' row = target.execute("pragma quick_check").fetchone()',
|
|
261
|
+
' out = {"ok": True, "quickCheck": (row[0] if row else "unknown")}',
|
|
262
|
+
' finally:',
|
|
263
|
+
' target.close()',
|
|
264
|
+
' finally:',
|
|
265
|
+
' source.close()',
|
|
266
|
+
'except Exception as error:',
|
|
267
|
+
' out = {"ok": False, "error": str(error)}',
|
|
268
|
+
'sys.stdout.write(json.dumps(out))',
|
|
269
|
+
].join(NEWLINE)
|
|
270
|
+
|
|
271
|
+
let enginePythonCache = null
|
|
272
|
+
|
|
273
|
+
/** Forget the cached interpreter probe (tests use this between scenarios). */
|
|
274
|
+
export function resetBackupPythonProbe() { enginePythonCache = null }
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Runs an interpreter. `exec` is a seam for tests only (a fake interpreter);
|
|
278
|
+
* production always spawns the verified venv binary and nothing else.
|
|
279
|
+
*/
|
|
280
|
+
function runPython(exec, cmd, args, timeout) {
|
|
281
|
+
const runner = typeof exec === 'function' ? exec : spawnSync
|
|
282
|
+
try {
|
|
283
|
+
return runner(cmd, args, { timeout, windowsHide: true, encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 })
|
|
284
|
+
} catch (error) {
|
|
285
|
+
return { error, status: null, stdout: '', stderr: '' }
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function jsonOutcome(result) {
|
|
290
|
+
const text = String(result?.stdout || '').trim()
|
|
291
|
+
if (!text) return null
|
|
292
|
+
try { return JSON.parse(text) } catch { return null }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Resolves the engine interpreter a snapshot may use, or reports why there is
|
|
297
|
+
* none. Never falls back to a global interpreter: a missing, foreign or broken
|
|
298
|
+
* venv is a refusal, not a downgrade.
|
|
299
|
+
*/
|
|
300
|
+
function enginePythonForBackup(exec) {
|
|
301
|
+
const disabled = process.env.DSH_MEMU_BACKUP_NO_PYTHON === '1'
|
|
302
|
+
const root = engineRoot()
|
|
303
|
+
const candidates = disabled ? [] : engineVenvCandidates()
|
|
304
|
+
const key = JSON.stringify({ disabled, root, candidates })
|
|
305
|
+
if (enginePythonCache && enginePythonCache.key === key) return enginePythonCache.value
|
|
306
|
+
const value = { python: null, rejected: [] }
|
|
307
|
+
for (const candidate of candidates) {
|
|
308
|
+
const python = venvPythonPath(candidate.venv)
|
|
309
|
+
if (!fs.existsSync(python)) {
|
|
310
|
+
value.rejected.push(candidate.source + ': no interpreter on disk')
|
|
311
|
+
continue
|
|
312
|
+
}
|
|
313
|
+
if (!insideDir(candidate.venv, root) || !insideDir(python, root)) {
|
|
314
|
+
value.rejected.push(candidate.source + ': outside ' + root)
|
|
315
|
+
continue
|
|
316
|
+
}
|
|
317
|
+
const probe = runPython(exec, python, ['-c', PYTHON_PROBE_SCRIPT], PROBE_TIMEOUT_MS)
|
|
318
|
+
const outcome = jsonOutcome(probe)
|
|
319
|
+
if (!outcome || outcome.ok !== true) {
|
|
320
|
+
const reason = outcome?.error || probe?.error?.message || (probe?.status != null ? 'exited ' + probe.status : 'no answer')
|
|
321
|
+
value.rejected.push(candidate.source + ': ' + String(reason).slice(0, 200))
|
|
322
|
+
continue
|
|
323
|
+
}
|
|
324
|
+
if (!insideDir(outcome.prefix, root)) {
|
|
325
|
+
value.rejected.push(candidate.source + ': interpreter reports sys.prefix ' + String(outcome.prefix) + ', outside engine/')
|
|
326
|
+
continue
|
|
327
|
+
}
|
|
328
|
+
value.python = {
|
|
329
|
+
cmd: python,
|
|
330
|
+
venv: candidate.venv,
|
|
331
|
+
version: candidate.version,
|
|
332
|
+
source: candidate.source,
|
|
333
|
+
sqlite: outcome.sqlite ? String(outcome.sqlite) : null,
|
|
334
|
+
}
|
|
335
|
+
break
|
|
336
|
+
}
|
|
337
|
+
enginePythonCache = { key, value }
|
|
338
|
+
return value
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function backupRefusal(code, message) {
|
|
342
|
+
const error = new Error(message)
|
|
343
|
+
error.code = code
|
|
344
|
+
return error
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function removeQuietly(file) {
|
|
348
|
+
try { fs.rmSync(file, { force: true }) } catch {}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Asks the engine interpreter to run SQLite's online backup API: it folds any
|
|
353
|
+
* live WAL into one self-contained file and hands back a proven-consistent
|
|
354
|
+
* snapshot. Anything short of a clean `quick_check` is a failure, never a
|
|
355
|
+
* reason to copy the bytes ourselves.
|
|
356
|
+
*/
|
|
357
|
+
function pythonSnapshot(exec, python, from, to) {
|
|
358
|
+
const result = runPython(exec, python.cmd, ['-c', SQLITE_SNAPSHOT_SCRIPT, from, to], PYTHON_TIMEOUT_MS)
|
|
359
|
+
if (result?.error) return { ok: false, error: String(result.error.message || result.error) }
|
|
360
|
+
if (result?.status !== 0) {
|
|
361
|
+
return { ok: false, error: String(result?.stderr || ('engine interpreter exited ' + result?.status)).trim().slice(0, 400) }
|
|
362
|
+
}
|
|
363
|
+
const payload = jsonOutcome(result)
|
|
364
|
+
if (!payload || payload.ok !== true) {
|
|
365
|
+
return { ok: false, error: String(payload?.error || 'sqlite3 backup returned no result').slice(0, 400) }
|
|
366
|
+
}
|
|
367
|
+
if (payload.quickCheck !== 'ok') {
|
|
368
|
+
return { ok: false, error: 'snapshot failed pragma quick_check: ' + String(payload.quickCheck || 'unknown').slice(0, 200) }
|
|
369
|
+
}
|
|
370
|
+
if (!hasSqliteHeader(to) || fileSize(to) <= 0) {
|
|
371
|
+
return { ok: false, error: 'snapshot has no SQLite database header' }
|
|
372
|
+
}
|
|
373
|
+
return { ok: true, quickCheck: 'ok' }
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Produces one consistent copy of a live SQLite database inside the staging
|
|
378
|
+
* directory. Two outcomes only: a snapshot the engine proved consistent, or a
|
|
379
|
+
* thrown refusal that aborts the whole backup.
|
|
380
|
+
*/
|
|
381
|
+
function snapshotDatabase(from, to, exec) {
|
|
382
|
+
const before = statOrNull(from)
|
|
383
|
+
if (!before) return { mode: 'missing' }
|
|
384
|
+
if (before.isSymbolicLink()) throw new Error('backup refuses symbolic links')
|
|
385
|
+
if (!before.isFile()) throw new Error('backup refuses non-file: ' + path.basename(from))
|
|
386
|
+
const name = path.basename(from)
|
|
387
|
+
const walSize = fileSize(from + '-wal')
|
|
388
|
+
const walContent = walSize > 0
|
|
389
|
+
if (before.size === 0 && !walContent) {
|
|
390
|
+
// A placeholder no engine has written a page to: copying it cannot tear.
|
|
391
|
+
fs.copyFileSync(from, to)
|
|
392
|
+
return { mode: 'empty-placeholder', quickCheck: 'empty' }
|
|
393
|
+
}
|
|
394
|
+
if (!hasSqliteHeader(from)) {
|
|
395
|
+
throw backupRefusal('DSH_MEMU_BACKUP_NOT_SQLITE', 'backup refuses ' + name
|
|
396
|
+
+ ': it is neither an empty placeholder nor a SQLite database, so its contents cannot be verified'
|
|
397
|
+
+ (walContent ? ' (it has a non-empty WAL)' : ''))
|
|
398
|
+
}
|
|
399
|
+
const resolved = enginePythonForBackup(exec)
|
|
400
|
+
const python = resolved.python
|
|
401
|
+
if (!python) {
|
|
402
|
+
const why = resolved.rejected.length ? ' (' + resolved.rejected.join('; ') + ')' : ''
|
|
403
|
+
throw backupRefusal('DSH_MEMU_BACKUP_ENGINE_REQUIRED', 'backup of ' + name
|
|
404
|
+
+ " needs the plugin engine: no verified engine/<version>/venv Python with sqlite3 was found under "
|
|
405
|
+
+ engineRoot() + why + '. Install or repair the engine, then retry. '
|
|
406
|
+
+ 'Nothing was copied: a live database copied by hand can restore as silent corruption.')
|
|
407
|
+
}
|
|
408
|
+
const snapshot = pythonSnapshot(exec, python, from, to)
|
|
409
|
+
if (!snapshot.ok) {
|
|
410
|
+
removeQuietly(to)
|
|
411
|
+
removeQuietly(to + '-wal')
|
|
412
|
+
removeQuietly(to + '-shm')
|
|
413
|
+
throw backupRefusal('DSH_MEMU_BACKUP_SNAPSHOT_FAILED', 'backup could not snapshot ' + name + ' with '
|
|
414
|
+
+ python.source + ' (engine ' + String(python.version || 'unknown') + '): ' + snapshot.error)
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
mode: 'sqlite-backup-api',
|
|
418
|
+
engine: python.version ? String(python.version) : null,
|
|
419
|
+
via: python.source,
|
|
420
|
+
sqlite: python.sqlite,
|
|
421
|
+
quickCheck: snapshot.quickCheck,
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ------------------------------------------------------------------- inventory
|
|
426
|
+
|
|
427
|
+
function copyRegular(from, to) {
|
|
428
|
+
const info = statOrNull(from)
|
|
429
|
+
if (!info) return false
|
|
430
|
+
if (info.isSymbolicLink()) throw new Error('backup refuses symbolic links')
|
|
431
|
+
if (!info.isFile()) throw new Error('backup refuses non-file: ' + path.basename(from))
|
|
432
|
+
ensureDir(path.dirname(to))
|
|
433
|
+
fs.copyFileSync(from, to)
|
|
434
|
+
return true
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function copyTree(from, to) {
|
|
438
|
+
const entries = readdirSafe(from, { withFileTypes: true })
|
|
439
|
+
if (!entries.length && !statOrNull(from)) return
|
|
440
|
+
ensureDir(to)
|
|
441
|
+
for (const item of entries) {
|
|
442
|
+
if (item.isSymbolicLink()) throw new Error('backup refuses symbolic links')
|
|
443
|
+
const source = path.join(from, item.name)
|
|
444
|
+
const target = path.join(to, item.name)
|
|
445
|
+
if (item.isDirectory()) copyTree(source, target)
|
|
446
|
+
else copyRegular(source, target)
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function collectFiles(dir, prefix = '', out = {}) {
|
|
451
|
+
for (const item of readdirSafe(dir, { withFileTypes: true })) {
|
|
452
|
+
const full = path.join(dir, item.name)
|
|
453
|
+
const rel = prefix ? prefix + '/' + item.name : item.name
|
|
454
|
+
const info = statOrNull(full)
|
|
455
|
+
if (!info) continue
|
|
456
|
+
if (info.isSymbolicLink()) throw new Error('backup refuses symbolic links')
|
|
457
|
+
if (info.isDirectory()) { collectFiles(full, rel, out); continue }
|
|
458
|
+
if (!info.isFile()) continue
|
|
459
|
+
out[rel] = { bytes: info.size, sha256: sha256File(full) }
|
|
460
|
+
}
|
|
461
|
+
return out
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// --------------------------------------------------------------- backup creation
|
|
465
|
+
|
|
466
|
+
function nextBackupName() {
|
|
467
|
+
const base = isoStamp().replace(/[:.]/g, '-')
|
|
468
|
+
let name = base
|
|
469
|
+
for (let attempt = 0; fs.existsSync(path.join(backupsDir(), name)); attempt += 1) {
|
|
470
|
+
if (attempt >= 64) throw new Error('cannot allocate a unique backup name')
|
|
471
|
+
name = base + '-' + process.pid.toString(36) + attempt.toString(36)
|
|
472
|
+
}
|
|
473
|
+
return name
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function vectorSummary() {
|
|
477
|
+
const raw = readJsonQuiet(vectorActiveFile())
|
|
478
|
+
if (!raw || typeof raw !== 'object') return { present: false }
|
|
479
|
+
const active = raw.active && typeof raw.active === 'object' ? raw.active : null
|
|
480
|
+
const profile = active && active.profile && typeof active.profile === 'object' ? active.profile : null
|
|
481
|
+
const libraries = active && active.databases && active.databases.libraries && typeof active.databases.libraries === 'object'
|
|
482
|
+
? Object.keys(active.databases.libraries).length
|
|
483
|
+
: 0
|
|
484
|
+
return {
|
|
485
|
+
present: true,
|
|
486
|
+
generation: Number.isSafeInteger(raw.generation) ? raw.generation : null,
|
|
487
|
+
active: active
|
|
488
|
+
? {
|
|
489
|
+
generation: Number.isSafeInteger(active.generation) ? active.generation : null,
|
|
490
|
+
fingerprint: typeof active.fingerprint === 'string' ? active.fingerprint : null,
|
|
491
|
+
catalogId: typeof profile?.catalogId === 'string' ? profile.catalogId : null,
|
|
492
|
+
revision: typeof profile?.revision === 'string' ? profile.revision : null,
|
|
493
|
+
dimension: Number.isSafeInteger(profile?.dimension) ? profile.dimension : null,
|
|
494
|
+
libraries,
|
|
495
|
+
}
|
|
496
|
+
: null,
|
|
497
|
+
pending: raw.pending && typeof raw.pending === 'object' ? String(raw.pending.status || '') : null,
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function engineSummary() {
|
|
502
|
+
const current = engineCurrent()
|
|
503
|
+
return current ? { version: current.version, schemaVersion: current.schemaVersion } : null
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Copies every store a restore needs: plugin state, the global index and the
|
|
508
|
+
* per-library index files, the active vector profile journal, prompt overrides
|
|
509
|
+
* and their manifest, attachment references, and every MemU SQLite database.
|
|
510
|
+
* Deliberately absent: engine/, downloads/, logs/, tmp/ (large and
|
|
511
|
+
* re-downloadable) and any key material - the manifest carries identity, never
|
|
512
|
+
* a credential.
|
|
513
|
+
*
|
|
514
|
+
* `exec` only exists so tests can stand in for the engine interpreter; the
|
|
515
|
+
* default path spawns the verified venv binary and nothing else.
|
|
516
|
+
*/
|
|
517
|
+
function writeStores(staging, exec) {
|
|
518
|
+
ensureDir(path.join(staging, 'libraries'))
|
|
519
|
+
ensureDir(path.join(staging, 'index'))
|
|
520
|
+
ensureDir(path.join(staging, 'prompts'))
|
|
521
|
+
copyRegular(stateFile(), path.join(staging, 'state.json'))
|
|
522
|
+
for (const name of readdirSafe(indexDir())) {
|
|
523
|
+
if (name.endsWith('.json')) copyRegular(path.join(indexDir(), name), path.join(staging, 'index', name))
|
|
524
|
+
}
|
|
525
|
+
copyRegular(path.join(dataDir(), 'prompts', 'manifest.json'), path.join(staging, 'prompts', 'manifest.json'))
|
|
526
|
+
copyTree(path.join(dataDir(), 'prompts'), path.join(staging, 'prompts'))
|
|
527
|
+
copyTree(path.join(dataDir(), 'attachments'), path.join(staging, 'attachments'))
|
|
528
|
+
const databases = []
|
|
529
|
+
const globalInfo = statOrNull(globalDbFile())
|
|
530
|
+
if (globalInfo && globalInfo.isFile()) databases.push({ src: globalDbFile(), rel: 'global.sqlite3' })
|
|
531
|
+
for (const item of readdirSafe(librariesDir(), { withFileTypes: true })) {
|
|
532
|
+
if (!item.isFile() || !item.name.endsWith('.sqlite3')) continue
|
|
533
|
+
databases.push({ src: path.join(librariesDir(), item.name), rel: 'libraries/' + item.name })
|
|
534
|
+
}
|
|
535
|
+
const snapshots = []
|
|
536
|
+
for (const database of databases) {
|
|
537
|
+
const target = path.join(staging, database.rel.split('/').join(path.sep))
|
|
538
|
+
ensureDir(path.dirname(target))
|
|
539
|
+
const result = snapshotDatabase(database.src, target, exec)
|
|
540
|
+
snapshots.push({
|
|
541
|
+
file: database.rel,
|
|
542
|
+
mode: result.mode,
|
|
543
|
+
engine: result.engine || null,
|
|
544
|
+
quickCheck: result.quickCheck || null,
|
|
545
|
+
})
|
|
546
|
+
}
|
|
547
|
+
return snapshots
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function publishStaging(staging, name) {
|
|
551
|
+
const dir = path.join(backupsDir(), name)
|
|
552
|
+
if (fs.existsSync(dir)) throw new Error('backup directory already exists: ' + name)
|
|
553
|
+
try {
|
|
554
|
+
fs.renameSync(staging, dir)
|
|
555
|
+
} catch (error) {
|
|
556
|
+
throw new Error('could not publish backup ' + name + ': ' + (error?.code || 'io error'))
|
|
557
|
+
}
|
|
558
|
+
return dir
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function discardStaging(staging) {
|
|
562
|
+
try { fs.rmSync(staging, { recursive: true, force: true, maxRetries: 3 }) } catch (error) {
|
|
563
|
+
warn('backups: could not discard staging directory (' + (error?.code || 'io error') + ')')
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Staging leftovers of crashed runs are reclaimed; a live sibling is kept. */
|
|
568
|
+
function cleanupStaging() {
|
|
569
|
+
const root = stagingRoot()
|
|
570
|
+
const now = Date.now()
|
|
571
|
+
for (const item of readdirSafe(root, { withFileTypes: true })) {
|
|
572
|
+
if (!item.isDirectory()) continue
|
|
573
|
+
const full = path.join(root, item.name)
|
|
574
|
+
const parts = item.name.split('.')
|
|
575
|
+
const pid = Number(parts.length > 1 ? parts[parts.length - 2] : NaN)
|
|
576
|
+
const info = statOrNull(full)
|
|
577
|
+
const age = info ? now - info.mtimeMs : 0
|
|
578
|
+
const mine = Number.isSafeInteger(pid) && pid === process.pid
|
|
579
|
+
const stale = mine ? age > STAGING_TTL_MS : (!Number.isSafeInteger(pid) || !pidAlive(pid) || age > STAGING_TTL_MS)
|
|
580
|
+
if (!stale) continue
|
|
581
|
+
try { fs.rmSync(full, { recursive: true, force: true, maxRetries: 3 }) } catch {}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Creates one managed backup of the current data root, then keeps the newest
|
|
587
|
+
* BACKUP_KEEP complete ones. Returns a report whose dir is the published
|
|
588
|
+
* directory, so every existing caller that treats the return value as a path
|
|
589
|
+
* keeps working.
|
|
590
|
+
*
|
|
591
|
+
* `pythonExec` is a test seam for the engine interpreter (see writeStores).
|
|
592
|
+
* A database that cannot be snapshotted by the verified engine throws, and the
|
|
593
|
+
* throw happens before anything is published, so no partial or torn backup can
|
|
594
|
+
* ever appear in backups/.
|
|
595
|
+
*/
|
|
596
|
+
export function createBackup({ label = 'auto', keep = BACKUP_KEEP, flushState = null, pythonExec = null } = {}) {
|
|
597
|
+
return withBackupLock(() => {
|
|
598
|
+
ensureDir(backupsDir())
|
|
599
|
+
cleanupStaging()
|
|
600
|
+
const name = nextBackupName()
|
|
601
|
+
const staging = path.join(stagingRoot(), name + '.' + process.pid + '.' + crypto.randomBytes(4).toString('hex'))
|
|
602
|
+
ensureDir(staging)
|
|
603
|
+
try {
|
|
604
|
+
// Plugin state is flushed first: a backup must never capture the pending
|
|
605
|
+
// bookkeeping edits that are still only in memory.
|
|
606
|
+
if (typeof flushState === 'function') flushState()
|
|
607
|
+
const snapshots = writeStores(staging, pythonExec)
|
|
608
|
+
const files = collectFiles(staging)
|
|
609
|
+
const totals = { files: 0, bytes: 0 }
|
|
610
|
+
for (const rel of Object.keys(files)) {
|
|
611
|
+
totals.files += 1
|
|
612
|
+
totals.bytes += Number(files[rel].bytes || 0)
|
|
613
|
+
}
|
|
614
|
+
const createdAt = isoStamp()
|
|
615
|
+
const manifest = {
|
|
616
|
+
version: BACKUP_SCHEMA,
|
|
617
|
+
kind: 'dsh-memu-memory/backup',
|
|
618
|
+
name,
|
|
619
|
+
uniqueId: name + '-' + crypto.randomBytes(4).toString('hex'),
|
|
620
|
+
createdAt,
|
|
621
|
+
createdAtMs: Date.parse(createdAt) || Date.now(),
|
|
622
|
+
label: String(label || 'auto'),
|
|
623
|
+
complete: true,
|
|
624
|
+
engine: engineSummary(),
|
|
625
|
+
vector: vectorSummary(),
|
|
626
|
+
snapshot: { databases: snapshots },
|
|
627
|
+
totals,
|
|
628
|
+
files,
|
|
629
|
+
}
|
|
630
|
+
// Written last and atomically: a finished manifest is the completion
|
|
631
|
+
// marker that prune requires before it may delete anything.
|
|
632
|
+
writeJson(path.join(staging, 'manifest.json'), manifest)
|
|
633
|
+
const dir = publishStaging(staging, name)
|
|
634
|
+
const prune = pruneBackups({ keep })
|
|
635
|
+
return {
|
|
636
|
+
name,
|
|
637
|
+
dir,
|
|
638
|
+
createdAt,
|
|
639
|
+
files: totals.files,
|
|
640
|
+
bytes: totals.bytes,
|
|
641
|
+
snapshots: snapshots.map(item => ({ file: item.file, mode: item.mode, engine: item.engine || null })),
|
|
642
|
+
pruned: prune.deleted,
|
|
643
|
+
pruneFailed: prune.failed,
|
|
644
|
+
pendingCleanup: prune.pendingCleanup,
|
|
645
|
+
notice: prune.notice,
|
|
646
|
+
}
|
|
647
|
+
} catch (error) {
|
|
648
|
+
discardStaging(staging)
|
|
649
|
+
throw error
|
|
650
|
+
}
|
|
651
|
+
})
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// -------------------------------------------------------------- listing / scan
|
|
655
|
+
|
|
656
|
+
function isManagedName(name) {
|
|
657
|
+
return typeof name === 'string' && isSafeId(name) && MANAGED_NAME.test(name)
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** A relative path inside a backup that we are willing to copy back. */
|
|
661
|
+
function isSafeRelative(rel) {
|
|
662
|
+
if (typeof rel !== 'string' || !rel) return false
|
|
663
|
+
if (path.isAbsolute(rel)) return false
|
|
664
|
+
if (rel.indexOf(BACKSLASH) !== -1) return false
|
|
665
|
+
for (const part of rel.split('/')) {
|
|
666
|
+
if (!part || part === '.' || part === '..') return false
|
|
667
|
+
}
|
|
668
|
+
return true
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function inventoryIssues(dir, files) {
|
|
672
|
+
const issues = []
|
|
673
|
+
const rels = Object.keys(files)
|
|
674
|
+
if (!rels.length) return ['manifest lists no files']
|
|
675
|
+
for (const rel of rels) {
|
|
676
|
+
if (!isSafeRelative(rel)) { issues.push('unsafe path in manifest: ' + rel.slice(0, 120)); continue }
|
|
677
|
+
if (issues.length >= 5) { issues.push('and more'); break }
|
|
678
|
+
const full = path.join(dir, rel.split('/').join(path.sep))
|
|
679
|
+
const info = statOrNull(full)
|
|
680
|
+
if (!info) { issues.push('missing: ' + rel); continue }
|
|
681
|
+
if (!info.isFile()) { issues.push('not a file: ' + rel); continue }
|
|
682
|
+
const expected = Number(files[rel]?.bytes)
|
|
683
|
+
if (Number.isFinite(expected) && expected !== info.size) issues.push('size mismatch: ' + rel)
|
|
684
|
+
}
|
|
685
|
+
return issues
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function buildEntry(name, dir, manifest, status, issues) {
|
|
689
|
+
const files = manifest?.files && typeof manifest.files === 'object' ? manifest.files : null
|
|
690
|
+
let bytes = null
|
|
691
|
+
if (files) {
|
|
692
|
+
bytes = 0
|
|
693
|
+
for (const rel of Object.keys(files)) bytes += Number(files[rel]?.bytes || 0)
|
|
694
|
+
}
|
|
695
|
+
const createdAt = typeof manifest?.createdAt === 'string' ? manifest.createdAt : null
|
|
696
|
+
const info = statOrNull(dir)
|
|
697
|
+
const parsed = Date.parse(createdAt || '')
|
|
698
|
+
return {
|
|
699
|
+
name,
|
|
700
|
+
dir,
|
|
701
|
+
createdAt: createdAt || (info ? new Date(info.mtimeMs).toISOString() : null),
|
|
702
|
+
createdAtMs: Number.isFinite(Number(manifest?.createdAtMs)) ? Number(manifest.createdAtMs) : (Number.isFinite(parsed) ? parsed : (info ? info.mtimeMs : 0)),
|
|
703
|
+
engine: manifest?.engine || null,
|
|
704
|
+
files: files ? Object.keys(files).length : null,
|
|
705
|
+
bytes,
|
|
706
|
+
hasManifest: Boolean(manifest),
|
|
707
|
+
complete: status === 'complete',
|
|
708
|
+
status,
|
|
709
|
+
issues: issues.slice(0, 6),
|
|
710
|
+
uniqueId: typeof manifest?.uniqueId === 'string' ? manifest.uniqueId : null,
|
|
711
|
+
label: typeof manifest?.label === 'string' ? manifest.label : null,
|
|
712
|
+
vector: manifest?.vector || null,
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Reads one directory in backups/ and classifies it. status is 'complete'
|
|
718
|
+
* (only these are counted or deleted), 'incomplete', 'corrupt' or 'unknown'.
|
|
719
|
+
* verify re-hashes every file: cheap listing stays cheap, but retention must
|
|
720
|
+
* never count a backup it has not actually checked.
|
|
721
|
+
*/
|
|
722
|
+
function scanEntry(name, { verify = false } = {}) {
|
|
723
|
+
const dir = path.join(backupsDir(), name)
|
|
724
|
+
const info = statOrNull(dir)
|
|
725
|
+
if (!info) return null
|
|
726
|
+
if (info.isSymbolicLink()) return buildEntry(name, dir, null, 'unknown', ['backups entry is a symbolic link or junction'])
|
|
727
|
+
if (!info.isDirectory()) return buildEntry(name, dir, null, 'unknown', ['backups entry is not a directory'])
|
|
728
|
+
const manifest = readJsonQuiet(path.join(dir, 'manifest.json'))
|
|
729
|
+
const issues = []
|
|
730
|
+
let status = 'complete'
|
|
731
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
|
732
|
+
status = 'unknown'
|
|
733
|
+
issues.push('manifest.json is missing or unreadable')
|
|
734
|
+
} else if (!isManagedName(name)) {
|
|
735
|
+
status = 'unknown'
|
|
736
|
+
issues.push('not a managed backup name')
|
|
737
|
+
} else if (!manifest.files || typeof manifest.files !== 'object' || Array.isArray(manifest.files)) {
|
|
738
|
+
status = 'incomplete'
|
|
739
|
+
issues.push('manifest has no file inventory')
|
|
740
|
+
} else if (manifest.complete !== true) {
|
|
741
|
+
status = 'incomplete'
|
|
742
|
+
issues.push('backup was never completed')
|
|
743
|
+
}
|
|
744
|
+
if (status === 'complete') {
|
|
745
|
+
for (const issue of inventoryIssues(dir, manifest.files)) issues.push(issue)
|
|
746
|
+
if (issues.length) status = 'incomplete'
|
|
747
|
+
}
|
|
748
|
+
if (status === 'complete' && verify) {
|
|
749
|
+
for (const rel of Object.keys(manifest.files)) {
|
|
750
|
+
const expected = manifest.files[rel] || {}
|
|
751
|
+
if (typeof expected.sha256 !== 'string' || !expected.sha256) continue
|
|
752
|
+
const full = path.join(dir, rel.split('/').join(path.sep))
|
|
753
|
+
let actual = null
|
|
754
|
+
try { actual = sha256File(full) } catch { actual = null }
|
|
755
|
+
if (actual === expected.sha256) continue
|
|
756
|
+
status = 'corrupt'
|
|
757
|
+
issues.push('checksum mismatch: ' + rel)
|
|
758
|
+
break
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
if (status !== 'unknown') {
|
|
762
|
+
const real = realpathOrNull(dir)
|
|
763
|
+
if (!real || !samePath(real, dir)) { status = 'unknown'; issues.push('directory resolves somewhere else (link or junction)') }
|
|
764
|
+
else if (!samePath(path.dirname(path.resolve(dir)), path.resolve(backupsDir()))) { status = 'unknown'; issues.push('directory is not a direct child of backups/') }
|
|
765
|
+
}
|
|
766
|
+
return buildEntry(name, dir, manifest, status, issues)
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function compareEntries(a, b) {
|
|
770
|
+
// createdAt first because mtimes lie, then the name: names are unique inside
|
|
771
|
+
// backups/ and a colliding millisecond appends a monotonic suffix, so the
|
|
772
|
+
// order stays deterministic without trusting the filesystem's clock.
|
|
773
|
+
if (b.createdAtMs !== a.createdAtMs) return b.createdAtMs - a.createdAtMs
|
|
774
|
+
return String(b.name).localeCompare(String(a.name))
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/** Every entry in backups/, newest first, including incomplete and unknown. */
|
|
778
|
+
export function scanBackups({ verify = false } = {}) {
|
|
779
|
+
const out = []
|
|
780
|
+
for (const item of readdirSafe(backupsDir(), { withFileTypes: true })) {
|
|
781
|
+
if (item.name.startsWith('.')) continue
|
|
782
|
+
const scanned = scanEntry(item.name, { verify })
|
|
783
|
+
if (scanned) out.push(scanned)
|
|
784
|
+
}
|
|
785
|
+
return out.sort(compareEntries)
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/** Public listing used by the UI: newest first, with completeness reported. */
|
|
789
|
+
export function listBackupEntries() { return scanBackups() }
|
|
790
|
+
|
|
791
|
+
export function findBackup(name) {
|
|
792
|
+
if (!isSafeId(name) || name.startsWith('.')) throw new Error('invalid backup name')
|
|
793
|
+
const dir = path.join(backupsDir(), name)
|
|
794
|
+
if (!statOrNull(dir)) throw new Error('backup not found: ' + name)
|
|
795
|
+
const found = scanEntry(name)
|
|
796
|
+
if (!found) throw new Error('backup not found: ' + name)
|
|
797
|
+
return found
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// ----------------------------------------------------------------------- prune
|
|
801
|
+
|
|
802
|
+
function assertNoLinks(dir, prefix) {
|
|
803
|
+
for (const item of readdirSafe(dir, { withFileTypes: true })) {
|
|
804
|
+
const rel = prefix ? prefix + '/' + item.name : item.name
|
|
805
|
+
if (item.isSymbolicLink()) throw new Error('refusing to delete: ' + rel + ' is a link')
|
|
806
|
+
if (item.isDirectory()) assertNoLinks(path.join(dir, item.name), rel)
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Every guard a recursive delete needs, re-checked on the resolved path:
|
|
812
|
+
* managed single-segment name, direct child of backups/, realpath unchanged,
|
|
813
|
+
* and no link or junction anywhere inside.
|
|
814
|
+
*/
|
|
815
|
+
function assertRemovable(found) {
|
|
816
|
+
if (!found || typeof found.name !== 'string' || !isManagedName(found.name)) throw new Error('refusing to delete a backup with an unmanaged name')
|
|
817
|
+
const dir = path.join(backupsDir(), found.name)
|
|
818
|
+
const info = statOrNull(dir)
|
|
819
|
+
if (!info) throw new Error('backup not found: ' + found.name)
|
|
820
|
+
if (info.isSymbolicLink()) throw new Error('refusing to delete a link: ' + found.name)
|
|
821
|
+
if (!info.isDirectory()) throw new Error('refusing to delete a non-directory: ' + found.name)
|
|
822
|
+
if (!samePath(path.dirname(path.resolve(dir)), path.resolve(backupsDir()))) throw new Error('refusing to delete outside backups/: ' + found.name)
|
|
823
|
+
const real = realpathOrNull(dir)
|
|
824
|
+
if (!real || !samePath(real, dir)) throw new Error('refusing to delete a redirected path: ' + found.name)
|
|
825
|
+
assertNoLinks(dir, '')
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function removeBackupDir(found) {
|
|
829
|
+
assertRemovable(found)
|
|
830
|
+
fs.rmSync(path.join(backupsDir(), found.name), { recursive: true, force: true, maxRetries: 3 })
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Keeps the newest keep complete managed backups and deletes the rest.
|
|
835
|
+
* Incomplete and unknown entries are reported, never counted, never deleted.
|
|
836
|
+
* A leased directory (a restore or migration is reading it) is skipped, as is
|
|
837
|
+
* the safety backup named in protect - retention may briefly exceed the limit,
|
|
838
|
+
* and the next prune reclaims it.
|
|
839
|
+
*/
|
|
840
|
+
export function pruneBackups({ keep = BACKUP_KEEP, protect = [] } = {}) {
|
|
841
|
+
return withBackupLock(() => {
|
|
842
|
+
ensureDir(backupsDir())
|
|
843
|
+
cleanupStaging()
|
|
844
|
+
const protectSet = new Set()
|
|
845
|
+
for (const name of protect) if (name) protectSet.add(path.basename(String(name)))
|
|
846
|
+
for (const name of Object.keys(activeLeases())) protectSet.add(name)
|
|
847
|
+
// Retention decides what to delete, so it pays for the full hash pass: a
|
|
848
|
+
// backup whose bytes no longer match its manifest must not occupy one of the
|
|
849
|
+
// three slots, and must never be the reason an intact one is deleted.
|
|
850
|
+
const all = scanBackups({ verify: true })
|
|
851
|
+
const complete = all.filter(found => found.status === 'complete')
|
|
852
|
+
const victims = complete.slice(keep).filter(found => !protectSet.has(found.name))
|
|
853
|
+
const deleted = []
|
|
854
|
+
const failed = []
|
|
855
|
+
for (const victim of victims) {
|
|
856
|
+
try {
|
|
857
|
+
removeBackupDir(victim)
|
|
858
|
+
deleted.push(victim.name)
|
|
859
|
+
} catch (error) {
|
|
860
|
+
failed.push({ name: victim.name, error: String(error?.message || error) })
|
|
861
|
+
warn('backups: could not remove ' + victim.name + ' (' + String(error?.message || error) + ')')
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
const kept = deleted.length
|
|
865
|
+
? scanBackups().filter(found => found.status === 'complete')
|
|
866
|
+
: complete.filter(found => !deleted.includes(found.name))
|
|
867
|
+
const notice = 'at most ' + keep + ' complete backups are kept; older ones are cleaned up automatically'
|
|
868
|
+
if (deleted.length) {
|
|
869
|
+
warn('backups: ' + notice + ' (deleted ' + deleted.join(', ') + ')')
|
|
870
|
+
try {
|
|
871
|
+
const marker = path.join(backupsDir(), '.retention-notice.json')
|
|
872
|
+
if (!readJsonQuiet(marker)) writeJson(marker, { at: isoStamp(), policy: 'keep ' + keep, notice })
|
|
873
|
+
} catch {}
|
|
874
|
+
}
|
|
875
|
+
return {
|
|
876
|
+
keep,
|
|
877
|
+
notice,
|
|
878
|
+
deleted,
|
|
879
|
+
failed,
|
|
880
|
+
protected: Array.from(protectSet),
|
|
881
|
+
pendingCleanup: Math.max(0, kept.length - keep),
|
|
882
|
+
kept: kept.map(found => found.name),
|
|
883
|
+
skipped: all.filter(found => found.status !== 'complete').map(found => ({ name: found.name, status: found.status, issues: found.issues })),
|
|
884
|
+
}
|
|
885
|
+
})
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/** Explicit removal requested by the user. */
|
|
889
|
+
export function deleteBackupEntry(name) {
|
|
890
|
+
return withBackupLock(() => {
|
|
891
|
+
const found = findBackup(name)
|
|
892
|
+
if (activeLeases()[found.name]) throw new Error('backup is in use by a running restore: ' + found.name)
|
|
893
|
+
removeBackupDir(found)
|
|
894
|
+
return { deleted: found.name, status: found.status }
|
|
895
|
+
})
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// --------------------------------------------------------------------- restore
|
|
899
|
+
|
|
900
|
+
/** Where one relative path inside a backup lands in the live data root. */
|
|
901
|
+
function liveTargetFor(rel) {
|
|
902
|
+
const parts = rel.split('/')
|
|
903
|
+
const head = parts[0]
|
|
904
|
+
if (parts.length === 1) {
|
|
905
|
+
if (head === 'state.json') return stateFile()
|
|
906
|
+
if (head === 'global.sqlite3') return globalDbFile()
|
|
907
|
+
if (head === 'global.sqlite3-wal') return globalDbFile() + '-wal'
|
|
908
|
+
if (head === 'global.sqlite3-shm') return globalDbFile() + '-shm'
|
|
909
|
+
}
|
|
910
|
+
if ((head === 'index' || head === 'libraries') && parts.length === 2) {
|
|
911
|
+
return path.join(head === 'index' ? indexDir() : librariesDir(), parts[1])
|
|
912
|
+
}
|
|
913
|
+
if ((head === 'prompts' || head === 'attachments') && parts.length >= 2) {
|
|
914
|
+
return path.join(dataDir(), parts.join(path.sep))
|
|
915
|
+
}
|
|
916
|
+
throw new Error('backup contains an entry this version cannot restore: ' + rel)
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function writeAtomicCopy(from, to) {
|
|
920
|
+
ensureDir(path.dirname(to))
|
|
921
|
+
const tmp = to + '.restore-' + process.pid + '-' + crypto.randomBytes(4).toString('hex')
|
|
922
|
+
try {
|
|
923
|
+
fs.copyFileSync(from, tmp)
|
|
924
|
+
const fd = fs.openSync(tmp, 'r+')
|
|
925
|
+
try { fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
|
|
926
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
927
|
+
try { fs.renameSync(tmp, to); return } catch (error) {
|
|
928
|
+
if (!['EPERM', 'EBUSY', 'EACCES'].includes(error?.code) || attempt >= 5) throw error
|
|
929
|
+
pause(RETRY_MS * (attempt + 1))
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
} catch (error) {
|
|
933
|
+
try { fs.rmSync(tmp, { force: true }) } catch {}
|
|
934
|
+
throw error
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/** A restored database must not inherit the WAL of the one it replaces. */
|
|
939
|
+
function dropCompanions(target) {
|
|
940
|
+
for (const suffix of ['-wal', '-shm']) {
|
|
941
|
+
try { fs.rmSync(target + suffix, { force: true }) } catch (error) {
|
|
942
|
+
if (error?.code !== 'ENOENT') throw error
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function isSidecar(rel) {
|
|
948
|
+
return rel.endsWith('-wal') || rel.endsWith('-shm')
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/** A database file always lands before its sidecars. */
|
|
952
|
+
function restoreOrder(rels) {
|
|
953
|
+
return rels.slice().sort((left, right) => {
|
|
954
|
+
const delta = (isSidecar(left) ? 1 : 0) - (isSidecar(right) ? 1 : 0)
|
|
955
|
+
if (delta !== 0) return delta
|
|
956
|
+
return left.localeCompare(right)
|
|
957
|
+
})
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* Validates the WHOLE inventory before anything is written, so a restore either
|
|
962
|
+
* has every byte it needs or leaves the live root untouched.
|
|
963
|
+
*/
|
|
964
|
+
function verifyInventory(found) {
|
|
965
|
+
const manifest = readJsonQuiet(path.join(found.dir, 'manifest.json'))
|
|
966
|
+
if (!manifest || typeof manifest !== 'object' || !manifest.files || typeof manifest.files !== 'object') {
|
|
967
|
+
throw new Error('backup is incomplete: ' + found.name + ' has no file inventory')
|
|
968
|
+
}
|
|
969
|
+
const rels = []
|
|
970
|
+
for (const rel of Object.keys(manifest.files)) {
|
|
971
|
+
if (!isSafeRelative(rel)) throw new Error('invalid backup manifest path')
|
|
972
|
+
const full = path.join(found.dir, rel.split('/').join(path.sep))
|
|
973
|
+
const info = statOrNull(full)
|
|
974
|
+
if (!info || info.isSymbolicLink() || !info.isFile()) throw new Error('backup is incomplete: ' + rel + ' is missing')
|
|
975
|
+
const expected = manifest.files[rel] || {}
|
|
976
|
+
if (Number.isFinite(Number(expected.bytes)) && Number(expected.bytes) !== info.size) {
|
|
977
|
+
throw new Error('backup is corrupt: ' + rel + ' has the wrong size')
|
|
978
|
+
}
|
|
979
|
+
if (expected.sha256 && sha256File(full) !== expected.sha256) {
|
|
980
|
+
throw new Error('backup is corrupt: ' + rel + ' failed its checksum')
|
|
981
|
+
}
|
|
982
|
+
liveTargetFor(rel)
|
|
983
|
+
rels.push(rel)
|
|
984
|
+
}
|
|
985
|
+
if (!rels.length) throw new Error('backup is incomplete: ' + found.name + ' lists no files')
|
|
986
|
+
return { manifest, rels }
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function normalizeDbRel(value) {
|
|
990
|
+
if (typeof value !== 'string' || !value) return null
|
|
991
|
+
const rel = value.split(BACKSLASH).join('/').replace(/^\.\//, '')
|
|
992
|
+
if (rel.startsWith('libraries/')) return rel
|
|
993
|
+
if (rel.indexOf('/') === -1) return 'libraries/' + rel
|
|
994
|
+
return rel
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* A backup made before the vector journal existed carries no vector-active
|
|
999
|
+
* manifest. Restoring it over a live root that still points at the newer
|
|
1000
|
+
* generation would leave recall reading tables the restored text index never
|
|
1001
|
+
* saw, so that pointer must be retired, and the same holds for a journal that
|
|
1002
|
+
* points at databases the backup does not contain.
|
|
1003
|
+
*/
|
|
1004
|
+
function planVectorActive(found, rels) {
|
|
1005
|
+
const rel = 'index/vector-active.json'
|
|
1006
|
+
if (!rels.includes(rel)) return { action: 'absent', restore: false }
|
|
1007
|
+
const raw = readJsonQuiet(path.join(found.dir, 'index', 'vector-active.json'))
|
|
1008
|
+
if (!raw || typeof raw !== 'object') return { action: 'unreadable', restore: false }
|
|
1009
|
+
const active = raw.active && typeof raw.active === 'object' ? raw.active : null
|
|
1010
|
+
if (!active) return { action: 'restore', restore: true }
|
|
1011
|
+
const needed = []
|
|
1012
|
+
const global = active.databases && active.databases.global
|
|
1013
|
+
if (global && typeof global.path === 'string') needed.push(global.path)
|
|
1014
|
+
const libraries = active.databases && active.databases.libraries
|
|
1015
|
+
if (libraries && typeof libraries === 'object') {
|
|
1016
|
+
for (const key of Object.keys(libraries)) {
|
|
1017
|
+
const item = libraries[key]
|
|
1018
|
+
if (item && typeof item.path === 'string') needed.push(item.path)
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
const missing = []
|
|
1022
|
+
for (const value of needed) {
|
|
1023
|
+
const normalized = normalizeDbRel(value)
|
|
1024
|
+
if (!normalized || !rels.includes(normalized)) missing.push(String(value))
|
|
1025
|
+
}
|
|
1026
|
+
if (missing.length) return { action: 'missing-databases', restore: false, missing: missing.slice(0, 5) }
|
|
1027
|
+
return { action: 'restore', restore: true }
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/** Moves the live vector-active pointer aside so recall falls back to text. */
|
|
1031
|
+
function retireVectorActive(reason) {
|
|
1032
|
+
const live = vectorActiveFile()
|
|
1033
|
+
const info = statOrNull(live)
|
|
1034
|
+
if (!info) return 'absent'
|
|
1035
|
+
if (info.isSymbolicLink()) {
|
|
1036
|
+
warn('backups: the live vector-active.json is a link; leaving it alone')
|
|
1037
|
+
return 'unreadable'
|
|
1038
|
+
}
|
|
1039
|
+
const aside = live + '.stale-' + isoStamp().replace(/[:.]/g, '-')
|
|
1040
|
+
try { fs.renameSync(live, aside) } catch (error) {
|
|
1041
|
+
throw new Error('the restored data has no usable vector-active manifest and the stale pointer could not be retired: ' + String(error?.message || error))
|
|
1042
|
+
}
|
|
1043
|
+
warn('backups: ' + reason + '; the stale vector-active pointer was moved to ' + path.basename(aside) + ' so recall falls back to lexical text')
|
|
1044
|
+
return 'cleared'
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* Puts a backup back. The target is leased and fully verified first, the live
|
|
1049
|
+
* root is saved as a safety backup second, and only then is anything written.
|
|
1050
|
+
* A failure after that point is recoverable: the safety backup holds the state
|
|
1051
|
+
* that was replaced, and it is protected from this operation's prune.
|
|
1052
|
+
*/
|
|
1053
|
+
export function restoreFromBackup(name, { beforeRestore = null, flushState = null, clearCaches = null, keep = BACKUP_KEEP } = {}) {
|
|
1054
|
+
return withBackupLock(() => {
|
|
1055
|
+
const found = findBackup(name)
|
|
1056
|
+
const plan = verifyInventory(found)
|
|
1057
|
+
const vector = planVectorActive(found, plan.rels)
|
|
1058
|
+
writeLease(found.name, { pid: process.pid, at: Date.now(), kind: 'restore' })
|
|
1059
|
+
let safety = null
|
|
1060
|
+
try {
|
|
1061
|
+
const created = typeof beforeRestore === 'function'
|
|
1062
|
+
? beforeRestore()
|
|
1063
|
+
: createBackup({ label: 'before-restore', flushState })
|
|
1064
|
+
safety = typeof created === 'string' ? created : (created && created.dir ? created.dir : null)
|
|
1065
|
+
const rels = restoreOrder(plan.rels.filter(rel => rel !== 'index/vector-active.json' || vector.restore))
|
|
1066
|
+
for (const rel of rels) {
|
|
1067
|
+
const target = liveTargetFor(rel)
|
|
1068
|
+
if (rel.endsWith('.sqlite3')) dropCompanions(target)
|
|
1069
|
+
writeAtomicCopy(path.join(found.dir, rel.split('/').join(path.sep)), target)
|
|
1070
|
+
}
|
|
1071
|
+
const vectorActive = vector.restore
|
|
1072
|
+
? 'restored'
|
|
1073
|
+
: retireVectorActive(vector.action === 'absent'
|
|
1074
|
+
? 'this backup predates the vector generation journal'
|
|
1075
|
+
: 'the vector journal in this backup does not match the databases it contains')
|
|
1076
|
+
if (typeof clearCaches === 'function') clearCaches()
|
|
1077
|
+
const prune = pruneBackups({ keep, protect: safety ? [path.basename(safety)] : [] })
|
|
1078
|
+
return {
|
|
1079
|
+
restored: found.name,
|
|
1080
|
+
safety,
|
|
1081
|
+
files: rels.length,
|
|
1082
|
+
vectorActive,
|
|
1083
|
+
pruned: prune.deleted,
|
|
1084
|
+
pendingCleanup: prune.pendingCleanup,
|
|
1085
|
+
}
|
|
1086
|
+
} catch (error) {
|
|
1087
|
+
const reason = String(error?.message || error)
|
|
1088
|
+
warn('backups: restore of ' + found.name + ' failed: ' + reason)
|
|
1089
|
+
const wrapped = new Error(safety
|
|
1090
|
+
? 'restore failed (' + reason + '); the live state it replaced is saved as ' + path.basename(safety)
|
|
1091
|
+
: 'restore failed (' + reason + ') and no safety backup could be made')
|
|
1092
|
+
wrapped.code = 'DSH_MEMU_BACKUP_RESTORE_FAILED'
|
|
1093
|
+
wrapped.cause = error
|
|
1094
|
+
wrapped.safety = safety ? path.basename(safety) : null
|
|
1095
|
+
// Whatever landed on disk is the truth now: cached readers must not keep
|
|
1096
|
+
// serving the half-replaced state.
|
|
1097
|
+
try { if (typeof clearCaches === 'function') clearCaches() } catch {}
|
|
1098
|
+
throw wrapped
|
|
1099
|
+
} finally {
|
|
1100
|
+
try { writeLease(found.name, null) } catch {}
|
|
1101
|
+
}
|
|
1102
|
+
})
|
|
1103
|
+
}
|