Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ OPENCODE_MODEL_ID=big-pickle
# Maximum file size in KB to send as document (default: 100)
# CODE_FILE_MAX_SIZE_KB=100

# Health Server (optional)
# Port for the internal health endpoint (default: 3100, 0 to disable)
# Exposes GET http://127.0.0.1:3100/health, /health/live, /health/ready
# Used by Docker HEALTHCHECK and hermes-ops. Binds only to loopback.
# BOT_HEALTH_PORT=3100

# Speech-to-Text / Voice Recognition (optional)
# Enable voice message transcription by setting a Whisper-compatible API URL.
# Works with OpenAI, Groq, or any Whisper-compatible endpoint.
Expand Down
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ ENV OPENCODE_TELEGRAM_HOME=/app/data
RUN mkdir -p /app/data/logs /app/data/run && \
chown -R node:node /app

# Health server port (internal, loopback only)
EXPOSE 3100

# Copy built application and production dependencies from builder
COPY --from=builder --chown=node:node /app/dist ./dist
COPY --from=builder --chown=node:node /app/node_modules ./node_modules
Expand Down
7 changes: 7 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ services:
- OPENCODE_API_URL=${OPENCODE_API_URL:-http://127.0.0.1:4096}
- OPENCODE_TELEGRAM_HOME=/app/data
- OPENCODE_TELEGRAM_CONTAINER=1
- BOT_HEALTH_PORT=${BOT_HEALTH_PORT:-3100}
env_file:
- .env
volumes:
Expand All @@ -20,6 +21,12 @@ services:
options:
max-size: "10m"
max-file: "5"
healthcheck:
test: ["CMD-SHELL", "node -e \"const p=process.env.BOT_HEALTH_PORT||'3100'; if(p==='0') process.exit(0); fetch('http://127.0.0.1:'+p+'/health').then(r=>r.json()).then(j=>process.exit(j.status==='healthy'?0:1)).catch(()=>process.exit(1))\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 45s

volumes:
opencode-bot-data:
4 changes: 4 additions & 0 deletions src/app/bootstrap/start-bot-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { clearServiceStateFile } from "../../runtime/service/manager.js";
import { getServiceStateFilePathFromEnv, isServiceChildProcess } from "../../runtime/service/env.js";
import { flushLogger, getLogFilePath, initializeLogger, logger } from "../../utils/logger.js";
import { safeBackgroundTask } from "../../utils/safe-background-task.js";
import { startHealthServer, stopHealthServer } from "../../health/server.js";

const SHUTDOWN_TIMEOUT_MS = 5000;
const SETTINGS_FLUSH_TIMEOUT_MS = 1000;
Expand Down Expand Up @@ -116,6 +117,7 @@ export async function startBotApp(): Promise<void> {
await loadSettings();
await reconcileStoredModelSelection();
registerOpenCodeReadyRefreshHandler();
await startHealthServer(config.health.port, version);
const bot = createBot();
await scheduledTaskRuntime.initialize(
bot,
Expand All @@ -142,6 +144,7 @@ export async function startBotApp(): Promise<void> {
cleanupBotRuntime(`app_shutdown_${signal.toLowerCase()}`);
opencodeAutoRestartService.stop();
scheduledTaskRuntime.shutdown();
void stopHealthServer();

shutdownTimeout = setTimeout(() => {
logger.warn(`[App] Shutdown did not finish in ${SHUTDOWN_TIMEOUT_MS}ms, forcing exit.`);
Expand Down Expand Up @@ -200,6 +203,7 @@ export async function startBotApp(): Promise<void> {
cleanupBotRuntime("app_shutdown_complete");
opencodeAutoRestartService.stop();
scheduledTaskRuntime.shutdown();
await stopHealthServer();
await clearManagedServiceState().catch((error) => {
logger.warn("[App] Failed to clear managed service state", error);
});
Expand Down
3 changes: 3 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,9 @@ export const config = {
server: {
logLevel: getEnvVar("LOG_LEVEL", false) || "info",
},
health: {
port: getOptionalNonNegativeIntEnvVar("BOT_HEALTH_PORT", 3100),
},
bot: {
sessionsListLimit: getOptionalPositiveIntEnvVar("SESSIONS_LIST_LIMIT", 10),
messagesListLimit: getOptionalPositiveIntEnvVar("MESSAGES_LIST_LIMIT", 10),
Expand Down
146 changes: 146 additions & 0 deletions src/health/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import http from "node:http";
import { isOpencodeServerHealthy } from "../opencode/ready-refresh.js";
import { logger } from "../utils/logger.js";

let server: http.Server | null = null;
let startTimeMs: number | null = null;
let botVersion = "unknown";

interface HealthPayload {
status: "healthy" | "degraded";
version: string;
uptimeSeconds: number;
timestamp: string;
checks: {
process: { healthy: boolean };
opencode: { healthy: boolean; latencyMs: number | null; error?: string };
};
}

function getUptimeSeconds(): number {
if (startTimeMs === null) return 0;
return Math.floor((Date.now() - startTimeMs) / 1000);
}

async function checkOpencodeWithTimeout(timeoutMs = 3000): Promise<{ healthy: boolean; latencyMs: number | null; error?: string }> {
const start = Date.now();
try {
const result = await Promise.race([
isOpencodeServerHealthy(),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("timeout")), timeoutMs)),
]);
return { healthy: result === true, latencyMs: Date.now() - start };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { healthy: false, latencyMs: Date.now() - start, error: message };
}
}

async function buildHealthPayload(): Promise<HealthPayload> {
const opencode = await checkOpencodeWithTimeout(3000);

let status: HealthPayload["status"] = "healthy";
if (!opencode.healthy) {
// Bot process alive but dependency down -> degraded (still running, can recover)
status = "degraded";
}

return {
status,
version: botVersion,
uptimeSeconds: getUptimeSeconds(),
timestamp: new Date().toISOString(),
checks: {
process: { healthy: true },
opencode,
},
};
}

function sendJson(res: http.ServerResponse, statusCode: number, data: unknown): void {
const body = JSON.stringify(data);
res.writeHead(statusCode, {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body),
"Cache-Control": "no-store",
});
res.end(body);
}

export async function startHealthServer(port: number, version: string): Promise<void> {
if (port === 0) {
logger.info("[Health] Health server disabled (BOT_HEALTH_PORT=0)");
return;
}
if (server) {
logger.warn("[Health] Health server already running");
return;
}

botVersion = version;
startTimeMs = Date.now();

server = http.createServer(async (req, res) => {
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "127.0.0.1"}`);
const path = url.pathname;

if (req.method !== "GET") {
sendJson(res, 405, { error: "Method not allowed" });
return;
}

if (path === "/health/live") {
// Liveness: process is alive (no dependency checks)
sendJson(res, 200, {
status: "healthy",
version: botVersion,
uptimeSeconds: getUptimeSeconds(),
timestamp: new Date().toISOString(),
});
return;
}

if (path === "/health/ready") {
// Readiness: 200 if healthy, 503 if degraded
const payload = await buildHealthPayload();
sendJson(res, payload.status === "healthy" ? 200 : 503, payload);
return;
}

if (path === "/health") {
// Full health: always 200 with status field (Docker healthcheck convention)
const payload = await buildHealthPayload();
sendJson(res, 200, payload);
return;
}

sendJson(res, 404, { error: "Not found", path });
});

server.on("error", (error) => {
logger.error("[Health] Health server error", error);
});

await new Promise<void>((resolve, reject) => {
server!.listen(port, "127.0.0.1", () => {
logger.info(`[Health] Health server listening on 127.0.0.1:${port} (/health, /health/live, /health/ready)`);
resolve();
});
server!.once("error", reject);
});
}

export async function stopHealthServer(): Promise<void> {
if (!server) return;
const s = server;
server = null;
startTimeMs = null;
await new Promise<void>((resolve) => {
s.close(() => resolve());
setTimeout(() => {
s.closeAllConnections?.();
resolve();
}, 2000).unref?.();
});
logger.info("[Health] Health server stopped");
}
19 changes: 18 additions & 1 deletion tests/app/start-bot-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ const mocked = vi.hoisted(() => ({
initializeLoggerMock: vi.fn(),
getLogFilePathMock: vi.fn(),
flushLoggerMock: vi.fn(),
startHealthServerMock: vi.fn(),
stopHealthServerMock: vi.fn(),
config: {
opencode: {
apiUrl: "http://localhost:4096",
Expand All @@ -41,7 +43,12 @@ vi.mock("../../src/bot/index.js", () => ({
}));

vi.mock("../../src/config.js", () => ({
config: mocked.config,
config: {
...mocked.config,
health: {
port: 0,
},
},
}));

vi.mock("../../src/opencode/auto-restart.js", () => ({
Expand Down Expand Up @@ -101,6 +108,11 @@ vi.mock("../../src/utils/logger.js", () => ({
},
}));

vi.mock("../../src/health/server.js", () => ({
startHealthServer: mocked.startHealthServerMock,
stopHealthServer: mocked.stopHealthServerMock,
}));

import { startBotApp } from "../../src/app/bootstrap/start-bot-app.js";
import { defined } from "../helpers/defined.js";

Expand Down Expand Up @@ -226,6 +238,8 @@ describe("app/start-bot-app", () => {

expect(mocked.registerOpenCodeReadyRefreshHandlerMock).toHaveBeenCalledTimes(1);
expect(mocked.notifyOpencodeReadyIfHealthyMock).toHaveBeenCalledWith("startup");
expect(mocked.startHealthServerMock).toHaveBeenCalledTimes(1);
expect(mocked.startHealthServerMock).toHaveBeenCalledWith(0, expect.any(String));
});

it("runs startup health notification even when auto-restart handled startup", async () => {
Expand All @@ -235,6 +249,7 @@ describe("app/start-bot-app", () => {
await flushBackgroundTasks();

expect(mocked.notifyOpencodeReadyIfHealthyMock).toHaveBeenCalledWith("startup");
expect(mocked.startHealthServerMock).toHaveBeenCalledTimes(1);
});

it("starts Telegram polling without waiting for OpenCode startup checks", async () => {
Expand All @@ -251,6 +266,7 @@ describe("app/start-bot-app", () => {

expect(bot.start).toHaveBeenCalledTimes(1);
expect(mocked.notifyOpencodeReadyIfHealthyMock).not.toHaveBeenCalled();
expect(mocked.startHealthServerMock).toHaveBeenCalledTimes(1);

resolveAutoRestart(false);
await flushBackgroundTasks();
Expand Down Expand Up @@ -404,5 +420,6 @@ describe("app/start-bot-app", () => {
expect(defined(mocked.flushSettingsMock.mock.invocationCallOrder[0])).toBeGreaterThan(
defined(mocked.scheduledTaskShutdownMock.mock.invocationCallOrder[0]),
);
expect(mocked.stopHealthServerMock).toHaveBeenCalledTimes(1);
});
});
Loading
Loading