niahere 0.2.29 → 0.2.31
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/package.json +1 -1
- package/src/cli/index.ts +33 -0
- package/src/commands/health.ts +3 -102
- package/src/core/alive.ts +58 -66
- package/src/core/health.ts +145 -0
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -424,6 +424,38 @@ switch (command) {
|
|
|
424
424
|
process.exit(result.ok ? 0 : 1);
|
|
425
425
|
}
|
|
426
426
|
|
|
427
|
+
case "update": {
|
|
428
|
+
const { version: currentVersion } = await import("../../package.json");
|
|
429
|
+
console.log(`Current: v${currentVersion}`);
|
|
430
|
+
console.log("Updating...");
|
|
431
|
+
const install = Bun.spawn(["npm", "i", "-g", "niahere@latest"], { stdio: ["ignore", "inherit", "inherit"] });
|
|
432
|
+
const installExit = await install.exited;
|
|
433
|
+
if (installExit !== 0) {
|
|
434
|
+
fail("Update failed.");
|
|
435
|
+
}
|
|
436
|
+
// Get new version
|
|
437
|
+
const check = Bun.spawn(["npm", "view", "niahere", "version"], { stdout: "pipe", stderr: "pipe" });
|
|
438
|
+
const newVersion = (await new Response(check.stdout).text()).trim();
|
|
439
|
+
await check.exited;
|
|
440
|
+
if (newVersion === currentVersion) {
|
|
441
|
+
console.log("Already on latest.");
|
|
442
|
+
} else {
|
|
443
|
+
console.log(`Updated: v${currentVersion} → v${newVersion}`);
|
|
444
|
+
if (isRunning()) {
|
|
445
|
+
console.log("Restarting daemon...");
|
|
446
|
+
const { isServiceInstalled, restartService } = await import("../commands/service");
|
|
447
|
+
if (isServiceInstalled()) {
|
|
448
|
+
await restartService();
|
|
449
|
+
} else {
|
|
450
|
+
stopDaemon();
|
|
451
|
+
startDaemon();
|
|
452
|
+
}
|
|
453
|
+
console.log("Restarted.");
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
|
|
427
459
|
case "init": {
|
|
428
460
|
const { runInit } = await import("../commands/init");
|
|
429
461
|
await runInit();
|
|
@@ -432,6 +464,7 @@ switch (command) {
|
|
|
432
464
|
|
|
433
465
|
default:
|
|
434
466
|
console.log("Usage: nia <command>\n");
|
|
467
|
+
console.log(" update — update to latest version and restart");
|
|
435
468
|
console.log(" init — setup nia");
|
|
436
469
|
console.log(" start / stop — daemon + service control");
|
|
437
470
|
console.log(" restart — restart daemon");
|
package/src/commands/health.ts
CHANGED
|
@@ -1,107 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { join } from "path";
|
|
3
|
-
import { isRunning, readPid } from "../core/daemon";
|
|
4
|
-
import { getConfig, readRawConfig } from "../utils/config";
|
|
5
|
-
import { getPaths } from "../utils/paths";
|
|
6
|
-
import { errMsg } from "../utils/errors";
|
|
7
|
-
import { localTime } from "../utils/time";
|
|
8
|
-
|
|
9
|
-
type Check = { name: string; status: "ok" | "warn" | "fail"; detail: string };
|
|
10
|
-
|
|
11
|
-
function push(checks: Check[], name: string, status: Check["status"], detail: string): void {
|
|
12
|
-
checks.push({ name, status, detail });
|
|
13
|
-
}
|
|
1
|
+
import { runHealthChecks } from "../core/health";
|
|
14
2
|
|
|
15
3
|
export async function healthCommand(): Promise<void> {
|
|
16
|
-
const checks: Check[] = [];
|
|
17
|
-
const paths = getPaths();
|
|
18
|
-
|
|
19
|
-
// 0. Version
|
|
20
|
-
const { version } = await import("../../package.json");
|
|
21
|
-
push(checks, "nia", "ok", "v" + version);
|
|
22
|
-
|
|
23
|
-
// 1. Daemon
|
|
24
|
-
const pid = readPid();
|
|
25
|
-
if (isRunning()) {
|
|
26
|
-
push(checks, "daemon", "ok", "running (pid: " + pid + ")");
|
|
27
|
-
} else if (pid) {
|
|
28
|
-
push(checks, "daemon", "fail", "stale pid file (pid: " + pid + ", not running)");
|
|
29
|
-
} else {
|
|
30
|
-
push(checks, "daemon", "warn", "not running");
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// 2. Config
|
|
34
|
-
if (existsSync(paths.config)) {
|
|
35
|
-
const raw = readRawConfig();
|
|
36
|
-
push(checks, "config", "ok", Object.keys(raw).length + " keys loaded");
|
|
37
|
-
} else {
|
|
38
|
-
push(checks, "config", "fail", "missing (" + paths.config + ")");
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// 3. Database
|
|
42
|
-
try {
|
|
43
|
-
const config = getConfig();
|
|
44
|
-
if (!config.database_url || !config.database_url.startsWith("postgres")) {
|
|
45
|
-
push(checks, "database", "fail", 'invalid url: "' + (config.database_url || "(empty)") + '"');
|
|
46
|
-
} else {
|
|
47
|
-
const { checkDbHealth } = await import("./health-db");
|
|
48
|
-
const ok = await checkDbHealth(config.database_url);
|
|
49
|
-
push(checks, "database", ok ? "ok" : "fail", config.database_url.replace(/\/\/.*@/, "//***@"));
|
|
50
|
-
}
|
|
51
|
-
} catch (err) {
|
|
52
|
-
push(checks, "database", "fail", errMsg(err));
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// 4. Channels
|
|
56
|
-
const config = getConfig();
|
|
57
|
-
if (!config.channels.enabled) {
|
|
58
|
-
push(checks, "channels", "warn", "disabled");
|
|
59
|
-
} else {
|
|
60
|
-
const chans: string[] = [];
|
|
61
|
-
if (config.channels.telegram.bot_token) chans.push("telegram");
|
|
62
|
-
if (config.channels.slack.bot_token && config.channels.slack.app_token) chans.push("slack");
|
|
63
|
-
if (chans.length > 0) {
|
|
64
|
-
push(checks, "channels", "ok", "configured: " + chans.join(", "));
|
|
65
|
-
} else {
|
|
66
|
-
push(checks, "channels", "warn", "enabled but no tokens configured");
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// 5. API keys
|
|
71
|
-
const geminiKey = config.gemini_api_key;
|
|
72
|
-
const rawConfig = readRawConfig();
|
|
73
|
-
const openaiKey = typeof rawConfig.openai_api_key === "string" ? rawConfig.openai_api_key : null;
|
|
74
|
-
const apiKeys: string[] = [];
|
|
75
|
-
if (geminiKey) apiKeys.push("gemini");
|
|
76
|
-
if (openaiKey) apiKeys.push("openai");
|
|
77
|
-
push(checks, "api keys", apiKeys.length > 0 ? "ok" : "warn",
|
|
78
|
-
apiKeys.length > 0 ? apiKeys.join(", ") : "none configured");
|
|
79
|
-
|
|
80
|
-
// 6. Persona files
|
|
81
|
-
const personaFiles = ["identity.md", "owner.md", "soul.md"];
|
|
82
|
-
const missing = personaFiles.filter((f) => !existsSync(join(paths.selfDir, f)));
|
|
83
|
-
if (missing.length === 0) {
|
|
84
|
-
push(checks, "persona", "ok", "all files present");
|
|
85
|
-
} else {
|
|
86
|
-
push(checks, "persona", "warn", "missing: " + missing.join(", "));
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// 7. Daemon log
|
|
90
|
-
if (existsSync(paths.daemonLog)) {
|
|
91
|
-
const stat = statSync(paths.daemonLog);
|
|
92
|
-
const sizeMb = (stat.size / 1024 / 1024).toFixed(1);
|
|
93
|
-
const lastMod = localTime(stat.mtime);
|
|
94
|
-
push(checks, "logs", stat.size > 100 * 1024 * 1024 ? "warn" : "ok",
|
|
95
|
-
sizeMb + " MB, last write: " + lastMod);
|
|
96
|
-
} else {
|
|
97
|
-
push(checks, "logs", "warn", "no log file");
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// 8. Bun version
|
|
101
|
-
const bunVersion = typeof Bun !== "undefined" ? Bun.version : "unknown";
|
|
102
|
-
push(checks, "bun", "ok", "v" + bunVersion);
|
|
103
|
-
|
|
104
|
-
// Output
|
|
105
4
|
const { GREEN, YELLOW, RED, RESET, ICON_PASS, ICON_FAIL, ICON_WARN } = await import("../utils/cli");
|
|
106
5
|
const icons: Record<string, string> = {
|
|
107
6
|
ok: GREEN + ICON_PASS + RESET,
|
|
@@ -109,6 +8,8 @@ export async function healthCommand(): Promise<void> {
|
|
|
109
8
|
fail: RED + ICON_FAIL + RESET,
|
|
110
9
|
};
|
|
111
10
|
|
|
11
|
+
const checks = await runHealthChecks();
|
|
12
|
+
|
|
112
13
|
console.log();
|
|
113
14
|
for (const c of checks) {
|
|
114
15
|
console.log(" " + icons[c.status] + " " + c.name.padEnd(12) + " " + c.detail);
|
package/src/core/alive.ts
CHANGED
|
@@ -1,28 +1,17 @@
|
|
|
1
1
|
import { log } from "../utils/log";
|
|
2
2
|
import { getConfig } from "../utils/config";
|
|
3
3
|
import { getSql, closeDb } from "../db/connection";
|
|
4
|
+
import { getFailures, type Check } from "./health";
|
|
4
5
|
|
|
5
6
|
const HEARTBEAT_INTERVAL = 60_000; // 60s
|
|
6
|
-
const RECOVERY_THRESHOLD = 30; // 30 consecutive failures = ~30 min
|
|
7
7
|
|
|
8
8
|
let timer: ReturnType<typeof setInterval> | null = null;
|
|
9
|
-
let
|
|
9
|
+
let lastFailures: string[] = [];
|
|
10
10
|
let recoveryAttempted = false;
|
|
11
11
|
|
|
12
|
-
async function
|
|
13
|
-
try {
|
|
14
|
-
const sql = getSql();
|
|
15
|
-
await sql`SELECT 1`;
|
|
16
|
-
return true;
|
|
17
|
-
} catch {
|
|
18
|
-
return false;
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
async function attemptReconnect(): Promise<boolean> {
|
|
12
|
+
async function attemptDbReconnect(): Promise<boolean> {
|
|
23
13
|
try {
|
|
24
14
|
await closeDb();
|
|
25
|
-
// getSql() will create a fresh connection on next call
|
|
26
15
|
const sql = getSql();
|
|
27
16
|
await sql`SELECT 1`;
|
|
28
17
|
return true;
|
|
@@ -35,7 +24,6 @@ async function attemptReconnect(): Promise<boolean> {
|
|
|
35
24
|
async function notifyUser(message: string): Promise<void> {
|
|
36
25
|
const config = getConfig();
|
|
37
26
|
|
|
38
|
-
// Try Telegram first
|
|
39
27
|
const tgToken = config.channels.telegram.bot_token;
|
|
40
28
|
const tgChatId = config.channels.telegram.chat_id;
|
|
41
29
|
if (tgToken && tgChatId) {
|
|
@@ -50,7 +38,6 @@ async function notifyUser(message: string): Promise<void> {
|
|
|
50
38
|
}
|
|
51
39
|
}
|
|
52
40
|
|
|
53
|
-
// Fall back to Slack
|
|
54
41
|
const slToken = config.channels.slack.bot_token;
|
|
55
42
|
const slRecipient = config.channels.slack.dm_user_id || config.channels.slack.channel_id;
|
|
56
43
|
if (slToken && slRecipient) {
|
|
@@ -72,37 +59,39 @@ async function notifyUser(message: string): Promise<void> {
|
|
|
72
59
|
log.error("alive: could not notify user — no channel available");
|
|
73
60
|
}
|
|
74
61
|
|
|
75
|
-
/**
|
|
76
|
-
async function runRecoveryAgent(
|
|
62
|
+
/** Run LLM recovery agent for failures it can fix (e.g. DB down). */
|
|
63
|
+
async function runRecoveryAgent(failures: Check[]): Promise<{ recovered: boolean; report: string }> {
|
|
77
64
|
try {
|
|
78
65
|
const { runJobWithClaude } = await import("./runner");
|
|
79
66
|
const { homedir } = await import("os");
|
|
80
67
|
|
|
68
|
+
const failureSummary = failures.map((f) => `- ${f.name}: ${f.detail}`).join("\n");
|
|
69
|
+
|
|
81
70
|
const systemPrompt = [
|
|
82
71
|
"You are a system recovery agent for the Nia daemon.",
|
|
83
|
-
"
|
|
84
|
-
"Your job: diagnose the issue, attempt to fix it, and report the outcome.",
|
|
72
|
+
"Health checks detected failures. Diagnose and fix what you can.",
|
|
85
73
|
"",
|
|
86
|
-
"
|
|
87
|
-
"1. Check
|
|
88
|
-
"2.
|
|
89
|
-
"3.
|
|
90
|
-
"
|
|
74
|
+
"For database issues:",
|
|
75
|
+
"1. Check: pg_isready, brew services list (macOS), systemctl status postgresql (Linux)",
|
|
76
|
+
"2. Fix: brew services start postgresql@17 (macOS) or systemctl start postgresql (Linux)",
|
|
77
|
+
"3. Verify: psql -d niahere -c 'SELECT 1'",
|
|
78
|
+
"",
|
|
79
|
+
"For other issues: diagnose, attempt fix if safe, report findings.",
|
|
91
80
|
"",
|
|
92
81
|
"Respond with a brief postmortem:",
|
|
93
82
|
"- What was wrong",
|
|
94
|
-
"- What you did
|
|
95
|
-
"-
|
|
96
|
-
"- Any recommendations",
|
|
83
|
+
"- What you did",
|
|
84
|
+
"- Current status",
|
|
97
85
|
].join("\n");
|
|
98
86
|
|
|
99
|
-
const jobPrompt = `
|
|
87
|
+
const jobPrompt = `Health check failures:\n${failureSummary}\n\nDiagnose and fix.`;
|
|
100
88
|
|
|
101
89
|
const result = await runJobWithClaude(systemPrompt, jobPrompt, homedir());
|
|
102
|
-
const recovered = await checkDb();
|
|
103
90
|
|
|
91
|
+
// Re-check after recovery attempt
|
|
92
|
+
const remaining = await getFailures();
|
|
104
93
|
return {
|
|
105
|
-
recovered,
|
|
94
|
+
recovered: remaining.length === 0,
|
|
106
95
|
report: result.agentText || "Recovery agent returned no output.",
|
|
107
96
|
};
|
|
108
97
|
} catch (err) {
|
|
@@ -114,61 +103,64 @@ async function runRecoveryAgent(error: string): Promise<{ recovered: boolean; re
|
|
|
114
103
|
}
|
|
115
104
|
|
|
116
105
|
async function heartbeat(): Promise<void> {
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
106
|
+
const failures = await getFailures();
|
|
107
|
+
const failureNames = failures.map((f) => f.name);
|
|
108
|
+
|
|
109
|
+
// All clear
|
|
110
|
+
if (failures.length === 0) {
|
|
111
|
+
if (lastFailures.length > 0) {
|
|
112
|
+
log.info({ recovered: lastFailures }, "alive: all checks passing");
|
|
113
|
+
await notifyUser(`Recovered: ${lastFailures.join(", ")} back to normal.`);
|
|
125
114
|
}
|
|
126
|
-
|
|
115
|
+
lastFailures = [];
|
|
127
116
|
recoveryAttempted = false;
|
|
128
117
|
return;
|
|
129
118
|
}
|
|
130
119
|
|
|
131
|
-
|
|
132
|
-
|
|
120
|
+
// New failures detected
|
|
121
|
+
const newFailures = failureNames.filter((f) => !lastFailures.includes(f));
|
|
122
|
+
if (newFailures.length > 0) {
|
|
123
|
+
log.warn({ failures: failureNames }, "alive: health check failures detected");
|
|
124
|
+
}
|
|
133
125
|
|
|
134
|
-
// Try reconnect
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
126
|
+
// Try DB reconnect if database is failing
|
|
127
|
+
if (failureNames.includes("database")) {
|
|
128
|
+
const reconnected = await attemptDbReconnect();
|
|
129
|
+
if (reconnected) {
|
|
130
|
+
log.info("alive: database reconnected");
|
|
131
|
+
// Re-check everything
|
|
132
|
+
const remaining = await getFailures();
|
|
133
|
+
if (remaining.length === 0) {
|
|
134
|
+
lastFailures = [];
|
|
135
|
+
recoveryAttempted = false;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
141
139
|
}
|
|
142
140
|
|
|
143
|
-
//
|
|
144
|
-
if (
|
|
141
|
+
// Run recovery agent once per outage
|
|
142
|
+
if (!recoveryAttempted) {
|
|
145
143
|
recoveryAttempted = true;
|
|
146
|
-
log.info(
|
|
144
|
+
log.info({ failures: failureNames }, "alive: running recovery agent");
|
|
147
145
|
|
|
148
|
-
|
|
149
|
-
const lastError = "PostgreSQL unreachable after " + consecutiveFailures + " consecutive heartbeat failures";
|
|
150
|
-
const { recovered, report } = await runRecoveryAgent(lastError);
|
|
146
|
+
const { recovered, report } = await runRecoveryAgent(failures);
|
|
151
147
|
|
|
152
148
|
if (recovered) {
|
|
153
|
-
log.info("alive: recovery agent succeeded");
|
|
154
|
-
await notifyUser(
|
|
155
|
-
|
|
149
|
+
log.info({ report }, "alive: recovery agent succeeded");
|
|
150
|
+
await notifyUser(report);
|
|
151
|
+
lastFailures = [];
|
|
156
152
|
recoveryAttempted = false;
|
|
157
153
|
} else {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
await notifyUser(
|
|
161
|
-
`Database has been down for ~${consecutiveFailures} min and auto-recovery failed.\n\n` +
|
|
162
|
-
`Recovery report:\n${report}\n\n` +
|
|
163
|
-
`Run \`nia health\` to check status. You may need to restart PostgreSQL manually.`
|
|
164
|
-
);
|
|
154
|
+
log.error({ report }, "alive: recovery failed, notifying user");
|
|
155
|
+
await notifyUser(report);
|
|
165
156
|
}
|
|
166
157
|
}
|
|
158
|
+
|
|
159
|
+
lastFailures = failureNames;
|
|
167
160
|
}
|
|
168
161
|
|
|
169
162
|
export function startAlive(): void {
|
|
170
163
|
log.info("alive started (60s heartbeat)");
|
|
171
|
-
// Initial check after a short delay (let startup finish)
|
|
172
164
|
setTimeout(heartbeat, 10_000);
|
|
173
165
|
timer = setInterval(heartbeat, HEARTBEAT_INTERVAL);
|
|
174
166
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { existsSync, statSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { getConfig, readRawConfig } from "../utils/config";
|
|
4
|
+
import { getPaths } from "../utils/paths";
|
|
5
|
+
import { isRunning, readPid } from "./daemon";
|
|
6
|
+
import { errMsg } from "../utils/errors";
|
|
7
|
+
import { localTime } from "../utils/time";
|
|
8
|
+
|
|
9
|
+
export type CheckStatus = "ok" | "warn" | "fail";
|
|
10
|
+
export type Check = { name: string; status: CheckStatus; detail: string };
|
|
11
|
+
|
|
12
|
+
/** Run all health checks. Returns structured results usable by CLI and alive monitor. */
|
|
13
|
+
export async function runHealthChecks(): Promise<Check[]> {
|
|
14
|
+
const checks: Check[] = [];
|
|
15
|
+
const paths = getPaths();
|
|
16
|
+
const config = getConfig();
|
|
17
|
+
|
|
18
|
+
// Version
|
|
19
|
+
const { version } = await import("../../package.json");
|
|
20
|
+
checks.push({ name: "nia", status: "ok", detail: "v" + version });
|
|
21
|
+
|
|
22
|
+
// Daemon
|
|
23
|
+
const pid = readPid();
|
|
24
|
+
if (isRunning()) {
|
|
25
|
+
checks.push({ name: "daemon", status: "ok", detail: "running (pid: " + pid + ")" });
|
|
26
|
+
} else if (pid) {
|
|
27
|
+
checks.push({ name: "daemon", status: "fail", detail: "stale pid file (pid: " + pid + ", not running)" });
|
|
28
|
+
} else {
|
|
29
|
+
checks.push({ name: "daemon", status: "warn", detail: "not running" });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Config
|
|
33
|
+
if (existsSync(paths.config)) {
|
|
34
|
+
const raw = readRawConfig();
|
|
35
|
+
checks.push({ name: "config", status: "ok", detail: Object.keys(raw).length + " keys loaded" });
|
|
36
|
+
} else {
|
|
37
|
+
checks.push({ name: "config", status: "fail", detail: "missing (" + paths.config + ")" });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Database
|
|
41
|
+
try {
|
|
42
|
+
if (!config.database_url || !config.database_url.startsWith("postgres")) {
|
|
43
|
+
checks.push({ name: "database", status: "fail", detail: 'invalid url: "' + (config.database_url || "(empty)") + '"' });
|
|
44
|
+
} else {
|
|
45
|
+
const { checkDbHealth } = await import("../commands/health-db");
|
|
46
|
+
const ok = await checkDbHealth(config.database_url);
|
|
47
|
+
checks.push({ name: "database", status: ok ? "ok" : "fail", detail: ok ? "connected" : "unreachable" });
|
|
48
|
+
}
|
|
49
|
+
} catch (err) {
|
|
50
|
+
checks.push({ name: "database", status: "fail", detail: errMsg(err) });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Channels — check actual connectivity, not just config
|
|
54
|
+
if (!config.channels.enabled) {
|
|
55
|
+
checks.push({ name: "channels", status: "warn", detail: "disabled" });
|
|
56
|
+
} else {
|
|
57
|
+
const results: string[] = [];
|
|
58
|
+
|
|
59
|
+
// Telegram
|
|
60
|
+
const tgToken = config.channels.telegram.bot_token;
|
|
61
|
+
if (tgToken) {
|
|
62
|
+
try {
|
|
63
|
+
const resp = await fetch(`https://api.telegram.org/bot${tgToken}/getMe`);
|
|
64
|
+
const data = await resp.json() as { ok: boolean };
|
|
65
|
+
results.push(data.ok ? "telegram: connected" : "telegram: auth failed");
|
|
66
|
+
if (!data.ok) checks.push({ name: "telegram", status: "fail", detail: "auth failed" });
|
|
67
|
+
} catch {
|
|
68
|
+
results.push("telegram: unreachable");
|
|
69
|
+
checks.push({ name: "telegram", status: "fail", detail: "unreachable" });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Slack
|
|
74
|
+
const slToken = config.channels.slack.bot_token;
|
|
75
|
+
if (slToken) {
|
|
76
|
+
try {
|
|
77
|
+
const resp = await fetch("https://slack.com/api/auth.test", {
|
|
78
|
+
method: "POST",
|
|
79
|
+
headers: { Authorization: `Bearer ${slToken}`, "Content-Type": "application/json" },
|
|
80
|
+
});
|
|
81
|
+
const data = await resp.json() as { ok: boolean; error?: string };
|
|
82
|
+
results.push(data.ok ? "slack: connected" : `slack: ${data.error || "auth failed"}`);
|
|
83
|
+
if (!data.ok) checks.push({ name: "slack", status: "fail", detail: data.error || "auth failed" });
|
|
84
|
+
} catch {
|
|
85
|
+
results.push("slack: unreachable");
|
|
86
|
+
checks.push({ name: "slack", status: "fail", detail: "unreachable" });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (results.length === 0) {
|
|
91
|
+
checks.push({ name: "channels", status: "warn", detail: "enabled but no tokens configured" });
|
|
92
|
+
} else {
|
|
93
|
+
const allOk = results.every((r) => r.includes("connected"));
|
|
94
|
+
checks.push({ name: "channels", status: allOk ? "ok" : "warn", detail: results.join(", ") });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// API keys
|
|
99
|
+
const geminiKey = config.gemini_api_key;
|
|
100
|
+
const rawConfig = readRawConfig();
|
|
101
|
+
const openaiKey = typeof rawConfig.openai_api_key === "string" ? rawConfig.openai_api_key : null;
|
|
102
|
+
const apiKeys: string[] = [];
|
|
103
|
+
if (geminiKey) apiKeys.push("gemini");
|
|
104
|
+
if (openaiKey) apiKeys.push("openai");
|
|
105
|
+
checks.push({
|
|
106
|
+
name: "api keys",
|
|
107
|
+
status: apiKeys.length > 0 ? "ok" : "warn",
|
|
108
|
+
detail: apiKeys.length > 0 ? apiKeys.join(", ") : "none configured",
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Persona files
|
|
112
|
+
const personaFiles = ["identity.md", "owner.md", "soul.md"];
|
|
113
|
+
const missing = personaFiles.filter((f) => !existsSync(join(paths.selfDir, f)));
|
|
114
|
+
checks.push({
|
|
115
|
+
name: "persona",
|
|
116
|
+
status: missing.length === 0 ? "ok" : "warn",
|
|
117
|
+
detail: missing.length === 0 ? "all files present" : "missing: " + missing.join(", "),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Daemon log
|
|
121
|
+
if (existsSync(paths.daemonLog)) {
|
|
122
|
+
const stat = statSync(paths.daemonLog);
|
|
123
|
+
const sizeMb = (stat.size / 1024 / 1024).toFixed(1);
|
|
124
|
+
const lastMod = localTime(stat.mtime);
|
|
125
|
+
checks.push({
|
|
126
|
+
name: "logs",
|
|
127
|
+
status: stat.size > 100 * 1024 * 1024 ? "warn" : "ok",
|
|
128
|
+
detail: sizeMb + " MB, last write: " + lastMod,
|
|
129
|
+
});
|
|
130
|
+
} else {
|
|
131
|
+
checks.push({ name: "logs", status: "warn", detail: "no log file" });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Bun version
|
|
135
|
+
const bunVersion = typeof Bun !== "undefined" ? Bun.version : "unknown";
|
|
136
|
+
checks.push({ name: "bun", status: "ok", detail: "v" + bunVersion });
|
|
137
|
+
|
|
138
|
+
return checks;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Quick check — returns just the failures. Used by alive monitor. */
|
|
142
|
+
export async function getFailures(): Promise<Check[]> {
|
|
143
|
+
const checks = await runHealthChecks();
|
|
144
|
+
return checks.filter((c) => c.status === "fail");
|
|
145
|
+
}
|