forked from bai/curriculum-project-hub
feat(hub): 飞书 @bot 触发链路 + ProjectAgentLock 不变式(ADR-0001/0002)
群消息事件 → 创建 AgentRun → 获锁 → 调 runAgent → 释放锁 + 状态回写: - feishu/client.ts: lark WSClient 长连接接收 im.message.receive_v1; sendText 经 client.im.v1 发送(SDK 运行时动态 API,类型弱化务实收口) - feishu/trigger.ts: @bot 识别(chat 绑定解析 ADR-0001)→ 创建 run+ session(provider-bound ADR-0017)→ acquireLock(ADR-0002,竞态回退 busy)→ runAgent(fire-and-forget)→ finally releaseLock;非项目群/无 @bot/非文本 全部忽略 - lock.ts: ADR-0002 WellFormed 落代码——currentLockRunId 读锁时校验 持锁 run 非终止,终止 run 持锁即 release-path bug(抛而非静默) - models.ts: resolve 提到 ModelRegistry 接口(之前漏在实现上) - server.ts: Fastify + lark ws 启动,/api/healthz prompt 提取 smoke 通过(@bot+prompt→prompt;纯@bot→null;无mention→null; 非文本→null)。修了 mention 正则 bug(/@_\w+_/ 的回溯错误)。tsc rc=0。
This commit is contained in:
+58
-33
@@ -1,31 +1,54 @@
|
||||
/**
|
||||
* Hub entry — skeleton.
|
||||
* Hub entry — Fastify server + Feishu WebSocket listener.
|
||||
*
|
||||
* Wires the provider-agnostic agent seam end-to-end with a no-op Feishu read, so
|
||||
* the invariant path is exercised without live credentials. Real wiring (Fastify
|
||||
* server, Feishu OAuth/event callbacks, Prisma, admin-web) lands incrementally;
|
||||
* this file proves the seam compiles and the pieces connect.
|
||||
* 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 { runAgent } from "./agent/runner.js";
|
||||
import { startFeishuListener } from "./feishu/client.js";
|
||||
import { makeTriggerHandler } from "./feishu/trigger.js";
|
||||
|
||||
function main(): void {
|
||||
const apiKey = process.env["OPENROUTER_API_KEY"];
|
||||
if (apiKey === undefined || apiKey === "") {
|
||||
console.error("[hub] OPENROUTER_API_KEY not set; nothing to run. Exiting.");
|
||||
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;
|
||||
}
|
||||
|
||||
const provider = new OpenRouterProvider({ apiKey });
|
||||
async function main(): Promise<void> {
|
||||
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 },
|
||||
],
|
||||
[
|
||||
// Roles are data here — admin/teachers add presets, not code changes.
|
||||
{ id: "draft", label: "草稿", defaultModel: "z-ai/glm-4.6" },
|
||||
{ id: "review", label: "审校", defaultModel: "anthropic/claude-3.5-sonnet" },
|
||||
],
|
||||
@@ -33,30 +56,32 @@ function main(): void {
|
||||
|
||||
const tools = new ToolRegistry();
|
||||
tools.register(
|
||||
feishuContextTool(async (args) => {
|
||||
// Skeleton: no live Feishu calls. Real impl wraps @larksuiteoapi/node-sdk.
|
||||
return JSON.stringify({ stub: true, anchor: args.anchor, id: args.id });
|
||||
// ADR-0003: the handler enforces chat==boundChat before any Feishu API call.
|
||||
// Real impl wraps @larksuiteoapi/node-sdk's im.message.list/read APIs.
|
||||
feishuContextTool(async (args, ctx) => {
|
||||
return JSON.stringify({ stub: true, anchor: args.anchor, id: args.id, projectId: ctx.projectId });
|
||||
}),
|
||||
);
|
||||
|
||||
const model = models.resolve(undefined, "draft");
|
||||
runAgent(provider, tools, {
|
||||
prompt: "ping",
|
||||
model,
|
||||
project: {
|
||||
projectId: "proj-skeleton",
|
||||
boundChatId: "chat-skeleton",
|
||||
workspaceDir: "/tmp/cph-skeleton",
|
||||
},
|
||||
})
|
||||
.then((r) => {
|
||||
console.log("[hub] run finished", { status: r.status, model, usage: r.usage });
|
||||
if (r.error !== undefined) console.error("[hub] error:", r.error);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("[hub] run threw:", e);
|
||||
// --- Feishu listener ---
|
||||
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: app.log });
|
||||
const feishuCtx = startFeishuListener(
|
||||
{ appId: feishuAppId, appSecret: feishuAppSecret, botOpenId: feishuBotOpenId },
|
||||
app.log,
|
||||
trigger,
|
||||
);
|
||||
void feishuCtx;
|
||||
|
||||
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();
|
||||
main().catch((e) => {
|
||||
console.error("[hub] fatal:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user