niahere 0.2.30 → 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 +56 -63
- 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,24 +1,15 @@
|
|
|
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
7
|
|
|
7
8
|
let timer: ReturnType<typeof setInterval> | null = null;
|
|
8
|
-
let
|
|
9
|
+
let lastFailures: string[] = [];
|
|
9
10
|
let recoveryAttempted = false;
|
|
10
11
|
|
|
11
|
-
async function
|
|
12
|
-
try {
|
|
13
|
-
const sql = getSql();
|
|
14
|
-
await sql`SELECT 1`;
|
|
15
|
-
return true;
|
|
16
|
-
} catch {
|
|
17
|
-
return false;
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
async function attemptReconnect(): Promise<boolean> {
|
|
12
|
+
async function attemptDbReconnect(): Promise<boolean> {
|
|
22
13
|
try {
|
|
23
14
|
await closeDb();
|
|
24
15
|
const sql = getSql();
|
|
@@ -33,7 +24,6 @@ async function attemptReconnect(): Promise<boolean> {
|
|
|
33
24
|
async function notifyUser(message: string): Promise<void> {
|
|
34
25
|
const config = getConfig();
|
|
35
26
|
|
|
36
|
-
// Try Telegram first
|
|
37
27
|
const tgToken = config.channels.telegram.bot_token;
|
|
38
28
|
const tgChatId = config.channels.telegram.chat_id;
|
|
39
29
|
if (tgToken && tgChatId) {
|
|
@@ -48,7 +38,6 @@ async function notifyUser(message: string): Promise<void> {
|
|
|
48
38
|
}
|
|
49
39
|
}
|
|
50
40
|
|
|
51
|
-
// Fall back to Slack
|
|
52
41
|
const slToken = config.channels.slack.bot_token;
|
|
53
42
|
const slRecipient = config.channels.slack.dm_user_id || config.channels.slack.channel_id;
|
|
54
43
|
if (slToken && slRecipient) {
|
|
@@ -70,37 +59,39 @@ async function notifyUser(message: string): Promise<void> {
|
|
|
70
59
|
log.error("alive: could not notify user — no channel available");
|
|
71
60
|
}
|
|
72
61
|
|
|
73
|
-
/** Run
|
|
74
|
-
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 }> {
|
|
75
64
|
try {
|
|
76
65
|
const { runJobWithClaude } = await import("./runner");
|
|
77
66
|
const { homedir } = await import("os");
|
|
78
67
|
|
|
68
|
+
const failureSummary = failures.map((f) => `- ${f.name}: ${f.detail}`).join("\n");
|
|
69
|
+
|
|
79
70
|
const systemPrompt = [
|
|
80
71
|
"You are a system recovery agent for the Nia daemon.",
|
|
81
|
-
"
|
|
82
|
-
"
|
|
72
|
+
"Health checks detected failures. Diagnose and fix what you can.",
|
|
73
|
+
"",
|
|
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'",
|
|
83
78
|
"",
|
|
84
|
-
"
|
|
85
|
-
"1. Check if PostgreSQL is running: pg_isready, brew services list (macOS), systemctl status postgresql (Linux)",
|
|
86
|
-
"2. If stopped, try to start it: brew services start postgresql@17 (macOS) or systemctl start postgresql (Linux)",
|
|
87
|
-
"3. Wait a few seconds, then verify connectivity: psql -d niahere -c 'SELECT 1'",
|
|
88
|
-
"4. If it's a different issue (disk space, permissions, etc), diagnose and report",
|
|
79
|
+
"For other issues: diagnose, attempt fix if safe, report findings.",
|
|
89
80
|
"",
|
|
90
81
|
"Respond with a brief postmortem:",
|
|
91
82
|
"- What was wrong",
|
|
92
|
-
"- What you did
|
|
93
|
-
"-
|
|
94
|
-
"- Any recommendations",
|
|
83
|
+
"- What you did",
|
|
84
|
+
"- Current status",
|
|
95
85
|
].join("\n");
|
|
96
86
|
|
|
97
|
-
const jobPrompt = `
|
|
87
|
+
const jobPrompt = `Health check failures:\n${failureSummary}\n\nDiagnose and fix.`;
|
|
98
88
|
|
|
99
89
|
const result = await runJobWithClaude(systemPrompt, jobPrompt, homedir());
|
|
100
|
-
const recovered = await checkDb();
|
|
101
90
|
|
|
91
|
+
// Re-check after recovery attempt
|
|
92
|
+
const remaining = await getFailures();
|
|
102
93
|
return {
|
|
103
|
-
recovered,
|
|
94
|
+
recovered: remaining.length === 0,
|
|
104
95
|
report: result.agentText || "Recovery agent returned no output.",
|
|
105
96
|
};
|
|
106
97
|
} catch (err) {
|
|
@@ -112,58 +103,60 @@ async function runRecoveryAgent(error: string): Promise<{ recovered: boolean; re
|
|
|
112
103
|
}
|
|
113
104
|
|
|
114
105
|
async function heartbeat(): Promise<void> {
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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.`);
|
|
121
114
|
}
|
|
122
|
-
|
|
115
|
+
lastFailures = [];
|
|
123
116
|
recoveryAttempted = false;
|
|
124
117
|
return;
|
|
125
118
|
}
|
|
126
119
|
|
|
127
|
-
|
|
128
|
-
|
|
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
|
+
}
|
|
129
125
|
|
|
130
|
-
// Try reconnect
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
+
}
|
|
136
138
|
}
|
|
137
|
-
consecutiveFailures = 0;
|
|
138
|
-
recoveryAttempted = false;
|
|
139
|
-
return;
|
|
140
139
|
}
|
|
141
140
|
|
|
142
|
-
//
|
|
141
|
+
// Run recovery agent once per outage
|
|
143
142
|
if (!recoveryAttempted) {
|
|
144
143
|
recoveryAttempted = true;
|
|
145
|
-
log.info(
|
|
144
|
+
log.info({ failures: failureNames }, "alive: running recovery agent");
|
|
146
145
|
|
|
147
|
-
const { recovered, report } = await runRecoveryAgent(
|
|
148
|
-
"PostgreSQL unreachable, reconnect failed after " + consecutiveFailures + " heartbeat(s)"
|
|
149
|
-
);
|
|
146
|
+
const { recovered, report } = await runRecoveryAgent(failures);
|
|
150
147
|
|
|
151
148
|
if (recovered) {
|
|
152
|
-
log.info("alive: recovery agent succeeded");
|
|
153
|
-
await notifyUser(
|
|
154
|
-
|
|
149
|
+
log.info({ report }, "alive: recovery agent succeeded");
|
|
150
|
+
await notifyUser(report);
|
|
151
|
+
lastFailures = [];
|
|
155
152
|
recoveryAttempted = false;
|
|
156
153
|
} else {
|
|
157
|
-
log.error("alive: recovery failed, notifying user");
|
|
158
|
-
await notifyUser(
|
|
159
|
-
`Database is down and auto-recovery failed.\n\n` +
|
|
160
|
-
`Recovery report:\n${report}\n\n` +
|
|
161
|
-
`Run \`nia health\` to check status.`
|
|
162
|
-
);
|
|
154
|
+
log.error({ report }, "alive: recovery failed, notifying user");
|
|
155
|
+
await notifyUser(report);
|
|
163
156
|
}
|
|
164
157
|
}
|
|
165
|
-
|
|
166
|
-
|
|
158
|
+
|
|
159
|
+
lastFailures = failureNames;
|
|
167
160
|
}
|
|
168
161
|
|
|
169
162
|
export function startAlive(): void {
|
|
@@ -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
|
+
}
|