forked from bai/curriculum-project-hub
92 lines
3.4 KiB
TypeScript
92 lines
3.4 KiB
TypeScript
/**
|
|
* Hub entry — Fastify server + Feishu WebSocket listener.
|
|
*
|
|
* Wires the full trigger path: lark ws receives `im.message.receive_v1` →
|
|
* trigger handler resolves chat->project (ADR-0001), creates AgentRun + acquires
|
|
* the project lock (ADR-0002), runs the AI SDK-backed agent runner
|
|
* (ADR-0017), and releases the lock on finish. Feishu context reads inside the
|
|
* loop are ADR-0003-authorized (chat must match binding).
|
|
*
|
|
* Env (see .env.example):
|
|
* DATABASE_URL, ANTHROPIC_AUTH_TOKEN, FEISHU_APP_ID/SECRET/BOT_OPEN_ID, PORT
|
|
*/
|
|
import "dotenv/config";
|
|
import Fastify from "fastify";
|
|
import { prisma } from "./db.js";
|
|
import { createEnvRuntimeSettings } from "./settings/runtime.js";
|
|
import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
|
|
import { makeTriggerHandler } from "./feishu/trigger.js";
|
|
import { triggerQueue } from "./feishu/triggerQueue.js";
|
|
|
|
function requireEnv(name: string): string {
|
|
const v = process.env[name];
|
|
if (v === undefined || v === "") {
|
|
console.error(`[hub] missing required env: ${name}`);
|
|
process.exit(1);
|
|
}
|
|
return v;
|
|
}
|
|
|
|
function booleanEnv(name: string, fallback: boolean): boolean {
|
|
const raw = process.env[name];
|
|
if (raw === undefined || raw === "") return fallback;
|
|
return !["0", "false", "no", "off"].includes(raw.trim().toLowerCase());
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const databaseUrl = requireEnv("DATABASE_URL");
|
|
void databaseUrl;
|
|
|
|
const runtimeSettings = createEnvRuntimeSettings();
|
|
await runtimeSettings.provider("openrouter");
|
|
|
|
const feishuAppId = requireEnv("FEISHU_APP_ID");
|
|
const feishuAppSecret = requireEnv("FEISHU_APP_SECRET");
|
|
const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID");
|
|
const projectWorkspaceRoot = requireEnv("HUB_PROJECT_WORKSPACE_ROOT");
|
|
const port = Number(process.env["PORT"] ?? "8788");
|
|
|
|
const app = Fastify({ logger: true });
|
|
|
|
// Startup reset: clear stale locks + mark dead runs as FAILED.
|
|
await prisma.projectAgentLock.deleteMany({});
|
|
await prisma.agentRun.updateMany({
|
|
where: { status: "ACTIVE" },
|
|
data: { status: "FAILED", error: "process restart", finishedAt: new Date() },
|
|
});
|
|
app.log.info("startup: cleared stale locks + dead runs");
|
|
|
|
app.get("/api/healthz", async () => ({ ok: true, ts: Date.now() }));
|
|
|
|
// --- Feishu listener ---
|
|
const feishuListenerEnabled = booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true);
|
|
if (feishuListenerEnabled) {
|
|
const feishuConfig = { appId: feishuAppId, appSecret: feishuAppSecret, botOpenId: feishuBotOpenId };
|
|
const larkClient = createLarkClient(feishuConfig);
|
|
const triggerQueuePurgeTimer = setInterval(() => {
|
|
const removed = triggerQueue.purgeExpired();
|
|
if (removed > 0) {
|
|
app.log.info({ removed }, "feishu trigger queue: purged expired triggers");
|
|
}
|
|
}, 60_000);
|
|
triggerQueuePurgeTimer.unref();
|
|
const trigger = makeTriggerHandler({ prisma, settings: runtimeSettings, logger: app.log, projectWorkspaceRoot });
|
|
startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger, trigger.onCardAction);
|
|
} else {
|
|
app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED");
|
|
}
|
|
|
|
app.listen({ port, host: "0.0.0.0" }, (err, address) => {
|
|
if (err !== null) {
|
|
app.log.error({ err }, "listen failed");
|
|
process.exit(1);
|
|
}
|
|
app.log.info({ address }, "hub listening");
|
|
});
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error("[hub] fatal:", e);
|
|
process.exit(1);
|
|
});
|