From 313e890b6ca3966f701b5a7e4f1115afe37520ce Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Mon, 6 Jul 2026 23:11:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(hub):=20=E9=A3=9E=E4=B9=A6=20@bot=20?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=E9=93=BE=E8=B7=AF=20+=20ProjectAgentLock=20?= =?UTF-8?q?=E4=B8=8D=E5=8F=98=E5=BC=8F(ADR-0001/0002)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 群消息事件 → 创建 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。 --- hub/package-lock.json | 315 +++++++++++++++++++++++++++++++++++++- hub/package.json | 14 +- hub/src/agent/models.ts | 2 + hub/src/feishu/client.ts | 102 ++++++++++++ hub/src/feishu/trigger.ts | 211 +++++++++++++++++++++++++ hub/src/lock.ts | 79 ++++++++++ hub/src/server.ts | 91 +++++++---- 7 files changed, 772 insertions(+), 42 deletions(-) create mode 100644 hub/src/feishu/client.ts create mode 100644 hub/src/feishu/trigger.ts create mode 100644 hub/src/lock.ts diff --git a/hub/package-lock.json b/hub/package-lock.json index 2759b49..e6fe304 100644 --- a/hub/package-lock.json +++ b/hub/package-lock.json @@ -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 + } + } } } } diff --git a/hub/package.json b/hub/package.json index 6495468..c13073f 100644 --- a/hub/package.json +++ b/hub/package.json @@ -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": { diff --git a/hub/src/agent/models.ts b/hub/src/agent/models.ts index 446e35b..8778fbc 100644 --- a/hub/src/agent/models.ts +++ b/hub/src/agent/models.ts @@ -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; } /** diff --git a/hub/src/feishu/client.ts b/hub/src/feishu/client.ts new file mode 100644 index 0000000..a05344e --- /dev/null +++ b/hub/src/feishu/client.ts @@ -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 { + // 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 } } }; + }; + 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, +): 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; +} diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts new file mode 100644 index 0000000..055b8e8 --- /dev/null +++ b/hub/src/feishu/trigger.ts @@ -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 => { + 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; + } +} diff --git a/hub/src/lock.ts b/hub/src/lock.ts new file mode 100644 index 0000000..167bde8 --- /dev/null +++ b/hub/src/lock.ts @@ -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 = 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 { + 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 { + 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 { + 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; +} diff --git a/hub/src/server.ts b/hub/src/server.ts index bf73431..2c49972 100644 --- a/hub/src/server.ts +++ b/hub/src/server.ts @@ -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 { + 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); +});