convert to cf worker

This commit is contained in:
Shadow 2026-04-27 18:43:18 -05:00
parent e792b3dcf8
commit 1175fd90b0
No known key found for this signature in database
15 changed files with 1048 additions and 956 deletions

View File

@ -1,7 +1,10 @@
BASE_URL=
DEPLOY_SECRET=
DISCORD_CLIENT_ID=
DISCORD_PUBLIC_KEY=
DISCORD_BOT_TOKEN=
DISCORD_DEV_GUILDS=
ANSWER_OVERFLOW_API_KEY=
ANSWER_OVERFLOW_API_BASE_URL=
WORKER_EVENT_URL=
WORKER_EVENT_SECRET=
HELPER_THREAD_WELCOME_PARENT_ID=
HELPER_THREAD_WELCOME_TEMPLATE=
THREAD_LENGTH_CHECK_INTERVAL_HOURS=

438
README.md
View File

@ -1,415 +1,73 @@
# Hermit
# Hermit (Cloudflare Worker)
Hermit is the OpenClaw Discord bot built on [Carbon](https://carbon.buape.com), Bun, and SQLite.
Discord bot built with Carbon on Cloudflare Workers.
It handles:
## Stack
- Discord slash commands and message-context moderation actions
- helper-thread onboarding and thread-length enforcement
- keyword-based automod responses
- announcement crossposting for selected channels
- local event and helper-thread state persistence in SQLite
- a read-only local dashboard for helper events and tracked threads
- `@buape/carbon`
- Cloudflare Workers (`@buape/carbon/adapters/fetch`)
- Gateway plugin: `CloudflareGatewayPlugin` + `CloudflareGatewayDurableObject`
- Cloudflare D1 + Drizzle ORM
Repository: [openclaw/hermit](https://github.com/openclaw/hermit)
## Setup
## Runtime Overview
Hermit runs as a gateway-first Discord bot:
- Bun is the runtime and package manager
- Carbon handles command registration, gateway events, and Discord API access
- Drizzle manages the SQLite schema and migrations
- SQLite stores helper event history and tracked helper thread state
- A small Bun HTTP server exposes read-only operational visibility
Main entrypoint: [src/index.ts](src/index.ts)
## Features
- `/github` looks up GitHub issues and pull requests
- `Solved (Mod)` marks a thread as solved in Answer Overflow and closes it
- `/say ...` posts common guidance and documentation links
- `/helper ...` posts helper-thread moderation messages and closes threads
- `/role ...` toggles specific server roles
- helper-thread creation triggers a welcome message and thread tracking
- a background monitor warns on long threads and auto-closes very long ones
- automod rules can repost/redact matching messages and send guidance replies
- selected announcement channels are auto-crossposted
## Requirements
- Bun
- a Discord application and bot token
- access to the target Discord server
- SQLite filesystem access for `DB_PATH`
## Installation
1. Install dependencies:
1. Install deps:
```bash
bun install
pnpm install
```
2. Create a `.env` file.
2. Create `.env` from `.env.example`.
Recommended variables:
Required:
```env
DISCORD_CLIENT_ID="your-client-id"
DISCORD_BOT_TOKEN="your-bot-token"
DISCORD_DEV_GUILDS="guild_id_1,guild_id_2"
ANSWER_OVERFLOW_API_KEY="your-answer-overflow-api-key"
ANSWER_OVERFLOW_API_BASE_URL="https://www.answeroverflow.com"
HELPER_THREAD_WELCOME_PARENT_ID="123456789012345678"
HELPER_THREAD_WELCOME_TEMPLATE="Welcome to helpers. Please include expected vs actual behavior, what you already tried, and relevant logs/code."
THREAD_LENGTH_CHECK_INTERVAL_HOURS="2"
DB_PATH="data/hermit.sqlite"
DRIZZLE_MIGRATIONS="drizzle"
HELPER_LOGS_HOST="127.0.0.1"
HELPER_LOGS_PORT="8787"
BASE_URL=
DEPLOY_SECRET=
DISCORD_CLIENT_ID=
DISCORD_PUBLIC_KEY=
DISCORD_BOT_TOKEN=
```
3. Apply migrations:
Optional:
```bash
bun run db:migrate
```env
DISCORD_DEV_GUILDS=
ANSWER_OVERFLOW_API_KEY=
HELPER_THREAD_WELCOME_PARENT_ID=
HELPER_THREAD_WELCOME_TEMPLATE=
THREAD_LENGTH_CHECK_INTERVAL_HOURS=
```
4. Start Hermit:
3. Configure `wrangler.jsonc` D1 binding:
- set `d1_databases[0].database_id` to your real D1 database id
- keep `binding = "DB"`
4. Apply D1 migrations:
```bash
bun run dev
pnpm run db:apply:local
# or
pnpm run db:apply:remote
```
5. Run locally:
```bash
pnpm run dev
```
## Scripts
- `bun run dev` starts Hermit in watch mode
- `bun run start` starts Hermit normally
- `bun run typecheck` runs TypeScript without emitting files
- `bun run db:migrate` applies Drizzle migrations to SQLite
- `bun run db:generate` generates Drizzle migration files from the schema
- `pnpm run dev``wrangler dev --env-file .env`
- `pnpm run deploy` → deploy worker
- `pnpm run cf-typegen` → regenerate `worker-configuration.d.ts`
- `pnpm run typecheck` → TypeScript check
- `pnpm run db:generate` → generate Drizzle SQL
- `pnpm run db:apply:local` / `db:apply:remote` → apply D1 migrations
## Environment Variables
## Notes
### Required
- `DISCORD_CLIENT_ID`: Discord application client ID
- `DISCORD_BOT_TOKEN`: Discord bot token
### Optional
- `DISCORD_DEV_GUILDS`: comma-separated guild IDs for dev command registration
- `ANSWER_OVERFLOW_API_KEY`: required for `Solved (Mod)` to call Answer Overflow
- `ANSWER_OVERFLOW_API_BASE_URL`: defaults to `https://www.answeroverflow.com`
- `HELPER_THREAD_WELCOME_PARENT_ID`: parent forum or helper channel whose new threads should receive the welcome message
- `HELPER_THREAD_WELCOME_TEMPLATE`: overrides the default helper welcome text
- `THREAD_LENGTH_CHECK_INTERVAL_HOURS`: enables the helper thread monitor when set to a positive number
- `DB_PATH`: SQLite database path, defaults to `data/hermit.sqlite`
- `DRIZZLE_MIGRATIONS`: migration directory, defaults to `drizzle`
- `HELPER_LOGS_HOST`: host for the read-only helper dashboard, defaults to `127.0.0.1`
- `HELPER_LOGS_PORT`: port for the read-only helper dashboard, defaults to `8787`; set to `0` to disable it
- `SKIP_DB_MIGRATIONS`: set to `1` to skip automatic migration-on-startup
## Commands
### `/github`
Looks up a GitHub issue or pull request and returns:
- title and state
- repo and author
- labels
- description summary
- recent comments
- pull request change stats when applicable
Options:
- `number` required
- `user` optional, defaults to `openclaw`
- `repo` optional, defaults to `hermit`
Available in:
- guilds
- bot DMs
Source: [src/commands/github.ts](src/commands/github.ts)
### `Solved (Mod)`
Message-context moderation action that:
- posts the chosen solution message to Answer Overflow
- adds a checkmark reaction to the solved message
- archives and locks the thread
- records a `mark_solution` helper event in SQLite
Permissions:
- `ManageMessages`
- `ManageThreads`
Requires:
- `ANSWER_OVERFLOW_API_KEY`
Source: [src/commands/solvedMod.ts](src/commands/solvedMod.ts)
### `/say`
Posts common canned guidance messages.
Subcommands:
- `guide`
- `server-faq`
- `help`
- `user-help`
- `model`
- `stuck`
- `ci`
- `answeroverflow`
- `pinging`
- `docs`
- `security`
- `install`
- `blog-rename`
Available in:
- guilds
- bot DMs
Source: [src/commands/say.ts](src/commands/say.ts)
### `/helper`
Helper-channel moderation utilities.
Subcommands:
- `warn-new-thread`: posts the long-thread warning message
- `close`: posts the close message, archives the thread, and locks it
- `close-thread`: same behavior as `close`
These commands also emit helper events into SQLite.
Source: [src/commands/helper.ts](src/commands/helper.ts)
### `/role`
Toggles specific hard-coded server roles.
Current subcommands:
- `showcase-ban`
- `clawtributor`
Permissions:
- command requires `ManageRoles`
- runtime access also checks that the invoking member has the hard-coded `communityStaff` role
Source: [src/commands/role.ts](src/commands/role.ts)
## Gateway Events And Background Behavior
### Ready
On startup, Hermit logs the connected username and starts the helper thread monitor when configured.
Source: [src/events/ready.ts](src/events/ready.ts)
### Thread Create Welcome
When a new thread is created under `HELPER_THREAD_WELCOME_PARENT_ID`, Hermit:
- stores the thread in `tracked_threads`
- records a `thread_welcome_created` event
- posts the helper welcome message
Source: [src/events/threadCreateWelcome.ts](src/events/threadCreateWelcome.ts)
### Thread Length Monitor
When `THREAD_LENGTH_CHECK_INTERVAL_HOURS` is set, Hermit polls tracked helper threads with `setInterval`.
Behavior:
- loads open tracked threads from SQLite
- fetches the Discord thread live
- updates message counts and close state
- warns at more than `100` messages
- warns again at more than `150` messages
- posts a close notice and archives/locks at more than `200` messages
Configured messages live in:
- [src/config/threadLengthMessages.ts](src/config/threadLengthMessages.ts)
Source: [src/services/threadLengthMonitor.ts](src/services/threadLengthMonitor.ts)
### AutoModeration Action Execution
Hermit listens to automod keyword actions and can:
- repost the triggering content through a webhook
- redact the matched trigger in the repost
- send a follow-up warning/guidance message
- optionally include a role mention in the guidance message
Automod rule configuration lives in:
- [src/config/automod-messages.json](src/config/automod-messages.json)
Message template placeholders:
- `{user}`
- `{keyword}`
- `{content}`
Source: [src/events/autoModerationActionExecution.ts](src/events/autoModerationActionExecution.ts)
### Auto Publish Message Create
Hermit auto-crossposts messages from a fixed set of announcement channel IDs.
Source: [src/events/autoPublishMessageCreate.ts](src/events/autoPublishMessageCreate.ts)
## Database
Hermit uses SQLite via Bun and Drizzle.
Database bootstrap: [src/db.ts](src/db.ts)
Schema definition: [src/db/schema.ts](src/db/schema.ts)
### Tables
#### `keyValue`
Generic key/value storage with:
- `key`
- `value`
- `createdAt`
- `updatedAt`
#### `helper_events`
Operational event log for helper-related actions.
Fields:
- `id`
- `event_type`
- `thread_id`
- `message_count`
- `event_time`
- `command`
- `invoked_by_id`
- `invoked_by_username`
- `invoked_by_global_name`
- `received_at`
- `raw_payload`
Typical event types:
- `mark_solution`
- `helper_command`
- `thread_welcome_created`
#### `tracked_threads`
Persistent helper-thread state used by the monitor.
Fields:
- `id`
- `thread_id`
- `created_at`
- `last_checked`
- `solved`
- `warning_level`
- `closed`
- `last_message_count`
- `received_at`
- `raw_payload`
## Migrations
Drizzle configuration: [drizzle.config.ts](drizzle.config.ts)
Migration runner: [src/scripts/migrate.ts](src/scripts/migrate.ts)
Committed SQL migrations live under:
- [drizzle/](drizzle)
On startup, Hermit automatically applies migrations unless `SKIP_DB_MIGRATIONS=1`.
## Read-Only Helper Logs HTTP Server
Hermit starts a small Bun HTTP server for local visibility into helper activity.
Default address:
- `http://127.0.0.1:8787`
Routes:
- `GET /`: dashboard UI for helper events
- `GET /api/events`: JSON event listing
- `GET /api/threads`: JSON tracked-thread listing
Supported `GET /api/events` query params:
- `eventType`
- `command`
- `threadId`
- `invokedBy`
- `from`
- `to`
- `limit` up to `500`
Supported `GET /api/threads` query params:
- `threadId`
- `solved`
- `closed`
- `limit` up to `500`
Source: [src/server/helperLogsServer.ts](src/server/helperLogsServer.ts)
## Configuration Files
- [src/config/automod-messages.json](src/config/automod-messages.json): automod trigger-to-response mapping
- [src/config/threadLengthMessages.ts](src/config/threadLengthMessages.ts): warning and auto-close helper thread messages
## Development Notes
- Hermit is Bun-first; `package-lock.json` is intentionally not used
- command registration and gateway listeners are wired in [src/index.ts](src/index.ts)
- helper events and tracked-thread writes are internal; the HTTP server is read-only
- the thread-length scheduler is interval-based, not cron-based
## Verification
Useful local checks:
```bash
bun run typecheck
bun run db:migrate
bun run dev
```
## License
MIT
- Answer Overflow base URL is hardcoded to `https://www.answeroverflow.com`.
- Helper thread monitor runs via Worker cron (`wrangler.jsonc` `triggers.crons`).

View File

@ -1,12 +1,7 @@
import { defineConfig } from "drizzle-kit"
const dbPath = process.env.DB_PATH ?? "data/hermit.sqlite"
export default defineConfig({
dialect: "sqlite",
schema: "./src/db/schema.ts",
out: "./drizzle",
dbCredentials: {
url: dbPath
}
out: "./drizzle"
})

View File

@ -5,19 +5,23 @@
"type": "module",
"main": "./src/index.ts",
"scripts": {
"dev": "bun --watch run src/index.ts",
"start": "bun run src/index.ts",
"dev": "wrangler dev --port 3000 --env-file .env",
"deploy": "wrangler deploy --env-file .env",
"cf-typegen": "wrangler types",
"postinstall": "wrangler types",
"typecheck": "tsc --noEmit",
"db:migrate": "bun run src/scripts/migrate.ts",
"db:generate": "drizzle-kit generate"
"db:generate": "drizzle-kit generate",
"db:apply:local": "wrangler d1 migrations apply hermit-db --local",
"db:apply:remote": "wrangler d1 migrations apply hermit-db --remote"
},
"dependencies": {
"@buape/carbon": "0.16.0",
"@buape/carbon": "0.0.0-beta-20260427233936",
"drizzle-orm": "^0.45.1"
},
"devDependencies": {
"@types/bun": "1.3.6",
"@types/node": "^25.6.0",
"drizzle-kit": "^0.31.8",
"typescript": "5.9.3"
"typescript": "5.9.3",
"wrangler": "^4.85.0"
}
}

864
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -12,9 +12,7 @@ import {
import BaseCommand from "./base.js"
import { sendWorkerEvent } from "../utils/workerEvent.js"
const answerOverflowBaseUrl = (
process.env.ANSWER_OVERFLOW_API_BASE_URL ?? "https://www.answeroverflow.com"
).replace(/\/+$/, "")
const answerOverflowBaseUrl = "https://www.answeroverflow.com"
type MarkSolutionResponse = {
success?: boolean

View File

@ -1,5 +1,5 @@
import { and, desc, eq, gte, lte, sql } from "drizzle-orm"
import { db } from "../db.js"
import { getDb } from "../db.js"
import { helperEvents, trackedThreads } from "../db/schema.js"
type GenericWorkerEventPayload = {
@ -169,7 +169,7 @@ export const normalizeEventPayload = (
}
export const insertEvent = async (normalizedEvent: NormalizedEvent) => {
await db.insert(helperEvents).values(normalizedEvent)
await getDb().insert(helperEvents).values(normalizedEvent)
}
export const listEvents = async ({
@ -181,6 +181,7 @@ export const listEvents = async ({
to,
limit = 100
}: EventFilters = {}) => {
const db = getDb()
const filters = []
if (eventType) {
@ -229,6 +230,7 @@ export const listEvents = async ({
}
export const upsertTrackedThread = async (payload: ThreadUpsertPayload) => {
const db = getDb()
const threadId = asStringOrNull(payload.threadId)
if (!threadId) {
return { error: "threadId is required", status: 400 as const }
@ -278,6 +280,7 @@ export const listTrackedThreads = async ({
closed,
limit = 100
}: ThreadFilters = {}) => {
const db = getDb()
const filters = []
if (threadId) {

View File

@ -1,32 +1,5 @@
import { Database } from "bun:sqlite"
import { existsSync, mkdirSync } from "node:fs"
import { dirname, isAbsolute, resolve } from "node:path"
import { fileURLToPath } from "node:url"
import { drizzle } from "drizzle-orm/bun-sqlite"
import { migrate } from "drizzle-orm/bun-sqlite/migrator"
import { drizzle } from "drizzle-orm/d1"
import { getRuntimeEnv } from "./runtime/env.js"
import * as schema from "./db/schema.js"
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..")
const resolveFromProjectRoot = (path: string) =>
isAbsolute(path) ? path : resolve(projectRoot, path)
const DB_PATH = resolveFromProjectRoot(Bun.env.DB_PATH ?? "data/hermit.sqlite")
const MIGRATIONS_FOLDER = resolveFromProjectRoot(
Bun.env.DRIZZLE_MIGRATIONS ?? "drizzle"
)
mkdirSync(dirname(DB_PATH), { recursive: true })
const sqlite = new Database(DB_PATH)
sqlite.exec("PRAGMA journal_mode = WAL;")
sqlite.exec("PRAGMA synchronous = NORMAL;")
export const db = drizzle(sqlite, { schema })
if (existsSync(MIGRATIONS_FOLDER) && Bun.env.SKIP_DB_MIGRATIONS !== "1") {
migrate(db, { migrationsFolder: MIGRATIONS_FOLDER })
}
export { DB_PATH, sqlite }
export const getDb = () => drizzle(getRuntimeEnv().DB, { schema })

View File

@ -8,7 +8,7 @@ import {
serializePayload,
type MessagePayloadObject
} from "@buape/carbon"
import { readFile } from "node:fs/promises"
import automodMessages from "../config/automod-messages.js"
type AutomodRuleConfig = {
trigger: string
@ -32,20 +32,13 @@ type WebhookSendPayload = MessagePayloadObject & {
avatar_url?: string
}
const automodMessagesUrl = new URL("../config/automod-messages.json", import.meta.url)
const webhookCache = new Map<string, WebhookCacheEntry>()
const webhookCacheTtlMs = 15 * 60 * 1000
const normalizeKeyword = (keyword: string) => keyword.trim().toLowerCase()
const loadAutomodMessages = async (): Promise<AutomodMessageMap> => {
try {
const raw = await readFile(automodMessagesUrl, "utf8")
return JSON.parse(raw) as AutomodMessageMap
} catch (error) {
console.error("Failed to load automod messages:", error)
return {}
}
return automodMessages as AutomodMessageMap
}
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")

View File

@ -1,13 +1,7 @@
import {
type Client,
ReadyListener,
type ListenerEventData
} from "@buape/carbon"
import { startThreadLengthMonitor } from "../services/threadLengthMonitor.js"
import { ReadyListener, type ListenerEventData } from "@buape/carbon"
export default class Ready extends ReadyListener {
async handle(data: ListenerEventData[this["type"]], client: Client) {
async handle(data: ListenerEventData[this["type"]]) {
console.log(`Logged in as ${data.user.username}`)
startThreadLengthMonitor(client)
}
}

View File

@ -1,20 +1,25 @@
import { Client } from "@buape/carbon"
import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway"
import GithubCommand from "./commands/github.js"
import SolvedModCommand from "./commands/solvedMod.js"
import SayRootCommand from "./commands/say.js"
import RoleCommand from "./commands/role.js"
import HelperRootCommand from "./commands/helper.js"
import { createHandler } from "@buape/carbon/adapters/fetch"
import {
CloudflareGatewayDurableObject,
CloudflareGatewayPlugin
} from "@buape/carbon/cloudflare-gateway"
import { GatewayIntents } from "@buape/carbon/gateway"
import AdminCommand from "./commands/admin.js"
import GithubCommand from "./commands/github.js"
import HelperRootCommand from "./commands/helper.js"
import RoleCommand from "./commands/role.js"
import SayRootCommand from "./commands/say.js"
import SolvedModCommand from "./commands/solvedMod.js"
import AutoModerationActionExecution from "./events/autoModerationActionExecution.js"
import AutoPublishMessageCreate from "./events/autoPublishMessageCreate.js"
import Ready from "./events/ready.js"
import ThreadCreateWelcome from "./events/threadCreateWelcome.js"
import { startHelperLogsServer } from "./server/helperLogsServer.js"
import { hydrateRuntimeEnv, type HermitEnv } from "./runtime/env.js"
import { registerHelperLogsRoutes } from "./server/helperLogsServer.js"
import { runThreadLengthMonitor } from "./services/threadLengthMonitor.js"
startHelperLogsServer()
const gateway = new GatewayPlugin({
const gateway = new CloudflareGatewayPlugin({
intents:
GatewayIntents.Guilds |
GatewayIntents.GuildMessages |
@ -23,18 +28,15 @@ const gateway = new GatewayPlugin({
autoInteractions: true
})
const client = new Client(
export const client = new Client(
{
baseUrl: "http://localhost:3000",
deploySecret: "unused",
baseUrl: process.env.BASE_URL,
deploySecret: process.env.DEPLOY_SECRET,
clientId: process.env.DISCORD_CLIENT_ID,
publicKey: "unused",
publicKey: process.env.DISCORD_PUBLIC_KEY,
token: process.env.DISCORD_BOT_TOKEN,
autoDeploy: true,
disableDeployRoute: true,
disableInteractionsRoute: true,
disableEventsRoute: true,
devGuilds: process.env.DISCORD_DEV_GUILDS?.split(","), // Optional: comma-separated list of dev guild IDs
devGuilds: process.env.DISCORD_DEV_GUILDS?.split(",")
},
{
commands: [
@ -50,11 +52,31 @@ const client = new Client(
new AutoPublishMessageCreate(),
new ThreadCreateWelcome(),
new Ready()
],
]
},
[gateway]
)
registerHelperLogsRoutes(client)
const handler = createHandler(client)
export { CloudflareGatewayDurableObject }
export default {
fetch(request: Request, env: HermitEnv, ctx: ExecutionContext) {
hydrateRuntimeEnv(env)
return handler(request, {
env,
waitUntil: ctx.waitUntil.bind(ctx)
})
},
scheduled(_controller: ScheduledController, env: HermitEnv, ctx: ExecutionContext) {
hydrateRuntimeEnv(env)
ctx.waitUntil(runThreadLengthMonitor(client))
}
} satisfies ExportedHandler<Env>
declare global {
namespace NodeJS {
interface ProcessEnv {
@ -63,15 +85,11 @@ declare global {
DISCORD_CLIENT_ID: string;
DISCORD_PUBLIC_KEY: string;
DISCORD_BOT_TOKEN: string;
DISCORD_DEV_GUILDS?: string;
ANSWER_OVERFLOW_API_KEY?: string;
ANSWER_OVERFLOW_API_BASE_URL?: string;
HELPER_THREAD_WELCOME_PARENT_ID?: string;
HELPER_THREAD_WELCOME_TEMPLATE?: string;
THREAD_LENGTH_CHECK_INTERVAL_HOURS?: string;
HELPER_LOGS_HOST?: string;
HELPER_LOGS_PORT?: string;
DB_PATH?: string;
DRIZZLE_MIGRATIONS?: string;
}
}
}

View File

@ -1,14 +1 @@
import { migrate } from "drizzle-orm/bun-sqlite/migrator"
import { dirname, isAbsolute, resolve } from "node:path"
import { fileURLToPath } from "node:url"
import { db } from "../db.js"
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..")
const configuredMigrationsFolder = Bun.env.DRIZZLE_MIGRATIONS ?? "drizzle"
const migrationsFolder = isAbsolute(configuredMigrationsFolder)
? configuredMigrationsFolder
: resolve(projectRoot, configuredMigrationsFolder)
migrate(db, { migrationsFolder })
console.log(`Applied migrations from ${migrationsFolder}.`)
console.log("Use `pnpm run db:apply:local` or `pnpm run db:apply:remote` for D1 migrations.")

View File

@ -1,9 +1,5 @@
import {
listEvents,
listTrackedThreads,
} from "../data/helperLogs.js"
let serverStarted = false
import type { Client } from "@buape/carbon"
import { listEvents, listTrackedThreads } from "../data/helperLogs.js"
const asStringOrNull = (value: unknown): string | null =>
typeof value === "string" && value.trim().length > 0 ? value.trim() : null
@ -31,407 +27,77 @@ const renderHtml = () => `<!doctype html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Worker Events</title>
<title>Hermit helper logs</title>
<style>
:root {
--bg: #f5f7fb;
--panel: #ffffff;
--text: #1b2330;
--muted: #5d6b82;
--border: #dbe2ee;
--accent: #1b6fff;
--accent-2: #0f4dcf;
--shadow: 0 10px 30px rgba(25, 40, 70, 0.08);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--text);
background:
radial-gradient(circle at 10% 10%, #dbe8ff 0%, transparent 40%),
radial-gradient(circle at 90% 20%, #e7f0ff 0%, transparent 35%),
var(--bg);
}
.wrap {
max-width: 1280px;
margin: 0 auto;
padding: 28px 16px 40px;
}
.header {
margin-bottom: 18px;
}
h1 {
margin: 0 0 6px;
font-size: 1.8rem;
}
.subtitle {
margin: 0;
color: var(--muted);
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: var(--shadow);
}
.filters {
padding: 14px;
display: grid;
grid-template-columns: repeat(7, minmax(130px, 1fr));
gap: 10px;
}
.filters label {
font-size: 0.78rem;
color: var(--muted);
display: block;
margin-bottom: 6px;
}
input, select, button {
width: 100%;
border-radius: 10px;
border: 1px solid var(--border);
padding: 9px 10px;
font-size: 0.92rem;
background: white;
}
button {
background: var(--accent);
color: white;
border: none;
cursor: pointer;
font-weight: 600;
transition: background 0.2s ease;
}
button:hover { background: var(--accent-2); }
.button-row {
display: flex;
gap: 8px;
align-items: end;
}
.button-row button:last-child {
background: #eef3ff;
color: #1d3f88;
}
.meta {
padding: 0 14px 10px;
font-size: 0.85rem;
color: var(--muted);
}
.table-wrap {
overflow: auto;
border-top: 1px solid var(--border);
}
table {
width: 100%;
border-collapse: collapse;
min-width: 1200px;
}
th, td {
text-align: left;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
th {
background: #f8faff;
position: sticky;
top: 0;
z-index: 1;
font-size: 0.78rem;
letter-spacing: 0.03em;
text-transform: uppercase;
color: var(--muted);
}
tbody tr:hover {
background: #f9fbff;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.84rem;
}
@media (max-width: 1100px) {
.filters {
grid-template-columns: repeat(2, minmax(140px, 1fr));
}
}
body { font-family: ui-sans-serif, system-ui, sans-serif; margin: 2rem; }
a { color: #2563eb; text-decoration: none; }
a:hover { text-decoration: underline; }
code { background: #f3f4f6; padding: 0.15rem 0.35rem; border-radius: 4px; }
</style>
</head>
<body>
<main class="wrap">
<header class="header">
<h1>Worker Events</h1>
<p class="subtitle">Generic worker events captured from Hermit.</p>
</header>
<section class="panel">
<div class="filters">
<div>
<label for="eventType">Event Type</label>
<input id="eventType" placeholder="mark_solution" />
</div>
<div>
<label for="command">Command</label>
<input id="command" placeholder="Solved (Mod)" />
</div>
<div>
<label for="threadId">Thread ID</label>
<input id="threadId" placeholder="123..." />
</div>
<div>
<label for="invokedBy">Invoker ID</label>
<input id="invokedBy" placeholder="145..." />
</div>
<div>
<label for="from">From (ISO)</label>
<input id="from" placeholder="2026-03-01T00:00:00Z" />
</div>
<div>
<label for="to">To (ISO)</label>
<input id="to" placeholder="2026-03-06T23:59:59Z" />
</div>
<div>
<label for="limit">Limit</label>
<select id="limit">
<option value="50">50</option>
<option value="100" selected>100</option>
<option value="250">250</option>
<option value="500">500</option>
</select>
</div>
<div class="button-row">
<button id="apply">Apply filters</button>
</div>
<div class="button-row">
<button id="clear" type="button">Clear</button>
</div>
</div>
<div class="meta" id="meta">Loading...</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>Event Type</th>
<th>Event Time</th>
<th>Received</th>
<th>Command</th>
<th>Thread ID</th>
<th>Message Count</th>
<th>Invoker ID</th>
<th>Invoker Username</th>
<th>Invoker Global Name</th>
</tr>
</thead>
<tbody id="rows"></tbody>
</table>
</div>
</section>
</main>
<script>
const els = {
eventType: document.getElementById("eventType"),
command: document.getElementById("command"),
threadId: document.getElementById("threadId"),
invokedBy: document.getElementById("invokedBy"),
from: document.getElementById("from"),
to: document.getElementById("to"),
limit: document.getElementById("limit"),
apply: document.getElementById("apply"),
clear: document.getElementById("clear"),
rows: document.getElementById("rows"),
meta: document.getElementById("meta")
}
const escapeHtml = (value) => String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
const buildParams = () => {
const params = new URLSearchParams()
const values = {
eventType: els.eventType.value.trim(),
command: els.command.value.trim(),
threadId: els.threadId.value.trim(),
invokedBy: els.invokedBy.value.trim(),
from: els.from.value.trim(),
to: els.to.value.trim(),
limit: els.limit.value.trim()
}
for (const [key, value] of Object.entries(values)) {
if (value) params.set(key, value)
}
return params
}
const renderRows = (rows) => {
if (!rows.length) {
els.rows.innerHTML = '<tr><td colspan="10">No matching events.</td></tr>'
return
}
const html = rows.map((row) => {
return '<tr>' +
'<td class="mono">' + escapeHtml(row.id) + '</td>' +
'<td>' + escapeHtml(row.event_type) + '</td>' +
'<td class="mono">' + escapeHtml(row.event_time) + '</td>' +
'<td class="mono">' + escapeHtml(row.received_at) + '</td>' +
'<td>' + escapeHtml(row.command) + '</td>' +
'<td class="mono">' + escapeHtml(row.thread_id ?? "") + '</td>' +
'<td>' + escapeHtml(row.message_count ?? "") + '</td>' +
'<td class="mono">' + escapeHtml(row.invoked_by_id ?? "") + '</td>' +
'<td>' + escapeHtml(row.invoked_by_username ?? "") + '</td>' +
'<td>' + escapeHtml(row.invoked_by_global_name ?? "") + '</td>' +
'</tr>'
}).join("")
els.rows.innerHTML = html
}
const load = async () => {
els.meta.textContent = "Loading..."
const params = buildParams()
const response = await fetch('/api/events?' + params.toString())
if (!response.ok) {
els.meta.textContent = 'Failed to load events (' + response.status + ')'
els.rows.innerHTML = ""
return
}
const data = await response.json()
renderRows(data.events || [])
els.meta.textContent = 'Showing ' + data.count + ' events'
}
els.apply.addEventListener("click", load)
els.clear.addEventListener("click", () => {
els.eventType.value = ""
els.command.value = ""
els.threadId.value = ""
els.invokedBy.value = ""
els.from.value = ""
els.to.value = ""
els.limit.value = "100"
load()
})
load()
</script>
<h1>Hermit helper logs</h1>
<p>JSON endpoints:</p>
<ul>
<li><a href="/api/events">/api/events</a></li>
<li><a href="/api/threads">/api/threads</a></li>
</ul>
<p>Filters: <code>eventType</code>, <code>command</code>, <code>threadId</code>, <code>invokedBy</code>, <code>from</code>, <code>to</code>, <code>limit</code>.</p>
</body>
</html>`
const parsePort = () => {
const rawPort = process.env.HELPER_LOGS_PORT?.trim()
if (!rawPort) {
return 8787
}
export const registerHelperLogsRoutes = (client: Client) => {
client.routes.push(
{
method: "GET",
path: "/",
handler: () =>
new Response(renderHtml(), {
headers: {
"content-type": "text/html; charset=utf-8"
}
})
},
{
method: "GET",
path: "/api/events",
handler: async (request) => {
const url = new URL(request.url)
const events = await listEvents({
eventType: asStringOrNull(url.searchParams.get("eventType")),
command: asStringOrNull(url.searchParams.get("command")),
threadId: asStringOrNull(url.searchParams.get("threadId")),
invokedBy: asStringOrNull(url.searchParams.get("invokedBy")),
from: asStringOrNull(url.searchParams.get("from")),
to: asStringOrNull(url.searchParams.get("to")),
limit: parseLimit(url.searchParams.get("limit"))
})
const port = Number.parseInt(rawPort, 10)
if (!Number.isInteger(port) || port < 0) {
console.warn(`Invalid HELPER_LOGS_PORT "${rawPort}". Falling back to 8787.`)
return 8787
}
return port
}
export const startHelperLogsServer = () => {
if (serverStarted) {
return
}
const port = parsePort()
if (port === 0) {
console.log("Helper logs server disabled.")
return
}
const hostname = process.env.HELPER_LOGS_HOST?.trim() || "127.0.0.1"
Bun.serve({
hostname,
port,
routes: {
"/": {
GET: () =>
new Response(renderHtml(), {
headers: {
"content-type": "text/html; charset=utf-8"
}
})
},
"/api/events": {
GET: async (request) => {
const url = new URL(request.url)
const events = await listEvents({
eventType: asStringOrNull(url.searchParams.get("eventType")),
command: asStringOrNull(url.searchParams.get("command")),
threadId: asStringOrNull(url.searchParams.get("threadId")),
invokedBy: asStringOrNull(url.searchParams.get("invokedBy")),
from: asStringOrNull(url.searchParams.get("from")),
to: asStringOrNull(url.searchParams.get("to")),
limit: parseLimit(url.searchParams.get("limit"))
})
return json({ count: events.length, events })
}
},
"/api/threads": {
GET: async (request) => {
const url = new URL(request.url)
const threads = await listTrackedThreads({
threadId: asStringOrNull(url.searchParams.get("threadId")),
solved:
url.searchParams.get("solved") === null
? undefined
: url.searchParams.get("solved") === "1" ||
url.searchParams.get("solved")?.toLowerCase() === "true",
closed:
url.searchParams.get("closed") === null
? undefined
: url.searchParams.get("closed") === "1" ||
url.searchParams.get("closed")?.toLowerCase() === "true",
limit: parseLimit(url.searchParams.get("limit"))
})
return json({ count: threads.length, threads })
}
return json({ count: events.length, events })
}
},
fetch: () => json({ error: "Not found" }, { status: 404 }),
error: (error) => {
console.error("Helper logs server error:", error)
return json({ error: "Internal Server Error" }, { status: 500 })
}
})
{
method: "GET",
path: "/api/threads",
handler: async (request) => {
const url = new URL(request.url)
const threads = await listTrackedThreads({
threadId: asStringOrNull(url.searchParams.get("threadId")),
solved:
url.searchParams.get("solved") === null
? undefined
: url.searchParams.get("solved") === "1" ||
url.searchParams.get("solved")?.toLowerCase() === "true",
closed:
url.searchParams.get("closed") === null
? undefined
: url.searchParams.get("closed") === "1" ||
url.searchParams.get("closed")?.toLowerCase() === "true",
limit: parseLimit(url.searchParams.get("limit"))
})
serverStarted = true
console.log(`Helper logs server listening on http://${hostname}:${port}`)
return json({ count: threads.length, threads })
}
}
)
}

View File

@ -20,14 +20,12 @@ const SECOND_WARNING_THRESHOLD = 150
const AUTO_CLOSE_THRESHOLD = 200
const DEFAULT_FETCH_LIMIT = 500
let monitorStarted = false
let monitorInterval: ReturnType<typeof setInterval> | null = null
let monitorRunInFlight = false
const parseIntervalMs = () => {
const isThreadLengthMonitorEnabled = () => {
const rawValue = process.env.THREAD_LENGTH_CHECK_INTERVAL_HOURS?.trim()
if (!rawValue) {
return null
return false
}
const intervalHours = Number.parseFloat(rawValue)
@ -35,10 +33,10 @@ const parseIntervalMs = () => {
console.warn(
`THREAD_LENGTH_CHECK_INTERVAL_HOURS must be a positive number. Got "${rawValue}".`
)
return null
return false
}
return Math.round(intervalHours * 60 * 60 * 1000)
return true
}
const getErrorStatus = (error: unknown) => {
@ -250,42 +248,24 @@ const runMonitorPass = async (client: Client) => {
}
}
export const startThreadLengthMonitor = (client: Client) => {
if (monitorStarted) {
export const runThreadLengthMonitor = async (client: Client) => {
if (!isThreadLengthMonitorEnabled()) {
return
}
monitorStarted = true
const intervalMs = parseIntervalMs()
if (!intervalMs) {
console.log("Thread length monitor disabled.")
if (monitorRunInFlight) {
console.log(
"Skipping thread length monitor pass because the previous pass is still running."
)
return
}
const run = async () => {
if (monitorRunInFlight) {
console.log("Skipping thread length monitor pass because the previous pass is still running.")
return
}
monitorRunInFlight = true
try {
await runMonitorPass(client)
} catch (error) {
console.error("Thread length monitor pass failed:", error)
} finally {
monitorRunInFlight = false
}
}
console.log(`Thread length monitor enabled with interval ${intervalMs}ms.`)
void run()
monitorInterval = setInterval(() => {
void run()
}, intervalMs)
if (typeof monitorInterval.unref === "function") {
monitorInterval.unref()
monitorRunInFlight = true
try {
await runMonitorPass(client)
} catch (error) {
console.error("Thread length monitor pass failed:", error)
} finally {
monitorRunInFlight = false
}
}

View File

@ -1,5 +1,5 @@
{
"include": ["src"],
"include": ["src", "worker-configuration.d.ts"],
"exclude": ["node_modules", "dist"],
"compilerOptions": {
"strict": true,
@ -7,7 +7,9 @@
"lib": ["dom", "es2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": ["node"],
"resolveJsonModule": true,
"baseUrl": ".",
"outDir": "dist"
}
}
}