luckytri 1.0.0 → 1.0.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/.github/SECURITY.md +4 -4
- package/README.en.md +252 -250
- package/README.md +252 -250
- package/bin/luckytri.js +5 -1
- package/docs/en/CHANGELOG.md +8 -0
- package/docs/en/connect.md +18 -18
- package/docs/en/guide.md +202 -216
- package/docs/en/npm-release.md +25 -25
- package/docs/zh/CHANGELOG.md +8 -0
- package/docs/zh/connect.md +17 -17
- package/docs/zh/guide.md +202 -216
- package/docs/zh/npm-release.md +33 -33
- package/package.json +110 -108
- package/plugins/sdk/index.d.ts +155 -155
- package/plugins/sdk/testing.js +58 -58
- package/plugins/weather/index.js +82 -82
- package/plugins/weather/luckytri-plugin.json +29 -29
- package/public/app/assets/ChatsPage-BhlJHseG.js +3 -0
- package/public/app/assets/Empty-DP6WMGHi.js +2 -0
- package/public/app/assets/Field-CtbdqaiB.js +1 -0
- package/public/app/assets/HeartPage-32BeTPn2.js +1 -0
- package/public/app/assets/LifePage-CxSm0XKI.js +1 -0
- package/public/app/assets/MemoryPage-BeKBcPCY.js +1 -0
- package/public/app/assets/NaturePage-NEPYl2HS.js +3 -0
- package/public/app/assets/PeoplePage-DxtpekwM.js +1 -0
- package/public/app/assets/PluginsPage-CVVQwEBI.css +1 -0
- package/public/app/assets/PluginsPage-DksEZbUU.js +2 -0
- package/public/app/assets/{SystemPage-CAMPoYqM.css → SystemPage-BkRu0Rvx.css} +1 -1
- package/public/app/assets/SystemPage-BvzJlHuY.js +1 -0
- package/public/app/assets/TimePage-7-awH5XL.js +3 -0
- package/public/app/assets/index-4rItIkD-.js +4 -0
- package/public/app/assets/{index-YDz1ree1.css → index-DY4e15Kh.css} +1 -1
- package/public/app/assets/{knowledge-DG0e0uKw.js → knowledge-C6_HvoyB.js} +1 -1
- package/public/app/assets/media-SMfTT-yc.js +1 -0
- package/public/app/index.html +3 -3
- package/public/guide.en.html +72 -99
- package/public/guide.html +44 -86
- package/public/tutorial.en.md +25 -39
- package/public/tutorial.md +25 -39
- package/scripts/backup.js +311 -311
- package/scripts/browser.js +41 -41
- package/scripts/import-data.js +128 -0
- package/scripts/launch.js +101 -94
- package/scripts/plugin.js +150 -150
- package/scripts/reset-password.js +22 -0
- package/scripts/runtime.js +25 -25
- package/scripts/service.js +56 -47
- package/scripts/setup.js +50 -50
- package/scripts/start-server.js +41 -41
- package/scripts/stop.js +36 -36
- package/server/app.js +112 -96
- package/server/auth.js +217 -0
- package/server/channels/contract.js +57 -57
- package/server/channels/hub.js +165 -165
- package/server/channels/index.js +28 -25
- package/server/channels/onebot/index.js +335 -336
- package/server/channels/onebot/token.js +22 -3
- package/server/core/capabilities.js +208 -208
- package/server/core/events.js +64 -60
- package/server/core/image-preprocessor.js +208 -208
- package/server/core/model-manager.js +1213 -1213
- package/server/core/net-guard.js +49 -49
- package/server/core/orchestrator.js +569 -569
- package/server/core/perceivers.js +91 -91
- package/server/core/persona-manager.js +243 -243
- package/server/core/repository.js +293 -293
- package/server/core/response-validator.js +508 -508
- package/server/core/turn-processor.js +630 -630
- package/server/core/turn.js +236 -236
- package/server/core/vision-manager.js +633 -633
- package/server/i18n/errors.js +548 -497
- package/server/i18n/labels.js +649 -653
- package/server/index.js +151 -102
- package/server/knowledge/manager.js +386 -386
- package/server/knowledge/schema.js +12 -8
- package/server/local-access.js +32 -0
- package/server/mind/anticipations.js +507 -507
- package/server/mind/api.js +703 -703
- package/server/mind/budget.js +152 -152
- package/server/mind/context-selection.js +61 -61
- package/server/mind/day-planner.js +324 -324
- package/server/mind/evidence.js +148 -148
- package/server/mind/index.js +445 -445
- package/server/mind/life/diary.js +786 -786
- package/server/mind/life/index.js +1049 -1049
- package/server/mind/observations.js +84 -84
- package/server/mind/own-day.js +324 -324
- package/server/mind/own-voice.js +318 -318
- package/server/mind/senses.js +53 -53
- package/server/mind/time/activity-clock.js +196 -196
- package/server/mind/time/availability.js +69 -69
- package/server/mind/time/commitment-review.js +198 -198
- package/server/mind/time/executor.js +512 -512
- package/server/mind/time/experiences.js +128 -128
- package/server/mind/time/games.js +562 -562
- package/server/mind/time/index.js +739 -739
- package/server/mind/time/intent.js +166 -166
- package/server/mind/time/kinds.js +171 -171
- package/server/mind/time/plugin-activity.js +207 -207
- package/server/mind/time/tasks.js +587 -587
- package/server/mind/util.js +110 -110
- package/server/mind/view.js +273 -273
- package/server/paths.js +102 -102
- package/server/plugins/api.js +204 -204
- package/server/plugins/host.js +967 -967
- package/server/plugins/install.js +295 -295
- package/server/plugins/manifest.js +201 -201
- package/server/plugins/permissions.js +42 -42
- package/server/plugins/runner/guard.mjs +36 -36
- package/server/plugins/runner/worker.mjs +250 -250
- package/server/plugins/schema.js +91 -91
- package/server/plugins/zip.js +112 -112
- package/server/startup-lock.js +118 -118
- package/server/storage/archive.js +242 -241
- package/server/storage/backup-cleanup-worker.js +8 -8
- package/server/storage/backup-cleanup.js +415 -414
- package/server/storage/backup-lock.js +76 -76
- package/server/storage/backup-scheduler.js +131 -131
- package/server/storage/data-package.js +298 -0
- package/server/storage/data-transfer-api.js +50 -0
- package/server/storage/data-transfer-worker.js +18 -0
- package/server/storage/data-transfer.js +415 -0
- package/server/storage/store.js +202 -199
- package/server/studio/readiness.js +181 -190
- package/server/studio/settings.js +236 -236
- package/public/app/assets/ChatsPage-C-FcRapR.js +0 -3
- package/public/app/assets/Empty-CpP5kQHH.js +0 -2
- package/public/app/assets/Field-D4gY-INU.js +0 -1
- package/public/app/assets/HeartPage-CPy_thQd.js +0 -1
- package/public/app/assets/LifePage-CxWvwmSf.js +0 -1
- package/public/app/assets/MemoryPage-DC4rhQ68.js +0 -1
- package/public/app/assets/NaturePage-IvYw04Zq.js +0 -3
- package/public/app/assets/PeoplePage-Cqefm9-q.js +0 -1
- package/public/app/assets/PluginsPage-DKSaKvXb.js +0 -2
- package/public/app/assets/PluginsPage-HyBe5D8w.css +0 -1
- package/public/app/assets/SystemPage-CJmtJkrK.js +0 -1
- package/public/app/assets/TimePage-Cw44inM4.js +0 -3
- package/public/app/assets/index-BheRW-1t.js +0 -4
- package/public/app/assets/media-BzPDBm7e.js +0 -1
package/scripts/backup.js
CHANGED
|
@@ -1,311 +1,311 @@
|
|
|
1
|
-
import { DatabaseSync } from "node:sqlite";
|
|
2
|
-
import {
|
|
3
|
-
existsSync,
|
|
4
|
-
lstatSync,
|
|
5
|
-
mkdirSync,
|
|
6
|
-
readdirSync,
|
|
7
|
-
readFileSync,
|
|
8
|
-
realpathSync,
|
|
9
|
-
renameSync,
|
|
10
|
-
rmSync,
|
|
11
|
-
statSync,
|
|
12
|
-
} from "node:fs";
|
|
13
|
-
import { resolve, join, basename, dirname } from "node:path";
|
|
14
|
-
import {
|
|
15
|
-
copyDatabaseFile,
|
|
16
|
-
archiveIdentity,
|
|
17
|
-
writeArchiveManifest,
|
|
18
|
-
ensureArchiveIdentity,
|
|
19
|
-
} from "../server/storage/archive.js";
|
|
20
|
-
import { withBackupLock } from "../server/storage/backup-lock.js";
|
|
21
|
-
import { pathToFileURL } from "node:url";
|
|
22
|
-
import { randomUUID } from "node:crypto";
|
|
23
|
-
import { runtimePaths } from "../server/paths.js";
|
|
24
|
-
|
|
25
|
-
const HOUR = 3600000;
|
|
26
|
-
const DAY = 24 * HOUR;
|
|
27
|
-
export const AUTO_PREFIX = "luckytri-auto-";
|
|
28
|
-
const MISSING = "数据库不存在,请先启动一次 LuckyTri";
|
|
29
|
-
|
|
30
|
-
export function backupDirectory(
|
|
31
|
-
source = runtimePaths().database,
|
|
32
|
-
env = process.env,
|
|
33
|
-
) {
|
|
34
|
-
return env.BACKUP_DIR
|
|
35
|
-
? resolve(runtimePaths(env).home, env.BACKUP_DIR)
|
|
36
|
-
: join(dirname(resolve(source)), "backups");
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function copyDatabase(source, target) {
|
|
40
|
-
copyDatabaseFile(source, target);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function backupDatabase(
|
|
44
|
-
source = runtimePaths().database,
|
|
45
|
-
directory = backupDirectory(source),
|
|
46
|
-
) {
|
|
47
|
-
if (!existsSync(source)) throw new Error(MISSING);
|
|
48
|
-
return withBackupLock(directory, () => fullBackup(source, directory));
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function fullBackup(source, directory) {
|
|
52
|
-
if (!existsSync(source)) throw new Error(MISSING);
|
|
53
|
-
ensureArchiveIdentity(source);
|
|
54
|
-
mkdirSync(directory, { recursive: true });
|
|
55
|
-
const target = resolve(
|
|
56
|
-
join(
|
|
57
|
-
directory,
|
|
58
|
-
`luckytri-${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}.db`,
|
|
59
|
-
),
|
|
60
|
-
);
|
|
61
|
-
const partial = `${target}.partial`;
|
|
62
|
-
try {
|
|
63
|
-
copyDatabase(source, partial);
|
|
64
|
-
writeArchiveManifest(partial, { filename: basename(target) });
|
|
65
|
-
renameSync(partial, target);
|
|
66
|
-
renameSync(`${partial}.json`, `${target}.json`);
|
|
67
|
-
} catch (error) {
|
|
68
|
-
rmSync(partial, { force: true });
|
|
69
|
-
rmSync(`${partial}.json`, { force: true });
|
|
70
|
-
throw error;
|
|
71
|
-
}
|
|
72
|
-
return target;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Automatic backups exist to keep her continuity: what she remembers, how she
|
|
76
|
-
// has changed and the days she has lived. Model call traces are megabytes each
|
|
77
|
-
// and can be rebuilt from nothing that matters, so old ones are left out; that
|
|
78
|
-
// keeps a daily copy small enough to actually keep a week of them.
|
|
79
|
-
// When a backup was made comes from its name, so copying the folder to another
|
|
80
|
-
// disk does not make old backups look new.
|
|
81
|
-
function stampTime(name, file) {
|
|
82
|
-
const match = /(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z/.exec(
|
|
83
|
-
name,
|
|
84
|
-
);
|
|
85
|
-
const parsed = match
|
|
86
|
-
? Date.parse(`${match[1]}T${match[2]}:${match[3]}:${match[4]}.${match[5]}Z`)
|
|
87
|
-
: NaN;
|
|
88
|
-
return Number.isFinite(parsed) ? parsed : statSync(file).mtimeMs;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function autoBackups(directory, { source, databaseId } = {}) {
|
|
92
|
-
if (!existsSync(directory)) return [];
|
|
93
|
-
const scoped = !!source || databaseId !== undefined;
|
|
94
|
-
const sourceStat = source && existsSync(source) ? statSync(source) : null;
|
|
95
|
-
const sourcePath = source
|
|
96
|
-
? sourceStat
|
|
97
|
-
? realpathSync(source)
|
|
98
|
-
: resolve(source)
|
|
99
|
-
: null;
|
|
100
|
-
const identity = databaseId ?? (sourceStat ? archiveIdentity(source) : null);
|
|
101
|
-
const normalize = (file) =>
|
|
102
|
-
process.platform === "win32" ? file.toLowerCase() : file;
|
|
103
|
-
const entries = [];
|
|
104
|
-
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
105
|
-
if (
|
|
106
|
-
!entry.isFile() ||
|
|
107
|
-
!entry.name.startsWith(AUTO_PREFIX) ||
|
|
108
|
-
!entry.name.endsWith(".db")
|
|
109
|
-
)
|
|
110
|
-
continue;
|
|
111
|
-
const file = join(directory, entry.name);
|
|
112
|
-
const stat = lstatSync(file);
|
|
113
|
-
if (
|
|
114
|
-
!stat.isFile() ||
|
|
115
|
-
(sourcePath && normalize(realpathSync(file)) === normalize(sourcePath)) ||
|
|
116
|
-
(sourceStat?.ino &&
|
|
117
|
-
stat.ino === sourceStat.ino &&
|
|
118
|
-
stat.dev === sourceStat.dev)
|
|
119
|
-
)
|
|
120
|
-
continue;
|
|
121
|
-
if (scoped) {
|
|
122
|
-
if (!identity) continue;
|
|
123
|
-
try {
|
|
124
|
-
const manifestPath = `${file}.json`;
|
|
125
|
-
if (!lstatSync(manifestPath).isFile()) continue;
|
|
126
|
-
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
127
|
-
if (
|
|
128
|
-
manifest.format !== 1 ||
|
|
129
|
-
manifest.app !== "luckytri" ||
|
|
130
|
-
manifest.kind !== "auto" ||
|
|
131
|
-
manifest.databaseId !== identity ||
|
|
132
|
-
archiveIdentity(file) !== identity
|
|
133
|
-
)
|
|
134
|
-
continue;
|
|
135
|
-
} catch {
|
|
136
|
-
// Legacy, damaged and unidentifiable copies require a manual decision.
|
|
137
|
-
continue;
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
entries.push({ name: entry.name, file, time: stampTime(entry.name, file) });
|
|
141
|
-
}
|
|
142
|
-
return entries.sort(
|
|
143
|
-
(a, b) => b.time - a.time || b.name.localeCompare(a.name),
|
|
144
|
-
);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Newest first, with the size the management page shows.
|
|
148
|
-
export function listAutoBackups(directory, options = {}) {
|
|
149
|
-
return autoBackups(directory, options).map(({ name, file, time }) => ({
|
|
150
|
-
name,
|
|
151
|
-
time,
|
|
152
|
-
bytes: statSync(file).size,
|
|
153
|
-
}));
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export function autoBackupDue(
|
|
157
|
-
directory,
|
|
158
|
-
{ now = Date.now(), intervalMs = DAY, source, databaseId } = {},
|
|
159
|
-
) {
|
|
160
|
-
const latest = autoBackups(directory, { source, databaseId })[0];
|
|
161
|
-
return !latest || now - latest.time >= intervalMs;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Trim a fresh copy down to what continuity needs, then prove it is readable.
|
|
165
|
-
function slimAndVerify(file, traceCutoff) {
|
|
166
|
-
const db = new DatabaseSync(file);
|
|
167
|
-
try {
|
|
168
|
-
const hasTraces = db
|
|
169
|
-
.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?")
|
|
170
|
-
.get("core_traces");
|
|
171
|
-
if (hasTraces) {
|
|
172
|
-
db.prepare("DELETE FROM core_traces WHERE time<?").run(traceCutoff);
|
|
173
|
-
db.exec("VACUUM");
|
|
174
|
-
}
|
|
175
|
-
const check = db.prepare("PRAGMA integrity_check").all();
|
|
176
|
-
if (check.length !== 1 || check[0].integrity_check !== "ok")
|
|
177
|
-
throw new Error("备份未通过完整性检查,已丢弃");
|
|
178
|
-
const hasEvents = db
|
|
179
|
-
.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?")
|
|
180
|
-
.get("core_events");
|
|
181
|
-
return {
|
|
182
|
-
events: hasEvents
|
|
183
|
-
? Number(db.prepare("SELECT COUNT(*) n FROM core_events").get().n)
|
|
184
|
-
: 0,
|
|
185
|
-
};
|
|
186
|
-
} finally {
|
|
187
|
-
db.close();
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
export function autoBackup(options = {}) {
|
|
192
|
-
const source = options.source || runtimePaths().database;
|
|
193
|
-
const directory = options.directory || backupDirectory(source);
|
|
194
|
-
if (!existsSync(source)) throw new Error(MISSING);
|
|
195
|
-
return withBackupLock(directory, () =>
|
|
196
|
-
runAutoBackup({ ...options, source, directory }),
|
|
197
|
-
);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function runAutoBackup({
|
|
201
|
-
source = runtimePaths().database,
|
|
202
|
-
directory = backupDirectory(source),
|
|
203
|
-
now = Date.now(),
|
|
204
|
-
intervalMs = DAY,
|
|
205
|
-
keep = 7,
|
|
206
|
-
keepTraceDays = 1,
|
|
207
|
-
force = false,
|
|
208
|
-
} = {}) {
|
|
209
|
-
if (!existsSync(source)) throw new Error(MISSING);
|
|
210
|
-
if (!force && !autoBackupDue(directory, { now, intervalMs, source }))
|
|
211
|
-
return { status: "skipped" };
|
|
212
|
-
const databaseId = ensureArchiveIdentity(source);
|
|
213
|
-
mkdirSync(directory, { recursive: true });
|
|
214
|
-
const sourceStat = statSync(source);
|
|
215
|
-
const sourcePath = realpathSync(source);
|
|
216
|
-
const normalize = (file) =>
|
|
217
|
-
process.platform === "win32" ? file.toLowerCase() : file;
|
|
218
|
-
// A run that was cut off leaves a partial file; it is never a backup.
|
|
219
|
-
for (const name of readdirSync(directory)) {
|
|
220
|
-
if (!name.startsWith(AUTO_PREFIX) || !name.endsWith(".partial")) continue;
|
|
221
|
-
const file = join(directory, name);
|
|
222
|
-
const stat = lstatSync(file);
|
|
223
|
-
if (
|
|
224
|
-
stat.isFile() &&
|
|
225
|
-
now - stat.mtimeMs > 6 * HOUR &&
|
|
226
|
-
normalize(realpathSync(file)) !== normalize(sourcePath) &&
|
|
227
|
-
!(
|
|
228
|
-
sourceStat.ino &&
|
|
229
|
-
stat.ino === sourceStat.ino &&
|
|
230
|
-
stat.dev === sourceStat.dev
|
|
231
|
-
)
|
|
232
|
-
)
|
|
233
|
-
rmSync(file, { force: true });
|
|
234
|
-
}
|
|
235
|
-
let stampTimeMs = now;
|
|
236
|
-
let final;
|
|
237
|
-
do {
|
|
238
|
-
const stamp = new Date(stampTimeMs++).toISOString().replace(/[:.]/g, "-");
|
|
239
|
-
final = resolve(join(directory, `${AUTO_PREFIX}${stamp}.db`));
|
|
240
|
-
} while (existsSync(final) || existsSync(`${final}.json`));
|
|
241
|
-
const partial = `${final}.${randomUUID().slice(0, 8)}.partial`;
|
|
242
|
-
let info;
|
|
243
|
-
try {
|
|
244
|
-
copyDatabase(source, partial);
|
|
245
|
-
info = slimAndVerify(partial, now - keepTraceDays * DAY);
|
|
246
|
-
writeArchiveManifest(partial, { kind: "auto", filename: basename(final) });
|
|
247
|
-
renameSync(partial, final);
|
|
248
|
-
renameSync(`${partial}.json`, `${final}.json`);
|
|
249
|
-
} catch (error) {
|
|
250
|
-
rmSync(partial, { force: true });
|
|
251
|
-
rmSync(`${partial}.json`, { force: true });
|
|
252
|
-
throw error;
|
|
253
|
-
}
|
|
254
|
-
const removed = [];
|
|
255
|
-
for (const old of autoBackups(directory, { source, databaseId }).slice(
|
|
256
|
-
Math.max(1, keep),
|
|
257
|
-
)) {
|
|
258
|
-
if (existsSync(`${old.file}-wal`) || existsSync(`${old.file}-shm`))
|
|
259
|
-
continue;
|
|
260
|
-
rmSync(old.file, { force: true });
|
|
261
|
-
rmSync(`${old.file}.json`, { force: true });
|
|
262
|
-
removed.push(old.name);
|
|
263
|
-
}
|
|
264
|
-
return {
|
|
265
|
-
status: "created",
|
|
266
|
-
file: final,
|
|
267
|
-
bytes: statSync(final).size,
|
|
268
|
-
events: info.events,
|
|
269
|
-
removed,
|
|
270
|
-
};
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// Reads the schedule from the environment; 0 turns automatic backups off.
|
|
274
|
-
export function autoBackupOptions(env = process.env) {
|
|
275
|
-
const hours = Number(env.BACKUP_INTERVAL_HOURS ?? 24);
|
|
276
|
-
const keep = Number(env.BACKUP_KEEP ?? 7);
|
|
277
|
-
const traceDays = Number(env.BACKUP_TRACE_DAYS ?? 1);
|
|
278
|
-
return {
|
|
279
|
-
enabled: Number.isFinite(hours) && hours > 0,
|
|
280
|
-
intervalMs: (Number.isFinite(hours) && hours > 0 ? hours : 24) * HOUR,
|
|
281
|
-
keep: Number.isFinite(keep) && keep >= 1 ? Math.floor(keep) : 7,
|
|
282
|
-
keepTraceDays: Number.isFinite(traceDays) && traceDays >= 0 ? traceDays : 1,
|
|
283
|
-
};
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
if (
|
|
287
|
-
process.argv[1] &&
|
|
288
|
-
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
|
289
|
-
) {
|
|
290
|
-
try {
|
|
291
|
-
const { prepareRuntime } = await import("./runtime.js");
|
|
292
|
-
prepareRuntime({ initialize: false });
|
|
293
|
-
if (process.argv.includes("--auto")) {
|
|
294
|
-
const options = autoBackupOptions();
|
|
295
|
-
const result = autoBackup({
|
|
296
|
-
intervalMs: options.intervalMs,
|
|
297
|
-
keep: options.keep,
|
|
298
|
-
keepTraceDays: options.keepTraceDays,
|
|
299
|
-
force: process.argv.includes("--force"),
|
|
300
|
-
});
|
|
301
|
-
console.log(
|
|
302
|
-
result.status === "created"
|
|
303
|
-
? `自动备份完成:${result.file}(${Math.round(result.bytes / 1048576)} MB,${result.events} 条事件${result.removed.length ? `,清理旧备份 ${result.removed.length} 份` : ""})`
|
|
304
|
-
: "自动备份未到时间,已跳过",
|
|
305
|
-
);
|
|
306
|
-
} else console.log("备份完成:" + backupDatabase());
|
|
307
|
-
} catch (error) {
|
|
308
|
-
console.error(error.message);
|
|
309
|
-
process.exitCode = 1;
|
|
310
|
-
}
|
|
311
|
-
}
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
lstatSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
realpathSync,
|
|
9
|
+
renameSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
statSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import { resolve, join, basename, dirname } from "node:path";
|
|
14
|
+
import {
|
|
15
|
+
copyDatabaseFile,
|
|
16
|
+
archiveIdentity,
|
|
17
|
+
writeArchiveManifest,
|
|
18
|
+
ensureArchiveIdentity,
|
|
19
|
+
} from "../server/storage/archive.js";
|
|
20
|
+
import { withBackupLock } from "../server/storage/backup-lock.js";
|
|
21
|
+
import { pathToFileURL } from "node:url";
|
|
22
|
+
import { randomUUID } from "node:crypto";
|
|
23
|
+
import { runtimePaths } from "../server/paths.js";
|
|
24
|
+
|
|
25
|
+
const HOUR = 3600000;
|
|
26
|
+
const DAY = 24 * HOUR;
|
|
27
|
+
export const AUTO_PREFIX = "luckytri-auto-";
|
|
28
|
+
const MISSING = "数据库不存在,请先启动一次 LuckyTri";
|
|
29
|
+
|
|
30
|
+
export function backupDirectory(
|
|
31
|
+
source = runtimePaths().database,
|
|
32
|
+
env = process.env,
|
|
33
|
+
) {
|
|
34
|
+
return env.BACKUP_DIR
|
|
35
|
+
? resolve(runtimePaths(env).home, env.BACKUP_DIR)
|
|
36
|
+
: join(dirname(resolve(source)), "backups");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function copyDatabase(source, target) {
|
|
40
|
+
copyDatabaseFile(source, target);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function backupDatabase(
|
|
44
|
+
source = runtimePaths().database,
|
|
45
|
+
directory = backupDirectory(source),
|
|
46
|
+
) {
|
|
47
|
+
if (!existsSync(source)) throw new Error(MISSING);
|
|
48
|
+
return withBackupLock(directory, () => fullBackup(source, directory));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fullBackup(source, directory) {
|
|
52
|
+
if (!existsSync(source)) throw new Error(MISSING);
|
|
53
|
+
ensureArchiveIdentity(source);
|
|
54
|
+
mkdirSync(directory, { recursive: true });
|
|
55
|
+
const target = resolve(
|
|
56
|
+
join(
|
|
57
|
+
directory,
|
|
58
|
+
`luckytri-${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}.db`,
|
|
59
|
+
),
|
|
60
|
+
);
|
|
61
|
+
const partial = `${target}.partial`;
|
|
62
|
+
try {
|
|
63
|
+
copyDatabase(source, partial);
|
|
64
|
+
writeArchiveManifest(partial, { filename: basename(target) });
|
|
65
|
+
renameSync(partial, target);
|
|
66
|
+
renameSync(`${partial}.json`, `${target}.json`);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
rmSync(partial, { force: true });
|
|
69
|
+
rmSync(`${partial}.json`, { force: true });
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
return target;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Automatic backups exist to keep her continuity: what she remembers, how she
|
|
76
|
+
// has changed and the days she has lived. Model call traces are megabytes each
|
|
77
|
+
// and can be rebuilt from nothing that matters, so old ones are left out; that
|
|
78
|
+
// keeps a daily copy small enough to actually keep a week of them.
|
|
79
|
+
// When a backup was made comes from its name, so copying the folder to another
|
|
80
|
+
// disk does not make old backups look new.
|
|
81
|
+
function stampTime(name, file) {
|
|
82
|
+
const match = /(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z/.exec(
|
|
83
|
+
name,
|
|
84
|
+
);
|
|
85
|
+
const parsed = match
|
|
86
|
+
? Date.parse(`${match[1]}T${match[2]}:${match[3]}:${match[4]}.${match[5]}Z`)
|
|
87
|
+
: NaN;
|
|
88
|
+
return Number.isFinite(parsed) ? parsed : statSync(file).mtimeMs;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function autoBackups(directory, { source, databaseId } = {}) {
|
|
92
|
+
if (!existsSync(directory)) return [];
|
|
93
|
+
const scoped = !!source || databaseId !== undefined;
|
|
94
|
+
const sourceStat = source && existsSync(source) ? statSync(source) : null;
|
|
95
|
+
const sourcePath = source
|
|
96
|
+
? sourceStat
|
|
97
|
+
? realpathSync(source)
|
|
98
|
+
: resolve(source)
|
|
99
|
+
: null;
|
|
100
|
+
const identity = databaseId ?? (sourceStat ? archiveIdentity(source) : null);
|
|
101
|
+
const normalize = (file) =>
|
|
102
|
+
process.platform === "win32" ? file.toLowerCase() : file;
|
|
103
|
+
const entries = [];
|
|
104
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
105
|
+
if (
|
|
106
|
+
!entry.isFile() ||
|
|
107
|
+
!entry.name.startsWith(AUTO_PREFIX) ||
|
|
108
|
+
!entry.name.endsWith(".db")
|
|
109
|
+
)
|
|
110
|
+
continue;
|
|
111
|
+
const file = join(directory, entry.name);
|
|
112
|
+
const stat = lstatSync(file);
|
|
113
|
+
if (
|
|
114
|
+
!stat.isFile() ||
|
|
115
|
+
(sourcePath && normalize(realpathSync(file)) === normalize(sourcePath)) ||
|
|
116
|
+
(sourceStat?.ino &&
|
|
117
|
+
stat.ino === sourceStat.ino &&
|
|
118
|
+
stat.dev === sourceStat.dev)
|
|
119
|
+
)
|
|
120
|
+
continue;
|
|
121
|
+
if (scoped) {
|
|
122
|
+
if (!identity) continue;
|
|
123
|
+
try {
|
|
124
|
+
const manifestPath = `${file}.json`;
|
|
125
|
+
if (!lstatSync(manifestPath).isFile()) continue;
|
|
126
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
127
|
+
if (
|
|
128
|
+
manifest.format !== 1 ||
|
|
129
|
+
manifest.app !== "luckytri" ||
|
|
130
|
+
manifest.kind !== "auto" ||
|
|
131
|
+
manifest.databaseId !== identity ||
|
|
132
|
+
archiveIdentity(file) !== identity
|
|
133
|
+
)
|
|
134
|
+
continue;
|
|
135
|
+
} catch {
|
|
136
|
+
// Legacy, damaged and unidentifiable copies require a manual decision.
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
entries.push({ name: entry.name, file, time: stampTime(entry.name, file) });
|
|
141
|
+
}
|
|
142
|
+
return entries.sort(
|
|
143
|
+
(a, b) => b.time - a.time || b.name.localeCompare(a.name),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Newest first, with the size the management page shows.
|
|
148
|
+
export function listAutoBackups(directory, options = {}) {
|
|
149
|
+
return autoBackups(directory, options).map(({ name, file, time }) => ({
|
|
150
|
+
name,
|
|
151
|
+
time,
|
|
152
|
+
bytes: statSync(file).size,
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function autoBackupDue(
|
|
157
|
+
directory,
|
|
158
|
+
{ now = Date.now(), intervalMs = DAY, source, databaseId } = {},
|
|
159
|
+
) {
|
|
160
|
+
const latest = autoBackups(directory, { source, databaseId })[0];
|
|
161
|
+
return !latest || now - latest.time >= intervalMs;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Trim a fresh copy down to what continuity needs, then prove it is readable.
|
|
165
|
+
function slimAndVerify(file, traceCutoff) {
|
|
166
|
+
const db = new DatabaseSync(file);
|
|
167
|
+
try {
|
|
168
|
+
const hasTraces = db
|
|
169
|
+
.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?")
|
|
170
|
+
.get("core_traces");
|
|
171
|
+
if (hasTraces) {
|
|
172
|
+
db.prepare("DELETE FROM core_traces WHERE time<?").run(traceCutoff);
|
|
173
|
+
db.exec("VACUUM");
|
|
174
|
+
}
|
|
175
|
+
const check = db.prepare("PRAGMA integrity_check").all();
|
|
176
|
+
if (check.length !== 1 || check[0].integrity_check !== "ok")
|
|
177
|
+
throw new Error("备份未通过完整性检查,已丢弃");
|
|
178
|
+
const hasEvents = db
|
|
179
|
+
.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?")
|
|
180
|
+
.get("core_events");
|
|
181
|
+
return {
|
|
182
|
+
events: hasEvents
|
|
183
|
+
? Number(db.prepare("SELECT COUNT(*) n FROM core_events").get().n)
|
|
184
|
+
: 0,
|
|
185
|
+
};
|
|
186
|
+
} finally {
|
|
187
|
+
db.close();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function autoBackup(options = {}) {
|
|
192
|
+
const source = options.source || runtimePaths().database;
|
|
193
|
+
const directory = options.directory || backupDirectory(source);
|
|
194
|
+
if (!existsSync(source)) throw new Error(MISSING);
|
|
195
|
+
return withBackupLock(directory, () =>
|
|
196
|
+
runAutoBackup({ ...options, source, directory }),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function runAutoBackup({
|
|
201
|
+
source = runtimePaths().database,
|
|
202
|
+
directory = backupDirectory(source),
|
|
203
|
+
now = Date.now(),
|
|
204
|
+
intervalMs = DAY,
|
|
205
|
+
keep = 7,
|
|
206
|
+
keepTraceDays = 1,
|
|
207
|
+
force = false,
|
|
208
|
+
} = {}) {
|
|
209
|
+
if (!existsSync(source)) throw new Error(MISSING);
|
|
210
|
+
if (!force && !autoBackupDue(directory, { now, intervalMs, source }))
|
|
211
|
+
return { status: "skipped" };
|
|
212
|
+
const databaseId = ensureArchiveIdentity(source);
|
|
213
|
+
mkdirSync(directory, { recursive: true });
|
|
214
|
+
const sourceStat = statSync(source);
|
|
215
|
+
const sourcePath = realpathSync(source);
|
|
216
|
+
const normalize = (file) =>
|
|
217
|
+
process.platform === "win32" ? file.toLowerCase() : file;
|
|
218
|
+
// A run that was cut off leaves a partial file; it is never a backup.
|
|
219
|
+
for (const name of readdirSync(directory)) {
|
|
220
|
+
if (!name.startsWith(AUTO_PREFIX) || !name.endsWith(".partial")) continue;
|
|
221
|
+
const file = join(directory, name);
|
|
222
|
+
const stat = lstatSync(file);
|
|
223
|
+
if (
|
|
224
|
+
stat.isFile() &&
|
|
225
|
+
now - stat.mtimeMs > 6 * HOUR &&
|
|
226
|
+
normalize(realpathSync(file)) !== normalize(sourcePath) &&
|
|
227
|
+
!(
|
|
228
|
+
sourceStat.ino &&
|
|
229
|
+
stat.ino === sourceStat.ino &&
|
|
230
|
+
stat.dev === sourceStat.dev
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
rmSync(file, { force: true });
|
|
234
|
+
}
|
|
235
|
+
let stampTimeMs = now;
|
|
236
|
+
let final;
|
|
237
|
+
do {
|
|
238
|
+
const stamp = new Date(stampTimeMs++).toISOString().replace(/[:.]/g, "-");
|
|
239
|
+
final = resolve(join(directory, `${AUTO_PREFIX}${stamp}.db`));
|
|
240
|
+
} while (existsSync(final) || existsSync(`${final}.json`));
|
|
241
|
+
const partial = `${final}.${randomUUID().slice(0, 8)}.partial`;
|
|
242
|
+
let info;
|
|
243
|
+
try {
|
|
244
|
+
copyDatabase(source, partial);
|
|
245
|
+
info = slimAndVerify(partial, now - keepTraceDays * DAY);
|
|
246
|
+
writeArchiveManifest(partial, { kind: "auto", filename: basename(final) });
|
|
247
|
+
renameSync(partial, final);
|
|
248
|
+
renameSync(`${partial}.json`, `${final}.json`);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
rmSync(partial, { force: true });
|
|
251
|
+
rmSync(`${partial}.json`, { force: true });
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
const removed = [];
|
|
255
|
+
for (const old of autoBackups(directory, { source, databaseId }).slice(
|
|
256
|
+
Math.max(1, keep),
|
|
257
|
+
)) {
|
|
258
|
+
if (existsSync(`${old.file}-wal`) || existsSync(`${old.file}-shm`))
|
|
259
|
+
continue;
|
|
260
|
+
rmSync(old.file, { force: true });
|
|
261
|
+
rmSync(`${old.file}.json`, { force: true });
|
|
262
|
+
removed.push(old.name);
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
status: "created",
|
|
266
|
+
file: final,
|
|
267
|
+
bytes: statSync(final).size,
|
|
268
|
+
events: info.events,
|
|
269
|
+
removed,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Reads the schedule from the environment; 0 turns automatic backups off.
|
|
274
|
+
export function autoBackupOptions(env = process.env) {
|
|
275
|
+
const hours = Number(env.BACKUP_INTERVAL_HOURS ?? 24);
|
|
276
|
+
const keep = Number(env.BACKUP_KEEP ?? 7);
|
|
277
|
+
const traceDays = Number(env.BACKUP_TRACE_DAYS ?? 1);
|
|
278
|
+
return {
|
|
279
|
+
enabled: Number.isFinite(hours) && hours > 0,
|
|
280
|
+
intervalMs: (Number.isFinite(hours) && hours > 0 ? hours : 24) * HOUR,
|
|
281
|
+
keep: Number.isFinite(keep) && keep >= 1 ? Math.floor(keep) : 7,
|
|
282
|
+
keepTraceDays: Number.isFinite(traceDays) && traceDays >= 0 ? traceDays : 1,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (
|
|
287
|
+
process.argv[1] &&
|
|
288
|
+
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
|
289
|
+
) {
|
|
290
|
+
try {
|
|
291
|
+
const { prepareRuntime } = await import("./runtime.js");
|
|
292
|
+
prepareRuntime({ initialize: false });
|
|
293
|
+
if (process.argv.includes("--auto")) {
|
|
294
|
+
const options = autoBackupOptions();
|
|
295
|
+
const result = autoBackup({
|
|
296
|
+
intervalMs: options.intervalMs,
|
|
297
|
+
keep: options.keep,
|
|
298
|
+
keepTraceDays: options.keepTraceDays,
|
|
299
|
+
force: process.argv.includes("--force"),
|
|
300
|
+
});
|
|
301
|
+
console.log(
|
|
302
|
+
result.status === "created"
|
|
303
|
+
? `自动备份完成:${result.file}(${Math.round(result.bytes / 1048576)} MB,${result.events} 条事件${result.removed.length ? `,清理旧备份 ${result.removed.length} 份` : ""})`
|
|
304
|
+
: "自动备份未到时间,已跳过",
|
|
305
|
+
);
|
|
306
|
+
} else console.log("备份完成:" + backupDatabase());
|
|
307
|
+
} catch (error) {
|
|
308
|
+
console.error(error.message);
|
|
309
|
+
process.exitCode = 1;
|
|
310
|
+
}
|
|
311
|
+
}
|