@holon-run/agentinbox 1.5.1 → 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");
@@ -1019,6 +1047,25 @@ async function runServe(args) {
1019
1047
  const daemonPaths = (0, paths_1.resolveDaemonPaths)(process.env, homeOverride);
1020
1048
  const logLevel = (0, logging_1.parseLogLevel)(takeFlagValue(args, "--log-level") ?? process.env.AGENTINBOX_LOG_LEVEL);
1021
1049
  const logger = new logging_1.JsonLogger(logLevel, "agentinbox");
1050
+ // Socket transport: take the admission lock and publish the pid file before
1051
+ // opening the store, so concurrent starters and status checks see this
1052
+ // process during the (possibly slow) store open instead of racing it.
1053
+ let daemonLock = null;
1054
+ if (serveConfig.transport.kind === "socket") {
1055
+ daemonLock = (0, paths_1.daemonLockPath)(serveConfig.transport.socketPath);
1056
+ const acquisition = (0, daemon_1.acquireDaemonLock)(daemonLock);
1057
+ if (!acquisition.acquired) {
1058
+ const holderPid = acquisition.holder?.pid ?? null;
1059
+ logger.warn("daemon.already_running", {
1060
+ pid: holderPid,
1061
+ socketPath: serveConfig.transport.socketPath,
1062
+ lockPath: daemonLock,
1063
+ });
1064
+ console.error(`agentinbox daemon already running${holderPid != null ? ` (pid ${holderPid})` : ""}; not starting a second instance`);
1065
+ process.exit(0);
1066
+ }
1067
+ (0, daemon_1.writePidFile)(daemonPaths.pidPath, process.pid);
1068
+ }
1022
1069
  const store = await store_1.AgentInboxStore.open(serveConfig.dbPath);
1023
1070
  let service;
1024
1071
  const adapters = new adapters_1.AdapterRegistry(store, async (input) => service.appendSourceEvent(input), {
@@ -1029,9 +1076,6 @@ async function runServe(args) {
1029
1076
  await adapters.start();
1030
1077
  await service.start();
1031
1078
  const controlServer = await (0, control_server_1.startControlServer)(server, serveConfig.transport);
1032
- if (serveConfig.transport.kind === "socket") {
1033
- (0, daemon_1.writePidFile)(daemonPaths.pidPath, process.pid);
1034
- }
1035
1079
  (0, daemon_1.writeDaemonMetadata)(daemonPaths.metadataPath, { logLevel });
1036
1080
  logger.info("daemon.ready", {
1037
1081
  homeDir: serveConfig.homeDir,
@@ -1050,8 +1094,11 @@ async function runServe(args) {
1050
1094
  void adapters.stop();
1051
1095
  void service.stop();
1052
1096
  store.close();
1053
- if (serveConfig.transport.kind === "socket") {
1054
- (0, daemon_1.removePidFile)(daemonPaths.pidPath);
1097
+ if (serveConfig.transport.kind === "socket" && daemonLock != null) {
1098
+ // Ownership: only remove files that still belong to this process so a
1099
+ // replaced or rebound instance keeps its own pid file and socket.
1100
+ (0, daemon_1.removePidFileIfOwned)(daemonPaths.pidPath, process.pid);
1101
+ (0, daemon_1.releaseDaemonLock)(daemonLock, process.pid);
1055
1102
  }
1056
1103
  (0, daemon_1.removePidFile)(daemonPaths.metadataPath);
1057
1104
  process.exit(0);
@@ -1658,6 +1705,14 @@ Usage:
1658
1705
  agentinbox daemon start [--home ~/.agentinbox] [--socket ~/.agentinbox/agentinbox.sock] [--log-level error|warn|info|debug|trace]
1659
1706
  agentinbox daemon stop [--home ~/.agentinbox] [--socket ~/.agentinbox/agentinbox.sock]
1660
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.
1661
1716
  `,
1662
1717
  host: `agentinbox host
1663
1718
 
@@ -3,12 +3,20 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.autostartDisabled = autostartDisabled;
6
7
  exports.ensureDaemonForClient = ensureDaemonForClient;
7
8
  exports.startDaemon = startDaemon;
8
9
  exports.stopDaemon = stopDaemon;
9
10
  exports.daemonStatus = daemonStatus;
10
11
  exports.writePidFile = writePidFile;
11
12
  exports.removePidFile = removePidFile;
13
+ exports.removePidFileIfOwned = removePidFileIfOwned;
14
+ exports.readDaemonLock = readDaemonLock;
15
+ exports.isLockHolderLive = isLockHolderLive;
16
+ exports.readLiveDaemonLockHolder = readLiveDaemonLockHolder;
17
+ exports.acquireDaemonLock = acquireDaemonLock;
18
+ exports.releaseDaemonLock = releaseDaemonLock;
19
+ exports.resolveStartTimeoutMs = resolveStartTimeoutMs;
12
20
  exports.resolveDaemonLogLevel = resolveDaemonLogLevel;
13
21
  exports.writeDaemonMetadata = writeDaemonMetadata;
14
22
  const node_fs_1 = __importDefault(require("node:fs"));
@@ -17,11 +25,20 @@ const node_child_process_1 = require("node:child_process");
17
25
  const client_1 = require("./client");
18
26
  const paths_1 = require("./paths");
19
27
  const logging_1 = require("./logging");
20
- const START_TIMEOUT_MS = 15_000;
28
+ const util_1 = require("./util");
29
+ const DEFAULT_START_TIMEOUT_MS = 60_000;
30
+ // A freshly created but still-empty lock file may belong to a process between
31
+ // create and write; never steal it within this grace window.
32
+ const STALE_LOCK_GRACE_MS = 5_000;
21
33
  const PACKAGE_VERSION = readOwnPackageVersion();
34
+ /** AGENTINBOX_NO_AUTOSTART disables implicit daemon spawning (systemd-style deployments). */
35
+ function autostartDisabled(env = process.env) {
36
+ return (0, util_1.isEnvFlagEnabled)(env.AGENTINBOX_NO_AUTOSTART);
37
+ }
22
38
  async function ensureDaemonForClient(options = {}) {
39
+ const env = options.env ?? process.env;
23
40
  const transport = resolveDaemonClientTransport(options);
24
- if (options.noAutoStart || transport.kind !== "socket") {
41
+ if (options.noAutoStart || autostartDisabled(env) || transport.kind !== "socket") {
25
42
  return transport;
26
43
  }
27
44
  if (await canReachHealthz(transport)) {
@@ -37,7 +54,6 @@ async function startDaemon(options = {}) {
37
54
  const homeDir = (0, paths_1.resolveAgentInboxHome)(env, options.homeDirOverride);
38
55
  const { pidPath, logPath } = (0, paths_1.resolveDaemonPaths)(env, options.homeDirOverride);
39
56
  node_fs_1.default.mkdirSync(homeDir, { recursive: true });
40
- cleanupStalePidFile(pidPath);
41
57
  if (await canReachHealthz(transport)) {
42
58
  const pid = readPidFile(pidPath);
43
59
  return {
@@ -49,6 +65,31 @@ async function startDaemon(options = {}) {
49
65
  transport,
50
66
  };
51
67
  }
68
+ // Admission: a live lock holder is starting (or serving) this socket — never
69
+ // spawn a competing daemon. This check is advisory; the spawned serve
70
+ // process enforces the lock authoritatively.
71
+ const lockPath = (0, paths_1.daemonLockPath)(transport.socketPath);
72
+ const timeoutMs = resolveStartTimeoutMs(env);
73
+ let pendingHolder = readLiveDaemonLockHolder(lockPath);
74
+ while (pendingHolder) {
75
+ const outcome = await waitForDaemonReady(transport, lockPath, timeoutMs);
76
+ if (outcome === "ready") {
77
+ const pid = readPidFile(pidPath) ?? pendingHolder.pid;
78
+ return {
79
+ started: false,
80
+ pid,
81
+ logLevel,
82
+ pidPath,
83
+ logPath,
84
+ transport,
85
+ };
86
+ }
87
+ if (outcome === "timeout") {
88
+ throw new Error(`AgentInbox daemon is already starting (pid ${pendingHolder.pid}) but did not become ready within ${timeoutMs}ms`);
89
+ }
90
+ // The pending holder exited before becoming ready; re-check admission.
91
+ pendingHolder = readLiveDaemonLockHolder(lockPath);
92
+ }
52
93
  const logFd = openLogFile(logPath);
53
94
  let child;
54
95
  try {
@@ -69,7 +110,7 @@ async function startDaemon(options = {}) {
69
110
  node_fs_1.default.closeSync(logFd);
70
111
  }
71
112
  child.unref();
72
- await waitForHealthz(transport, START_TIMEOUT_MS);
113
+ await waitForHealthz(transport, timeoutMs);
73
114
  return {
74
115
  started: true,
75
116
  pid: child.pid ?? -1,
@@ -83,42 +124,69 @@ async function stopDaemon(options = {}) {
83
124
  const env = options.env ?? process.env;
84
125
  const transport = requireSocketTransport(resolveDaemonClientTransport(options), "daemon stop");
85
126
  const { pidPath, logPath, metadataPath } = (0, paths_1.resolveDaemonPaths)(env, options.homeDirOverride);
86
- const pid = readPidFile(pidPath);
87
- if (pid != null && isProcessAlive(pid)) {
127
+ let pid = readPidFile(pidPath);
128
+ if (pid == null || !(0, util_1.isPidAlive)(pid)) {
129
+ // The pid file may be stale or overwritten while the live daemon still
130
+ // owns the socket; fall back to the admission lock holder.
131
+ pid = readLiveDaemonLockHolder((0, paths_1.daemonLockPath)(transport.socketPath))?.pid ?? pid;
132
+ }
133
+ if (pid != null && (0, util_1.isPidAlive)(pid)) {
88
134
  process.kill(pid, "SIGTERM");
89
135
  await waitForProcessExit(pid, 3_000);
90
136
  }
91
- cleanupStalePidFile(pidPath, true);
92
- cleanupFile(transport.socketPath);
93
- cleanupFile(metadataPath);
137
+ // Ownership: if an instance we did not stop still serves the socket, the
138
+ // files may now belong to it — leave them untouched. Otherwise remove the
139
+ // pid file only when it no longer references a live process.
140
+ if (!(await canReachHealthz(transport))) {
141
+ cleanupStalePidFile(pidPath);
142
+ cleanupFile(transport.socketPath);
143
+ cleanupFile(metadataPath);
144
+ }
94
145
  return daemonStatus(options);
95
146
  }
96
147
  async function daemonStatus(options = {}) {
97
148
  const env = options.env ?? process.env;
98
149
  const transport = requireSocketTransport(resolveDaemonClientTransport(options), "daemon status");
99
150
  const { pidPath, logPath, metadataPath } = (0, paths_1.resolveDaemonPaths)(env, options.homeDirOverride);
100
- const pid = readPidFile(pidPath);
101
- if (pid != null && !isProcessAlive(pid)) {
102
- cleanupStalePidFile(pidPath, true);
103
- cleanupFile(transport.socketPath);
104
- cleanupFile(metadataPath);
105
- return {
106
- running: false,
107
- pid: null,
108
- logLevel: null,
109
- version: null,
110
- startedAt: null,
111
- command: null,
112
- nodeVersion: null,
113
- pidPath,
114
- logPath,
115
- transport,
116
- };
151
+ let pid = readPidFile(pidPath);
152
+ if (pid != null && !(0, util_1.isPidAlive)(pid)) {
153
+ const holder = readLiveDaemonLockHolder((0, paths_1.daemonLockPath)(transport.socketPath));
154
+ if (holder != null) {
155
+ // The admission lock identifies the live daemon even when the pid file
156
+ // is stale (e.g. overwritten by a mixed-version writer).
157
+ pid = holder.pid;
158
+ }
159
+ else if (!(await canReachHealthz(transport))) {
160
+ // Nothing is serving and no live owner exists: clean stale files.
161
+ cleanupStalePidFile(pidPath);
162
+ cleanupFile(transport.socketPath);
163
+ cleanupFile(metadataPath);
164
+ return {
165
+ running: false,
166
+ starting: false,
167
+ pid: null,
168
+ logLevel: null,
169
+ version: null,
170
+ startedAt: null,
171
+ command: null,
172
+ nodeVersion: null,
173
+ pidPath,
174
+ logPath,
175
+ transport,
176
+ };
177
+ }
178
+ else {
179
+ // Something answers healthz (e.g. an older daemon without a lock);
180
+ // report it without owning its files.
181
+ pid = null;
182
+ }
117
183
  }
118
184
  const processInfo = pid == null ? null : readProcessMetadata(pid);
119
185
  const daemonMetadata = readDaemonMetadata(metadataPath);
186
+ const running = await canReachHealthz(transport);
120
187
  return {
121
- running: await canReachHealthz(transport),
188
+ running,
189
+ starting: !running && pid != null && (0, util_1.isPidAlive)(pid),
122
190
  pid,
123
191
  logLevel: daemonMetadata?.logLevel ?? null,
124
192
  version: pid == null ? null : PACKAGE_VERSION,
@@ -137,6 +205,127 @@ function writePidFile(pidPath, pid) {
137
205
  function removePidFile(pidPath) {
138
206
  cleanupFile(pidPath);
139
207
  }
208
+ /** Removes the pid file only when it still belongs to the given pid. */
209
+ function removePidFileIfOwned(pidPath, pid) {
210
+ if (readPidFile(pidPath) !== pid) {
211
+ return false;
212
+ }
213
+ cleanupFile(pidPath);
214
+ return true;
215
+ }
216
+ function readDaemonLock(lockPath) {
217
+ try {
218
+ const parsed = JSON.parse(node_fs_1.default.readFileSync(lockPath, "utf8"));
219
+ if (typeof parsed.pid !== "number" || !Number.isInteger(parsed.pid) || parsed.pid <= 0) {
220
+ return null;
221
+ }
222
+ return {
223
+ pid: parsed.pid,
224
+ processStartedAt: typeof parsed.processStartedAt === "string" ? parsed.processStartedAt : null,
225
+ acquiredAt: typeof parsed.acquiredAt === "string" ? parsed.acquiredAt : new Date(0).toISOString(),
226
+ };
227
+ }
228
+ catch {
229
+ return null;
230
+ }
231
+ }
232
+ function isLockHolderLive(holder) {
233
+ if (!(0, util_1.isPidAlive)(holder.pid)) {
234
+ return false;
235
+ }
236
+ if (holder.processStartedAt == null) {
237
+ return true;
238
+ }
239
+ const current = readProcessMetadata(holder.pid);
240
+ if (current?.startedAt == null) {
241
+ return true;
242
+ }
243
+ return current.startedAt === holder.processStartedAt;
244
+ }
245
+ function readLiveDaemonLockHolder(lockPath) {
246
+ const holder = readDaemonLock(lockPath);
247
+ return holder != null && isLockHolderLive(holder) ? holder : null;
248
+ }
249
+ /**
250
+ * Atomically (O_EXCL) acquires the daemon admission lock for this process.
251
+ * A stale lock (dead pid or pid reuse) is removed and retried once. A freshly
252
+ * created but still-empty lock is treated as held to avoid racing a concurrent
253
+ * creator between create and write.
254
+ */
255
+ function acquireDaemonLock(lockPath) {
256
+ let holder = null;
257
+ for (let attempt = 0; attempt < 2; attempt += 1) {
258
+ holder = null;
259
+ try {
260
+ const metadata = readProcessMetadata(process.pid);
261
+ const info = {
262
+ pid: process.pid,
263
+ processStartedAt: metadata?.startedAt ?? null,
264
+ acquiredAt: new Date().toISOString(),
265
+ };
266
+ node_fs_1.default.writeFileSync(lockPath, `${JSON.stringify(info)}\n`, { encoding: "utf8", flag: "wx" });
267
+ return { acquired: true, lockPath, holder: null };
268
+ }
269
+ catch (error) {
270
+ if (!isFileExistsError(error)) {
271
+ throw error;
272
+ }
273
+ }
274
+ holder = readDaemonLock(lockPath);
275
+ if (holder != null && isLockHolderLive(holder)) {
276
+ return { acquired: false, lockPath, holder };
277
+ }
278
+ if (holder == null && isLockFileFresh(lockPath)) {
279
+ return { acquired: false, lockPath, holder: null };
280
+ }
281
+ cleanupFile(lockPath);
282
+ }
283
+ return { acquired: false, lockPath, holder };
284
+ }
285
+ /** Releases the lock only when it still belongs to the given pid. */
286
+ function releaseDaemonLock(lockPath, pid) {
287
+ const holder = readDaemonLock(lockPath);
288
+ if (holder?.pid === pid) {
289
+ cleanupFile(lockPath);
290
+ }
291
+ }
292
+ function resolveStartTimeoutMs(env = process.env) {
293
+ const raw = env.AGENTINBOX_START_TIMEOUT_MS;
294
+ if (raw == null || raw.trim() === "") {
295
+ return DEFAULT_START_TIMEOUT_MS;
296
+ }
297
+ const parsed = Number.parseInt(raw, 10);
298
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_START_TIMEOUT_MS;
299
+ }
300
+ function isFileExistsError(error) {
301
+ return typeof error === "object" && error != null && error.code === "EEXIST";
302
+ }
303
+ function isLockFileFresh(lockPath) {
304
+ try {
305
+ return Date.now() - node_fs_1.default.statSync(lockPath).mtimeMs < STALE_LOCK_GRACE_MS;
306
+ }
307
+ catch {
308
+ return false;
309
+ }
310
+ }
311
+ /**
312
+ * Waits for the daemon holding the lock to serve healthz. Returns "lost" when
313
+ * the holder disappears without becoming ready so the caller can re-check
314
+ * admission instead of timing out.
315
+ */
316
+ async function waitForDaemonReady(transport, lockPath, timeoutMs) {
317
+ const startedAt = Date.now();
318
+ while (Date.now() - startedAt < timeoutMs) {
319
+ if (await canReachHealthz(transport)) {
320
+ return "ready";
321
+ }
322
+ if (readLiveDaemonLockHolder(lockPath) == null) {
323
+ return "lost";
324
+ }
325
+ await sleep(100);
326
+ }
327
+ return "timeout";
328
+ }
140
329
  function daemonChildArgs() {
141
330
  const execArgv = [...process.execArgv];
142
331
  return [...execArgv, process.argv[1], "serve"];
@@ -232,20 +421,20 @@ async function waitForHealthz(transport, timeoutMs) {
232
421
  async function waitForProcessExit(pid, timeoutMs) {
233
422
  const startedAt = Date.now();
234
423
  while (Date.now() - startedAt < timeoutMs) {
235
- if (!isProcessAlive(pid)) {
424
+ if (!(0, util_1.isPidAlive)(pid)) {
236
425
  return;
237
426
  }
238
427
  await sleep(100);
239
428
  }
240
429
  throw new Error(`timed out waiting for process ${pid} to exit`);
241
430
  }
242
- function cleanupStalePidFile(pidPath, force = false) {
431
+ function cleanupStalePidFile(pidPath) {
243
432
  const pid = readPidFile(pidPath);
244
433
  if (pid == null) {
245
434
  cleanupFile(pidPath);
246
435
  return;
247
436
  }
248
- if (force || !isProcessAlive(pid)) {
437
+ if (!(0, util_1.isPidAlive)(pid)) {
249
438
  cleanupFile(pidPath);
250
439
  }
251
440
  }
@@ -325,15 +514,6 @@ function inferNodeVersionFromCommand(command) {
325
514
  const match = command.match(/node\/(v\d+\.\d+\.\d+)\//);
326
515
  return match ? match[1] : null;
327
516
  }
328
- function isProcessAlive(pid) {
329
- try {
330
- process.kill(pid, 0);
331
- return true;
332
- }
333
- catch {
334
- return false;
335
- }
336
- }
337
517
  function cleanupFile(filePath) {
338
518
  try {
339
519
  if (node_fs_1.default.existsSync(filePath)) {
package/dist/src/paths.js CHANGED
@@ -7,6 +7,7 @@ exports.DEFAULT_AGENTINBOX_PORT = void 0;
7
7
  exports.resolveAgentInboxHome = resolveAgentInboxHome;
8
8
  exports.resolveServeConfig = resolveServeConfig;
9
9
  exports.resolveDaemonPaths = resolveDaemonPaths;
10
+ exports.daemonLockPath = daemonLockPath;
10
11
  exports.resolveClientTransport = resolveClientTransport;
11
12
  const node_fs_1 = __importDefault(require("node:fs"));
12
13
  const node_os_1 = __importDefault(require("node:os"));
@@ -74,6 +75,13 @@ function resolveDaemonPaths(env = process.env, homeDirOverride) {
74
75
  metadataPath: node_path_1.default.join(homeDir, "agentinbox.daemon.json"),
75
76
  };
76
77
  }
78
+ /**
79
+ * Admission lock for the daemon serving a given socket. Scoped to the socket
80
+ * identity (not the home dir) so an explicit --socket gets its own lock.
81
+ */
82
+ function daemonLockPath(socketPath) {
83
+ return `${socketPath}.lock`;
84
+ }
77
85
  function resolveClientTransport(input = {}) {
78
86
  const env = input.env ?? process.env;
79
87
  const homeDir = resolveAgentInboxHome(env, input.homeDirOverride);
package/dist/src/store.js CHANGED
@@ -35,27 +35,34 @@ class AgentInboxStore {
35
35
  this.dbPath = dbPath;
36
36
  this.db = db;
37
37
  }
38
- static async open(dbPath) {
38
+ static async open(dbPath, options = {}) {
39
+ const env = options.env ?? process.env;
39
40
  node_fs_1.default.mkdirSync(node_path_1.default.dirname(dbPath), { recursive: true });
41
+ removeOrphanBackupTmps(dbPath);
40
42
  const existedBeforeOpen = node_fs_1.default.existsSync(dbPath);
41
- 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);
42
47
  let store = new AgentInboxStore(dbPath, db);
48
+ let archivedPreV1 = false;
43
49
  if (existedBeforeOpen && store.shouldArchivePreV1Database()) {
44
50
  const archivedPath = store.archivePreV1Database();
45
51
  console.warn(`[agentinbox] archived pre-v1 local database to ${archivedPath}; starting with a fresh v1 database (no data imported).`);
46
52
  db = this.openDatabase(dbPath);
47
53
  store = new AgentInboxStore(dbPath, db);
54
+ archivedPreV1 = true;
48
55
  }
49
- else if (existedBeforeOpen) {
50
- await store.backupHealthyDatabase();
56
+ else if (existedBeforeOpen && legacyStartupBackup) {
57
+ await store.backupDatabase();
51
58
  }
52
- store.migrate();
59
+ await store.migrate({ existedBeforeOpen: existedBeforeOpen && !archivedPreV1, env });
53
60
  store.persist();
54
61
  return store;
55
62
  }
56
- static async openDatabaseWithRecovery(dbPath) {
63
+ static async openDatabaseWithRecovery(dbPath, fullCheck) {
57
64
  try {
58
- return this.openDatabase(dbPath);
65
+ return this.openDatabase(dbPath, fullCheck);
59
66
  }
60
67
  catch (error) {
61
68
  if (!node_fs_1.default.existsSync(dbPath)) {
@@ -69,19 +76,29 @@ class AgentInboxStore {
69
76
  node_fs_1.default.renameSync(dbPath, corruptPath);
70
77
  node_fs_1.default.copyFileSync(backupPath, dbPath);
71
78
  console.warn(`[agentinbox] recovered local database from ${backupPath}; archived corrupt database to ${corruptPath}.`);
72
- return this.openDatabase(dbPath);
79
+ return this.openDatabase(dbPath, fullCheck);
73
80
  }
74
81
  }
75
- static openDatabase(dbPath) {
82
+ static openDatabase(dbPath, fullCheck = false) {
76
83
  const db = new better_sqlite3_1.default(dbPath);
77
84
  db.pragma("busy_timeout = 5000");
78
85
  db.pragma("journal_mode = WAL");
79
86
  db.pragma("synchronous = NORMAL");
80
87
  db.pragma("foreign_keys = ON");
81
- this.assertHealthy(db, dbPath);
88
+ this.assertHealthy(db, dbPath, fullCheck);
82
89
  return db;
83
90
  }
84
- 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
+ }
85
102
  const result = db.pragma("integrity_check", { simple: true });
86
103
  if (result !== "ok") {
87
104
  db.close();
@@ -93,7 +110,7 @@ class AgentInboxStore {
93
110
  for (const candidate of candidates) {
94
111
  try {
95
112
  const db = new better_sqlite3_1.default(candidate, { readonly: true, fileMustExist: true });
96
- this.assertHealthy(db, candidate);
113
+ this.assertHealthy(db, candidate, true);
97
114
  db.close();
98
115
  return candidate;
99
116
  }
@@ -106,14 +123,32 @@ class AgentInboxStore {
106
123
  static listBackupCandidates(dbPath) {
107
124
  const dir = node_path_1.default.dirname(dbPath);
108
125
  const baseName = node_path_1.default.basename(dbPath);
109
- const startupBackups = node_fs_1.default.existsSync(dir)
110
- ? node_fs_1.default.readdirSync(dir)
111
- .filter((name) => name.startsWith(`${baseName}.startup.`) && name.endsWith(".bak"))
112
- .sort()
113
- .reverse()
114
- .map((name) => node_path_1.default.join(dir, name))
115
- : [];
116
- 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);
117
152
  }
118
153
  static nextPath(base) {
119
154
  if (!node_fs_1.default.existsSync(base)) {
@@ -133,13 +168,22 @@ class AgentInboxStore {
133
168
  }
134
169
  getDatabaseHealth() {
135
170
  return {
136
- integrityCheck: String(this.db.pragma("integrity_check", { simple: true })),
171
+ quickCheck: String(this.db.pragma("quick_check", { simple: true })),
137
172
  journalMode: String(this.db.pragma("journal_mode", { simple: true })),
138
173
  foreignKeys: Number(this.db.pragma("foreign_keys", { simple: true })) === 1,
139
174
  };
140
175
  }
141
- 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() {
142
182
  const backupPath = `${this.dbPath}.bak`;
183
+ await this.writeBackupTo(backupPath);
184
+ return backupPath;
185
+ }
186
+ async writeBackupTo(backupPath) {
143
187
  const tmpPath = `${backupPath}.${process.pid}.tmp`;
144
188
  try {
145
189
  node_fs_1.default.rmSync(tmpPath, { force: true });
@@ -150,17 +194,57 @@ class AgentInboxStore {
150
194
  node_fs_1.default.rmSync(tmpPath, { force: true });
151
195
  }
152
196
  }
153
- migrate() {
197
+ async migrate(options) {
154
198
  const migrations = this.loadSqlMigrations();
155
199
  this.ensureDrizzleMigrationsTable();
156
200
  const applied = this.listAppliedMigrationTags();
157
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
+ }
158
207
  for (const migration of pending) {
159
208
  this.applyMigration(migration);
160
209
  }
161
210
  this.ensureInboxEntryBackfill();
162
211
  this.setUserVersion(migrations.length);
163
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
+ }
164
248
  loadSqlMigrations() {
165
249
  const migrationsDir = this.resolveMigrationsDir();
166
250
  const files = node_fs_1.default
@@ -2382,3 +2466,61 @@ function uniqueSorted(values) {
2382
2466
  function summarizeBackfilledItemEntry(item) {
2383
2467
  return `${item.eventVariant} from ${item.sourceId}`;
2384
2468
  }
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
+ }
2491
+ }
2492
+ /**
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.
2496
+ */
2497
+ function removeOrphanBackupTmps(dbPath) {
2498
+ const dir = node_path_1.default.dirname(dbPath);
2499
+ const pattern = new RegExp(`^${escapeRegExp(node_path_1.default.basename(dbPath))}\\.(?:.+\\.)?bak\\.(\\d+)\\.tmp$`);
2500
+ let entries;
2501
+ try {
2502
+ entries = node_fs_1.default.readdirSync(dir);
2503
+ }
2504
+ catch {
2505
+ return;
2506
+ }
2507
+ for (const name of entries) {
2508
+ const match = pattern.exec(name);
2509
+ if (!match) {
2510
+ continue;
2511
+ }
2512
+ const pid = Number.parseInt(match[1], 10);
2513
+ if (Number.isInteger(pid) && pid > 0 && (0, util_1.isPidAlive)(pid)) {
2514
+ continue;
2515
+ }
2516
+ try {
2517
+ node_fs_1.default.rmSync(node_path_1.default.join(dir, name), { force: true });
2518
+ }
2519
+ catch {
2520
+ // Best-effort GC.
2521
+ }
2522
+ }
2523
+ }
2524
+ function escapeRegExp(value) {
2525
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2526
+ }
package/dist/src/util.js CHANGED
@@ -4,6 +4,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.nowIso = nowIso;
7
+ exports.isPidAlive = isPidAlive;
8
+ exports.isEnvFlagDisabled = isEnvFlagDisabled;
9
+ exports.isEnvFlagEnabled = isEnvFlagEnabled;
7
10
  exports.generateCanonicalId = generateCanonicalId;
8
11
  exports.generateId = generateId;
9
12
  exports.generateShortToken = generateShortToken;
@@ -21,6 +24,32 @@ const ENTRY_THREAD_ID_PATTERN = new RegExp(`^(ent|thr)_[${CANONICAL_ID_ALPHABET}
21
24
  function nowIso() {
22
25
  return new Date().toISOString();
23
26
  }
27
+ function isPidAlive(pid) {
28
+ try {
29
+ process.kill(pid, 0);
30
+ return true;
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
36
+ /**
37
+ * Treats an env flag as disabled only when it holds an explicit falsy word.
38
+ * Missing or empty values mean "unset" and keep the default behavior.
39
+ */
40
+ function isEnvFlagDisabled(raw) {
41
+ if (raw == null) {
42
+ return false;
43
+ }
44
+ const normalized = raw.trim().toLowerCase();
45
+ return normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off";
46
+ }
47
+ /**
48
+ * Treats an env flag as set only when it holds a non-empty, non-falsy value.
49
+ */
50
+ function isEnvFlagEnabled(raw) {
51
+ return raw != null && raw.trim() !== "" && !isEnvFlagDisabled(raw);
52
+ }
24
53
  function generateCanonicalId(prefix, length = CANONICAL_ID_TOKEN_LENGTH) {
25
54
  return `${prefix}_${generateShortToken(length)}`;
26
55
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@holon-run/agentinbox",
3
- "version": "1.5.1",
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": {