@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 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
- let db = await this.openDatabaseWithRecovery(dbPath);
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 && startupBackupEnabled(env)) {
52
- await store.backupHealthyDatabase();
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
- static assertHealthy(db, dbPath) {
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 startupBackups = node_fs_1.default.existsSync(dir)
112
- ? node_fs_1.default.readdirSync(dir)
113
- .filter((name) => name.startsWith(`${baseName}.startup.`) && name.endsWith(".bak"))
114
- .sort()
115
- .reverse()
116
- .map((name) => node_path_1.default.join(dir, name))
117
- : [];
118
- return [`${dbPath}.bak`, ...startupBackups].filter((candidate, index, all) => node_fs_1.default.existsSync(candidate) && all.indexOf(candidate) === index);
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
- integrityCheck: String(this.db.pragma("integrity_check", { simple: true })),
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
- async backupHealthyDatabase() {
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
- function startupBackupEnabled(env) {
2388
- return !(0, util_1.isEnvFlagDisabled)(env.AGENTINBOX_STARTUP_BACKUP);
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>.bak.<pid>.tmp` files whose owning process is dead.
2392
- * Tmp files owned by a live pid belong to a concurrent open and are preserved.
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@holon-run/agentinbox",
3
- "version": "1.5.2",
3
+ "version": "1.6.0",
4
4
  "description": "Local event subscription and delivery service for agents.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {