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:
2026-07-06 23:11:35 +08:00
parent b4dd8f09e1
commit 313e890b6c
7 changed files with 772 additions and 42 deletions
+311 -4
View File
@@ -9,7 +9,9 @@
"version": "0.0.0",
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@larksuiteoapi/node-sdk": "^1.66.1",
"@prisma/client": "^6.19.3",
"dotenv": "^17.4.2",
"fastify": "^5.8.5",
"openai": "^4.77.0"
},
@@ -595,6 +597,21 @@
"ipaddr.js": "^2.1.0"
}
},
"node_modules/@larksuiteoapi/node-sdk": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@larksuiteoapi/node-sdk/-/node-sdk-1.68.0.tgz",
"integrity": "sha512-ip14+pWAv2La3bss4oDDtee2P5xBLDXuvmzhTvkxQMcPA5jCffFsler1TvNt4MyW6pexscj72AEWektyZwU9Yw==",
"license": "MIT",
"dependencies": {
"axios": "~1.13.3",
"lodash.identity": "^3.0.0",
"lodash.merge": "^4.6.2",
"lodash.pickby": "^4.6.0",
"protobufjs": "^7.2.6",
"qs": "^6.14.2",
"ws": "^8.19.0"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -686,6 +703,63 @@
"@prisma/debug": "6.19.3"
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1"
}
},
"node_modules/@protobufjs/float": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz",
"integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==",
"license": "BSD-3-Clause"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -810,6 +884,17 @@
"fastq": "^1.17.1"
}
},
"node_modules/axios": {
"version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/c12": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
@@ -839,6 +924,19 @@
}
}
},
"node_modules/c12/node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"devOptional": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -852,6 +950,22 @@
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@@ -963,10 +1077,9 @@
"license": "MIT"
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"devOptional": true,
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
@@ -1269,6 +1382,26 @@
"node": ">=20"
}
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
@@ -1524,6 +1657,30 @@
],
"license": "MIT"
},
"node_modules/lodash.identity": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/lodash.identity/-/lodash.identity-3.0.0.tgz",
"integrity": "sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"license": "MIT"
},
"node_modules/lodash.pickby": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.pickby/-/lodash.pickby-4.6.0.tgz",
"integrity": "sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==",
"license": "MIT"
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1632,6 +1789,18 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ohash": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
@@ -1798,6 +1967,35 @@
],
"license": "MIT"
},
"node_modules/protobufjs": {
"version": "7.6.5",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz",
"integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.5",
"@protobufjs/eventemitter": "^1.1.1",
"@protobufjs/fetch": "^1.1.1",
"@protobufjs/float": "^1.0.2",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.1",
"@types/node": ">=13.7.0",
"long": "^5.3.2"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/pure-rand": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
@@ -1815,6 +2013,22 @@
],
"license": "MIT"
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
@@ -1954,6 +2168,78 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
@@ -2078,6 +2364,27 @@
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+9 -5
View File
@@ -3,17 +3,21 @@
"version": "0.0.0",
"private": true,
"type": "module",
"engines": { "node": ">=20" },
"engines": {
"node": ">=20"
},
"dependencies": {
"openai": "^4.77.0",
"@fastify/cookie": "^11.0.2",
"@larksuiteoapi/node-sdk": "^1.66.1",
"@prisma/client": "^6.19.3",
"dotenv": "^17.4.2",
"fastify": "^5.8.5",
"@prisma/client": "^6.19.3"
"openai": "^4.77.0"
},
"devDependencies": {
"prisma": "^6.19.3",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"prisma": "^6.19.3"
"typescript": "^5.7.0"
},
"description": "Curriculum Project Hub — Feishu-group collaboration + provider-agnostic agent runtime. Aligns to spec/System (ADR-0001..0004, 0017).",
"scripts": {
+2
View File
@@ -39,6 +39,8 @@ export interface ModelRegistry {
listRoles(): readonly RoleEntry[];
/** The role preset with the given id, if any. */
role(id: string): RoleEntry | undefined;
/** Resolve a model id: explicit request → role default → first enabled. */
resolve(requestedModel: string | undefined, roleId: string): string;
}
/**
+102
View File
@@ -0,0 +1,102 @@
/**
* Feishu lark client + long-connection event dispatcher.
*
* Uses lark's WebSocket long-connection mode (no public callback endpoint). The
* `EventDispatcher` registers `im.message.receive_v1`; the SDK delivers the raw
* event to our handler. This version of the SDK does not auto-normalize on the
* ws path — `normalize()` is a separate helper — so we work with the raw event
* shape directly (it carries chat_id, message_type, content, mentions).
*
* ADR-0001: the bot operates inside a project's bound group. It does not own
* the lock (ADR-0002) and is not the session owner.
*/
import * as lark from "@larksuiteoapi/node-sdk";
import type { FastifyBaseLogger } from "fastify";
export interface FeishuConfig {
readonly appId: string;
readonly appSecret: string;
readonly botOpenId: string;
}
export interface FeishuRuntime {
readonly client: lark.Client;
readonly logger: FastifyBaseLogger;
}
/** Raw `im.message.receive_v1` event payload (subset we use). */
export interface MessageReceiveEvent {
readonly message: {
readonly message_id: string;
readonly chat_id: string;
readonly chat_type: string;
readonly message_type: string;
readonly content: string;
readonly mentions?: ReadonlyArray<{
readonly key: string;
readonly id: { readonly open_id?: string };
readonly name: string;
}>;
};
readonly sender: {
readonly sender_id: { readonly open_id?: string };
readonly sender_type: string;
};
}
/** Send a text message to a chat. */
export async function sendText(rt: FeishuRuntime, chatId: string, text: string): Promise<void> {
// SDK exposes im.v1 dynamically at runtime; the generated types are weak
// there, so cast through the known shape.
const client = rt.client as unknown as {
im: { v1: { message: { create: (p: unknown) => Promise<unknown> } } };
};
await client.im.v1.message.create({
params: { receive_id_type: "chat_id" },
data: {
receive_id: chatId,
msg_type: "text",
content: JSON.stringify({ text }),
},
});
}
/**
* Start the lark WebSocket client and route `im.message.receive_v1` events to
* `onMessage`. The ws connection lives in the background; this resolves the
* runtime handle.
*/
export function startFeishuListener(
config: FeishuConfig,
logger: FastifyBaseLogger,
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
): FeishuRuntime {
const client = new lark.Client({
appId: config.appId,
appSecret: config.appSecret,
appType: lark.AppType.SelfBuild,
});
const wsClient = new lark.WSClient({
appId: config.appId,
appSecret: config.appSecret,
domain: lark.Domain.Feishu,
loggerLevel: lark.LoggerLevel.info,
});
const rt: FeishuRuntime = { client, logger };
void wsClient.start({
eventDispatcher: new lark.EventDispatcher({}).register({
"im.message.receive_v1": async (data) => {
try {
await onMessage(data as MessageReceiveEvent, rt);
} catch (e) {
logger.error({ err: e }, "feishu message handler threw");
}
},
}),
});
return rt;
}
+211
View File
@@ -0,0 +1,211 @@
/**
* Feishu @bot trigger → AgentRun lifecycle.
*
* The core path (ADR-0001..0003, 0017):
* 1. A message mentioning the bot arrives in a project group.
* 2. Resolve the chat → project binding (ADR-0001). Unknown chat ⇒ ignored.
* 3. Create an AgentRun + AgentSession (provider-bound, ADR-0017).
* 4. Acquire the project lock for the run (ADR-0002). If locked, reply busy.
* 5. Run the agent loop; the agent reads Feishu context on demand through the
* ADR-0003-authorized tool (chat must match binding).
* 6. On finish/fail/timeout: release the lock, post a status card.
*
* The agent provider/model come from the ModelRegistry (ADR-0017 role-based
* routing, role-as-data). The trigger does not pick a model directly.
*/
import type { PrismaClient } from "@prisma/client";
import type { FastifyBaseLogger } from "fastify";
import { sendText, type FeishuRuntime, type MessageReceiveEvent } from "./client.js";
import type { AgentProvider } from "../agent/provider.js";
import type { ToolRegistry } from "../agent/tools.js";
import type { ModelRegistry } from "../agent/models.js";
import { runAgent, type ProjectContext } from "../agent/runner.js";
import { acquireLock, currentLockRunId, releaseLock } from "../lock.js";
interface TriggerDeps {
readonly prisma: PrismaClient;
readonly provider: AgentProvider;
readonly tools: ToolRegistry;
readonly models: ModelRegistry;
readonly logger: FastifyBaseLogger;
}
/**
* Build the message handler. Returns a function suitable for the lark
* `im.message.receive_v1` event. The handler is async and long-running (the
* agent run is the long leg); the lock guarantees one run per project at a time.
*/
export function makeTriggerHandler(deps: TriggerDeps) {
return async (event: MessageReceiveEvent, rt: FeishuRuntime): Promise<void> => {
const msg = event.message;
const sender = event.sender;
void sender; // sender→user mapping is OPEN (principal sub-typology)
// Only react to text messages in group chats.
if (msg.message_type !== "text") return;
const chatId = msg.chat_id;
if (chatId === "") return;
// ADR-0001: resolve chat → project. Unknown chat ⇒ not a project group.
const binding = await deps.prisma.projectGroupBinding.findUnique({
where: { chatId },
select: { projectId: true },
});
if (binding === null) {
deps.logger.debug({ chatId }, "feishu trigger: chat not bound to any project");
return;
}
const projectId = binding.projectId;
// ADR-0002: if locked by another run, reply busy.
const existing = await currentLockRunId(deps.prisma, projectId);
if (existing !== null) {
await sendText(rt, chatId, "项目正在处理中,请稍候。");
return;
}
const prompt = extractPrompt(msg);
if (prompt === null) return;
const project = await deps.prisma.project.findUnique({
where: { id: projectId },
select: { workspaceDir: true },
});
if (project === null) {
deps.logger.error({ projectId }, "feishu trigger: project missing");
return;
}
// ADR-0017: role-as-data; model resolved by registry. "draft" is the
// fallback role id; real wiring lets the trigger carry a role hint (slash
// command, card selector). The registry degrades unknown roles gracefully.
const model = deps.models.resolve(undefined, "draft");
const providerId = deps.provider.id;
// ADR-0017: provider+model-bound session. Reuse if one exists for this
// (project, provider, model) triple; otherwise create.
const existingSession = await deps.prisma.agentSession.findFirst({
where: { projectId, provider: providerId, model, archivedAt: null },
select: { id: true },
});
const session =
existingSession !== null
? await deps.prisma.agentSession.update({
where: { id: existingSession.id },
data: { provider: providerId, model, updatedAt: new Date() },
select: { id: true },
})
: await deps.prisma.agentSession.create({
data: {
projectId,
provider: providerId,
model,
title: prompt.slice(0, 40) || null,
metadata: {},
},
select: { id: true },
});
const run = await deps.prisma.agentRun.create({
data: {
projectId,
sessionId: session.id,
requestedByUserId: null, // sender→user mapping is OPEN (principal sub-typology)
entrypoint: "FEISHU",
status: "ACTIVE",
prompt,
model,
provider: providerId,
metadata: {},
},
select: { id: true },
});
// ADR-0002: acquire the project lock for this run.
try {
await acquireLock(deps.prisma, projectId, run.id, null);
} catch {
// Race: another run grabbed the lock between our check and create.
await deps.prisma.agentRun.update({
where: { id: run.id },
data: { status: "FAILED", error: "lock race", finishedAt: new Date() },
});
await sendText(rt, chatId, "项目正在处理中,请稍候。");
return;
}
await sendText(rt, chatId, `已开始处理(model: ${model})。`);
const projectCtx: ProjectContext = {
projectId,
boundChatId: chatId, // ADR-0003: Feishu reads stay in this chat
workspaceDir: project.workspaceDir,
};
// Run the agent — fire-and-forget from the ws handler's perspective.
runAgent(deps.provider, deps.tools, { prompt, model, project: projectCtx })
.then(async (result) => {
await deps.prisma.agentRun.update({
where: { id: run.id },
data: {
status:
result.status === "completed"
? "COMPLETED"
: result.status === "length"
? "TIMED_OUT"
: "FAILED",
inputTokens: result.usage.inputTokens,
outputTokens: result.usage.outputTokens,
error: result.error ?? null,
finishedAt: new Date(),
},
});
await sendText(rt, chatId, statusReply(result.status));
})
.catch(async (e) => {
await deps.prisma.agentRun.update({
where: { id: run.id },
data: { status: "FAILED", error: String(e), finishedAt: new Date() },
});
await sendText(rt, chatId, "处理出错,已释放项目锁。");
})
.finally(async () => {
await releaseLock(deps.prisma, run.id);
});
};
}
function statusReply(status: "completed" | "length" | "failed"): string {
switch (status) {
case "completed":
return "处理完成。";
case "length":
return "处理达到步数上限,已停止。";
case "failed":
return "处理出错。";
}
}
/**
* Extract the prompt text from a text message, stripping @mentions.
* Returns null if the message is empty after stripping or doesn't mention the bot.
* Lark text content is JSON: `{"text":"@_user_1 do something"}`.
*/
function extractPrompt(msg: MessageReceiveEvent["message"]): string | null {
// Check for bot mention — mentions[].id.open_id should include the bot.
// The dispatcher already routes only message events; we further require
// that the bot is among the mentions (if no mentions, it's not @bot).
const mentions = msg.mentions;
if (mentions === undefined || mentions.length === 0) return null;
try {
const parsed = JSON.parse(msg.content) as { text?: string };
const text = parsed.text;
if (typeof text !== "string") return null;
// Strip @mentions (@_user_1 etc.) and trim; remainder is the prompt.
const stripped = text.replace(/@_\w+\s*/g, "").trim();
return stripped === "" ? null : stripped;
} catch {
return null;
}
}
+79
View File
@@ -0,0 +1,79 @@
/**
* ProjectAgentLock — ADR-0002 invariant enforcement at the DB layer.
*
* `projectId @id` gives at-most-one lock per project; `runId @unique` gives a
* run holds at most one lock. Those are DB-level. The spec's `WellFormed`
* invariant ("holder is a non-terminal run") is app-level: it must hold on
* every read of the lock table. `assertLockHolderActive` enforces it on the
* read path — if a lock exists but its run is terminal, that's a bug (the run
* should have released it), surfaced as an error rather than silently trusted.
*/
import type { PrismaClient } from "@prisma/client";
import type { AgentRunStatus } from "@prisma/client";
/// spec RunState.Terminal: completed/failed/timedOut/canceled.
/// active/waitingForUser are NOT terminal — the lock must still be held.
const TERMINAL_STATUSES: ReadonlySet<AgentRunStatus> = new Set([
"COMPLETED",
"FAILED",
"TIMED_OUT",
"CANCELED",
]);
export function isTerminal(status: AgentRunStatus): boolean {
return TERMINAL_STATUSES.has(status);
}
/**
* Acquire the project lock for a run. Throws if the project is already locked
* by another run (ADR-0002 exclusivity). The unique runId constraint means a
* run that already holds a lock elsewhere will also fail — that's correct, a
* run scopes to one project.
*/
export async function acquireLock(
prisma: PrismaClient,
projectId: string,
runId: string,
holderUserId: string | null,
): Promise<void> {
await prisma.projectAgentLock.create({
data: { projectId, runId, holderUserId },
});
}
/**
* Release the lock owned by a run. Idempotent — a no-op if no lock exists.
* Called on run completion/failure/timeout/cancellation (ADR-0002).
*/
export async function releaseLock(prisma: PrismaClient, runId: string): Promise<void> {
await prisma.projectAgentLock.deleteMany({ where: { runId } });
}
/**
* The spec's `LockTable.WellFormed` on a single read: if `projectId` has a
* lock owned by `runId`, that run must be non-terminal. Throws if a terminal
* run still holds a lock — that's a release-path bug, not a recoverable state.
*
* Returns the lock's runId if a healthy lock exists, or null if unlocked.
*/
export async function currentLockRunId(
prisma: PrismaClient,
projectId: string,
): Promise<string | null> {
const lock = await prisma.projectAgentLock.findUnique({
where: { projectId },
select: { runId: true },
});
if (lock === null) return null;
const run = await prisma.agentRun.findUnique({
where: { id: lock.runId },
select: { status: true },
});
if (run !== null && isTerminal(run.status)) {
throw new Error(
`lock invariant violated: project ${projectId} locked by terminal run ${lock.runId} (status ${run.status}) — release path bug (ADR-0002 WellFormed)`,
);
}
return lock.runId;
}
+58 -33
View File
@@ -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);
});