@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 +16 -0
- package/dist/src/cli.js +60 -5
- package/dist/src/daemon.js +219 -39
- package/dist/src/paths.js +8 -0
- package/dist/src/store.js +165 -23
- package/dist/src/util.js +29 -0
- 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");
|
|
@@ -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
|
-
|
|
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
|
|
package/dist/src/daemon.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
87
|
-
if (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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
101
|
-
if (pid != null && !
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
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 (!
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|