Compare commits

...

45 Commits

Author SHA1 Message Date
hongjr03 2b7aa3294f feat: add organization tenant model 2026-07-09 23:37:20 +08:00
hongjr03 5d315fff21 fix: enforce role tool whitelist 2026-07-09 20:48:03 +08:00
hongjr03 716101eed0 feat: add agent cost reporting 2026-07-09 20:29:56 +08:00
hongjr03 fc5a5365d8 fix: reply to Feishu trigger messages 2026-07-09 20:04:13 +08:00
hongjr03 fc0df7b823 fix: label Feishu trigger senders in agent prompts 2026-07-09 19:56:47 +08:00
hongjr03 f1d29176d3 feat: add slash command help 2026-07-09 19:43:13 +08:00
hongjr03 f260195439 fix: ack Feishu card actions before interrupt work 2026-07-09 19:26:57 +08:00
hongjr03 d01ea0e1cb feat: interrupt running agent via card button with confirm dialog
Add a  中断 button to live streaming cards; Feishu's native confirm
dialog is the confirmation step, so no extra card round-trip is needed.

- builder.ts: render interrupt action (danger button + confirm) while the
  run is live; new `interrupted` flag renders a 已中断 footer instead of
  完成/失败 on the complete card.
- streaming-card.ts: finish(text, { interrupted }) threads the flag into
  the final patch; overflow cards suppress the button.
- runner.ts: RunRequest gains abortController, passed to the SDK query;
  abort surfaces as RunStatus "interrupted" (no error) in the catch.
- trigger.ts: activeRuns registry maps runId → AbortController; onCardAction
  resolves the interrupt button, authorizes agent.cancel, aborts the run.
  Interrupted runs persist as CANCELED (spec RunState.canceled, terminal →
  lock released per ADR-0002).
- authorizer.ts: agent.cancel now consults PermissionSettings.agentCancel
  (MANAGE_ONLY/DISABLED) — previously only agentTrigger was honored, but
  spec/PermissionGrant deliberately splits these knobs ("谁能触发" ≠
  "谁能取消"). Default role remains MANAGE (Capability.normalCancel).

Tests: runner abort unit test, trigger interrupt + denied integration
tests, three agent.cancel authorization tests (manage allowed, edit denied,
DISABLED policy denies even manage). 26 files / 175 tests pass.
2026-07-09 18:59:31 +08:00
hongjr03 d6724e5a83 feat: streaming agent card with tool-use trace, reasoning panel, and tool results
Replace text-only PatchableTextStream with a unified StreamingAgentCard that
renders the full agent run lifecycle in a single Feishu interactive card:

- Tool-use panel: collapsible, shows each tool call with status (running/
  success/error), input summary, and result/error in code blocks
- Reasoning panel: collapsible, streams thinking text inline then collapses
  on completion
- Answer text: rendered via native Feishu markdown component (no post-row
  round-trip)

Runner now captures previously-discarded SDK events:
- thinking_delta → reasoning stream
- tool_use id + input on content_block_start / assistant message
- tool_result from user messages (was entirely unhandled)

New files:
- card/trace-store.ts: per-run in-memory tool-use trace with secret redaction
- card/builder.ts: Feishu card JSON builder (tool panel + reasoning + text)
- card/streaming-card.ts: StreamingAgentCard controller (400ms throttled)

client.ts: add sendCard/patchCard raw JSON primitives
2026-07-09 17:18:40 +08:00
hongjr03 346aee5a68 fix: bind agent sessions to role 2026-07-09 16:06:06 +08:00
hongjr03 491fd13ca8 fix: resolve Feishu sender names via basic profile API 2026-07-08 23:59:17 +08:00
hongjr03 79505e6d44 fix: use startedAt instead of createdAt in trigger integration test
AgentRun model has startedAt, not createdAt. Codex introduced this
typo when refactoring the trigger queue integration tests.
2026-07-08 23:30:28 +08:00
hongjr03 a0df8b6bcf fix: repair streaming output broken by markdown rendering changes
Two bugs were introduced when sendTextMessage was changed to auto-detect
message format (text/post/interactive):

1. Message type mismatch: sendTextMessage could create a text or post
   message, but patchTextMessage only works on interactive cards.
   Fix: add sendInteractiveCardMessage for streaming create (always
   interactive, always patchable).

2. flushTextToSink state corruption: the text management after flush
   had a flawed appendedDuringFlush heuristic that could lose text or
   duplicate it. Fix: properly track sent vs unsent text by removing
   the sent prefix length, keeping only the last chunk for future patches.

Also fix finish() to flush pending text regardless of currentMessageId.
2026-07-08 17:48:55 +08:00
hongjr03 36c0801e6f feat: add per-project message trigger wait queue
- TriggerQueue: FIFO per-project queue, max 5 items, 5min TTL expiry
- Replace reject-when-locked with queue: users get position feedback
- Run completion auto-starts next queued trigger (skips expired items)
- /reset clears project queue; 60s periodic purge interval
- Add unit tests (10) for queue lifecycle and edge cases
2026-07-08 17:31:43 +08:00
hongjr03 4479f88f4f feat: add sender profile cache with LRU eviction
- SenderNameCache: TTL 10min, LRU max 2048 entries, getOrFetch pattern
- resolveSenderName: API call with cache integration
- Trigger uses cache for approval card resolutions and audit metadata
- Add unit tests (9) for cache lifecycle, LRU, expiry, and null handling
2026-07-08 17:22:23 +08:00
hongjr03 2736006262 feat: add retry with exponential backoff for file send/download
- withRetry helper: configurable maxAttempts, baseDelayMs, shouldRetry
- sendFile: upload and message send wrapped with 3 retries, 1s/2s/4s backoff
- downloadMessageFile: wrapped with 3 retries
- Add unit tests (6) for retry behavior and sendFile retry integration
2026-07-08 17:15:30 +08:00
hongjr03 294d51dbfe feat: parse inbound post (rich text) messages
- parsePostMessage: full Feishu post payload parser with locale fallback,
  supports text/a/at/img/media/file/code_block/br/hr/emotion elements
- Trigger accepts post messages, extracts text + downloads attachments
- Post messages with attachments bypass batching, text-only posts batch
- Add unit tests (10) for all element types and edge cases
2026-07-08 17:05:51 +08:00
hongjr03 bef10149a9 feat: improve outbound message chunking with code-block-aware splitting
- Increase max message length from 4000 to 8000 chars
- Add splitAtBoundary: paragraph > newline > space priority, never split
  inside fenced code blocks (move split to before opening fence)
- Add sendLongText: sends multi-chunk long messages sequentially
- Update PatchableTextStream.flush to use splitAtBoundary
- Add unit tests (6) for splitting edge cases
2026-07-08 16:57:53 +08:00
hongjr03 4736610cf3 feat: add interactive approval/confirmation cards
- sendApprovalCard: interactive card with configurable buttons
- resolveCard: patch card to show resolution state (green/red)
- ApprovalManager: register/resolve lifecycle with 5min timeout
- request_approval MCP tool: agent can ask user for confirmation
- Wire onCardAction in trigger handler and server startup
- Add unit tests (5) for card JSON, manager, and routing
2026-07-08 16:47:31 +08:00
hongjr03 ad65d93b2e feat: add message burst debouncing (MessageBatcher)
- Create MessageBatcher: debounce 600ms, adaptive delay for long chunks,
  max 8 messages/4000 chars before immediate flush, per (chatId, sender) key
- Integrate into trigger: text messages enqueued, slash commands and
  file/image bypass batching, locked projects respond immediately
- Add unit tests (8) for batcher lifecycle and edge cases
2026-07-08 16:40:39 +08:00
hongjr03 6227e1931b feat: improve Feishu markdown rendering and processing status feedback
- Add buildOutboundPayload: detect markdown format, isolate code blocks,
  force plain text for tables, fallback from post to text on API rejection
- Add addReaction/removeReaction for processing status lifecycle
- Replace THUMBSUP with Typing→(remove on success | CrossMark on failure)
- Add unit tests for markdown rendering (15) and reactions (6)
2026-07-08 16:31:07 +08:00
hongjr03 a00e4f9871 fix(hub): 保留飞书回复上下文 2026-07-08 16:03:52 +08:00
hongjr03 a025d23af8 fix(hub): 启动前应用 Prisma 迁移 2026-07-08 15:45:12 +08:00
hongjr03 dfcc2d70f8 feat(hub): 支持教师团队权限 2026-07-08 15:42:53 +08:00
hongjr03 3f486232a8 feat(hub): 抽出运行时配置 2026-07-08 15:25:49 +08:00
hongjr03 a766d99573 fix(hub): 修复 Hub 部署脚本失败处理 2026-07-08 15:14:23 +08:00
hongjr03 1cfda3ccd2 ci(hub): 补齐 Hub 检查和健康 smoke 2026-07-08 15:14:06 +08:00
hongjr03 ead5cf04d6 fix(hub): 稳定 Feishu trigger 集成测试 2026-07-08 15:13:50 +08:00
hongjr03 ecbc2c8666 fix(hub): 对齐 ADR-0018 agent 执行面边界
runner.ts: 启用 SDK 内置沙箱(bubblewrap/seatbelt),写入限制在 workspace,
denyRead 敏感路径,failIfUnavailable 硬拒非沙箱运行。bypassPermissions 保留
(headless 无交互),沙箱是硬边界而非权限提示层。

install_service.sh: 默认 service user 从 root 改为 cph-hub;env 模板修正
OPENROUTER_API_KEY → ANTHROPIC_AUTH_TOKEN/ANTHROPIC_BASE_URL/ANTHROPIC_API_KEY
(与 server.ts 实际读取一致);补 __BASE_DIR__ sed 替换。

cph-hub.service: AssertPathExists=/usr/bin/bwrap 强制沙箱依赖;NoNewPrivileges。
不加 ProtectSystem/ProtectHome(会破坏 cph render-cache 与 bwrap user-namespace)。

trigger.ts: 权限闸门去掉 if (senderOpenId !== '') 短路——缺 open_id 一律
fail-closed 拒绝,不再静默跳过;role gate 同步去掉冗余守卫(已由上游保证)。
顺手把 JSON.parse(msg.content) 的 inline cast 换成 Zod schema parse
(FileMessageContentSchema / TextMessageContentSchema)。

ADR-0018: 状态改为 Accepted+implemented,机制段写定 SDK 沙箱,网络决定为开放,
Open Questions 更新。
2026-07-08 13:11:14 +08:00
hongjr03 5b7fd70124 spec: 钉死 agent 执行面边界(ADR-0018)
ADR-0001/0002/0004 覆盖协作治理到 triggerAgent,但触发后 agent 在执行层
能干什么——读哪些文件、跑什么命令——无 ADR / spec 覆盖。ADR-0017 落地时
采用 bypassPermissions + 全量内置工具,agent 文件/shell 面对宿主无界;
workspace.ts 的 confine() 沙箱意图存在但被 ADR-0017 静默覆盖成死代码。

新增 ADR-0018 + Spec.System.AgentSurface 模块补这一层:
- AgentFileOp(run × path)+ Authorized 谓词(路径必须落在 run 工作区内)
- 与 Lock 正交:Lock 限定并发,Surface 限定波及面,都按 run × project 作用域
- 机制 OPEN(工具包装 / OS 沙箱 / SDK 钩子),契约只钉不变式
- ADR-0017 的 bypassPermissions + workspace.ts 死代码标为偏离契约,对齐为 follow-up
2026-07-08 12:37:37 +08:00
hongjr03 6178664696 fix(hub): send Feishu files explicitly 2026-07-08 11:48:25 +08:00
hongjr03 c73b41e1da fix(hub): resume Claude SDK sessions 2026-07-08 11:48:20 +08:00
hongjr03 012c8a7d89 fix(hub): default to OpenRouter Sonnet model 2026-07-08 11:48:16 +08:00
hongjr03 250f918346 chore: delete dead code 2026-07-08 02:37:44 +08:00
hongjr03 30d20421b3 fix(hub): busy 表情从 Hourglass 改为 OnIt(飞书支持的 emoji_type) 2026-07-08 02:22:05 +08:00
hongjr03 20bf7e271f fix(hub): 启动重置状态 + lazy-init 修复空白消息 + busy 改表情
三个修复:
1. 启动时清锁+标死run为FAILED(防重启后卡死)
2. 空白消息修复:lazy-init 改为第一个 text-delta 立即创建消息
  (不走 throttle);.then 加 fallback:没收到 text-delta 时用
  result.text 发完整回复
3. busy 提示从文本'项目正在处理中'改为表情回复

tsc rc=0。
2026-07-08 02:20:49 +08:00
hongjr03 eaa7fd4fd8 feat(hub): 文件收发 + 卡片回调 + 更好的流式
client.ts 重写:
- downloadMessageFile: 下载飞书消息里的文件/图片到本地
- sendFile: 上传文件到飞书 + 发送文件消息(msg_type=file)
- CardActionEvent 类型 + startFeishuListenerWithClient 加 onCardAction
  回调参数,注册 card.action.trigger 事件(消除了 'no card.action.trigger
  handle' 警告)
- 去掉未使用的 card builder 函数

trigger.ts:
- 处理 file/image 消息:下载到 workspace/.cph/inbox/,prompt 里告诉
  agent 文件路径
- 流式 throttle 从 800ms 降到 400ms,初始占位从 '…' 改为空(更干净)
- run 完成后扫描 workspace/build/ 下 5 分钟内的新文件,自动发送给
  老师(cph build 产出的 PDF 自动回传)

tsc rc=0。
2026-07-08 02:13:51 +08:00
hongjr03 1ad78dbcce test(hub): 适配 Claude Code SDK 的 mock 修复
helpers.ts: MockLanguageModel 加 doStream 实现(返回 text-delta +
finish part);runner.test.ts: 加 stubPrisma(必填字段)。
这些测试在 Claude Code SDK 迁移后还需要进一步适配,先提交 buffer。
2026-07-08 01:52:21 +08:00
hongjr03 085f7c7186 fix(hub): 去掉冗余 OPENROUTER_API_KEY,直接用 ANTHROPIC_AUTH_TOKEN 2026-07-08 01:48:01 +08:00
hongjr03 b9f50407be fix(hub): Claude Code SDK 经 OpenRouter Anthropic Skin,保留 provider-agnostic
我之前判断错了——OpenRouter 有 Anthropic Messages API 兼容端点
(https://openrouter.ai/api),Claude Code SDK 直连不需要代理。
ANTHROPIC_AUTH_TOKEN=OpenRouter key,ANTHROPIC_API_KEY='' 即可。

而且 ANTHROPIC_DEFAULT_SONNET_MODEL 可设 OpenRouter model ID
(z-ai/glm-4.7 等),所以 provider-agnostic 没丢。

ADR-0017 重写:从'Anthropic 专有'改为'Claude Code SDK via OpenRouter,
保留 provider-agnostic'。tsc rc=0。
2026-07-08 01:46:09 +08:00
hongjr03 082562ca08 docs(adr): ADR-0017 重写——从 provider-agnostic 改为 Claude Code SDK
放弃 provider-agnostic,用 @anthropic-ai/claude-agent-sdk。代价:不能走
OpenRouter(GLM 等),需 Anthropic API key。收益:compaction、工具审批、
partial message 流式、Claude-to-IM 原生兼容。迁移路径:如果将来需要
provider-agnostic,用 streamText + SSE 转换层(~100 行)替回 query()。
2026-07-08 01:40:28 +08:00
hongjr03 4bd5b03bb1 refactor(hub): Vercel AI SDK → Claude Code SDK
用 @anthropic-ai/claude-agent-sdk 的 query() 替换 Vercel AI SDK 的
streamText。SDK 自带 Read/Write/Bash/Glob/Grep 工具——read_file/
write_file/list_files/cph_check/cph_build 全部不需要了,Bash 跑
cph 直接。

UX 同时改成 workbuddy 风格:
- 收到 @bot → 表情回复(👍)确认收到,不发'已开始处理'
- agent 回复作为文本流式发送(text-as-card patch,markdown 渲染)
- 超长自动续发新消息,不截断
- 完成/出错不再额外发状态消息
- 去掉卡片(model选择器/取消按钮)

ADR-0017 更新:放弃 provider-agnostic,Claude Code SDK 是 Anthropic
专有。换来:compaction、工具审批、partial message 流式、成熟 agent
loop。

tsc rc=0。旧文件(workspace.ts/cph.ts/provider.ts/tools.ts)暂留,
下个 commit 清理。
2026-07-08 01:36:59 +08:00
hongjr03 1abcc495f4 test(hub): 真实 model 集成测试 + Gitea CI workflow
real-model.test.ts: 用真 OpenRouter model + 真 cph + 真 workspace 跑 agent
  tool-calls 循环,验证 MockProvider 测不到的路径(model 返回 tool_calls →
  dispatch → tool_result 回传 → 继续/stop)。没 OPENROUTER_API_KEY 时自动
  skip,不阻塞 CI;有 key 时自动跑。
  两个 test:(1) list files → cph check → 回答;(2) write file → read back。
hub-check.yml: Gitea CI workflow,对齐 checker-check/spec-check 的纪律:
  npm ci → prisma generate → tsc → prisma validate → 装 cph → vitest unit →
  vitest integration(postgres service container)→ real-model(可选,从
  secrets 注入 key)。
全 suite: 60 passed + 2 skipped(无 key)。tsc rc=0。
2026-07-07 23:45:59 +08:00
hongjr03 e7924f78d5 feat(hub): 结构化运行历史(A-3) + lock 心跳(A-5)
A-3 结构化历史:
- 新增 AgentMessage 表(每条消息的 queryable 投影,transcript 文件仍是 agent 读回记忆)
- 新增 AgentFileChange 表(run 期间文件变更 before/after hash + operation)
- runner.ts: generateText 后 persistAgentMessages 落库 result.responseMessages
- workspace.ts writeFileTool: 写前算 beforeHash(SHA-256),写后算 afterHash,通过 ctx.onFileChange sink 记录
- runner.ts flushFileChanges: 收集 sink 的变更批量写入 AgentFileChange
- ToolContext 加 onFileChange sink 字段
- trigger.ts 接线 runId/sessionId/prisma 到 RunRequest

A-5 lock 心跳:
- ProjectAgentLock 加 heartbeatAt 列
- runner.ts onStepFinish 回调:每步 model step 后更新 heartbeatAt(吞错,lock 可能被 force-release)

60 tests 全过(tsc 干净)。
2026-07-07 22:20:30 +08:00
hongjr03 f8c3e16a52 feat(hub): 事件去重(A-4) + 审计写入路径(A-8)
A-4 FeishuEventReceipt:
- 新增 FeishuEventReceipt 表(eventId @unique),lark ws 至少一次投递的幂等兜底
- MessageReceiveEvent 加 header.event_id 字段
- trigger 入口查重:已存在的 event_id → 跳过(不建第二次 run)
- resetDb 加入 FeishuEventReceipt

A-8 审计写入:
- AuditEntry 加 projectId(权限拒绝/slash 命令等无 run 的审计点可定位)
- 新建 src/audit.ts:writeAudit(prisma, entry) best-effort 写入(吞错,不阻断 trigger)
- trigger 五个审计点:trigger.denied / trigger.role_denied / run.created / run.lock_race / run.finished / run.failed
- 新增 audit unit 测试(3) + trigger 集成测试(去重 + 审计,2)

59 tests 全过。
2026-07-07 21:34:15 +08:00
81 changed files with 11739 additions and 830 deletions
+132
View File
@@ -0,0 +1,132 @@
name: hub check
# Builds, type-checks, and tests the Hub TS package under hub/.
# The Hub is the Feishu-group collaboration + agent runtime half
# (spec/System implementation). This is an INTERNAL gate on the Hub's own
# health, like checker-check is for the Rust half.
on:
push:
pull_request:
workflow_dispatch:
jobs:
hub-check:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: paradigm
POSTGRES_PASSWORD: paradigm
POSTGRES_DB: cph_hub_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U paradigm -d cph_hub_test"
--health-interval 5s
--health-timeout 5s
--health-retries 20
defaults:
run:
working-directory: hub
steps:
- uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: hub/package-lock.json
- name: Install dependencies
run: npm ci
- name: Wait for Postgres
run: |
node <<'NODE'
const net = require("node:net");
const deadline = Date.now() + 60000;
function tryConnect() {
const socket = net.createConnection({ host: "127.0.0.1", port: 5432 });
socket.once("connect", () => {
socket.end();
process.exit(0);
});
socket.once("error", () => {
socket.destroy();
if (Date.now() > deadline) {
console.error("Postgres did not become reachable at 127.0.0.1:5432");
process.exit(1);
}
setTimeout(tryConnect, 1000);
});
}
tryConnect();
NODE
# Prisma client must be generated before tsc can check imports from
# @prisma/client. Stub DATABASE_URL so prisma generate works.
- name: Generate Prisma client
run: DATABASE_URL="postgresql://stub:stub@127.0.0.1:5432/stub" npx prisma generate --schema prisma/schema.prisma
- name: Type-check
run: npx tsc -p tsconfig.json --noEmit
- name: Build
run: npm run build
- name: Validate Prisma schema
run: DATABASE_URL="postgresql://stub:stub@127.0.0.1:5432/stub" npx prisma validate --schema prisma/schema.prisma
- name: Install cph binary
run: |
cd ..
cargo install --path crates/cph-cli --locked
- name: Run unit tests
run: npx vitest run test/unit
# Integration tests need PostgreSQL + cph. cph is installed above.
# PostgreSQL is set up as a service container below.
- name: Run integration tests (mock provider, real prisma + cph)
run: |
npx prisma migrate deploy --schema prisma/schema.prisma
npx vitest run test/integration --exclude test/integration/real-model.test.ts
env:
DATABASE_URL: postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test
- name: Smoke health endpoint
run: |
PORT=8878 \
NODE_ENV=production \
DATABASE_URL=postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test \
ANTHROPIC_AUTH_TOKEN=smoke \
FEISHU_APP_ID=cli_smoke \
FEISHU_APP_SECRET=smoke_secret \
FEISHU_BOT_OPEN_ID=ou_smoke \
HUB_FEISHU_LISTENER_ENABLED=false \
npm start &
pid=$!
trap 'kill "$pid" 2>/dev/null || true' EXIT
for attempt in $(seq 1 30); do
if curl --fail --silent "http://127.0.0.1:8878/api/healthz" >/dev/null; then
exit 0
fi
if ! kill -0 "$pid" 2>/dev/null; then
echo "hub server exited before health check passed"
wait "$pid"
exit 1
fi
sleep 1
done
curl --fail --silent --show-error "http://127.0.0.1:8878/api/healthz" >/dev/null
# Real-model tests are opt-in: set RUN_REAL_MODEL_TESTS=true and provide
# OPENROUTER_API_KEY when a branch should hit live OpenRouter.
- name: Run real-model integration tests (optional, needs secret)
run: npx vitest run test/integration/real-model.test.ts
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
RUN_REAL_MODEL_TESTS: ${{ vars.RUN_REAL_MODEL_TESTS }}
+22
View File
@@ -0,0 +1,22 @@
# AGENTS.md —— agent 操作手册(全 repo)
本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
## 这个 repo 是什么
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照。
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
## 纪律
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。契约里 prose doc 注释是语义的唯一权威来源;契约没写的,就是没定的。
2. **凡契约未写明者,不得假设。** 遇到标了 `OPEN` 的地方,或契约根本没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
3. **改 `spec/` 必须保持其 `lake build` 通过。**`spec/` 目录下跑 `lake build`。新增声明必须带 `/-- … -/` doc 注释和恰当标签(`PINNED` / `OPEN` / `ADR-NNNN`)。规范见 `spec/README.md`。不准用 `sorry` 把 build 糊绿。
4. **实现向契约对齐;偏离必须 surface。** 没有 CI gate 替你把关 spec↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与契约不一致时,报告它,不要默默让其中一边将就另一边。
5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。
+2
View File
@@ -6,6 +6,8 @@
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照。
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
## 纪律
@@ -1,4 +1,4 @@
# ADR 0017: AgentSession Is Provider-Bound
# ADR 0017: Agent Runtime — Claude Code SDK via OpenRouter
## Status
@@ -6,42 +6,62 @@ Accepted.
## Context
ADR-0002 says a long-lived `AgentSession` can be reused by many runs, but never
addressed the provider/model dimension. The agent layer is provider-agnostic
(ADR-0001..0003 never required Claude specifically; the `@Claude` trigger name
is a product brand, not a provider commitment). In practice the Hub routes
model calls through an OpenAI-compatible client (e.g. OpenRouter), so a run may
target different models — and the motivation for multi-model is **role-based
routing**: drafting, reviewing, and triage suit different models.
The original ADR-0017 mandated a provider-agnostic agent layer: any
OpenAI-compatible model via OpenRouter, custom agent loop built on Vercel AI
SDK's `streamText`. The motivation was to avoid vendor lock-in.
That raises the question ADR-0002 left open: when a teacher switches model
mid-project, does the `AgentSession` carry live context across the switch, or
is it bound to a single provider/model?
In practice, the hand-rolled agent loop lacked capabilities that a production
agent needs: context compaction (long conversations), tool approval flows,
partial-message streaming, and a battle-tested multi-turn loop. Building these
ourselves meant re-implementing what the Claude Code SDK already provides.
The Feishu integration also proved costly to hand-roll — card schema quirks,
patch limitations, file support — each API quirk cost a round-trip.
## Decision
An `AgentSession` is **provider/model-bound**. A model or provider switch
starts a new `AgentSession`. The `AgentSession` does not carry live context
across a switch.
Adopt `@anthropic-ai/claude-agent-sdk` as the agent runtime, routed through
OpenRouter's **"Anthropic Skin"** (`https://openrouter.ai/api`).
Cross-session continuity is not the session's job — it is carried by ADR-0003's
project memory and anchors, which the Hub reads on demand when seeding a new
run. This keeps B consistent with ADR-0003 ("the Hub avoids becoming a full
chat history store"): session continuity and history continuity are the same
mechanism, both via memory/anchors, never via a live cross-provider session.
OpenRouter exposes an Anthropic Messages API-compatible endpoint. Claude Code
SDK speaks its native protocol directly to OpenRouter — no proxy, no format
conversion. The SDK's `query()` owns the agent loop (tool dispatch, compaction,
streaming). Built-in tools (Read, Write, Bash, Glob, Grep) cover the entire
tool surface — no custom tools needed. `cph check` / `cph build` run via Bash.
Per-run model selection (the run carries its model) is the switching point.
The Hub `AgentSession` is provider/role/model-bound, and it must persist the
provider runtime cursor needed to continue a conversation. For Claude Code SDK,
that cursor is the `result.session_id`; store it in `AgentSession.metadata` as
`claudeSessionId` and pass it back to the next `query()` call as
`options.resume`. Role is part of the session binding because role prompts and
tool surfaces can differ even when the underlying model is the same; `/draft`
and `/review` must not resume the same Claude runtime cursor by accident.
Environment variables:
```
ANTHROPIC_BASE_URL=https://openrouter.ai/api
ANTHROPIC_AUTH_TOKEN=<OpenRouter API key>
ANTHROPIC_API_KEY="" # must be explicitly empty
```
Model routing: `ANTHROPIC_DEFAULT_SONNET_MODEL` etc. accept OpenRouter model
IDs (e.g. `z-ai/glm-4.7`, `anthropic/claude-sonnet-4-20250514`). This gives
limited OpenRouter-level model routing, but the runtime is still Claude Agent
SDK-shaped: non-Claude models are best-effort and may not support every
Claude Code feature.
## Consequences
- Switching model mid-project = new session; the new run seeds from project
memory/anchors (ADR-0003), not the prior session's live context.
- The agent layer is provider-agnostic: the agent loop is ours; model calls go
through an OpenAI-compatible client. No hard dependency on a single-vendor
agent SDK.
- Role-based model routing (different run kinds default to different models) is
a product/admin configuration concern, not a spec invariant.
- "AgentSession reused by many runs" (ADR-0002) holds *within* one
provider/model; it does not span a switch.
- The `@Claude` trigger name remains the product mention brand regardless of the
backing model.
- Provider-agnosticism is not fully preserved. OpenRouter can route to GLM,
Claude, GPT, etc., but the runtime protocol and agent loop remain Claude
Agent SDK-bound.
- The agent loop is Claude Code SDK's (compaction, tool approval, partial
message streaming) — not hand-rolled.
- Custom tools (read_file, write_file, cph_check, cph_build) are replaced by
the SDK's built-in Read/Write/Bash/Glob/Grep.
- The SSE event format matches Claude-to-IM's expected protocol natively.
- Per-run model selection works via role→model mapping; models are OpenRouter
IDs, not Anthropic-only aliases.
- OpenRouter recommends setting Anthropic as the top-priority provider for
Claude Code; non-Anthropic models may have reduced compatibility with some
SDK features (e.g. thinking blocks).
@@ -0,0 +1,166 @@
# ADR 0018: The Agent Execution Surface Is Bounded By The Project Workspace
## Status
Accepted, and implemented. The mechanism is settled: the Claude Code SDK's
built-in sandbox (bubblewrap on Linux, seatbelt on macOS) confines the agent
process and its Bash subprocesses at the OS level. `bypassPermissions`
remains (headless server — no interactive prompts), but the sandbox is the
hard boundary, not the permission-prompt layer. Constitution rule 4
divergence (ADR-0017's unbounded tools) is resolved.
Builds on **ADR-0001** (project group as collaboration space), **ADR-0002**
(project lock scoped to `run_id`), **ADR-0004** (permission grants govern
`triggerAgent`), **ADR-0007** (the engineering file is a directory tree on
disk), and **ADR-0017** (Claude Code SDK as the agent runtime).
## Context
ADR-0001/0002/0004 together cover *collaboration governance* up to the
moment a teacher `@Claude`s: who may trigger an `AgentRun`, that the run
holds a project-scoped lock, that the group is a long-lived space. They stop
at `triggerAgent`. What the agent may do *after* the trigger — which files it
may read or write, what commands it may run — is not named in any ADR, not
modeled in `spec/`, and not enforced by any contract-level invariant.
The gap is not theoretical. ADR-0017 adopted the Claude Code SDK with
`allowedTools: ["Read", "Write", "Bash", "Glob", "Grep"]` and
`permissionMode: "bypassPermissions"`. Under that configuration the SDK's
built-in tools operate over the whole host filesystem with no confinement, and
`Bash` executes arbitrary shell with no approval gate. The Hub's
`hub/src/agent/workspace.ts` was *intended* to be "the agent's only file
surface" (its module doc says so) and ships a `confine()` path validator that
rejects `..` and absolute escapes. But ADR-0017's cutover left those custom
tools unwired — the SDK's built-in tools replaced them — so `confine()` is
dead code and the boundary it was meant to enforce does not exist at runtime.
Compounding this, the systemd unit's default `SERVICE_USER` is `root`
(`hub/deploy/install_service.sh`), so the unbounded agent runs as root.
This is a real divergence point by the constitution's test: "if unwritten,
will the developer and the agent make different assumptions?" They already
have. ADR-0017 assumed the boundary was an implementation concern; the
`workspace.ts` author assumed it was a contract. The next person to touch the
runner has nothing telling them the boundary must be enforced, and ADR-0017
silently overrode the one that existed.
## Decision
### The agent execution surface is bounded by the run's project workspace
During an `AgentRun`, every file operation the agent performs — read, write,
glob, grep target — must fall **within the run's project workspace directory**
(the on-disk engineering-file tree of ADR-0007). Operations whose target path
escapes that tree are refused. This is a platform core invariant, pinned in
`Spec.System.AgentSurface`.
The boundary is **per-run, anchored to the run's project**, the same scope as
the Lock (ADR-0002). This keeps the two invariants composable:
- `Lock` (ADR-0002) bounds **concurrency** — who may be modifying the project.
- `AgentSurface` (this ADR) bounds **blast radius** — what the agent may touch.
Both scope to `run × project`. Acquiring the lock and entering the surface
happen together at run start; releasing the lock and leaving the surface
happen together at run termination.
### Shell execution is subject to the same boundary, by effect
A shell command the agent runs executes with the workspace as its working
directory (already true for the `cph` subprocess, ADR-0016) and its **file
effects** — files read, written, created, or deleted — are subject to the same
workspace boundary. The agent may invoke **named external tools** whose
binaries live outside the workspace (notably `cph` via `CPH_BIN`); the
boundary governs *file effects*, not *binary location*. Arbitrary
`cat /etc/shadow` or `curl … > /etc/...` is out of bounds regardless of how it
is invoked.
### The Hub process runs as a non-privileged service user
The systemd unit must not run as `root`. The default in
`hub/deploy/install_service.sh` changes from `root` to a dedicated unprivileged
user. This is a deployment invariant, recorded here because the unbounded-agent
risk is compounded by root; it is enforced in the unit, not in `spec/` (it is
not a semantic divergence between developer and agent — it is operational
discipline).
### The mechanism: SDK sandbox (OS-level, per agent process)
The boundary is enforced by the Claude Code SDK's built-in sandbox
(bubblewrap on Linux, seatbelt on macOS), configured via `query()` options in
`hub/src/agent/runner.ts`:
- `sandbox.enabled: true` + `failIfUnavailable: true` — the agent process and
its Bash subprocesses run sandboxed; if the sandbox can't start, `query()`
emits an error and exits rather than running unsandboxed.
- `sandbox.filesystem.allowWrite: [workspaceDir]` — writes confined to the
workspace (the ADR-0007 directory tree). Reads outside are allowed by
default (the agent may read system files for context), but sensitive host
paths are denied via `denyRead` (`/etc`, `/root`, `~/.ssh`, `~/.aws`, …).
- `sandbox.filesystem.denyRead: SENSITIVE_READ_PATHS` — defense-in-depth;
extends via `CPH_SANDBOX_EXTRA_DENY_READ` for deployment-specific secrets.
- Network: open (see Open Questions).
`bypassPermissions` is kept (headless server — no interactive prompts); the
sandbox is the hard boundary, not the permission-prompt layer. This is
subprocess-level, not process-level: the Hub host process is not sandboxed
(it manages DB, Feishu ws, logs), only the Claude Code agent process and its
children. This keeps the sandbox config surface small and lets future
external CLIs (Gitea, Lark) run inside the sandbox by giving their binaries
read access, without widening the Hub's own filesystem surface.
The contract pins the **invariant** (operations stay in the workspace), not
the **mechanism**. The SDK sandbox is the current mechanism; switching to a
different one (tool wrappers, container) would not be a spec change as long
as it upholds `AgentFileOp.Authorized`.
## Consequences
- `Spec.System.AgentSurface` gains an `AgentFileOp` structure and an
`Authorized` predicate mirroring `Memory.McpReadRequest.Authorized`: the op
carries a `run` and a `path`; `Authorized` holds iff the path falls within
the run's workspace, via platform-provided `runWorkspace` and `pathWithin`
(representations `OPEN`).
- `hub/src/agent/workspace.ts`'s `confine()` intent is now contract-backed.
Re-wiring it (or replacing it with an OS sandbox) is **mandatory**, not a
preference. Its current dead-code status under ADR-0017 is a divergence to
be addressed, not silently retained.
- ADR-0017's `bypassPermissions` + unrestricted built-in tools is a
**divergence from this ADR** and is surfaced as such (constitution rule 4).
The reconciliation — restrict the SDK tool set, wrap Bash, or sandbox at the
OS level — is the open mechanism question above. Whichever is chosen must
uphold the `AgentSurface` invariant.
- Inbound file/image delivery (`hub/src/feishu/trigger.ts`) lands files under
`${workspaceDir}/.cph/inbox/`, which is inside the boundary — consistent, no
change to the delivery path.
- The `cph` subprocess (ADR-0016) already runs with `cwd = workspaceDir`
(`hub/src/agent/cph.ts`), so it is inside the boundary by construction.
## Open Questions / Deferred
- **Network egress.** Decided: network is **open** in the initial
production deployment. The sandbox confines filesystem writes to the
workspace and denies reads of sensitive host paths, but does not restrict
outbound network. Rationale: future Gitea/Lark CLI integration needs
unconstrained network egress; `cph build` may fetch typst packages. The
exfiltration risk (agent `curl`s workspace content out) is accepted as a
tradeoff for CLI integration simplicity. This is revisitable — a network
allowlist (`sandbox.network.allowedDomains`) can be added without a spec
change if the threat model tightens.
- **Sensitive read path coverage.** `SENSITIVE_READ_PATHS` in `runner.ts`
covers `/etc`, `/root`, `/var/log`, `/proc`, `/sys`, and common
credential dirs (`~/.ssh`, `~/.aws`, `~/.config/gcloud`, `~/.gnupg`).
Deployments with additional secret locations extend via
`CPH_SANDBOX_EXTRA_DENY_READ` (colon-separated). Whether this set should be
spec-pinned rather than implementation-chosen is `OPEN` — it's plumbing, not
a semantic divergence, so currently left out of `spec/`.
- **Shell command vocabulary.** Whether the agent's Bash is restricted to a
whitelist (e.g. `cph`, `ls`, `grep`) or allowed arbitrary commands whose
*effects* are then confined, is a policy choice left to implementation. The
invariant governs effects either way; current choice is arbitrary commands
(sandbox confines effects).
- **Per-user rate limiting and token budgets.** Orthogonal to the surface
boundary (this ADR is about *what* the agent may touch, not *how often* it
may run). Deferred to a separate concern.
- **Inbound file size / MIME limits.** The inbox lives inside the boundary,
but unbounded file size is a DoS vector distinct from the boundary
invariant. Deferred.
@@ -0,0 +1,129 @@
# ADR 0019: Resolve Actors to Principal Sets for Team Permissions
## Status
Accepted.
## Context
ADR-0004 deliberately chose a Feishu Docs-like permission model:
`grant(resource, principal, role)` plus separate resource settings. The first
Hub implementation stored `principal` as an opaque string and the Feishu trigger
used the sender's `open_id` directly. That was enough for individual teacher
grants, but not for production collaboration:
- a curriculum team needs one grant to apply to many teachers;
- Feishu departments, user groups, and project chats should be grantable
principals;
- role-specific agent grants (`/review`, `/draft`) must compose with project
permissions consistently;
- audit/debug output must explain which principal and grant allowed or denied a
run.
## Decision
Introduce typed principals and resolve every actor to a principal set before
authorization.
### Principal types
`PrincipalType` is:
- `USER`: a Feishu user, identified by `feishuOpenId`.
- `TEAM`: a Hub-managed teacher team.
- `FEISHU_CHAT`: a Feishu chat/group.
- `FEISHU_DEPARTMENT`: a Feishu department.
- `FEISHU_USER_GROUP`: a Feishu user group.
- `APP`: an integration or bot principal.
`PermissionGrant` and `RoleTriggerGrant` store `principalType` and
`principalId`. Legacy opaque principal strings are backfilled as
`USER/<old principal>`.
### Team membership
Hub teams are first-class. A user can be a direct active member of a team. A
team can also be bound to external Feishu principals: chat, department, or user
group. If an actor resolves to any bound external principal, the actor also
resolves to that Hub team.
Membership changes are effective immediately because authorization resolves the
principal set at request time.
### External Feishu principal sync
Feishu department, user group, and chat memberships are not scattered through
callers. They are synchronized into `ExternalPrincipalMembership` rows and then
read by `PrincipalResolver`. The Feishu message context can also contribute the
current `FEISHU_CHAT` principal directly because receiving a group event is
already contextual proof that the actor is speaking in that group.
### Grant merging
Authorization evaluates all active grants matching any resolved principal for
the requested resource. There is no explicit deny in this ADR. Among matching
grants, the highest role wins:
```text
READ < EDIT < MANAGE
```
Action thresholds:
- project read requires `READ`.
- project edit requires `EDIT`.
- collaborator management requires `MANAGE`.
- agent trigger requires `EDIT`.
- normal agent cancel requires `MANAGE`.
`PermissionSettings` never grants access beyond grants. Settings only constrain
an already-granted capability. For `agentTrigger`:
- missing setting or `ROLE` means role-derived grants decide;
- `MANAGE_ONLY` raises the threshold to `MANAGE`;
- `DISABLED` denies the action.
Other settings keep their existing string storage, but the same rule applies:
settings are policy constraints, not positive grants.
### Role-trigger composition
Role-trigger grants are a second gate after the project-level `agent.trigger`
gate:
1. The actor must pass project `agent.trigger` for the project.
2. If no `RoleTriggerGrant` row has ever existed for `(projectId, roleId)`,
the role is open for backward compatibility.
3. Once any row exists for `(projectId, roleId)`, including a revoked row, the
role is configured. The actor must match an active role grant by any resolved
principal.
This keeps `/review` restrictions orthogonal to ordinary edit access while
preventing a fully revoked role from silently reopening.
### Module seam
Callers use a single authorization module:
```ts
authorizer.can({
actor,
action,
resource,
roleId,
})
```
The caller does not know how users, teams, Feishu departments, user groups, or
chats expand. `PrincipalResolver` owns that implementation, and
`PermissionAuthorizer` owns grant/settings/role-trigger composition.
## Consequences
- Teacher teams become production-grade principals rather than UI sugar.
- Feishu external organization concepts can be synchronized and granted without
leaking Feishu-specific checks into the trigger path.
- A single audit decision can report actor, principal set, matched grant, and
matched role grant.
- Backward compatibility is explicit: old principal strings become user
principals, and unconfigured role grants remain open.
@@ -0,0 +1,92 @@
# ADR 0020: Organization Is The SaaS Tenant Root
## Status
Accepted.
## Context
The Hub is moving from a single-organization deployment assumption toward a
SaaS shape. The existing permission model already supports team principals via
`PermissionGrant(PROJECT, TEAM, role)`, but without a tenant root the data model
cannot safely answer:
- which customer owns a project;
- which organization a team belongs to;
- whether a team grant crosses customer boundaries;
- which external directory sync produced a Feishu department/user-group
membership;
- where future org admin, billing, audit, and platform break-glass controls
attach.
Retrofitting this after projects, teams, runs, and audit records grow would
force tenant assumptions into every caller.
## Decision
Introduce `Organization` as the SaaS tenant root.
Customer-side collaboration data is organization-scoped:
```text
Organization
OrganizationMembership
Team
TeamMembership
Project
ExternalDirectoryConnection
PermissionGrant
```
`User` remains global. A user can belong to multiple organizations through
`OrganizationMembership`. Project-level authorization still uses
`PermissionGrant` and `PermissionAuthorizer`; the authorizer derives the
organization from the requested resource, then resolves teams and external
principals in that organization scope.
`Team` belongs to exactly one organization. `Project` belongs to exactly one
organization. A `PermissionGrant` that grants a `TEAM` principal on a `PROJECT`
resource is valid only when the team and project are in the same organization.
This invariant is pinned in `Spec.System.Organization`.
External directory membership is scoped through `ExternalDirectoryConnection`:
```text
ExternalDirectoryConnection(
organizationId,
provider,
providerTenantId,
source
)
```
`ExternalPrincipalMembership` points at a connection rather than carrying a
free-floating `source` string. `source` names a sync pipeline; it is not the
tenant identity.
The SaaS platform control plane is separate from customer project permissions.
`READ/EDIT/MANAGE` remains the customer-side project permission lattice.
Platform staff access, tenant suspension, billing, and break-glass access are a
separate control plane and must not be modeled as project `MANAGE`.
## Consequences
- Existing single-org data is backfilled into a default organization during
migration.
- `Team.slug` becomes organization-scoped instead of globally unique.
- Actor resolution is organization-aware: direct teams and synchronized external
principals only expand inside the resource's organization.
- Direct `USER` grants remain possible because a global user can collaborate
across organizations when explicitly granted.
- Future org admin UI can be built on this model without changing the
authorization core.
## Open Questions / Deferred
- Full platform admin UI, billing, limits, and tenant suspension workflows are
deferred.
- Whether direct `USER` project grants should require active
`OrganizationMembership` is deferred; explicit direct grants remain allowed
for now.
- Provider-specific directory semantics beyond Feishu are deferred, but the
connection model leaves room for them.
+1
View File
@@ -0,0 +1 @@
Hello, World!
+22 -12
View File
@@ -1,23 +1,33 @@
# Hub runtime configuration. Copy to .env and fill in.
# PostgreSQL connection string. Used by Prisma and the Hub server.
# PostgreSQL connection string.
DATABASE_URL="postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm"
# OpenRouter (or any OpenAI-compatible) API key + base URL.
# The Hub's agent layer is provider-agnostic (ADR-0017); this points the
# OpenAI-compatible client at OpenRouter by default.
OPENROUTER_API_KEY=""
# Optional: override the base URL (e.g. direct vendor API, local gateway).
# OPENROUTER_BASE_URL="https://openrouter.ai/api/v1"
# Claude Code SDK auth via OpenRouter's Anthropic Skin.
# ANTHROPIC_AUTH_TOKEN = your OpenRouter API key (https://openrouter.ai/keys)
# ANTHROPIC_API_KEY must be explicitly empty to avoid auth conflicts.
ANTHROPIC_BASE_URL="https://openrouter.ai/api"
ANTHROPIC_AUTH_TOKEN=""
ANTHROPIC_API_KEY=""
# Hub server port. Defaults to 8788.
PORT=8788
# Model override (optional). Defaults to anthropic/claude-sonnet-5.
# Use an OpenRouter model slug, for example:
# ANTHROPIC_DEFAULT_SONNET_MODEL="~anthropic/claude-sonnet-latest"
# To use GLM:
# ANTHROPIC_DEFAULT_SONNET_MODEL="z-ai/glm-4.7"
# ANTHROPIC_DEFAULT_OPUS_MODEL="z-ai/glm-4.7"
# ANTHROPIC_DEFAULT_HAIKU_MODEL="z-ai/glm-4.6"
# Feishu (lark) bot credentials. The bot receives @mentions in project groups.
# Agent run policy (optional). Defaults to 25.
# HUB_AGENT_MAX_TURNS=25
# Feishu (lark) bot credentials.
FEISHU_APP_ID=""
FEISHU_APP_SECRET=""
FEISHU_BOT_OPEN_ID=""
# Path to the `cph` binary (the Courseware-side checker, ADR-0016).
# Defaults to `cph` on PATH if unset.
# Path to the `cph` binary (ADR-0016). Defaults to `cph` on PATH.
# CPH_BIN="/usr/local/bin/cph"
# Hub server port. Defaults to 8788.
PORT=8788
+8
View File
@@ -16,6 +16,14 @@ RestartSec=5
# Graceful shutdown: lark ws close + in-flight runs.
KillSignal=SIGTERM
TimeoutStopSec=30
# ADR-0018: the agent sandbox (bubblewrap) must be available on the host —
# failIfUnavailable makes the Hub refuse to run unsandboxed. Install bwrap.
AssertPathExists=/usr/bin/bwrap
# Hardening: no new privileges. ProtectSystem/ProtectHome are NOT set here —
# they would break cph's render-cache extraction (~/.cache/cph/) and the
# bwrap user-namespace setup. The OS-level sandbox (ADR-0018) is the hard
# boundary; systemd hardening is kept minimal to avoid interfering with it.
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
+25 -11
View File
@@ -7,12 +7,11 @@
# PLATFORM_DEPLOY_USER optional, defaults to deploy
# PLATFORM_DEPLOY_PORT optional, defaults to 22
# PLATFORM_DEPLOY_BASE optional, defaults to /srv/curriculum-project-hub
#
# The target host must have Node.js, npm, rsync, PostgreSQL, systemd, and a
# writable $PLATFORM_DEPLOY_BASE. Use a dedicated `deploy` user that has
# passwordless sudo for:
# systemctl restart cph-hub.service
# systemctl is-active --quiet cph-hub.service
# PLATFORM_DEPLOY_HEALTH_URL optional, defaults to http://127.0.0.1:8788/api/healthz
# The target host must have Node.js, npm, rsync, PostgreSQL, systemd, bubblewrap
# (`bwrap`, for the ADR-0018 agent sandbox), and a writable $PLATFORM_DEPLOY_BASE.
# Use a dedicated `deploy` user that has passwordless sudo for systemctl and
# writing /etc/systemd/system/cph-hub.service through deploy/install_service.sh.
set -euo pipefail
HOST="${PLATFORM_DEPLOY_HOST:?PLATFORM_DEPLOY_HOST required}"
@@ -20,12 +19,15 @@ SSH_KEY="${PLATFORM_DEPLOY_SSH_KEY:?PLATFORM_DEPLOY_SSH_KEY required}"
DEPLOY_USER="${PLATFORM_DEPLOY_USER:-deploy}"
PORT="${PLATFORM_DEPLOY_PORT:-22}"
BASE="${PLATFORM_DEPLOY_BASE:-/srv/curriculum-project-hub}"
HEALTH_URL="${PLATFORM_DEPLOY_HEALTH_URL:-http://127.0.0.1:8788/api/healthz}"
HUB_DIR="$BASE/hub"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
SSH_OPTS=(-i "$SSH_KEY" -p "$PORT" -o StrictHostKeyChecking=accept-new)
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "mkdir -p '$HUB_DIR'"
# 1. Rsync the hub/ directory (exclude node_modules/dist, they rebuild on host).
rsync -az --delete \
--exclude node_modules --exclude dist --exclude .env \
@@ -37,11 +39,23 @@ ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "cd '$HUB_DIR' && npm ci && npm run bu
# 3. Ensure the service is installed (idempotent), then restart.
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "
BASE='$BASE' HUB_DIR='$HUB_DIR' bash '$HUB_DIR/deploy/install_service.sh' || true
sudo systemctl restart cph-hub.service
sleep 2
sudo systemctl is-active --quiet cph-hub.service || { echo 'service failed to start'; exit 1; }
curl -sf http://127.0.0.1:8788/api/healthz || { echo 'healthz failed'; exit 1; }
set -euo pipefail
if [ \"\$(id -u)\" = \"0\" ]; then
BASE='$BASE' HUB_DIR='$HUB_DIR' bash '$HUB_DIR/deploy/install_service.sh'
systemctl restart cph-hub.service
systemctl is-active --quiet cph-hub.service
else
sudo -n BASE='$BASE' HUB_DIR='$HUB_DIR' bash '$HUB_DIR/deploy/install_service.sh'
sudo -n systemctl restart cph-hub.service
sudo -n systemctl is-active --quiet cph-hub.service
fi
for attempt in \$(seq 1 30); do
if curl --fail --silent '$HEALTH_URL' >/dev/null 2>&1; then
exit 0
fi
sleep 1
done
curl --fail --silent --show-error '$HEALTH_URL' >/dev/null
"
echo "Deployed cph-hub to $HOST ($HUB_DIR)"
+18 -4
View File
@@ -11,11 +11,12 @@ set -euo pipefail
BASE="${BASE:-/srv/curriculum-project-hub}"
HUB_DIR="${HUB_DIR:-$BASE/hub}"
SERVICE_USER="${SERVICE_USER:-root}"
SERVICE_USER="${SERVICE_USER:-cph-hub}"
HOST="${HOST:-127.0.0.1}"
PORT="${PORT:-8788}"
ENV_FILE="${ENV_FILE:-$BASE/.secrets/platform.env}"
NODE_BIN="${NODE_BIN:-$(command -v node || true)}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -z "$NODE_BIN" ]; then
echo "node must be on PATH, or set NODE_BIN." >&2
@@ -29,6 +30,10 @@ if [ ! -f "$HUB_DIR/dist/server.js" ]; then
echo "build missing: run 'npm ci && npm run build' in $HUB_DIR first." >&2
exit 1
fi
if [ ! -f "$SCRIPT_DIR/cph-hub.service" ]; then
echo "service template missing: $SCRIPT_DIR/cph-hub.service" >&2
exit 1
fi
# Seed the env file if absent. Secrets stay on the server, never in the repo.
if [ ! -f "$ENV_FILE" ]; then
@@ -36,25 +41,34 @@ if [ ! -f "$ENV_FILE" ]; then
cat >"$ENV_FILE" <<EOF
NODE_ENV=production
DATABASE_URL=postgresql://paradigm:change-me@127.0.0.1:5432/paradigm
OPENROUTER_API_KEY=
# Claude Code SDK auth via OpenRouter's Anthropic Skin (ADR-0017).
# ANTHROPIC_AUTH_TOKEN = your OpenRouter API key; ANTHROPIC_API_KEY must be empty.
ANTHROPIC_BASE_URL=https://openrouter.ai/api
ANTHROPIC_AUTH_TOKEN=
ANTHROPIC_API_KEY=
FEISHU_APP_ID=
FEISHU_APP_SECRET=
FEISHU_BOT_OPEN_ID=
PORT=$PORT
HOST=$HOST
EOF
echo "Seeded $ENV_FILE — edit it to fill in OPENROUTER_API_KEY and Feishu creds, then re-run."
chmod 0600 "$ENV_FILE"
echo "Seeded $ENV_FILE — edit it to fill in ANTHROPIC_AUTH_TOKEN and Feishu creds, then re-run."
exit 0
fi
# Render the unit with concrete paths.
UNIT=/etc/systemd/system/cph-hub.service
TMP_UNIT="$(mktemp)"
sed \
-e "s|__SERVICE_USER__|$SERVICE_USER|g" \
-e "s|__HUB_DIR__|$HUB_DIR|g" \
-e "s|__BASE_DIR__|$BASE|g" \
-e "s|__ENV_FILE__|$ENV_FILE|g" \
-e "s|__NODE_BIN__|$NODE_BIN|g" \
"$HUB_DIR/deploy/cph-hub.service" >"$UNIT"
"$SCRIPT_DIR/cph-hub.service" >"$TMP_UNIT"
install -m 0644 "$TMP_UNIT" "$UNIT"
rm -f "$TMP_UNIT"
systemctl daemon-reload
systemctl enable cph-hub.service
+1172 -17
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -7,7 +7,7 @@
"node": ">=20"
},
"dependencies": {
"@ai-sdk/openai-compatible": "^3.0.5",
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
"@fastify/cookie": "^11.0.2",
"@larksuiteoapi/node-sdk": "^1.66.1",
"@prisma/client": "^6.19.3",
@@ -22,11 +22,11 @@
"typescript": "^5.7.0",
"vitest": "^4.1.10"
},
"description": "Curriculum Project Hub — Feishu-group collaboration + provider-agnostic agent runtime. Aligns to spec/System (ADR-0001..0004, 0017).",
"description": "Curriculum Project Hub — Feishu-group collaboration + Claude Code SDK agent runtime. Aligns to spec/System (ADR-0001..0004, 0017).",
"scripts": {
"dev": "tsx watch src/server.ts",
"dev": "npm run prisma:migrate && tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js",
"start": "npm run prisma:migrate && node dist/server.js",
"check": "tsc -p tsconfig.json --noEmit",
"prisma:generate": "prisma generate --schema prisma/schema.prisma",
"prisma:validate": "DATABASE_URL=${DATABASE_URL:-postgresql://stub:stub@127.0.0.1:5432/stub} prisma validate --schema prisma/schema.prisma",
@@ -0,0 +1,23 @@
-- FeishuEventReceipt: idempotency for inbound ws events (at-least-once redelivery).
CREATE TABLE "FeishuEventReceipt" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"eventType" TEXT NOT NULL,
"messageId" TEXT,
"receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FeishuEventReceipt_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "FeishuEventReceipt_eventId_key" ON "FeishuEventReceipt"("eventId");
CREATE INDEX "FeishuEventReceipt_eventType_idx" ON "FeishuEventReceipt"("eventType");
CREATE INDEX "FeishuEventReceipt_messageId_idx" ON "FeishuEventReceipt"("messageId");
CREATE INDEX "FeishuEventReceipt_receivedAt_idx" ON "FeishuEventReceipt"("receivedAt");
-- AuditEntry: add projectId so audit points before/without a run (permission
-- denials, slash commands) are locatable to a project.
ALTER TABLE "AuditEntry" ADD COLUMN "projectId" TEXT;
CREATE INDEX "AuditEntry_projectId_createdAt_idx" ON "AuditEntry"("projectId", "createdAt");
ALTER TABLE "AuditEntry" ADD CONSTRAINT "AuditEntry_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,45 @@
-- AgentMessage: per-message record of the agent loop (queryable projection
-- alongside the transcript JSONL file, which remains the agent's read-back memory).
CREATE TABLE "AgentMessage" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"runId" TEXT,
"role" TEXT NOT NULL,
"content" TEXT NOT NULL,
"attachments" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AgentMessage_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "AgentMessage_sessionId_createdAt_idx" ON "AgentMessage"("sessionId", "createdAt");
CREATE INDEX "AgentMessage_runId_idx" ON "AgentMessage"("runId");
ALTER TABLE "AgentMessage" ADD CONSTRAINT "AgentMessage_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "AgentSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "AgentMessage" ADD CONSTRAINT "AgentMessage_runId_fkey" FOREIGN KEY ("runId") REFERENCES "AgentRun"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AgentFileChange: file change captured during a run (write/rename/delete).
CREATE TABLE "AgentFileChange" (
"id" TEXT NOT NULL,
"runId" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"path" TEXT NOT NULL,
"operation" TEXT NOT NULL,
"beforeHash" TEXT,
"afterHash" TEXT,
"diff" TEXT,
"metadata" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AgentFileChange_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "AgentFileChange_runId_idx" ON "AgentFileChange"("runId");
CREATE INDEX "AgentFileChange_projectId_idx" ON "AgentFileChange"("projectId");
CREATE INDEX "AgentFileChange_path_idx" ON "AgentFileChange"("path");
ALTER TABLE "AgentFileChange" ADD CONSTRAINT "AgentFileChange_runId_fkey" FOREIGN KEY ("runId") REFERENCES "AgentRun"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "AgentFileChange" ADD CONSTRAINT "AgentFileChange_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- ProjectAgentLock: add heartbeatAt for liveness (A-5).
ALTER TABLE "ProjectAgentLock" ADD COLUMN "heartbeatAt" TIMESTAMP(3);
@@ -0,0 +1,143 @@
-- ADR-0019: typed principals, Hub teams, and external Feishu principal sync.
CREATE TYPE "PrincipalType" AS ENUM (
'USER',
'TEAM',
'FEISHU_CHAT',
'FEISHU_DEPARTMENT',
'FEISHU_USER_GROUP',
'APP'
);
CREATE TABLE "Team" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"archivedAt" TIMESTAMP(3),
CONSTRAINT "Team_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "Team_slug_key" ON "Team"("slug");
CREATE INDEX "Team_archivedAt_idx" ON "Team"("archivedAt");
CREATE TABLE "TeamMembership" (
"id" TEXT NOT NULL,
"teamId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
CONSTRAINT "TeamMembership_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "TeamMembership_teamId_revokedAt_idx" ON "TeamMembership"("teamId", "revokedAt");
CREATE INDEX "TeamMembership_userId_revokedAt_idx" ON "TeamMembership"("userId", "revokedAt");
CREATE UNIQUE INDEX "TeamMembership_teamId_userId_revokedAt_key" ON "TeamMembership"("teamId", "userId", "revokedAt");
CREATE UNIQUE INDEX "TeamMembership_active_key" ON "TeamMembership"("teamId", "userId") WHERE "revokedAt" IS NULL;
ALTER TABLE "TeamMembership" ADD CONSTRAINT "TeamMembership_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "TeamMembership" ADD CONSTRAINT "TeamMembership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE TABLE "TeamExternalBinding" (
"id" TEXT NOT NULL,
"teamId" TEXT NOT NULL,
"principalType" "PrincipalType" NOT NULL,
"principalId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
CONSTRAINT "TeamExternalBinding_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "TeamExternalBinding_principalType_principalId_revokedAt_idx" ON "TeamExternalBinding"("principalType", "principalId", "revokedAt");
CREATE INDEX "TeamExternalBinding_teamId_revokedAt_idx" ON "TeamExternalBinding"("teamId", "revokedAt");
CREATE UNIQUE INDEX "TeamExternalBinding_teamId_principalType_principalId_revokedAt_key" ON "TeamExternalBinding"("teamId", "principalType", "principalId", "revokedAt");
CREATE UNIQUE INDEX "TeamExternalBinding_active_key" ON "TeamExternalBinding"("teamId", "principalType", "principalId") WHERE "revokedAt" IS NULL;
ALTER TABLE "TeamExternalBinding" ADD CONSTRAINT "TeamExternalBinding_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE TABLE "ExternalPrincipalMembership" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"principalType" "PrincipalType" NOT NULL,
"principalId" TEXT NOT NULL,
"source" TEXT NOT NULL,
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
CONSTRAINT "ExternalPrincipalMembership_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "ExternalPrincipalMembership_userId_revokedAt_idx" ON "ExternalPrincipalMembership"("userId", "revokedAt");
CREATE INDEX "ExternalPrincipalMembership_principalType_principalId_revokedAt_idx" ON "ExternalPrincipalMembership"("principalType", "principalId", "revokedAt");
CREATE INDEX "ExternalPrincipalMembership_source_syncedAt_idx" ON "ExternalPrincipalMembership"("source", "syncedAt");
CREATE UNIQUE INDEX "ExternalPrincipalMembership_userId_principalType_principalId_source_revokedAt_key" ON "ExternalPrincipalMembership"("userId", "principalType", "principalId", "source", "revokedAt");
CREATE UNIQUE INDEX "ExternalPrincipalMembership_active_key" ON "ExternalPrincipalMembership"("userId", "principalType", "principalId", "source") WHERE "revokedAt" IS NULL;
ALTER TABLE "ExternalPrincipalMembership" ADD CONSTRAINT "ExternalPrincipalMembership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- PermissionGrant: backfill legacy opaque principal strings as USER principals.
ALTER TABLE "PermissionGrant" DROP CONSTRAINT IF EXISTS "PermissionGrant_resourceId_fkey";
DROP INDEX IF EXISTS "PermissionGrant_resourceType_resourceId_principal_role_revo_key";
DROP INDEX IF EXISTS "PermissionGrant_principal_revokedAt_idx";
ALTER TABLE "PermissionGrant" ADD COLUMN "principalType" "PrincipalType" NOT NULL DEFAULT 'USER';
ALTER TABLE "PermissionGrant" ADD COLUMN "principalId" TEXT;
UPDATE "PermissionGrant" SET "principalId" = "principal";
ALTER TABLE "PermissionGrant" ALTER COLUMN "principalId" SET NOT NULL;
ALTER TABLE "PermissionGrant" ALTER COLUMN "principalType" DROP DEFAULT;
ALTER TABLE "PermissionGrant" DROP COLUMN "principal";
WITH ranked_permission_grants AS (
SELECT
"id",
row_number() OVER (
PARTITION BY "resourceType", "resourceId", "principalType", "principalId", "role"
ORDER BY "createdAt", "id"
) AS rn
FROM "PermissionGrant"
WHERE "revokedAt" IS NULL
)
DELETE FROM "PermissionGrant"
WHERE "id" IN (
SELECT "id" FROM ranked_permission_grants WHERE rn > 1
);
CREATE INDEX "PermissionGrant_principalType_principalId_revokedAt_idx" ON "PermissionGrant"("principalType", "principalId", "revokedAt");
CREATE UNIQUE INDEX "PermissionGrant_active_key" ON "PermissionGrant"("resourceType", "resourceId", "principalType", "principalId", "role") WHERE "revokedAt" IS NULL;
-- PermissionSettings resource is polymorphic; do not FK resourceId to Project.
ALTER TABLE "PermissionSettings" DROP CONSTRAINT IF EXISTS "PermissionSettings_resourceId_fkey";
-- RoleTriggerGrant: same backfill as PermissionGrant.
DROP INDEX IF EXISTS "RoleTriggerGrant_projectId_roleId_principal_revokedAt_key";
DROP INDEX IF EXISTS "RoleTriggerGrant_principal_revokedAt_idx";
ALTER TABLE "RoleTriggerGrant" ADD COLUMN "principalType" "PrincipalType" NOT NULL DEFAULT 'USER';
ALTER TABLE "RoleTriggerGrant" ADD COLUMN "principalId" TEXT;
UPDATE "RoleTriggerGrant" SET "principalId" = "principal";
ALTER TABLE "RoleTriggerGrant" ALTER COLUMN "principalId" SET NOT NULL;
ALTER TABLE "RoleTriggerGrant" ALTER COLUMN "principalType" DROP DEFAULT;
ALTER TABLE "RoleTriggerGrant" DROP COLUMN "principal";
WITH ranked_role_trigger_grants AS (
SELECT
"id",
row_number() OVER (
PARTITION BY "projectId", "roleId", "principalType", "principalId"
ORDER BY "createdAt", "id"
) AS rn
FROM "RoleTriggerGrant"
WHERE "revokedAt" IS NULL
)
DELETE FROM "RoleTriggerGrant"
WHERE "id" IN (
SELECT "id" FROM ranked_role_trigger_grants WHERE rn > 1
);
CREATE INDEX "RoleTriggerGrant_principalType_principalId_revokedAt_idx" ON "RoleTriggerGrant"("principalType", "principalId", "revokedAt");
CREATE UNIQUE INDEX "RoleTriggerGrant_active_key" ON "RoleTriggerGrant"("projectId", "roleId", "principalType", "principalId") WHERE "revokedAt" IS NULL;
@@ -0,0 +1,9 @@
-- ADR-0017 refinement: Hub sessions are provider/role/model-bound.
-- Existing sessions predate role-aware binding; treat them as draft sessions.
ALTER TABLE "AgentSession" ADD COLUMN "roleId" TEXT NOT NULL DEFAULT 'draft';
ALTER TABLE "AgentSession" ALTER COLUMN "roleId" DROP DEFAULT;
CREATE INDEX "AgentSession_provider_roleId_model_idx" ON "AgentSession"("provider", "roleId", "model");
CREATE INDEX "AgentSession_projectId_provider_roleId_model_archivedAt_idx" ON "AgentSession"("projectId", "provider", "roleId", "model", "archivedAt");
DROP INDEX "AgentSession_provider_model_idx";
@@ -0,0 +1,6 @@
-- Record real provider-reported run cost for /cost reporting.
-- No backfill: pre-existing test runs remain cost-unrecorded by design.
ALTER TABLE "AgentRun" ADD COLUMN "costUsd" DECIMAL(18, 8);
ALTER TABLE "AgentRun" ADD COLUMN "costSource" TEXT;
CREATE INDEX "AgentRun_projectId_finishedAt_idx" ON "AgentRun"("projectId", "finishedAt");
@@ -0,0 +1,145 @@
-- ADR-0020: organization is the SaaS tenant root.
-- Backfill all existing data into one default organization so old databases
-- upgrade without a manual bootstrap step.
CREATE TYPE "OrganizationStatus" AS ENUM ('ACTIVE', 'SUSPENDED', 'ARCHIVED');
CREATE TYPE "OrganizationMemberRole" AS ENUM ('OWNER', 'ADMIN', 'MEMBER');
CREATE TABLE "Organization" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"name" TEXT NOT NULL,
"status" "OrganizationStatus" NOT NULL DEFAULT 'ACTIVE',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Organization_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "Organization_slug_key" ON "Organization"("slug");
CREATE INDEX "Organization_status_idx" ON "Organization"("status");
INSERT INTO "Organization" ("id", "slug", "name", "status")
VALUES ('org_default', 'legacy-default', 'Legacy Default Organization', 'ACTIVE');
CREATE TABLE "OrganizationMembership" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" "OrganizationMemberRole" NOT NULL DEFAULT 'MEMBER',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
CONSTRAINT "OrganizationMembership_pkey" PRIMARY KEY ("id")
);
INSERT INTO "OrganizationMembership" ("id", "organizationId", "userId", "role")
SELECT
'orgmem_default_' || md5("User"."id"),
'org_default',
"User"."id",
CASE
WHEN EXISTS (
SELECT 1 FROM "PlatformRoleAssignment"
WHERE "PlatformRoleAssignment"."userId" = "User"."id"
AND "PlatformRoleAssignment"."role" = 'ADMIN'
AND "PlatformRoleAssignment"."revokedAt" IS NULL
)
THEN 'OWNER'::"OrganizationMemberRole"
ELSE 'MEMBER'::"OrganizationMemberRole"
END
FROM "User";
CREATE INDEX "OrganizationMembership_organizationId_revokedAt_idx" ON "OrganizationMembership"("organizationId", "revokedAt");
CREATE INDEX "OrganizationMembership_userId_revokedAt_idx" ON "OrganizationMembership"("userId", "revokedAt");
CREATE UNIQUE INDEX "OrganizationMembership_organizationId_userId_revokedAt_key" ON "OrganizationMembership"("organizationId", "userId", "revokedAt");
CREATE UNIQUE INDEX "OrganizationMembership_active_key" ON "OrganizationMembership"("organizationId", "userId") WHERE "revokedAt" IS NULL;
ALTER TABLE "OrganizationMembership" ADD CONSTRAINT "OrganizationMembership_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "OrganizationMembership" ADD CONSTRAINT "OrganizationMembership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Project" ADD COLUMN "organizationId" TEXT;
UPDATE "Project" SET "organizationId" = 'org_default';
ALTER TABLE "Project" ALTER COLUMN "organizationId" SET NOT NULL;
ALTER TABLE "Project" ADD CONSTRAINT "Project_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE INDEX "Project_organizationId_archivedAt_idx" ON "Project"("organizationId", "archivedAt");
ALTER TABLE "Team" ADD COLUMN "organizationId" TEXT;
UPDATE "Team" SET "organizationId" = 'org_default';
ALTER TABLE "Team" ALTER COLUMN "organizationId" SET NOT NULL;
ALTER TABLE "Team" ADD CONSTRAINT "Team_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
DROP INDEX IF EXISTS "Team_slug_key";
CREATE INDEX "Team_organizationId_archivedAt_idx" ON "Team"("organizationId", "archivedAt");
CREATE INDEX "Team_organizationId_slug_archivedAt_idx" ON "Team"("organizationId", "slug", "archivedAt");
CREATE UNIQUE INDEX "Team_active_slug_key" ON "Team"("organizationId", "slug") WHERE "archivedAt" IS NULL;
CREATE TABLE "ExternalDirectoryConnection" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerTenantId" TEXT,
"source" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
CONSTRAINT "ExternalDirectoryConnection_pkey" PRIMARY KEY ("id")
);
INSERT INTO "ExternalDirectoryConnection" ("id", "organizationId", "provider", "source")
SELECT DISTINCT
'extconn_default_' || md5("source"),
'org_default',
'FEISHU',
"source"
FROM "ExternalPrincipalMembership";
CREATE UNIQUE INDEX "ExternalDirectoryConnection_organizationId_provider_source_key" ON "ExternalDirectoryConnection"("organizationId", "provider", "source");
CREATE INDEX "ExternalDirectoryConnection_organizationId_revokedAt_idx" ON "ExternalDirectoryConnection"("organizationId", "revokedAt");
CREATE INDEX "ExternalDirectoryConnection_provider_providerTenantId_idx" ON "ExternalDirectoryConnection"("provider", "providerTenantId");
ALTER TABLE "ExternalDirectoryConnection" ADD CONSTRAINT "ExternalDirectoryConnection_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
DROP INDEX IF EXISTS "ExternalPrincipalMembership_userId_principalType_principalId_source_revokedAt_key";
DROP INDEX IF EXISTS "ExternalPrincipalMembership_active_key";
DROP INDEX IF EXISTS "ExternalPrincipalMembership_source_syncedAt_idx";
ALTER TABLE "ExternalPrincipalMembership" ADD COLUMN "connectionId" TEXT;
UPDATE "ExternalPrincipalMembership"
SET "connectionId" = 'extconn_default_' || md5("source");
ALTER TABLE "ExternalPrincipalMembership" ALTER COLUMN "connectionId" SET NOT NULL;
ALTER TABLE "ExternalPrincipalMembership" DROP COLUMN "source";
ALTER TABLE "ExternalPrincipalMembership" ADD CONSTRAINT "ExternalPrincipalMembership_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "ExternalDirectoryConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE INDEX "ExternalPrincipalMembership_connectionId_revokedAt_idx" ON "ExternalPrincipalMembership"("connectionId", "revokedAt");
CREATE UNIQUE INDEX "ExternalPrincipalMembership_userId_principalType_principalId_connectionId_revokedAt_key" ON "ExternalPrincipalMembership"("userId", "principalType", "principalId", "connectionId", "revokedAt");
CREATE UNIQUE INDEX "ExternalPrincipalMembership_active_key" ON "ExternalPrincipalMembership"("userId", "principalType", "principalId", "connectionId") WHERE "revokedAt" IS NULL;
-- A principal should have one active role on a resource. Keep the strongest
-- grant and revoke lower active grants to preserve history without ambiguity.
DROP INDEX IF EXISTS "PermissionGrant_active_key";
WITH ranked_permission_grants AS (
SELECT
"id",
row_number() OVER (
PARTITION BY "resourceType", "resourceId", "principalType", "principalId"
ORDER BY
CASE "role"
WHEN 'MANAGE' THEN 3
WHEN 'EDIT' THEN 2
WHEN 'READ' THEN 1
END DESC,
"createdAt" ASC,
"id" ASC
) AS rn
FROM "PermissionGrant"
WHERE "revokedAt" IS NULL
)
UPDATE "PermissionGrant"
SET "revokedAt" = CURRENT_TIMESTAMP
WHERE "id" IN (
SELECT "id" FROM ranked_permission_grants WHERE rn > 1
);
CREATE UNIQUE INDEX "PermissionGrant_active_principal_key" ON "PermissionGrant"("resourceType", "resourceId", "principalType", "principalId") WHERE "revokedAt" IS NULL;
+299 -67
View File
@@ -3,9 +3,9 @@
// Aligns to spec/System (ADR-0001..0004, 0017). Key divergences from the
// legacy teaching-material-host-service schema, each deliberate:
//
// - No AgentSession.claudeSessionId. ADR-0017: session is provider/model-
// bound, not Claude-bound; `provider`+`model` replace it. Switching model
// = new session, so the unique constraint is on the session id alone.
// - AgentSession is provider/model-bound. Provider runtime cursors such as
// Claude SDK `session_id` live in `metadata`, so switching model still means
// a new Hub session while same-session runs can resume provider context.
// - PermissionRole enum = read/edit/manage (ADR-0004 capability lattice),
// distinct from platform UserRole (admin/teacher) — legacy conflated them.
// - ProjectGroupBinding is project→chat only (ADR-0001 1:1); legacy mixed
@@ -25,6 +25,54 @@ datasource db {
// --- Platform identity ---------------------------------------------------
/// ADR-0020: SaaS tenant root. Every project and team belongs to exactly one
/// organization; platform operator access remains outside project roles.
model Organization {
id String @id @default(cuid())
slug String @unique
name String
status OrganizationStatus @default(ACTIVE)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
memberships OrganizationMembership[]
projects Project[]
teams Team[]
externalDirectoryConnections ExternalDirectoryConnection[]
@@index([status])
}
enum OrganizationStatus {
ACTIVE
SUSPENDED
ARCHIVED
}
/// Org-scoped platform role. Distinct from project PermissionRole and from
/// global PlatformRoleAssignment, which is reserved for SaaS/platform control.
model OrganizationMembership {
id String @id @default(cuid())
organizationId String
userId String
role OrganizationMemberRole @default(MEMBER)
createdAt DateTime @default(now())
revokedAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([organizationId, userId, revokedAt])
@@index([organizationId, revokedAt])
@@index([userId, revokedAt])
}
enum OrganizationMemberRole {
OWNER
ADMIN
MEMBER
}
/// A Feishu user known to the Hub. principal sub-typology is OPEN (ADR-0004).
model User {
id String @id @default(cuid())
@@ -34,14 +82,17 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
platformRoles PlatformRoleAssignment[]
createdProjects Project[] @relation("projectCreator")
requestedRuns AgentRun[] @relation("runRequester")
heldLocks ProjectAgentLock[] @relation("lockHolder")
feishuBindings ProjectGroupBinding[] @relation("bindingCreator")
permissionGrants PermissionGrant[] @relation("grantCreator")
roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator")
auditEntries AuditEntry[] @relation("auditActor")
platformRoles PlatformRoleAssignment[]
organizationMemberships OrganizationMembership[]
createdProjects Project[] @relation("projectCreator")
requestedRuns AgentRun[] @relation("runRequester")
heldLocks ProjectAgentLock[] @relation("lockHolder")
feishuBindings ProjectGroupBinding[] @relation("bindingCreator")
teamMemberships TeamMembership[]
externalPrincipalMemberships ExternalPrincipalMembership[]
permissionGrants PermissionGrant[] @relation("grantCreator")
roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator")
auditEntries AuditEntry[] @relation("auditActor")
}
/// Platform-level role (admin/teacher). Distinct from ADR-0004 PermissionRole.
@@ -64,10 +115,116 @@ enum PlatformRole {
TEACHER
}
/// ADR-0019: typed principals for permission grants and actor resolution.
/// USER is identified by Feishu open_id; TEAM by Hub Team.id; Feishu external
/// principals by their Feishu ids.
enum PrincipalType {
USER
TEAM
FEISHU_CHAT
FEISHU_DEPARTMENT
FEISHU_USER_GROUP
APP
}
/// Hub-managed teacher team. A team is a first-class permission principal.
model Team {
id String @id @default(cuid())
organizationId String
slug String
name String
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
archivedAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
memberships TeamMembership[]
externalBindings TeamExternalBinding[]
@@index([organizationId, archivedAt])
@@index([organizationId, slug, archivedAt])
}
/// Direct Hub team membership. External Feishu groups can also map to teams via
/// TeamExternalBinding; both sources are resolved at authorization time.
model TeamMembership {
id String @id @default(cuid())
teamId String
userId String
createdAt DateTime @default(now())
revokedAt DateTime?
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([teamId, userId, revokedAt])
@@index([teamId, revokedAt])
@@index([userId, revokedAt])
}
/// Bind a Hub team to an external Feishu principal. When an actor resolves to
/// the external principal, the actor also resolves to this team.
model TeamExternalBinding {
id String @id @default(cuid())
teamId String
principalType PrincipalType
principalId String
createdAt DateTime @default(now())
revokedAt DateTime?
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
@@unique([teamId, principalType, principalId, revokedAt])
@@index([principalType, principalId, revokedAt])
@@index([teamId, revokedAt])
}
/// Locally synchronized Feishu external principal membership.
model ExternalDirectoryConnection {
id String @id @default(cuid())
organizationId String
provider String
providerTenantId String?
source String
status String @default("ACTIVE")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
revokedAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
memberships ExternalPrincipalMembership[]
@@unique([organizationId, provider, source])
@@index([organizationId, revokedAt])
@@index([provider, providerTenantId])
}
/// Locally synchronized Feishu external principal membership, scoped through
/// an organization-owned ExternalDirectoryConnection.
model ExternalPrincipalMembership {
id String @id @default(cuid())
userId String
connectionId String
principalType PrincipalType
principalId String
syncedAt DateTime @default(now())
revokedAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
connection ExternalDirectoryConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
@@unique([userId, principalType, principalId, connectionId, revokedAt])
@@index([userId, revokedAt])
@@index([connectionId, revokedAt])
@@index([principalType, principalId, revokedAt])
}
// --- Project & Feishu binding (ADR-0001) ---------------------------------
model Project {
id String @id @default(cuid())
organizationId String
name String
workspaceDir String
createdByUserId String?
@@ -75,15 +232,17 @@ model Project {
updatedAt DateTime @updatedAt
archivedAt DateTime?
createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
groupBinding ProjectGroupBinding?
agentSessions AgentSession[]
agentRuns AgentRun[]
agentLock ProjectAgentLock?
permissionGrants PermissionGrant[] @relation("projectGrants")
permissionSettings PermissionSettings[] @relation("projectSettings")
roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants")
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
groupBinding ProjectGroupBinding?
agentSessions AgentSession[]
agentRuns AgentRun[]
agentLock ProjectAgentLock?
roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants")
auditEntries AuditEntry[] @relation("projectAudit")
fileChanges AgentFileChange[] @relation("projectFileChanges")
@@index([organizationId, archivedAt])
@@index([archivedAt])
}
@@ -108,13 +267,15 @@ model ProjectGroupBinding {
// --- AgentRun, session, lock (ADR-0002, 0017) -----------------------------
/// ADR-0017: session is provider/model-bound. No claudeSessionId; `provider`
/// + `model` capture the binding. Same provider+model ⇒ reuse across runs
/// (ADR-0002); a switch ⇒ new session.
/// ADR-0017: session is provider/role/model-bound. `provider` + `roleId` +
/// `model` capture the binding; provider-specific runtime cursors live in
/// `metadata` (e.g. metadata.claudeSessionId). Same provider+role+model ⇒
/// reuse across runs (ADR-0002); a switch ⇒ new Hub session.
model AgentSession {
id String @id @default(cuid())
projectId String
provider String
roleId String
model String
title String?
metadata Json
@@ -122,11 +283,13 @@ model AgentSession {
updatedAt DateTime @updatedAt
archivedAt DateTime?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
runs AgentRun[]
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
runs AgentRun[]
messages AgentMessage[] @relation("sessionMessages")
@@index([projectId, archivedAt])
@@index([provider, model])
@@index([provider, roleId, model])
@@index([projectId, provider, roleId, model, archivedAt])
@@index([updatedAt])
}
@@ -149,30 +312,37 @@ enum AgentEntrypoint {
}
model AgentRun {
id String @id @default(cuid())
id String @id @default(cuid())
projectId String
sessionId String?
requestedByUserId String?
entrypoint AgentEntrypoint
status AgentRunStatus @default(ACTIVE)
status AgentRunStatus @default(ACTIVE)
prompt String
model String
provider String
summary String?
inputTokens Int?
outputTokens Int?
/// Provider/gateway-reported cost in USD. Null means this run has no trusted cost fact.
costUsd Decimal? @db.Decimal(18, 8)
/// Semantic source of costUsd, e.g. provider_reported. Not a pricing-estimate fallback.
costSource String?
metadata Json
error String?
startedAt DateTime @default(now())
startedAt DateTime @default(now())
finishedAt DateTime?
updatedAt DateTime @updatedAt
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
session AgentSession? @relation(fields: [sessionId], references: [id], onDelete: SetNull)
requestedBy User? @relation("runRequester", fields: [requestedByUserId], references: [id], onDelete: SetNull)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
session AgentSession? @relation(fields: [sessionId], references: [id], onDelete: SetNull)
requestedBy User? @relation("runRequester", fields: [requestedByUserId], references: [id], onDelete: SetNull)
projectLock ProjectAgentLock?
messages AgentMessage[] @relation("runMessages")
fileChanges AgentFileChange[] @relation("runFileChanges")
@@index([projectId, status])
@@index([projectId, finishedAt])
@@index([sessionId])
@@index([requestedByUserId])
@@index([updatedAt])
@@ -183,15 +353,16 @@ model AgentRun {
/// a run holds at most one lock. WellFormed (holder is non-terminal) is an
/// app-level invariant checked on read/write, not a DB constraint.
model ProjectAgentLock {
projectId String @id
runId String @unique
projectId String @id
runId String @unique
holderUserId String?
acquiredAt DateTime @default(now())
expiresAt DateTime?
acquiredAt DateTime @default(now())
heartbeatAt DateTime?
expiresAt DateTime?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
run AgentRun @relation(fields: [runId], references: [id], onDelete: Cascade)
holder User? @relation("lockHolder", fields: [holderUserId], references: [id], onDelete: SetNull)
run AgentRun @relation(fields: [runId], references: [id], onDelete: Cascade)
holder User? @relation("lockHolder", fields: [holderUserId], references: [id], onDelete: SetNull)
@@index([expiresAt])
}
@@ -217,34 +388,31 @@ enum PermissionResourceType {
}
/// ADR-0004 PermissionGrant: resource × principal × role.
/// `principal` is an opaque string (principal sub-typology OPEN, ADR-0004).
/// The compound unique covers "one active grant per (resource, principal, role)"
/// — revoked rows keep `revokedAt` set, so a re-grant after revocation is a new
/// row, not a conflict.
/// ADR-0019 replaces the old opaque `principal` with typed
/// `principalType/principalId` so user/team/Feishu principals compose through
/// the same authorization path.
model PermissionGrant {
id String @id @default(cuid())
id String @id @default(cuid())
resourceType PermissionResourceType
resourceId String
principal String
principalType PrincipalType
principalId String
role PermissionRole
createdByUserId String?
createdAt DateTime @default(now())
createdAt DateTime @default(now())
revokedAt DateTime?
project Project? @relation("projectGrants", fields: [resourceId], references: [id], onDelete: Cascade)
createdBy User? @relation("grantCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdBy User? @relation("grantCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
@@unique([resourceType, resourceId, principal, role, revokedAt])
@@index([resourceType, resourceId, revokedAt])
@@index([principal, revokedAt])
@@index([principalType, principalId, revokedAt])
}
/// ADR-0004 PermissionSettings: six policy knobs, values OPEN. Stored as one
/// opaque string column each; the enum/value-domain is decided by admin policy,
/// not by this schema. `resourceType`+`resourceId` identify the resource.
/// Related to Project only when resourceType=PROJECT (null otherwise).
/// opaque string column each. ADR-0019 pins `agentTrigger` values used by the
/// authorizer: ROLE, MANAGE_ONLY, DISABLED.
model PermissionSettings {
id String @id @default(cuid())
id String @id @default(cuid())
resourceType PermissionResourceType
resourceId String
externalShare String
@@ -253,59 +421,123 @@ model PermissionSettings {
collaboratorMgmt String
agentTrigger String
agentCancel String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project? @relation("projectSettings", fields: [resourceId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([resourceType, resourceId])
@@index([resourceType, resourceId])
}
/// Per-role agent trigger grant. Orthogonal to ADR-0004's PermissionGrant:
/// ADR-0004 decides "can trigger an agent at all" (triggerAgent capability,
/// edit+ role); this table decides "can trigger *which* agent role" (e.g.
/// /review vs /draft). Two gates in series — both must pass.
///
/// `roleId` is an opaque string matching RoleEntry.id (ADR-0017: roles are
/// data, not a code enum). `principal` mirrors ADR-0004's opaque principal
/// (sub-typology OPEN). A project-scoped row grants the role on that project;
/// data, not a code enum). ADR-0019 makes the principal typed. A project-scoped
/// row grants the role on that project;
/// the compound unique covers "one active grant per (project, principal, role)".
/// Revocation via `revokedAt`, same pattern as PermissionGrant.
model RoleTriggerGrant {
id String @id @default(cuid())
id String @id @default(cuid())
projectId String
roleId String
principal String
principalType PrincipalType
principalId String
createdByUserId String?
createdAt DateTime @default(now())
createdAt DateTime @default(now())
revokedAt DateTime?
project Project @relation("projectRoleGrants", fields: [projectId], references: [id], onDelete: Cascade)
createdBy User? @relation("roleGrantCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
@@unique([projectId, roleId, principal, revokedAt])
@@index([projectId, roleId, revokedAt])
@@index([principal, revokedAt])
@@index([principalType, principalId, revokedAt])
}
// --- Audit (ADR Audit, content OPEN) -------------------------------------
/// spec AuditEntry: minimal skeleton — one entry relates to a run. Event type,
/// actor, timestamp, details are OPEN. This table mirrors that: `runId` is the
/// PINNED relation; the rest is open-shaped columns.
model AuditEntry {
id String @id @default(cuid())
runId String?
projectId String?
actorUserId String?
action String
metadata Json
createdAt DateTime @default(now())
actor User? @relation("auditActor", fields: [actorUserId], references: [id], onDelete: SetNull)
actor User? @relation("auditActor", fields: [actorUserId], references: [id], onDelete: SetNull)
project Project? @relation("projectAudit", fields: [projectId], references: [id], onDelete: SetNull)
@@index([runId])
@@index([projectId, createdAt])
@@index([actorUserId])
@@index([createdAt])
}
// --- Feishu event dedup --------------------------------------------------
/// Idempotency receipt for inbound Feishu ws events. The lark ws client may
/// redeliver an event (at-least-once); without dedup a duplicate would spawn a
/// second AgentRun — the lock would refuse it, but a FAILED run row + a busy
/// reply would still leak. `eventId @unique` makes the second insert a no-op
/// signal: the handler checks existence before processing.
model FeishuEventReceipt {
id String @id @default(cuid())
eventId String @unique
eventType String
messageId String?
receivedAt DateTime @default(now())
@@index([eventType])
@@index([messageId])
@@index([receivedAt])
}
// --- Structured run history (ADR-0003 Memory) ----------------------------
/// Per-message record of the agent loop. The transcript JSONL file remains the
/// agent's own read-back memory (runner loads it to seed the next run); these
/// rows are the queryable/auditable projection for admin APIs. ADR-0003:
/// session messages ≠ Feishu group chat history.
model AgentMessage {
id String @id @default(cuid())
sessionId String
runId String?
role String
content String
attachments Json
createdAt DateTime @default(now())
session AgentSession @relation("sessionMessages", fields: [sessionId], references: [id], onDelete: Cascade)
run AgentRun? @relation("runMessages", fields: [runId], references: [id], onDelete: SetNull)
@@index([sessionId, createdAt])
@@index([runId])
}
/// File change captured during a run (writeFile/rename/delete in workspace).
/// before/after hash + diff for audit and admin review. Linked to the run
/// that produced it; the tool's execute closure records via a sink the runner
/// injects into ToolContext (A-3 file-change capture).
model AgentFileChange {
id String @id @default(cuid())
runId String
projectId String
path String
operation String
beforeHash String?
afterHash String?
diff String?
metadata Json
createdAt DateTime @default(now())
run AgentRun @relation("runFileChanges", fields: [runId], references: [id], onDelete: Cascade)
project Project @relation("projectFileChanges", fields: [projectId], references: [id], onDelete: Cascade)
@@index([runId])
@@index([projectId])
@@index([path])
}
+23
View File
@@ -0,0 +1,23 @@
export function formatRunCostLine(costUsd: number | undefined): string {
return costUsd === undefined
? "本次成本: 未记录"
: `本次成本: ${formatUsd(costUsd)}`;
}
export function formatInteger(value: number): string {
return new Intl.NumberFormat("en-US").format(value);
}
export function formatUsd(value: number): string {
const digits = value > 0 && value < 0.01 ? 4 : 2;
return `$${value.toFixed(digits)}`;
}
export function decimalToNumberOrNull(value: unknown): number | null {
if (value === null || value === undefined) return null;
const numberValue = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(numberValue)) {
throw new Error("invalid cost value");
}
return numberValue;
}
+5
View File
@@ -0,0 +1,5 @@
export {
createDefaultModelRegistry,
defaultSonnetModel,
type Env,
} from "../settings/runtime.js";
+12 -8
View File
@@ -17,10 +17,11 @@
*
* A role bundles everything that distinguishes one agent persona from another:
* model, system prompt, and the tool surface (files / cph / feishu / skills /
* mcps). Per-run tool whitelisting is enforced by {@link ToolRegistry.subset};
* the model never sees tools outside its role's whitelist, even if it tries to
* call them — security does not rely on the system prompt.
* mcps). Per-run tool whitelisting is enforced by the Claude SDK runner and
* the cph_hub MCP server builder; security does not rely on the system prompt.
*/
import { assertSupportedRoleTools } from "./roleTools.js";
export interface RoleEntry {
readonly id: string;
/** Human label for the teacher-side switcher UX. */
@@ -29,16 +30,16 @@ export interface RoleEntry {
readonly defaultModel: string | undefined;
/**
* System prompt seeding the agent's persona/instructions. Prepended to the
* run's messages only at session start (resume reads it from the transcript,
* see runner.ts). `undefined` ⇒ no system prompt (bare run).
* run's messages only at session start; Claude SDK resume restores later
* turns from the provider session. `undefined` ⇒ no system prompt (bare run).
*/
readonly systemPrompt: string | undefined;
readonly systemPrompt?: string | undefined;
/**
* Tool names this role may use (whitelist). `undefined` ⇒ the full registered
* set (back-compat / unrestricted roles). An empty array ⇒ no tools at all.
* Names not present in the registry are silently dropped at run setup.
* Invalid names fail fast when settings are loaded or the run is set up.
*/
readonly tools: readonly string[] | undefined;
readonly tools?: readonly string[] | undefined;
}
/** A model the admin has enabled for use by the Hub. */
@@ -72,6 +73,9 @@ export class InMemoryModelRegistry implements ModelRegistry {
constructor(models: readonly ModelEntry[], roles: readonly RoleEntry[] = []) {
this.models = models;
for (const role of roles) {
if (role.tools !== undefined) assertSupportedRoleTools(role.tools);
}
this.roles = new Map(roles.map((r) => [r.id, r]));
}
-18
View File
@@ -1,18 +0,0 @@
/**
* OpenRouter model factory.
*
* ADR-0017: the agent layer keeps role/model resolution separate from provider
* construction. A run carries a resolved model id, and switching model still
* means a new provider-bound session.
*/
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import type { LanguageModel } from "ai";
export function createModelFactory(): (modelId: string) => LanguageModel {
const provider = createOpenAICompatible({
name: "openrouter",
baseURL: process.env.OPENROUTER_BASE_URL ?? "https://openrouter.ai/api/v1",
headers: { Authorization: `Bearer ${process.env.OPENROUTER_API_KEY ?? ""}` },
});
return (modelId: string) => provider(modelId);
}
+110
View File
@@ -0,0 +1,110 @@
export const DEFAULT_CLAUDE_BUILT_IN_TOOLS = ["Read", "Write", "Bash", "Glob", "Grep"] as const;
export const CPH_HUB_MCP_SERVER_NAME = "cph_hub";
export const CPH_HUB_MCP_TOOL_IDS = ["send_file", "feishu_read_context", "request_approval"] as const;
export type CphHubMcpToolId = (typeof CPH_HUB_MCP_TOOL_IDS)[number];
export interface ClaudeSdkToolConfig {
readonly tools: readonly string[];
readonly allowedTools: readonly string[];
}
const ROLE_TOOL_TO_CLAUDE_BUILT_INS = new Map<string, readonly string[]>([
["read_file", ["Read"]],
["write_file", ["Write"]],
["list_files", ["Glob"]],
["search_files", ["Grep"]],
["bash", ["Bash"]],
// ADR-0017 replaced cph custom tools with Bash commands. Granting either
// cph role tool therefore exposes the SDK Bash tool; cph-only Bash narrowing
// would need a separate command-policy layer.
["cph_check", ["Bash"]],
["cph_build", ["Bash"]],
["Read", ["Read"]],
["Write", ["Write"]],
["Bash", ["Bash"]],
["Glob", ["Glob"]],
["Grep", ["Grep"]],
]);
const ROLE_TOOL_TO_CPH_HUB_MCP_TOOL = new Map<string, CphHubMcpToolId>([
["send_file", "send_file"],
["feishu_read_context", "feishu_read_context"],
["request_approval", "request_approval"],
["mcp__cph_hub__send_file", "send_file"],
["mcp__cph_hub__feishu_read_context", "feishu_read_context"],
["mcp__cph_hub__request_approval", "request_approval"],
]);
const SUPPORTED_ROLE_TOOLS = new Set([
...ROLE_TOOL_TO_CLAUDE_BUILT_INS.keys(),
...ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.keys(),
]);
export function claudeSdkToolConfigForRole(roleTools: readonly string[] | undefined): ClaudeSdkToolConfig {
if (roleTools === undefined) {
const mcpTools = CPH_HUB_MCP_TOOL_IDS.map(claudeMcpToolName);
return {
tools: [...DEFAULT_CLAUDE_BUILT_IN_TOOLS],
allowedTools: [...DEFAULT_CLAUDE_BUILT_IN_TOOLS, ...mcpTools],
};
}
const builtIns: string[] = [];
const allowedTools: string[] = [];
for (const roleTool of roleTools) {
assertSupportedRoleTool(roleTool);
for (const tool of ROLE_TOOL_TO_CLAUDE_BUILT_INS.get(roleTool) ?? []) {
pushUnique(builtIns, tool);
pushUnique(allowedTools, tool);
}
const mcpTool = ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.get(roleTool);
if (mcpTool !== undefined) {
pushUnique(allowedTools, claudeMcpToolName(mcpTool));
}
}
return { tools: builtIns, allowedTools };
}
export function cphHubMcpToolsForRole(roleTools: readonly string[] | undefined): readonly CphHubMcpToolId[] {
if (roleTools === undefined) return [...CPH_HUB_MCP_TOOL_IDS];
const tools: CphHubMcpToolId[] = [];
for (const roleTool of roleTools) {
assertSupportedRoleTool(roleTool);
const mcpTool = ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.get(roleTool);
if (mcpTool !== undefined) pushUnique(tools, mcpTool);
}
return tools;
}
export function roleToolsAllow(roleTools: readonly string[] | undefined, roleTool: string): boolean {
if (roleTools === undefined) return true;
for (const configured of roleTools) {
assertSupportedRoleTool(configured);
if (configured === roleTool) return true;
if (ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.get(configured) === roleTool) return true;
}
return false;
}
export function assertSupportedRoleTools(roleTools: readonly string[]): void {
for (const roleTool of roleTools) assertSupportedRoleTool(roleTool);
}
function claudeMcpToolName(tool: CphHubMcpToolId): string {
return `mcp__${CPH_HUB_MCP_SERVER_NAME}__${tool}`;
}
function assertSupportedRoleTool(roleTool: string): void {
if (!SUPPORTED_ROLE_TOOLS.has(roleTool)) {
throw new Error(`unknown role tool id: ${roleTool}`);
}
}
function pushUnique<T>(items: T[], item: T): void {
if (!items.includes(item)) items.push(item);
}
+282 -98
View File
@@ -1,140 +1,324 @@
/**
* Provider-bound agent runner.
* Claude Code SDK agent runner.
*
* The AI SDK owns tool-call dispatch and message folding. The Hub owns the
* per-run tool context, role/model selection, transcript persistence, and the
* ADR-0017 session boundary: a run is bound to one model. Switching model is a
* new run + new session; cross-run continuity is carried by project
* memory/anchors (ADR-0003), not by this runner.
* Uses `@anthropic-ai/claude-agent-sdk`'s `query()` as the agent loop. The SDK
* owns tool dispatch, context compaction, streaming — we just map its events
* to the Feishu streaming callback.
*
* Built-in tools (Read, Write, Bash, Glob, Grep) cover our entire tool surface:
* - read_file → Read
* - write_file → Write
* - list_files → Glob
* - cph_check / cph_build → Bash (`cph check .`, `cph build . --target student`)
* No custom tools needed — the SDK's Bash tool runs cph directly.
*
* ADR-0017 update: this drops provider-agnosticism. Claude Code SDK is
* Anthropic-specific. The tradeoff: we get compaction, tool approval, partial
* message streaming, and a battle-tested agent loop — capabilities we'd have
* to build ourselves with a generic provider.
*
* ADR-0018: the agent execution surface is bounded by the run's workspace.
* The SDK sandbox (bubblewrap on Linux, seatbelt on macOS) confines the Claude
* Code process and its subprocesses at the OS level — writes are confined to
* the workspace (`sandbox.filesystem.allowWrite`), sensitive host paths are
* denied reads (`denyRead`), and `failIfUnavailable` hard-fails if the sandbox
* can't start (never run unsandboxed). This upholds `AgentFileOp.Authorized`
* (ADR-0018 / `Spec.System.AgentSurface`) without re-implementing the
* `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox
* is the mechanism, the contract pins the invariant.
*/
import { generateText, stepCountIs, type LanguageModel, type ModelMessage } from "ai";
import type { ToolContext, ToolRegistry } from "./tools.js";
import { readTranscript, appendTranscript } from "./transcript.js";
import { homedir } from "node:os";
import { query, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKUserMessage, type SDKResultMessage, type SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
import type { PrismaClient } from "@prisma/client";
import { claudeSdkToolConfigForRole } from "./roleTools.js";
export type ModelFactory = (modelId: string) => LanguageModel;
/** What the runner needs about the project to seed and bound a run. */
export interface ProjectContext {
readonly projectId: string;
/** ADR-0001 binding - the chat this project is bound to. */
readonly boundChatId: string;
/** Absolute path to the curriculum engineering-file workspace. */
readonly workspaceDir: string;
}
export type StreamEvent =
| { readonly type: "text-delta"; readonly text: string }
| { readonly type: "thinking-delta"; readonly text: string }
| { readonly type: "tool-start"; readonly toolName: string; readonly toolUseId: string }
| { readonly type: "tool-end"; readonly toolName: string; readonly toolUseId: string; readonly input: unknown; readonly durationMs?: number }
| { readonly type: "tool-result"; readonly toolUseId: string; readonly toolName: string; readonly result: string; readonly isError: boolean; readonly durationMs?: number }
| { readonly type: "finish" };
export type StreamCallback = (event: StreamEvent) => void;
export interface RunRequest {
readonly prompt: string;
/** Resolved model id (already chosen by the caller per ADR-0017). */
readonly model: string;
readonly model: string | undefined;
readonly project: ProjectContext;
readonly systemPrompt: string | undefined;
/** Iteration cap before the run is returned as `length`. Default 25. */
readonly maxIterations?: number;
/** Per-role tool whitelist (ADR-0017). Undefined means all registered tools. */
readonly toolWhitelist?: readonly string[] | undefined;
/** Path to the session transcript JSONL. If set, prior messages are loaded
* from it before the run, and new messages are appended after. */
readonly transcriptPath?: string;
readonly providerEnv?: Record<string, string | undefined> | undefined;
readonly resumeSessionId?: string | undefined;
/**
* RoleEntry.tools from admin/runtime settings. Values are Hub role tool ids
* (for example read_file/cph_check/send_file), mapped to Claude SDK tool
* names at run setup. `undefined` means the full supported tool surface; []
* means no tools.
*/
readonly tools?: readonly string[] | undefined;
readonly mcpServers?: Record<string, McpServerConfig> | undefined;
readonly maxTurns?: number;
readonly runId: string;
readonly sessionId: string;
readonly prisma: PrismaClient;
readonly onStream?: StreamCallback;
/**
* Abort controller for the run. Aborting the signal interrupts the SDK query
* (it stops the agent loop and cleans up); the run resolves with status
* "interrupted". The caller owns the controller and triggers abort.
*/
readonly abortController?: AbortController | undefined;
}
export type RunStatus = "completed" | "length" | "failed";
export type RunStatus = "completed" | "length" | "failed" | "interrupted";
export interface RunResult {
readonly status: RunStatus;
/** Full transcript of the run, for audit (ADR Audit, content OPEN). */
readonly messages: readonly ModelMessage[];
readonly text: string;
readonly usage: { inputTokens: number; outputTokens: number };
/** Provider- or gateway-reported cost for this run, in USD. */
readonly costUsd?: number | undefined;
readonly numTurns: number;
readonly sdkSessionId?: string | undefined;
readonly error?: string;
}
const DEFAULT_MAX_ITERATIONS = 25;
const DEFAULT_MAX_TURNS = 25;
export async function runAgent(
modelFactory: ModelFactory,
tools: ToolRegistry,
req: RunRequest,
): Promise<RunResult> {
const ctx: ToolContext = {
runId: cryptoRandomId(),
projectId: req.project.projectId,
boundChatId: req.project.boundChatId,
workspaceDir: req.project.workspaceDir,
/**
* Host paths the agent may not read (ADR-0018 defense-in-depth). The sandbox
* confines writes to the workspace; these deny reads of credentials/secrets
* the process could otherwise see (read-only mounts are still readable). The
* workspace itself is never in this list — it's writable by `allowWrite`.
* Extend via `CPH_SANDBOX_EXTRA_DENY_READ` (colon-separated) for deployments
* with additional secret locations.
*/
const SENSITIVE_READ_PATHS: readonly string[] = (() => {
const home = homedir();
const base = [
"/etc",
"/root",
"/var/log",
"/proc",
"/sys",
`${home}/.ssh`,
`${home}/.aws`,
`${home}/.config/gcloud`,
`${home}/.gnupg`,
];
const extra = process.env["CPH_SANDBOX_EXTRA_DENY_READ"];
return extra !== undefined && extra !== ""
? [...base, ...extra.split(":").filter((p) => p !== "")]
: base;
})();
export async function runAgent(req: RunRequest): Promise<RunResult> {
const onStream = req.onStream;
const cap = req.maxTurns ?? DEFAULT_MAX_TURNS;
// Lock heartbeat: refresh on each step. Best-effort.
const heartbeat = async () => {
try {
await req.prisma.projectAgentLock.update({
where: { runId: req.runId },
data: { heartbeatAt: new Date() },
});
} catch { /* best-effort */ }
};
const aiTools = tools.build(ctx, req.toolWhitelist);
// Load prior session messages from transcript (continuity across @bot calls
// in the same session). ADR-0003: session messages != group chat history.
let messages: ModelMessage[] = req.transcriptPath !== undefined ? await readTranscript(req.transcriptPath) : [];
const loadedCount = messages.length;
if (req.systemPrompt !== undefined && messages.length === 0) {
// System prompt only at the very start of a session; on resume it's
// already in the transcript.
messages = [{ role: "system", content: req.systemPrompt }];
}
messages = [...messages, { role: "user", content: req.prompt }];
const cap = req.maxIterations ?? DEFAULT_MAX_ITERATIONS;
let fullText = "";
let usage = { inputTokens: 0, outputTokens: 0 };
let costUsd: number | undefined;
let numTurns = 0;
let sdkSessionId: string | undefined;
let error: string | undefined;
try {
const result = await generateText({
model: modelFactory(req.model),
messages,
allowSystemInMessages: true,
tools: aiTools,
stopWhen: stepCountIs(cap),
});
const allMessages: ModelMessage[] = [...messages, ...result.responseMessages];
await appendTranscriptIfSet(req, allMessages, loadedCount);
await persistAgentMessage(req, "user", req.prompt);
const toolConfig = claudeSdkToolConfigForRole(req.tools);
const stoppedByStepCap = result.finishReason === "tool-calls" && result.steps.length >= cap;
const status: RunStatus =
result.finishReason === "stop"
? "completed"
: result.finishReason === "length" || stoppedByStepCap
? "length"
: "failed";
const error =
status === "failed"
? `finishReason=${result.finishReason}`
: stoppedByStepCap
? `exceeded maxIterations=${cap}`
: undefined;
return {
status,
messages: allMessages,
usage: {
inputTokens: result.usage.inputTokens ?? 0,
outputTokens: result.usage.outputTokens ?? 0,
const options: Parameters<typeof query>[0]["options"] = {
cwd: req.project.workspaceDir,
tools: [...toolConfig.tools],
allowedTools: [...toolConfig.allowedTools],
maxTurns: cap,
includePartialMessages: true,
// ADR-0018: bypass interactive prompts (headless server); the sandbox
// below is the hard boundary, not the permission-prompt layer.
permissionMode: "bypassPermissions" as const,
allowDangerouslySkipPermissions: true,
// ADR-0018: OS-level sandbox confines the agent process + its Bash
// subprocesses. Writes confined to the workspace; sensitive host paths
// denied reads; network open. failIfUnavailable hard-fails if the
// sandbox can't start — never run unsandboxed.
sandbox: {
enabled: true,
failIfUnavailable: true,
autoAllowBashIfSandboxed: true,
filesystem: {
allowWrite: [req.project.workspaceDir],
denyRead: [...SENSITIVE_READ_PATHS],
},
},
};
if (req.systemPrompt !== undefined) options.systemPrompt = req.systemPrompt;
if (req.model !== undefined) options.model = req.model;
if (req.providerEnv !== undefined) options.env = { ...process.env, ...req.providerEnv };
if (req.resumeSessionId !== undefined) options.resume = req.resumeSessionId;
if (req.mcpServers !== undefined) options.mcpServers = req.mcpServers;
if (req.abortController !== undefined) options.abortController = req.abortController;
const conversation = query({
prompt: req.prompt,
options,
});
// Track tool start timestamps for duration calculation
const toolStartTimestamps = new Map<string, number>();
for await (const message of conversation) {
switch (message.type) {
case "stream_event": {
const evt = (message as SDKPartialAssistantMessage).event;
if (evt.type === "content_block_delta" && evt.delta.type === "text_delta") {
onStream?.({ type: "text-delta", text: evt.delta.text });
}
if (evt.type === "content_block_delta" && evt.delta.type === "thinking_delta") {
onStream?.({ type: "thinking-delta", text: evt.delta.thinking });
}
if (evt.type === "content_block_start" && evt.content_block.type === "tool_use") {
const toolUseId = evt.content_block.id;
toolStartTimestamps.set(toolUseId, Date.now());
onStream?.({ type: "tool-start", toolName: evt.content_block.name, toolUseId });
}
if (evt.type === "content_block_stop") {
// Tool end is attributed from the assistant message below.
}
break;
}
case "assistant": {
const msg = (message as SDKAssistantMessage).message;
await heartbeat();
numTurns++;
let assistantText = "";
for (const block of msg.content) {
if (block.type === "text" && typeof block.text === "string") {
fullText += block.text;
assistantText += block.text;
}
if (block.type === "tool_use") {
const durationMs = toolStartTimestamps.has(block.id)
? Date.now() - (toolStartTimestamps.get(block.id) ?? 0)
: undefined;
onStream?.({
type: "tool-end",
toolName: block.name,
toolUseId: block.id,
input: block.input,
...(durationMs !== undefined ? { durationMs } : {}),
});
}
if (block.type === "thinking" && typeof block.thinking === "string") {
// Complete thinking block from the assistant message.
// Stream deltas already delivered this; no separate event needed.
}
}
await persistAgentMessage(req, "assistant", assistantText);
if (msg.usage !== undefined) {
usage.inputTokens += msg.usage.input_tokens ?? 0;
usage.outputTokens += msg.usage.output_tokens ?? 0;
}
break;
}
case "user": {
// User messages carry tool_result blocks — what each tool returned.
const msg = (message as SDKUserMessage).message;
const content = msg.content;
if (!Array.isArray(content)) break;
for (const block of content) {
if (typeof block !== "object" || block === null || block.type !== "tool_result") continue;
// Narrowed to BetaToolResultBlockParam by discriminant + field presence
if (!("tool_use_id" in block)) continue;
const toolUseId = block.tool_use_id;
const isError = block.is_error === true;
const resultText = extractToolResultText(block.content);
const durationMs = toolStartTimestamps.get(toolUseId);
onStream?.({
type: "tool-result",
toolUseId,
toolName: toolUseId,
result: resultText,
isError,
...(durationMs !== undefined ? { durationMs: Date.now() - durationMs } : {}),
});
}
break;
}
case "result": {
const result = message as SDKResultMessage;
sdkSessionId = result.session_id;
costUsd = Number.isFinite(result.total_cost_usd) ? result.total_cost_usd : undefined;
if (result.subtype !== "success") {
error = `result_${result.subtype}`;
}
break;
}
}
}
onStream?.({ type: "finish" });
return {
status: error !== undefined ? "failed" : "completed",
text: fullText,
usage,
...(costUsd !== undefined ? { costUsd } : {}),
numTurns,
sdkSessionId,
...(error !== undefined ? { error } : {}),
};
} catch (e) {
await appendTranscriptIfSet(req, messages, loadedCount);
const aborted = req.abortController?.signal.aborted === true;
return {
status: "failed",
messages,
usage: { inputTokens: 0, outputTokens: 0 },
error: e instanceof Error ? e.message : String(e),
status: aborted ? "interrupted" : "failed",
text: fullText,
usage,
...(costUsd !== undefined ? { costUsd } : {}),
numTurns,
sdkSessionId,
...(aborted ? {} : { error: e instanceof Error ? e.message : String(e) }),
};
}
}
/// Append only the messages produced this run (skip the `loadedCount` prior
/// ones already in the file). Errors are swallowed - a transcript write
/// failure must not lose the run result.
async function appendTranscriptIfSet(req: RunRequest, messages: readonly ModelMessage[], loadedCount: number): Promise<void> {
if (req.transcriptPath === undefined) return;
const newMessages = messages.slice(loadedCount);
if (newMessages.length === 0) return;
async function persistAgentMessage(req: RunRequest, role: string, content: string): Promise<void> {
if (content === "") return;
try {
await appendTranscript(req.transcriptPath, newMessages);
await req.prisma.agentMessage.create({
data: {
sessionId: req.sessionId,
runId: req.runId,
role,
content,
attachments: [],
},
});
} catch {
// Swallow - transcript is best-effort persistence; the run result is
// returned regardless. ADR-0002 lock ensures no concurrent writer.
// Best-effort projection: a history write failure must not break the run.
}
}
function cryptoRandomId(): string {
return globalThis.crypto.randomUUID();
function extractToolResultText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
const parts: string[] = [];
for (const part of content) {
if (typeof part === "object" && part !== null && "type" in part && part.type === "text" && "text" in part && typeof part.text === "string") {
parts.push(part.text);
}
}
return parts.join("\n");
}
+5
View File
@@ -24,6 +24,11 @@ export interface ToolContext {
readonly boundChatId: string;
/** Absolute path to the project's curriculum engineering-file workspace. */
readonly workspaceDir: string;
/** Optional sink for file-change capture (A-3). The writeFile tool calls
* this with before/after hash when it modifies a file; the runner injects
* a collector that flushes to AgentFileChange at run end. Undefined when
* the runner doesn't wire capture (e.g. unit tests). */
readonly onFileChange?: (change: { path: string; operation: "CREATE" | "UPDATE" | "DELETE" | "RENAME"; beforeHash: string | null; afterHash: string | null; diff: string | null }) => void;
}
export type ToolDefinition = Tool;
+32
View File
@@ -8,6 +8,7 @@
* surface; the Hub agent is narrower than a general coding agent.
*/
import { readFile, writeFile, readdir, mkdir } from "node:fs/promises";
import { createHash } from "node:crypto";
import { join, resolve, relative, isAbsolute } from "node:path";
import { tool } from "ai";
import { z } from "zod";
@@ -67,8 +68,39 @@ export function writeFileTool(ctx: ToolContext): ToolDefinition {
execute: async (args): Promise<string> => {
try {
const full = confine(args.path, ctx.workspaceDir);
// A-3 file-change capture: compute before/after hashes around the write.
// A hashing failure must not block the write — skip the sink in that case.
let beforeHash: string | null = null;
let afterHash: string | null = null;
let operation: "CREATE" | "UPDATE" | null = null;
try {
let oldContent: string | null;
try {
oldContent = await readFile(full, "utf8");
} catch (e) {
if (e !== null && typeof e === "object" && "code" in e && e.code === "ENOENT") {
oldContent = null;
} else {
throw e;
}
}
afterHash = createHash("sha256").update(args.content).digest("hex");
beforeHash = oldContent === null ? null : createHash("sha256").update(oldContent).digest("hex");
if (beforeHash === null) {
operation = "CREATE";
} else if (beforeHash === afterHash) {
operation = null;
} else {
operation = "UPDATE";
}
} catch {
operation = null;
}
await mkdir(join(full, ".."), { recursive: true });
await writeFile(full, args.content, "utf8");
if (operation !== null && ctx.onFileChange !== undefined) {
ctx.onFileChange({ path: args.path, operation, beforeHash, afterHash, diff: null });
}
return JSON.stringify({ ok: true, path: args.path, bytes: args.content.length });
} catch (e) {
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
+59
View File
@@ -0,0 +1,59 @@
/**
* Audit write path — A-8. The `AuditEntry` table existed but had no writer;
* callers (trigger path, permission denials, slash commands) had no way to
* record what happened. This module is that single write seam.
*
* ADR reference: spec Audit. The skeleton pins only the `runId` relation;
* event type / actor / timestamp / details are content-OPEN. This helper
* exposes `action` (event type) + `actorUserId` (actor) + `metadata`
* (details) + `projectId` (locates audit points that don't have a run, e.g.
* permission denials) + `runId` (loose string ref, not a Prisma relation —
* see schema). `createdAt` is `@default(now())` so we don't pass it.
*
* Best-effort by design: an audit failure must never break the caller's path
* (a trigger failing because the audit log is down would be a spec breach —
* audit is observability, not control). Errors are swallowed here. There's no
* logger in this layer yet; a future logger can be threaded in without
* changing the signature.
*/
import type { PrismaClient, Prisma } from "@prisma/client";
export interface AuditInput {
readonly runId?: string | undefined;
readonly projectId?: string | undefined;
readonly actorUserId?: string | undefined;
readonly action: string;
readonly metadata: Record<string, unknown>;
}
/**
* Write one audit entry. Best-effort: any Prisma error is swallowed so the
* caller's path (trigger, permission gate, slash command) is unaffected.
*
* Optional fields are only included when defined — under
* `exactOptionalPropertyTypes`, assigning `undefined` to an optional key in a
* Prisma `create` payload is a type error (and would also write an explicit
* NULL rather than omit the column). Conditional assignment keeps the payload
* honest. The data object is typed as `AuditEntryUncheckedCreateInput` (the
* direct-FK variant) so it matches one branch of Prisma's `create` input
* union exactly — the relation-connect branch has no `projectId`/`runId`
* columns and would otherwise reject these fields.
*/
export async function writeAudit(
prisma: PrismaClient,
entry: AuditInput,
): Promise<void> {
const data: Prisma.AuditEntryUncheckedCreateInput = {
action: entry.action,
metadata: entry.metadata as Prisma.InputJsonValue,
};
if (entry.runId !== undefined) data.runId = entry.runId;
if (entry.projectId !== undefined) data.projectId = entry.projectId;
if (entry.actorUserId !== undefined) data.actorUserId = entry.actorUserId;
try {
await prisma.auditEntry.create({ data });
} catch {
// Swallow: audit is best-effort and must not break the caller's path.
}
}
+54
View File
@@ -0,0 +1,54 @@
export interface ApprovalResult {
readonly action: string;
readonly resolvedBy: string;
}
export interface PendingApproval {
readonly messageId: string;
readonly chatId: string;
readonly resolve: (result: ApprovalResult) => void;
}
export interface ApprovalManagerOptions {
readonly timeoutMs?: number;
}
export class ApprovalManager {
private readonly timeoutMs: number;
private pending = new Map<string, PendingApproval>();
constructor(options: ApprovalManagerOptions = {}) {
this.timeoutMs = options.timeoutMs ?? 5 * 60 * 1000;
}
register(messageId: string, chatId: string): Promise<ApprovalResult> {
return new Promise<ApprovalResult>((resolve, reject) => {
const timeout = setTimeout(() => {
this.pending.delete(messageId);
reject(new Error(`approval timed out after ${this.timeoutMs}ms`));
}, this.timeoutMs);
const unref = (timeout as { unref?: () => void }).unref;
if (unref !== undefined) unref.call(timeout);
this.pending.set(messageId, {
messageId,
chatId,
resolve: (result) => {
clearTimeout(timeout);
resolve(result);
},
});
});
}
resolve(messageId: string, action: string, resolvedBy: string): void {
const pending = this.pending.get(messageId);
if (pending === undefined) return;
this.pending.delete(messageId);
pending.resolve({ action, resolvedBy });
}
hasPending(messageId: string): boolean {
return this.pending.has(messageId);
}
}
+408
View File
@@ -0,0 +1,408 @@
/**
* Feishu interactive card builder for agent run output.
*
* Produces card JSON with:
* 1. A collapsible tool-use panel (tool steps with status, params, results)
* 2. A collapsible reasoning panel (thinking text)
* 3. The streaming/final answer text (markdown)
*
* Adapted from openclaw-lark's builder.ts, simplified for our
* message.patch-based approach (no CardKit 2.0 streaming_mode).
* Uses JSON 1.0 card format with `collapsible_panel` and `markdown` tags.
*/
import type { ToolUseTraceStep } from "./trace-store.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface FeishuCard {
readonly config: Record<string, unknown>;
readonly elements: ReadonlyArray<unknown>;
}
export type CardPhase = "thinking" | "streaming" | "complete";
const STEP_INDENT = "0px 0px 0px 22px";
const MAX_TEXT_LENGTH = 7500; // Feishu card content limit ~30k; leave room for panels
// ---------------------------------------------------------------------------
// Tool name → icon mapping
// ---------------------------------------------------------------------------
const TOOL_ICONS: Record<string, string> = {
read: "doc-filled",
write: "edit-filled",
bash: "terminal-filled",
glob: "search-filled",
grep: "search-filled",
edit: "edit-filled",
send_file: "send-filled",
request_approval: "thumb-up-filled",
feishu_read_context: "search-filled",
};
function toolIcon(toolName: string): string {
const normalized = toolName.toLowerCase().replace(/^mcp_/, "");
return TOOL_ICONS[normalized] ?? "tool-filled";
}
// ---------------------------------------------------------------------------
// Card builder
// ---------------------------------------------------------------------------
export function buildAgentCard(params: {
phase: CardPhase;
text: string;
reasoningText: string | undefined;
toolUseSteps: ToolUseTraceStep[];
toolUseElapsedMs: number | undefined;
isError: boolean | undefined;
interrupted: boolean | undefined;
runId: string | undefined;
}): Record<string, unknown> {
const { phase, text, reasoningText, toolUseSteps, toolUseElapsedMs, isError, interrupted } = params;
const elements: unknown[] = [];
// Tool-use panel (always present if there are steps)
if (toolUseSteps.length > 0) {
elements.push(buildToolUsePanel(toolUseSteps, toolUseElapsedMs, phase !== "complete"));
} else if (phase === "thinking" || (phase === "streaming" && text === "")) {
elements.push(buildPendingToolUsePanel());
}
// Reasoning panel
if (reasoningText !== undefined && reasoningText !== "") {
if (phase === "streaming" && text === "") {
// Still thinking: show reasoning inline
elements.push({
tag: "markdown",
content: `\u{1F4AD} \u601d\u8003\u4E2D...\n\n${reasoningText}`,
text_size: "notation",
});
} else if (phase === "complete") {
// Collapsed reasoning panel in complete state
elements.push(buildReasoningPanel(reasoningText));
}
}
// Main text content
if (text !== "") {
elements.push({
tag: "markdown",
content: truncateText(text, MAX_TEXT_LENGTH),
});
} else if (phase === "thinking" && toolUseSteps.length === 0 && (reasoningText === undefined || reasoningText === "")) {
elements.push({
tag: "markdown",
content: "\u{1F4AD} \u601d\u8003\u4E2D...",
text_size: "notation",
});
}
// Interrupt button while the run is live. The button carries the run id in
// its action value; the card action handler aborts the run. Feishu's native
// `confirm` dialog is the "are you sure" step — the action only fires after
// the user confirms, so no second round-trip is needed.
if (phase !== "complete" && params.runId !== undefined) {
elements.push(buildInterruptAction(params.runId));
}
// Footer for complete state
if (phase === "complete") {
const footerParts: string[] = [];
if (interrupted === true) footerParts.push("\u{1F6D1} \u5DF2\u4E2D\u65AD");
else if (isError === true) footerParts.push("\u274C \u5931\u8D25");
else footerParts.push("\u2705 \u5B8C\u6210");
if (toolUseElapsedMs !== undefined) {
footerParts.push(formatElapsed(toolUseElapsedMs));
}
elements.push({
tag: "div",
text: {
tag: "plain_text",
content: footerParts.join(" \u00B7 "),
text_size: "notation",
text_color: "grey",
},
});
}
return {
config: { wide_screen_mode: true, update_multi: true },
elements,
};
}
/** Action row with a single danger "interrupt" button for a live run. */
function buildInterruptAction(runId: string): unknown {
return {
tag: "action",
actions: [
{
tag: "button",
text: { tag: "plain_text", content: "\u{1F6D1} \u4E2D\u65AD" },
type: "danger",
value: { interrupt_run: runId },
confirm: {
title: { tag: "plain_text", content: "\u786E\u8BA4\u4E2D\u65AD" },
text: {
tag: "plain_text",
content: "\u786E\u5B9A\u8981\u4E2D\u65AD\u5F53\u524D\u8FD0\u884C\u5417\uFF1F\u4E2D\u65AD\u540E\u65E0\u6CD5\u6062\u590D\uFF0C\u5DF2\u4EA7\u51FA\u7684\u5185\u5BB9\u4F1A\u4FDD\u7559\u3002",
},
},
},
],
};
}
// ---------------------------------------------------------------------------
// Tool-use panel
// ---------------------------------------------------------------------------
function buildPendingToolUsePanel(): unknown {
return {
tag: "collapsible_panel",
expanded: false,
header: {
title: {
tag: "plain_text",
content: "\u{1F6E0}\uFE0F \u7B49\u5F85\u5DE5\u5177\u6267\u884C",
text_color: "grey",
text_size: "notation",
},
vertical_align: "center",
icon: {
tag: "standard_icon",
token: "down-small-ccm_outlined",
color: "grey",
size: "16px 16px",
},
icon_position: "right",
icon_expanded_angle: -180,
},
border: { color: "grey", corner_radius: "5px" },
vertical_spacing: "4px",
padding: "8px 8px 8px 8px",
elements: [],
};
}
function buildToolUsePanel(steps: ToolUseTraceStep[], elapsedMs?: number, expanded = false): unknown {
const parts: string[] = ["\u{1F6E0}\uFE0F \u5DE5\u5177\u6267\u884C"];
if (steps.length > 0) {
parts.push(`${steps.length} \u6B65`);
}
if (elapsedMs !== undefined && elapsedMs > 0) {
parts.push(`(${formatElapsed(elapsedMs)})`);
}
const stepElements = steps.flatMap((step) => buildToolStepElements(step));
return {
tag: "collapsible_panel",
expanded,
header: {
title: {
tag: "plain_text",
content: parts.join(" \u00B7 "),
text_color: "grey",
text_size: "notation",
},
vertical_align: "center",
icon: {
tag: "standard_icon",
token: "down-small-ccm_outlined",
color: "grey",
size: "16px 16px",
},
icon_position: "right",
icon_expanded_angle: -180,
},
border: { color: "grey", corner_radius: "5px" },
vertical_spacing: "4px",
padding: "8px 8px 8px 8px",
elements: stepElements,
};
}
function buildToolStepElements(step: ToolUseTraceStep): unknown[] {
const elements: unknown[] = [buildToolStepTitle(step)];
const detailElement = buildToolStepDetail(step);
if (detailElement !== undefined) {
elements.push(detailElement);
}
const outputElement = buildToolStepOutput(step);
if (outputElement !== undefined) {
elements.push(outputElement);
}
return elements;
}
function buildToolStepTitle(step: ToolUseTraceStep): unknown {
const status = formatStepStatus(step.status);
return {
tag: "div",
icon: {
tag: "standard_icon",
token: toolIcon(step.toolName),
color: "grey",
},
text: {
tag: "lark_md",
content: `**${escapeMarkdown(step.toolName)}** \u00B7 <font color='${status.color}'>${status.label}</font>`,
text_size: "notation",
},
};
}
function buildToolStepDetail(step: ToolUseTraceStep): unknown | undefined {
const detail = extractStepDetail(step);
if (detail === undefined) return undefined;
return {
tag: "div",
margin: STEP_INDENT,
text: {
tag: "plain_text",
content: detail,
text_color: "grey",
text_size: "notation",
},
};
}
function buildToolStepOutput(step: ToolUseTraceStep): unknown | undefined {
const content = buildStepOutputMarkdown(step);
if (content === undefined) return undefined;
return {
tag: "div",
margin: STEP_INDENT,
text: {
tag: "lark_md",
content,
text_size: "notation",
},
};
}
function buildStepOutputMarkdown(step: ToolUseTraceStep): string | undefined {
const lines: string[] = [];
if (step.error !== undefined) {
lines.push("**\u9519\u8BEF**");
lines.push(formatCodeBlock(step.error, "text"));
} else if (step.result !== undefined && step.result !== "") {
const resultStr = typeof step.result === "string" ? step.result : JSON.stringify(step.result, null, 2);
lines.push("**\u7ED3\u679C**");
lines.push(formatCodeBlock(resultStr, typeof step.result === "string" ? "text" : "json"));
}
if (lines.length === 0) return undefined;
return lines.join("\n");
}
function extractStepDetail(step: ToolUseTraceStep): string | undefined {
if (step.input === undefined || step.input === null) return undefined;
if (typeof step.input === "string") return truncateText(step.input, 200);
const input = step.input as Record<string, unknown>;
// Extract the most relevant field based on tool name
const toolLower = step.toolName.toLowerCase().replace(/^mcp_/, "");
if (toolLower === "bash" && typeof input.command === "string") {
return truncateText(input.command, 200);
}
if (toolLower === "read" && typeof input.file_path === "string") {
return input.file_path;
}
if (toolLower === "write" && typeof input.file_path === "string") {
return input.file_path;
}
if (toolLower === "edit" && typeof input.file_path === "string") {
return input.file_path;
}
if (toolLower === "glob" && typeof input.pattern === "string") {
return input.pattern;
}
if (toolLower === "grep" && typeof input.pattern === "string") {
return input.pattern;
}
// Fallback: show keys
const keys = Object.keys(input);
if (keys.length === 0) return undefined;
return keys.slice(0, 4).join(", ");
}
// ---------------------------------------------------------------------------
// Reasoning panel
// ---------------------------------------------------------------------------
function buildReasoningPanel(reasoningText: string): unknown {
return {
tag: "collapsible_panel",
expanded: false,
header: {
title: {
tag: "markdown",
content: "\u{1F4AD} \u601D\u8003",
text_color: "grey",
},
vertical_align: "center",
icon: {
tag: "standard_icon",
token: "down-small-ccm_outlined",
size: "16px 16px",
},
icon_position: "follow_text",
icon_expanded_angle: -180,
},
border: { color: "grey", corner_radius: "5px" },
vertical_spacing: "8px",
padding: "8px 8px 8px 8px",
elements: [
{
tag: "markdown",
content: truncateText(reasoningText, MAX_TEXT_LENGTH),
text_size: "notation",
},
],
};
}
// ---------------------------------------------------------------------------
// Formatting helpers
// ---------------------------------------------------------------------------
function formatStepStatus(status: ToolUseTraceStep["status"]): { label: string; color: string } {
switch (status) {
case "running":
return { label: "\u8FD0\u884C\u4E2D", color: "turquoise" };
case "error":
return { label: "\u5931\u8D25", color: "red" };
case "success":
default:
return { label: "\u6210\u529F", color: "green" };
}
}
function formatCodeBlock(content: string, language: "json" | "text"): string {
const normalized = content.replace(/\r\n/g, "\n").trim();
const fence = "`".repeat(Math.max(3, longestBacktickRun(normalized) + 1));
return `${fence}${language}\n${normalized}\n${fence}`;
}
function longestBacktickRun(value: string): number {
const matches = value.match(/`+/g);
if (matches === null) return 0;
return matches.reduce((max, run) => Math.max(max, run.length), 0);
}
function escapeMarkdown(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/([`*_{}[\]<>])/g, "\\$1");
}
function truncateText(value: string, maxLength: number): string {
return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`;
}
function formatElapsed(ms: number): string {
if (ms < 1000) return `${ms} ms`;
return `${(ms / 1000).toFixed(1)} s`;
}
+239
View File
@@ -0,0 +1,239 @@
/**
* Streaming card controller for agent runs.
*
* Replaces PatchableTextStream for the agent-run use case. Manages a single
* Feishu interactive card through the full run lifecycle:
*
* thinking → tool calls → streaming text → complete
*
* The card is rebuilt from scratch on each flush (throttled at 400ms) using
* the trace store for tool steps + accumulated reasoning/text. This is
* simpler than CardKit 2.0 element-level streaming and works with the
* existing message.patch API.
*
* Lifecycle:
* 1. appendText(delta) — streaming answer text
* 2. appendReasoning(delta) — streaming thinking text
* 3. onToolStart(name, id) — register a tool step in the trace store
* 4. onToolEnd(name, id, input, result?, error?) — complete a tool step
* 5. finish(finalText) — flush + transition to complete card
* 6. fail(errorText) — flush + transition to error card
*/
import type { FeishuRuntime, SendMessageOptions } from "../client.js";
import { sendCard, patchCard, sendText } from "../client.js";
import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "../textStream.js";
import {
startToolUseTraceRun,
clearToolUseTraceRun,
recordToolUseStart,
recordToolUseEnd,
getToolUseTraceSteps,
} from "./trace-store.js";
import { buildAgentCard, type CardPhase } from "./builder.js";
export interface StreamingCardSink {
readonly create: (card: Record<string, unknown>) => Promise<string | null>;
readonly patch: (messageId: string, card: Record<string, unknown>) => Promise<void>;
}
export interface StreamingCardOptions {
readonly runId: string;
readonly rt: FeishuRuntime;
readonly chatId: string;
readonly sendOptions?: SendMessageOptions | undefined;
readonly patchIntervalMs: number | undefined;
readonly maxMessageLength: number | undefined;
}
const DEFAULT_PATCH_INTERVAL_MS = 400;
export class StreamingAgentCard {
private currentMessageId: string | null = null;
private text = "";
private reasoningText = "";
private runStartedAt = Date.now();
private toolUseElapsedMs: number | undefined;
private flushChain: Promise<void> = Promise.resolve();
private flushScheduled = false;
private lastPatchAt = 0;
private interrupted = false;
private readonly runId: string;
private readonly rt: FeishuRuntime;
private readonly chatId: string;
private readonly sendOptions: SendMessageOptions | undefined;
private readonly patchIntervalMs: number;
private readonly maxMessageLength: number;
constructor(options: StreamingCardOptions) {
this.runId = options.runId;
this.rt = options.rt;
this.chatId = options.chatId;
this.sendOptions = options.sendOptions;
this.patchIntervalMs = options.patchIntervalMs ?? DEFAULT_PATCH_INTERVAL_MS;
this.maxMessageLength = options.maxMessageLength ?? DEFAULT_MAX_MESSAGE_LENGTH;
startToolUseTraceRun(this.runId);
}
// --- Public API ---
appendText(delta: string): void {
if (delta === "") return;
this.text += delta;
this.scheduleFlush();
}
appendReasoning(delta: string): void {
if (delta === "") return;
this.reasoningText += delta;
this.scheduleFlush();
}
onToolStart(toolName: string, toolUseId: string | undefined): void {
recordToolUseStart({ runId: this.runId, toolName, toolUseId, input: undefined });
this.scheduleFlush();
}
onToolEnd(params: {
toolName: string;
toolUseId: string | undefined;
input: unknown;
result: unknown;
error: string | undefined;
durationMs: number | undefined;
}): void {
recordToolUseEnd({ runId: this.runId, ...params });
this.scheduleFlush();
}
async finish(fallbackText: string, options: { readonly interrupted?: boolean; readonly footerText?: string | undefined } = {}): Promise<void> {
await this.flushChain;
this.interrupted = options.interrupted === true;
const footerText = options.footerText ?? "";
const fallbackWithFooter = appendFooter(fallbackText, footerText);
try {
let updated = true;
if (this.text.length > 0) {
this.text = appendFooter(this.text, footerText);
updated = await this.flushCard("complete", this.text);
} else if (this.currentMessageId === null && fallbackWithFooter.length > 0) {
// No streaming text was sent. If we never created a card, send one now.
this.text = fallbackWithFooter;
updated = await this.flushCard("complete", this.text);
} else if (this.currentMessageId !== null) {
// Patch the existing card with the final text.
updated = await this.flushCard("complete", fallbackWithFooter);
}
if (!updated && this.interrupted) {
await sendText(this.rt, this.chatId, "\u5DF2\u4E2D\u65AD\u5F53\u524D\u8FD0\u884C\u3002", this.sendOptions);
}
} finally {
clearToolUseTraceRun(this.runId);
}
}
async fail(errorText: string): Promise<void> {
await this.flushChain;
try {
this.text = errorText;
await this.flushCard("complete", errorText, true);
} finally {
clearToolUseTraceRun(this.runId);
}
}
// --- Internal flush logic ---
private scheduleFlush(): void {
if (this.flushScheduled) return;
this.flushScheduled = true;
this.flushChain = this.flushChain.then(async () => {
this.flushScheduled = false;
await this.flush();
});
}
private async flush(): Promise<boolean> {
if (this.currentMessageId === null) {
const updated = await this.flushCard(this.currentPhase(), this.text);
this.lastPatchAt = Date.now();
return updated;
}
const now = Date.now();
if (now - this.lastPatchAt < this.patchIntervalMs) return true;
this.lastPatchAt = now;
return this.flushCard(this.currentPhase(), this.text);
}
private async flushCard(phase: CardPhase, text: string, isError = false): Promise<boolean> {
const chunks = splitAtBoundary(text, this.maxMessageLength);
const firstChunk = chunks[0];
if (firstChunk === undefined) return true;
const toolUseSteps = getToolUseTraceSteps(this.runId);
const card = buildAgentCard({
phase,
text: firstChunk,
reasoningText: this.reasoningText || undefined,
toolUseSteps,
toolUseElapsedMs: this.toolUseElapsedMs,
isError,
interrupted: this.interrupted,
runId: this.runId,
});
if (this.currentMessageId === null) {
this.currentMessageId = await sendCard(this.rt, this.chatId, card, this.sendOptions);
let updated = this.currentMessageId !== null;
// Send overflow chunks as new messages (rare for agent output)
for (const chunk of chunks.slice(1)) {
const overflowCard = buildAgentCard({
phase,
text: chunk,
reasoningText: undefined,
toolUseSteps: [],
toolUseElapsedMs: undefined,
isError,
interrupted: this.interrupted,
runId: undefined,
});
const overflowMessageId = await sendCard(this.rt, this.chatId, overflowCard, this.sendOptions);
updated = updated && overflowMessageId !== null;
this.currentMessageId = overflowMessageId;
}
return updated;
} else {
let updated = await patchCard(this.rt, this.currentMessageId, card);
// For overflow, create new messages
for (const chunk of chunks.slice(1)) {
const overflowCard = buildAgentCard({
phase,
text: chunk,
reasoningText: undefined,
toolUseSteps: [],
toolUseElapsedMs: undefined,
isError,
interrupted: this.interrupted,
runId: undefined,
});
const overflowMessageId = await sendCard(this.rt, this.chatId, overflowCard, this.sendOptions);
updated = updated && overflowMessageId !== null;
this.currentMessageId = overflowMessageId;
}
return updated;
}
}
private currentPhase(): CardPhase {
if (this.text.length > 0) return "streaming";
if (this.reasoningText.length > 0) return "streaming";
return "thinking";
}
}
function appendFooter(text: string, footerText: string): string {
if (footerText === "") return text;
if (text === "") return footerText;
return `${text.trimEnd()}\n\n${footerText}`;
}
+225
View File
@@ -0,0 +1,225 @@
/**
* In-memory tool-use trace store, adapted from openclaw-lark's
* tool-use-trace-store. One trace per agent run; the card builder
* reads steps from here to render the tool-use panel.
*
* The store is per-run (not per-session) because each run has its
* own card lifecycle. Steps are capped to prevent unbounded memory.
*/
export interface ToolUseTraceStep {
readonly id: string;
readonly seq: number;
readonly toolName: string;
readonly toolUseId: string | undefined;
readonly input: unknown;
readonly result: unknown;
readonly error: string | undefined;
readonly durationMs: number | undefined;
readonly status: "running" | "success" | "error";
readonly startedAt: number;
readonly finishedAt: number | undefined;
}
interface RunTraceState {
nextSeq: number;
updatedAt: number;
steps: ToolUseTraceStep[];
}
const MAX_STEPS = 64;
const STEP_RUNNING_TIMEOUT_MS = 5 * 60 * 1000;
const RUN_TTL_MS = 30 * 60 * 1000;
const RESULT_LIMIT = 2048;
const GENERIC_LIMIT = 512;
const runs = new Map<string, RunTraceState>();
export function startToolUseTraceRun(runId: string): void {
pruneStaleRuns();
runs.set(runId, { nextSeq: 1, updatedAt: Date.now(), steps: [] });
}
export function clearToolUseTraceRun(runId: string): void {
runs.delete(runId);
}
export function recordToolUseStart(params: {
runId: string;
toolName: string;
toolUseId: string | undefined;
input: unknown;
}): void {
const state = runs.get(params.runId);
if (state === undefined) return;
if (state.steps.length >= MAX_STEPS) {
state.steps.splice(0, state.steps.length - MAX_STEPS + 1);
}
const now = Date.now();
state.steps.push({
id: `${state.nextSeq}`,
seq: state.nextSeq,
toolName: params.toolName,
toolUseId: params.toolUseId,
input: sanitizeTraceValue(params.input),
result: undefined,
error: undefined,
durationMs: undefined,
status: "running",
startedAt: now,
finishedAt: undefined,
});
state.nextSeq += 1;
state.updatedAt = now;
}
export function recordToolUseEnd(params: {
runId: string;
toolName: string;
toolUseId: string | undefined;
result: unknown;
error: string | undefined;
durationMs: number | undefined;
}): void {
const state = runs.get(params.runId);
if (state === undefined) return;
const now = Date.now();
const pendingIndex = findPendingStepIndex(state.steps, params.toolUseId, params.toolName);
if (pendingIndex >= 0) {
const step = state.steps[pendingIndex];
if (step !== undefined) {
(state.steps as Array<ToolUseTraceStep>)[pendingIndex] = {
...step,
status: params.error !== undefined ? "error" : "success",
result: sanitizeTraceValue(params.result),
error: params.error !== undefined ? truncate(params.error, 160) : undefined,
durationMs: params.durationMs,
finishedAt: now,
};
state.updatedAt = now;
return;
}
}
// No matching pending step — record as already-completed
state.steps.push({
id: `${state.nextSeq}`,
seq: state.nextSeq,
toolName: params.toolName,
toolUseId: params.toolUseId,
input: undefined,
result: sanitizeTraceValue(params.result),
error: params.error !== undefined ? truncate(params.error, 160) : undefined,
durationMs: params.durationMs,
status: params.error !== undefined ? "error" : "success",
startedAt: now,
finishedAt: now,
});
state.nextSeq += 1;
state.updatedAt = now;
}
export function getToolUseTraceSteps(runId: string): ToolUseTraceStep[] {
const state = runs.get(runId);
if (state === undefined) return [];
if (Date.now() - state.updatedAt > RUN_TTL_MS) {
runs.delete(runId);
return [];
}
const now = Date.now();
return state.steps.map((step) => {
if (step.status === "running" && now - step.startedAt > STEP_RUNNING_TIMEOUT_MS) {
return { ...step, status: "error" as const, error: "timed out", finishedAt: now };
}
return { ...step };
});
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
function findPendingStepIndex(
steps: ToolUseTraceStep[],
toolUseId: string | undefined,
toolName: string | undefined,
): number {
if (toolUseId !== undefined) {
for (let i = steps.length - 1; i >= 0; i -= 1) {
const step = steps[i];
if (step !== undefined && step.status === "running" && step.toolUseId === toolUseId) {
return i;
}
}
}
if (toolName !== undefined) {
for (let i = steps.length - 1; i >= 0; i -= 1) {
const step = steps[i];
if (step !== undefined && step.status === "running" && step.toolName === toolName) {
return i;
}
}
}
return -1;
}
function pruneStaleRuns(): void {
const now = Date.now();
for (const [runId, state] of runs) {
if (now - state.updatedAt > RUN_TTL_MS) {
runs.delete(runId);
}
}
}
function truncate(value: string, maxLength: number): string {
return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`;
}
/**
* Sanitize a tool input or result value for display.
* Truncates strings, redacts sensitive keys, limits depth.
*/
function sanitizeTraceValue(
value: unknown,
depth = 0,
context: { source: "input" | "result" | undefined; key: string | undefined } = { source: undefined, key: undefined },
): unknown {
if (value === null || value === undefined) return undefined;
if (typeof value === "string") {
const limit = context.source === "result" ? RESULT_LIMIT : GENERIC_LIMIT;
return truncate(value, limit);
}
if (typeof value === "number" || typeof value === "boolean") return value;
if (depth >= 2) return "[truncated]";
if (Array.isArray(value)) {
return value.slice(0, 8).map((item) => sanitizeTraceValue(item, depth + 1, { source: context.source, key: undefined }));
}
if (typeof value === "object") {
const input = value as Record<string, unknown>;
const output: Record<string, unknown> = {};
for (const [key, entryValue] of Object.entries(input).slice(0, 12)) {
output[key] = isSensitiveKey(key)
? "[redacted]"
: sanitizeTraceValue(entryValue, depth + 1, { source: context.source, key });
}
return output;
}
return truncate(String(value), 180);
}
const SENSITIVE_KEY_RE =
/token|secret|password|api[_-]?key|authorization|cookie|credential|bearer|session[_-]?id|client[_-]?secret|access[_-]?key/i;
function isSensitiveKey(key: string): boolean {
return SENSITIVE_KEY_RE.test(key);
}
+818 -89
View File
@@ -1,17 +1,16 @@
/**
* 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.
* Handles: ws event receiving (im.message.receive_v1 + card.action.trigger),
* message sending, streaming patch via interactive cards, emoji
* reactions, file download/upload, and the card action callback.
*/
import * as lark from "@larksuiteoapi/node-sdk";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { dirname } from "node:path";
import type { FastifyBaseLogger } from "fastify";
import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "./textStream.js";
import type { SenderNameCache } from "./senderCache.js";
export interface FeishuConfig {
readonly appId: string;
@@ -24,10 +23,55 @@ export interface FeishuRuntime {
readonly logger: FastifyBaseLogger;
}
/** Raw `im.message.receive_v1` event payload (subset we use). */
export interface OutboundPayload {
readonly msgType: string;
readonly content: string;
}
export interface SendMessageOptions {
readonly replyToMessageId?: string | undefined;
}
export async function withRetry<T>(
fn: () => Promise<T>,
options: {
maxAttempts?: number;
baseDelayMs?: number;
shouldRetry?: (error: unknown) => boolean;
} = {},
): Promise<T> {
const maxAttempts = Math.max(1, options.maxAttempts ?? 3);
const baseDelayMs = options.baseDelayMs ?? 1000;
const shouldRetry = options.shouldRetry ?? (() => true);
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
return await fn();
} catch (error) {
if (attempt === maxAttempts || !shouldRetry(error)) {
throw error;
}
const delay = baseDelayMs * 2 ** (attempt - 1);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw new Error("withRetry reached an unreachable state");
}
/** Raw `im.message.receive_v1` event payload. */
export interface MessageReceiveEvent {
/** Feishu's SDK dispatcher flattens v2 headers onto the top-level payload. */
readonly event_id?: string;
readonly event_type?: string;
readonly header?: { readonly event_id?: string; readonly event_type?: string };
readonly message: {
readonly message_id: string;
readonly root_id?: string;
readonly parent_id?: string;
readonly thread_id?: string;
readonly create_time?: string;
readonly update_time?: string;
readonly chat_id: string;
readonly chat_type: string;
readonly message_type: string;
@@ -44,86 +88,771 @@ export interface MessageReceiveEvent {
};
}
/** 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> } } };
/** Card action trigger event (button clicks, select changes). */
export interface CardActionEvent {
readonly operator: { readonly open_id?: string };
readonly action: {
readonly value?: unknown;
readonly tag?: string;
readonly option?: string;
};
await client.im.v1.message.create({
params: { receive_id_type: "chat_id" },
data: {
receive_id: chatId,
msg_type: "text",
content: JSON.stringify({ text }),
},
});
readonly context?: { readonly open_message_id?: string; readonly open_chat_id?: string };
readonly token?: string;
}
/** Send an interactive card to a chat. `card` is the lark card schema object. */
interface ContactBasicUser {
readonly name?: string | undefined;
readonly i18n_name?: {
readonly zh_cn?: string | undefined;
readonly en_us?: string | undefined;
readonly ja_jp?: string | undefined;
} | undefined;
}
type ContactUserBasicBatchResponse = {
readonly data?: {
readonly users?: readonly ContactBasicUser[] | undefined;
} | undefined;
} | null;
// --- Message helpers ---
export function buildOutboundPayload(content: string): OutboundPayload {
if (containsMarkdownTable(content)) {
return { msgType: "text", content: JSON.stringify({ text: content }) };
}
if (containsMarkdownSyntax(content)) {
return {
msgType: "post",
content: JSON.stringify({ zh_cn: { content: _buildMarkdownPostRows(content) } }),
};
}
return { msgType: "text", content: JSON.stringify({ text: content }) };
}
export function _buildMarkdownPostRows(content: string): unknown[][] {
if (!content.includes("```")) {
return [[{ tag: "md", text: content }]];
}
const rows: unknown[][] = [];
const codeBlockPattern = /```[\s\S]*?```/g;
let cursor = 0;
let match: RegExpExecArray | null;
while ((match = codeBlockPattern.exec(content)) !== null) {
if (match.index > cursor) {
rows.push([{ tag: "md", text: content.slice(cursor, match.index) }]);
}
rows.push([{ tag: "md", text: match[0] }]);
cursor = match.index + match[0].length;
}
if (rows.length === 0) {
return [[{ tag: "md", text: content }]];
}
if (cursor < content.length) {
rows.push([{ tag: "md", text: content.slice(cursor) }]);
}
return rows;
}
export function _stripMarkdownToPlainText(text: string): string {
return text
.replace(/```[^\n`]*\n?([\s\S]*?)```/g, "$1")
.replace(/`([^`\n]+)`/g, "$1")
.replace(/!\[([^\]\n]*)\]\([^)]+\)/g, "$1")
.replace(/\[([^\]\n]+)\]\([^)]+\)/g, "$1")
.replace(/^ {0,3}#{1,6}\s+/gm, "")
.replace(/^ {0,3}>\s?/gm, "")
.replace(/^(\s*)[-*+]\s+/gm, "$1")
.replace(/^(\s*)\d+[.)]\s+/gm, "$1")
.replace(/\*\*([^*\n]+)\*\*/g, "$1")
.replace(/__([^_\n]+)__/g, "$1")
.replace(/~~([^~\n]+)~~/g, "$1")
.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1$2")
.replace(/(^|[^_])_([^_\n]+)_/g, "$1$2");
}
export function parsePostMessage(content: string): { text: string; imageKeys: string[]; fileKeys: string[] } {
const parsed = parseJsonObject(content);
const root = parsed === null ? null : postRoot(parsed);
const locale = root === null ? null : selectPostLocale(root);
if (locale === null) {
return { text: "", imageKeys: [], fileKeys: [] };
}
const imageKeys: string[] = [];
const fileKeys: string[] = [];
const rows = Array.isArray(locale["content"]) ? locale["content"] : [];
const body = rows
.map((row) => {
const elements = Array.isArray(row) ? row : [row];
return elements.map((element) => renderPostElement(element, imageKeys, fileKeys)).join("");
})
.join("\n");
const title = stringField(locale, "title");
const text = title === undefined || title === "" ? body : body === "" ? `# ${title}` : `# ${title}\n\n${body}`;
return { text, imageKeys, fileKeys };
}
/** Send a text message using the best Feishu message type for the content. */
export async function sendTextMessage(
rt: FeishuRuntime,
chatId: string,
text: string,
options?: SendMessageOptions,
): Promise<string | null> {
const payload = buildOutboundPayload(text);
try {
return await sendMessagePayload(rt, chatId, payload, options);
} catch (e) {
if (payload.msgType === "post" && isPostContentFormatError(e)) {
try {
return await sendMessagePayload(
rt,
chatId,
{ msgType: "text", content: JSON.stringify({ text: _stripMarkdownToPlainText(text) }) },
options,
);
} catch {
return null;
}
}
return null;
}
}
/** Send long text as multiple Feishu messages. Returns the last successful message_id. */
export async function sendLongText(
rt: FeishuRuntime,
chatId: string,
text: string,
options?: SendMessageOptions,
): Promise<string | null> {
let lastMessageId: string | null = null;
for (const chunk of splitAtBoundary(text, DEFAULT_MAX_MESSAGE_LENGTH)) {
const messageId = await sendTextMessage(rt, chatId, chunk, options);
if (messageId !== null) {
lastMessageId = messageId;
}
}
return lastMessageId;
}
/** Send a plain text message (fire-and-forget). */
export async function sendText(
rt: FeishuRuntime,
chatId: string,
text: string,
options?: SendMessageOptions,
): Promise<void> {
await sendTextMessage(rt, chatId, text, options);
}
/** Send an interactive card message (always patchable). Used for streaming. */
export async function sendInteractiveCardMessage(
rt: FeishuRuntime,
chatId: string,
text: string,
options?: SendMessageOptions,
): Promise<string | null> {
const payload = buildOutboundPayload(text);
const card = buildInteractiveCardFromOutboundPayload(payload);
try {
return await sendMessagePayload(
rt,
chatId,
{ msgType: "interactive", content: JSON.stringify(card) },
options,
);
} catch {
return null;
}
}
/** Patch a text-as-card message in-place (streaming updates). */
export async function patchTextMessage(rt: FeishuRuntime, messageId: string, text: string): Promise<void> {
const payload = buildOutboundPayload(text);
const card = buildInteractiveCardFromOutboundPayload(payload);
const client = rt.client as unknown as {
im: { v1: { message: { patch: (p: unknown) => Promise<unknown> } } };
};
try {
await client.im.v1.message.patch({
path: { message_id: messageId },
params: { msg_type: "interactive" },
data: { content: JSON.stringify(card) },
});
} catch (e) {
rt.logger.warn({ messageId, err: e instanceof Error ? e.message : String(e) }, "patchTextMessage failed");
}
}
/** Send a raw interactive card (arbitrary JSON). Returns message_id on success. */
export async function sendCard(
rt: FeishuRuntime,
chatId: string,
card: unknown,
card: Record<string, unknown>,
options?: SendMessageOptions,
): Promise<string | null> {
const client = rt.client as unknown as {
im: { v1: { message: { create: (p: unknown) => Promise<{ data?: { message_id?: string } }> } } };
};
const res = await client.im.v1.message.create({
params: { receive_id_type: "chat_id" },
data: {
receive_id: chatId,
msg_type: "interactive",
content: JSON.stringify(card),
},
});
return res.data?.message_id ?? null;
try {
return await sendMessagePayload(
rt,
chatId,
{ msgType: "interactive", content: JSON.stringify(card) },
options,
);
} catch {
return null;
}
}
/** Build a run-status card: status text + model selector + cancel button. */
export function buildRunStatusCard(opts: {
readonly status: string;
readonly model: string;
readonly models: readonly { readonly id: string; readonly label: string }[];
readonly runId: string;
}): unknown {
return {
/** Patch an interactive card in-place with raw JSON. */
export async function patchCard(rt: FeishuRuntime, messageId: string, card: Record<string, unknown>): Promise<boolean> {
const client = rt.client as unknown as {
im: { v1: { message: { patch: (p: unknown) => Promise<unknown> } } };
};
try {
await client.im.v1.message.patch({
path: { message_id: messageId },
params: { msg_type: "interactive" },
data: { content: JSON.stringify(card) },
});
return true;
} catch (e) {
rt.logger.warn({ messageId, err: e instanceof Error ? e.message : String(e) }, "patchCard failed");
return false;
}
}
/** Send an approval card with buttons. Returns message_id on success. */
export async function sendApprovalCard(
rt: FeishuRuntime,
chatId: string,
title: string,
body: string,
buttons: ReadonlyArray<{ label: string; value: string; style?: "primary" | "default" | "danger" }>,
options?: SendMessageOptions,
): Promise<string | null> {
const card = {
config: { wide_screen_mode: true },
header: {
title: { tag: "plain_text", content: `@Claude · ${opts.status}` },
template: opts.status === "处理完成" ? "green" : opts.status === "处理出错" ? "red" : "blue",
},
header: { title: { content: title, tag: "plain_text" }, template: "orange" },
elements: [
{ tag: "div", text: { tag: "lark_md", content: `**model:** ${opts.model}` } },
{ tag: "markdown", content: body },
{
tag: "action",
actions: [
{
tag: "select",
placeholder: { tag: "plain_text", content: "切换 model" },
options: opts.models.map((m) => ({ text: { tag: "plain_text", content: m.label }, value: m.id })),
value: opts.model,
},
{
tag: "button",
text: { tag: "plain_text", content: "取消" },
type: "danger",
value: JSON.stringify({ action: "cancel", runId: opts.runId }),
},
],
actions: buttons.map((button) => ({
tag: "button",
text: { tag: "plain_text", content: button.label },
type: button.style ?? "default",
value: { approval_action: button.value },
})),
},
],
};
try {
return await sendMessagePayload(
rt,
chatId,
{ msgType: "interactive", content: JSON.stringify(card) },
options,
);
} catch (e) {
rt.logger.warn({ chatId, err: e instanceof Error ? e.message : String(e) }, "sendApprovalCard failed");
return null;
}
}
/**
* 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.
*/
/** Build a lark Client (HTTP API surface). Used before the ws starts, so tools
* that need to call Feishu APIs can hold it from server boot. */
/** Update a card to show it has been resolved. */
export async function resolveCard(
rt: FeishuRuntime,
messageId: string,
icon: string,
label: string,
template: "green" | "red",
resolvedBy: string,
): Promise<void> {
const card = {
config: { wide_screen_mode: true },
header: { title: { content: `${icon} ${label}`, tag: "plain_text" }, template },
elements: [{ tag: "markdown", content: `${icon} **${label}** by ${resolvedBy}` }],
};
const client = rt.client as unknown as {
im: { v1: { message: { patch: (p: unknown) => Promise<unknown> } } };
};
try {
await client.im.v1.message.patch({
path: { message_id: messageId },
params: { msg_type: "interactive" },
data: { content: JSON.stringify(card) },
});
} catch (e) {
rt.logger.warn({ messageId, err: e instanceof Error ? e.message : String(e) }, "resolveCard failed");
}
}
/** React to a message with an emoji. */
export async function reactToMessage(rt: FeishuRuntime, messageId: string, emoji: string): Promise<void> {
try {
await rt.client.request({
method: "POST",
url: `/open-apis/im/v1/messages/${messageId}/reactions`,
data: { reaction_type: { emoji_type: emoji } },
});
} catch (e) {
rt.logger.warn({ messageId, err: e instanceof Error ? e.message : String(e) }, "reactToMessage failed");
}
}
/** Add a reaction to a message, returning the reaction_id (needed for later removal). */
export async function addReaction(rt: FeishuRuntime, messageId: string, emojiType: string): Promise<string | null> {
try {
const res = await rt.client.request({
method: "POST",
url: `/open-apis/im/v1/messages/${messageId}/reactions`,
data: { reaction_type: { emoji_type: emojiType } },
}) as ReactionCreateResponse;
const reactionId = reactionIdFromResponse(res);
if (reactionId === null) {
rt.logger.warn({ messageId, emojiType }, "addReaction failed: response missing reaction_id");
}
return reactionId;
} catch (e) {
rt.logger.warn({ messageId, emojiType, err: e instanceof Error ? e.message : String(e) }, "addReaction failed");
return null;
}
}
/** Remove a previously added reaction by its reaction_id. */
export async function removeReaction(rt: FeishuRuntime, messageId: string, reactionId: string): Promise<boolean> {
try {
await rt.client.request({
method: "DELETE",
url: `/open-apis/im/v1/messages/${messageId}/reactions/${reactionId}`,
});
return true;
} catch (e) {
rt.logger.warn({ messageId, reactionId, err: e instanceof Error ? e.message : String(e) }, "removeReaction failed");
return false;
}
}
/** Resolve a Feishu sender display name from open_id, optionally using a cache. */
export async function resolveSenderName(
rt: FeishuRuntime,
openId: string,
cache?: SenderNameCache,
): Promise<string | null> {
if (openId === "") return null;
const fetchName = async (id: string): Promise<string | null> => {
try {
const res = await rt.client.contact.v3.user.basicBatch({
params: { user_id_type: "open_id" },
data: { user_ids: [id] },
}) as ContactUserBasicBatchResponse;
const user = res?.data?.users?.[0];
return user === undefined ? null : contactBasicUserName(user);
} catch (e) {
rt.logger.warn({ openId: id, err: e instanceof Error ? e.message : String(e) }, "resolveSenderName failed");
return null;
}
};
if (cache !== undefined) {
return cache.getOrFetch(openId, fetchName);
}
return fetchName(openId);
}
function contactBasicUserName(user: ContactBasicUser): string | null {
const candidates = [
user.name,
user.i18n_name?.zh_cn,
user.i18n_name?.en_us,
user.i18n_name?.ja_jp,
];
for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.trim() !== "") return candidate.trim();
}
return null;
}
// --- File helpers ---
/** Download a file/image from a Feishu message to a local path. */
export async function downloadMessageFile(
rt: FeishuRuntime,
messageId: string,
fileKey: string,
savePath: string,
): Promise<void> {
await mkdir(dirname(savePath), { recursive: true });
try {
const res = await withRetry(async () => {
return rt.client.request({
method: "GET",
url: `/open-apis/im/v1/messages/${messageId}/resources/${fileKey}`,
params: { type: "file" },
responseType: "arraybuffer",
}) as Promise<{ data: Buffer }>;
});
await writeFile(savePath, res.data);
} catch (e) {
rt.logger.warn({ messageId, fileKey, err: e instanceof Error ? e.message : String(e) }, "downloadMessageFile failed");
}
}
/** Upload and send a file to a chat. */
export async function sendFile(
rt: FeishuRuntime,
chatId: string,
filePath: string,
fileName: string,
options?: SendMessageOptions,
): Promise<string | null> {
const client = rt.client as unknown as {
im: { v1: {
file: { create: (p: unknown) => Promise<FileCreateResponse> }
} };
};
try {
const buf = await readFile(filePath);
const ext = fileName.split(".").pop() ?? "";
const fileType = ext === "pdf" ? "pdf" : ext === "doc" ? "doc" : ext === "xls" ? "xls" : ext === "ppt" ? "ppt" : "stream";
const uploadRes = await withRetry(async () =>
client.im.v1.file.create({ data: { file_type: fileType, file_name: fileName, file: buf } }),
);
const fileKey = fileKeyFromResponse(uploadRes);
if (fileKey === undefined) {
rt.logger.warn({ chatId, filePath }, "sendFile failed: upload response missing file_key");
return null;
}
const messageId = await withRetry(async () =>
sendMessagePayload(rt, chatId, { msgType: "file", content: JSON.stringify({ file_key: fileKey }) }, options),
);
if (messageId === null) {
rt.logger.warn({ chatId, filePath }, "sendFile failed: message response missing message_id");
}
return messageId;
} catch (e) {
rt.logger.warn({ chatId, filePath, err: e instanceof Error ? e.message : String(e) }, "sendFile failed");
return null;
}
}
async function sendMessagePayload(
rt: FeishuRuntime,
chatId: string,
payload: OutboundPayload,
options?: SendMessageOptions,
): Promise<string | null> {
const client = rt.client as unknown as {
im: { v1: { message: {
create: (p: unknown) => Promise<MessageCreateResponse>;
reply: (p: unknown) => Promise<MessageCreateResponse>;
} } };
};
const replyToMessageId = normalizeReplyMessageId(options?.replyToMessageId);
if (replyToMessageId !== null) {
const res = await client.im.v1.message.reply({
path: { message_id: replyToMessageId },
data: {
msg_type: payload.msgType,
content: payload.content,
},
});
return messageIdFromResponse(res);
}
const res = await client.im.v1.message.create({
params: { receive_id_type: "chat_id" },
data: { receive_id: chatId, msg_type: payload.msgType, content: payload.content },
});
return messageIdFromResponse(res);
}
function normalizeReplyMessageId(messageId: string | undefined): string | null {
return messageId === undefined || messageId === "" ? null : messageId;
}
type MessageCreateResponse = {
readonly message_id?: string | undefined;
readonly data?: { readonly message_id?: string | undefined } | undefined;
} | null;
type FileCreateResponse = {
readonly file_key?: string | undefined;
readonly data?: { readonly file_key?: string | undefined } | undefined;
} | null;
type ReactionCreateResponse = {
readonly reaction_id?: string | undefined;
readonly data?: { readonly reaction_id?: string | undefined } | undefined;
} | null;
function messageIdFromResponse(res: MessageCreateResponse): string | null {
return res?.data?.message_id ?? res?.message_id ?? null;
}
function fileKeyFromResponse(res: FileCreateResponse): string | undefined {
return res?.file_key ?? res?.data?.file_key;
}
function reactionIdFromResponse(res: ReactionCreateResponse): string | null {
return res?.data?.reaction_id ?? res?.reaction_id ?? null;
}
function parseJsonObject(content: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(content) as unknown;
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
function postRoot(parsed: Record<string, unknown>): Record<string, unknown> {
const post = parsed["post"];
return isRecord(post) ? post : parsed;
}
function selectPostLocale(root: Record<string, unknown>): Record<string, unknown> | null {
if (Array.isArray(root["content"])) return root;
for (const locale of ["zh_cn", "en_us"]) {
const candidate = root[locale];
if (isPostLocale(candidate)) return candidate;
}
for (const candidate of Object.values(root)) {
if (isPostLocale(candidate)) return candidate;
}
return null;
}
function isPostLocale(value: unknown): value is Record<string, unknown> {
return isRecord(value) && (Array.isArray(value["content"]) || typeof value["title"] === "string");
}
function renderPostElement(element: unknown, imageKeys: string[], fileKeys: string[]): string {
if (!isRecord(element)) return "";
switch (stringField(element, "tag")) {
case "text":
return applyTextStyles(stringField(element, "text") ?? "", element);
case "a":
return renderLink(element);
case "at":
return renderMention(element);
case "img":
case "image":
pushStringField(imageKeys, element, "image_key");
return "[Image]";
case "media":
case "file":
case "audio":
case "video":
pushStringField(fileKeys, element, "file_key");
return renderAttachmentPlaceholder(element);
case "code":
case "code_block":
return renderCodeBlock(element);
case "br":
return "\n";
case "hr":
case "divider":
return "\n\n---\n\n";
case "emotion":
case "emoji":
return renderEmoji(element);
case "md":
return stringField(element, "text") ?? "";
default:
return stringField(element, "text") ?? "";
}
}
function renderLink(element: Record<string, unknown>): string {
const text = stringField(element, "text") ?? "";
const href = stringField(element, "href") ?? "";
if (href === "") return text;
return `[${text}](${href})`;
}
function renderMention(element: Record<string, unknown>): string {
if (stringField(element, "user_id") === "@_all") return "@all";
const name =
stringField(element, "user_name") ??
stringField(element, "name") ??
stringField(element, "text") ??
stringField(element, "user_id") ??
"";
return name === "" ? "" : `@${name.replace(/^@/, "")}`;
}
function renderAttachmentPlaceholder(element: Record<string, unknown>): string {
const fileName = stringField(element, "file_name") ?? stringField(element, "name");
return fileName === undefined || fileName === "" ? "[Attachment]" : `[Attachment: ${fileName}]`;
}
function renderCodeBlock(element: Record<string, unknown>): string {
const language = stringField(element, "language") ?? "";
const text = stringField(element, "text") ?? "";
return `\`\`\`${language}\n${text}\n\`\`\``;
}
function renderEmoji(element: Record<string, unknown>): string {
const emojiType =
stringField(element, "emoji_type") ??
stringField(element, "emotion_key") ??
stringField(element, "name") ??
stringField(element, "text");
return emojiType === undefined || emojiType === "" ? "" : `:${emojiType}:`;
}
function applyTextStyles(text: string, element: Record<string, unknown>): string {
let rendered = text;
if (hasPostStyle(element, "bold")) rendered = `**${rendered}**`;
if (hasPostStyle(element, "italic")) rendered = `*${rendered}*`;
if (hasPostStyle(element, "strikethrough", "strike", "line_through", "lineThrough")) rendered = `~~${rendered}~~`;
if (hasPostStyle(element, "code", "inline_code", "inlineCode")) rendered = `\`${rendered}\``;
return rendered;
}
function hasPostStyle(element: Record<string, unknown>, ...names: string[]): boolean {
const style = element["style"] ?? element["styles"];
if (styleMatches(style, names)) return true;
return names.some((name) => element[name] === true);
}
function styleMatches(style: unknown, names: readonly string[]): boolean {
if (Array.isArray(style)) {
return style.some((entry) => typeof entry === "string" && names.includes(entry));
}
if (typeof style === "string") {
const tokens = style.split(/[\s,]+/).filter((token) => token !== "");
return tokens.some((token) => names.includes(token));
}
if (isRecord(style)) {
return names.some((name) => style[name] === true || style[name] === "true");
}
return false;
}
function pushStringField(target: string[], record: Record<string, unknown>, field: string): void {
const value = stringField(record, field);
if (value !== undefined && value !== "") target.push(value);
}
function stringField(record: Record<string, unknown>, field: string): string | undefined {
const value = record[field];
return typeof value === "string" ? value : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function containsMarkdownSyntax(content: string): boolean {
const patterns = [
/^ {0,3}#{1,6}\s+\S/m,
/^ {0,3}>\s+\S/m,
/^ {0,3}(?:[-*+]\s+\S|\d+[.)]\s+\S)/m,
/```/,
/`[^`\n]+`/,
/\*\*[^*\n]+\*\*/,
/__[^_\n]+__/,
/~~[^~\n]+~~/,
/(^|[^*])\*[^*\s][^*\n]*\*(?!\*)/,
/(^|[^_])_[^_\s][^_\n]*_(?!_)/,
/\[[^\]\n]+\]\([^)]+\)/,
];
return patterns.some((pattern) => pattern.test(content));
}
function containsMarkdownTable(content: string): boolean {
const lines = content.split(/\r?\n/);
for (let i = 0; i < lines.length - 1; i += 1) {
const current = lines[i];
const next = lines[i + 1];
if (current !== undefined && next !== undefined && isTableHeaderLine(current) && isTableSeparatorLine(next)) {
return true;
}
}
return false;
}
function isTableHeaderLine(line: string): boolean {
const trimmed = line.trim();
return trimmed.startsWith("|") && trimmed.includes("|", 1);
}
function isTableSeparatorLine(line: string): boolean {
const trimmed = line.trim();
if (!trimmed.startsWith("|")) return false;
const cells = trimmed.replace(/^\|/, "").replace(/\|$/, "").split("|");
return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()));
}
function buildInteractiveCardFromOutboundPayload(payload: OutboundPayload): { elements: Array<{ tag: "div"; text: { tag: "lark_md"; content: string } }> } {
return {
elements: rowsFromOutboundPayload(payload).map((row) => ({
tag: "div",
text: { tag: "lark_md", content: row.map(markdownTextFromPostElement).join("") },
})),
};
}
function rowsFromOutboundPayload(payload: OutboundPayload): unknown[][] {
if (payload.msgType !== "post") {
return [[{ tag: "md", text: textFromTextPayload(payload.content) }]];
}
try {
const parsed = JSON.parse(payload.content) as { zh_cn?: { content?: unknown } };
return Array.isArray(parsed.zh_cn?.content) ? parsed.zh_cn.content as unknown[][] : [[{ tag: "md", text: "" }]];
} catch {
return [[{ tag: "md", text: "" }]];
}
}
function textFromTextPayload(content: string): string {
try {
const parsed = JSON.parse(content) as { text?: unknown };
return typeof parsed.text === "string" ? parsed.text : content;
} catch {
return content;
}
}
function markdownTextFromPostElement(element: unknown): string {
if (typeof element !== "object" || element === null) return "";
const text = (element as { text?: unknown }).text;
return typeof text === "string" ? text : "";
}
function isPostContentFormatError(error: unknown): boolean {
return errorText(error).includes("content format of the post type is incorrect");
}
function errorText(error: unknown): string {
if (error instanceof Error) {
return `${error.message} ${errorText((error as { response?: unknown }).response)}`;
}
if (typeof error === "string") return error;
if (typeof error === "object" && error !== null) {
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}
return String(error);
}
// --- Lark client + ws listener ---
export function createLarkClient(config: FeishuConfig): lark.Client {
return new lark.Client({
appId: config.appId,
@@ -132,17 +861,12 @@ export function createLarkClient(config: FeishuConfig): lark.Client {
});
}
export interface FeishuListener {
readonly runtime: FeishuRuntime;
readonly stop: () => Promise<void>;
}
/** Start the ws listener using an already-built client. Returns the runtime. */
export function startFeishuListenerWithClient(
config: FeishuConfig,
client: lark.Client,
logger: FastifyBaseLogger,
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>,
): FeishuRuntime {
const wsClient = new lark.WSClient({
appId: config.appId,
@@ -151,23 +875,28 @@ export function startFeishuListenerWithClient(
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");
}
},
}),
});
const handlers: Record<string, (data: unknown) => Promise<void>> = {
"im.message.receive_v1": async (data) => {
try { await onMessage(data as MessageReceiveEvent, rt); }
catch (e) { logger.error({ err: e }, "feishu message handler threw"); }
},
};
if (onCardAction !== undefined) {
handlers["card.action.trigger"] = async (data) => {
void Promise.resolve()
.then(() => onCardAction(data as CardActionEvent, rt))
.catch((e) => { logger.error({ err: e }, "feishu card action handler threw"); });
};
}
void wsClient.start({ eventDispatcher: new lark.EventDispatcher({}).register(handlers) });
return rt;
}
export function startFeishuListener(
config: FeishuConfig,
logger: FastifyBaseLogger,
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>,
): FeishuRuntime {
return startFeishuListenerWithClient(config, createLarkClient(config), logger, onMessage);
return startFeishuListenerWithClient(config, createLarkClient(config), logger, onMessage, onCardAction);
}
+95
View File
@@ -0,0 +1,95 @@
import { access, stat } from "node:fs/promises";
import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
export interface DeliverableFile {
readonly path: string;
readonly name: string;
}
const DELIVERABLE_EXTENSIONS = new Set([
".csv",
".doc",
".docx",
".gif",
".jpeg",
".jpg",
".md",
".pdf",
".png",
".ppt",
".pptx",
".svg",
".txt",
".typ",
".xls",
".xlsx",
".zip",
]);
export async function resolveDeliverableFile(
requestedPath: string,
workspaceDir: string,
): Promise<DeliverableFile | null> {
const token = requestedPath.trim();
if (token === "" || !DELIVERABLE_EXTENSIONS.has(extname(token).toLowerCase())) {
return null;
}
const roots = await allowedRoots(workspaceDir);
for (const candidate of candidatePaths(token, workspaceDir, roots)) {
if (!isAllowedPath(candidate, roots)) continue;
const existing = await existingFile(candidate);
if (existing !== null) return { path: existing, name: basename(existing) };
}
return null;
}
async function allowedRoots(workspaceDir: string): Promise<string[]> {
const roots = [resolve(workspaceDir)];
const gitRoot = await findGitRoot(workspaceDir);
if (gitRoot !== undefined) roots.push(gitRoot);
return [...new Set(roots)];
}
async function findGitRoot(startDir: string): Promise<string | undefined> {
let current = resolve(startDir);
while (true) {
try {
await access(join(current, ".git"));
return current;
} catch {
const parent = resolve(current, "..");
if (parent === current) return undefined;
current = parent;
}
}
}
function candidatePaths(token: string, workspaceDir: string, roots: readonly string[]): string[] {
if (isAbsolute(token)) return [resolve(token)];
const candidates: string[] = [];
for (const root of roots) {
candidates.push(resolve(root, token));
}
if (!token.includes("/") && !token.includes("\\")) {
candidates.push(resolve(workspaceDir, "build", token));
}
return [...new Set(candidates)];
}
function isAllowedPath(path: string, roots: readonly string[]): boolean {
return roots.some((root) => {
const rel = relative(root, path);
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
});
}
async function existingFile(path: string): Promise<string | null> {
try {
const s = await stat(path);
return s.isFile() ? resolve(path) : null;
} catch {
return null;
}
}
+166
View File
@@ -0,0 +1,166 @@
import { createSdkMcpServer, tool, type McpSdkServerConfigWithInstance, type SdkMcpToolDefinition } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";
import { sendApprovalCard, sendFile, type FeishuRuntime, type SendMessageOptions } from "./client.js";
import { resolveDeliverableFile } from "./fileDelivery.js";
import { readFeishuContext } from "./read.js";
import type { ApprovalManager } from "./approval.js";
import { CPH_HUB_MCP_TOOL_IDS, type CphHubMcpToolId } from "../agent/roleTools.js";
export interface FileDeliveryToolOptions {
readonly rt: FeishuRuntime;
readonly chatId: string;
readonly projectId: string;
readonly runId: string;
readonly workspaceDir: string;
readonly sendOptions?: SendMessageOptions | undefined;
readonly approvalManager: ApprovalManager;
readonly onDelivered?: (path: string) => void;
readonly tools?: readonly CphHubMcpToolId[] | undefined;
}
export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): McpSdkServerConfigWithInstance {
const enabledTools = new Set(options.tools ?? CPH_HUB_MCP_TOOL_IDS);
const tools: Array<SdkMcpToolDefinition<any>> = [];
if (enabledTools.has("send_file")) {
tools.push(
tool(
"send_file",
"Upload an existing project/repository file to the current Feishu chat. The path must point to a concrete file, for example build/student.pdf or README.md.",
{
path: z.string().describe("Workspace-relative, repository-relative, or absolute path to the existing file."),
name: z.string().optional().describe("Optional display filename. Defaults to the file's basename."),
},
async (args) => {
const file = await resolveDeliverableFile(args.path, options.workspaceDir);
if (file === null) {
return {
isError: true,
content: [{ type: "text", text: `File not found or not deliverable: ${args.path}` }],
};
}
const messageId = await sendFile(options.rt, options.chatId, file.path, args.name ?? file.name, options.sendOptions);
if (messageId === null) {
return {
isError: true,
content: [{ type: "text", text: `Failed to send file: ${file.path}` }],
};
}
options.onDelivered?.(file.path);
return {
content: [{ type: "text", text: `Sent file: ${file.path}` }],
};
},
{ alwaysLoad: true },
),
);
}
if (enabledTools.has("feishu_read_context")) {
tools.push(
tool(
"feishu_read_context",
"Read Feishu context for the current project's bound chat. Use anchor ids from the trigger context; this tool never reads arbitrary chats.",
{
anchor: z.enum(["trigger_message", "status_card", "reply", "thread"]).describe("Which kind of Feishu anchor to read."),
id: z.string().describe("The message/thread/status anchor id to read."),
},
async (args) => {
const result = await readFeishuContext(
{ chat_id: options.chatId, anchor: args.anchor, id: args.id },
{
runId: options.runId,
projectId: options.projectId,
boundChatId: options.chatId,
workspaceDir: options.workspaceDir,
},
options.rt,
);
return { content: [{ type: "text", text: result }] };
},
{ alwaysLoad: true },
),
);
}
if (enabledTools.has("request_approval")) {
tools.push(
tool(
"request_approval",
"Send an interactive Feishu approval/confirmation card to the current chat and wait for a button click.",
{
title: z.string().describe("Card title."),
body: z.string().describe("Markdown card body explaining what needs approval or confirmation."),
options: z
.array(z.object({
label: z.string().describe("Button label shown to the user."),
value: z.string().describe("Action value returned to the agent when this button is clicked."),
style: z.enum(["primary", "default", "danger"]).optional().describe("Optional Feishu button style."),
}))
.min(1)
.describe("Button choices for the approval card."),
},
async (args) => {
const messageId = await sendApprovalCard(
options.rt,
options.chatId,
args.title,
args.body,
args.options.map((option) => ({
label: option.label,
value: option.value,
...(option.style === undefined ? {} : { style: option.style }),
})),
options.sendOptions,
);
if (messageId === null) {
return {
isError: true,
content: [{ type: "text", text: "Failed to send approval card." }],
};
}
try {
const result = await options.approvalManager.register(messageId, options.chatId);
return { content: [{ type: "text", text: JSON.stringify(result) }] };
} catch (e) {
return {
isError: true,
content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
};
}
},
{ alwaysLoad: true },
),
);
}
const instructions = mcpInstructions(enabledTools);
return createSdkMcpServer({
name: "cph_hub",
version: "0.0.0",
alwaysLoad: true,
...(instructions === "" ? {} : { instructions }),
tools,
});
}
function mcpInstructions(enabledTools: ReadonlySet<CphHubMcpToolId>): string {
const instructions: string[] = [];
if (enabledTools.has("send_file")) {
instructions.push(
"Use send_file when the user asks to receive, resend, download, or attach a file.",
"Do not claim a file was sent unless send_file returns success.",
"If a generated file path is uncertain, list or inspect the workspace first, then call send_file with the actual path.",
);
}
if (enabledTools.has("feishu_read_context")) {
instructions.push("Use feishu_read_context when the trigger context contains a reply, thread, or message anchor and more Feishu context is needed.");
}
if (enabledTools.has("request_approval")) {
instructions.push("Use request_approval when explicit human approval or confirmation is required before continuing.");
}
return instructions.join(" ");
}
+125
View File
@@ -0,0 +1,125 @@
export interface MessageBatcherOptions {
/** Default: 600. */
readonly debounceMs?: number;
/** Default: 8. */
readonly maxMessages?: number;
/** Default: 4000. */
readonly maxChars?: number;
/** Default: 3500. */
readonly splitThreshold?: number;
/** Default: 1200. */
readonly extendedDebounceMs?: number;
}
export type MessageBatcherCallback = (mergedText: string, key?: string) => Promise<void>;
interface PendingBatch {
readonly texts: string[];
charCount: number;
timer: ReturnType<typeof setTimeout> | null;
}
const DEFAULT_OPTIONS = {
debounceMs: 600,
maxMessages: 8,
maxChars: 4000,
splitThreshold: 3500,
extendedDebounceMs: 1200,
} as const;
export class MessageBatcher {
private readonly debounceMs: number;
private readonly maxMessages: number;
private readonly maxChars: number;
private readonly splitThreshold: number;
private readonly extendedDebounceMs: number;
private readonly pending = new Map<string, PendingBatch>();
private readonly chains = new Map<string, Promise<void>>();
constructor(
private readonly onFlush: MessageBatcherCallback,
options: MessageBatcherOptions = {},
) {
this.debounceMs = options.debounceMs ?? DEFAULT_OPTIONS.debounceMs;
this.maxMessages = options.maxMessages ?? DEFAULT_OPTIONS.maxMessages;
this.maxChars = options.maxChars ?? DEFAULT_OPTIONS.maxChars;
this.splitThreshold = options.splitThreshold ?? DEFAULT_OPTIONS.splitThreshold;
this.extendedDebounceMs = options.extendedDebounceMs ?? DEFAULT_OPTIONS.extendedDebounceMs;
}
async enqueue(chatId: string, senderOpenId: string, text: string): Promise<void> {
const key = messageBatchKey(chatId, senderOpenId);
await this.runSerial(key, async () => {
const existing = this.pending.get(key);
const batch =
existing ??
{
texts: [],
charCount: 0,
timer: null,
};
if (existing === undefined) {
this.pending.set(key, batch);
}
batch.texts.push(text);
batch.charCount += text.length + (batch.texts.length === 1 ? 0 : 1);
if (batch.texts.length >= this.maxMessages || batch.charCount >= this.maxChars) {
await this.flushPendingBatch(key, batch);
return;
}
this.scheduleFlush(key, batch, text.length >= this.splitThreshold ? this.extendedDebounceMs : this.debounceMs);
});
}
async flushNow(key: string): Promise<void> {
await this.runSerial(key, async () => {
const batch = this.pending.get(key);
if (batch === undefined) return;
await this.flushPendingBatch(key, batch);
});
}
async flushAll(): Promise<void> {
const keys = [...this.pending.keys()];
await Promise.all(keys.map((key) => this.flushNow(key)));
}
private scheduleFlush(key: string, batch: PendingBatch, delayMs: number): void {
if (batch.timer !== null) {
clearTimeout(batch.timer);
}
batch.timer = setTimeout(() => {
void this.flushNow(key).catch(() => undefined);
}, delayMs);
(batch.timer as { unref?: () => void }).unref?.();
}
private async flushPendingBatch(key: string, batch: PendingBatch): Promise<void> {
if (batch.timer !== null) {
clearTimeout(batch.timer);
batch.timer = null;
}
if (this.pending.get(key) !== batch) return;
this.pending.delete(key);
await this.onFlush(batch.texts.join("\n"), key);
}
private runSerial(key: string, operation: () => Promise<void>): Promise<void> {
const previous = this.chains.get(key) ?? Promise.resolve();
const next = previous.catch(() => undefined).then(operation);
this.chains.set(key, next);
return next.finally(() => {
if (this.chains.get(key) === next) {
this.chains.delete(key);
}
});
}
}
export function messageBatchKey(chatId: string, senderOpenId: string): string {
return `${chatId}:${senderOpenId}`;
}
+9 -3
View File
@@ -24,11 +24,14 @@ interface LarkMessage {
message_id?: string;
msg_type?: string;
content?: string;
body?: { content?: string };
create_time?: string;
sender?: { id?: string; sender_type?: string };
chat_id?: string;
parent_id?: string;
parent_message_id?: string;
root_id?: string;
thread_id?: string;
}
interface ImV1Message {
@@ -84,14 +87,17 @@ export async function readFeishuContext(
/** Project a lark message down to the fields the model actually needs. */
function compact(m: LarkMessage): Record<string, unknown> {
const parentId = m.parent_id ?? m.parent_message_id;
return {
message_id: m.message_id,
msg_type: m.msg_type,
content: m.content,
content: m.body?.content ?? m.content,
create_time: m.create_time,
chat_id: m.chat_id,
sender_id: m.sender?.id,
parent_message_id: m.parent_message_id,
parent_id: parentId,
parent_message_id: parentId,
root_id: m.root_id,
thread_id: m.thread_id,
};
}
+82
View File
@@ -0,0 +1,82 @@
export interface SenderCacheOptions {
readonly ttlMs?: number;
readonly maxSize?: number;
}
interface SenderCacheEntry {
readonly name: string;
readonly expiresAt: number;
}
const DEFAULT_TTL_MS = 600_000;
const DEFAULT_MAX_SIZE = 2_048;
export class SenderNameCache {
private readonly ttlMs: number;
private readonly maxSize: number;
private readonly entries = new Map<string, SenderCacheEntry>();
constructor(options: SenderCacheOptions = {}) {
this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
this.maxSize = Math.max(0, options.maxSize ?? DEFAULT_MAX_SIZE);
}
get(openId: string): string | null {
const entry = this.entries.get(openId);
if (entry === undefined) return null;
if (entry.expiresAt <= Date.now()) {
this.entries.delete(openId);
return null;
}
this.entries.delete(openId);
this.entries.set(openId, entry);
return entry.name;
}
set(openId: string, name: string): void {
if (this.maxSize === 0) return;
this.entries.delete(openId);
this.entries.set(openId, { name, expiresAt: Date.now() + this.ttlMs });
while (this.entries.size > this.maxSize) {
const oldest = this.entries.keys().next().value;
if (oldest === undefined) break;
this.entries.delete(oldest);
}
}
async getOrFetch(
openId: string,
fetchFn: (openId: string) => Promise<string | null>,
): Promise<string | null> {
const cached = this.get(openId);
if (cached !== null) return cached;
const fetched = await fetchFn(openId);
if (fetched !== null) {
this.set(openId, fetched);
}
return fetched;
}
clear(): void {
this.entries.clear();
}
size(): number {
this.pruneExpired();
return this.entries.size;
}
private pruneExpired(): void {
const now = Date.now();
for (const [openId, entry] of this.entries) {
if (entry.expiresAt <= now) {
this.entries.delete(openId);
}
}
}
}
+412
View File
@@ -0,0 +1,412 @@
import type { PrismaClient } from "@prisma/client";
import type { FastifyBaseLogger } from "fastify";
import { decimalToNumberOrNull, formatInteger, formatUsd } from "../agent/cost.js";
import type { ModelRegistry, RoleEntry } from "../agent/models.js";
import type { RuntimeSettings } from "../settings/runtime.js";
import { sendText, type FeishuRuntime, type SendMessageOptions } from "./client.js";
import type { TriggerQueue } from "./triggerQueue.js";
export interface SlashInvocation {
readonly name: string;
readonly args: readonly string[];
}
export interface SlashCommandRunContext {
readonly invocation: SlashInvocation;
readonly projectId: string;
readonly chatId: string;
readonly rt: FeishuRuntime;
readonly sendOptions?: SendMessageOptions | undefined;
}
export interface SlashCommandDefinition {
readonly name: string;
readonly usage: string;
readonly summary: string;
readonly details: readonly string[];
run(context: SlashCommandRunContext): Promise<void>;
}
export interface SlashCommandRegistryDeps {
readonly prisma: PrismaClient;
readonly settings: RuntimeSettings;
readonly logger: FastifyBaseLogger;
readonly triggerQueue: TriggerQueue;
}
const TERMINAL_RUN_STATUSES = ["COMPLETED", "FAILED", "TIMED_OUT", "CANCELED"] as const;
export function parseSlashInvocation(prompt: string): SlashInvocation | null {
const trimmed = prompt.trim();
if (!trimmed.startsWith("/")) return null;
const tokens = trimmed.split(/\s+/);
const rawCommand = tokens[0];
if (rawCommand === undefined || rawCommand.length <= 1) return null;
return {
name: rawCommand.slice(1),
args: tokens.slice(1),
};
}
export function parseSlashHelpSubcommand(invocation: SlashInvocation): string | null {
if (invocation.name === "help") return null;
return invocation.args.length === 1 && invocation.args[0] === "help"
? invocation.name
: null;
}
export function createSlashCommandRegistry(deps: SlashCommandRegistryDeps): ReadonlyMap<string, SlashCommandDefinition> {
const commands = new Map<string, SlashCommandDefinition>();
const add = (command: SlashCommandDefinition): void => {
if (commands.has(command.name)) {
throw new Error(`duplicate slash command definition: /${command.name}`);
}
commands.set(command.name, command);
};
add({
name: "help",
usage: "/help [command]",
summary: "查看可用 slash 命令或单个命令说明。",
details: [
"不创建 agent run,也不改变当前会话。",
"支持 /help new 和 /new help 两种写法。",
],
run: async ({ invocation, projectId, chatId, rt, sendOptions }) => {
const registry = await deps.settings.modelRegistry({ projectId });
await sendText(rt, chatId, formatHelpCommandInvocation(invocation, registry, commands), sendOptions);
},
});
add({
name: "new",
usage: "/new",
summary: "开新会话,下次 @bot 将从头开始。",
details: [
"归档当前未归档的 agent session。",
"不会清空当前项目的等待队列。",
],
run: async ({ projectId, chatId, rt, sendOptions }) => {
await deps.prisma.agentSession.updateMany({
where: { projectId, archivedAt: null },
data: { archivedAt: new Date() },
});
await sendText(rt, chatId, "已开新会话,下次 @bot 将从头开始。", sendOptions);
},
});
add({
name: "resume",
usage: "/resume",
summary: "恢复最近一次已归档的会话。",
details: [
"只恢复当前项目最近归档的 agent session。",
"没有可恢复会话时只回复提示,不会创建 agent run。",
],
run: async ({ projectId, chatId, rt, sendOptions }) => {
const latest = await deps.prisma.agentSession.findFirst({
where: { projectId, archivedAt: { not: null } },
orderBy: { archivedAt: "desc" },
select: { id: true },
});
if (latest === null) {
await sendText(rt, chatId, "没有可恢复的会话。", sendOptions);
return;
}
await deps.prisma.agentSession.update({
where: { id: latest.id },
data: { archivedAt: null },
});
await sendText(rt, chatId, "已恢复上一个会话。", sendOptions);
},
});
add({
name: "cost",
usage: "/cost",
summary: "查看当前会话已记录的 agent 成本。",
details: [
"只读取当前项目未归档 agent session 下已经结束的 run,不创建 agent run。",
"只统计运行时真实记录到 AgentRun.costUsd 的成本;未记录成本的 run 会单独列出。",
],
run: async ({ invocation, projectId, chatId, rt, sendOptions }) => {
if (invocation.args.length > 0) {
await sendText(rt, chatId, ["用法错误: /cost 暂不接受参数。", "", formatBuiltinSlashCommandHelp(commands.get("cost")!)].join("\n"), sendOptions);
return;
}
const sessions = await deps.prisma.agentSession.findMany({
where: { projectId, archivedAt: null },
orderBy: { updatedAt: "asc" },
select: { id: true },
});
if (sessions.length === 0) {
await sendText(rt, chatId, "当前会话还没有 agent session。", sendOptions);
return;
}
const runs = await deps.prisma.agentRun.findMany({
where: {
projectId,
sessionId: { in: sessions.map((session) => session.id) },
status: { in: [...TERMINAL_RUN_STATUSES] },
finishedAt: { not: null },
},
orderBy: { finishedAt: "asc" },
select: {
model: true,
provider: true,
inputTokens: true,
outputTokens: true,
costUsd: true,
},
});
await sendText(rt, chatId, formatCostReport(runs), sendOptions);
},
});
add({
name: "reset",
usage: "/reset",
summary: "重置当前会话并清空等待队列。",
details: [
"归档当前未归档的 agent session。",
"清空当前项目已经排队、尚未开始的触发请求。",
],
run: async ({ projectId, chatId, rt, sendOptions }) => {
await deps.prisma.agentSession.updateMany({
where: { projectId, archivedAt: null },
data: { archivedAt: new Date() },
});
const cleared = deps.triggerQueue.clear(projectId);
if (cleared > 0) {
deps.logger.info({ projectId, cleared }, "feishu trigger: cleared queued triggers on reset");
}
await sendText(rt, chatId, "已重置,下次 @bot 将从头开始。", sendOptions);
},
});
return commands;
}
interface CostReportRun {
readonly model: string;
readonly provider: string;
readonly inputTokens: number | null;
readonly outputTokens: number | null;
readonly costUsd: unknown;
}
interface CostReportBucket {
readonly provider: string;
readonly model: string;
runs: number;
inputTokens: number;
outputTokens: number;
costUsd: number;
}
function formatCostReport(runs: readonly CostReportRun[]): string {
if (runs.length === 0) {
return "当前会话还没有已结束的 agent run。";
}
const buckets = new Map<string, CostReportBucket>();
let recordedRuns = 0;
let unrecordedRuns = 0;
let totalInputTokens = 0;
let totalOutputTokens = 0;
let totalCostUsd = 0;
for (const run of runs) {
const costUsd = decimalToNumberOrNull(run.costUsd);
if (costUsd === null) {
unrecordedRuns++;
continue;
}
recordedRuns++;
const inputTokens = run.inputTokens ?? 0;
const outputTokens = run.outputTokens ?? 0;
totalInputTokens += inputTokens;
totalOutputTokens += outputTokens;
totalCostUsd += costUsd;
const key = `${run.provider}\u0000${run.model}`;
let bucket = buckets.get(key);
if (bucket === undefined) {
bucket = {
provider: run.provider,
model: run.model,
runs: 0,
inputTokens: 0,
outputTokens: 0,
costUsd: 0,
};
buckets.set(key, bucket);
}
bucket.runs++;
bucket.inputTokens += inputTokens;
bucket.outputTokens += outputTokens;
bucket.costUsd += costUsd;
}
if (recordedRuns === 0) {
return [
"当前会话已有已结束 agent run,但还没有任何 run 记录到真实成本。",
`未记录成本: ${formatInteger(unrecordedRuns)} runs。`,
"后续 run 需要 SDK 返回 total_cost_usd 才会进入 /cost 合计。",
].join("\n");
}
const lines = [
"当前会话已记录 agent 成本:",
`总计: ${formatUsd(totalCostUsd)}`,
`Runs: ${formatInteger(recordedRuns)} 已记录${unrecordedRuns > 0 ? ` / ${formatInteger(unrecordedRuns)} 未记录` : ""}`,
`Tokens: input ${formatInteger(totalInputTokens)} / output ${formatInteger(totalOutputTokens)}`,
"",
"按模型:",
];
const sortedBuckets = [...buckets.values()].sort((a, b) => b.costUsd - a.costUsd);
for (const bucket of sortedBuckets) {
lines.push(
`- ${bucket.provider} / ${bucket.model}: ${formatInteger(bucket.runs)} runs, ${formatUsd(bucket.costUsd)}, input ${formatInteger(bucket.inputTokens)} / output ${formatInteger(bucket.outputTokens)}`,
);
}
return lines.join("\n");
}
function formatHelpCommandInvocation(
invocation: SlashInvocation,
registry: ModelRegistry,
slashCommands: ReadonlyMap<string, SlashCommandDefinition>,
): string {
if (invocation.args.length > 1) {
const helpCommand = slashCommands.get("help");
if (helpCommand === undefined) {
throw new Error("slash command registry is missing /help");
}
return [
"用法错误: /help 只接受一个命令名。",
"",
formatBuiltinSlashCommandHelp(helpCommand),
].join("\n");
}
const target = invocation.args[0];
if (target === undefined) return formatSlashOverview(registry, slashCommands);
const normalizedTarget = normalizeHelpTarget(target);
if (normalizedTarget === "") return formatSlashOverview(registry, slashCommands);
return formatSlashHelpTarget(normalizedTarget, registry, slashCommands);
}
export function formatSlashHelpTarget(
target: string,
registry: ModelRegistry,
slashCommands: ReadonlyMap<string, SlashCommandDefinition>,
): string {
const normalizedTarget = normalizeHelpTarget(target);
if (normalizedTarget === "") return formatSlashOverview(registry, slashCommands);
return formatSlashCommandHelp(normalizedTarget, registry, slashCommands) ?? formatUnknownSlashHelp(normalizedTarget, registry, slashCommands);
}
function normalizeHelpTarget(target: string): string {
return target.trim().replace(/^\/+/, "");
}
function formatSlashOverview(
registry: ModelRegistry,
slashCommands: ReadonlyMap<string, SlashCommandDefinition>,
): string {
const lines = [
"可用 slash 命令:",
...[...slashCommands.values()].map((command) => `/${command.name} - ${command.summary}`),
];
const roles = visibleRoleCommands(registry, slashCommands);
if (roles.length > 0) {
lines.push("", "角色命令:");
for (const role of roles) {
lines.push(`/${role.id} <需求> - 使用“${role.label}”角色发起请求。`);
}
} else {
lines.push("", "当前没有配置角色命令。");
}
lines.push("", "查看单个命令: /help new 或 /new help。");
return lines.join("\n");
}
function formatSlashCommandHelp(
commandName: string,
registry: ModelRegistry,
slashCommands: ReadonlyMap<string, SlashCommandDefinition>,
): string | null {
const normalizedName = normalizeHelpTarget(commandName);
const slashCommand = slashCommands.get(normalizedName);
if (slashCommand !== undefined) return formatBuiltinSlashCommandHelp(slashCommand);
const role = registry.role(normalizedName);
if (role === undefined) return null;
if (slashCommands.has(role.id)) return null;
return formatRoleSlashCommandHelp(role);
}
function formatBuiltinSlashCommandHelp(command: SlashCommandDefinition): string {
const helpUsage = command.name === "help"
? "帮助: /help help"
: `帮助: /help ${command.name} 或 /${command.name} help`;
return [
`/${command.name}`,
command.summary,
"",
`用法: ${command.usage}`,
...command.details.map((detail) => `- ${detail}`),
"",
helpUsage,
].join("\n");
}
function formatRoleSlashCommandHelp(role: RoleEntry): string {
const lines = [
`/${role.id}`,
`使用“${role.label}”角色发起一次 agent run。`,
"",
`用法: /${role.id} <需求>`,
"- 真正运行时仍会按当前项目的 role.trigger 授权检查。",
];
if (role.defaultModel !== undefined) {
lines.push(`- 默认模型: ${role.defaultModel}`);
}
lines.push(`- 工具范围: ${roleToolsDescription(role)}`, "", `帮助: /help ${role.id} 或 /${role.id} help`);
return lines.join("\n");
}
function roleToolsDescription(role: RoleEntry): string {
if (role.tools === undefined) return "全部已注册工具";
if (role.tools.length === 0) return "无";
return role.tools.join(", ");
}
function formatUnknownSlashHelp(
commandName: string,
registry: ModelRegistry,
slashCommands: ReadonlyMap<string, SlashCommandDefinition>,
): string {
const normalizedName = normalizeHelpTarget(commandName);
return [
`未知 slash 命令 /${normalizedName}`,
"",
formatSlashOverview(registry, slashCommands),
].join("\n");
}
function visibleRoleCommands(
registry: ModelRegistry,
slashCommands: ReadonlyMap<string, SlashCommandDefinition>,
): readonly RoleEntry[] {
return registry
.listRoles()
.filter((role) => !slashCommands.has(role.id));
}
+210
View File
@@ -0,0 +1,210 @@
export interface PatchableTextSink {
readonly create: (text: string) => Promise<string | null>;
readonly patch: (messageId: string, text: string) => Promise<void>;
}
export interface PatchableTextStreamOptions {
readonly patchIntervalMs?: number;
readonly maxMessageLength?: number;
}
const DEFAULT_PATCH_INTERVAL_MS = 400;
export const DEFAULT_MAX_MESSAGE_LENGTH = 8000;
interface CodeBlockRange {
readonly start: number;
readonly end: number;
}
export function splitAtBoundary(text: string, maxLength: number): string[] {
if (!Number.isInteger(maxLength) || maxLength <= 0) {
throw new RangeError("maxLength must be a positive integer");
}
if (text.length <= maxLength) return [text];
const chunks: string[] = [];
let remaining = text;
while (remaining.length > maxLength) {
const splitPoint = chooseSplitPoint(remaining, maxLength);
if (splitPoint <= 0) {
const codeBlockEnd = codeBlockAtStartEnd(remaining);
const forcedSplitPoint = codeBlockEnd ?? maxLength;
chunks.push(remaining.slice(0, forcedSplitPoint));
remaining = remaining.slice(forcedSplitPoint);
continue;
}
chunks.push(remaining.slice(0, splitPoint));
remaining = remaining.slice(splitPoint);
}
if (remaining.length > 0) {
chunks.push(remaining);
}
return chunks;
}
function chooseSplitPoint(text: string, maxLength: number): number {
const codeBlocks = findFencedCodeBlocks(text);
const splitCodeBlock = codeBlocks.find((range) => maxLength > range.start && maxLength < range.end);
if (splitCodeBlock !== undefined) {
return splitCodeBlock.start;
}
return (
lastBoundary(text, maxLength, /\r?\n\r?\n/g, codeBlocks) ??
lastBoundary(text, maxLength, /\r?\n/g, codeBlocks) ??
lastBoundary(text, maxLength, / /g, codeBlocks) ??
maxLength
);
}
function lastBoundary(text: string, maxLength: number, pattern: RegExp, codeBlocks: ReadonlyArray<CodeBlockRange>): number | undefined {
let boundary: number | undefined;
let match: RegExpExecArray | null;
pattern.lastIndex = 0;
while ((match = pattern.exec(text)) !== null) {
const splitPoint = match.index + match[0].length;
if (splitPoint > maxLength) break;
if (splitPoint > 0 && !isInsideCodeBlock(splitPoint, codeBlocks)) {
boundary = splitPoint;
}
}
return boundary;
}
function isInsideCodeBlock(splitPoint: number, codeBlocks: ReadonlyArray<CodeBlockRange>): boolean {
return codeBlocks.some((range) => splitPoint > range.start && splitPoint < range.end);
}
function codeBlockAtStartEnd(text: string): number | undefined {
const firstCodeBlock = findFencedCodeBlocks(text)[0];
return firstCodeBlock?.start === 0 ? firstCodeBlock.end : undefined;
}
function findFencedCodeBlocks(text: string): CodeBlockRange[] {
const ranges: CodeBlockRange[] = [];
let openStart: number | null = null;
let lineStart = 0;
while (lineStart < text.length) {
const newlineIndex = text.indexOf("\n", lineStart);
const lineEnd = newlineIndex === -1 ? text.length : newlineIndex;
const nextLineStart = newlineIndex === -1 ? text.length : newlineIndex + 1;
const line = text.slice(lineStart, lineEnd);
if (/^ {0,3}```/.test(line)) {
if (openStart === null) {
openStart = lineStart;
} else {
ranges.push({ start: openStart, end: nextLineStart });
openStart = null;
}
}
lineStart = nextLineStart;
}
if (openStart !== null) {
ranges.push({ start: openStart, end: text.length });
}
return ranges;
}
/**
* Serializes text-card creation/patching for streaming output.
*
* Feishu returns the message id asynchronously. Without this queue, multiple
* early text deltas can all observe "no message yet" and create duplicate
* prefix messages before the first create call resolves.
*/
export class PatchableTextStream {
private currentMessageId: string | null = null;
private text = "";
private lastPatchAt = 0;
private flushChain: Promise<void> = Promise.resolve();
private flushScheduled = false;
private readonly patchIntervalMs: number;
private readonly maxMessageLength: number;
constructor(
private readonly sink: PatchableTextSink,
options: PatchableTextStreamOptions = {},
) {
this.patchIntervalMs = options.patchIntervalMs ?? DEFAULT_PATCH_INTERVAL_MS;
this.maxMessageLength = options.maxMessageLength ?? DEFAULT_MAX_MESSAGE_LENGTH;
}
append(delta: string): void {
if (delta === "") return;
this.text += delta;
this.scheduleFlush();
}
async finish(fallbackText: string): Promise<void> {
await this.flushChain;
// If there's pending text to flush, do it now.
if (this.text.length > 0) {
await this.flushTextToSink();
return;
}
// No pending text. If we never sent anything, send the fallback.
if (this.currentMessageId === null && fallbackText.length > 0) {
this.text = fallbackText;
await this.flushTextToSink();
}
}
private scheduleFlush(): void {
if (this.flushScheduled) return;
this.flushScheduled = true;
this.flushChain = this.flushChain.then(async () => {
this.flushScheduled = false;
await this.flush();
});
}
private async flush(): Promise<void> {
if (this.currentMessageId === null) {
await this.flushTextToSink();
this.lastPatchAt = Date.now();
return;
}
const now = Date.now();
if (now - this.lastPatchAt < this.patchIntervalMs) return;
this.lastPatchAt = now;
await this.flushTextToSink();
}
private async flushTextToSink(): Promise<void> {
const textToFlush = this.text;
const chunks = splitAtBoundary(textToFlush, this.maxMessageLength);
const firstChunk = chunks[0];
if (firstChunk === undefined) return;
if (this.currentMessageId === null) {
// First message: create it with the first chunk, then send overflow
// chunks as new messages.
this.currentMessageId = await this.sink.create(firstChunk);
for (const chunk of chunks.slice(1)) {
this.currentMessageId = await this.sink.create(chunk);
}
// Keep only the last chunk for future patches; remove already-sent prefix.
const sentLength = chunks.slice(0, -1).reduce((sum, c) => sum + c.length, 0);
this.text = this.text.slice(sentLength);
} else {
// Patch the current message with the first chunk, then create new
// messages for overflow chunks.
await this.sink.patch(this.currentMessageId, firstChunk);
for (const chunk of chunks.slice(1)) {
this.currentMessageId = await this.sink.create(chunk);
}
// Keep only the last chunk for future patches; remove already-sent prefix.
const sentLength = chunks.slice(0, -1).reduce((sum, c) => sum + c.length, 0);
this.text = this.text.slice(sentLength);
}
}
}
+986 -241
View File
File diff suppressed because it is too large Load Diff
+100
View File
@@ -0,0 +1,100 @@
import type { MessageReceiveEvent } from "./client.js";
export interface QueuedTrigger {
readonly projectId: string;
readonly chatId: string;
readonly prompt: string;
readonly msg: MessageReceiveEvent["message"];
readonly senderOpenId: string;
readonly actor: { readonly feishuOpenId: string; readonly chatId: string };
readonly enqueuedAt: number;
}
export interface TriggerQueueOptions {
readonly maxQueueSize?: number;
readonly maxWaitMs?: number;
}
const DEFAULT_MAX_QUEUE_SIZE = 5;
const DEFAULT_MAX_WAIT_MS = 300_000;
export class TriggerQueue {
readonly maxQueueSize: number;
readonly maxWaitMs: number;
private readonly queues = new Map<string, QueuedTrigger[]>();
constructor(options: TriggerQueueOptions = {}) {
this.maxQueueSize = Math.max(0, Math.trunc(options.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE));
this.maxWaitMs = Math.max(0, Math.trunc(options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS));
}
enqueue(projectId: string, trigger: Omit<QueuedTrigger, "projectId" | "enqueuedAt">): number {
const queue = this.queues.get(projectId) ?? [];
if (queue.length >= this.maxQueueSize) return 0;
queue.push({
...trigger,
projectId,
enqueuedAt: Date.now(),
});
if (!this.queues.has(projectId)) {
this.queues.set(projectId, queue);
}
return queue.length;
}
dequeue(projectId: string): QueuedTrigger | null {
const queue = this.queues.get(projectId);
if (queue === undefined || queue.length === 0) return null;
const next = queue.shift() ?? null;
if (queue.length === 0) {
this.queues.delete(projectId);
}
return next;
}
peek(projectId: string): QueuedTrigger | null {
return this.queues.get(projectId)?.[0] ?? null;
}
length(projectId: string): number {
return this.queues.get(projectId)?.length ?? 0;
}
hasPending(projectId: string): boolean {
return this.length(projectId) > 0;
}
purgeExpired(): number {
const now = Date.now();
let removed = 0;
for (const [projectId, queue] of this.queues) {
const fresh = queue.filter((trigger) => !this.isExpired(trigger, now));
removed += queue.length - fresh.length;
if (fresh.length === 0) {
this.queues.delete(projectId);
} else if (fresh.length !== queue.length) {
this.queues.set(projectId, fresh);
}
}
return removed;
}
clear(projectId: string): number {
const count = this.length(projectId);
this.queues.delete(projectId);
return count;
}
clearAll(): void {
this.queues.clear();
}
isExpired(trigger: QueuedTrigger, now = Date.now()): boolean {
return trigger.enqueuedAt + this.maxWaitMs < now;
}
}
export const triggerQueue = new TriggerQueue();
+45 -74
View File
@@ -1,89 +1,70 @@
/**
* Permission gate — ADR-0004 in code, project-scope.
* Permission compatibility exports.
*
* `triggerAgent` requires the `edit` capability (ADR-0004). `edit` is granted
* by a `PermissionGrant` with role `edit` or `manage` (manage ⊇ edit, spec
* `Role.can_mono`). This module checks that for the run's requester against
* the project resource.
*
* Scope decision (project-wise): the resource is `project`, not
* `project_group` — the run and lock scope to the project (ADR-0002), so the
* capability is checked against the project. per-artifact permission is `OPEN`
* pending a Courseware-side artifact id (ADR-0004 + ADR-0007 mapping).
*
* principal→user mapping is `OPEN` (ADR-0004 principal sub-typology). Here we
* use the sender's Feishu open_id as the principal string literal — a pragmatic
* choice for the first cut, not a spec decision. When a real principal model
* lands, this is the single seam to change.
*
* settings.agentTrigger composition is `OPEN` (ADR-0004 separates role-derived
* capabilities from settings policy knobs but doesn't pin the composition
* rule). This gate implements the role half only; settings composition is
* deferred and clearly marked.
* ADR-0019 moves production authorization behind PrincipalResolver +
* PermissionAuthorizer. The wrappers below preserve the old helper names for
* tests and incremental callers while routing project trigger checks through
* the new authorizer.
*/
import type { PrismaClient } from "@prisma/client";
import type { PermissionRole } from "@prisma/client";
import { createPermissionAuthorizer } from "./permissions/authorizer.js";
/// The roles that grant `edit` capability (edit itself + manage, by monotonicity).
const EDIT_OR_HIGHER: ReadonlySet<PermissionRole> = new Set(["EDIT", "MANAGE"]);
export { createPermissionAuthorizer, PrismaPermissionAuthorizer, roleGrantsEdit } from "./permissions/authorizer.js";
export { PrismaPrincipalResolver, principalKey } from "./permissions/principals.js";
export { syncExternalPrincipalMemberships } from "./permissions/externalSync.js";
export { grantTeamProjectAccess, listProjectTeamAccess, revokeTeamProjectAccess } from "./permissions/projectTeamAccess.js";
export type {
AuthorizationAction,
AuthorizationDecision,
AuthorizationRequest,
AuthorizationResource,
PermissionAuthorizer,
} from "./permissions/authorizer.js";
export type {
ActorInput,
PrincipalRef,
PrincipalResolution,
PrincipalResolver,
ResolvedPrincipal,
} from "./permissions/principals.js";
export type {
ExternalPrincipalMembershipInput,
ExternalPrincipalType,
SyncExternalPrincipalMembershipsInput,
SyncExternalPrincipalMembershipsResult,
} from "./permissions/externalSync.js";
export type {
GrantTeamProjectAccessInput,
ProjectTeamAccessEntry,
RevokeTeamProjectAccessInput,
} from "./permissions/projectTeamAccess.js";
export interface PermissionResult {
readonly allowed: boolean;
readonly reason: string;
}
/**
* Check whether `principal` may trigger an agent run on `projectId`.
*
* Returns `{allowed, reason}`. On allow, the caller proceeds; on deny, the
* caller replies with the reason. Never throws — a DB error is a deny with the
* error as reason, so the run is not started on a broken state.
*/
export async function canTriggerAgent(
prisma: PrismaClient,
projectId: string,
principal: string,
): Promise<PermissionResult> {
try {
// Look for an active (non-revoked) grant on the project resource for this
// principal, with a role that grants edit or higher.
const grant = await prisma.permissionGrant.findFirst({
where: {
resourceType: "PROJECT",
resourceId: projectId,
principal,
role: { in: ["EDIT", "MANAGE"] },
revokedAt: null,
},
select: { id: true, role: true },
const decision = await createPermissionAuthorizer(prisma).can({
actor: { feishuOpenId: principal },
action: "agent.trigger",
resource: { type: "PROJECT", id: projectId },
});
if (grant !== null) {
return { allowed: true, reason: `granted by role ${grant.role}` };
}
return {
allowed: false,
reason: `no active edit+ grant for ${principal} on project ${projectId}`,
};
return { allowed: decision.allowed, reason: decision.reason };
} catch (e) {
return { allowed: false, reason: `permission check failed: ${e instanceof Error ? e.message : String(e)}` };
}
}
/**
* Per-role trigger gate (ADR-0017: roles are product config). Orthogonal to
* {@link canTriggerAgent}: that decides "can trigger an agent at all"
* (ADR-0004 triggerAgent capability); this decides "can trigger *which* role".
* Two gates in series — both must pass.
*
* Semantics: if **no** `RoleTriggerGrant` row exists for `(projectId, roleId)`
* (regardless of principal), the role is unconfigured on this project →
* **allow** (back-compat: a project that hasn't configured per-role grants
* doesn't get locked down). Once any grant exists for that role on the
* project, only principals with an active grant may trigger it. "Grants exist
* but this principal has none" → deny (admin explicitly restricted the role).
*
* `roleId` matches `RoleEntry.id` (the slash-command name). `principal` is the
* same opaque string ADR-0004 uses (sender open_id here; principal
* sub-typology OPEN).
* Role-only legacy helper. New trigger code uses `role.trigger` on
* PermissionAuthorizer so project edit+ and role grant composition happen in
* one decision.
*/
export async function canTriggerRole(
prisma: PrismaClient,
@@ -92,10 +73,6 @@ export async function canTriggerRole(
principal: string,
): Promise<PermissionResult> {
try {
// Any grant record (active OR revoked) for this (project, role)? If none,
// the role is unconfigured → allow (back-compat: no per-role restriction).
// A revoked grant still counts as "configured" — revoking one person must
// not silently reopen the role to everyone.
const anyGrant = await prisma.roleTriggerGrant.findFirst({
where: { projectId, roleId },
select: { id: true },
@@ -103,9 +80,8 @@ export async function canTriggerRole(
if (anyGrant === null) {
return { allowed: true, reason: `role ${roleId} unconfigured on project (open)` };
}
// Grants exist — this principal must hold one.
const grant = await prisma.roleTriggerGrant.findFirst({
where: { projectId, roleId, principal, revokedAt: null },
where: { projectId, roleId, principalType: "USER", principalId: principal, revokedAt: null },
select: { id: true },
});
if (grant !== null) {
@@ -119,8 +95,3 @@ export async function canTriggerRole(
return { allowed: false, reason: `role permission check failed: ${e instanceof Error ? e.message : String(e)}` };
}
}
/// Whether a role grants edit capability (edit or manage). Exported for tests.
export function roleGrantsEdit(role: PermissionRole): boolean {
return EDIT_OR_HIGHER.has(role);
}
+295
View File
@@ -0,0 +1,295 @@
import type { PermissionResourceType, PermissionRole, PrismaClient, PrincipalType } from "@prisma/client";
import { PrismaPrincipalResolver, type ActorInput, type PrincipalRef, type PrincipalResolution, type PrincipalResolver } from "./principals.js";
export type AuthorizationAction =
| "project.read"
| "project.edit"
| "collaborator.manage"
| "agent.trigger"
| "agent.cancel"
| "role.trigger";
export interface AuthorizationResource {
readonly type: PermissionResourceType;
readonly id: string;
}
export interface AuthorizationRequest {
readonly actor: ActorInput;
readonly action: AuthorizationAction;
readonly resource: AuthorizationResource;
readonly roleId?: string | undefined;
}
export interface MatchedPermissionGrant {
readonly id: string;
readonly principal: PrincipalRef;
readonly role: PermissionRole;
}
export interface MatchedRoleTriggerGrant {
readonly id: string;
readonly principal: PrincipalRef;
}
export interface AuthorizationDecision {
readonly allowed: boolean;
readonly reason: string;
readonly action: AuthorizationAction;
readonly resource: AuthorizationResource;
readonly actor: ActorInput;
readonly organizationId: string;
readonly actorUserId?: string | undefined;
readonly principals: readonly PrincipalRef[];
readonly requiredRole: PermissionRole;
readonly effectiveRole?: PermissionRole | undefined;
readonly matchedGrant?: MatchedPermissionGrant | undefined;
readonly matchedRoleGrant?: MatchedRoleTriggerGrant | undefined;
}
export interface PermissionAuthorizer {
can(req: AuthorizationRequest): Promise<AuthorizationDecision>;
}
export class PrismaPermissionAuthorizer implements PermissionAuthorizer {
constructor(
private readonly prisma: PrismaClient,
private readonly resolver: PrincipalResolver = new PrismaPrincipalResolver(prisma),
) {}
async can(req: AuthorizationRequest): Promise<AuthorizationDecision> {
if (req.action === "role.trigger" && (req.roleId === undefined || req.roleId === "")) {
throw new Error("role.trigger authorization requires roleId");
}
const organizationId = await this.organizationForResource(req.resource);
const resolution = await this.resolver.resolveActor(req.actor, { organizationId });
const threshold = await this.requiredRole(req);
if (threshold === "DISABLED") {
return this.decision(req, resolution, {
allowed: false,
reason: "permission setting disables this action",
requiredRole: defaultRequiredRole(req.action),
});
}
const grant = await this.bestGrant(req.resource, resolution.principals, threshold);
if (grant === undefined) {
return this.decision(req, resolution, {
allowed: false,
reason: `no active ${threshold}+ grant for resolved principals`,
requiredRole: threshold,
});
}
if (req.action !== "role.trigger") {
return this.decision(req, resolution, {
allowed: true,
reason: `granted by ${grant.principal.type}:${grant.principal.id} ${grant.role}`,
requiredRole: threshold,
effectiveRole: grant.role,
matchedGrant: grant,
});
}
const roleGate = await this.roleTriggerGrant(req.resource, req.roleId!, resolution.principals);
if (roleGate.status === "open") {
return this.decision(req, resolution, {
allowed: true,
reason: `role ${req.roleId} unconfigured on project (open)`,
requiredRole: threshold,
effectiveRole: grant.role,
matchedGrant: grant,
});
}
if (roleGate.grant !== undefined) {
return this.decision(req, resolution, {
allowed: true,
reason: `role ${req.roleId} granted by ${roleGate.grant.principal.type}:${roleGate.grant.principal.id}`,
requiredRole: threshold,
effectiveRole: grant.role,
matchedGrant: grant,
matchedRoleGrant: roleGate.grant,
});
}
return this.decision(req, resolution, {
allowed: false,
reason: `no active role ${req.roleId} grant for resolved principals`,
requiredRole: threshold,
effectiveRole: grant.role,
matchedGrant: grant,
});
}
private async requiredRole(req: AuthorizationRequest): Promise<PermissionRole | "DISABLED"> {
let required = defaultRequiredRole(req.action);
// ADR-0019 + PermissionGrant spec: agentTrigger and agentCancel are
// distinct policy knobs ("谁能触发" ≠ "谁能取消"). Each can tighten the
// default role to MANAGE_ONLY or DISABLE the action entirely. The default
// role (trigger=EDIT, cancel=MANAGE) is the floor when no policy is set.
if (req.resource.type === "PROJECT" && (req.action === "agent.trigger" || req.action === "role.trigger")) {
const settings = await this.prisma.permissionSettings.findFirst({
where: { resourceType: req.resource.type, resourceId: req.resource.id },
select: { agentTrigger: true },
});
const policy = normalizePolicy(settings?.agentTrigger);
if (policy === "DISABLED") return "DISABLED";
if (policy === "MANAGE_ONLY") required = "MANAGE";
} else if (req.resource.type === "PROJECT" && req.action === "agent.cancel") {
const settings = await this.prisma.permissionSettings.findFirst({
where: { resourceType: req.resource.type, resourceId: req.resource.id },
select: { agentCancel: true },
});
const policy = normalizePolicy(settings?.agentCancel);
if (policy === "DISABLED") return "DISABLED";
if (policy === "MANAGE_ONLY") required = "MANAGE";
}
return required;
}
private async bestGrant(
resource: AuthorizationResource,
principals: readonly PrincipalRef[],
threshold: PermissionRole,
): Promise<MatchedPermissionGrant | undefined> {
const grants = await this.prisma.permissionGrant.findMany({
where: {
resourceType: resource.type,
resourceId: resource.id,
revokedAt: null,
OR: principalWhere(principals),
},
select: { id: true, principalType: true, principalId: true, role: true },
});
const sufficient = grants
.filter((grant) => roleRank(grant.role) >= roleRank(threshold))
.sort((a, b) => roleRank(b.role) - roleRank(a.role))[0];
if (sufficient === undefined) return undefined;
return {
id: sufficient.id,
principal: { type: sufficient.principalType, id: sufficient.principalId },
role: sufficient.role,
};
}
private async roleTriggerGrant(
resource: AuthorizationResource,
roleId: string,
principals: readonly PrincipalRef[],
): Promise<{ readonly status: "open" } | { readonly status: "configured"; readonly grant?: MatchedRoleTriggerGrant | undefined }> {
if (resource.type !== "PROJECT") {
return { status: "configured" };
}
const anyGrant = await this.prisma.roleTriggerGrant.findFirst({
where: { projectId: resource.id, roleId },
select: { id: true },
});
if (anyGrant === null) return { status: "open" };
const grant = await this.prisma.roleTriggerGrant.findFirst({
where: {
projectId: resource.id,
roleId,
revokedAt: null,
OR: principalWhere(principals),
},
select: { id: true, principalType: true, principalId: true },
});
if (grant === null) return { status: "configured" };
return {
status: "configured",
grant: {
id: grant.id,
principal: { type: grant.principalType, id: grant.principalId },
},
};
}
private decision(
req: AuthorizationRequest,
resolution: PrincipalResolution,
result: Omit<AuthorizationDecision, "action" | "resource" | "actor" | "organizationId" | "actorUserId" | "principals">,
): AuthorizationDecision {
return {
action: req.action,
resource: req.resource,
actor: req.actor,
organizationId: resolution.organizationId,
...(resolution.userId !== undefined ? { actorUserId: resolution.userId } : {}),
principals: resolution.principals,
...result,
};
}
private async organizationForResource(resource: AuthorizationResource): Promise<string> {
switch (resource.type) {
case "PROJECT": {
const project = await this.prisma.project.findUnique({
where: { id: resource.id },
select: { organizationId: true },
});
if (project === null) {
throw new Error(`authorization resource missing: PROJECT ${resource.id}`);
}
return project.organizationId;
}
case "PROJECT_GROUP": {
const binding = await this.prisma.projectGroupBinding.findUnique({
where: { chatId: resource.id },
select: { project: { select: { organizationId: true } } },
});
if (binding === null) {
throw new Error(`authorization resource missing: PROJECT_GROUP ${resource.id}`);
}
return binding.project.organizationId;
}
case "ARTIFACT":
throw new Error("authorization for ARTIFACT resources requires an organization resolver");
}
}
}
export function createPermissionAuthorizer(
prisma: PrismaClient,
resolver?: PrincipalResolver,
): PermissionAuthorizer {
return new PrismaPermissionAuthorizer(prisma, resolver);
}
export function roleGrantsEdit(role: PermissionRole): boolean {
return roleRank(role) >= roleRank("EDIT");
}
export function roleRank(role: PermissionRole): number {
switch (role) {
case "READ": return 1;
case "EDIT": return 2;
case "MANAGE": return 3;
}
}
function defaultRequiredRole(action: AuthorizationAction): PermissionRole {
switch (action) {
case "project.read": return "READ";
case "project.edit": return "EDIT";
case "agent.trigger": return "EDIT";
case "role.trigger": return "EDIT";
case "collaborator.manage": return "MANAGE";
case "agent.cancel": return "MANAGE";
}
}
function normalizePolicy(policy: string | undefined): "ROLE" | "MANAGE_ONLY" | "DISABLED" {
if (policy === undefined || policy.trim() === "") return "ROLE";
const normalized = policy.trim().toUpperCase();
if (normalized === "DISABLED" || normalized === "DENY" || normalized === "OFF") return "DISABLED";
if (normalized === "MANAGE_ONLY") return "MANAGE_ONLY";
return "ROLE";
}
function principalWhere(principals: readonly PrincipalRef[]): Array<{ principalType: PrincipalType; principalId: string }> {
return principals.map((principal) => ({
principalType: principal.type,
principalId: principal.id,
}));
}
+149
View File
@@ -0,0 +1,149 @@
import type { PrismaClient, PrincipalType } from "@prisma/client";
export type ExternalPrincipalType = Extract<
PrincipalType,
"FEISHU_CHAT" | "FEISHU_DEPARTMENT" | "FEISHU_USER_GROUP"
>;
export interface ExternalPrincipalMembershipInput {
readonly feishuOpenId: string;
readonly displayName: string;
readonly principalType: ExternalPrincipalType;
readonly principalId: string;
}
export interface SyncExternalPrincipalMembershipsInput {
readonly organizationId: string;
readonly provider?: string | undefined;
readonly providerTenantId?: string | undefined;
readonly source: string;
readonly replaceSource?: boolean | undefined;
readonly memberships: readonly ExternalPrincipalMembershipInput[];
}
export interface SyncExternalPrincipalMembershipsResult {
readonly synced: number;
readonly revoked: number;
}
export async function syncExternalPrincipalMemberships(
prisma: PrismaClient,
input: SyncExternalPrincipalMembershipsInput,
): Promise<SyncExternalPrincipalMembershipsResult> {
const syncedAt = new Date();
const incomingKeys = new Set<string>();
let synced = 0;
const connection = await prisma.externalDirectoryConnection.upsert({
where: {
organizationId_provider_source: {
organizationId: input.organizationId,
provider: input.provider ?? "FEISHU",
source: input.source,
},
},
update: {
revokedAt: null,
...(input.providerTenantId !== undefined ? { providerTenantId: input.providerTenantId } : {}),
},
create: {
organizationId: input.organizationId,
provider: input.provider ?? "FEISHU",
...(input.providerTenantId !== undefined ? { providerTenantId: input.providerTenantId } : {}),
source: input.source,
},
select: { id: true },
});
for (const item of input.memberships) {
assertExternalPrincipalType(item.principalType);
const user = await prisma.user.upsert({
where: { feishuOpenId: item.feishuOpenId },
update: { displayName: item.displayName },
create: {
feishuOpenId: item.feishuOpenId,
displayName: item.displayName,
platformRoles: { create: { role: "TEACHER" } },
organizationMemberships: { create: { organizationId: input.organizationId, role: "MEMBER" } },
},
});
await ensureOrganizationMembership(prisma, input.organizationId, user.id);
incomingKeys.add(externalMembershipKey(user.id, item.principalType, item.principalId, connection.id));
const existing = await prisma.externalPrincipalMembership.findFirst({
where: {
userId: user.id,
principalType: item.principalType,
principalId: item.principalId,
connectionId: connection.id,
revokedAt: null,
},
select: { id: true },
});
if (existing === null) {
await prisma.externalPrincipalMembership.create({
data: {
userId: user.id,
connectionId: connection.id,
principalType: item.principalType,
principalId: item.principalId,
syncedAt,
},
});
} else {
await prisma.externalPrincipalMembership.update({
where: { id: existing.id },
data: { syncedAt },
});
}
synced++;
}
let revoked = 0;
if (input.replaceSource === true) {
const active = await prisma.externalPrincipalMembership.findMany({
where: { connectionId: connection.id, revokedAt: null },
select: { id: true, userId: true, principalType: true, principalId: true, connectionId: true },
});
const revokeIds = active
.filter((membership) => !incomingKeys.has(externalMembershipKey(
membership.userId,
membership.principalType,
membership.principalId,
membership.connectionId,
)))
.map((membership) => membership.id);
if (revokeIds.length > 0) {
const result = await prisma.externalPrincipalMembership.updateMany({
where: { id: { in: revokeIds } },
data: { revokedAt: syncedAt },
});
revoked = result.count;
}
}
return { synced, revoked };
}
async function ensureOrganizationMembership(
prisma: PrismaClient,
organizationId: string,
userId: string,
): Promise<void> {
const active = await prisma.organizationMembership.findFirst({
where: { organizationId, userId, revokedAt: null },
select: { id: true },
});
if (active !== null) return;
await prisma.organizationMembership.create({
data: { organizationId, userId, role: "MEMBER" },
});
}
function externalMembershipKey(userId: string, type: PrincipalType, id: string, connectionId: string): string {
return `${connectionId}:${userId}:${type}:${id}`;
}
function assertExternalPrincipalType(type: PrincipalType): asserts type is ExternalPrincipalType {
if (type !== "FEISHU_CHAT" && type !== "FEISHU_DEPARTMENT" && type !== "FEISHU_USER_GROUP") {
throw new Error(`unsupported external principal type: ${type}`);
}
}
+152
View File
@@ -0,0 +1,152 @@
import type { PrismaClient, PrincipalType } from "@prisma/client";
export interface PrincipalRef {
readonly type: PrincipalType;
readonly id: string;
}
export interface ActorInput {
readonly feishuOpenId: string;
readonly chatId?: string | undefined;
}
export interface PrincipalResolutionScope {
readonly organizationId: string;
}
export type PrincipalSource =
| "actor-user"
| "context-chat"
| "team-membership"
| "external-membership"
| "team-external-binding";
export interface ResolvedPrincipal {
readonly principal: PrincipalRef;
readonly source: PrincipalSource;
readonly via?: PrincipalRef | undefined;
}
export interface PrincipalResolution {
readonly actor: ActorInput;
readonly organizationId: string;
readonly userId?: string | undefined;
readonly principals: readonly PrincipalRef[];
readonly resolved: readonly ResolvedPrincipal[];
}
export interface PrincipalResolver {
resolveActor(actor: ActorInput, scope: PrincipalResolutionScope): Promise<PrincipalResolution>;
}
export class PrismaPrincipalResolver implements PrincipalResolver {
constructor(private readonly prisma: PrismaClient) {}
async resolveActor(actor: ActorInput, scope: PrincipalResolutionScope): Promise<PrincipalResolution> {
const resolved = new PrincipalCollector();
resolved.add({ type: "USER", id: actor.feishuOpenId }, "actor-user");
if (actor.chatId !== undefined && actor.chatId !== "") {
resolved.add({ type: "FEISHU_CHAT", id: actor.chatId }, "context-chat");
}
const user = await this.prisma.user.findUnique({
where: { feishuOpenId: actor.feishuOpenId },
select: { id: true },
});
if (user !== null) {
const memberships = await this.prisma.teamMembership.findMany({
where: {
userId: user.id,
revokedAt: null,
team: { organizationId: scope.organizationId, archivedAt: null },
},
select: { teamId: true },
});
for (const membership of memberships) {
resolved.add({ type: "TEAM", id: membership.teamId }, "team-membership");
}
const externalMemberships = await this.prisma.externalPrincipalMembership.findMany({
where: {
userId: user.id,
revokedAt: null,
connection: {
organizationId: scope.organizationId,
revokedAt: null,
},
},
select: { principalType: true, principalId: true },
});
for (const membership of externalMemberships) {
resolved.add(
{ type: membership.principalType, id: membership.principalId },
"external-membership",
);
}
}
const externalPrincipals = resolved.principals().filter((principal) => isExternalPrincipal(principal.type));
if (externalPrincipals.length > 0) {
const bindings = await this.prisma.teamExternalBinding.findMany({
where: {
revokedAt: null,
team: { organizationId: scope.organizationId, archivedAt: null },
OR: externalPrincipals.map((principal) => ({
principalType: principal.type,
principalId: principal.id,
})),
},
select: { teamId: true, principalType: true, principalId: true },
});
for (const binding of bindings) {
resolved.add(
{ type: "TEAM", id: binding.teamId },
"team-external-binding",
{ type: binding.principalType, id: binding.principalId },
);
}
}
return {
actor,
organizationId: scope.organizationId,
...(user !== null ? { userId: user.id } : {}),
principals: resolved.principals(),
resolved: resolved.entries(),
};
}
}
export function principalKey(principal: PrincipalRef): string {
return `${principal.type}:${principal.id}`;
}
function isExternalPrincipal(type: PrincipalType): boolean {
return type === "FEISHU_CHAT" || type === "FEISHU_DEPARTMENT" || type === "FEISHU_USER_GROUP";
}
class PrincipalCollector {
private readonly refs = new Map<string, PrincipalRef>();
private readonly provenance: ResolvedPrincipal[] = [];
add(principal: PrincipalRef, source: PrincipalSource, via?: PrincipalRef): void {
const key = principalKey(principal);
if (!this.refs.has(key)) {
this.refs.set(key, principal);
}
this.provenance.push({
principal,
source,
...(via !== undefined ? { via } : {}),
});
}
principals(): readonly PrincipalRef[] {
return [...this.refs.values()];
}
entries(): readonly ResolvedPrincipal[] {
return this.provenance;
}
}
+218
View File
@@ -0,0 +1,218 @@
import type { PermissionRole, Prisma, PrismaClient } from "@prisma/client";
export interface GrantTeamProjectAccessInput {
readonly projectId: string;
readonly teamId?: string | undefined;
readonly teamSlug?: string | undefined;
readonly role: PermissionRole;
readonly createdByUserId?: string | undefined;
}
export interface RevokeTeamProjectAccessInput {
readonly projectId: string;
readonly teamId?: string | undefined;
readonly teamSlug?: string | undefined;
}
export interface ProjectTeamAccessEntry {
readonly grantId: string;
readonly projectId: string;
readonly organizationId: string;
readonly teamId: string;
readonly teamSlug: string;
readonly teamName: string;
readonly role: PermissionRole;
}
export async function grantTeamProjectAccess(
prisma: PrismaClient,
input: GrantTeamProjectAccessInput,
): Promise<ProjectTeamAccessEntry> {
return prisma.$transaction(async (tx) => {
const { project, team } = await resolveProjectAndTeam(tx, input);
const now = new Date();
const existing = await tx.permissionGrant.findFirst({
where: {
resourceType: "PROJECT",
resourceId: project.id,
principalType: "TEAM",
principalId: team.id,
revokedAt: null,
},
select: { id: true, role: true },
});
if (existing?.role === input.role) {
return entryFromGrant({
grantId: existing.id,
projectId: project.id,
organizationId: project.organizationId,
team,
role: existing.role,
});
}
await tx.permissionGrant.updateMany({
where: {
resourceType: "PROJECT",
resourceId: project.id,
principalType: "TEAM",
principalId: team.id,
revokedAt: null,
},
data: { revokedAt: now },
});
const grant = await tx.permissionGrant.create({
data: {
resourceType: "PROJECT",
resourceId: project.id,
principalType: "TEAM",
principalId: team.id,
role: input.role,
...(input.createdByUserId !== undefined ? { createdByUserId: input.createdByUserId } : {}),
},
select: { id: true, role: true },
});
return entryFromGrant({
grantId: grant.id,
projectId: project.id,
organizationId: project.organizationId,
team,
role: grant.role,
});
});
}
export async function revokeTeamProjectAccess(
prisma: PrismaClient,
input: RevokeTeamProjectAccessInput,
): Promise<number> {
return prisma.$transaction(async (tx) => {
const { project, team } = await resolveProjectAndTeam(tx, input);
const result = await tx.permissionGrant.updateMany({
where: {
resourceType: "PROJECT",
resourceId: project.id,
principalType: "TEAM",
principalId: team.id,
revokedAt: null,
},
data: { revokedAt: new Date() },
});
return result.count;
});
}
export async function listProjectTeamAccess(
prisma: PrismaClient,
projectId: string,
): Promise<readonly ProjectTeamAccessEntry[]> {
const project = await prisma.project.findUnique({
where: { id: projectId },
select: { id: true, organizationId: true },
});
if (project === null) {
throw new Error(`project not found: ${projectId}`);
}
const grants = await prisma.permissionGrant.findMany({
where: {
resourceType: "PROJECT",
resourceId: projectId,
principalType: "TEAM",
revokedAt: null,
},
select: { id: true, principalId: true, role: true },
orderBy: { createdAt: "asc" },
});
if (grants.length === 0) return [];
const teams = await prisma.team.findMany({
where: {
organizationId: project.organizationId,
id: { in: grants.map((grant) => grant.principalId) },
archivedAt: null,
},
select: { id: true, slug: true, name: true },
});
const teamsById = new Map(teams.map((team) => [team.id, team]));
const missing = grants.filter((grant) => !teamsById.has(grant.principalId));
if (missing.length > 0) {
throw new Error(
`project ${projectId} has active TEAM grants outside organization ${project.organizationId}: ${missing.map((grant) => grant.principalId).join(", ")}`,
);
}
return grants.map((grant) => entryFromGrant({
grantId: grant.id,
projectId: project.id,
organizationId: project.organizationId,
team: teamsById.get(grant.principalId)!,
role: grant.role,
}));
}
type ProjectForAccess = {
readonly id: string;
readonly organizationId: string;
};
type TeamForAccess = {
readonly id: string;
readonly slug: string;
readonly name: string;
};
async function resolveProjectAndTeam(
prisma: PrismaClient | Prisma.TransactionClient,
input: { readonly projectId: string; readonly teamId?: string | undefined; readonly teamSlug?: string | undefined },
): Promise<{ readonly project: ProjectForAccess; readonly team: TeamForAccess }> {
if ((input.teamId === undefined || input.teamId === "") && (input.teamSlug === undefined || input.teamSlug === "")) {
throw new Error("granting team project access requires teamId or teamSlug");
}
if (input.teamId !== undefined && input.teamId !== "" && input.teamSlug !== undefined && input.teamSlug !== "") {
throw new Error("granting team project access accepts only one of teamId or teamSlug");
}
const project = await prisma.project.findUnique({
where: { id: input.projectId },
select: { id: true, organizationId: true },
});
if (project === null) {
throw new Error(`project not found: ${input.projectId}`);
}
const team = await prisma.team.findFirst({
where:
input.teamId !== undefined && input.teamId !== ""
? { id: input.teamId, archivedAt: null }
: { organizationId: project.organizationId, slug: input.teamSlug!, archivedAt: null },
select: { id: true, slug: true, name: true, organizationId: true },
});
if (team === null) {
const label = input.teamId ?? input.teamSlug;
throw new Error(`active team not found for project ${input.projectId}: ${label}`);
}
if (team.organizationId !== project.organizationId) {
throw new Error(
`cross-organization team grant refused: project ${project.id} is in ${project.organizationId}, team ${team.id} is in ${team.organizationId}`,
);
}
return { project, team };
}
function entryFromGrant(input: {
readonly grantId: string;
readonly projectId: string;
readonly organizationId: string;
readonly team: TeamForAccess;
readonly role: PermissionRole;
}): ProjectTeamAccessEntry {
return {
grantId: input.grantId,
projectId: input.projectId,
organizationId: input.organizationId,
teamId: input.team.id,
teamSlug: input.team.slug,
teamName: input.team.name,
role: input.role,
};
}
+40 -57
View File
@@ -8,19 +8,15 @@
* 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
* 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 { createModelFactory } from "./agent/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 { 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];
@@ -31,10 +27,19 @@ function requireEnv(name: string): string {
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; // prisma reads DATABASE_URL from env directly
requireEnv("OPENROUTER_API_KEY");
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");
@@ -42,55 +47,33 @@ async function main(): Promise<void> {
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() }));
// --- Agent seam wiring ---
const modelFactory = createModelFactory();
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",
systemPrompt: undefined,
// Drafting needs full workspace + cph access.
tools: ["read_file", "write_file", "list_files", "cph_check", "cph_build", "feishu_read_context"],
},
{
id: "review",
label: "审校",
defaultModel: "anthropic/claude-3.5-sonnet",
systemPrompt: undefined,
// Review is read-only on artifacts; no write_file, no build.
tools: ["read_file", "list_files", "cph_check", "feishu_read_context"],
},
],
);
// 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("feishu_read_context", feishuContextTool(readFeishuContext, feishuRt));
// Workspace file tools (ADR-0007 directory tree, path-confined).
tools.register("read_file", readFileTool);
tools.register("write_file", writeFileTool);
tools.register("list_files", listFilesTool);
// cph CLI tools (ADR-0016 version contract enforced by cph itself).
tools.register("cph_check", cphCheckTool);
tools.register("cph_build", cphBuildTool);
// --- Feishu listener (reuses the same lark client) ---
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: app.log });
startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger);
// --- 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 });
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) {
+128
View File
@@ -0,0 +1,128 @@
import { InMemoryModelRegistry, type ModelRegistry } from "../agent/models.js";
export type Env = Readonly<Record<string, string | undefined>>;
type EnvSource = Env | (() => Env);
const DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api";
const DEFAULT_SONNET_MODEL = "anthropic/claude-sonnet-5";
const DEFAULT_SONNET_LABEL = "Claude Sonnet 5";
const DEFAULT_AGENT_MAX_TURNS = 25;
export interface ProviderRuntimeSettings {
readonly id: string;
readonly baseUrl: string;
readonly authToken: string;
readonly anthropicApiKey: string;
readonly sdkEnv: Record<string, string | undefined>;
}
export interface RuntimeScope {
readonly projectId?: string;
}
export interface RunPolicyInput {
readonly projectId: string;
readonly roleId: string;
}
export interface RunPolicy {
readonly maxTurns: number;
}
export interface RuntimeSettings {
provider(providerId: string, scope?: RuntimeScope): Promise<ProviderRuntimeSettings>;
modelRegistry(scope?: RuntimeScope): Promise<ModelRegistry>;
runPolicy(input: RunPolicyInput): Promise<RunPolicy>;
}
export class EnvRuntimeSettings implements RuntimeSettings {
private readonly envSource: () => Env;
constructor(env: EnvSource = process.env) {
this.envSource = typeof env === "function" ? env : () => env;
}
async provider(providerId: string, scope?: RuntimeScope): Promise<ProviderRuntimeSettings> {
void scope;
if (providerId !== "openrouter") {
throw new Error(`unknown provider runtime settings: ${providerId}`);
}
const env = this.envSource();
const baseUrl = readEnv(env, "ANTHROPIC_BASE_URL") ?? DEFAULT_OPENROUTER_BASE_URL;
const authToken = requireSetting(env, "ANTHROPIC_AUTH_TOKEN");
const anthropicApiKey = readEnv(env, "ANTHROPIC_API_KEY") ?? "";
return {
id: providerId,
baseUrl,
authToken,
anthropicApiKey,
sdkEnv: {
ANTHROPIC_BASE_URL: baseUrl,
ANTHROPIC_AUTH_TOKEN: authToken,
ANTHROPIC_API_KEY: anthropicApiKey,
},
};
}
async modelRegistry(scope?: RuntimeScope): Promise<ModelRegistry> {
void scope;
return createDefaultModelRegistry(this.envSource());
}
async runPolicy(input: RunPolicyInput): Promise<RunPolicy> {
void input;
return {
maxTurns: positiveIntegerEnv(this.envSource(), "HUB_AGENT_MAX_TURNS", DEFAULT_AGENT_MAX_TURNS),
};
}
}
export function createEnvRuntimeSettings(env: EnvSource = process.env): RuntimeSettings {
return new EnvRuntimeSettings(env);
}
export function defaultSonnetModel(env: Env = process.env): string {
return readEnv(env, "ANTHROPIC_DEFAULT_SONNET_MODEL") ?? DEFAULT_SONNET_MODEL;
}
export function createDefaultModelRegistry(env: Env = process.env): InMemoryModelRegistry {
const sonnetModel = defaultSonnetModel(env);
const sonnetLabel =
readEnv(env, "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME") ??
(sonnetModel === DEFAULT_SONNET_MODEL ? DEFAULT_SONNET_LABEL : sonnetModel);
return new InMemoryModelRegistry(
[
{ id: sonnetModel, label: sonnetLabel, toolCapable: true },
],
[
{ id: "draft", label: "草稿", defaultModel: sonnetModel },
{ id: "review", label: "审校", defaultModel: sonnetModel },
],
);
}
function readEnv(env: Env, name: string): string | undefined {
const value = env[name]?.trim();
return value === undefined || value === "" ? undefined : value;
}
function requireSetting(env: Env, name: string): string {
const value = readEnv(env, name);
if (value === undefined) {
throw new Error(`missing required runtime setting: ${name}`);
}
return value;
}
function positiveIntegerEnv(env: Env, name: string, fallback: number): number {
const raw = readEnv(env, name);
if (raw === undefined) return fallback;
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`runtime setting ${name} must be a positive integer`);
}
return value;
}
+448
View File
@@ -0,0 +1,448 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization } from "./helpers.js";
import {
createPermissionAuthorizer,
grantTeamProjectAccess,
listProjectTeamAccess,
PrismaPrincipalResolver,
syncExternalPrincipalMemberships,
} from "../../src/permission.js";
describe("ADR-0019 authorization", () => {
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await prisma.$disconnect();
});
it("resolves actor principals from user, chat, direct teams, external memberships, and external team bindings", async () => {
const user = await prisma.user.create({
data: { id: "u-auth-1", feishuOpenId: "ou_auth_1", displayName: "Teacher A" },
});
const directTeam = await prisma.team.create({
data: { organizationId: DEFAULT_ORG_ID, slug: "direct-auth", name: "Direct Team" },
});
const departmentTeam = await prisma.team.create({
data: { organizationId: DEFAULT_ORG_ID, slug: "department-auth", name: "Department Team" },
});
const chatTeam = await prisma.team.create({
data: { organizationId: DEFAULT_ORG_ID, slug: "chat-auth", name: "Chat Team" },
});
await prisma.teamMembership.create({
data: { teamId: directTeam.id, userId: user.id },
});
const connection = await prisma.externalDirectoryConnection.create({
data: {
organizationId: DEFAULT_ORG_ID,
provider: "FEISHU",
source: "test",
},
});
await prisma.externalPrincipalMembership.create({
data: {
userId: user.id,
connectionId: connection.id,
principalType: "FEISHU_DEPARTMENT",
principalId: "dep-physics",
},
});
await prisma.teamExternalBinding.create({
data: { teamId: departmentTeam.id, principalType: "FEISHU_DEPARTMENT", principalId: "dep-physics" },
});
await prisma.teamExternalBinding.create({
data: { teamId: chatTeam.id, principalType: "FEISHU_CHAT", principalId: "chat-auth" },
});
const resolution = await new PrismaPrincipalResolver(prisma).resolveActor({
feishuOpenId: "ou_auth_1",
chatId: "chat-auth",
}, { organizationId: DEFAULT_ORG_ID });
expect(resolution.userId).toBe(user.id);
expect(resolution.principals).toEqual(expect.arrayContaining([
{ type: "USER", id: "ou_auth_1" },
{ type: "FEISHU_CHAT", id: "chat-auth" },
{ type: "FEISHU_DEPARTMENT", id: "dep-physics" },
{ type: "TEAM", id: directTeam.id },
{ type: "TEAM", id: departmentTeam.id },
{ type: "TEAM", id: chatTeam.id },
]));
});
it("allows agent trigger through a team project grant", async () => {
const { team } = await seedUserTeamProject("auth-team-grant");
await prisma.permissionGrant.create({
data: {
resourceType: "PROJECT",
resourceId: "p-auth-team-grant",
principalType: "TEAM",
principalId: team.id,
role: "EDIT",
},
});
const decision = await createPermissionAuthorizer(prisma).can({
actor: { feishuOpenId: "ou_auth_team_grant" },
action: "agent.trigger",
resource: { type: "PROJECT", id: "p-auth-team-grant" },
});
expect(decision.allowed).toBe(true);
expect(decision.matchedGrant).toMatchObject({
principal: { type: "TEAM", id: team.id },
role: "EDIT",
});
});
it("uses external Feishu principal memberships as grantable principals", async () => {
await prisma.project.create({
data: { id: "p-auth-ext", organizationId: DEFAULT_ORG_ID, name: "External", workspaceDir: "/tmp/auth-ext" },
});
await syncExternalPrincipalMemberships(prisma, {
organizationId: DEFAULT_ORG_ID,
source: "feishu-test",
memberships: [
{
feishuOpenId: "ou_auth_ext",
displayName: "Teacher Ext",
principalType: "FEISHU_USER_GROUP",
principalId: "ug-lesson-design",
},
],
});
await prisma.permissionGrant.create({
data: {
resourceType: "PROJECT",
resourceId: "p-auth-ext",
principalType: "FEISHU_USER_GROUP",
principalId: "ug-lesson-design",
role: "EDIT",
},
});
const decision = await createPermissionAuthorizer(prisma).can({
actor: { feishuOpenId: "ou_auth_ext" },
action: "agent.trigger",
resource: { type: "PROJECT", id: "p-auth-ext" },
});
expect(decision.allowed).toBe(true);
expect(decision.matchedGrant?.principal).toEqual({
type: "FEISHU_USER_GROUP",
id: "ug-lesson-design",
});
});
it("revokes missing external memberships when replacing a sync source", async () => {
await syncExternalPrincipalMemberships(prisma, {
organizationId: DEFAULT_ORG_ID,
source: "feishu-directory",
memberships: [
{
feishuOpenId: "ou_sync_a",
displayName: "Teacher A",
principalType: "FEISHU_DEPARTMENT",
principalId: "dep-a",
},
{
feishuOpenId: "ou_sync_b",
displayName: "Teacher B",
principalType: "FEISHU_DEPARTMENT",
principalId: "dep-b",
},
],
});
const result = await syncExternalPrincipalMemberships(prisma, {
organizationId: DEFAULT_ORG_ID,
source: "feishu-directory",
replaceSource: true,
memberships: [
{
feishuOpenId: "ou_sync_a",
displayName: "Teacher A",
principalType: "FEISHU_DEPARTMENT",
principalId: "dep-a",
},
],
});
expect(result).toEqual({ synced: 1, revoked: 1 });
const activeB = await new PrismaPrincipalResolver(prisma).resolveActor(
{ feishuOpenId: "ou_sync_b" },
{ organizationId: DEFAULT_ORG_ID },
);
expect(activeB.principals).not.toContainEqual({ type: "FEISHU_DEPARTMENT", id: "dep-b" });
});
it("lets PermissionSettings constrain agent trigger without granting by itself", async () => {
await prisma.project.create({
data: { id: "p-auth-policy", organizationId: DEFAULT_ORG_ID, name: "Policy", workspaceDir: "/tmp/auth-policy" },
});
await prisma.user.create({
data: { id: "u-auth-policy", feishuOpenId: "ou_auth_policy", displayName: "Teacher Policy" },
});
await prisma.permissionGrant.create({
data: {
resourceType: "PROJECT",
resourceId: "p-auth-policy",
principalType: "USER",
principalId: "ou_auth_policy",
role: "EDIT",
},
});
await prisma.permissionSettings.create({
data: defaultProjectSettings("p-auth-policy", { agentTrigger: "MANAGE_ONLY" }),
});
const editDecision = await createPermissionAuthorizer(prisma).can({
actor: { feishuOpenId: "ou_auth_policy" },
action: "agent.trigger",
resource: { type: "PROJECT", id: "p-auth-policy" },
});
expect(editDecision.allowed).toBe(false);
await prisma.permissionGrant.updateMany({
where: {
resourceType: "PROJECT",
resourceId: "p-auth-policy",
principalType: "USER",
principalId: "ou_auth_policy",
revokedAt: null,
},
data: {
role: "MANAGE",
},
});
const manageDecision = await createPermissionAuthorizer(prisma).can({
actor: { feishuOpenId: "ou_auth_policy" },
action: "agent.trigger",
resource: { type: "PROJECT", id: "p-auth-policy" },
});
expect(manageDecision.allowed).toBe(true);
expect(manageDecision.requiredRole).toBe("MANAGE");
});
it("requires project agent.trigger and configured role trigger grant for role.trigger", async () => {
const { team } = await seedUserTeamProject("auth-role");
await prisma.permissionGrant.create({
data: {
resourceType: "PROJECT",
resourceId: "p-auth-role",
principalType: "TEAM",
principalId: team.id,
role: "EDIT",
},
});
await prisma.roleTriggerGrant.create({
data: {
projectId: "p-auth-role",
roleId: "review",
principalType: "USER",
principalId: "ou_someone_else",
},
});
const denied = await createPermissionAuthorizer(prisma).can({
actor: { feishuOpenId: "ou_auth_role" },
action: "role.trigger",
resource: { type: "PROJECT", id: "p-auth-role" },
roleId: "review",
});
expect(denied.allowed).toBe(false);
await prisma.roleTriggerGrant.create({
data: {
projectId: "p-auth-role",
roleId: "review",
principalType: "TEAM",
principalId: team.id,
},
});
const allowed = await createPermissionAuthorizer(prisma).can({
actor: { feishuOpenId: "ou_auth_role" },
action: "role.trigger",
resource: { type: "PROJECT", id: "p-auth-role" },
roleId: "review",
});
expect(allowed.allowed).toBe(true);
expect(allowed.matchedRoleGrant?.principal).toEqual({ type: "TEAM", id: team.id });
});
it("keeps a role configured when all role grants are revoked", async () => {
await prisma.project.create({
data: { id: "p-auth-revoked-role", organizationId: DEFAULT_ORG_ID, name: "Revoked", workspaceDir: "/tmp/auth-revoked" },
});
await prisma.user.create({
data: { id: "u-auth-revoked-role", feishuOpenId: "ou_auth_revoked", displayName: "Teacher Revoked" },
});
await prisma.permissionGrant.create({
data: {
resourceType: "PROJECT",
resourceId: "p-auth-revoked-role",
principalType: "USER",
principalId: "ou_auth_revoked",
role: "EDIT",
},
});
await prisma.roleTriggerGrant.create({
data: {
projectId: "p-auth-revoked-role",
roleId: "review",
principalType: "USER",
principalId: "ou_auth_revoked",
revokedAt: new Date(),
},
});
const decision = await createPermissionAuthorizer(prisma).can({
actor: { feishuOpenId: "ou_auth_revoked" },
action: "role.trigger",
resource: { type: "PROJECT", id: "p-auth-revoked-role" },
roleId: "review",
});
expect(decision.allowed).toBe(false);
expect(decision.reason).toContain("no active role review grant");
});
it("agent.cancel defaults to MANAGE and is allowed for a manage grant", async () => {
await prisma.project.create({ data: { id: "p-cancel", organizationId: DEFAULT_ORG_ID, name: "Cancel", workspaceDir: "/tmp/cancel" } });
await prisma.user.create({ data: { id: "u-cancel", feishuOpenId: "ou_cancel", displayName: "Manager" } });
await prisma.permissionGrant.create({
data: { resourceType: "PROJECT", resourceId: "p-cancel", principalType: "USER", principalId: "ou_cancel", role: "MANAGE" },
});
const decision = await createPermissionAuthorizer(prisma).can({
actor: { feishuOpenId: "ou_cancel" },
action: "agent.cancel",
resource: { type: "PROJECT", id: "p-cancel" },
});
expect(decision.allowed).toBe(true);
expect(decision.requiredRole).toBe("MANAGE");
});
it("agent.cancel is denied to an edit grant (below the MANAGE default)", async () => {
await prisma.project.create({ data: { id: "p-cancel-edit", organizationId: DEFAULT_ORG_ID, name: "Cancel Edit", workspaceDir: "/tmp/cancel-edit" } });
await prisma.user.create({ data: { id: "u-cancel-edit", feishuOpenId: "ou_cancel_edit", displayName: "Editor" } });
await prisma.permissionGrant.create({
data: { resourceType: "PROJECT", resourceId: "p-cancel-edit", principalType: "USER", principalId: "ou_cancel_edit", role: "EDIT" },
});
const decision = await createPermissionAuthorizer(prisma).can({
actor: { feishuOpenId: "ou_cancel_edit" },
action: "agent.cancel",
resource: { type: "PROJECT", id: "p-cancel-edit" },
});
expect(decision.allowed).toBe(false);
});
it("agentCancel DISABLED policy denies cancel even for a manage grant", async () => {
await prisma.project.create({ data: { id: "p-cancel-off", organizationId: DEFAULT_ORG_ID, name: "Cancel Off", workspaceDir: "/tmp/cancel-off" } });
await prisma.user.create({ data: { id: "u-cancel-off", feishuOpenId: "ou_cancel_off", displayName: "Manager Off" } });
await prisma.permissionGrant.create({
data: { resourceType: "PROJECT", resourceId: "p-cancel-off", principalType: "USER", principalId: "ou_cancel_off", role: "MANAGE" },
});
await prisma.permissionSettings.create({
data: defaultProjectSettings("p-cancel-off", { agentCancel: "DISABLED" }),
});
const decision = await createPermissionAuthorizer(prisma).can({
actor: { feishuOpenId: "ou_cancel_off" },
action: "agent.cancel",
resource: { type: "PROJECT", id: "p-cancel-off" },
});
expect(decision.allowed).toBe(false);
expect(decision.reason).toContain("disables this action");
});
it("scopes team slug and project team access by organization", async () => {
await seedTestOrganization("org-other", "other");
await prisma.team.create({
data: { organizationId: "org-other", slug: "curriculum", name: "Other Curriculum" },
});
const team = await prisma.team.create({
data: { organizationId: DEFAULT_ORG_ID, slug: "curriculum", name: "Default Curriculum" },
});
await prisma.project.create({
data: { id: "p-team-access", organizationId: DEFAULT_ORG_ID, name: "Team Access", workspaceDir: "/tmp/team-access" },
});
const entry = await grantTeamProjectAccess(prisma, {
projectId: "p-team-access",
teamSlug: "curriculum",
role: "EDIT",
});
expect(entry).toMatchObject({
projectId: "p-team-access",
organizationId: DEFAULT_ORG_ID,
teamId: team.id,
teamSlug: "curriculum",
role: "EDIT",
});
await grantTeamProjectAccess(prisma, {
projectId: "p-team-access",
teamSlug: "curriculum",
role: "MANAGE",
});
const entries = await listProjectTeamAccess(prisma, "p-team-access");
expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({ teamId: team.id, role: "MANAGE" });
});
it("refuses cross-organization team project grants", async () => {
await seedTestOrganization("org-other-deny", "other-deny");
const otherTeam = await prisma.team.create({
data: { organizationId: "org-other-deny", slug: "review", name: "Other Review" },
});
await prisma.project.create({
data: { id: "p-cross-org", organizationId: DEFAULT_ORG_ID, name: "Cross Org", workspaceDir: "/tmp/cross-org" },
});
await expect(grantTeamProjectAccess(prisma, {
projectId: "p-cross-org",
teamId: otherTeam.id,
role: "EDIT",
})).rejects.toThrow(/cross-organization team grant refused/);
});
});
async function seedUserTeamProject(suffix: string) {
const user = await prisma.user.create({
data: {
id: `u-${suffix}`,
feishuOpenId: `ou_${suffix.replaceAll("-", "_")}`,
displayName: "Teacher Team",
},
});
const team = await prisma.team.create({
data: { organizationId: DEFAULT_ORG_ID, slug: `team-${suffix}`, name: `Team ${suffix}` },
});
await prisma.teamMembership.create({
data: { teamId: team.id, userId: user.id },
});
const project = await prisma.project.create({
data: { id: `p-${suffix}`, organizationId: DEFAULT_ORG_ID, name: `Project ${suffix}`, workspaceDir: `/tmp/${suffix}` },
});
return { user, team, project };
}
function defaultProjectSettings(
projectId: string,
overrides: { agentTrigger?: string; agentCancel?: string } = {},
) {
return {
resourceType: "PROJECT" as const,
resourceId: projectId,
externalShare: "ROLE",
comment: "ROLE",
copyDownload: "ROLE",
collaboratorMgmt: "ROLE",
agentTrigger: overrides.agentTrigger ?? "ROLE",
agentCancel: overrides.agentCancel ?? "ROLE",
};
}
+196 -13
View File
@@ -12,12 +12,14 @@ import type {
LanguageModelV4CallOptions,
LanguageModelV4Content,
LanguageModelV4GenerateResult,
LanguageModelV4StreamPart,
LanguageModelV4StreamResult,
} from "@ai-sdk/provider";
import type { FeishuRuntime } from "../../src/feishu/client.js";
import type { ModelFactory } from "../../src/agent/runner.js";
export const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
export const DEFAULT_ORG_ID = "org_test_default";
export const prisma = new PrismaClient({
datasources: { db: { url: TEST_DATABASE_URL } },
@@ -26,20 +28,46 @@ export const prisma = new PrismaClient({
/** Truncate all tables before each test for isolation. */
export async function resetDb(): Promise<void> {
const tables = [
"FeishuEventReceipt",
"AgentFileChange",
"AgentMessage",
"AuditEntry",
"PermissionSettings",
"PermissionGrant",
"RoleTriggerGrant",
"ExternalPrincipalMembership",
"ExternalDirectoryConnection",
"TeamExternalBinding",
"TeamMembership",
"Team",
"ProjectAgentLock",
"AgentRun",
"AgentSession",
"ProjectGroupBinding",
"OrganizationMembership",
"PlatformRoleAssignment",
"User",
"Project",
"Organization",
];
// Truncate with CASCADE to wipe dependent rows in one shot.
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tables.map((t) => `"${t}"`).join(", ")} RESTART IDENTITY CASCADE`);
await seedTestOrganization();
}
export async function seedTestOrganization(
id: string = DEFAULT_ORG_ID,
slug: string = "test-default",
): Promise<void> {
await prisma.organization.upsert({
where: { id },
update: {},
create: {
id,
slug,
name: "Test Default Organization",
},
});
}
/** A logger that discards everything (tests don't need fastify's pino). */
@@ -53,33 +81,99 @@ export const silentLogger: FastifyBaseLogger = {
export interface MockFeishuRuntime extends FeishuRuntime {
readonly sentTexts: string[];
readonly sentCards: unknown[];
readonly sentPatches: unknown[];
readonly sentReplies: Array<{
readonly messageId: string;
readonly msgType: string;
readonly content: string;
readonly replyInThread: unknown;
}>;
readonly reactions: Array<{ readonly messageId: string; readonly emoji: string }>;
readonly readableMessages: Map<string, MockFeishuMessage>;
readonly threadMessages: Map<string, readonly MockFeishuMessage[]>;
}
export interface MockFeishuMessage {
readonly message_id?: string;
readonly root_id?: string;
readonly parent_id?: string;
readonly thread_id?: string;
readonly msg_type?: string;
readonly create_time?: string;
readonly chat_id?: string;
readonly sender?: { readonly id?: string; readonly sender_type?: string };
readonly body?: { readonly content?: string };
readonly content?: string;
}
export function mockFeishuRuntime(): MockFeishuRuntime {
const sentTexts: string[] = [];
const sentCards: unknown[] = [];
const sentPatches: unknown[] = [];
const sentReplies: MockFeishuRuntime["sentReplies"] = [];
const reactions: Array<{ messageId: string; emoji: string }> = [];
const readableMessages = new Map<string, MockFeishuMessage>();
const threadMessages = new Map<string, readonly MockFeishuMessage[]>();
// Mock the client — sendText/sendCard are in client.ts, not on the runtime
// object directly. We patch by providing a runtime whose client is a stub;
// the actual send functions cast through shape, so a minimal stub works.
const rt: MockFeishuRuntime = {
client: {
request: async (p: unknown) => {
const payload = p as { url?: string; data?: { reaction_type?: { emoji_type?: string } } };
const match = payload.url?.match(/\/messages\/([^/]+)\/reactions$/);
if (match?.[1] !== undefined) {
reactions.push({
messageId: match[1],
emoji: payload.data?.reaction_type?.emoji_type ?? "",
});
}
return {};
},
im: {
v1: {
message: {
create: async (p: unknown) => {
const payload = p as { data?: { msg_type?: string; content?: string } };
if (payload.data?.msg_type === "interactive") {
sentCards.push(payload.data.content ? JSON.parse(payload.data.content) : null);
} else {
try {
const c = JSON.parse(payload.data?.content ?? "{}") as { text?: string };
sentTexts.push(c.text ?? payload.data?.content ?? "");
} catch {
sentTexts.push(payload.data?.content ?? "");
}
}
recordSentMessage(payload.data, sentTexts, sentCards);
return { data: { message_id: "mock-msg-id" } };
},
reply: async (p: unknown) => {
const payload = p as {
path?: { message_id?: string };
data?: { msg_type?: string; content?: string; reply_in_thread?: unknown };
};
sentReplies.push({
messageId: payload.path?.message_id ?? "",
msgType: payload.data?.msg_type ?? "",
content: payload.data?.content ?? "",
replyInThread: payload.data?.reply_in_thread,
});
recordSentMessage(payload.data, sentTexts, sentCards);
return { data: { message_id: "mock-msg-id" } };
},
patch: async (p: unknown) => {
const payload = p as { data?: { content?: string } };
const card = payload.data?.content ? JSON.parse(payload.data.content) : null;
sentPatches.push(card);
const text = textFromCard(card);
if (text !== null) sentTexts.push(text);
return {};
},
get: async (p: unknown) => {
const payload = p as { path?: { message_id?: string } };
const messageId = payload.path?.message_id ?? "";
const message = readableMessages.get(messageId);
return { data: { items: message === undefined ? [] : [message] } };
},
list: async (p: unknown) => {
const payload = p as { params?: { container_id?: string } };
const containerId = payload.params?.container_id ?? "";
const items =
threadMessages.get(containerId) ??
[...readableMessages.values()].filter((message) => message.parent_id === containerId);
return { data: { items } };
},
},
},
},
@@ -87,10 +181,60 @@ export function mockFeishuRuntime(): MockFeishuRuntime {
logger: silentLogger,
sentTexts,
sentCards,
sentPatches,
sentReplies,
reactions,
readableMessages,
threadMessages,
};
return rt;
}
function recordSentMessage(
data: { msg_type?: string; content?: string } | undefined,
sentTexts: string[],
sentCards: unknown[],
): void {
if (data?.msg_type === "interactive") {
const card = data.content ? JSON.parse(data.content) : null;
sentCards.push(card);
const text = textFromCard(card);
if (text !== null) sentTexts.push(text);
return;
}
try {
const c = JSON.parse(data?.content ?? "{}") as { text?: string };
sentTexts.push(c.text ?? data?.content ?? "");
} catch {
sentTexts.push(data?.content ?? "");
}
}
function textFromCard(card: unknown): string | null {
if (typeof card !== "object" || card === null) return null;
const elements = (card as { elements?: unknown }).elements;
if (!Array.isArray(elements)) return null;
const parts: string[] = [];
for (const element of elements) {
if (typeof element !== "object" || element === null) continue;
// New format: { tag: "markdown", content: string }
const tag = (element as { tag?: unknown }).tag;
const directContent = (element as { content?: unknown }).content;
if (tag === "markdown" && typeof directContent === "string") {
parts.push(directContent);
continue;
}
// Old format: { text: { content: string } }
const text = (element as { text?: unknown }).text;
if (typeof text === "object" && text !== null) {
const content = (text as { content?: unknown }).content;
if (typeof content === "string") parts.push(content);
}
}
return parts.length === 0 ? null : parts.join("\n");
}
export interface MockToolCall {
readonly toolCallId: string;
readonly toolName: string;
@@ -150,8 +294,44 @@ export class MockLanguageModel implements LanguageModelV4 {
};
}
async doStream(_options: LanguageModelV4CallOptions): Promise<LanguageModelV4StreamResult> {
throw new Error("MockLanguageModel does not implement streaming");
async doStream(options: LanguageModelV4CallOptions): Promise<LanguageModelV4StreamResult> {
this.calls.push(options);
const response = this.responses[this.calls.length - 1 % this.responses.length] ?? this.responses[0]!;
const text = response.text ?? "";
const finishReason = response.finishReason ?? ((response.toolCalls?.length ?? 0) > 0 ? "tool-calls" : "stop");
const id = `mock-${this.calls.length}`;
const parts: LanguageModelV4StreamPart[] = [
{ type: "text-start", id },
];
if (text.length > 0) {
parts.push({ type: "text-delta", id, delta: text });
}
parts.push({ type: "text-end", id });
for (const call of response.toolCalls ?? []) {
const tcId = `tc-${this.calls.length}-${call.toolName}`;
parts.push({ type: "tool-input-start", id: tcId, toolName: call.toolName });
parts.push({ type: "tool-input-delta", id: tcId, delta: JSON.stringify(call.input) });
parts.push({ type: "tool-input-end", id: tcId });
}
parts.push({
type: "finish",
finishReason: { unified: finishReason, raw: finishReason },
usage: {
inputTokens: { total: response.inputTokens ?? 10, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: response.outputTokens ?? 5, text: undefined, reasoning: undefined },
},
});
return {
stream: new ReadableStream({
start(controller) {
for (const part of parts) controller.enqueue(part);
controller.close();
},
}),
response: { id, timestamp: new Date(), modelId: "mock-model" },
};
}
}
@@ -180,6 +360,7 @@ export async function seedProject(
await prisma.project.create({
data: {
id: projectId,
organizationId: DEFAULT_ORG_ID,
name: `Test ${projectId}`,
workspaceDir: `/tmp/test-${projectId}`,
},
@@ -190,11 +371,13 @@ export async function seedProject(
feishuOpenId: principal,
displayName: "Test User",
platformRoles: { create: { role: "TEACHER" } },
organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role: "MEMBER" } },
permissionGrants: {
create: {
resourceType: "PROJECT",
resourceId: projectId,
principal,
principalType: "USER",
principalId: principal,
role,
},
},
+5 -5
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterAll } from "vitest";
import { prisma, resetDb } from "./helpers.js";
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
import { acquireLock, releaseLock, currentLockRunId, isTerminal } from "../../src/lock.js";
describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => {
@@ -9,7 +9,7 @@ describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => {
it("acquireLock + currentLockRunId round-trip", async () => {
const project = await prisma.project.create({
data: { id: "p-lock-1", name: "Test", workspaceDir: "/tmp/x" },
data: { id: "p-lock-1", organizationId: DEFAULT_ORG_ID, name: "Test", workspaceDir: "/tmp/x" },
});
const run = await prisma.agentRun.create({
data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} },
@@ -26,7 +26,7 @@ describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => {
it("acquireLock fails when project already locked (exclusivity)", async () => {
const project = await prisma.project.create({
data: { id: "p-lock-2", name: "Test", workspaceDir: "/tmp/x" },
data: { id: "p-lock-2", organizationId: DEFAULT_ORG_ID, name: "Test", workspaceDir: "/tmp/x" },
});
const run1 = await prisma.agentRun.create({
data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} },
@@ -41,7 +41,7 @@ describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => {
it("currentLockRunId throws when a terminal run holds the lock (WellFormed)", async () => {
const project = await prisma.project.create({
data: { id: "p-lock-3", name: "Test", workspaceDir: "/tmp/x" },
data: { id: "p-lock-3", organizationId: DEFAULT_ORG_ID, name: "Test", workspaceDir: "/tmp/x" },
});
// Create a run that is already COMPLETED (terminal), then manually insert a lock.
const run = await prisma.agentRun.create({
@@ -57,7 +57,7 @@ describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => {
it("releaseLock is idempotent", async () => {
const project = await prisma.project.create({
data: { id: "p-lock-4", name: "Test", workspaceDir: "/tmp/x" },
data: { id: "p-lock-4", organizationId: DEFAULT_ORG_ID, name: "Test", workspaceDir: "/tmp/x" },
});
const run = await prisma.agentRun.create({
data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} },
+63
View File
@@ -0,0 +1,63 @@
/**
* Optional live-model smoke for the current Claude SDK runner.
*
* Skips unless explicitly enabled with RUN_REAL_MODEL_TESTS=true and an
* OpenRouter/Anthropic token is available. When enabled, it verifies that
* `runAgent` can complete one real SDK turn and project the user/assistant
* messages into the structured AgentMessage table shape.
*/
import { describe, it, expect } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import "dotenv/config";
import { runAgent } from "../../src/agent/runner.js";
import type { PrismaClient } from "@prisma/client";
const API_KEY = process.env["OPENROUTER_API_KEY"] ?? process.env["ANTHROPIC_AUTH_TOKEN"];
const HAS_KEY = API_KEY !== undefined && API_KEY !== "";
const ENABLED = HAS_KEY && process.env["RUN_REAL_MODEL_TESTS"] === "true";
const describeOrSkip = ENABLED ? describe : describe.skip;
if (ENABLED) {
process.env["ANTHROPIC_AUTH_TOKEN"] = API_KEY;
process.env["ANTHROPIC_BASE_URL"] ??= "https://openrouter.ai/api";
process.env["ANTHROPIC_API_KEY"] ??= "";
}
const MODEL = process.env["SMOKE_MODEL"] ?? process.env["ANTHROPIC_DEFAULT_SONNET_MODEL"] ?? "z-ai/glm-4.7";
describeOrSkip("real model integration (OpenRouter)", () => {
it("completes one live SDK turn and records structured messages", async () => {
const workspace = await mkdtemp(join(tmpdir(), "hub-real-model-"));
const messages: unknown[] = [];
const stubPrisma = {
projectAgentLock: { update: async () => ({}) },
agentMessage: {
create: async (input: unknown) => {
messages.push(input);
return {};
},
},
} as unknown as PrismaClient;
try {
const result = await runAgent({
runId: "real-test-run",
sessionId: "real-test-session",
prompt: "请只回复: OK",
model: MODEL,
project: { projectId: "real-test-project", boundChatId: "chat-test", workspaceDir: workspace },
prisma: stubPrisma,
systemPrompt: "你是一个极简 smoke test 助手。请严格按用户要求回复。",
maxTurns: 3,
});
expect(result.status, result.error).toBe("completed");
expect(result.text.length).toBeGreaterThan(0);
expect(messages.length).toBeGreaterThanOrEqual(2);
} finally {
await rm(workspace, { recursive: true, force: true });
}
}, 60_000);
});
+12 -12
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterAll } from "vitest";
import { prisma, resetDb } from "./helpers.js";
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
import { canTriggerRole } from "../../src/permission.js";
describe("canTriggerRole (integration, per-role gate)", () => {
@@ -13,7 +13,7 @@ describe("canTriggerRole (integration, per-role gate)", () => {
it("allows when role is unconfigured on the project (back-compat: open)", async () => {
const project = await prisma.project.create({
data: { id: "p-role-1", name: "T", workspaceDir: "/tmp/x" },
data: { id: "p-role-1", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" },
});
const r = await canTriggerRole(prisma, project.id, "draft", "ou_a");
expect(r.allowed).toBe(true);
@@ -21,10 +21,10 @@ describe("canTriggerRole (integration, per-role gate)", () => {
it("allows when principal holds an active grant for the role", async () => {
const project = await prisma.project.create({
data: { id: "p-role-2", name: "T", workspaceDir: "/tmp/x" },
data: { id: "p-role-2", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" },
});
await prisma.roleTriggerGrant.create({
data: { projectId: project.id, roleId: "review", principal: "ou_a" },
data: { projectId: project.id, roleId: "review", principalType: "USER", principalId: "ou_a" },
});
const r = await canTriggerRole(prisma, project.id, "review", "ou_a");
expect(r.allowed).toBe(true);
@@ -32,11 +32,11 @@ describe("canTriggerRole (integration, per-role gate)", () => {
it("denies when grants exist for the role but principal has none", async () => {
const project = await prisma.project.create({
data: { id: "p-role-3", name: "T", workspaceDir: "/tmp/x" },
data: { id: "p-role-3", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" },
});
// Someone else has the role; ou_b does not.
await prisma.roleTriggerGrant.create({
data: { projectId: project.id, roleId: "review", principal: "ou_a" },
data: { projectId: project.id, roleId: "review", principalType: "USER", principalId: "ou_a" },
});
const r = await canTriggerRole(prisma, project.id, "review", "ou_b");
expect(r.allowed).toBe(false);
@@ -44,24 +44,24 @@ describe("canTriggerRole (integration, per-role gate)", () => {
it("denies when the principal's grant was revoked", async () => {
const project = await prisma.project.create({
data: { id: "p-role-4", name: "T", workspaceDir: "/tmp/x" },
data: { id: "p-role-4", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" },
});
await prisma.roleTriggerGrant.create({
data: { projectId: project.id, roleId: "review", principal: "ou_a", revokedAt: new Date() },
data: { projectId: project.id, roleId: "review", principalType: "USER", principalId: "ou_a", revokedAt: new Date() },
});
const r = await canTriggerRole(prisma, project.id, "review", "ou_a");
expect(r.allowed).toBe(false);
});
it("scopes grants per-project (grant on p1 does not allow on p2)", async () => {
const p1 = await prisma.project.create({ data: { id: "p-role-5a", name: "T", workspaceDir: "/tmp/x" } });
const p2 = await prisma.project.create({ data: { id: "p-role-5b", name: "T", workspaceDir: "/tmp/x" } });
const p1 = await prisma.project.create({ data: { id: "p-role-5a", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" } });
const p2 = await prisma.project.create({ data: { id: "p-role-5b", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" } });
await prisma.roleTriggerGrant.create({
data: { projectId: p1.id, roleId: "review", principal: "ou_a" },
data: { projectId: p1.id, roleId: "review", principalType: "USER", principalId: "ou_a" },
});
// p2 has the role configured for someone else; ou_a has no grant there.
await prisma.roleTriggerGrant.create({
data: { projectId: p2.id, roleId: "review", principal: "ou_other" },
data: { projectId: p2.id, roleId: "review", principalType: "USER", principalId: "ou_other" },
});
const onP1 = await canTriggerRole(prisma, p1.id, "review", "ou_a");
expect(onP1.allowed).toBe(true);
+769 -31
View File
@@ -1,15 +1,43 @@
import { describe, it, expect, beforeEach, afterAll, vi } from "vitest";
import { prisma, resetDb, mockFeishuRuntime, createMockModelFactory, seedProject, silentLogger } from "./helpers.js";
import { prisma, resetDb, mockFeishuRuntime, seedProject, silentLogger } from "./helpers.js";
import { InMemoryModelRegistry } from "../../src/agent/models.js";
import { ToolRegistry } from "../../src/agent/tools.js";
import { makeTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
import type { MessageReceiveEvent } from "../../src/feishu/client.js";
import type { ModelFactory } from "../../src/agent/runner.js";
import { TriggerQueue } from "../../src/feishu/triggerQueue.js";
import type { MessageReceiveEvent, CardActionEvent } from "../../src/feishu/client.js";
import type { RunRequest, RunResult } from "../../src/agent/runner.js";
import type { RuntimeSettings } from "../../src/settings/runtime.js";
const bot = { key: "@_user_1", id: { open_id: "ou_bot" }, name: "Bot" };
type TestRunner = (req: RunRequest) => Promise<RunResult>;
function makeEvent(chatId: string, text: string, senderOpenId = "ou_test_user"): MessageReceiveEvent {
function makeTestSettings(models: InMemoryModelRegistry): RuntimeSettings {
return {
async provider(providerId) {
return {
id: providerId,
baseUrl: "https://openrouter.ai/api",
authToken: "test-token",
anthropicApiKey: "",
sdkEnv: {
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
ANTHROPIC_AUTH_TOKEN: "test-token",
ANTHROPIC_API_KEY: "",
},
};
},
async modelRegistry() {
return models;
},
async runPolicy() {
return { maxTurns: 7 };
},
};
}
function makeEvent(chatId: string, text: string, senderOpenId = "ou_test_user", eventId?: string): MessageReceiveEvent {
const header = eventId !== undefined ? { event_id: eventId, event_type: "im.message.receive_v1" } : undefined;
return {
header,
message: {
message_id: "m_" + Math.random().toString(36).slice(2),
chat_id: chatId,
@@ -22,16 +50,47 @@ function makeEvent(chatId: string, text: string, senderOpenId = "ou_test_user"):
};
}
function expectPromptFromSender(prompt: string | undefined, senderOpenId: string, rawPrompt: string): void {
expect(prompt).toContain("Feishu trigger context");
expect(prompt).toContain(`"open_id": "${senderOpenId}"`);
expect(prompt).toContain(`User request from ${senderOpenId}:\n${rawPrompt}`);
}
function createMockRunAgent(calls: RunRequest[] = []): TestRunner {
return async (req) => {
calls.push(req);
req.onStream?.({ type: "text-delta", text: "mock response" });
await req.prisma.agentMessage.create({
data: {
sessionId: req.sessionId,
runId: req.runId,
role: "assistant",
content: "mock response",
attachments: [],
},
});
return {
status: "completed",
text: "mock response",
usage: { inputTokens: 10, outputTokens: 5 },
costUsd: 0.0023,
numTurns: 1,
sdkSessionId: "sdk-session-1",
};
};
}
describe("trigger full lifecycle (integration)", () => {
let modelFactory: ModelFactory;
let tools: ToolRegistry;
let models: InMemoryModelRegistry;
let settings: RuntimeSettings;
let rt: ReturnType<typeof mockFeishuRuntime>;
let runAgentCalls: RunRequest[];
let runAgent: TestRunner;
beforeEach(async () => {
await resetDb();
modelFactory = createMockModelFactory().modelFactory;
tools = new ToolRegistry();
runAgentCalls = [];
runAgent = createMockRunAgent(runAgentCalls);
models = new InMemoryModelRegistry(
[{ id: "mock-model", label: "Mock", toolCapable: true }],
[
@@ -39,14 +98,16 @@ describe("trigger full lifecycle (integration)", () => {
{ id: "review", label: "审校", defaultModel: "mock-model", systemPrompt: undefined, tools: ["read_file"] },
],
);
settings = makeTestSettings(models);
rt = mockFeishuRuntime();
});
it("creates a run, acquires + releases the lock, sends status card", async () => {
await seedProject("proj-1", "chat-1");
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
const event = makeEvent("chat-1", "@_user_1 写教案");
await trigger(makeEvent("chat-1", "@_user_1 写教案"), rt);
await trigger(event, rt);
// Wait for the async run to complete (fire-and-forget in trigger).
await vi.waitFor(async () => {
@@ -56,26 +117,308 @@ describe("trigger full lifecycle (integration)", () => {
});
// Lock released (no lock row remains).
const locks = await prisma.projectAgentLock.findMany();
expect(locks).toHaveLength(0);
await vi.waitFor(async () => {
const locks = await prisma.projectAgentLock.findMany();
expect(locks).toHaveLength(0);
});
// A status card was sent.
expect(rt.sentCards.length).toBeGreaterThanOrEqual(1);
expect(rt.sentTexts).toContain("已开始处理(role: draft, model: mock-model)。");
expect(rt.sentReplies[0]).toMatchObject({
messageId: event.message.message_id,
msgType: "interactive",
replyInThread: undefined,
});
expect(rt.sentTexts.some((text) => text.includes("mock response") && text.includes("本次成本: $0.0023"))).toBe(true);
expect(runAgentCalls).toHaveLength(1);
expect(runAgentCalls[0]?.providerEnv).toMatchObject({
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
ANTHROPIC_AUTH_TOKEN: "test-token",
ANTHROPIC_API_KEY: "",
});
expect(runAgentCalls[0]?.maxTurns).toBe(7);
const run = await prisma.agentRun.findFirstOrThrow();
expect(Number(run.costUsd)).toBeCloseTo(0.0023);
expect(run.costSource).toBe("provider_reported");
});
it("batches quick text messages from the same chat and sender into one run", async () => {
await seedProject("proj-1b", "chat-1b");
const trigger = makeTriggerHandler({
prisma,
settings,
logger: silentLogger,
runAgent,
messageBatcherOptions: { debounceMs: 10_000, maxMessages: 2 },
});
await trigger(makeEvent("chat-1b", "@_user_1 第一段"), rt);
expect(await prisma.agentRun.findMany()).toHaveLength(0);
await trigger(makeEvent("chat-1b", "@_user_1 第二段"), rt);
await vi.waitFor(async () => {
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(1);
expect(runs[0]?.status).toBe("COMPLETED");
expectPromptFromSender(runs[0]?.prompt, "ou_test_user", "第一段\n第二段");
});
expect(runAgentCalls).toHaveLength(1);
expectPromptFromSender(runAgentCalls[0]?.prompt, "ou_test_user", "第一段\n第二段");
});
it("keeps the project session shared while labeling each sender in the prompt", async () => {
await seedProject("proj-speaker", "chat-speaker");
await prisma.permissionGrant.create({
data: {
resourceType: "PROJECT",
resourceId: "proj-speaker",
principalType: "FEISHU_CHAT",
principalId: "chat-speaker",
role: "EDIT",
},
});
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-speaker", "@_user_1 Alice 的需求", "ou_alice"), rt);
await vi.waitFor(() => {
expect(runAgentCalls).toHaveLength(1);
});
await vi.waitFor(async () => {
expect(await prisma.projectAgentLock.findMany()).toHaveLength(0);
});
await trigger(makeEvent("chat-speaker", "@_user_1 Bob 的需求", "ou_bob"), rt);
await vi.waitFor(() => {
expect(runAgentCalls).toHaveLength(2);
});
expectPromptFromSender(runAgentCalls[0]?.prompt, "ou_alice", "Alice 的需求");
expectPromptFromSender(runAgentCalls[1]?.prompt, "ou_bob", "Bob 的需求");
const sessions = await prisma.agentSession.findMany();
expect(sessions).toHaveLength(1);
});
it("/new bypasses message batching", async () => {
await seedProject("proj-1c", "chat-1c");
const trigger = makeTriggerHandler({
prisma,
settings,
logger: silentLogger,
runAgent,
messageBatcherOptions: { debounceMs: 10_000 },
});
await trigger(makeEvent("chat-1c", "@_user_1 /new"), rt);
expect(rt.sentTexts).toContain("已开新会话,下次 @bot 将从头开始。");
expect(runAgentCalls).toHaveLength(0);
expect(await prisma.agentRun.findMany()).toHaveLength(0);
});
it("/help lists control and role commands without creating a run", async () => {
await seedProject("proj-help", "chat-help");
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-help", "@_user_1 /help"), rt);
const helpText = rt.sentTexts.at(-1) ?? "";
expect(helpText).toContain("可用 slash 命令");
expect(helpText).toContain("/new");
expect(helpText).toContain("/reset");
expect(helpText).toContain("/draft <需求>");
expect(helpText).toContain("/review <需求>");
expect(runAgentCalls).toHaveLength(0);
expect(await prisma.agentRun.findMany()).toHaveLength(0);
});
it("/cost reports recorded current-session cost without creating a run", async () => {
await seedProject("proj-cost", "chat-cost");
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-cost", "@_user_1 写教案"), rt);
await vi.waitFor(async () => {
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(1);
expect(runs[0]?.status).toBe("COMPLETED");
});
await trigger(makeEvent("chat-cost", "@_user_1 /cost"), rt);
const costText = rt.sentTexts.at(-1) ?? "";
expect(costText).toContain("当前会话已记录 agent 成本");
expect(costText).toContain("总计: $0.0023");
expect(costText).toContain("Runs: 1 已记录");
expect(costText).toContain("openrouter / mock-model");
expect(runAgentCalls).toHaveLength(1);
expect(await prisma.agentRun.findMany()).toHaveLength(1);
});
it("/cost surfaces finished runs without recorded cost", async () => {
await seedProject("proj-cost-missing", "chat-cost-missing");
const session = await prisma.agentSession.create({
data: {
projectId: "proj-cost-missing",
provider: "openrouter",
roleId: "draft",
model: "mock-model",
metadata: {},
},
select: { id: true },
});
await prisma.agentRun.create({
data: {
projectId: "proj-cost-missing",
sessionId: session.id,
entrypoint: "FEISHU",
status: "COMPLETED",
prompt: "old test run",
model: "mock-model",
provider: "openrouter",
inputTokens: 10,
outputTokens: 5,
metadata: {},
finishedAt: new Date(),
},
});
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-cost-missing", "@_user_1 /cost"), rt);
const costText = rt.sentTexts.at(-1) ?? "";
expect(costText).toContain("还没有任何 run 记录到真实成本");
expect(costText).toContain("未记录成本: 1 runs");
expect(runAgentCalls).toHaveLength(0);
});
it("/help is built from the current registry on each request", async () => {
await seedProject("proj-help-live", "chat-help-live");
let currentModels = new InMemoryModelRegistry(
[{ id: "mock-model", label: "Mock", toolCapable: true }],
[{ id: "draft", label: "草稿", defaultModel: "mock-model", systemPrompt: undefined, tools: undefined }],
);
const dynamicSettings: RuntimeSettings = {
async provider(providerId, scope) {
return settings.provider(providerId, scope);
},
async modelRegistry() {
return currentModels;
},
async runPolicy(input) {
return settings.runPolicy(input);
},
};
const trigger = makeTriggerHandler({ prisma, settings: dynamicSettings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-help-live", "@_user_1 /help"), rt);
expect(rt.sentTexts.at(-1) ?? "").not.toContain("/coach <需求>");
currentModels = new InMemoryModelRegistry(
[{ id: "mock-model", label: "Mock", toolCapable: true }],
[
{ id: "draft", label: "草稿", defaultModel: "mock-model", systemPrompt: undefined, tools: undefined },
{ id: "coach", label: "教练", defaultModel: "mock-model", systemPrompt: undefined, tools: [] },
],
);
await trigger(makeEvent("chat-help-live", "@_user_1 /help"), rt);
const helpText = rt.sentTexts.at(-1) ?? "";
expect(helpText).toContain("/coach <需求>");
expect(runAgentCalls).toHaveLength(0);
expect(await prisma.agentRun.findMany()).toHaveLength(0);
});
it("/help <command> returns command-specific help", async () => {
await seedProject("proj-help-reset", "chat-help-reset");
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-help-reset", "@_user_1 /help reset"), rt);
const helpText = rt.sentTexts.at(-1) ?? "";
expect(helpText).toContain("/reset");
expect(helpText).toContain("清空当前项目已经排队");
expect(helpText).toContain("/reset help");
expect(runAgentCalls).toHaveLength(0);
expect(await prisma.agentRun.findMany()).toHaveLength(0);
});
it("passes the selected role tool whitelist into the runner", async () => {
await seedProject("proj-role-tools", "chat-role-tools");
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-role-tools", "@_user_1 /review 检查讲义"), rt);
await vi.waitFor(async () => {
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(1);
expect(runs[0]?.status).toBe("COMPLETED");
});
expect(runAgentCalls).toHaveLength(1);
expect(runAgentCalls[0]?.tools).toEqual(["read_file"]);
});
it("control commands support a help subcommand", async () => {
await seedProject("proj-new-help", "chat-new-help");
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-new-help", "@_user_1 /new help"), rt);
const helpText = rt.sentTexts.at(-1) ?? "";
expect(helpText).toContain("/new");
expect(helpText).toContain("归档当前未归档");
expect(helpText).toContain("/help new");
expect(rt.sentTexts).not.toContain("已开新会话,下次 @bot 将从头开始。");
expect(runAgentCalls).toHaveLength(0);
expect(await prisma.agentRun.findMany()).toHaveLength(0);
});
it("role commands support a help subcommand without consuming role grants", async () => {
await seedProject("proj-role-help", "chat-role-help");
await prisma.roleTriggerGrant.create({
data: { projectId: "proj-role-help", roleId: "review", principalType: "USER", principalId: "ou_other" },
});
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-role-help", "@_user_1 /review help"), rt);
const helpText = rt.sentTexts.at(-1) ?? "";
expect(helpText).toContain("/review");
expect(helpText).toContain("使用“审校”角色");
expect(helpText).toContain("工具范围: read_file");
expect(rt.sentTexts).not.toContain("无权限使用角色 review。");
expect(runAgentCalls).toHaveLength(0);
expect(await prisma.agentRun.findMany()).toHaveLength(0);
});
it("/help unknown and /unknown help report the unknown command", async () => {
await seedProject("proj-help-unknown", "chat-help-unknown");
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-help-unknown", "@_user_1 /help unknown"), rt);
expect(rt.sentTexts.at(-1) ?? "").toContain("未知 slash 命令 /unknown");
expect(runAgentCalls).toHaveLength(0);
await trigger(makeEvent("chat-help-unknown", "@_user_1 /unknown help"), rt);
expect(rt.sentTexts.at(-1) ?? "").toContain("未知 slash 命令 /unknown");
expect(runAgentCalls).toHaveLength(0);
expect(await prisma.agentRun.findMany()).toHaveLength(0);
});
it("rejects a sender without edit grant (ADR-0004)", async () => {
await seedProject("proj-2", "chat-2", { role: "READ" });
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-2", "@_user_1 写教案"), rt);
expect(rt.sentTexts).toContain("无权限触发。");
expect(runAgentCalls).toHaveLength(0);
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(0);
});
it("replies busy when project is already locked (ADR-0002)", async () => {
it("queues a text trigger when project is already locked (ADR-0002)", async () => {
await seedProject("proj-3", "chat-3");
// Manually create a lock by inserting a run + lock.
const existingRun = await prisma.agentRun.create({
@@ -85,18 +428,65 @@ describe("trigger full lifecycle (integration)", () => {
data: { projectId: "proj-3", runId: existingRun.id },
});
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-3", "@_user_1 写教案"), rt);
expect(rt.sentTexts).toContain("项目正在处理中,请稍候。");
expect(rt.sentTexts).toContain("已加入队列(第1位),当前处理完成后将自动开始");
expect(rt.reactions.some((reaction) => reaction.emoji === "OnIt")).toBe(false);
expect(runAgentCalls).toHaveLength(0);
// No new run created.
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(1);
});
it("starts the next queued text trigger when the current run finishes", async () => {
await seedProject("proj-3b", "chat-3b");
const firstRun = deferred<RunResult>();
const secondRun = deferred<RunResult>();
const pendingRuns = [firstRun, secondRun];
const queuedRunAgent: TestRunner = async (req) => {
runAgentCalls.push(req);
req.onStream?.({ type: "text-delta", text: `mock response ${runAgentCalls.length}` });
const pendingRun = pendingRuns.shift();
if (pendingRun === undefined) {
throw new Error("unexpected extra run");
}
return pendingRun.promise;
};
const trigger = makeTriggerHandler({
prisma,
settings,
logger: silentLogger,
runAgent: queuedRunAgent,
messageBatcherOptions: { maxMessages: 1 },
});
await trigger(makeEvent("chat-3b", "@_user_1 第一个请求"), rt);
await vi.waitFor(() => {
expect(runAgentCalls).toHaveLength(1);
});
await trigger(makeEvent("chat-3b", "@_user_1 第二个请求"), rt);
expect(rt.sentTexts).toContain("已加入队列(第1位),当前处理完成后将自动开始");
expect(runAgentCalls).toHaveLength(1);
firstRun.resolve(completedRunResult("first done", "sdk-session-first"));
await vi.waitFor(() => {
expect(runAgentCalls).toHaveLength(2);
});
expectPromptFromSender(runAgentCalls[1]?.prompt, "ou_test_user", "第二个请求");
secondRun.resolve(completedRunResult("second done", "sdk-session-second"));
await vi.waitFor(async () => {
const runs = await prisma.agentRun.findMany({ orderBy: { startedAt: "asc" } });
expect(runs).toHaveLength(2);
expect(runs.every((run) => run.status === "COMPLETED")).toBe(true);
});
});
it("ignores messages from unbound chats (ADR-0001)", async () => {
await seedProject("proj-4", "chat-4");
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-UNKNOWN", "@_user_1 写教案"), rt);
@@ -108,7 +498,7 @@ describe("trigger full lifecycle (integration)", () => {
it("ignores messages without @bot mention", async () => {
await seedProject("proj-5", "chat-5");
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
const event: MessageReceiveEvent = {
message: {
@@ -130,7 +520,7 @@ describe("trigger full lifecycle (integration)", () => {
it("/new archives current session (no run created)", async () => {
await seedProject("proj-6", "chat-6");
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
// First @bot creates a session + run.
await trigger(makeEvent("chat-6", "@_user_1 写教案"), rt);
@@ -153,7 +543,7 @@ describe("trigger full lifecycle (integration)", () => {
it("/resume un-archives the most recent session", async () => {
await seedProject("proj-7", "chat-7");
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
// Create + archive a session via /new.
await trigger(makeEvent("chat-7", "@_user_1 写教案"), rt);
@@ -173,7 +563,15 @@ describe("trigger full lifecycle (integration)", () => {
it("/reset archives current session", async () => {
await seedProject("proj-8", "chat-8");
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
const queue = new TriggerQueue();
const trigger = makeTriggerHandler({
prisma,
settings,
logger: silentLogger,
runAgent,
messageBatcherOptions: { maxMessages: 1 },
triggerQueue: queue,
});
await trigger(makeEvent("chat-8", "@_user_1 写教案"), rt);
await vi.waitFor(async () => {
@@ -182,8 +580,19 @@ describe("trigger full lifecycle (integration)", () => {
expect(runs[0]?.status).toBe("COMPLETED");
});
const queuedEvent = makeEvent("chat-8", "@_user_1 后续需求");
queue.enqueue("proj-8", {
chatId: "chat-8",
prompt: extractPrompt(queuedEvent.message) ?? "后续需求",
msg: queuedEvent.message,
senderOpenId: "ou_test_user",
actor: { feishuOpenId: "ou_test_user", chatId: "chat-8" },
});
expect(queue.length("proj-8")).toBe(1);
await trigger(makeEvent("chat-8", "@_user_1 /reset"), rt);
expect(rt.sentTexts).toContain("已重置,下次 @bot 将从头开始。");
expect(queue.length("proj-8")).toBe(0);
const sessions = await prisma.agentSession.findMany();
expect(sessions).toHaveLength(1);
@@ -192,14 +601,14 @@ describe("trigger full lifecycle (integration)", () => {
it("unknown slash command falls through to agent", async () => {
await seedProject("proj-9", "chat-9");
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-9", "@_user_1 /unknown"), rt);
// Should create a run (falls through as a normal prompt).
await vi.waitFor(async () => {
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(1);
expect(runs[0]?.prompt).toBe("/unknown");
expectPromptFromSender(runs[0]?.prompt, "ou_test_user", "/unknown");
});
});
@@ -207,13 +616,14 @@ describe("trigger full lifecycle (integration)", () => {
await seedProject("proj-10", "chat-10");
// Someone else holds review; ou_test_user does not.
await prisma.roleTriggerGrant.create({
data: { projectId: "proj-10", roleId: "review", principal: "ou_other" },
data: { projectId: "proj-10", roleId: "review", principalType: "USER", principalId: "ou_other" },
});
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-10", "@_user_1 /review 看看这节"), rt);
expect(rt.sentTexts).toContain("无权限使用角色 review。");
expect(runAgentCalls).toHaveLength(0);
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(0);
});
@@ -221,9 +631,9 @@ describe("trigger full lifecycle (integration)", () => {
it("allows /review when sender holds the role grant", async () => {
await seedProject("proj-11", "chat-11");
await prisma.roleTriggerGrant.create({
data: { projectId: "proj-11", roleId: "review", principal: "ou_test_user" },
data: { projectId: "proj-11", roleId: "review", principalType: "USER", principalId: "ou_test_user" },
});
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-11", "@_user_1 /review 看看这节"), rt);
@@ -237,19 +647,347 @@ describe("trigger full lifecycle (integration)", () => {
it("extractRole: /draft sets roleId=draft, strips command from prompt", async () => {
await seedProject("proj-12", "chat-12");
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-12", "@_user_1 /draft 写第三单元"), rt);
await vi.waitFor(async () => {
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(1);
expect(runs[0]?.prompt).toBe("写第三单元");
expectPromptFromSender(runs[0]?.prompt, "ou_test_user", "写第三单元");
expect(runs[0]?.metadata).toMatchObject({ roleId: "draft" });
});
});
it("keeps role sessions separate even when roles share a model", async () => {
await seedProject("proj-12b", "chat-12b");
await prisma.roleTriggerGrant.create({
data: { projectId: "proj-12b", roleId: "review", principalType: "USER", principalId: "ou_test_user" },
});
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-12b", "@_user_1 /draft 写第三单元"), rt);
await vi.waitFor(async () => {
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(1);
expect(runs[0]?.status).toBe("COMPLETED");
});
await trigger(makeEvent("chat-12b", "@_user_1 /review 看看这节"), rt);
await vi.waitFor(async () => {
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(2);
expect(runs.every((run) => run.status === "COMPLETED")).toBe(true);
});
const sessions = await prisma.agentSession.findMany({ orderBy: { roleId: "asc" } });
expect(sessions.map((session) => session.roleId)).toEqual(["draft", "review"]);
expect(new Set(sessions.map((session) => session.model))).toEqual(new Set(["mock-model"]));
expect(new Set(sessions.map((session) => session.id)).size).toBe(2);
expect(runAgentCalls[1]?.resumeSessionId).toBeUndefined();
});
it("dedups a redelivered event by event_id (no second run)", async () => {
await seedProject("proj-13", "chat-13");
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
// First delivery: processes normally.
await trigger(makeEvent("chat-13", "@_user_1 写教案", "ou_test_user", "evt-dedup-1"), rt);
await vi.waitFor(async () => {
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(1);
expect(runs[0]?.status).toBe("COMPLETED");
});
// Redelivery of the same event_id: must not create a second run.
await trigger(makeEvent("chat-13", "@_user_1 写教案", "ou_test_user", "evt-dedup-1"), rt);
const runs2 = await prisma.agentRun.findMany();
expect(runs2).toHaveLength(1);
// Only one event receipt row.
const receipts = await prisma.feishuEventReceipt.findMany();
expect(receipts).toHaveLength(1);
expect(receipts[0]?.eventId).toBe("evt-dedup-1");
});
it("dedups flattened websocket events by top-level event_id", async () => {
await seedProject("proj-13b", "chat-13b");
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
const event: MessageReceiveEvent = {
...makeEvent("chat-13b", "@_user_1 写教案"),
event_id: "evt-flat-1",
event_type: "im.message.receive_v1",
};
await trigger(event, rt);
await vi.waitFor(async () => {
expect(await prisma.agentRun.findMany()).toHaveLength(1);
});
await trigger(
{
...event,
message: { ...event.message, message_id: "m_flat_redelivery" },
},
rt,
);
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(1);
const receipts = await prisma.feishuEventReceipt.findMany();
expect(receipts).toHaveLength(1);
expect(receipts[0]?.eventId).toBe("evt-flat-1");
});
it("seeds replied-to message context when @bot is used in a Feishu reply", async () => {
await seedProject("proj-13c", "chat-13c");
rt.readableMessages.set("m-parent", {
message_id: "m-parent",
root_id: "m-root",
thread_id: "thread-1",
msg_type: "text",
chat_id: "chat-13c",
sender: { id: "ou_teacher_2", sender_type: "user" },
body: { content: JSON.stringify({ text: "上一条需求: 把第二题改成探究题。" }) },
});
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
const baseEvent = makeEvent("chat-13c", "@_user_1 继续这个改法");
const event: MessageReceiveEvent = {
...baseEvent,
message: {
...baseEvent.message,
message_id: "m-child",
root_id: "m-root",
parent_id: "m-parent",
thread_id: "thread-1",
},
};
await trigger(event, rt);
await vi.waitFor(() => {
expect(runAgentCalls).toHaveLength(1);
});
const prompt = runAgentCalls[0]?.prompt ?? "";
expect(prompt).toContain("Feishu trigger context");
expect(prompt).toContain("reply_to_message_id");
expect(prompt).toContain("m-parent");
expect(prompt).toContain("thread-1");
expect(prompt).toContain("上一条需求");
expectPromptFromSender(prompt, "ou_test_user", "继续这个改法");
const run = await prisma.agentRun.findFirst();
expect(run?.prompt).toBe(prompt);
expect(run?.metadata).toMatchObject({
roleId: "draft",
rawPrompt: "继续这个改法",
feishuTriggerContext: {
trigger_message_id: "m-child",
chat_id: "chat-13c",
sender: { open_id: "ou_test_user" },
reply_to_message_id: "m-parent",
root_id: "m-root",
thread_id: "thread-1",
},
});
});
it("writes audit entries across the run lifecycle", async () => {
await seedProject("proj-14", "chat-14");
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-14", "@_user_1 写教案"), rt);
await vi.waitFor(async () => {
const audit = await prisma.auditEntry.findMany();
expect(audit.length).toBeGreaterThanOrEqual(2);
});
const audit = await prisma.auditEntry.findMany({ orderBy: { createdAt: "asc" } });
expect(audit.some((a) => a.action === "run.created")).toBe(true);
expect(audit.some((a) => a.action === "run.finished")).toBe(true);
});
it("persists AgentMessage rows for the run (A-3 structured history)", async () => {
await seedProject("proj-15", "chat-15");
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-15", "@_user_1 写教案"), rt);
await vi.waitFor(async () => {
const msgs = await prisma.agentMessage.findMany();
expect(msgs.length).toBeGreaterThanOrEqual(1);
});
const msgs = await prisma.agentMessage.findMany({ orderBy: { createdAt: "asc" } });
expect(msgs.length).toBeGreaterThanOrEqual(1);
const run = await prisma.agentRun.findFirst();
expect(msgs.every((m) => m.runId === run?.id)).toBe(true);
expect(msgs.some((m) => m.role === "assistant")).toBe(true);
});
it("interrupts a running agent via the card interrupt button", async () => {
await seedProject("proj-int", "chat-int", { role: "MANAGE" });
runAgent = createHangingRunAgent();
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-int", "@_user_1 写教案"), rt);
// The run started: a card was sent (with an interrupt button) and the run is ACTIVE.
await vi.waitFor(async () => {
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(1);
expect(runs[0]?.status).toBe("ACTIVE");
});
expect(rt.sentCards.length).toBeGreaterThanOrEqual(1);
const run = await prisma.agentRun.findFirst();
expect(run).not.toBeNull();
await trigger.onCardAction(makeInterruptEvent("chat-int", run!.id, "ou_test_user"), rt);
// The run transitions to CANCELED (spec RunState.canceled is terminal).
await vi.waitFor(async () => {
const runs = await prisma.agentRun.findMany();
expect(runs[0]?.status).toBe("CANCELED");
});
// The lock is released (terminal ⇒ release, ADR-0002).
await vi.waitFor(async () => {
const locks = await prisma.projectAgentLock.findMany();
expect(locks).toHaveLength(0);
});
// Audit records the interrupt.
const audit = await prisma.auditEntry.findMany();
expect(audit.some((a) => a.action === "run.interrupted")).toBe(true);
// The card was patched to a complete state with an "已中断" footer.
expect(rt.sentPatches.some((card) => cardHasInterruptedFooter(card))).toBe(true);
});
it("sends a fallback interrupt notice when the final card patch fails", async () => {
await seedProject("proj-int-patch-fail", "chat-int-patch-fail", { role: "MANAGE" });
runAgent = createHangingRunAgent();
const patch = vi.fn(async () => {
throw new Error("patch failed");
});
(rt.client as unknown as { im: { v1: { message: { patch: typeof patch } } } }).im.v1.message.patch = patch;
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-int-patch-fail", "@_user_1 写教案"), rt);
await vi.waitFor(async () => {
const runs = await prisma.agentRun.findMany();
expect(runs[0]?.status).toBe("ACTIVE");
});
const run = await prisma.agentRun.findFirst();
expect(run).not.toBeNull();
await trigger.onCardAction(makeInterruptEvent("chat-int-patch-fail", run!.id, "ou_test_user"), rt);
await vi.waitFor(async () => {
const runs = await prisma.agentRun.findMany();
expect(runs[0]?.status).toBe("CANCELED");
});
expect(patch).toHaveBeenCalled();
expect(rt.sentTexts).toContain("已中断当前运行。");
});
it("denies interrupt when the operator lacks agent.cancel permission", async () => {
// EDIT role can trigger but cannot cancel (agent.cancel requires MANAGE).
await seedProject("proj-int-deny", "chat-int-deny", { role: "EDIT" });
runAgent = createHangingRunAgent();
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-int-deny", "@_user_1 写教案"), rt);
await vi.waitFor(async () => {
const runs = await prisma.agentRun.findMany();
expect(runs[0]?.status).toBe("ACTIVE");
});
const run = await prisma.agentRun.findFirst();
expect(run).not.toBeNull();
await trigger.onCardAction(makeInterruptEvent("chat-int-deny", run!.id, "ou_test_user"), rt);
expect(rt.sentTexts).toContain("无权限中断该运行。");
const audit = await prisma.auditEntry.findMany();
expect(audit.some((a) => a.action === "run.interrupt_denied")).toBe(true);
// The run is still active — it was not aborted.
const runs = await prisma.agentRun.findMany();
expect(runs[0]?.status).toBe("ACTIVE");
// Clean up: release the hanging run so afterEach isolation isn't disturbed.
const active = await prisma.agentRun.findFirst({ where: { status: "ACTIVE" } });
if (active !== null) {
await prisma.agentRun.update({ where: { id: active.id }, data: { status: "FAILED", finishedAt: new Date() } });
await prisma.projectAgentLock.deleteMany({ where: { runId: active.id } });
}
});
});
interface Deferred<T> {
readonly promise: Promise<T>;
readonly resolve: (value: T) => void;
readonly reject: (reason?: unknown) => void;
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function completedRunResult(text: string, sdkSessionId: string): RunResult {
return {
status: "completed",
text,
usage: { inputTokens: 10, outputTokens: 5 },
numTurns: 1,
sdkSessionId,
};
}
function createHangingRunAgent(): TestRunner {
// Simulates a long-running agent: never resolves on its own, but resolves
// with status "interrupted" when its abortController fires (mirrors the
// real runner's abort path).
return async (req) => {
req.onStream?.({ type: "text-delta", text: "处理中..." });
await req.prisma.agentMessage.create({
data: { sessionId: req.sessionId, runId: req.runId, role: "assistant", content: "处理中...", attachments: [] },
});
const ac = req.abortController;
return new Promise<RunResult>((resolve) => {
if (ac === undefined) return;
if (ac.signal.aborted) {
resolve({ status: "interrupted", text: "处理中...", usage: { inputTokens: 1, outputTokens: 1 }, numTurns: 1, sdkSessionId: "sdk-int" });
return;
}
ac.signal.addEventListener("abort", () => {
resolve({ status: "interrupted", text: "处理中...", usage: { inputTokens: 1, outputTokens: 1 }, numTurns: 1, sdkSessionId: "sdk-int" });
}, { once: true });
});
};
}
function makeInterruptEvent(chatId: string, runId: string, openId: string): CardActionEvent {
return {
operator: { open_id: openId },
action: { value: { interrupt_run: runId }, tag: "button" },
context: { open_chat_id: chatId },
};
}
function cardHasInterruptedFooter(card: unknown): boolean {
if (typeof card !== "object" || card === null) return false;
if (!("elements" in card)) return false;
const elements = card.elements;
if (!Array.isArray(elements)) return false;
return elements.some((el) => {
if (typeof el !== "object" || el === null) return false;
if (!("text" in el)) return false;
const text = el.text;
if (typeof text !== "object" || text === null || !("content" in text)) return false;
const content = text.content;
return typeof content === "string" && content.includes("已中断");
});
}
afterAll(async () => {
await prisma.$disconnect();
});
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from "vitest";
import type { PrismaClient } from "@prisma/client";
import { writeAudit } from "../../src/audit.js";
/// Minimal mock: only the surface `writeAudit` touches. Cast through unknown
/// so we don't have to implement the rest of the PrismaClient surface — this
/// is a unit test, not a DB test.
interface AuditEntryMock {
create: ReturnType<typeof vi.fn>;
}
function mockPrisma(): PrismaClient & { auditEntry: AuditEntryMock } {
return { auditEntry: { create: vi.fn() } } as unknown as PrismaClient & {
auditEntry: AuditEntryMock;
};
}
describe("writeAudit (A-8 audit write path)", () => {
it("creates a row with all fields set", async () => {
const prisma = mockPrisma();
prisma.auditEntry.create.mockResolvedValue({ id: "aud-1" });
await writeAudit(prisma, {
runId: "run-1",
projectId: "proj-1",
actorUserId: "user-1",
action: "run.triggered",
metadata: { source: "feishu" },
});
expect(prisma.auditEntry.create).toHaveBeenCalledTimes(1);
expect(prisma.auditEntry.create).toHaveBeenCalledWith({
data: {
runId: "run-1",
projectId: "proj-1",
actorUserId: "user-1",
action: "run.triggered",
metadata: { source: "feishu" },
},
});
});
it("creates a row with only required fields (action + metadata)", async () => {
const prisma = mockPrisma();
prisma.auditEntry.create.mockResolvedValue({ id: "aud-2" });
await writeAudit(prisma, {
action: "permission.denied",
metadata: { reason: "no-edit-role" },
});
expect(prisma.auditEntry.create).toHaveBeenCalledTimes(1);
expect(prisma.auditEntry.create).toHaveBeenCalledWith({
data: {
action: "permission.denied",
metadata: { reason: "no-edit-role" },
},
});
});
it("swallows a prisma error (best-effort, must not throw)", async () => {
const prisma = mockPrisma();
prisma.auditEntry.create.mockRejectedValue(new Error("db down"));
// Must not throw — audit is observability, not control.
await expect(
writeAudit(prisma, {
action: "run.triggered",
metadata: {},
}),
).resolves.toBeUndefined();
expect(prisma.auditEntry.create).toHaveBeenCalledTimes(1);
});
});
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { createDefaultModelRegistry } from "../../src/agent/defaultModels.js";
describe("default model registry", () => {
it("uses OpenRouter model slugs for Sonnet defaults", () => {
const models = createDefaultModelRegistry({});
expect(models.listModels()[0]).toMatchObject({
id: "anthropic/claude-sonnet-5",
label: "Claude Sonnet 5",
toolCapable: true,
});
expect(models.resolve(undefined, "draft")).toBe("anthropic/claude-sonnet-5");
expect(models.resolve(undefined, "review")).toBe("anthropic/claude-sonnet-5");
});
it("honors ANTHROPIC_DEFAULT_SONNET_MODEL when configured", () => {
const models = createDefaultModelRegistry({
ANTHROPIC_DEFAULT_SONNET_MODEL: "z-ai/glm-4.7",
});
expect(models.listModels()[0]?.id).toBe("z-ai/glm-4.7");
expect(models.resolve(undefined, "draft")).toBe("z-ai/glm-4.7");
});
});
+189
View File
@@ -0,0 +1,189 @@
import { describe, expect, it, vi, afterEach } from "vitest";
import type { PrismaClient } from "@prisma/client";
import type { PermissionAuthorizer } from "../../src/permission.js";
import type { RuntimeSettings } from "../../src/settings/runtime.js";
import { sendApprovalCard, resolveCard, type CardActionEvent, type FeishuRuntime } from "../../src/feishu/client.js";
import { ApprovalManager, makeTriggerHandler } from "../../src/feishu/trigger.js";
describe("Feishu approval cards", () => {
afterEach(() => {
vi.useRealTimers();
});
it("sendApprovalCard builds correct card JSON", async () => {
const messageCreate = vi.fn(async () => ({ data: { message_id: "message-1" } }));
const rt = mockRuntime({ messageCreate });
await expect(sendApprovalCard(rt, "chat-1", "Approve?", "Please approve.", [
{ label: "Approve", value: "approve", style: "primary" },
{ label: "Reject", value: "reject", style: "danger" },
{ label: "Later", value: "later" },
])).resolves.toBe("message-1");
expect(messageCreate).toHaveBeenCalledTimes(1);
const payload = messageCreate.mock.calls[0]?.[0] as { data: { content: string } };
expect(payload).toMatchObject({
params: { receive_id_type: "chat_id" },
data: { receive_id: "chat-1", msg_type: "interactive" },
});
expect(JSON.parse(payload.data.content)).toEqual({
config: { wide_screen_mode: true },
header: { title: { content: "Approve?", tag: "plain_text" }, template: "orange" },
elements: [
{ tag: "markdown", content: "Please approve." },
{
tag: "action",
actions: [
{
tag: "button",
text: { tag: "plain_text", content: "Approve" },
type: "primary",
value: { approval_action: "approve" },
},
{
tag: "button",
text: { tag: "plain_text", content: "Reject" },
type: "danger",
value: { approval_action: "reject" },
},
{
tag: "button",
text: { tag: "plain_text", content: "Later" },
type: "default",
value: { approval_action: "later" },
},
],
},
],
});
});
it("resolveCard patches with resolved state", async () => {
const messagePatch = vi.fn(async () => ({}));
const rt = mockRuntime({ messagePatch });
await resolveCard(rt, "message-1", "OK", "Approved", "green", "ou_user");
expect(messagePatch).toHaveBeenCalledTimes(1);
expect(messagePatch).toHaveBeenCalledWith({
path: { message_id: "message-1" },
params: { msg_type: "interactive" },
data: {
content: JSON.stringify({
config: { wide_screen_mode: true },
header: { title: { content: "OK Approved", tag: "plain_text" }, template: "green" },
elements: [{ tag: "markdown", content: "OK **Approved** by ou_user" }],
}),
},
});
});
it("ApprovalManager register+resolve lifecycle", async () => {
const manager = new ApprovalManager({ timeoutMs: 10_000 });
const result = manager.register("message-1", "chat-1");
expect(manager.hasPending("message-1")).toBe(true);
manager.resolve("message-1", "approve", "ou_user");
await expect(result).resolves.toEqual({ action: "approve", resolvedBy: "ou_user" });
expect(manager.hasPending("message-1")).toBe(false);
});
it("ApprovalManager timeout", async () => {
vi.useFakeTimers();
const manager = new ApprovalManager({ timeoutMs: 100 });
const result = manager.register("message-1", "chat-1");
const assertion = expect(result).rejects.toThrow("approval timed out after 100ms");
await vi.advanceTimersByTimeAsync(100);
await assertion;
expect(manager.hasPending("message-1")).toBe(false);
});
it("routes card actions through the trigger handler", async () => {
const messagePatch = vi.fn(async () => ({}));
const rt = mockRuntime({ messagePatch });
const trigger = makeTriggerHandler({
prisma: {} as PrismaClient,
settings: {} as RuntimeSettings,
logger: silentLogger(),
authorizer: allowAllAuthorizer(),
});
const result = trigger.approvalManager.register("message-1", "chat-1");
await trigger.onCardAction(makeCardActionEvent("message-1", "approve", "ou_user"), rt);
await expect(result).resolves.toEqual({ action: "approve", resolvedBy: "ou_user" });
expect(messagePatch).toHaveBeenCalledTimes(1);
const payload = messagePatch.mock.calls[0]?.[0] as { data: { content: string } };
expect(payload).toMatchObject({
path: { message_id: "message-1" },
params: { msg_type: "interactive" },
});
expect(JSON.parse(payload.data.content)).toEqual({
config: { wide_screen_mode: true },
header: { title: { content: "✅ Approved", tag: "plain_text" }, template: "green" },
elements: [{ tag: "markdown", content: "✅ **Approved** by ou_user" }],
});
expect(trigger.approvalManager.hasPending("message-1")).toBe(false);
});
});
function makeCardActionEvent(messageId: string, action: string, openId: string): CardActionEvent {
return {
operator: { open_id: openId },
action: { value: { approval_action: action }, tag: "button" },
context: { open_message_id: messageId, open_chat_id: "chat-1" },
};
}
function mockRuntime(options: {
readonly messageCreate?: (payload: unknown) => Promise<unknown>;
readonly messagePatch?: (payload: unknown) => Promise<unknown>;
} = {}): FeishuRuntime {
return {
client: {
im: {
v1: {
message: {
create: options.messageCreate ?? vi.fn(async () => ({ data: { message_id: "message-1" } })),
patch: options.messagePatch ?? vi.fn(async () => ({})),
},
},
},
} as unknown as FeishuRuntime["client"],
logger: silentLogger(),
};
}
function allowAllAuthorizer(): PermissionAuthorizer {
return {
async can(req) {
return {
allowed: true,
reason: "test allow",
action: req.action,
resource: req.resource,
actor: req.actor,
organizationId: "org_test_default",
actorUserId: "user-1",
principals: [{ type: "USER", id: "ou_user" }],
requiredRole: "EDIT",
effectiveRole: "EDIT",
};
},
};
}
function silentLogger(): FeishuRuntime["logger"] {
return {
info() {},
warn() {},
error() {},
debug() {},
fatal() {},
child() { return this; },
level: "silent",
} as unknown as FeishuRuntime["logger"];
}
@@ -0,0 +1,131 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { FastifyBaseLogger } from "fastify";
import type { CardActionEvent, FeishuRuntime } from "../../src/feishu/client.js";
import { startFeishuListenerWithClient } from "../../src/feishu/client.js";
const larkMock = vi.hoisted(() => {
const starts: Array<{ readonly eventDispatcher?: unknown }> = [];
class MockEventDispatcher {
handlers: Record<string, (data: unknown) => Promise<void>> = {};
register(handlers: Record<string, (data: unknown) => Promise<void>>): this {
this.handlers = handlers;
return this;
}
}
class MockWSClient {
start(params: { readonly eventDispatcher?: unknown }): void {
starts.push(params);
}
}
return { starts, MockEventDispatcher, MockWSClient };
});
vi.mock("@larksuiteoapi/node-sdk", () => ({
AppType: { SelfBuild: "SelfBuild" },
Domain: { Feishu: "Feishu" },
LoggerLevel: { info: "info" },
Client: vi.fn(),
EventDispatcher: larkMock.MockEventDispatcher,
WSClient: larkMock.MockWSClient,
}));
describe("Feishu card action callbacks", () => {
beforeEach(() => {
larkMock.starts.length = 0;
});
it("acks card action callbacks before the business handler settles", async () => {
const action = deferred<void>();
const onCardAction = vi.fn(async () => action.promise);
const logger = silentLogger();
startFeishuListenerWithClient(
{ appId: "app-id", appSecret: "app-secret", botOpenId: "bot-open-id" },
fakeClient(),
logger,
async () => {},
onCardAction,
);
const handler = cardActionHandler();
const handlerDone = handler(makeCardActionEvent()).then(() => "done" as const);
const earlyResult = await Promise.race([handlerDone, delay(25).then(() => "timeout" as const)]);
action.resolve();
await handlerDone;
expect(earlyResult).toBe("done");
expect(onCardAction).toHaveBeenCalledTimes(1);
});
it("logs background card action failures after acking", async () => {
const logger = silentLogger();
const err = new Error("handler failed");
startFeishuListenerWithClient(
{ appId: "app-id", appSecret: "app-secret", botOpenId: "bot-open-id" },
fakeClient(),
logger,
async () => {},
async () => {
throw err;
},
);
await cardActionHandler()(makeCardActionEvent());
await vi.waitFor(() => {
expect(logger.error).toHaveBeenCalledWith({ err }, "feishu card action handler threw");
});
});
});
function cardActionHandler(): (data: unknown) => Promise<void> {
const start = larkMock.starts[0];
if (start === undefined) throw new Error("WSClient.start was not called");
const dispatcher = start.eventDispatcher as { readonly handlers?: Record<string, (data: unknown) => Promise<void>> };
const handler = dispatcher.handlers?.["card.action.trigger"];
if (handler === undefined) throw new Error("card.action.trigger handler was not registered");
return handler;
}
function makeCardActionEvent(): CardActionEvent {
return {
operator: { open_id: "ou_user" },
action: { value: { interrupt_run: "run-1" }, tag: "button" },
context: { open_chat_id: "chat-1", open_message_id: "message-1" },
};
}
function fakeClient(): FeishuRuntime["client"] {
return {} as FeishuRuntime["client"];
}
function silentLogger(): FastifyBaseLogger {
return {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
fatal: vi.fn(),
child() { return this; },
level: "silent",
} as unknown as FastifyBaseLogger;
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
interface Deferred<T> {
readonly promise: Promise<T>;
readonly resolve: (value: T) => void;
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, it, vi } from "vitest";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { resolveSenderName, sendFile, sendLongText, sendTextMessage, type FeishuRuntime } from "../../src/feishu/client.js";
import { SenderNameCache } from "../../src/feishu/senderCache.js";
describe("Feishu client helpers", () => {
it("accepts top-level file_key from the Lark SDK file upload response", async () => {
const dir = await mkdtemp(join(tmpdir(), "hub-feishu-client-"));
try {
const file = join(dir, "student.pdf");
await writeFile(file, "pdf bytes");
const fileCreate = vi.fn(async () => ({ file_key: "file-key-1" }));
const messageCreate = vi.fn(async () => ({ data: { message_id: "message-1" } }));
const rt = mockRuntime({ fileCreate, messageCreate });
await expect(sendFile(rt, "chat-1", file, "student.pdf")).resolves.toBe("message-1");
expect(messageCreate).toHaveBeenCalledWith({
params: { receive_id_type: "chat_id" },
data: { receive_id: "chat-1", msg_type: "file", content: JSON.stringify({ file_key: "file-key-1" }) },
});
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it("accepts top-level message_id from message create responses", async () => {
const rt = mockRuntime({
fileCreate: vi.fn(),
messageCreate: vi.fn(async () => ({ message_id: "message-1" })),
});
await expect(sendTextMessage(rt, "chat-1", "hello")).resolves.toBe("message-1");
});
it("replies to a message without enabling Feishu topic mode", async () => {
const messageCreate = vi.fn();
const messageReply = vi.fn(async () => ({ data: { message_id: "reply-1" } }));
const rt = mockRuntime({ fileCreate: vi.fn(), messageCreate, messageReply });
await expect(sendTextMessage(rt, "chat-1", "hello", { replyToMessageId: "m-trigger" })).resolves.toBe("reply-1");
expect(messageCreate).not.toHaveBeenCalled();
expect(messageReply).toHaveBeenCalledWith({
path: { message_id: "m-trigger" },
data: {
msg_type: "text",
content: JSON.stringify({ text: "hello" }),
},
});
});
it("sends long text in chunks and returns the last successful message id", async () => {
let messageNumber = 0;
const messageCreate = vi.fn(async () => ({ data: { message_id: `message-${++messageNumber}` } }));
const rt = mockRuntime({ fileCreate: vi.fn(), messageCreate });
await expect(sendLongText(rt, "chat-1", "x".repeat(16_005))).resolves.toBe("message-3");
expect(messageCreate).toHaveBeenCalledTimes(3);
const sentTexts = messageCreate.mock.calls.map(([payload]) => {
const content = (payload as { data: { content: string } }).data.content;
return (JSON.parse(content) as { text: string }).text;
});
expect(sentTexts.map((text) => text.length)).toEqual([8000, 8000, 5]);
});
it("resolves sender names through the basic contact API and caches hits", async () => {
const contactBasicBatch = vi.fn(async () => ({ data: { users: [{ name: "Jiarong" }] } }));
const request = vi.fn();
const rt = mockRuntime({ fileCreate: vi.fn(), messageCreate: vi.fn(), contactBasicBatch, request });
const cache = new SenderNameCache();
await expect(resolveSenderName(rt, "ou_user_1", cache)).resolves.toBe("Jiarong");
await expect(resolveSenderName(rt, "ou_user_1", cache)).resolves.toBe("Jiarong");
expect(contactBasicBatch).toHaveBeenCalledTimes(1);
expect(contactBasicBatch).toHaveBeenCalledWith({
params: { user_id_type: "open_id" },
data: { user_ids: ["ou_user_1"] },
});
expect(request).not.toHaveBeenCalled();
});
});
function mockRuntime(options: {
readonly fileCreate: (payload: unknown) => Promise<unknown>;
readonly messageCreate: (payload: unknown) => Promise<unknown>;
readonly messageReply?: (payload: unknown) => Promise<unknown>;
readonly contactBasicBatch?: (payload: unknown) => Promise<unknown>;
readonly request?: (payload: unknown) => Promise<unknown>;
}): FeishuRuntime {
return {
client: {
request: options.request,
contact: {
v3: {
user: {
basicBatch: options.contactBasicBatch,
},
},
},
im: {
v1: {
file: { create: options.fileCreate },
message: {
create: options.messageCreate,
reply: options.messageReply ?? vi.fn(async () => ({ data: { message_id: "reply-1" } })),
},
},
},
} as unknown as FeishuRuntime["client"],
logger: {
info() {},
warn() {},
error() {},
debug() {},
fatal() {},
child() { return this; },
level: "silent",
} as unknown as FeishuRuntime["logger"],
};
}
+141
View File
@@ -0,0 +1,141 @@
import { describe, expect, it, vi } from "vitest";
import {
_buildMarkdownPostRows,
buildOutboundPayload,
patchTextMessage,
sendTextMessage,
type FeishuRuntime,
} from "../../src/feishu/client.js";
describe("Feishu markdown outbound payloads", () => {
it("uses text messages for plain text", () => {
const payload = buildOutboundPayload("hello from cph");
expect(payload.msgType).toBe("text");
expect(JSON.parse(payload.content)).toEqual({ text: "hello from cph" });
});
it("keeps fenced code blocks in separate post rows", () => {
const content = "Before\n```ts\nconst x = 1;\n```\nAfter";
expect(_buildMarkdownPostRows(content)).toEqual([
[{ tag: "md", text: "Before\n" }],
[{ tag: "md", text: "```ts\nconst x = 1;\n```" }],
[{ tag: "md", text: "\nAfter" }],
]);
});
it("forces markdown tables to plain text", () => {
const content = "| A | B |\n|---|---|\n| 1 | 2 |";
const payload = buildOutboundPayload(content);
expect(payload.msgType).toBe("text");
expect(JSON.parse(payload.content)).toEqual({ text: content });
});
it("uses post messages for markdown headers, bold text, and lists", () => {
const content = "# Title\n\nThis is **important**.\n\n- first\n- second";
const payload = buildOutboundPayload(content);
expect(payload.msgType).toBe("post");
expect(JSON.parse(payload.content)).toEqual({
zh_cn: {
content: [[{ tag: "md", text: content }]],
},
});
});
it.each([
["header", "# Heading"],
["list", "- item"],
["code block", "```ts\nconst x = 1;\n```"],
["inline code", "Use `code` here"],
["bold", "**bold**"],
["italic", "_italic_"],
["strikethrough", "~~removed~~"],
["link", "[docs](https://example.com)"],
["blockquote", "> quoted"],
])("detects markdown %s syntax", (_name, content) => {
expect(buildOutboundPayload(content).msgType).toBe("post");
});
it("falls back from rejected post payloads to stripped plain text", async () => {
const messageCreate = vi
.fn()
.mockRejectedValueOnce(new Error("content format of the post type is incorrect"))
.mockResolvedValueOnce({ data: { message_id: "message-1" } });
const rt = mockRuntime({ messageCreate });
await expect(sendTextMessage(rt, "chat-1", "# Title\n\n**bold** and `code`")).resolves.toBe("message-1");
expect(messageCreate).toHaveBeenCalledTimes(2);
expect(messageCreate).toHaveBeenNthCalledWith(1, {
params: { receive_id_type: "chat_id" },
data: {
receive_id: "chat-1",
msg_type: "post",
content: JSON.stringify({
zh_cn: {
content: [[{ tag: "md", text: "# Title\n\n**bold** and `code`" }]],
},
}),
},
});
expect(messageCreate).toHaveBeenNthCalledWith(2, {
params: { receive_id_type: "chat_id" },
data: {
receive_id: "chat-1",
msg_type: "text",
content: JSON.stringify({ text: "Title\n\nbold and code" }),
},
});
});
it("patches markdown post rows as separate interactive card elements", async () => {
const patch = vi.fn(async () => ({}));
const rt = mockRuntime({ patch });
await patchTextMessage(rt, "message-1", "Before\n```ts\nconst x = 1;\n```\nAfter");
expect(patch).toHaveBeenCalledWith({
path: { message_id: "message-1" },
params: { msg_type: "interactive" },
data: {
content: JSON.stringify({
elements: [
{ tag: "div", text: { tag: "lark_md", content: "Before\n" } },
{ tag: "div", text: { tag: "lark_md", content: "```ts\nconst x = 1;\n```" } },
{ tag: "div", text: { tag: "lark_md", content: "\nAfter" } },
],
}),
},
});
});
});
function mockRuntime(options: {
readonly messageCreate?: (payload: unknown) => Promise<unknown>;
readonly patch?: (payload: unknown) => Promise<unknown>;
}): FeishuRuntime {
return {
client: {
im: {
v1: {
message: {
create: options.messageCreate ?? vi.fn(async () => ({ data: { message_id: "message-1" } })),
patch: options.patch ?? vi.fn(async () => ({})),
},
},
},
} as unknown as FeishuRuntime["client"],
logger: {
info() {},
warn() {},
error() {},
debug() {},
fatal() {},
child() { return this; },
level: "silent",
} as unknown as FeishuRuntime["logger"],
};
}
+104
View File
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import { parsePostMessage } from "../../src/feishu/client.js";
describe("parsePostMessage", () => {
it("parses a simple text post", () => {
const result = parsePostMessage(postContent({
zh_cn: { content: [[{ tag: "text", text: "Hello" }]] },
}));
expect(result).toEqual({ text: "Hello", imageKeys: [], fileKeys: [] });
});
it("prepends a post title as a markdown header", () => {
const result = parsePostMessage(postContent({
zh_cn: { title: "Lesson Notes", content: [[{ tag: "text", text: "Hello" }]] },
}));
expect(result.text).toBe("# Lesson Notes\n\nHello");
});
it("converts links to markdown", () => {
const result = parsePostMessage(postContent({
zh_cn: {
content: [[
{ tag: "text", text: "Read " },
{ tag: "a", text: "docs", href: "https://example.com" },
]],
},
}));
expect(result.text).toBe("Read [docs](https://example.com)");
});
it("converts mentions to readable @names", () => {
const result = parsePostMessage(postContent({
zh_cn: { content: [[{ tag: "at", user_id: "@_user_1", user_name: "John" }]] },
}));
expect(result.text).toBe("@John");
});
it("converts code blocks to fenced markdown", () => {
const result = parsePostMessage(postContent({
zh_cn: { content: [[{ tag: "code_block", language: "ts", text: "const x = 1;" }]] },
}));
expect(result.text).toBe("```ts\nconst x = 1;\n```");
});
it("collects image keys and inserts an image placeholder", () => {
const result = parsePostMessage(postContent({
zh_cn: { content: [[{ tag: "img", image_key: "img_key_here" }]] },
}));
expect(result).toEqual({ text: "[Image]", imageKeys: ["img_key_here"], fileKeys: [] });
});
it("collects file keys and inserts an attachment placeholder", () => {
const result = parsePostMessage(postContent({
zh_cn: { content: [[{ tag: "file", file_key: "file_key_here", file_name: "worksheet.pdf" }]] },
}));
expect(result).toEqual({
text: "[Attachment: worksheet.pdf]",
imageKeys: [],
fileKeys: ["file_key_here"],
});
});
it("falls back to en_us when zh_cn is missing", () => {
const result = parsePostMessage(postContent({
en_us: { content: [[{ tag: "text", text: "Hello from English" }]] },
}));
expect(result.text).toBe("Hello from English");
});
it("unwraps content nested under a post key", () => {
const result = parsePostMessage(postContent({
post: {
zh_cn: { content: [[{ tag: "text", text: "Wrapped" }]] },
},
}));
expect(result.text).toBe("Wrapped");
});
it("joins multiple rows with newlines", () => {
const result = parsePostMessage(postContent({
zh_cn: {
content: [
[{ tag: "text", text: "First row" }],
[{ tag: "text", text: "Second row" }],
],
},
}));
expect(result.text).toBe("First row\nSecond row");
});
});
function postContent(value: unknown): string {
return JSON.stringify(value);
}
+300
View File
@@ -0,0 +1,300 @@
import { describe, expect, it, vi } from "vitest";
import type { PrismaClient } from "@prisma/client";
import { InMemoryModelRegistry } from "../../src/agent/models.js";
import type { RunRequest, RunResult } from "../../src/agent/runner.js";
import type { FeishuRuntime, MessageReceiveEvent } from "../../src/feishu/client.js";
import { addReaction, removeReaction } from "../../src/feishu/client.js";
import { makeTriggerHandler } from "../../src/feishu/trigger.js";
import type { PermissionAuthorizer } from "../../src/permission.js";
import type { RuntimeSettings } from "../../src/settings/runtime.js";
describe("Feishu reactions", () => {
it("addReaction returns reaction_id on success", async () => {
const request = vi.fn(async () => ({ data: { reaction_id: "reaction-1" } }));
const rt = mockRuntime({ request });
await expect(addReaction(rt, "message-1", "Typing")).resolves.toBe("reaction-1");
expect(request).toHaveBeenCalledWith({
method: "POST",
url: "/open-apis/im/v1/messages/message-1/reactions",
data: { reaction_type: { emoji_type: "Typing" } },
});
});
it("addReaction returns null on API failure", async () => {
const rt = mockRuntime({
request: vi.fn(async () => {
throw new Error("api failed");
}),
});
await expect(addReaction(rt, "message-1", "Typing")).resolves.toBeNull();
});
it("removeReaction returns true on success", async () => {
const request = vi.fn(async () => ({}));
const rt = mockRuntime({ request });
await expect(removeReaction(rt, "message-1", "reaction-1")).resolves.toBe(true);
expect(request).toHaveBeenCalledWith({
method: "DELETE",
url: "/open-apis/im/v1/messages/message-1/reactions/reaction-1",
});
});
it("removeReaction returns false on API failure", async () => {
const rt = mockRuntime({
request: vi.fn(async () => {
throw new Error("api failed");
}),
});
await expect(removeReaction(rt, "message-1", "reaction-1")).resolves.toBe(false);
});
it("adds Typing on start and removes it on success", async () => {
const run = deferred<RunResult>();
const rt = mockRuntime();
const runAgent = vi.fn((req: RunRequest) => {
req.onStream?.({ type: "text-delta", text: "done" });
return run.promise;
});
await triggerWithRunAgent(runAgent, rt);
expect(rt.reactionRequests).toEqual([
{ kind: "add", messageId: "message-1", emoji: "Typing", reactionId: "reaction-1" },
]);
run.resolve(completedRunResult("done"));
await vi.waitFor(() => {
expect(rt.reactionRequests).toEqual([
{ kind: "add", messageId: "message-1", emoji: "Typing", reactionId: "reaction-1" },
{ kind: "remove", messageId: "message-1", reactionId: "reaction-1" },
]);
});
});
it("replaces Typing with CrossMark on failure", async () => {
const run = deferred<RunResult>();
const rt = mockRuntime();
const runAgent = vi.fn(() => run.promise);
await triggerWithRunAgent(runAgent, rt);
run.reject(new Error("agent failed"));
await vi.waitFor(() => {
expect(rt.reactionRequests).toEqual([
{ kind: "add", messageId: "message-1", emoji: "Typing", reactionId: "reaction-1" },
{ kind: "remove", messageId: "message-1", reactionId: "reaction-1" },
{ kind: "add", messageId: "message-1", emoji: "CrossMark", reactionId: "reaction-2" },
]);
});
});
});
async function triggerWithRunAgent(
runAgent: (req: RunRequest) => Promise<RunResult>,
rt: MockRuntime,
): Promise<void> {
const trigger = makeTriggerHandler({
prisma: mockPrisma(),
settings: mockSettings(),
logger: rt.logger,
authorizer: allowAllAuthorizer(),
runAgent,
messageBatcherOptions: { maxMessages: 1 },
});
await trigger(makeEvent(), rt);
}
function completedRunResult(text: string): RunResult {
return {
status: "completed",
text,
usage: { inputTokens: 1, outputTokens: 1 },
numTurns: 1,
};
}
interface Deferred<T> {
readonly promise: Promise<T>;
readonly resolve: (value: T) => void;
readonly reject: (reason?: unknown) => void;
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
const bot = { key: "@_user_1", id: { open_id: "ou_bot" }, name: "Bot" };
function makeEvent(): MessageReceiveEvent {
return {
message: {
message_id: "message-1",
chat_id: "chat-1",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "@_user_1 do work" }),
mentions: [bot],
},
sender: { sender_id: { open_id: "ou_user" }, sender_type: "user" },
};
}
type ReactionRequest =
| { readonly kind: "add"; readonly messageId: string; readonly emoji: string; readonly reactionId: string }
| { readonly kind: "remove"; readonly messageId: string; readonly reactionId: string };
interface MockRuntime extends FeishuRuntime {
readonly reactionRequests: ReactionRequest[];
}
function mockRuntime(options: { readonly request?: (payload: unknown) => Promise<unknown> } = {}): MockRuntime {
const reactionRequests: ReactionRequest[] = [];
let nextReactionId = 1;
const request =
options.request ??
vi.fn(async (payload: unknown) => {
const requestPayload = payload as { method?: string; url?: string; data?: { reaction_type?: { emoji_type?: string } } };
const addMatch = requestPayload.url?.match(/\/messages\/([^/]+)\/reactions$/);
if (requestPayload.method === "POST" && addMatch?.[1] !== undefined) {
const reactionId = `reaction-${nextReactionId}`;
nextReactionId += 1;
reactionRequests.push({
kind: "add",
messageId: addMatch[1],
emoji: requestPayload.data?.reaction_type?.emoji_type ?? "",
reactionId,
});
return { data: { reaction_id: reactionId } };
}
const removeMatch = requestPayload.url?.match(/\/messages\/([^/]+)\/reactions\/([^/]+)$/);
if (requestPayload.method === "DELETE" && removeMatch?.[1] !== undefined && removeMatch[2] !== undefined) {
reactionRequests.push({ kind: "remove", messageId: removeMatch[1], reactionId: removeMatch[2] });
}
return {};
});
return {
client: {
request,
im: {
v1: {
message: {
create: vi.fn(async () => ({ data: { message_id: "reply-message-1" } })),
patch: vi.fn(async () => ({})),
},
},
},
} as unknown as FeishuRuntime["client"],
logger: silentLogger(),
reactionRequests,
};
}
function mockSettings(): RuntimeSettings {
const registry = new InMemoryModelRegistry(
[{ id: "mock-model", label: "Mock", toolCapable: true }],
[{ id: "draft", label: "Draft", defaultModel: "mock-model" }],
);
return {
async provider(providerId) {
return {
id: providerId,
baseUrl: "https://example.invalid",
authToken: "test-token",
anthropicApiKey: "",
sdkEnv: {},
};
},
async modelRegistry() {
return registry;
},
async runPolicy() {
return { maxTurns: 1 };
},
};
}
function allowAllAuthorizer(): PermissionAuthorizer {
return {
async can(req) {
return {
allowed: true,
reason: "test allow",
action: req.action,
resource: req.resource,
actor: req.actor,
organizationId: "org_test_default",
actorUserId: "user-1",
principals: [{ type: "USER", id: "ou_user" }],
requiredRole: "EDIT",
effectiveRole: "EDIT",
};
},
};
}
function mockPrisma(): PrismaClient {
let lock: { projectId: string; runId: string } | null = null;
const session = { id: "session-1", metadata: {} };
return {
feishuEventReceipt: {
findUnique: vi.fn(async () => null),
create: vi.fn(async () => ({ id: "receipt-1" })),
},
projectGroupBinding: {
findUnique: vi.fn(async () => ({ projectId: "project-1" })),
},
project: {
findUnique: vi.fn(async () => ({ workspaceDir: "/tmp/cph-project" })),
},
agentSession: {
findFirst: vi.fn(async () => null),
create: vi.fn(async () => session),
update: vi.fn(async () => session),
},
agentRun: {
create: vi.fn(async () => ({ id: "run-1" })),
findUnique: vi.fn(async () => null),
update: vi.fn(async () => ({ id: "run-1" })),
},
projectAgentLock: {
findUnique: vi.fn(async () => (lock === null ? null : { runId: lock.runId })),
create: vi.fn(async (args: { data: { projectId: string; runId: string } }) => {
lock = { projectId: args.data.projectId, runId: args.data.runId };
return lock;
}),
deleteMany: vi.fn(async () => {
lock = null;
return { count: 1 };
}),
},
auditEntry: {
create: vi.fn(async () => ({ id: "audit-1" })),
},
} as unknown as PrismaClient;
}
function silentLogger(): FeishuRuntime["logger"] {
return {
info() {},
warn() {},
error() {},
debug() {},
fatal() {},
child() { return this; },
level: "silent",
} as unknown as FeishuRuntime["logger"];
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { readFeishuContext } from "../../src/feishu/read.js";
import type { FeishuRuntime } from "../../src/feishu/client.js";
describe("readFeishuContext", () => {
it("compacts Feishu message.get replies with parent_id, thread_id, and body.content", async () => {
const rt = {
client: {
im: {
v1: {
message: {
get: async () => ({
data: {
items: [
{
message_id: "m-child",
root_id: "m-root",
parent_id: "m-parent",
thread_id: "thread-1",
msg_type: "text",
body: { content: JSON.stringify({ text: "parent text" }) },
},
],
},
}),
list: async () => ({ data: { items: [] } }),
},
},
},
},
logger: { warn() {}, info() {}, error() {}, debug() {} },
} as unknown as FeishuRuntime;
const raw = await readFeishuContext(
{ chat_id: "chat-1", anchor: "reply", id: "m-child" },
{ runId: "run-1", projectId: "proj-1", boundChatId: "chat-1", workspaceDir: "/tmp/proj-1" },
rt,
);
expect(JSON.parse(raw)).toMatchObject({
message_id: "m-child",
content: JSON.stringify({ text: "parent text" }),
parent_id: "m-parent",
parent_message_id: "m-parent",
root_id: "m-root",
thread_id: "thread-1",
});
});
});
+163
View File
@@ -0,0 +1,163 @@
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { sendFile, withRetry, type FeishuRuntime } from "../../src/feishu/client.js";
describe("withRetry", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it("succeeds on first attempt without delaying", async () => {
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
const fn = vi.fn(async () => "ok");
await expect(withRetry(fn)).resolves.toBe("ok");
expect(fn).toHaveBeenCalledTimes(1);
expect(setTimeoutSpy).not.toHaveBeenCalled();
});
it("retries on failure then succeeds", async () => {
const fn = vi.fn()
.mockRejectedValueOnce(new Error("temporary failure"))
.mockResolvedValueOnce("ok");
const result = withRetry(fn);
await Promise.resolve();
expect(fn).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1000);
await expect(result).resolves.toBe("ok");
expect(fn).toHaveBeenCalledTimes(2);
});
it("exhausts all attempts and rethrows", async () => {
const error = new Error("still failing");
const fn = vi.fn(async () => {
throw error;
});
const result = withRetry(fn);
const assertion = expect(result).rejects.toBe(error);
await Promise.resolve();
await vi.advanceTimersByTimeAsync(1000);
await vi.advanceTimersByTimeAsync(2000);
await assertion;
expect(fn).toHaveBeenCalledTimes(3);
});
it("respects shouldRetry predicate", async () => {
const error = new Error("validation failure");
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
const shouldRetry = vi.fn(() => false);
const fn = vi.fn(async () => {
throw error;
});
await expect(withRetry(fn, { shouldRetry })).rejects.toBe(error);
expect(fn).toHaveBeenCalledTimes(1);
expect(shouldRetry).toHaveBeenCalledWith(error);
expect(setTimeoutSpy).not.toHaveBeenCalled();
});
it("uses exponential backoff delays", async () => {
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
const fn = vi.fn()
.mockRejectedValueOnce(new Error("first failure"))
.mockRejectedValueOnce(new Error("second failure"))
.mockResolvedValueOnce("ok");
const result = withRetry(fn);
await Promise.resolve();
await vi.advanceTimersByTimeAsync(1000);
await vi.advanceTimersByTimeAsync(2000);
await expect(result).resolves.toBe("ok");
expect(setTimeoutSpy.mock.calls.map(([, delay]) => delay)).toEqual([1000, 2000]);
});
});
describe("sendFile retry", () => {
afterEach(() => {
vi.useRealTimers();
});
it("retries upload on transient failure", async () => {
const dir = await mkdtemp(join(tmpdir(), "hub-feishu-retry-"));
try {
const file = join(dir, "student.pdf");
await writeFile(file, "pdf bytes");
let failFirstUpload: (error: Error) => void = () => {
throw new Error("upload rejection was not initialized");
};
const firstUpload = new Promise<never>((_resolve, reject) => {
failFirstUpload = reject;
});
const fileCreate = vi.fn()
.mockReturnValueOnce(firstUpload)
.mockResolvedValueOnce({ data: { file_key: "file-key-1" } });
const messageCreate = vi.fn(async () => ({ data: { message_id: "message-1" } }));
const rt = mockRuntime({ fileCreate, messageCreate });
const result = sendFile(rt, "chat-1", file, "student.pdf");
await waitForCalls(fileCreate, 1);
vi.useFakeTimers();
failFirstUpload(new Error("temporary upload failure"));
await Promise.resolve();
await vi.advanceTimersByTimeAsync(1000);
await expect(result).resolves.toBe("message-1");
expect(fileCreate).toHaveBeenCalledTimes(2);
expect(messageCreate).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
await rm(dir, { recursive: true, force: true });
}
});
});
async function waitForCalls(mock: Mock, expectedCalls: number): Promise<void> {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (mock.mock.calls.length >= expectedCalls) return;
await new Promise((resolve) => setTimeout(resolve, 0));
}
throw new Error(`Timed out waiting for mock to be called ${expectedCalls} time(s)`);
}
function mockRuntime(options: {
readonly fileCreate: (payload: unknown) => Promise<unknown>;
readonly messageCreate: (payload: unknown) => Promise<unknown>;
}): FeishuRuntime {
return {
client: {
im: {
v1: {
file: { create: options.fileCreate },
message: { create: options.messageCreate },
},
},
} as unknown as FeishuRuntime["client"],
logger: {
info() {},
warn() {},
error() {},
debug() {},
fatal() {},
child() { return this; },
level: "silent",
} as unknown as FeishuRuntime["logger"],
};
}
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { resolveDeliverableFile } from "../../src/feishu/fileDelivery.js";
describe("file delivery path resolution", () => {
it("resolves a repo-root file from a project workspace only when explicitly named", async () => {
const root = await makeRepo();
try {
const workspace = join(root, "examples", "TH-141");
await writeFile(join(root, "README.md"), "# root readme\n");
await expect(resolveDeliverableFile("README.md", workspace)).resolves.toEqual({
path: join(root, "README.md"),
name: "README.md",
});
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("resolves a bare build filename against workspace/build", async () => {
const root = await makeRepo();
try {
const workspace = join(root, "examples", "TH-141");
const pdf = join(workspace, "build", "student.pdf");
await writeFile(pdf, "pdf bytes");
await expect(resolveDeliverableFile("student.pdf", workspace)).resolves.toEqual({
path: pdf,
name: "student.pdf",
});
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("does not infer files from natural-language prompts", async () => {
const root = await makeRepo();
try {
const workspace = join(root, "examples", "TH-141");
await writeFile(join(workspace, "build", "student.pdf"), "pdf bytes");
await expect(resolveDeliverableFile("cph build 生成 PDF 给我", workspace)).resolves.toBeNull();
} finally {
await rm(root, { recursive: true, force: true });
}
});
});
async function makeRepo(): Promise<string> {
const root = await mkdir(join(tmpdir(), `hub-file-delivery-${Date.now()}-${Math.random().toString(36).slice(2)}`), { recursive: true });
await mkdir(join(root, ".git"));
await mkdir(join(root, "examples", "TH-141", "build"), { recursive: true });
return root;
}
+114
View File
@@ -0,0 +1,114 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MessageBatcher, messageBatchKey } from "../../src/feishu/messageBatcher.js";
describe("MessageBatcher", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("flushes a single message after the debounce period", async () => {
const flushed: string[] = [];
const batcher = new MessageBatcher(async (text) => {
flushed.push(text);
}, { debounceMs: 25 });
await batcher.enqueue("chat-1", "sender-1", "hello");
expect(flushed).toEqual([]);
await vi.advanceTimersByTimeAsync(24);
expect(flushed).toEqual([]);
await vi.advanceTimersByTimeAsync(1);
expect(flushed).toEqual(["hello"]);
});
it("merges two quick messages with a newline", async () => {
const flushed: string[] = [];
const batcher = new MessageBatcher(async (text) => {
flushed.push(text);
}, { debounceMs: 25 });
await batcher.enqueue("chat-1", "sender-1", "first");
await batcher.enqueue("chat-1", "sender-1", "second");
await vi.advanceTimersByTimeAsync(25);
expect(flushed).toEqual(["first\nsecond"]);
});
it("flushes immediately when the max message count is reached", async () => {
const flushed: string[] = [];
const batcher = new MessageBatcher(async (text) => {
flushed.push(text);
}, { debounceMs: 1000, maxMessages: 2 });
await batcher.enqueue("chat-1", "sender-1", "first");
expect(flushed).toEqual([]);
await batcher.enqueue("chat-1", "sender-1", "second");
expect(flushed).toEqual(["first\nsecond"]);
});
it("flushes immediately when the max character count is reached", async () => {
const flushed: string[] = [];
const batcher = new MessageBatcher(async (text) => {
flushed.push(text);
}, { debounceMs: 1000, maxChars: 5 });
await batcher.enqueue("chat-1", "sender-1", "abc");
expect(flushed).toEqual([]);
await batcher.enqueue("chat-1", "sender-1", "d");
expect(flushed).toEqual(["abc\nd"]);
});
it("uses the extended debounce for a chunk near the split threshold", async () => {
const flushed: string[] = [];
const batcher = new MessageBatcher(async (text) => {
flushed.push(text);
}, { debounceMs: 10, splitThreshold: 5, extendedDebounceMs: 50 });
await batcher.enqueue("chat-1", "sender-1", "12345");
await vi.advanceTimersByTimeAsync(49);
expect(flushed).toEqual([]);
await vi.advanceTimersByTimeAsync(1);
expect(flushed).toEqual(["12345"]);
});
it("keeps different chat/sender pairs in separate batches", async () => {
const flushed: string[] = [];
const batcher = new MessageBatcher(async (text) => {
flushed.push(text);
}, { debounceMs: 25 });
await batcher.enqueue("chat-1", "sender-1", "one");
await batcher.enqueue("chat-1", "sender-2", "two");
await batcher.enqueue("chat-2", "sender-1", "three");
await vi.advanceTimersByTimeAsync(25);
expect(flushed.sort()).toEqual(["one", "three", "two"]);
});
it("flushAll flushes every pending batch", async () => {
const flushed: string[] = [];
const batcher = new MessageBatcher(async (text) => {
flushed.push(text);
}, { debounceMs: 1000 });
await batcher.enqueue("chat-1", "sender-1", "one");
await batcher.enqueue("chat-2", "sender-2", "two");
await batcher.flushAll();
expect(flushed.sort()).toEqual(["one", "two"]);
});
it("builds keys from chat and sender open ids", () => {
expect(messageBatchKey("chat-1", "ou-1")).toBe("chat-1:ou-1");
});
// Slash-command bypass is owned by trigger.ts, not MessageBatcher. Existing
// trigger integration tests cover /new, /resume, /reset as immediate paths.
});
+255 -32
View File
@@ -1,39 +1,262 @@
import { describe, expect, it } from "vitest";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { runAgent } from "../../src/agent/runner.js";
import { ToolRegistry } from "../../src/agent/tools.js";
import { readFileTool } from "../../src/agent/workspace.js";
import { createMockModelFactory } from "../integration/helpers.js";
describe("runAgent with AI SDK tool loop", () => {
it("executes SDK tools and returns the final assistant response", async () => {
const ws = await mkdtemp(join(tmpdir(), "hub-runner-"));
try {
await writeFile(join(ws, "lesson.txt"), "hello lesson", "utf8");
const tools = new ToolRegistry();
tools.register("read_file", readFileTool);
const { modelFactory, models } = createMockModelFactory([
{
toolCalls: [{ toolCallId: "call-1", toolName: "read_file", input: { path: "lesson.txt" } }],
const queryMock = vi.hoisted(() => vi.fn());
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
query: queryMock,
}));
const stubPrisma = {
projectAgentLock: { update: async () => ({}) },
} as unknown as import("@prisma/client").PrismaClient;
function assistantMessage(text: string) {
return {
type: "assistant",
message: {
content: [{ type: "text", text }],
usage: { input_tokens: 3, output_tokens: 5 },
},
};
}
function resultMessage(sessionId: string, costUsd?: number) {
return {
type: "result",
subtype: "success",
session_id: sessionId,
...(costUsd !== undefined ? { total_cost_usd: costUsd } : {}),
};
}
function messages(...items: unknown[]) {
return (async function* () {
for (const item of items) yield item;
})();
}
function abortableMessages(ac: AbortController, ...items: unknown[]) {
return (async function* () {
for (const item of items) yield item;
// Simulate the SDK: after yielding, the query blocks until the abort
// signal fires, then throws (the SDK raises AbortError).
if (ac.signal.aborted) throw new Error("aborted");
await new Promise<void>((resolve) => {
ac.signal.addEventListener("abort", () => resolve(), { once: true });
});
throw new Error("aborted");
})();
}
describe("runAgent", () => {
beforeEach(() => {
queryMock.mockReset();
});
it("passes the previous Claude SDK session id through resume", async () => {
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-new")));
const result = await runAgent({
prompt: "再发一次",
model: "anthropic/claude-sonnet-5",
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
systemPrompt: undefined,
resumeSessionId: "sdk-session-old",
runId: "run-1",
sessionId: "hub-session-1",
prisma: stubPrisma,
});
expect(queryMock).toHaveBeenCalledWith({
prompt: "再发一次",
options: expect.objectContaining({
cwd: "/tmp/ws",
model: "anthropic/claude-sonnet-5",
resume: "sdk-session-old",
// ADR-0018: agent execution surface bounded by workspace.
permissionMode: "bypassPermissions",
allowDangerouslySkipPermissions: true,
sandbox: expect.objectContaining({
enabled: true,
failIfUnavailable: true,
filesystem: expect.objectContaining({
allowWrite: ["/tmp/ws"],
}),
}),
}),
});
});
it("does not send resume for a fresh Hub session", async () => {
queryMock.mockReturnValue(messages(assistantMessage("fresh"), resultMessage("sdk-session-1")));
await runAgent({
prompt: "你好",
model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
systemPrompt: undefined,
runId: "run-1",
sessionId: "hub-session-1",
prisma: stubPrisma,
});
const call = queryMock.mock.calls[0]?.[0] as { options?: Record<string, unknown> } | undefined;
expect(call?.options).not.toHaveProperty("resume");
});
it("maps role tool ids to the Claude SDK tool whitelist", async () => {
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
await runAgent({
prompt: "审校一下",
model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
systemPrompt: undefined,
tools: ["read_file", "cph_check", "send_file"],
runId: "run-1",
sessionId: "hub-session-1",
prisma: stubPrisma,
});
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
options: {
tools: ["Read", "Bash"],
allowedTools: ["Read", "Bash", "mcp__cph_hub__send_file"],
},
});
});
it("disables all SDK tools for an empty role tool whitelist", async () => {
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
await runAgent({
prompt: "只回答",
model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
systemPrompt: undefined,
tools: [],
runId: "run-1",
sessionId: "hub-session-1",
prisma: stubPrisma,
});
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
options: {
tools: [],
allowedTools: [],
},
});
});
it("returns SDK-reported cost when present", async () => {
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1", 0.0042)));
const result = await runAgent({
prompt: "算一下成本",
model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
systemPrompt: undefined,
runId: "run-1",
sessionId: "hub-session-1",
prisma: stubPrisma,
});
expect(result.costUsd).toBe(0.0042);
});
it("passes provider env per run without mutating process env", async () => {
delete process.env["CPH_TEST_PROVIDER_ENV_MUTATION"];
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
await runAgent({
prompt: "你好",
model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
systemPrompt: undefined,
providerEnv: {
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
ANTHROPIC_AUTH_TOKEN: "test-token",
ANTHROPIC_API_KEY: "",
CPH_TEST_PROVIDER_ENV_MUTATION: "per-run",
},
runId: "run-1",
sessionId: "hub-session-1",
prisma: stubPrisma,
});
const call = queryMock.mock.calls[0]?.[0];
expect(call).toMatchObject({
options: {
env: {
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
ANTHROPIC_AUTH_TOKEN: "test-token",
ANTHROPIC_API_KEY: "",
CPH_TEST_PROVIDER_ENV_MUTATION: "per-run",
},
{ text: "done", finishReason: "stop" },
]);
},
});
expect(process.env["CPH_TEST_PROVIDER_ENV_MUTATION"]).toBeUndefined();
});
const result = await runAgent(modelFactory, tools, {
prompt: "read lesson.txt",
model: "mock-model",
project: { projectId: "p", boundChatId: "c", workspaceDir: ws },
systemPrompt: "system",
maxIterations: 5,
});
it("persists structured user and assistant messages best-effort", async () => {
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
const createMessage = vi.fn().mockResolvedValue({});
const prisma = {
projectAgentLock: { update: async () => ({}) },
agentMessage: { create: createMessage },
} as unknown as import("@prisma/client").PrismaClient;
expect(result.status).toBe("completed");
expect(result.messages.at(-1)).toMatchObject({ role: "assistant" });
expect(models.get("mock-model")?.calls).toHaveLength(2);
} finally {
await rm(ws, { recursive: true, force: true });
}
await runAgent({
prompt: "你好",
model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
systemPrompt: undefined,
runId: "run-1",
sessionId: "hub-session-1",
prisma,
});
expect(createMessage).toHaveBeenCalledTimes(2);
expect(createMessage).toHaveBeenNthCalledWith(1, {
data: expect.objectContaining({
sessionId: "hub-session-1",
runId: "run-1",
role: "user",
content: "你好",
}),
});
expect(createMessage).toHaveBeenNthCalledWith(2, {
data: expect.objectContaining({
sessionId: "hub-session-1",
runId: "run-1",
role: "assistant",
content: "ok",
}),
});
});
it("returns interrupted status when the abort controller fires", async () => {
const ac = new AbortController();
queryMock.mockReturnValue(abortableMessages(ac, assistantMessage("partial"), resultMessage("sdk-session-abort")));
const runPromise = runAgent({
prompt: "长任务",
model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
systemPrompt: undefined,
runId: "run-abort",
sessionId: "hub-session-abort",
prisma: stubPrisma,
abortController: ac,
});
// Let the query yield its messages and block on the abort wait.
await Promise.resolve();
ac.abort();
const result = await runPromise;
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({ options: { abortController: ac } });
expect(result.error).toBeUndefined();
expect(result.sdkSessionId).toBe("sdk-session-abort");
const call = queryMock.mock.calls[0]?.[0] as { options?: { abortController?: AbortController } } | undefined;
expect(call?.options?.abortController).toBe(ac);
});
});
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { createEnvRuntimeSettings } from "../../src/settings/runtime.js";
describe("runtime settings", () => {
it("reads the model registry from the current env at call time", async () => {
const env: Record<string, string | undefined> = {
ANTHROPIC_DEFAULT_SONNET_MODEL: "anthropic/claude-sonnet-5",
};
const settings = createEnvRuntimeSettings(env);
expect((await settings.modelRegistry()).resolve(undefined, "draft")).toBe("anthropic/claude-sonnet-5");
env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = "z-ai/glm-4.7";
expect((await settings.modelRegistry()).resolve(undefined, "draft")).toBe("z-ai/glm-4.7");
});
it("returns provider SDK env without requiring global process env mutation", async () => {
const settings = createEnvRuntimeSettings({
ANTHROPIC_BASE_URL: "https://example.test/api",
ANTHROPIC_AUTH_TOKEN: "openrouter-token",
ANTHROPIC_API_KEY: "explicit-api-key",
});
await expect(settings.provider("openrouter")).resolves.toMatchObject({
id: "openrouter",
baseUrl: "https://example.test/api",
authToken: "openrouter-token",
anthropicApiKey: "explicit-api-key",
sdkEnv: {
ANTHROPIC_BASE_URL: "https://example.test/api",
ANTHROPIC_AUTH_TOKEN: "openrouter-token",
ANTHROPIC_API_KEY: "explicit-api-key",
},
});
});
it("defaults Anthropic API key to empty for OpenRouter's Anthropic skin", async () => {
const settings = createEnvRuntimeSettings({
ANTHROPIC_AUTH_TOKEN: "openrouter-token",
});
await expect(settings.provider("openrouter")).resolves.toMatchObject({
baseUrl: "https://openrouter.ai/api",
anthropicApiKey: "",
sdkEnv: {
ANTHROPIC_API_KEY: "",
},
});
});
it("requires an auth token for the OpenRouter provider", async () => {
const settings = createEnvRuntimeSettings({});
await expect(settings.provider("openrouter")).rejects.toThrow("missing required runtime setting: ANTHROPIC_AUTH_TOKEN");
});
it("honors HUB_AGENT_MAX_TURNS as a runtime run policy", async () => {
const settings = createEnvRuntimeSettings({
HUB_AGENT_MAX_TURNS: "9",
});
await expect(settings.runPolicy({ projectId: "p", roleId: "draft" })).resolves.toEqual({ maxTurns: 9 });
});
});
+107
View File
@@ -0,0 +1,107 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { SenderNameCache } from "../../src/feishu/senderCache.js";
describe("SenderNameCache", () => {
afterEach(() => {
vi.useRealTimers();
});
it("get returns null for missing key", () => {
const cache = new SenderNameCache();
expect(cache.get("ou_user")).toBeNull();
});
it("set then get returns the name", () => {
const cache = new SenderNameCache();
cache.set("ou_user", "Alice");
expect(cache.get("ou_user")).toBe("Alice");
});
it("expired entry returns null", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const cache = new SenderNameCache({ ttlMs: 100 });
cache.set("ou_user", "Alice");
vi.advanceTimersByTime(101);
expect(cache.get("ou_user")).toBeNull();
expect(cache.size()).toBe(0);
});
it("getOrFetch uses cache on hit", async () => {
const cache = new SenderNameCache();
const fetchFn = vi.fn(async () => "Fetched Alice");
cache.set("ou_user", "Alice");
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBe("Alice");
expect(fetchFn).not.toHaveBeenCalled();
});
it("getOrFetch calls fetchFn on miss", async () => {
const cache = new SenderNameCache();
const fetchFn = vi.fn(async () => "Alice");
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBe("Alice");
expect(fetchFn).toHaveBeenCalledTimes(1);
expect(fetchFn).toHaveBeenCalledWith("ou_user");
});
it("getOrFetch caches the result of fetchFn", async () => {
const cache = new SenderNameCache();
const fetchFn = vi.fn(async () => "Alice");
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBe("Alice");
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBe("Alice");
expect(fetchFn).toHaveBeenCalledTimes(1);
});
it("evicts the least recently used entry when full", () => {
const cache = new SenderNameCache({ maxSize: 2 });
cache.set("ou_1", "Alice");
cache.set("ou_2", "Bob");
expect(cache.get("ou_1")).toBe("Alice");
cache.set("ou_3", "Carol");
expect(cache.get("ou_1")).toBe("Alice");
expect(cache.get("ou_2")).toBeNull();
expect(cache.get("ou_3")).toBe("Carol");
});
it("clear empties the cache", () => {
const cache = new SenderNameCache();
cache.set("ou_user", "Alice");
cache.clear();
expect(cache.get("ou_user")).toBeNull();
expect(cache.size()).toBe(0);
});
it("size returns correct count", () => {
const cache = new SenderNameCache();
cache.set("ou_1", "Alice");
cache.set("ou_2", "Bob");
expect(cache.size()).toBe(2);
});
it("getOrFetch with null fetchFn result does not cache null", async () => {
const cache = new SenderNameCache();
const fetchFn = vi.fn(async () => null);
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBeNull();
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBeNull();
expect(fetchFn).toHaveBeenCalledTimes(2);
expect(cache.size()).toBe(0);
});
});
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { parseSlashHelpSubcommand, parseSlashInvocation } from "../../src/feishu/slashCommands.js";
describe("slash command parser", () => {
it("parses a slash invocation without resolving it", () => {
expect(parseSlashInvocation("/new")).toEqual({ name: "new", args: [] });
expect(parseSlashInvocation("/review 看看这节")).toEqual({ name: "review", args: ["看看这节"] });
expect(parseSlashInvocation("写教案")).toBeNull();
});
it("parses help subcommands only in the exact /<command> help form", () => {
expect(parseSlashHelpSubcommand({ name: "new", args: ["help"] })).toBe("new");
expect(parseSlashHelpSubcommand({ name: "unknown", args: ["help"] })).toBe("unknown");
expect(parseSlashHelpSubcommand({ name: "new", args: ["help", "please"] })).toBeNull();
expect(parseSlashHelpSubcommand({ name: "help", args: ["new"] })).toBeNull();
});
});
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { splitAtBoundary } from "../../src/feishu/textStream.js";
describe("splitAtBoundary", () => {
it("keeps short text in a single chunk", () => {
expect(splitAtBoundary("short text", 20)).toEqual(["short text"]);
});
it("keeps text at exactly maxLength in a single chunk", () => {
const text = "x".repeat(20);
expect(splitAtBoundary(text, 20)).toEqual([text]);
});
it("splits text exceeding maxLength into chunks at or below maxLength", () => {
const chunks = splitAtBoundary("x".repeat(45), 20);
expect(chunks).toHaveLength(3);
expect(chunks.every((chunk) => chunk.length <= 20)).toBe(true);
expect(chunks.join("")).toBe("x".repeat(45));
});
it("moves a split before a fenced code block when the boundary lands inside it", () => {
const prefix = "Before code starts\n";
const codeBlock = "```ts\nx\n```\n";
const text = `${prefix}${codeBlock}After`;
const chunks = splitAtBoundary(text, prefix.length + 4);
expect(chunks).toEqual([prefix, `${codeBlock}After`]);
});
it("prefers paragraph boundaries over newline boundaries", () => {
const text = "para one\n\nline two\nline three";
expect(splitAtBoundary(text, 22)).toEqual(["para one\n\n", "line two\nline three"]);
});
it("prefers newline boundaries over space boundaries", () => {
const text = "alpha beta\ngamma delta epsilon";
expect(splitAtBoundary(text, 24)).toEqual(["alpha beta\n", "gamma delta epsilon"]);
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { PatchableTextStream } from "../../src/feishu/textStream.js";
describe("PatchableTextStream", () => {
it("creates only one initial message when deltas arrive while create is pending", async () => {
const creates: string[] = [];
const patches: string[] = [];
let resolveCreate: ((id: string) => void) | undefined;
const firstCreate = new Promise<string>((resolve) => {
resolveCreate = resolve;
});
const stream = new PatchableTextStream(
{
create: async (text) => {
creates.push(text);
return firstCreate;
},
patch: async (_messageId, text) => {
patches.push(text);
},
},
{ patchIntervalMs: 0 },
);
stream.append("我目前运行在");
await Promise.resolve();
stream.append(" Claude Sonnet");
resolveCreate?.("msg-1");
await stream.finish("我目前运行在 Claude Sonnet");
expect(creates).toEqual(["我目前运行在"]);
expect(patches.at(-1)).toBe("我目前运行在 Claude Sonnet");
});
it("sends a fallback message when no stream delta arrived", async () => {
const creates: string[] = [];
const stream = new PatchableTextStream({
create: async (text) => {
creates.push(text);
return "msg-1";
},
patch: async () => {},
});
await stream.finish("完整回复");
expect(creates).toEqual(["完整回复"]);
});
});
+135
View File
@@ -0,0 +1,135 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { TriggerQueue, type QueuedTrigger } from "../../src/feishu/triggerQueue.js";
import type { MessageReceiveEvent } from "../../src/feishu/client.js";
describe("TriggerQueue", () => {
afterEach(() => {
vi.useRealTimers();
});
it("dequeues triggers in FIFO order", () => {
const queue = new TriggerQueue();
queue.enqueue("project-1", makeTrigger("first"));
queue.enqueue("project-1", makeTrigger("second"));
expect(queue.dequeue("project-1")?.prompt).toBe("first");
expect(queue.dequeue("project-1")?.prompt).toBe("second");
});
it("returns the 1-based queue position from enqueue", () => {
const queue = new TriggerQueue();
expect(queue.enqueue("project-1", makeTrigger("first"))).toBe(1);
expect(queue.enqueue("project-1", makeTrigger("second"))).toBe(2);
expect(queue.enqueue("project-1", makeTrigger("third"))).toBe(3);
});
it("returns 0 when the project queue is full", () => {
const queue = new TriggerQueue({ maxQueueSize: 2 });
expect(queue.enqueue("project-1", makeTrigger("first"))).toBe(1);
expect(queue.enqueue("project-1", makeTrigger("second"))).toBe(2);
expect(queue.enqueue("project-1", makeTrigger("third"))).toBe(0);
expect(queue.length("project-1")).toBe(2);
});
it("returns null when dequeueing an empty queue", () => {
const queue = new TriggerQueue();
expect(queue.dequeue("project-1")).toBeNull();
});
it("peeks without removing the trigger", () => {
const queue = new TriggerQueue();
queue.enqueue("project-1", makeTrigger("first"));
expect(queue.peek("project-1")?.prompt).toBe("first");
expect(queue.length("project-1")).toBe(1);
expect(queue.dequeue("project-1")?.prompt).toBe("first");
});
it("reports length and pending state per project", () => {
const queue = new TriggerQueue();
expect(queue.length("project-1")).toBe(0);
expect(queue.hasPending("project-1")).toBe(false);
queue.enqueue("project-1", makeTrigger("first"));
expect(queue.length("project-1")).toBe(1);
expect(queue.hasPending("project-1")).toBe(true);
});
it("purges expired triggers", () => {
vi.useFakeTimers();
vi.setSystemTime(1_000);
const queue = new TriggerQueue({ maxWaitMs: 100 });
queue.enqueue("project-1", makeTrigger("old"));
vi.setSystemTime(1_050);
queue.enqueue("project-1", makeTrigger("fresh"));
vi.setSystemTime(1_101);
expect(queue.purgeExpired()).toBe(1);
expect(queue.length("project-1")).toBe(1);
expect(queue.dequeue("project-1")?.prompt).toBe("fresh");
});
it("clears all triggers for a project", () => {
const queue = new TriggerQueue();
queue.enqueue("project-1", makeTrigger("first"));
queue.enqueue("project-1", makeTrigger("second"));
queue.enqueue("project-2", makeTrigger("other"));
expect(queue.clear("project-1")).toBe(2);
expect(queue.length("project-1")).toBe(0);
expect(queue.length("project-2")).toBe(1);
});
it("clears all project queues", () => {
const queue = new TriggerQueue();
queue.enqueue("project-1", makeTrigger("first"));
queue.enqueue("project-2", makeTrigger("other"));
queue.clearAll();
expect(queue.hasPending("project-1")).toBe(false);
expect(queue.hasPending("project-2")).toBe(false);
});
it("keeps multiple project queues independent", () => {
const queue = new TriggerQueue();
expect(queue.enqueue("project-1", makeTrigger("p1-first"))).toBe(1);
expect(queue.enqueue("project-2", makeTrigger("p2-first"))).toBe(1);
expect(queue.enqueue("project-1", makeTrigger("p1-second"))).toBe(2);
expect(queue.dequeue("project-2")?.prompt).toBe("p2-first");
expect(queue.dequeue("project-1")?.prompt).toBe("p1-first");
expect(queue.dequeue("project-1")?.prompt).toBe("p1-second");
});
});
function makeTrigger(prompt: string): Omit<QueuedTrigger, "projectId" | "enqueuedAt"> {
return {
chatId: "chat-1",
prompt,
msg: makeMessage(prompt),
senderOpenId: "ou-user",
actor: { feishuOpenId: "ou-user", chatId: "chat-1" },
};
}
function makeMessage(prompt: string): MessageReceiveEvent["message"] {
return {
message_id: `message-${prompt}`,
chat_id: "chat-1",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: `@_user_1 ${prompt}` }),
mentions: [{ key: "@_user_1", id: { open_id: "ou-bot" }, name: "Bot" }],
};
}
+4
View File
@@ -13,6 +13,10 @@ namespace Spec.System
structure Identifiers where
/-- 课程项目标识(`OPEN` 表示;聚合根,likec4 `Project`)。 -/
ProjectId : Type
/-- SaaS 租户/组织标识(`OPEN` 表示;ADR-0020 tenant root)。 -/
OrganizationId : Type
/-- Hub teacher team 标识(`OPEN` 表示;ADR-0020 org-scoped team)。 -/
TeamId : Type
/-- 一次 agent 任务的标识(`OPEN` 表示;锁的 owner、审计主体,`AgentRun`。provider 无关,
ADR-0017;`@Claude` 仅为触发品牌,不承诺 provider)。 -/
RunId : Type
+6 -2
View File
@@ -1,11 +1,12 @@
import Spec.System.ProjectGroup
import Spec.System.Organization
import Spec.System.Run
import Spec.System.Lock
import Spec.System.Memory
import Spec.System.AgentSurface
import Spec.System.Permission
import Spec.System.PermissionGrant
import Spec.System.Audit
/-!
# System —— Hub 平台层契约
@@ -14,13 +15,16 @@ import Spec.System.Audit
**语义分歧点**:
- `ProjectGroup` —— project↔飞书群 1:1 长生命周期绑定(ADR-0001);群是协作空间,不持锁。
- `Organization` —— SaaS tenant root(ADR-0020);project/team 单归属,TEAM grant 不跨 org。
- `Run` —— AgentRun 状态与终止判定(状态集合完整性 OPEN)。
- `Lock` —— 锁 owner=run(ADR-0002),及"持锁者必为非终止 run"的核心不变式。
- `Memory` —— 按需上下文:锚点类别(ADR-0003)+ MCP 工具按 run/project 上下文授权的不变式。
- `AgentSurface` —— agent 执行面被 run 的工作区所界定(ADR-0018);与 Lock 正交——
Lock 限定并发,Surface 限定波及面。机制 OPEN。
- `Permission` —— read⊂edit⊂manage 角色格、能力推导、单调性;force-release 在格外。
- `PermissionGrant` —— grant(resource×principal×role)与 settings(六 policy 旋钮)结构
(ADR-0004);role-capability 与 settings-policy 的组合规则 OPEN。
- `Audit` —— 有意从简(内容多为 plumbing,OPEN)。
标识符见 `Spec.Prelude`。决策出处:ADR-0001..0004。
标识符见 `Spec.Prelude`。决策出处:ADR-0001..0004, 0018, 0020
-/
+44
View File
@@ -0,0 +1,44 @@
import Spec.Prelude
import Spec.System.Run
/-!
# AgentSurface —— Agent 执行面边界(ADR-0018)
ADR-0001/0002/0004 覆盖"协作治理"直到 `triggerAgent`:谁能触发一次 run。但触发之后
agent 在执行层面能干什么——读哪些文件、跑什么命令——任何 ADR / 散文都未定。ADR-0017
落地时采用 Claude Code SDK 的 `bypassPermissions` + 全量 Read/Write/Bash/Glob/Grep,
agent 的文件与 shell 面对宿主**无界**:spec 与 ADR 均未钉死边界。本模块补这一层。
钉死的不变式:agent 在一次 run 内发起的文件操作,其路径必须落在该 run 所属 project
的工作区目录内(ADR-0007:工程文件是目录树;此 ADR 固定"agent 操作落在该树内")。
逃逸即越权,拒绝。与 `Lock`(ADR-0002)正交:Lock 限定**并发**(谁在改),Surface
限定**波及面**(能改到哪)。二者都按 run × project 作用域。
shell 面的边界(命令的文件效果同样不得逃逸工作区)是同一不变式的推论,但**机制**
——路径校验工具包装、OS 级沙箱(bubblewrap/容器)、SDK 权限钩子,或其组合——`OPEN`
(ADR-0018)。契约钉死不变式,不钉死机制。
-/
namespace Spec.System
variable (I : Identifiers) (Path : Type)
/-- Agent 在一次 run 内发起的文件操作(`PINNED` 关系, ADR-0018)。由某 run 发起、
指向某路径;是否越权由下方 `Authorized` 钉死。 -/
structure AgentFileOp where
/-- 发起操作的 run(授权上下文主体, ADR-0018;与 `Lock` 同作用域 run × project)。 -/
run : I.RunId
/-- 操作目标路径(`PINNED` 字段, ADR-0018)。 -/
path : Path
/-- 工作区边界良构:run 的文件操作路径必须落在该 run 所属 project 的工作区目录内
(`PINNED` 平台核心安全不变式, ADR-0018)。`runWorkspace` 与 `pathWithin` 均由平台提供
(表示 `OPEN`——路径如何表示、"在内"如何判定是纯 plumbing,非本层分歧点);本谓词只
钉死"操作路径必须以 run 的工作区为根",杜绝 agent 越权读写宿主任意文件。 -/
def AgentFileOp.Authorized
(op : AgentFileOp I Path)
(runWorkspace : I.RunId Option Path)
(pathWithin : Path Path Prop) : Prop :=
w, runWorkspace op.run = some w pathWithin op.path w
end Spec.System
+51
View File
@@ -0,0 +1,51 @@
import Spec.Prelude
/-!
# Organization —— SaaS tenant root (ADR-0020)
ADR-0020:Hub 长期按 SaaS 形态演进,`Organization` 是客户侧 tenant root。
`Project` 与 `Team` 都必须归属且只归属一个 organization;team 对 project 的授权
必须在同一 organization 内。平台运营控制面(platform staff / break-glass / 账单)
不复用 project 的 `read/edit/manage` 角色格,而是另一个控制面。
本模块只钉死会造成实现分歧的不变量:tenant root 存在、project/team 单归属、team grant
不得跨 org。用户身份、外部目录 provider 细节、平台控制面角色集合仍为 `OPEN`。
-/
namespace Spec.System
variable (I : Identifiers)
/-- 课程项目的租户归属(`PINNED`, ADR-0020):每个 project 必须有一个 organization。 -/
structure ProjectTenancy where
/-- 被归属的课程项目(`PINNED`, ADR-0020)。 -/
project : I.ProjectId
/-- project 所属 organization(`PINNED`, ADR-0020)。 -/
organization : I.OrganizationId
/-- Hub team 的租户归属(`PINNED`, ADR-0020):team 是 org-scoped principal。 -/
structure TeamTenancy where
/-- 被归属的 Hub team(`PINNED`, ADR-0020)。 -/
team : I.TeamId
/-- team 所属 organization(`PINNED`, ADR-0020)。 -/
organization : I.OrganizationId
/-- Team 对 project 的授权作用域(`PINNED`, ADR-0020):`PermissionGrant` 可以把
TEAM principal 授给 PROJECT resource,但该 grant 必须同 org。role 本身仍由
`PermissionGrant`/`Permission` 定义,这里仅刻画 tenant well-scopedness。 -/
structure TeamProjectGrantScope where
/-- 被授权的 project(`PINNED`, ADR-0020)。 -/
project : I.ProjectId
/-- 获得授权的 team principal(`PINNED`, ADR-0020)。 -/
team : I.TeamId
/-- Team-project grant 是良构的 iff project 与 team 解析到同一 organization
(`PINNED`, ADR-0020)。`projectOrg`/`teamOrg` 由平台提供(表示 `OPEN`);本谓词钉死
跨 org team grant 必须被拒绝。 -/
def TeamProjectGrantScope.WellScoped
(grant : TeamProjectGrantScope I)
(projectOrg : I.ProjectId Option I.OrganizationId)
(teamOrg : I.TeamId Option I.OrganizationId) : Prop :=
o, projectOrg grant.project = some o teamOrg grant.team = some o
end Spec.System
+2 -1
View File
@@ -12,7 +12,8 @@ ADR-0004 的"飞书云文档式"权限:**grant**(`resource × principal × role`
principal 子类型学(user/chat/department/…)与各 policy 值域均为 `OPEN`(ADR 未定,非本
层分歧点)。**role-capability 与 settings-policy 如何组合成最终授权决策**亦 `OPEN`——
ADR-0004 把二者列为分离的闸,但未明文规定组合规则(AND?settings 能否超出 role?),实现
须 surface,不得默认。
须 surface,不得默认。ADR-0020 另行钉死 TEAM principal 授权 PROJECT resource 时必须
同 organization;该 tenant well-scopedness 见 `Spec.System.Organization`。
-/
namespace Spec.System