@holon-run/agentinbox 1.5.2 → 1.6.0
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/README.md +16 -0
- package/dist/src/cli.js +36 -0
- package/dist/src/store.js +130 -27
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -299,6 +299,22 @@ that database next to the DB path (for example,
|
|
|
299
299
|
`~/.agentinbox/agentinbox.sqlite.pre-v1.<timestamp>.bak`) and starts with a
|
|
300
300
|
fresh v1 database; pre-v1 local data is not imported.
|
|
301
301
|
|
|
302
|
+
## Database Backups
|
|
303
|
+
|
|
304
|
+
Startup backups are event-driven, not unconditional:
|
|
305
|
+
|
|
306
|
+
- before pending schema migrations run, a full backup is written to
|
|
307
|
+
`<db>.pre-migrate-v<N>.bak` (bounded by `AGENTINBOX_MIGRATION_BACKUP_KEEP`,
|
|
308
|
+
default 5; `0` keeps all)
|
|
309
|
+
- `agentinbox backup [--home DIR] [--state PATH]` writes a manual snapshot to
|
|
310
|
+
`<db>.bak` on demand, for scheduled or pre-upgrade snapshots
|
|
311
|
+
- regular opens verify the database with `PRAGMA quick_check` and escalate to
|
|
312
|
+
a full `integrity_check` only when the quick pass fails; recovery considers
|
|
313
|
+
manual and pre-migration backups, most recent first
|
|
314
|
+
|
|
315
|
+
Setting `AGENTINBOX_STARTUP_BACKUP=1` restores the legacy behavior of backing
|
|
316
|
+
up (and fully checking) the database on every open.
|
|
317
|
+
|
|
302
318
|
## License
|
|
303
319
|
|
|
304
320
|
Apache-2.0
|
package/dist/src/cli.js
CHANGED
|
@@ -60,6 +60,14 @@ async function main() {
|
|
|
60
60
|
await runDaemon(normalized.slice(1));
|
|
61
61
|
return;
|
|
62
62
|
}
|
|
63
|
+
if (command === "backup") {
|
|
64
|
+
if (hasHelpFlag(normalized.slice(1))) {
|
|
65
|
+
printHelp(["backup"]);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
await runBackup(normalized.slice(1));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
63
71
|
if (hasHelpFlag(normalized.slice(1))) {
|
|
64
72
|
printHelp([command]);
|
|
65
73
|
return;
|
|
@@ -971,6 +979,7 @@ const COMMAND_SHELL_SPECS = [
|
|
|
971
979
|
{ name: "subscription", group: true, summary: "Manage source subscriptions." },
|
|
972
980
|
{ name: "inbox", group: true, summary: "Read and manage agent inboxes." },
|
|
973
981
|
{ name: "gc", group: false, summary: "Run garbage collection." },
|
|
982
|
+
{ name: "backup", group: false, summary: "Write a local database backup snapshot." },
|
|
974
983
|
{ name: "deliver", group: true, summary: "Send outbound delivery actions." },
|
|
975
984
|
{ name: "status", group: false, summary: "Show daemon status." },
|
|
976
985
|
{ name: "version", group: false, summary: "Show CLI version." },
|
|
@@ -1006,6 +1015,25 @@ function parseCommanderShell(args) {
|
|
|
1006
1015
|
isBareGroup: Boolean(COMMAND_SHELL_SPECS.find((item) => item.name === spec?.name())?.group && args.length === 1),
|
|
1007
1016
|
};
|
|
1008
1017
|
}
|
|
1018
|
+
async function runBackup(args) {
|
|
1019
|
+
const serveConfig = (0, paths_1.resolveServeConfig)({
|
|
1020
|
+
env: process.env,
|
|
1021
|
+
homeDirOverride: takeFlagValue(args, "--home"),
|
|
1022
|
+
statePathOverride: takeFlagValue(args, "--state"),
|
|
1023
|
+
});
|
|
1024
|
+
const store = await store_1.AgentInboxStore.open(serveConfig.dbPath);
|
|
1025
|
+
try {
|
|
1026
|
+
const backupPath = await store.backupDatabase();
|
|
1027
|
+
console.log((0, util_1.jsonResponse)({
|
|
1028
|
+
ok: true,
|
|
1029
|
+
dbPath: serveConfig.dbPath,
|
|
1030
|
+
backupPath,
|
|
1031
|
+
}));
|
|
1032
|
+
}
|
|
1033
|
+
finally {
|
|
1034
|
+
store.close();
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1009
1037
|
async function runServe(args) {
|
|
1010
1038
|
const port = parseOptionalNumber(takeFlagValue(args, "--port"));
|
|
1011
1039
|
const homeOverride = takeFlagValue(args, "--home");
|
|
@@ -1677,6 +1705,14 @@ Usage:
|
|
|
1677
1705
|
agentinbox daemon start [--home ~/.agentinbox] [--socket ~/.agentinbox/agentinbox.sock] [--log-level error|warn|info|debug|trace]
|
|
1678
1706
|
agentinbox daemon stop [--home ~/.agentinbox] [--socket ~/.agentinbox/agentinbox.sock]
|
|
1679
1707
|
agentinbox daemon status [--home ~/.agentinbox] [--socket ~/.agentinbox/agentinbox.sock]
|
|
1708
|
+
`,
|
|
1709
|
+
backup: `agentinbox backup
|
|
1710
|
+
|
|
1711
|
+
Usage:
|
|
1712
|
+
agentinbox backup [--home ~/.agentinbox] [--state ~/.agentinbox/agentinbox.sqlite]
|
|
1713
|
+
|
|
1714
|
+
Writes a verified snapshot to <db>.bak (replaces the previous manual backup).
|
|
1715
|
+
Pre-migration backups (<db>.pre-migrate-v<N>.bak) are created automatically.
|
|
1680
1716
|
`,
|
|
1681
1717
|
host: `agentinbox host
|
|
1682
1718
|
|
package/dist/src/store.js
CHANGED
|
@@ -40,24 +40,29 @@ class AgentInboxStore {
|
|
|
40
40
|
node_fs_1.default.mkdirSync(node_path_1.default.dirname(dbPath), { recursive: true });
|
|
41
41
|
removeOrphanBackupTmps(dbPath);
|
|
42
42
|
const existedBeforeOpen = node_fs_1.default.existsSync(dbPath);
|
|
43
|
-
|
|
43
|
+
// Legacy AGENTINBOX_STARTUP_BACKUP=1 mode pays for a full check and an
|
|
44
|
+
// unconditional backup on every open, matching v1.5.2 behavior.
|
|
45
|
+
const legacyStartupBackup = legacyStartupBackupEnabled(env);
|
|
46
|
+
let db = await this.openDatabaseWithRecovery(dbPath, legacyStartupBackup);
|
|
44
47
|
let store = new AgentInboxStore(dbPath, db);
|
|
48
|
+
let archivedPreV1 = false;
|
|
45
49
|
if (existedBeforeOpen && store.shouldArchivePreV1Database()) {
|
|
46
50
|
const archivedPath = store.archivePreV1Database();
|
|
47
51
|
console.warn(`[agentinbox] archived pre-v1 local database to ${archivedPath}; starting with a fresh v1 database (no data imported).`);
|
|
48
52
|
db = this.openDatabase(dbPath);
|
|
49
53
|
store = new AgentInboxStore(dbPath, db);
|
|
54
|
+
archivedPreV1 = true;
|
|
50
55
|
}
|
|
51
|
-
else if (existedBeforeOpen &&
|
|
52
|
-
await store.
|
|
56
|
+
else if (existedBeforeOpen && legacyStartupBackup) {
|
|
57
|
+
await store.backupDatabase();
|
|
53
58
|
}
|
|
54
|
-
store.migrate();
|
|
59
|
+
await store.migrate({ existedBeforeOpen: existedBeforeOpen && !archivedPreV1, env });
|
|
55
60
|
store.persist();
|
|
56
61
|
return store;
|
|
57
62
|
}
|
|
58
|
-
static async openDatabaseWithRecovery(dbPath) {
|
|
63
|
+
static async openDatabaseWithRecovery(dbPath, fullCheck) {
|
|
59
64
|
try {
|
|
60
|
-
return this.openDatabase(dbPath);
|
|
65
|
+
return this.openDatabase(dbPath, fullCheck);
|
|
61
66
|
}
|
|
62
67
|
catch (error) {
|
|
63
68
|
if (!node_fs_1.default.existsSync(dbPath)) {
|
|
@@ -71,19 +76,29 @@ class AgentInboxStore {
|
|
|
71
76
|
node_fs_1.default.renameSync(dbPath, corruptPath);
|
|
72
77
|
node_fs_1.default.copyFileSync(backupPath, dbPath);
|
|
73
78
|
console.warn(`[agentinbox] recovered local database from ${backupPath}; archived corrupt database to ${corruptPath}.`);
|
|
74
|
-
return this.openDatabase(dbPath);
|
|
79
|
+
return this.openDatabase(dbPath, fullCheck);
|
|
75
80
|
}
|
|
76
81
|
}
|
|
77
|
-
static openDatabase(dbPath) {
|
|
82
|
+
static openDatabase(dbPath, fullCheck = false) {
|
|
78
83
|
const db = new better_sqlite3_1.default(dbPath);
|
|
79
84
|
db.pragma("busy_timeout = 5000");
|
|
80
85
|
db.pragma("journal_mode = WAL");
|
|
81
86
|
db.pragma("synchronous = NORMAL");
|
|
82
87
|
db.pragma("foreign_keys = ON");
|
|
83
|
-
this.assertHealthy(db, dbPath);
|
|
88
|
+
this.assertHealthy(db, dbPath, fullCheck);
|
|
84
89
|
return db;
|
|
85
90
|
}
|
|
86
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Verifies database integrity. Regular opens run `quick_check` and only
|
|
93
|
+
* escalate to a full `integrity_check` when the quick pass fails, so large
|
|
94
|
+
* databases do not pay a full scan on every startup. Conservative callers
|
|
95
|
+
* (legacy `AGENTINBOX_STARTUP_BACKUP=1` mode, recovery-candidate
|
|
96
|
+
* validation) pass `fullCheck`.
|
|
97
|
+
*/
|
|
98
|
+
static assertHealthy(db, dbPath, fullCheck) {
|
|
99
|
+
if (!fullCheck && db.pragma("quick_check", { simple: true }) === "ok") {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
87
102
|
const result = db.pragma("integrity_check", { simple: true });
|
|
88
103
|
if (result !== "ok") {
|
|
89
104
|
db.close();
|
|
@@ -95,7 +110,7 @@ class AgentInboxStore {
|
|
|
95
110
|
for (const candidate of candidates) {
|
|
96
111
|
try {
|
|
97
112
|
const db = new better_sqlite3_1.default(candidate, { readonly: true, fileMustExist: true });
|
|
98
|
-
this.assertHealthy(db, candidate);
|
|
113
|
+
this.assertHealthy(db, candidate, true);
|
|
99
114
|
db.close();
|
|
100
115
|
return candidate;
|
|
101
116
|
}
|
|
@@ -108,14 +123,32 @@ class AgentInboxStore {
|
|
|
108
123
|
static listBackupCandidates(dbPath) {
|
|
109
124
|
const dir = node_path_1.default.dirname(dbPath);
|
|
110
125
|
const baseName = node_path_1.default.basename(dbPath);
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
126
|
+
const preMigrationPattern = new RegExp(`^${escapeRegExp(baseName)}\\.pre-migrate-v(\\d+)\\.bak$`);
|
|
127
|
+
let entries;
|
|
128
|
+
try {
|
|
129
|
+
entries = node_fs_1.default.readdirSync(dir);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
const candidates = [];
|
|
135
|
+
for (const name of entries) {
|
|
136
|
+
const candidatePath = node_path_1.default.join(dir, name);
|
|
137
|
+
if (name === `${baseName}.bak`) {
|
|
138
|
+
candidates.push({ path: candidatePath, version: 0, modifiedAtMs: backupMtimeMs(candidatePath) });
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const preMigration = preMigrationPattern.exec(name);
|
|
142
|
+
if (preMigration) {
|
|
143
|
+
candidates.push({
|
|
144
|
+
path: candidatePath,
|
|
145
|
+
version: Number.parseInt(preMigration[1], 10),
|
|
146
|
+
modifiedAtMs: backupMtimeMs(candidatePath),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
candidates.sort((left, right) => right.modifiedAtMs - left.modifiedAtMs || right.version - left.version);
|
|
151
|
+
return candidates.map((candidate) => candidate.path);
|
|
119
152
|
}
|
|
120
153
|
static nextPath(base) {
|
|
121
154
|
if (!node_fs_1.default.existsSync(base)) {
|
|
@@ -135,13 +168,22 @@ class AgentInboxStore {
|
|
|
135
168
|
}
|
|
136
169
|
getDatabaseHealth() {
|
|
137
170
|
return {
|
|
138
|
-
|
|
171
|
+
quickCheck: String(this.db.pragma("quick_check", { simple: true })),
|
|
139
172
|
journalMode: String(this.db.pragma("journal_mode", { simple: true })),
|
|
140
173
|
foreignKeys: Number(this.db.pragma("foreign_keys", { simple: true })) === 1,
|
|
141
174
|
};
|
|
142
175
|
}
|
|
143
|
-
|
|
176
|
+
/**
|
|
177
|
+
* Writes a snapshot to `<db>.bak`, used by `agentinbox backup` and by the
|
|
178
|
+
* opt-in legacy `AGENTINBOX_STARTUP_BACKUP=1` startup backup. The open-time
|
|
179
|
+
* health check has already run against this handle. Returns the backup path.
|
|
180
|
+
*/
|
|
181
|
+
async backupDatabase() {
|
|
144
182
|
const backupPath = `${this.dbPath}.bak`;
|
|
183
|
+
await this.writeBackupTo(backupPath);
|
|
184
|
+
return backupPath;
|
|
185
|
+
}
|
|
186
|
+
async writeBackupTo(backupPath) {
|
|
145
187
|
const tmpPath = `${backupPath}.${process.pid}.tmp`;
|
|
146
188
|
try {
|
|
147
189
|
node_fs_1.default.rmSync(tmpPath, { force: true });
|
|
@@ -152,17 +194,57 @@ class AgentInboxStore {
|
|
|
152
194
|
node_fs_1.default.rmSync(tmpPath, { force: true });
|
|
153
195
|
}
|
|
154
196
|
}
|
|
155
|
-
migrate() {
|
|
197
|
+
async migrate(options) {
|
|
156
198
|
const migrations = this.loadSqlMigrations();
|
|
157
199
|
this.ensureDrizzleMigrationsTable();
|
|
158
200
|
const applied = this.listAppliedMigrationTags();
|
|
159
201
|
const pending = migrations.filter((migration) => !applied.has(migration.tag));
|
|
202
|
+
if (options.existedBeforeOpen && pending.length > 0) {
|
|
203
|
+
const backupPath = await this.backupBeforeMigration(migrations.length);
|
|
204
|
+
console.warn(`[agentinbox] pending schema migrations detected; backed up the local database to ${backupPath} before migrating to schema v${migrations.length}.`);
|
|
205
|
+
this.pruneMigrationBackups(options.env);
|
|
206
|
+
}
|
|
160
207
|
for (const migration of pending) {
|
|
161
208
|
this.applyMigration(migration);
|
|
162
209
|
}
|
|
163
210
|
this.ensureInboxEntryBackfill();
|
|
164
211
|
this.setUserVersion(migrations.length);
|
|
165
212
|
}
|
|
213
|
+
async backupBeforeMigration(targetVersion) {
|
|
214
|
+
const backupPath = `${this.dbPath}.pre-migrate-v${targetVersion}.bak`;
|
|
215
|
+
await this.writeBackupTo(backupPath);
|
|
216
|
+
return backupPath;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Keeps only the newest `AGENTINBOX_MIGRATION_BACKUP_KEEP` pre-migration
|
|
220
|
+
* backups (default 5; values <= 0 keep everything). Migration backups are
|
|
221
|
+
* rare, so pruning only runs right after a new one is written.
|
|
222
|
+
*/
|
|
223
|
+
pruneMigrationBackups(env) {
|
|
224
|
+
const keep = migrationBackupKeep(env);
|
|
225
|
+
if (keep <= 0) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const dir = node_path_1.default.dirname(this.dbPath);
|
|
229
|
+
const pattern = new RegExp(`^${escapeRegExp(node_path_1.default.basename(this.dbPath))}\\.pre-migrate-v(\\d+)\\.bak$`);
|
|
230
|
+
const entries = [];
|
|
231
|
+
for (const name of node_fs_1.default.readdirSync(dir)) {
|
|
232
|
+
const match = pattern.exec(name);
|
|
233
|
+
if (!match) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
entries.push({ path: node_path_1.default.join(dir, name), version: Number.parseInt(match[1], 10) });
|
|
237
|
+
}
|
|
238
|
+
entries.sort((left, right) => right.version - left.version);
|
|
239
|
+
for (const entry of entries.slice(keep)) {
|
|
240
|
+
try {
|
|
241
|
+
node_fs_1.default.rmSync(entry.path, { force: true });
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
// Best-effort pruning.
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
166
248
|
loadSqlMigrations() {
|
|
167
249
|
const migrationsDir = this.resolveMigrationsDir();
|
|
168
250
|
const files = node_fs_1.default
|
|
@@ -2384,16 +2466,37 @@ function uniqueSorted(values) {
|
|
|
2384
2466
|
function summarizeBackfilledItemEntry(item) {
|
|
2385
2467
|
return `${item.eventVariant} from ${item.sourceId}`;
|
|
2386
2468
|
}
|
|
2387
|
-
|
|
2388
|
-
|
|
2469
|
+
const DEFAULT_MIGRATION_BACKUP_KEEP = 5;
|
|
2470
|
+
/**
|
|
2471
|
+
* Backups are event-driven: a full database backup is taken only before
|
|
2472
|
+
* pending schema migrations run, plus explicit `agentinbox backup` snapshots.
|
|
2473
|
+
* Setting `AGENTINBOX_STARTUP_BACKUP=1` opts back into the legacy v1.5.2
|
|
2474
|
+
* behavior of an unconditional backup and a full `integrity_check` on every
|
|
2475
|
+
* open.
|
|
2476
|
+
*/
|
|
2477
|
+
function legacyStartupBackupEnabled(env) {
|
|
2478
|
+
return (0, util_1.isEnvFlagEnabled)(env.AGENTINBOX_STARTUP_BACKUP);
|
|
2479
|
+
}
|
|
2480
|
+
function migrationBackupKeep(env) {
|
|
2481
|
+
const parsed = Number.parseInt(env.AGENTINBOX_MIGRATION_BACKUP_KEEP ?? "", 10);
|
|
2482
|
+
return Number.isInteger(parsed) ? parsed : DEFAULT_MIGRATION_BACKUP_KEEP;
|
|
2483
|
+
}
|
|
2484
|
+
function backupMtimeMs(candidatePath) {
|
|
2485
|
+
try {
|
|
2486
|
+
return node_fs_1.default.statSync(candidatePath).mtimeMs;
|
|
2487
|
+
}
|
|
2488
|
+
catch {
|
|
2489
|
+
return 0;
|
|
2490
|
+
}
|
|
2389
2491
|
}
|
|
2390
2492
|
/**
|
|
2391
|
-
* Removes leftover `<db
|
|
2392
|
-
*
|
|
2493
|
+
* Removes leftover `<db>.*.bak.<pid>.tmp` files (manual and pre-migration
|
|
2494
|
+
* backup temporaries) whose owning process is dead. Tmp files owned by a live
|
|
2495
|
+
* pid belong to a concurrent open and are preserved.
|
|
2393
2496
|
*/
|
|
2394
2497
|
function removeOrphanBackupTmps(dbPath) {
|
|
2395
2498
|
const dir = node_path_1.default.dirname(dbPath);
|
|
2396
|
-
const pattern = new RegExp(`^${escapeRegExp(node_path_1.default.basename(dbPath))}\\.bak\\.(\\d+)\\.tmp$`);
|
|
2499
|
+
const pattern = new RegExp(`^${escapeRegExp(node_path_1.default.basename(dbPath))}\\.(?:.+\\.)?bak\\.(\\d+)\\.tmp$`);
|
|
2397
2500
|
let entries;
|
|
2398
2501
|
try {
|
|
2399
2502
|
entries = node_fs_1.default.readdirSync(dir);
|