/** * 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 provider-agnostic agent loop * (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, OPENROUTER_API_KEY, FEISHU_APP_ID/SECRET/BOT_OPEN_ID, PORT */ import "dotenv/config"; import Fastify from "fastify"; import { prisma } from "./db.js"; import { OpenRouterProvider } from "./agent/openrouter-provider.js"; import { InMemoryModelRegistry } from "./agent/models.js"; import { ToolRegistry, feishuContextTool } from "./agent/tools.js"; import { readFileTool, writeFileTool, listFilesTool } from "./agent/workspace.js"; import { cphCheckTool, cphBuildTool } from "./agent/cph.js"; import { createLarkClient, startFeishuListenerWithClient, type FeishuRuntime } from "./feishu/client.js"; import { readFeishuContext } from "./feishu/read.js"; import { makeTriggerHandler } from "./feishu/trigger.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; } async function main(): Promise { const databaseUrl = requireEnv("DATABASE_URL"); void databaseUrl; // prisma reads DATABASE_URL from env directly const openrouterKey = requireEnv("OPENROUTER_API_KEY"); const feishuAppId = requireEnv("FEISHU_APP_ID"); const feishuAppSecret = requireEnv("FEISHU_APP_SECRET"); const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID"); const port = Number(process.env["PORT"] ?? "8788"); const app = Fastify({ logger: true }); app.get("/api/healthz", async () => ({ ok: true, ts: Date.now() })); // --- Agent seam wiring --- const provider = new OpenRouterProvider({ apiKey: openrouterKey }); const models = new InMemoryModelRegistry( [ { id: "z-ai/glm-4.6", label: "GLM-4.6", toolCapable: true }, { id: "anthropic/claude-3.5-sonnet", label: "Claude 3.5 Sonnet", toolCapable: true }, ], [ { id: "draft", label: "草稿", defaultModel: "z-ai/glm-4.6" }, { id: "review", label: "审校", defaultModel: "anthropic/claude-3.5-sonnet" }, ], ); // Build the lark client first — tools that call Feishu APIs hold it from boot. const feishuConfig = { appId: feishuAppId, appSecret: feishuAppSecret, botOpenId: feishuBotOpenId }; const larkClient = createLarkClient(feishuConfig); const feishuRt: FeishuRuntime = { client: larkClient, logger: app.log }; const tools = new ToolRegistry(); // ADR-0003: the handler enforces chat==boundChat before any API call; // real read wraps @larksuiteoapi/node-sdk's im.v1.message get/list. tools.register(feishuContextTool(readFeishuContext, feishuRt)); // Workspace file tools (ADR-0007 directory tree, path-confined). tools.register(readFileTool()); tools.register(writeFileTool()); tools.register(listFilesTool()); // cph CLI tools (ADR-0016 version contract enforced by cph itself). tools.register(cphCheckTool()); tools.register(cphBuildTool()); // --- Feishu listener (reuses the same lark client) --- const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: app.log }); startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger); 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); });