Compare commits

...

13 Commits

Author SHA1 Message Date
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
hongjr03 a2c8fa8eaf refactor(hub): 手搓 agent loop 换成 Vercel AI SDK
将 provider/runner/tools 三件手搓轮子替换为 AI SDK v7 的 generateText + tool:
- 删 openrouter-provider.ts(翻译层消失)、provider.ts 自定义消息类型
- runner.ts 循环体改为 generateText({stopWhen: stepCountIs}),保留 RunRequest/RunResult 公共签名
- tools.ts ToolRegistry 改为 ToolFactory + build(ctx, names?) 按 role 白名单构建 tools record
- workspace/cph/feishu 工具改写为 tool() 定义,execute 闭包捕获 per-run ToolContext
- transcript.ts 改为 ModelMessage[] JSONL(0.0.0 cutover,无迁移)
- server.ts/trigger.ts 接 modelFactory + toolWhitelist
- 测试:helpers 写 MockLanguageModel implements LanguageModelV4;5 个测试改写 + 新增 runner.test.ts
- 移除未使用的 openai 依赖
- ADR-0017 Consequences 同步:loop 委托给 AI SDK,provider seam 是 LanguageModel

tsc 干净,54 tests 全过。
2026-07-07 20:07:02 +08:00
hongjr03 afaf5bee09 feat(hub): RoleEntry 完整 bundle + per-run tool 白名单 + per-role 触发权限
RoleEntry 扩成 (model, systemPrompt, tools) bundle,id 兼任 slash command 名。
ToolRegistry.subset() 支持按白名单构造 per-run 视图,模型永远看不到 role 外的工具。
trigger 解析 /<role> 命令,查 RoleEntry 组装 systemPrompt + 子集 registry 传给 runner。
新增 RoleTriggerGrant 表 + canTriggerRole gate,与 ADR-0004 canTriggerAgent 串联:
先问'能不能触发 agent',再问'能触发哪个 role'。未配置 role 放行(back-compat)。

- models.ts: RoleEntry 加 systemPrompt + tools 字段
- tools.ts: ToolRegistry.subset(names) 返回共享 handler 的子集视图
- trigger.ts: extractRole 解析 slash; 传 systemPrompt + runTools; catch 链容错 P2025
- runner.ts: RunRequest.systemPrompt 改 string | undefined (exactOptionalPropertyTypes)
- schema.prisma + migration: RoleTriggerGrant(projectId, roleId, principal, revokedAt)
- permission.ts: canTriggerRole gate (有 grant 记录即白名单模式,含 revoked)
- server.ts: draft/review 两个 role 加 systemPrompt + tools 白名单
- 测试: role-permission.test.ts (5) + trigger.test.ts (+3), 53 全绿
2026-07-07 18:47:51 +08:00
hongjr03 0fe6cabc68 feat(hub): session transcript 连续性 + slash commands (/new /resume /reset)
修复每次 @bot 都是失忆状态的问题。
- transcript.ts: JSONL 文件存 session 对话消息(workspace/.cph/sessions/
  <id>.jsonl),append-only;和 Claude Code 一致;ADR-0003 说的'不存全
  历史'是群聊历史,session 对话是 Hub 自己产生的,不冲突
- runner.ts: RunRequest 加 transcriptPath;run 开始时读历史拼进初始
  messages(system prompt 只在首条);run 结束时 append 本轮新消息
  (loadedCount 区分历史 vs 新增,不重复写);best-effort(写失败不丢
  run 结果)
- trigger.ts: slash commands 在 lock 检查之前(/new /resume /reset
  不创建 run 不需要锁);transcriptPath 传给 runner
  /new = 归档当前 session,下次 @bot 自动建新的
  /resume = 恢复最近归档的 session
  /reset = 同 /new
  未知 /cmd 透传给 agent 当普通 prompt
- unit: transcript.test.ts 读写回环/append 累积/损坏行恢复/路径(6)
- integration: trigger.test.ts 加 /new /resume /reset /unknown(4)
修了 slash command 被 lock 挡住的问题(提到 lock 之前)。
vitest run 45/45 通过;tsc rc=0。
2026-07-07 17:50:05 +08:00
hongjr03 e60aa43731 test(hub): integration 测试套件(13 tests,真 prisma+真 cph)
三层 integration,对齐 unit 层:
- trigger.test.ts: @bot→run→lock→release→card 全链路;权限拒绝(READ
  不能 triggerAgent ADR-0004);竞态 busy(ADR-0002);未绑定 chat 忽略
  (ADR-0001);无 @bot 忽略 —— 5 tests
- lock.test.ts: acquire/release 回环;排他性(同 project 二次 acquire 抛);
  WellFormed(终止 run 持锁即抛);release 幂等;isTerminal 对齐 spec —— 5 tests
- cph.test.ts: 真 cph binary 跑 TH-141 examples 的 check(exit 0)+
  build student PDF(exit 0)+ 空目录 check(非 0)—— 3 tests
helpers: resetDb(truncate CASCADE)、mockFeishuRuntime(记录 sentTexts/
  sentCards)、MockProvider(fixed stop 响应)、seedProject(project→user→grant)
prisma migrate dev init 生成初始 migration 并应用到 cph_hub_test 库。
vitest config 改 fileParallelism=false(integration 共享一 DB,串行避免
truncate/insert 竞态)。
vitest run 35/35(22 unit + 13 integration)通过;tsc rc=0。
2026-07-07 15:40:28 +08:00
hongjr03 09cadd0148 test(hub): Vitest suite + 三组 unit 测试(22 tests)
用 Vitest 组织测试,对齐 Courseware 半边的测试纪律(非散脚本):
- test/unit/workspace.test.ts: 路径 confinement(读写回环/..逃逸/绝对路径逃逸/
  父目录创建/列举/不存在 error JSON)—— 11 tests
- test/unit/prompt.test.ts: @bot 提取(单 mention/多 mention/纯@bot/无 mention/
  非 JSON/无 text 字段)—— 8 tests
- test/unit/permission.test.ts: roleGrantsEdit 单调性(READ 不含 / EDIT 含 /
  MANAGE 含,ADR-0004 can_mono)—— 3 tests
extractPrompt 导出供测试。修了 test helper 默认参数 bug(显式传 undefined 触发
默认值,绕过 mentions 检查)。
vitest run 22/22 通过;tsc rc=0。
Integration 层(trigger 全链路 + lock WellFormed + 真 cph)待本地 postgres
后再起。
2026-07-07 15:26:53 +08:00
hongjr03 ce767e9cab feat(hub): 真实飞书读取 + 状态卡片 + 权限 gate + 部署脚本
FeishuRead(ADR-0003): feishuContextTool 从 stub 换成真实 lark
  im.v1.message.get(单条 trigger/reply/status_card)+ list(thread 回复);
  ADR-0003 chat 授权不变式不变(handler 已 gate)。
StatusCard: sendCard + buildRunStatusCard——完成/出错发 interactive card
  (status 文案 + model 选择器 + 取消按钮带 runId),替代纯文本。
Permission(ADR-0004, project-wise): triggerAgent 查 PermissionGrant 对
  project 的 edit+ 能力(manage⊇edit 单调);sender open_id 当 principal
  (子类型学 OPEN,务实选择,permission.ts 标注);per-artifact OPEN;
  settings 组合规则 OPEN。无权限回 '无权限触发'。
Deploy: cph-hub.service systemd unit(ExecStartPre 跑 prisma migrate)+
  install_service.sh(idempotent,seed env)+ deploy_platform.sh
  (rsync + npm ci + build + restart + healthz)。
client.ts 抽 createLarkClient/startFeishuListenerWithClient,tools 与
  ws 共用同一 client。
tsc rc=0;prisma validate 通过;deploy 脚本 bash -n 通过。
2026-07-06 23:44:07 +08:00
hongjr03 a4d52e1850 feat(hub): workspace 文件工具 + cph 子进程工具(hub↔courseware 耦合点)
agent 的窄工具面补全:
- workspace.ts: read_file/write_file/list_files,路径全 confinement 到
  ctx.workspaceDir(ADR-0007 目录树);.. 与绝对路径逃逸返 error JSON 不中断 run
- cph.ts: cph_check/cph_build 子进程,cwd=workspace;ADR-0016 版本契约由 cph
  自身在 load 阶段校验(cphVersionMismatch),Hub 透传诊断不重复校验;
  CPH_BIN 可配,缺二进制返 exitCode 127 不抛
- server.ts: 注册全部 5 个工具
修 dynamic import(rule: ts-no-dynamic-import)——mkdir 改顶部静态 import;
三个 handler 的 confine 包进 try(操作性错误返 JSON 而非冒泡中断 run)。
tsc rc=0;smoke 通过(逃逸拒绝/读写回环/列举/cph ENOENT)。
2026-07-06 23:19:28 +08:00
hongjr03 313e890b6c feat(hub): 飞书 @bot 触发链路 + ProjectAgentLock 不变式(ADR-0001/0002)
群消息事件 → 创建 AgentRun → 获锁 → 调 runAgent → 释放锁 + 状态回写:
- feishu/client.ts: lark WSClient 长连接接收 im.message.receive_v1;
  sendText 经 client.im.v1 发送(SDK 运行时动态 API,类型弱化务实收口)
- feishu/trigger.ts: @bot 识别(chat 绑定解析 ADR-0001)→ 创建 run+
  session(provider-bound ADR-0017)→ acquireLock(ADR-0002,竞态回退 busy)→
  runAgent(fire-and-forget)→ finally releaseLock;非项目群/无 @bot/非文本
  全部忽略
- lock.ts: ADR-0002 WellFormed 落代码——currentLockRunId 读锁时校验
  持锁 run 非终止,终止 run 持锁即 release-path bug(抛而非静默)
- models.ts: resolve 提到 ModelRegistry 接口(之前漏在实现上)
- server.ts: Fastify + lark ws 启动,/api/healthz
prompt 提取 smoke 通过(@bot+prompt→prompt;纯@bot→null;无mention→null;
非文本→null)。修了 mention 正则 bug(/@_\w+_/ 的回溯错误)。tsc rc=0。
2026-07-06 23:11:35 +08:00
hongjr03 b4dd8f09e1 feat(hub): Prisma schema + DB singleton,对齐 spec/System 契约
按 ADR-0001..0004/0017 重塑 schema,非照搬旧服务:
- ADR-0001: ProjectGroupBinding project↔chat 1:1(chatId @unique 钉单射不变式)
- ADR-0002: ProjectAgentLock projectId @id(at-most-one)+runId @unique;holder=run 非 session
- ADR-0004: PermissionGrant(resource×principal×role)+PermissionSettings(六旋钮)新落;
  PermissionRole=read/edit/manage 与 PlatformRole=admin/teacher 分离(旧 schema 混淆了)
- ADR-0017: AgentSession 无 claudeSessionId,改 provider+model(切 model 即新 session)
- RunState 对齐 spec:加 WAITING_FOR_USER/TIMED_OUT(枚举完整性 OPEN)
- Audit:runId 是 PINNED 关系,其余 OPEN
旧 schema 的 FeishuProjectBinding(user/chat 混表)拆为单一 ProjectGroupBinding。
prisma validate 通过;generate 通过;tsc --noEmit rc=0;
类型级 schema 约束检查全部通过(ADR-0002 主键/唯一、ADR-0017 无 claudeSessionId 等)。
2026-07-06 22:48:28 +08:00
hongjr03 a08c33205b refactor(hub): role 改为数据条目,不再是写死的闭集枚举
ADR-0017 说 role-based routing 是 product/admin config,不是 spec invariant。
之前 RunRole = "draft"|"review"|"triage" 闭集违反这条——role 钉死在代码里,
教师/admin 无法自定义。改为:
- RoleEntry(id+label+defaultModel)是数据,registry 持有 role 集合
- roleId 退化为 string,新 role 是配置改动不是代码改动
- runner 不再带 role 字段(model 已由 caller resolve 好传入,role 透传无意义)
- 未知 role 优雅降级到首个启用 model,不抛
tsc --noEmit rc=0;role-as-data + ADR-0003 不变式 smoke 通过。
2026-07-06 22:41:39 +08:00
hongjr03 3bca137b48 feat(hub): provider-agnostic agent seam 骨架(ADR-0001/0003/0017)
hub/ TS 新部件,平级于 spec/。本仓从零 TS 重写(非迁移旧服务),栈:
Fastify+Prisma 待补;本提交落 provider-agnostic agent runtime 骨架:
- provider.ts: AgentProvider seam,OpenAI-compatible messages+tools+tool_calls
  形状(语言通解,非 OpenAI 承诺);@Claude 仅为触发品牌(ADR-0017)
- openrouter-provider.ts: openai SDK 指向 OpenRouter 的具体适配器,仅做
  Message↔SDK 翻译;agent loop 不 import SDK,换 provider 不改 loop
- runner.ts: 自有 agent loop(dispatch tools/循环到 stop/budget),per-run
  model(ADR-0017 role-based routing,run 带其 model)
- tools.ts: 窄工具面 + ADR-0003 McpReadRequest.Authorized 落代码:
  feishu_read_context 拒绝非本项目绑定 chat 的读取(实测错 chat 抛
  UnauthorizedChatRead,对 chat 放行)
- models.ts: ModelRegistry seam(role→default 映射策略 OPEN)
- server.ts: 接线入口(stub Feishu read,无活凭证即可编译)
tsc --noEmit rc=0;ADR-0003 不变式 smoke 通过。
2026-07-06 22:38:04 +08:00
hongjr03 18aac1ff16 feat(spec): System 层补 ADR-0001/0003/0004 缺口 + ADR-0017 provider-bound session
ADR-0001/0003/0004 已 PINNED 但 spec/System 未落,补三模块:
- ProjectGroup: project↔飞书群 1:1 绑定 + 单射良构(ADR-0001);群失效态 OPEN
- Memory: 锚点类别 + MCP 读上下文按 run/project 授权不变式(ADR-0003)
- PermissionGrant: grant(resource×principal×role)+settings 六旋钮(ADR-0004);
  role-capability×settings-policy 组合规则 OPEN
- Prelude: 新增 ChatId 载体
- ADR-0017: AgentSession provider/model 绑定,切 model 即新 session,
  跨 session 连续性由 ADR-0003 记忆/锚点重建;agent 层 provider 无关,
  @Claude 仅为触发品牌。RunId/SessionId doc 去 provider 暗示。
lake build 28/28 绿。
2026-07-06 22:30:05 +08:00
47 changed files with 7429 additions and 4 deletions
+3
View File
@@ -11,5 +11,8 @@
# regenerable, not for VCS. The embedded engine mounts cph-render directly. # regenerable, not for VCS. The embedded engine mounts cph-render directly.
render/vendor/local-packages/ render/vendor/local-packages/
# Node (hub/ TS workspace and any future JS package)
node_modules/
# OS / editor # OS / editor
.DS_Store .DS_Store
@@ -0,0 +1,50 @@
# ADR 0017: AgentSession Is Provider-Bound
## Status
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.
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?
## 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.
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.
Per-run model selection (the run carries its model) is the switching point.
## 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: model calls go through an
OpenAI-compatible client, and the tool-calling loop is delegated to the
Vercel AI SDK (`generateText` + `tool`). The provider seam is the SDK's
`LanguageModel` interface, not a hand-rolled loop; swapping provider is
swapping the `createOpenAICompatible` factory, not rewriting the loop. 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.
+23
View File
@@ -0,0 +1,23 @@
# Hub runtime configuration. Copy to .env and fill in.
# PostgreSQL connection string. Used by Prisma and the Hub server.
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"
# Hub server port. Defaults to 8788.
PORT=8788
# Feishu (lark) bot credentials. The bot receives @mentions in project groups.
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.
# CPH_BIN="/usr/local/bin/cph"
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
*.log
.env
.env.*
!.env.example
+21
View File
@@ -0,0 +1,21 @@
[Unit]
Description=Curriculum Project Hub (Feishu agent + cph)
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
Type=simple
User=__SERVICE_USER__
WorkingDirectory=__HUB_DIR__
EnvironmentFile=__ENV_FILE__
# Run prisma migrations before each start, then launch the Hub.
ExecStartPre=__NODE_BIN__ __HUB_DIR__/node_modules/prisma/build/index.js migrate deploy --schema __HUB_DIR__/prisma/schema.prisma
ExecStart=__NODE_BIN__ __HUB_DIR__/dist/server.js
Restart=on-failure
RestartSec=5
# Graceful shutdown: lark ws close + in-flight runs.
KillSignal=SIGTERM
TimeoutStopSec=30
[Install]
WantedBy=multi-user.target
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Deploy the Hub to a host: rsync the repo, install deps, build, restart service.
#
# Configure these env vars (e.g. in CI secrets):
# PLATFORM_DEPLOY_HOST required
# PLATFORM_DEPLOY_SSH_KEY required (private key for the deploy user)
# 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
set -euo pipefail
HOST="${PLATFORM_DEPLOY_HOST:?PLATFORM_DEPLOY_HOST required}"
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}"
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)
# 1. Rsync the hub/ directory (exclude node_modules/dist, they rebuild on host).
rsync -az --delete \
--exclude node_modules --exclude dist --exclude .env \
-e "ssh ${SSH_OPTS[*]}" \
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
# 2. Install deps + build on the host.
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "cd '$HUB_DIR' && npm ci && npm run build"
# 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; }
"
echo "Deployed cph-hub to $HOST ($HUB_DIR)"
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Install the cph-hub systemd service on a host. Idempotent.
#
# Prerequisites already on the host: Node.js, npm, PostgreSQL, systemd.
# The Hub repo is expected at HUB_DIR (default /srv/curriculum-project-hub/hub)
# with `npm ci` + `npm run build` already run by deploy_platform.sh.
#
# Usage:
# sudo BASE=/srv/curriculum-project-hub bash deploy/install_service.sh
set -euo pipefail
BASE="${BASE:-/srv/curriculum-project-hub}"
HUB_DIR="${HUB_DIR:-$BASE/hub}"
SERVICE_USER="${SERVICE_USER:-root}"
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)}"
if [ -z "$NODE_BIN" ]; then
echo "node must be on PATH, or set NODE_BIN." >&2
exit 1
fi
if [ ! -d "$HUB_DIR" ]; then
echo "HUB_DIR not found: $HUB_DIR (set HUB_DIR or clone the repo there)." >&2
exit 1
fi
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
# Seed the env file if absent. Secrets stay on the server, never in the repo.
if [ ! -f "$ENV_FILE" ]; then
install -d -m 0700 "$(dirname "$ENV_FILE")"
cat >"$ENV_FILE" <<EOF
NODE_ENV=production
DATABASE_URL=postgresql://paradigm:change-me@127.0.0.1:5432/paradigm
OPENROUTER_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."
exit 0
fi
# Render the unit with concrete paths.
UNIT=/etc/systemd/system/cph-hub.service
sed \
-e "s|__SERVICE_USER__|$SERVICE_USER|g" \
-e "s|__HUB_DIR__|$HUB_DIR|g" \
-e "s|__ENV_FILE__|$ENV_FILE|g" \
-e "s|__NODE_BIN__|$NODE_BIN|g" \
"$HUB_DIR/deploy/cph-hub.service" >"$UNIT"
systemctl daemon-reload
systemctl enable cph-hub.service
echo "Installed cph-hub.service. Start with: systemctl start cph-hub"
echo "Check health: curl http://127.0.0.1:$PORT/api/healthz"
+3558
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@paradigm/hub",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=20"
},
"dependencies": {
"@ai-sdk/openai-compatible": "^3.0.5",
"@fastify/cookie": "^11.0.2",
"@larksuiteoapi/node-sdk": "^1.66.1",
"@prisma/client": "^6.19.3",
"ai": "^7.0.16",
"dotenv": "^17.4.2",
"fastify": "^5.8.5",
"zod": "^4.4.3"
},
"devDependencies": {
"prisma": "^6.19.3",
"tsx": "^4.19.0",
"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).",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"start": "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",
"prisma:migrate": "DATABASE_URL=${DATABASE_URL:-postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm} prisma migrate deploy --schema prisma/schema.prisma",
"deploy": "bash deploy/deploy_platform.sh",
"test": "vitest run",
"test:watch": "vitest"
}
}
@@ -0,0 +1,271 @@
-- CreateEnum
CREATE TYPE "PlatformRole" AS ENUM ('ADMIN', 'TEACHER');
-- CreateEnum
CREATE TYPE "AgentRunStatus" AS ENUM ('ACTIVE', 'WAITING_FOR_USER', 'COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELED');
-- CreateEnum
CREATE TYPE "AgentEntrypoint" AS ENUM ('FEISHU', 'WEB', 'CLI');
-- CreateEnum
CREATE TYPE "PermissionRole" AS ENUM ('READ', 'EDIT', 'MANAGE');
-- CreateEnum
CREATE TYPE "PermissionResourceType" AS ENUM ('PROJECT', 'ARTIFACT', 'PROJECT_GROUP');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"feishuOpenId" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"avatarUrl" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PlatformRoleAssignment" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" "PlatformRole" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
CONSTRAINT "PlatformRoleAssignment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Project" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"workspaceDir" TEXT NOT NULL,
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"archivedAt" TIMESTAMP(3),
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProjectGroupBinding" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"chatId" TEXT NOT NULL,
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProjectGroupBinding_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AgentSession" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"model" TEXT NOT NULL,
"title" TEXT,
"metadata" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"archivedAt" TIMESTAMP(3),
CONSTRAINT "AgentSession_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AgentRun" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"sessionId" TEXT,
"requestedByUserId" TEXT,
"entrypoint" "AgentEntrypoint" NOT NULL,
"status" "AgentRunStatus" NOT NULL DEFAULT 'ACTIVE',
"prompt" TEXT NOT NULL,
"model" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"summary" TEXT,
"inputTokens" INTEGER,
"outputTokens" INTEGER,
"metadata" JSONB NOT NULL,
"error" TEXT,
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"finishedAt" TIMESTAMP(3),
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AgentRun_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProjectAgentLock" (
"projectId" TEXT NOT NULL,
"runId" TEXT NOT NULL,
"holderUserId" TEXT,
"acquiredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3),
CONSTRAINT "ProjectAgentLock_pkey" PRIMARY KEY ("projectId")
);
-- CreateTable
CREATE TABLE "PermissionGrant" (
"id" TEXT NOT NULL,
"resourceType" "PermissionResourceType" NOT NULL,
"resourceId" TEXT NOT NULL,
"principal" TEXT NOT NULL,
"role" "PermissionRole" NOT NULL,
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
CONSTRAINT "PermissionGrant_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PermissionSettings" (
"id" TEXT NOT NULL,
"resourceType" "PermissionResourceType" NOT NULL,
"resourceId" TEXT NOT NULL,
"externalShare" TEXT NOT NULL,
"comment" TEXT NOT NULL,
"copyDownload" TEXT NOT NULL,
"collaboratorMgmt" TEXT NOT NULL,
"agentTrigger" TEXT NOT NULL,
"agentCancel" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PermissionSettings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditEntry" (
"id" TEXT NOT NULL,
"runId" TEXT,
"actorUserId" TEXT,
"action" TEXT NOT NULL,
"metadata" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditEntry_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_feishuOpenId_key" ON "User"("feishuOpenId");
-- CreateIndex
CREATE INDEX "PlatformRoleAssignment_userId_revokedAt_idx" ON "PlatformRoleAssignment"("userId", "revokedAt");
-- CreateIndex
CREATE INDEX "PlatformRoleAssignment_role_revokedAt_idx" ON "PlatformRoleAssignment"("role", "revokedAt");
-- CreateIndex
CREATE INDEX "Project_archivedAt_idx" ON "Project"("archivedAt");
-- CreateIndex
CREATE UNIQUE INDEX "ProjectGroupBinding_projectId_key" ON "ProjectGroupBinding"("projectId");
-- CreateIndex
CREATE UNIQUE INDEX "ProjectGroupBinding_chatId_key" ON "ProjectGroupBinding"("chatId");
-- CreateIndex
CREATE INDEX "ProjectGroupBinding_chatId_idx" ON "ProjectGroupBinding"("chatId");
-- CreateIndex
CREATE INDEX "AgentSession_projectId_archivedAt_idx" ON "AgentSession"("projectId", "archivedAt");
-- CreateIndex
CREATE INDEX "AgentSession_provider_model_idx" ON "AgentSession"("provider", "model");
-- CreateIndex
CREATE INDEX "AgentSession_updatedAt_idx" ON "AgentSession"("updatedAt");
-- CreateIndex
CREATE INDEX "AgentRun_projectId_status_idx" ON "AgentRun"("projectId", "status");
-- CreateIndex
CREATE INDEX "AgentRun_sessionId_idx" ON "AgentRun"("sessionId");
-- CreateIndex
CREATE INDEX "AgentRun_requestedByUserId_idx" ON "AgentRun"("requestedByUserId");
-- CreateIndex
CREATE INDEX "AgentRun_updatedAt_idx" ON "AgentRun"("updatedAt");
-- CreateIndex
CREATE UNIQUE INDEX "ProjectAgentLock_runId_key" ON "ProjectAgentLock"("runId");
-- CreateIndex
CREATE INDEX "ProjectAgentLock_expiresAt_idx" ON "ProjectAgentLock"("expiresAt");
-- CreateIndex
CREATE INDEX "PermissionGrant_resourceType_resourceId_revokedAt_idx" ON "PermissionGrant"("resourceType", "resourceId", "revokedAt");
-- CreateIndex
CREATE INDEX "PermissionGrant_principal_revokedAt_idx" ON "PermissionGrant"("principal", "revokedAt");
-- CreateIndex
CREATE UNIQUE INDEX "PermissionGrant_resourceType_resourceId_principal_role_revo_key" ON "PermissionGrant"("resourceType", "resourceId", "principal", "role", "revokedAt");
-- CreateIndex
CREATE INDEX "PermissionSettings_resourceType_resourceId_idx" ON "PermissionSettings"("resourceType", "resourceId");
-- CreateIndex
CREATE UNIQUE INDEX "PermissionSettings_resourceType_resourceId_key" ON "PermissionSettings"("resourceType", "resourceId");
-- CreateIndex
CREATE INDEX "AuditEntry_runId_idx" ON "AuditEntry"("runId");
-- CreateIndex
CREATE INDEX "AuditEntry_actorUserId_idx" ON "AuditEntry"("actorUserId");
-- CreateIndex
CREATE INDEX "AuditEntry_createdAt_idx" ON "AuditEntry"("createdAt");
-- AddForeignKey
ALTER TABLE "PlatformRoleAssignment" ADD CONSTRAINT "PlatformRoleAssignment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Project" ADD CONSTRAINT "Project_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectGroupBinding" ADD CONSTRAINT "ProjectGroupBinding_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectGroupBinding" ADD CONSTRAINT "ProjectGroupBinding_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentSession" ADD CONSTRAINT "AgentSession_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentRun" ADD CONSTRAINT "AgentRun_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentRun" ADD CONSTRAINT "AgentRun_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "AgentSession"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentRun" ADD CONSTRAINT "AgentRun_requestedByUserId_fkey" FOREIGN KEY ("requestedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectAgentLock" ADD CONSTRAINT "ProjectAgentLock_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectAgentLock" ADD CONSTRAINT "ProjectAgentLock_runId_fkey" FOREIGN KEY ("runId") REFERENCES "AgentRun"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectAgentLock" ADD CONSTRAINT "ProjectAgentLock_holderUserId_fkey" FOREIGN KEY ("holderUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PermissionGrant" ADD CONSTRAINT "PermissionGrant_resourceId_fkey" FOREIGN KEY ("resourceId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PermissionGrant" ADD CONSTRAINT "PermissionGrant_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PermissionSettings" ADD CONSTRAINT "PermissionSettings_resourceId_fkey" FOREIGN KEY ("resourceId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditEntry" ADD CONSTRAINT "AuditEntry_actorUserId_fkey" FOREIGN KEY ("actorUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,27 @@
-- CreateTable
CREATE TABLE "RoleTriggerGrant" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"principal" TEXT NOT NULL,
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
CONSTRAINT "RoleTriggerGrant_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "RoleTriggerGrant_projectId_roleId_revokedAt_idx" ON "RoleTriggerGrant"("projectId", "roleId", "revokedAt");
-- CreateIndex
CREATE INDEX "RoleTriggerGrant_principal_revokedAt_idx" ON "RoleTriggerGrant"("principal", "revokedAt");
-- CreateIndex
CREATE UNIQUE INDEX "RoleTriggerGrant_projectId_roleId_principal_revokedAt_key" ON "RoleTriggerGrant"("projectId", "roleId", "principal", "revokedAt");
-- AddForeignKey
ALTER TABLE "RoleTriggerGrant" ADD CONSTRAINT "RoleTriggerGrant_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoleTriggerGrant" ADD CONSTRAINT "RoleTriggerGrant_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -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,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+333
View File
@@ -0,0 +1,333 @@
// Prisma schema for Curriculum Project Hub.
//
// 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.
// - 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
// user/chat targets into one binding table.
// - PermissionGrant + PermissionSettings land (ADR-0004), missing in legacy.
// - AgentRunStatus adds WAITING_FOR_USER + TIMED_OUT (spec RunState; enum
// completeness OPEN — add states without a schema migration war).
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// --- Platform identity ---------------------------------------------------
/// A Feishu user known to the Hub. principal sub-typology is OPEN (ADR-0004).
model User {
id String @id @default(cuid())
feishuOpenId String @unique
displayName String
avatarUrl String?
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")
}
/// Platform-level role (admin/teacher). Distinct from ADR-0004 PermissionRole.
/// `admin` is the only override path for force-release (spec RequiresAdmin).
model PlatformRoleAssignment {
id String @id @default(cuid())
userId String
role PlatformRole
createdAt DateTime @default(now())
revokedAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, revokedAt])
@@index([role, revokedAt])
}
enum PlatformRole {
ADMIN
TEACHER
}
// --- Project & Feishu binding (ADR-0001) ---------------------------------
model Project {
id String @id @default(cuid())
name String
workspaceDir String
createdByUserId String?
createdAt DateTime @default(now())
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")
auditEntries AuditEntry[] @relation("projectAudit")
@@index([archivedAt])
}
/// ADR-0001: one project ↔ one Feishu chat (1:1). `chatId` is unique ⇒
/// GroupBinding.WellFormed's injectivity half (no two projects bind one chat).
/// The "at most one binding per project" half is enforced by the 1:1 relation.
/// Group dissolution lifecycle is OPEN (ADR-0001 Consequences) — not modeled
/// here; archival is a future policy, not a current column.
model ProjectGroupBinding {
id String @id @default(cuid())
projectId String @unique
chatId String @unique
createdByUserId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
createdBy User? @relation("bindingCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
@@index([chatId])
}
// --- 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.
model AgentSession {
id String @id @default(cuid())
projectId String
provider String
model String
title String?
metadata Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
archivedAt DateTime?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
runs AgentRun[]
@@index([projectId, archivedAt])
@@index([provider, model])
@@index([updatedAt])
}
/// spec RunState: active/waitingForUser/completed/failed/timedOut/canceled.
/// Enum completeness OPEN (Run.lean:12) — adding a state is a value add, not a
/// spec breach. DB mirrors the current enum.
enum AgentRunStatus {
ACTIVE
WAITING_FOR_USER
COMPLETED
FAILED
TIMED_OUT
CANCELED
}
enum AgentEntrypoint {
FEISHU
WEB
CLI
}
model AgentRun {
id String @id @default(cuid())
projectId String
sessionId String?
requestedByUserId String?
entrypoint AgentEntrypoint
status AgentRunStatus @default(ACTIVE)
prompt String
model String
provider String
summary String?
inputTokens Int?
outputTokens Int?
metadata Json
error String?
startedAt DateTime @default(now())
finishedAt DateTime?
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)
projectLock ProjectAgentLock?
@@index([projectId, status])
@@index([sessionId])
@@index([requestedByUserId])
@@index([updatedAt])
}
/// ADR-0002: lock owner = run_id (not session/user/chat). `projectId @id` ⇒
/// at most one lock per project (LockTable exclusivity). `runId @unique` ⇒
/// 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
holderUserId String?
acquiredAt DateTime @default(now())
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)
@@index([expiresAt])
}
// --- Permission grants & settings (ADR-0004) ----------------------------
/// ADR-0004 PermissionRole: read ⊂ edit ⊂ manage (capability lattice).
/// Distinct from PlatformRole. Force-release is admin-only, outside this
/// lattice (spec RequiresAdmin).
enum PermissionRole {
READ
EDIT
MANAGE
}
/// ADR-0004 resource_type: project | artifact | project_group. The resource
/// id's meaning is determined by its type (artifact id semantics align to the
/// Courseware half; OPEN here).
enum PermissionResourceType {
PROJECT
ARTIFACT
PROJECT_GROUP
}
/// 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.
model PermissionGrant {
id String @id @default(cuid())
resourceType PermissionResourceType
resourceId String
principal String
role PermissionRole
createdByUserId String?
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)
@@unique([resourceType, resourceId, principal, role, revokedAt])
@@index([resourceType, resourceId, revokedAt])
@@index([principal, 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).
model PermissionSettings {
id String @id @default(cuid())
resourceType PermissionResourceType
resourceId String
externalShare String
comment String
copyDownload String
collaboratorMgmt String
agentTrigger String
agentCancel String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project? @relation("projectSettings", fields: [resourceId], references: [id], onDelete: Cascade)
@@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;
/// 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())
projectId String
roleId String
principal String
createdByUserId String?
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])
}
// --- 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
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)
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])
}
+105
View File
@@ -0,0 +1,105 @@
/**
* `cph` subprocess tools - the Hub<->Courseware coupling point.
*
* The Hub agent does not parse engineering-file models in-process; it calls the
* stable `cph` CLI (ADR-0016 version contract). `cph check` validates a
* project; `cph build` renders a target artifact. Version compatibility is the
* CLI's responsibility - it refuses incompatible `.cph-version` files with a
* `cphVersionMismatch` error diagnostic (ADR-0016); the Hub merely runs the
* subprocess and returns its stdout/stderr/exit to the model.
*
* The `cph` binary path is configurable (env `CPH_BIN`, default `cph` on
* PATH). All subprocesses run with `cwd = ctx.workspaceDir` so paths in
* diagnostics are relative to the project root.
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { tool } from "ai";
import { z } from "zod";
import type { ToolContext, ToolDefinition } from "./tools.js";
const execFileAsync = promisify(execFile);
const CPH_BIN = process.env["CPH_BIN"] ?? "cph";
const DEFAULT_TIMEOUT_MS = 120_000;
interface ExecResult {
readonly exitCode: number;
readonly stdout: string;
readonly stderr: string;
}
async function runCph(args: readonly string[], workspaceDir: string): Promise<ExecResult> {
try {
const { stdout, stderr } = await execFileAsync(CPH_BIN, args, {
cwd: workspaceDir,
timeout: DEFAULT_TIMEOUT_MS,
maxBuffer: 10 * 1024 * 1024,
});
return { exitCode: 0, stdout, stderr };
} catch (e) {
// execFile rejects on non-zero exit; the error carries stdout/stderr/code.
const err = e as NodeJS.ErrnoException & { stdout?: string; stderr?: string; code?: number | string };
if (err.code === "ENOENT") {
return {
exitCode: 127,
stdout: "",
stderr: `cph binary not found at "${CPH_BIN}" (set CPH_BIN env or install cph on PATH)`,
};
}
return {
exitCode: typeof err.code === "number" ? err.code : 1,
stdout: err.stdout ?? "",
stderr: err.stderr ?? "",
};
}
}
/// `cph check <dir>` - validate a curriculum engineering file. Returns the
/// checker's diagnostics (stdout) for the model to act on. Non-zero exit means
/// the file has error diagnostics (ADR-0010); the output is still returned,
/// not thrown - the model needs to see the diagnostics to fix them.
export function cphCheckTool(ctx: ToolContext): ToolDefinition {
return tool({
description:
"Run `cph check` on the project's curriculum engineering file. Returns diagnostics (7 classes: structure/schema/compile/version...). Non-zero exit means error diagnostics present - read the output to fix them.",
inputSchema: z.object({
path: z.string().describe("Path to the engineering file dir; defaults to the workspace root.").optional(),
}),
execute: async (args): Promise<string> => {
const target = args.path ?? ".";
const res = await runCph(["check", target], ctx.workspaceDir);
return JSON.stringify({
exitCode: res.exitCode,
stdout: res.stdout,
stderr: res.stderr,
});
},
});
}
/// `cph build <dir> --target <T> -o <out>` - render a target artifact. The
/// target (student/teacher/...) determines the build (ADR-0009). Output path
/// is relative to the workspace unless absolute.
export function cphBuildTool(ctx: ToolContext): ToolDefinition {
return tool({
description:
"Run `cph build` to render a curriculum artifact (e.g. student/teacher PDF). Target selects the build; output names the result file.",
inputSchema: z.object({
target: z.string().describe("Build target, e.g. 'student', 'teacher'."),
path: z.string().describe("Path to the engineering file dir; defaults to the workspace root.").optional(),
output: z.string().describe("Output file path (relative to workspace or absolute).").optional(),
}),
execute: async (args): Promise<string> => {
const target = args.path ?? ".";
const out = args.output ?? `build/${args.target}.pdf`;
const res = await runCph(["build", target, "--target", args.target, "-o", out], ctx.workspaceDir);
return JSON.stringify({
exitCode: res.exitCode,
stdout: res.stdout,
stderr: res.stderr,
output: out,
});
},
});
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Per-run model selection & role-based routing.
*
* ADR-0017 consequence: role-based model routing (different run kinds default
* to different models) is a **product/admin configuration** concern, not a spec
* invariant. This registry is the seam for that config; the concrete policy
* (which models are admin-enabled, which role maps to which default) is `OPEN`
* here — decided by admin settings + ADR, not hard-coded.
*/
/**
* A named role preset — the full per-run agent bundle. Roles are **data**, not
* a code enum: admin/teachers define them (ADR-0017: role-based routing is
* product config, not a spec invariant). `roleId` is an opaque string and
* doubles as the slash-command name (`/draft ...`) — the registry holds the
* role set, so new roles are added by configuration, not by editing code.
*
* 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.
*/
export interface RoleEntry {
readonly id: string;
/** Human label for the teacher-side switcher UX. */
readonly label: string;
/** Default model id for runs under this role, if a routing rule is set. */
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).
*/
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.
*/
readonly tools: readonly string[] | undefined;
}
/** A model the admin has enabled for use by the Hub. */
export interface ModelEntry {
readonly id: string;
/** Human label for the teacher-side switcher UX. */
readonly label: string;
/** True when the model is known to support tool use reliably. */
readonly toolCapable: boolean;
}
export interface ModelRegistry {
/** All models admin-enabled for this Hub instance. */
listModels(): readonly ModelEntry[];
/** All role presets currently configured (admin/teacher-defined). */
listRoles(): readonly RoleEntry[];
/** The role preset with the given id, if any. */
role(id: string): RoleEntry | undefined;
/** Resolve a model id: explicit request → role default → first enabled. */
resolve(requestedModel: string | undefined, roleId: string): string;
}
/**
* A simple in-memory registry. Real wiring reads from admin settings / DB; this
* is the skeleton seam. Both the model list and the role set are pluggable data
* — adding a role is a config change, not a code change.
*/
export class InMemoryModelRegistry implements ModelRegistry {
private readonly models: readonly ModelEntry[];
private readonly roles: Map<string, RoleEntry>;
constructor(models: readonly ModelEntry[], roles: readonly RoleEntry[] = []) {
this.models = models;
this.roles = new Map(roles.map((r) => [r.id, r]));
}
listModels(): readonly ModelEntry[] {
return this.models;
}
listRoles(): readonly RoleEntry[] {
return [...this.roles.values()];
}
role(id: string): RoleEntry | undefined {
return this.roles.get(id);
}
/**
* Resolve a model id for a run. Priority: teacher's explicit request → the
* role preset's default → the first admin-enabled model. `roleId` is a string
* so unknown roles degrade gracefully to the fallback rather than throwing.
*/
resolve(requestedModel: string | undefined, roleId: string): string {
if (requestedModel !== undefined && this.models.some((m) => m.id === requestedModel)) {
return requestedModel;
}
const role = this.roles.get(roleId);
if (role?.defaultModel !== undefined) return role.defaultModel;
const first = this.models[0];
if (first === undefined) throw new Error("no models enabled for this Hub");
return first.id;
}
}
+18
View File
@@ -0,0 +1,18 @@
/**
* 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);
}
+140
View File
@@ -0,0 +1,140 @@
/**
* Provider-bound 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.
*/
import { generateText, stepCountIs, type LanguageModel, type ModelMessage } from "ai";
import type { ToolContext, ToolRegistry } from "./tools.js";
import { readTranscript, appendTranscript } from "./transcript.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 interface RunRequest {
readonly prompt: string;
/** Resolved model id (already chosen by the caller per ADR-0017). */
readonly model: string;
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;
}
export type RunStatus = "completed" | "length" | "failed";
export interface RunResult {
readonly status: RunStatus;
/** Full transcript of the run, for audit (ADR Audit, content OPEN). */
readonly messages: readonly ModelMessage[];
readonly usage: { inputTokens: number; outputTokens: number };
readonly error?: string;
}
const DEFAULT_MAX_ITERATIONS = 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,
};
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;
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);
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,
},
...(error !== undefined ? { error } : {}),
};
} catch (e) {
await appendTranscriptIfSet(req, messages, loadedCount);
return {
status: "failed",
messages,
usage: { inputTokens: 0, outputTokens: 0 },
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;
try {
await appendTranscript(req.transcriptPath, newMessages);
} catch {
// Swallow - transcript is best-effort persistence; the run result is
// returned regardless. ADR-0002 lock ensures no concurrent writer.
}
}
function cryptoRandomId(): string {
return globalThis.crypto.randomUUID();
}
+114
View File
@@ -0,0 +1,114 @@
/**
* Tool registry - the agent's narrow, project-scoped tool surface.
*
* Unlike a general "Claude Code", the Hub agent operates on a curriculum
* engineering-file tree (ADR-0007) and reads Feishu context on demand
* (ADR-0003). Its tools are therefore bounded:
*
* - file-tree read/write against the project workspace dir,
* - `cph check` / `cph build` subprocess calls (the Courseware-side checker,
* coupled only via the stable CLI contract ADR-0016),
* - Feishu context reads, which must honor ADR-0003's authorization invariant.
*
* The AI SDK owns tool-call dispatch; this registry owns Hub-specific tool
* construction and per-role whitelisting (ADR-0017).
*/
import { tool, type Tool } from "ai";
import { z } from "zod";
/** Per-run execution context closed over by every tool execute function. */
export interface ToolContext {
readonly runId: string;
readonly projectId: string;
/** ADR-0001: the chat this project is bound to. Feishu reads must stay in it. */
readonly boundChatId: string;
/** Absolute path to the project's curriculum engineering-file workspace. */
readonly workspaceDir: string;
}
export type ToolDefinition = Tool;
export type ToolFactory = (ctx: ToolContext) => ToolDefinition;
export class ToolRegistry {
private readonly factories = new Map<string, ToolFactory>();
register(name: string, factory: ToolFactory): void {
if (this.factories.has(name)) {
throw new Error(`duplicate tool: ${name}`);
}
this.factories.set(name, factory);
}
/**
* Build the AI SDK `tools` record. If `names` is given, include only those
* tools (per-role whitelist, ADR-0017). Names not registered are silently
* dropped because role config may name tools a Hub instance does not provide.
*/
build(ctx: ToolContext, names: readonly string[] | undefined): Record<string, ToolDefinition> {
const built: Record<string, ToolDefinition> = {};
const selected = names ?? [...this.factories.keys()];
for (const name of selected) {
const factory = this.factories.get(name);
if (factory !== undefined) {
built[name] = factory(ctx);
}
}
return built;
}
}
/**
* Thrown when a Feishu context read targets a chat other than the run's project's
* bound chat. This is the runtime enforcement of ADR-0003's
* `McpReadRequest.Authorized`: "Claude cannot pass arbitrary chat ids".
*/
export class UnauthorizedChatRead extends Error {
constructor(
readonly requestedChatId: string,
readonly boundChatId: string,
) {
super(
`refused Feishu context read: requested chat ${requestedChatId} is not the run's project's bound chat ${boundChatId} (ADR-0003)`,
);
this.name = "UnauthorizedChatRead";
}
}
/** Schema for the Feishu context read tool's arguments. */
export interface FeishuContextArgs {
readonly chat_id: string;
readonly anchor: "trigger_message" | "status_card" | "reply" | "thread";
readonly id: string;
}
/**
* Build the Feishu context-read tool factory. The execute closure enforces
* ADR-0003's invariant before any API call: the requested `chat_id` must equal
* `ctx.boundChatId`.
*
* The actual Feishu API call (read message / reply chain / thread / recent
* group messages) is injected as `read`, so the invariant check is separable
* from the transport. In tests `read` can be a stub; in production it wraps
* `@larksuiteoapi/node-sdk`.
*/
export function feishuContextTool(
read: (args: FeishuContextArgs, ctx: ToolContext, rt: unknown) => Promise<string>,
rt: unknown,
): ToolFactory {
return (ctx) =>
tool({
description:
"Read on-demand Feishu context (a trigger message, status card, reply, or thread) for the current project's bound chat. The chat id must match the project binding.",
inputSchema: z.object({
chat_id: z.string().describe("The Feishu chat id to read from."),
anchor: z.enum(["trigger_message", "status_card", "reply", "thread"]).describe("Which kind of anchor to read."),
id: z.string().describe("The anchor id (message id or run id)."),
}),
execute: async (args): Promise<string> => {
if (args.chat_id !== ctx.boundChatId) {
throw new UnauthorizedChatRead(args.chat_id, ctx.boundChatId);
}
return read(args, ctx, rt);
},
});
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Session transcript — append-only JSONL file in the workspace.
*
* Each line is one AI SDK {@link ModelMessage}, JSON-encoded. The transcript lives at
* `workspaceDir/.cph/sessions/<sessionId>.jsonl`, following the same "workspace
* is a directory tree" principle as the engineering file itself (ADR-0007).
*
* This mirrors how Claude Code stores its transcript (a `.jsonl` file per
* session). The agent can read its own history via `read_file` — no special
* "load history" code path. ADR-0002's lock guarantees one run per project at a
* time, so the transcript is never concurrently written.
*
* ADR-0003: this stores **agent session messages** (prompt + response + tool
* calls produced by the Hub), NOT Feishu group chat history. The two are
* distinct; storing session messages does not conflict with "the Hub avoids
* becoming a full chat history store."
*/
import { readFile, mkdir, appendFile } from "node:fs/promises";
import { join, dirname } from "node:path";
import type { ModelMessage } from "ai";
/// Subdirectory within a workspace where session transcripts live.
export const TRANSCRIPT_DIR = ".cph/sessions";
/** Path for a session's transcript file within a workspace. */
export function transcriptPath(workspaceDir: string, sessionId: string): string {
return join(workspaceDir, TRANSCRIPT_DIR, `${sessionId}.jsonl`);
}
/**
* Read prior messages from a transcript file. Returns an empty array if the
* file doesn't exist yet (first run in this session). Corrupt lines are
* skipped — a partially-written last line (crash mid-append) won't break the
* next run.
*/
export async function readTranscript(path: string): Promise<ModelMessage[]> {
let content: string;
try {
content = await readFile(path, "utf8");
} catch {
return [];
}
const messages: ModelMessage[] = [];
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (trimmed === "") continue;
try {
messages.push(JSON.parse(trimmed) as ModelMessage);
} catch {
// Skip corrupt line — likely a partial write from a crash.
}
}
return messages;
}
/**
* Append messages to a transcript file. Creates the parent directory if needed.
* Each message is written as one JSON line.
*/
export async function appendTranscript(path: string, messages: readonly ModelMessage[]): Promise<void> {
await mkdir(dirname(path), { recursive: true });
const lines = messages.map((m) => JSON.stringify(m)).join("\n") + "\n";
await appendFile(path, lines, "utf8");
}
+106
View File
@@ -0,0 +1,106 @@
/**
* Workspace file tools - bounded read/write/list against the project's
* curriculum engineering-file tree (ADR-0007 directory tree).
*
* All paths are **confined to `ctx.workspaceDir`**: the handler resolves the
* requested relative path against the workspace root and rejects any path that
* escapes it (via `..` or absolute paths). This is the agent's only file
* surface; the Hub agent is narrower than a general coding agent.
*/
import { readFile, writeFile, readdir, mkdir } from "node:fs/promises";
import { join, resolve, relative, isAbsolute } from "node:path";
import { tool } from "ai";
import { z } from "zod";
import type { ToolContext, ToolDefinition } from "./tools.js";
/** Thrown when a requested path escapes the project workspace root. */
export class PathEscape extends Error {
constructor(readonly requested: string, readonly workspaceDir: string) {
super(`path escapes workspace: ${requested} (root ${workspaceDir})`);
this.name = "PathEscape";
}
}
/** Resolve a tool-supplied path against the workspace root, rejecting escapes. */
function confine(requestedPath: string, workspaceDir: string): string {
if (isAbsolute(requestedPath)) {
// Allow absolute paths only if they're already inside the workspace.
const rel = relative(workspaceDir, requestedPath);
if (rel.startsWith("..") || rel === "") {
throw new PathEscape(requestedPath, workspaceDir);
}
return requestedPath;
}
const resolved = resolve(workspaceDir, requestedPath);
const rel = relative(workspaceDir, resolved);
if (rel.startsWith("..")) {
throw new PathEscape(requestedPath, workspaceDir);
}
return resolved;
}
export function readFileTool(ctx: ToolContext): ToolDefinition {
return tool({
description: "Read a file from the project's curriculum engineering-file workspace. Path is relative to the workspace root.",
inputSchema: z.object({
path: z.string().describe("Relative path within the workspace."),
}),
execute: async (args): Promise<string> => {
try {
const full = confine(args.path, ctx.workspaceDir);
return await readFile(full, "utf8");
} catch (e) {
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
}
},
});
}
export function writeFileTool(ctx: ToolContext): ToolDefinition {
return tool({
description:
"Write a file in the project's curriculum engineering-file workspace. Path is relative to the workspace root. Creates parent directories.",
inputSchema: z.object({
path: z.string().describe("Relative path within the workspace."),
content: z.string().describe("The full file content to write."),
}),
execute: async (args): Promise<string> => {
try {
const full = confine(args.path, ctx.workspaceDir);
await mkdir(join(full, ".."), { recursive: true });
await writeFile(full, args.content, "utf8");
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) });
}
},
});
}
interface Entry {
readonly name: string;
readonly kind: "file" | "dir";
}
export function listFilesTool(ctx: ToolContext): ToolDefinition {
return tool({
description: "List files and directories at a path within the workspace. Defaults to the workspace root.",
inputSchema: z.object({
path: z.string().describe("Relative path within the workspace; defaults to root.").optional(),
}),
execute: async (args): Promise<string> => {
const sub = args.path ?? ".";
try {
const full = confine(sub, ctx.workspaceDir);
const entries = await readdir(full, { withFileTypes: true });
const result: Entry[] = entries.map((e) => ({
name: e.name,
kind: e.isDirectory() ? "dir" : "file",
}));
return JSON.stringify(result);
} 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.
}
}
+21
View File
@@ -0,0 +1,21 @@
/**
* Prisma client singleton.
*
* Prisma's client is a connection-pooled datasource; constructing it per-query
* exhausts connections. This module exports one shared instance per process.
* Hot-reload (tsx watch) can re-import this module — the global guard prevents
* a second PrismaClient from being created.
*/
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { __hubPrisma?: PrismaClient };
export const prisma: PrismaClient =
globalForPrisma.__hubPrisma ??
new PrismaClient({
log: process.env["HUB_PRISMA_LOG"] !== undefined ? ["query", "error", "warn"] : ["error", "warn"],
});
if (process.env["NODE_ENV"] !== "production") {
globalForPrisma.__hubPrisma = prisma;
}
+177
View File
@@ -0,0 +1,177 @@
/**
* Feishu lark client + long-connection event dispatcher.
*
* Uses lark's WebSocket long-connection mode (no public callback endpoint). The
* `EventDispatcher` registers `im.message.receive_v1`; the SDK delivers the raw
* event to our handler. This version of the SDK does not auto-normalize on the
* ws path — `normalize()` is a separate helper — so we work with the raw event
* shape directly (it carries chat_id, message_type, content, mentions).
*
* ADR-0001: the bot operates inside a project's bound group. It does not own
* the lock (ADR-0002) and is not the session owner.
*/
import * as lark from "@larksuiteoapi/node-sdk";
import type { FastifyBaseLogger } from "fastify";
export interface FeishuConfig {
readonly appId: string;
readonly appSecret: string;
readonly botOpenId: string;
}
export interface FeishuRuntime {
readonly client: lark.Client;
readonly logger: FastifyBaseLogger;
}
/** Raw `im.message.receive_v1` event payload (subset we use). */
export interface MessageReceiveEvent {
readonly header?: {
readonly event_id?: string;
readonly event_type?: string;
};
readonly message: {
readonly message_id: string;
readonly chat_id: string;
readonly chat_type: string;
readonly message_type: string;
readonly content: string;
readonly mentions?: ReadonlyArray<{
readonly key: string;
readonly id: { readonly open_id?: string };
readonly name: string;
}>;
};
readonly sender: {
readonly sender_id: { readonly open_id?: string };
readonly sender_type: string;
};
}
/** Send a text message to a chat. */
export async function sendText(rt: FeishuRuntime, chatId: string, text: string): Promise<void> {
// SDK exposes im.v1 dynamically at runtime; the generated types are weak
// there, so cast through the known shape.
const client = rt.client as unknown as {
im: { v1: { message: { create: (p: unknown) => Promise<unknown> } } };
};
await client.im.v1.message.create({
params: { receive_id_type: "chat_id" },
data: {
receive_id: chatId,
msg_type: "text",
content: JSON.stringify({ text }),
},
});
}
/** Send an interactive card to a chat. `card` is the lark card schema object. */
export async function sendCard(
rt: FeishuRuntime,
chatId: string,
card: unknown,
): 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;
}
/** 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 {
config: { wide_screen_mode: true },
header: {
title: { tag: "plain_text", content: `@Claude · ${opts.status}` },
template: opts.status === "处理完成" ? "green" : opts.status === "处理出错" ? "red" : "blue",
},
elements: [
{ tag: "div", text: { tag: "lark_md", content: `**model:** ${opts.model}` } },
{
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 }),
},
],
},
],
};
}
/**
* 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. */
export function createLarkClient(config: FeishuConfig): lark.Client {
return new lark.Client({
appId: config.appId,
appSecret: config.appSecret,
appType: lark.AppType.SelfBuild,
});
}
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>,
): FeishuRuntime {
const wsClient = new lark.WSClient({
appId: config.appId,
appSecret: config.appSecret,
domain: lark.Domain.Feishu,
loggerLevel: lark.LoggerLevel.info,
});
const rt: FeishuRuntime = { client, logger };
void wsClient.start({
eventDispatcher: new lark.EventDispatcher({}).register({
"im.message.receive_v1": async (data) => {
try {
await onMessage(data as MessageReceiveEvent, rt);
} catch (e) {
logger.error({ err: e }, "feishu message handler threw");
}
},
}),
});
return rt;
}
export function startFeishuListener(
config: FeishuConfig,
logger: FastifyBaseLogger,
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
): FeishuRuntime {
return startFeishuListenerWithClient(config, createLarkClient(config), logger, onMessage);
}
+97
View File
@@ -0,0 +1,97 @@
/**
* Feishu context reader — the real implementation behind `feishuContextTool`.
*
* ADR-0003: Hub stores only anchors, reads Feishu context on demand. The tool
* (`feishu_read_context`) is already chat-authorized by `tools.ts` (chat must
* match the project binding). Here we actually call lark's `im.v1.message`:
*
* - `trigger_message` / `reply`: `message.get` by message_id.
* - `status_card`: the run's status card message — same `message.get` by id.
* - `thread`: lark's thread replies. The SDK exposes `message.list` with a
* `parent_message_id` filter; we map "thread" to that.
*
* The lark SDK's `im.v1.message` methods are dynamic at runtime (weak types);
* we cast through a known request/response shape and return a compact JSON for
* the model. All calls use the chat-authorized `client` from the runtime — no
* separate credential path, so the ADR-0003 invariant holds end-to-end.
*/
import type { FeishuRuntime } from "./client.js";
import type { FeishuContextArgs } from "../agent/tools.js";
import type { ToolContext } from "../agent/tools.js";
/** Pragmatic shape of the lark message object we consume. */
interface LarkMessage {
message_id?: string;
msg_type?: string;
content?: string;
create_time?: string;
sender?: { id?: string; sender_type?: string };
chat_id?: string;
parent_message_id?: string;
root_id?: string;
}
interface ImV1Message {
get: (p: { path: { message_id: string } }) => Promise<{ data?: { items?: LarkMessage[]; message?: LarkMessage } }>;
list: (p: {
params: { container_id_type: string; container_id: string; page_size?: number };
}) => Promise<{ data?: { items?: LarkMessage[] } }>;
}
function im(rt: FeishuRuntime): ImV1Message {
return (rt.client as unknown as { im: { v1: { message: ImV1Message } } }).im.v1.message;
}
/** Read on-demand Feishu context for the current run. Entry point for the tool. */
export async function readFeishuContext(
args: FeishuContextArgs,
_ctx: ToolContext,
rt: unknown,
): Promise<string> {
const runtime = rt as FeishuRuntime;
const api = im(runtime);
try {
switch (args.anchor) {
case "trigger_message":
case "status_card":
case "reply": {
// All three are single-message reads by id.
const res = await api.get({ path: { message_id: args.id } });
const msg = res.data?.message ?? res.data?.items?.[0];
if (msg === undefined) return JSON.stringify({ error: "message not found", id: args.id });
return JSON.stringify(compact(msg));
}
case "thread": {
// Thread = replies to a parent message. `container_id` is the parent's
// message_id; container_id_type=message_id scopes the list to that thread.
const res = await api.list({
params: {
container_id_type: "message_id",
container_id: args.id,
page_size: 50,
},
});
const items = res.data?.items ?? [];
return JSON.stringify(items.map(compact));
}
default:
return JSON.stringify({ error: `unknown anchor type: ${args.anchor as string}` });
}
} catch (e) {
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
}
}
/** Project a lark message down to the fields the model actually needs. */
function compact(m: LarkMessage): Record<string, unknown> {
return {
message_id: m.message_id,
msg_type: m.msg_type,
content: m.content,
create_time: m.create_time,
sender_id: m.sender?.id,
parent_message_id: m.parent_message_id,
root_id: m.root_id,
};
}
+361
View File
@@ -0,0 +1,361 @@
/**
* Feishu @bot trigger → AgentRun lifecycle.
*
* The core path (ADR-0001..0003, 0017):
* 1. A message mentioning the bot arrives in a project group.
* 2. Resolve the chat → project binding (ADR-0001). Unknown chat ⇒ ignored.
* 3. Create an AgentRun + AgentSession (provider-bound, ADR-0017).
* 4. Acquire the project lock for the run (ADR-0002). If locked, reply busy.
* 5. Run the agent loop; the agent reads Feishu context on demand through the
* ADR-0003-authorized tool (chat must match binding).
* 6. On finish/fail/timeout: release the lock, post a status card.
*
* The agent model id comes from the ModelRegistry (ADR-0017 role-based
* routing, role-as-data). The trigger does not pick a model directly.
*/
import type { PrismaClient } from "@prisma/client";
import type { FastifyBaseLogger } from "fastify";
import { sendText, sendCard, buildRunStatusCard, type FeishuRuntime, type MessageReceiveEvent } from "./client.js";
import type { ToolRegistry } from "../agent/tools.js";
import type { ModelRegistry } from "../agent/models.js";
import { runAgent, type ModelFactory, type ProjectContext } from "../agent/runner.js";
import { acquireLock, currentLockRunId, releaseLock } from "../lock.js";
import { canTriggerAgent, canTriggerRole } from "../permission.js";
import { transcriptPath } from "../agent/transcript.js";
import { writeAudit } from "../audit.js";
interface TriggerDeps {
readonly prisma: PrismaClient;
readonly modelFactory: ModelFactory;
readonly tools: ToolRegistry;
readonly models: ModelRegistry;
readonly logger: FastifyBaseLogger;
}
/**
* Build the message handler. Returns a function suitable for the lark
* `im.message.receive_v1` event. The handler is async and long-running (the
* agent run is the long leg); the lock guarantees one run per project at a time.
*/
export function makeTriggerHandler(deps: TriggerDeps) {
return async (event: MessageReceiveEvent, rt: FeishuRuntime): Promise<void> => {
const msg = event.message;
const sender = event.sender;
void sender; // sender→user mapping is OPEN (principal sub-typology)
// Dedup: the lark ws client redelivers at-least-once. A duplicate event
// would spawn a second AgentRun — the lock would refuse it, but a FAILED
// run row + a busy reply would leak. Record the event_id up front; if it
// already exists, this is a redelivery → stop.
const eventId = event.header?.event_id;
if (eventId !== undefined && eventId !== "") {
const existing = await deps.prisma.feishuEventReceipt.findUnique({
where: { eventId },
select: { id: true },
});
if (existing !== null) {
deps.logger.debug({ eventId }, "feishu trigger: duplicate event, skipping");
return;
}
await deps.prisma.feishuEventReceipt.create({
data: {
eventId,
eventType: event.header?.event_type ?? "im.message.receive_v1",
messageId: msg.message_id,
},
});
}
// Only react to text messages in group chats.
if (msg.message_type !== "text") return;
const chatId = msg.chat_id;
if (chatId === "") return;
// ADR-0001: resolve chat → project. Unknown chat ⇒ not a project group.
const binding = await deps.prisma.projectGroupBinding.findUnique({
where: { chatId },
select: { projectId: true },
});
if (binding === null) {
deps.logger.debug({ chatId }, "feishu trigger: chat not bound to any project");
return;
}
const projectId = binding.projectId;
// ADR-0004: triggerAgent requires edit on the project. principal=sender
// open_id (sub-typology OPEN — pragmatic choice, see permission.ts).
const senderOpenId = event.sender.sender_id.open_id ?? "";
if (senderOpenId !== "") {
const perm = await canTriggerAgent(deps.prisma, projectId, senderOpenId);
if (!perm.allowed) {
deps.logger.info({ projectId, senderOpenId, reason: perm.reason }, "feishu trigger: permission denied");
await writeAudit(deps.prisma, { projectId, actorUserId: senderOpenId || undefined, action: "trigger.denied", metadata: { reason: perm.reason } });
await sendText(rt, chatId, "无权限触发。");
return;
}
}
const prompt = extractPrompt(msg);
if (prompt === null) return;
// Slash commands: session management, not agent runs. These bypass the
// lock — they don't create a run, so they can't conflict with one.
if (prompt.startsWith("/")) {
const cmd = prompt.split(/\s+/)[0];
switch (cmd) {
case "/new": {
await deps.prisma.agentSession.updateMany({
where: { projectId, archivedAt: null },
data: { archivedAt: new Date() },
});
await sendText(rt, chatId, "已开新会话,下次 @bot 将从头开始。");
return;
}
case "/resume": {
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, "没有可恢复的会话。");
return;
}
await deps.prisma.agentSession.update({
where: { id: latest.id },
data: { archivedAt: null },
});
await sendText(rt, chatId, "已恢复上一个会话。");
return;
}
case "/reset": {
await deps.prisma.agentSession.updateMany({
where: { projectId, archivedAt: null },
data: { archivedAt: new Date() },
});
await sendText(rt, chatId, "已重置,下次 @bot 将从头开始。");
return;
}
default:
break;
}
}
// ADR-0002: if locked by another run, reply busy.
const existing = await currentLockRunId(deps.prisma, projectId);
if (existing !== null) {
await sendText(rt, chatId, "项目正在处理中,请稍候。");
return;
}
const project = await deps.prisma.project.findUnique({
where: { id: projectId },
select: { workspaceDir: true },
});
if (project === null) {
deps.logger.error({ projectId }, "feishu trigger: project missing");
return;
}
// ADR-0017: role-as-data. Parse a leading `/<role>` command; unknown
// slashes are left as literal text (extractRole returns null). Falls back
// to "draft" when no role is named — the registry degrades gracefully.
const { roleId: parsedRole, prompt: cleanPrompt } = extractRole(prompt, deps.models);
const roleId = parsedRole ?? "draft";
// Per-role trigger gate (ADR-0017). Orthogonal to ADR-0004's
// canTriggerAgent above: that checked "can trigger an agent at all";
// this checks "can trigger *this* role". Unconfigured role ⇒ open.
if (senderOpenId !== "") {
const rolePerm = await canTriggerRole(deps.prisma, projectId, roleId, senderOpenId);
if (!rolePerm.allowed) {
deps.logger.info({ projectId, roleId, senderOpenId, reason: rolePerm.reason }, "feishu trigger: role permission denied");
await writeAudit(deps.prisma, { projectId, actorUserId: senderOpenId || undefined, action: "trigger.role_denied", metadata: { roleId, reason: rolePerm.reason } });
await sendText(rt, chatId, `无权限使用角色 ${roleId}`);
return;
}
}
const role = deps.models.role(roleId);
const model = deps.models.resolve(undefined, roleId);
const providerId = "openrouter";
// Per-role bundle (ADR-0017): systemPrompt seeds persona; tools whitelist
// is enforced inside runAgent when it builds the SDK tools record. `tools:
// undefined` means the full registered set (unrestricted role).
const systemPrompt = role?.systemPrompt;
// ADR-0017: provider+model-bound session. Reuse if one exists for this
// (project, provider, model) triple; otherwise create.
const existingSession = await deps.prisma.agentSession.findFirst({
where: { projectId, provider: providerId, model, archivedAt: null },
select: { id: true },
});
const session =
existingSession !== null
? await deps.prisma.agentSession.update({
where: { id: existingSession.id },
data: { provider: providerId, model, updatedAt: new Date() },
select: { id: true },
})
: await deps.prisma.agentSession.create({
data: {
projectId,
provider: providerId,
model,
title: cleanPrompt.slice(0, 40) || null,
metadata: {},
},
select: { id: true },
});
const run = await deps.prisma.agentRun.create({
data: {
projectId,
sessionId: session.id,
requestedByUserId: null, // sender→user mapping is OPEN (principal sub-typology)
entrypoint: "FEISHU",
status: "ACTIVE",
prompt: cleanPrompt,
model,
provider: providerId,
metadata: { roleId },
},
select: { id: true },
});
await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.created", metadata: { roleId, model, prompt: cleanPrompt.slice(0, 200) } });
// ADR-0002: acquire the project lock for this run.
try {
await acquireLock(deps.prisma, projectId, run.id, null);
} catch {
await deps.prisma.agentRun.update({
where: { id: run.id },
data: { status: "FAILED", error: "lock race", finishedAt: new Date() },
});
await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.lock_race", metadata: {} });
await sendText(rt, chatId, "项目正在处理中,请稍候。");
return;
}
await sendText(rt, chatId, `已开始处理(role: ${roleId}, model: ${model})。`);
const projectCtx: ProjectContext = {
projectId,
boundChatId: chatId, // ADR-0003: Feishu reads stay in this chat
workspaceDir: project.workspaceDir,
};
// Run the agent — fire-and-forget from the ws handler's perspective.
// Per-run tools + systemPrompt come from the role bundle (ADR-0017).
runAgent(deps.modelFactory, deps.tools, {
prompt: cleanPrompt,
model,
project: projectCtx,
systemPrompt,
toolWhitelist: role?.tools,
transcriptPath: transcriptPath(project.workspaceDir, session.id),
})
.then(async (result) => {
await deps.prisma.agentRun.update({
where: { id: run.id },
data: {
status:
result.status === "completed"
? "COMPLETED"
: result.status === "length"
? "TIMED_OUT"
: "FAILED",
inputTokens: result.usage.inputTokens,
outputTokens: result.usage.outputTokens,
error: result.error ?? null,
finishedAt: new Date(),
},
});
await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.finished", metadata: { status: result.status, inputTokens: result.usage.inputTokens, outputTokens: result.usage.outputTokens } });
await sendCard(rt, chatId, buildRunStatusCard({
status: statusReply(result.status),
model,
models: deps.models.listModels(),
runId: run.id,
}));
})
.catch(async (e) => {
// The run may have been removed (admin force-delete, or test reset)
// between creation and this callback. A P2025 from update is not
// actionable here — log and still post the status card.
try {
await deps.prisma.agentRun.update({
where: { id: run.id },
data: { status: "FAILED", error: String(e), finishedAt: new Date() },
});
} catch (updateErr) {
deps.logger.warn({ runId: run.id, err: String(updateErr) }, "trigger: could not mark run FAILED (already gone?)");
}
await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.failed", metadata: { error: String(e) } });
await sendCard(rt, chatId, buildRunStatusCard({
status: "处理出错",
model,
models: deps.models.listModels(),
runId: run.id,
}));
})
.finally(async () => {
await releaseLock(deps.prisma, run.id);
});
};
}
function statusReply(status: "completed" | "length" | "failed"): string {
switch (status) {
case "completed":
return "处理完成。";
case "length":
return "处理达到步数上限,已停止。";
case "failed":
return "处理出错。";
}
}
/**
* Extract the prompt text from a text message, stripping @mentions.
* Returns null if the message is empty after stripping or doesn't mention the bot.
* Lark text content is JSON: `{"text":"@_user_1 do something"}`.
*/
export function extractPrompt(msg: MessageReceiveEvent["message"]): string | null {
// Check for bot mention — mentions[].id.open_id should include the bot.
// The dispatcher already routes only message events; we further require
// that the bot is among the mentions (if no mentions, it's not @bot).
const mentions = msg.mentions;
if (mentions === undefined || mentions.length === 0) return null;
try {
const parsed = JSON.parse(msg.content) as { text?: string };
const text = parsed.text;
if (typeof text !== "string") return null;
// Strip @mentions (@_user_1 etc.) and trim; remainder is the prompt.
const stripped = text.replace(/@_\w+\s*/g, "").trim();
return stripped === "" ? null : stripped;
} catch {
return null;
}
}
/**
* Parse a leading `/<role>` command from a prompt (e.g. `/draft 帮我写教案`).
* Returns the role id and the remaining prompt with the command stripped.
* Control commands already handled upstream (`/new`, `/resume`, `/reset`) are
* ignored here — they never reach this function.
*
* Unknown `/foo` that isn't a registered role: returns `null` role and the
* original prompt unchanged (the slash is treated as literal text). Role
* resolution still happens via the registry's fallback.
*/
export function extractRole(
prompt: string,
registry: { role(id: string): unknown },
): { roleId: string | null; prompt: string } {
const m = /^\/(\S+)\s*/.exec(prompt);
if (m === null || m[1] === undefined) return { roleId: null, prompt };
const roleId = m[1];
if (registry.role(roleId) === undefined) {
// Unknown slash command — leave the prompt as-is (no silent role switch).
return { roleId: null, prompt };
}
return { roleId, prompt: prompt.slice(m[0].length) };
}
+79
View File
@@ -0,0 +1,79 @@
/**
* ProjectAgentLock — ADR-0002 invariant enforcement at the DB layer.
*
* `projectId @id` gives at-most-one lock per project; `runId @unique` gives a
* run holds at most one lock. Those are DB-level. The spec's `WellFormed`
* invariant ("holder is a non-terminal run") is app-level: it must hold on
* every read of the lock table. `assertLockHolderActive` enforces it on the
* read path — if a lock exists but its run is terminal, that's a bug (the run
* should have released it), surfaced as an error rather than silently trusted.
*/
import type { PrismaClient } from "@prisma/client";
import type { AgentRunStatus } from "@prisma/client";
/// spec RunState.Terminal: completed/failed/timedOut/canceled.
/// active/waitingForUser are NOT terminal — the lock must still be held.
const TERMINAL_STATUSES: ReadonlySet<AgentRunStatus> = new Set([
"COMPLETED",
"FAILED",
"TIMED_OUT",
"CANCELED",
]);
export function isTerminal(status: AgentRunStatus): boolean {
return TERMINAL_STATUSES.has(status);
}
/**
* Acquire the project lock for a run. Throws if the project is already locked
* by another run (ADR-0002 exclusivity). The unique runId constraint means a
* run that already holds a lock elsewhere will also fail — that's correct, a
* run scopes to one project.
*/
export async function acquireLock(
prisma: PrismaClient,
projectId: string,
runId: string,
holderUserId: string | null,
): Promise<void> {
await prisma.projectAgentLock.create({
data: { projectId, runId, holderUserId },
});
}
/**
* Release the lock owned by a run. Idempotent — a no-op if no lock exists.
* Called on run completion/failure/timeout/cancellation (ADR-0002).
*/
export async function releaseLock(prisma: PrismaClient, runId: string): Promise<void> {
await prisma.projectAgentLock.deleteMany({ where: { runId } });
}
/**
* The spec's `LockTable.WellFormed` on a single read: if `projectId` has a
* lock owned by `runId`, that run must be non-terminal. Throws if a terminal
* run still holds a lock — that's a release-path bug, not a recoverable state.
*
* Returns the lock's runId if a healthy lock exists, or null if unlocked.
*/
export async function currentLockRunId(
prisma: PrismaClient,
projectId: string,
): Promise<string | null> {
const lock = await prisma.projectAgentLock.findUnique({
where: { projectId },
select: { runId: true },
});
if (lock === null) return null;
const run = await prisma.agentRun.findUnique({
where: { id: lock.runId },
select: { status: true },
});
if (run !== null && isTerminal(run.status)) {
throw new Error(
`lock invariant violated: project ${projectId} locked by terminal run ${lock.runId} (status ${run.status}) — release path bug (ADR-0002 WellFormed)`,
);
}
return lock.runId;
}
+126
View File
@@ -0,0 +1,126 @@
/**
* Permission gate — ADR-0004 in code, project-scope.
*
* `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.
*/
import type { PrismaClient } from "@prisma/client";
import type { PermissionRole } from "@prisma/client";
/// The roles that grant `edit` capability (edit itself + manage, by monotonicity).
const EDIT_OR_HIGHER: ReadonlySet<PermissionRole> = new Set(["EDIT", "MANAGE"]);
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 },
});
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}`,
};
} 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).
*/
export async function canTriggerRole(
prisma: PrismaClient,
projectId: string,
roleId: string,
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 },
});
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 },
select: { id: true },
});
if (grant !== null) {
return { allowed: true, reason: `granted role ${roleId}` };
}
return {
allowed: false,
reason: `no active ${roleId} grant for ${principal} on project ${projectId}`,
};
} catch (e) {
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);
}
+107
View File
@@ -0,0 +1,107 @@
/**
* Hub entry — Fastify server + Feishu WebSocket listener.
*
* Wires the full trigger path: lark ws receives `im.message.receive_v1` →
* trigger handler resolves chat->project (ADR-0001), creates AgentRun + acquires
* the project lock (ADR-0002), runs the AI SDK-backed agent runner
* (ADR-0017), and releases the lock on finish. Feishu context reads inside the
* loop are ADR-0003-authorized (chat must match binding).
*
* Env (see .env.example):
* DATABASE_URL, OPENROUTER_API_KEY, FEISHU_APP_ID/SECRET/BOT_OPEN_ID, PORT
*/
import "dotenv/config";
import Fastify from "fastify";
import { prisma } from "./db.js";
import { 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 { makeTriggerHandler } from "./feishu/trigger.js";
function requireEnv(name: string): string {
const v = process.env[name];
if (v === undefined || v === "") {
console.error(`[hub] missing required env: ${name}`);
process.exit(1);
}
return v;
}
async function main(): Promise<void> {
const databaseUrl = requireEnv("DATABASE_URL");
void databaseUrl; // prisma reads DATABASE_URL from env directly
requireEnv("OPENROUTER_API_KEY");
const feishuAppId = requireEnv("FEISHU_APP_ID");
const feishuAppSecret = requireEnv("FEISHU_APP_SECRET");
const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID");
const port = Number(process.env["PORT"] ?? "8788");
const app = Fastify({ logger: true });
app.get("/api/healthz", async () => ({ ok: true, ts: Date.now() }));
// --- Agent seam wiring ---
const 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);
app.listen({ port, host: "0.0.0.0" }, (err, address) => {
if (err !== null) {
app.log.error({ err }, "listen failed");
process.exit(1);
}
app.log.info({ address }, "hub listening");
});
}
main().catch((e) => {
console.error("[hub] fatal:", e);
process.exit(1);
});
+64
View File
@@ -0,0 +1,64 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { cp, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ToolRegistry, type ToolContext } from "../../src/agent/tools.js";
import { cphCheckTool, cphBuildTool } from "../../src/agent/cph.js";
const EXAMPLES_DIR = join(process.cwd(), "..", "examples", "TH-141");
describe("cph subprocess tools (integration, real cph binary)", () => {
let ws: string;
let tools: ToolRegistry;
beforeEach(async () => {
// Use a writable copy of the real TH-141 example as the workspace.
ws = await mkdtemp(join(tmpdir(), "hub-cph-example-"));
await cp(EXAMPLES_DIR, ws, { recursive: true });
tools = new ToolRegistry();
tools.register("cph_check", cphCheckTool);
tools.register("cph_build", cphBuildTool);
});
afterEach(async () => {
await rm(ws, { recursive: true, force: true });
});
it("cph_check runs on the TH-141 example and returns diagnostics", async () => {
const ctx = { runId: "r", projectId: "p", boundChatId: "c", workspaceDir: ws };
const out = await executeTool(tools, "cph_check", {}, ctx);
const result = JSON.parse(out) as { exitCode: number; stdout: string; stderr: string };
// cph check exits 0 on a legal lesson (no error diagnostics, ADR-0010).
// If the example has warnings, exit is still 0.
expect(result.exitCode).toBe(0);
}, 30000);
it("cph_build renders the student target PDF", async () => {
const ctx = { runId: "r", projectId: "p", boundChatId: "c", workspaceDir: ws };
const out = await executeTool(tools, "cph_build", { target: "student", output: "build/test-student.pdf" }, ctx);
const result = JSON.parse(out) as { exitCode: number; stdout: string; stderr: string; output: string };
expect(result.exitCode).toBe(0);
}, 30000);
it("cph_check on a temp dir with no engineering file returns non-zero", async () => {
const empty = await mkdtemp(join(tmpdir(), "hub-cph-empty-"));
try {
const ctx = { runId: "r", projectId: "p", boundChatId: "c", workspaceDir: empty };
const out = await executeTool(tools, "cph_check", {}, ctx);
const result = JSON.parse(out) as { exitCode: number };
expect(result.exitCode).not.toBe(0);
} finally {
await rm(empty, { recursive: true, force: true });
}
}, 15000);
});
async function executeTool(tools: ToolRegistry, name: string, args: unknown, ctx: ToolContext): Promise<string> {
const def = tools.build(ctx)[name];
if (def?.execute === undefined) {
throw new Error(`missing executable tool: ${name}`);
}
return def.execute(args, { toolCallId: "call", messages: [], context: undefined }) as Promise<string>;
}
+207
View File
@@ -0,0 +1,207 @@
/**
* Test helpers for integration tests.
*
* Each test gets a clean DB (tables truncated before the test), a mock
* FeishuRuntime (sendText/sendCard are no-ops that record calls), and a mock
* AI SDK model factory (doGenerate() returns canned responses - no network).
*/
import { PrismaClient } from "@prisma/client";
import type { FastifyBaseLogger } from "fastify";
import type {
LanguageModelV4,
LanguageModelV4CallOptions,
LanguageModelV4Content,
LanguageModelV4GenerateResult,
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 prisma = new PrismaClient({
datasources: { db: { url: TEST_DATABASE_URL } },
});
/** Truncate all tables before each test for isolation. */
export async function resetDb(): Promise<void> {
const tables = [
"FeishuEventReceipt",
"AuditEntry",
"PermissionSettings",
"PermissionGrant",
"RoleTriggerGrant",
"ProjectAgentLock",
"AgentRun",
"AgentSession",
"ProjectGroupBinding",
"PlatformRoleAssignment",
"User",
"Project",
];
// Truncate with CASCADE to wipe dependent rows in one shot.
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tables.map((t) => `"${t}"`).join(", ")} RESTART IDENTITY CASCADE`);
}
/** A logger that discards everything (tests don't need fastify's pino). */
export const silentLogger: FastifyBaseLogger = {
info() {}, warn() {}, error() {}, debug() {}, fatal() {},
child() { return this; },
level: "silent",
} as unknown as FastifyBaseLogger;
/** Records sendText/sendCard calls so tests can assert on them. */
export interface MockFeishuRuntime extends FeishuRuntime {
readonly sentTexts: string[];
readonly sentCards: unknown[];
}
export function mockFeishuRuntime(): MockFeishuRuntime {
const sentTexts: string[] = [];
const sentCards: unknown[] = [];
// 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: {
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 ?? "");
}
}
return { data: { message_id: "mock-msg-id" } };
},
},
},
},
} as unknown as FeishuRuntime["client"],
logger: silentLogger,
sentTexts,
sentCards,
};
return rt;
}
export interface MockToolCall {
readonly toolCallId: string;
readonly toolName: string;
readonly input: unknown;
}
export interface MockModelResponse {
readonly text?: string;
readonly toolCalls?: readonly MockToolCall[];
readonly finishReason?: "stop" | "length" | "content-filter" | "tool-calls" | "error" | "other";
readonly inputTokens?: number;
readonly outputTokens?: number;
}
export class MockLanguageModel implements LanguageModelV4 {
readonly specificationVersion = "v4";
readonly provider = "mock";
readonly supportedUrls: Record<string, RegExp[]> = {};
readonly calls: LanguageModelV4CallOptions[] = [];
constructor(
readonly modelId: string,
private readonly responses: readonly MockModelResponse[] = [{ text: "mock response" }],
) {}
async doGenerate(options: LanguageModelV4CallOptions): Promise<LanguageModelV4GenerateResult> {
this.calls.push(options);
const configured = this.responses[this.calls.length - 1];
const last = this.responses[this.responses.length - 1];
const response =
configured ??
((last?.toolCalls?.length ?? 0) > 0
? { text: "mock response" }
: last ?? { text: "mock response" });
const content: LanguageModelV4Content[] = [];
if (response.text !== undefined) {
content.push({ type: "text", text: response.text });
}
for (const call of response.toolCalls ?? []) {
content.push({
type: "tool-call",
toolCallId: call.toolCallId,
toolName: call.toolName,
input: JSON.stringify(call.input),
});
}
const finishReason = response.finishReason ?? ((response.toolCalls?.length ?? 0) > 0 ? "tool-calls" : "stop");
return {
content,
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 },
},
response: { id: `mock-${this.calls.length}`, timestamp: new Date(), modelId: this.modelId },
warnings: [],
};
}
async doStream(_options: LanguageModelV4CallOptions): Promise<LanguageModelV4StreamResult> {
throw new Error("MockLanguageModel does not implement streaming");
}
}
export function createMockModelFactory(
responses: readonly MockModelResponse[] = [{ text: "mock response" }],
): { readonly modelFactory: ModelFactory; readonly models: ReadonlyMap<string, MockLanguageModel> } {
const models = new Map<string, MockLanguageModel>();
return {
models,
modelFactory: (modelId) => {
const model = new MockLanguageModel(modelId, responses);
models.set(modelId, model);
return model;
},
};
}
/** Create a project + binding + user + grant for a test. */
export async function seedProject(
projectId: string,
chatId: string,
options: { principal?: string; role?: "READ" | "EDIT" | "MANAGE" } = {},
): Promise<void> {
const principal = options.principal ?? "ou_test_user";
const role = options.role ?? "EDIT";
await prisma.project.create({
data: {
id: projectId,
name: `Test ${projectId}`,
workspaceDir: `/tmp/test-${projectId}`,
},
});
await prisma.user.create({
data: {
id: "u_" + projectId,
feishuOpenId: principal,
displayName: "Test User",
platformRoles: { create: { role: "TEACHER" } },
permissionGrants: {
create: {
resourceType: "PROJECT",
resourceId: projectId,
principal,
role,
},
},
},
});
await prisma.projectGroupBinding.create({
data: { projectId, chatId, createdByUserId: "u_" + projectId },
});
}
+85
View File
@@ -0,0 +1,85 @@
import { describe, it, expect, beforeEach, afterAll } from "vitest";
import { prisma, resetDb } from "./helpers.js";
import { acquireLock, releaseLock, currentLockRunId, isTerminal } from "../../src/lock.js";
describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => {
beforeEach(async () => {
await resetDb();
});
it("acquireLock + currentLockRunId round-trip", async () => {
const project = await prisma.project.create({
data: { id: "p-lock-1", 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: {} },
});
await acquireLock(prisma, project.id, run.id, null);
const holder = await currentLockRunId(prisma, project.id);
expect(holder).toBe(run.id);
await releaseLock(prisma, run.id);
const after = await currentLockRunId(prisma, project.id);
expect(after).toBeNull();
});
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" },
});
const run1 = await prisma.agentRun.create({
data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} },
});
const run2 = await prisma.agentRun.create({
data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "y", model: "m", provider: "mock", metadata: {} },
});
await acquireLock(prisma, project.id, run1.id, null);
await expect(acquireLock(prisma, project.id, run2.id, null)).rejects.toThrow();
});
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" },
});
// Create a run that is already COMPLETED (terminal), then manually insert a lock.
const run = await prisma.agentRun.create({
data: { projectId: project.id, entrypoint: "FEISHU", status: "COMPLETED", prompt: "x", model: "m", provider: "mock", metadata: {}, finishedAt: new Date() },
});
await prisma.projectAgentLock.create({
data: { projectId: project.id, runId: run.id },
});
// WellFormed invariant: reading a lock held by a terminal run must throw.
await expect(currentLockRunId(prisma, project.id)).rejects.toThrow(/lock invariant violated/);
});
it("releaseLock is idempotent", async () => {
const project = await prisma.project.create({
data: { id: "p-lock-4", 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: {} },
});
await acquireLock(prisma, project.id, run.id, null);
await releaseLock(prisma, run.id);
// Second release is a no-op.
await releaseLock(prisma, run.id);
expect(await currentLockRunId(prisma, project.id)).toBeNull();
});
it("isTerminal matches spec RunState.Terminal", () => {
expect(isTerminal("ACTIVE")).toBe(false);
expect(isTerminal("WAITING_FOR_USER")).toBe(false);
expect(isTerminal("COMPLETED")).toBe(true);
expect(isTerminal("FAILED")).toBe(true);
expect(isTerminal("TIMED_OUT")).toBe(true);
expect(isTerminal("CANCELED")).toBe(true);
});
});
afterAll(async () => {
await prisma.$disconnect();
});
@@ -0,0 +1,71 @@
import { describe, it, expect, beforeEach, afterAll } from "vitest";
import { prisma, resetDb } from "./helpers.js";
import { canTriggerRole } from "../../src/permission.js";
describe("canTriggerRole (integration, per-role gate)", () => {
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await prisma.$disconnect();
});
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" },
});
const r = await canTriggerRole(prisma, project.id, "draft", "ou_a");
expect(r.allowed).toBe(true);
});
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" },
});
await prisma.roleTriggerGrant.create({
data: { projectId: project.id, roleId: "review", principal: "ou_a" },
});
const r = await canTriggerRole(prisma, project.id, "review", "ou_a");
expect(r.allowed).toBe(true);
});
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" },
});
// Someone else has the role; ou_b does not.
await prisma.roleTriggerGrant.create({
data: { projectId: project.id, roleId: "review", principal: "ou_a" },
});
const r = await canTriggerRole(prisma, project.id, "review", "ou_b");
expect(r.allowed).toBe(false);
});
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" },
});
await prisma.roleTriggerGrant.create({
data: { projectId: project.id, roleId: "review", principal: "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" } });
await prisma.roleTriggerGrant.create({
data: { projectId: p1.id, roleId: "review", principal: "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" },
});
const onP1 = await canTriggerRole(prisma, p1.id, "review", "ou_a");
expect(onP1.allowed).toBe(true);
const onP2 = await canTriggerRole(prisma, p2.id, "review", "ou_a");
expect(onP2.allowed).toBe(false);
});
});
+295
View File
@@ -0,0 +1,295 @@
import { describe, it, expect, beforeEach, afterAll, vi } from "vitest";
import { prisma, resetDb, mockFeishuRuntime, createMockModelFactory, 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";
const bot = { key: "@_user_1", id: { open_id: "ou_bot" }, name: "Bot" };
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,
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text }),
mentions: [bot],
},
sender: { sender_id: { open_id: senderOpenId }, sender_type: "user" },
};
}
describe("trigger full lifecycle (integration)", () => {
let modelFactory: ModelFactory;
let tools: ToolRegistry;
let models: InMemoryModelRegistry;
let rt: ReturnType<typeof mockFeishuRuntime>;
beforeEach(async () => {
await resetDb();
modelFactory = createMockModelFactory().modelFactory;
tools = new ToolRegistry();
models = new InMemoryModelRegistry(
[{ id: "mock-model", label: "Mock", toolCapable: true }],
[
{ id: "draft", label: "草稿", defaultModel: "mock-model", systemPrompt: undefined, tools: undefined },
{ id: "review", label: "审校", defaultModel: "mock-model", systemPrompt: undefined, tools: ["read_file"] },
],
);
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 });
await trigger(makeEvent("chat-1", "@_user_1 写教案"), rt);
// Wait for the async run to complete (fire-and-forget in trigger).
await vi.waitFor(async () => {
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(1);
expect(runs[0]?.status).toBe("COMPLETED");
});
// Lock released (no lock row remains).
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)。");
});
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 });
await trigger(makeEvent("chat-2", "@_user_1 写教案"), rt);
expect(rt.sentTexts).toContain("无权限触发。");
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(0);
});
it("replies busy 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({
data: { projectId: "proj-3", entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} },
});
await prisma.projectAgentLock.create({
data: { projectId: "proj-3", runId: existingRun.id },
});
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
await trigger(makeEvent("chat-3", "@_user_1 写教案"), rt);
expect(rt.sentTexts).toContain("项目正在处理中,请稍候。");
// No new run created.
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(1);
});
it("ignores messages from unbound chats (ADR-0001)", async () => {
await seedProject("proj-4", "chat-4");
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
await trigger(makeEvent("chat-UNKNOWN", "@_user_1 写教案"), rt);
expect(rt.sentTexts).toHaveLength(0);
expect(rt.sentCards).toHaveLength(0);
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(0);
});
it("ignores messages without @bot mention", async () => {
await seedProject("proj-5", "chat-5");
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
const event: MessageReceiveEvent = {
message: {
message_id: "m_nobot",
chat_id: "chat-5",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "hello no bot" }),
mentions: undefined,
},
sender: { sender_id: { open_id: "ou_test_user" }, sender_type: "user" },
};
await trigger(event, rt);
expect(rt.sentTexts).toHaveLength(0);
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(0);
});
it("/new archives current session (no run created)", async () => {
await seedProject("proj-6", "chat-6");
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
// First @bot creates a session + run.
await trigger(makeEvent("chat-6", "@_user_1 写教案"), rt);
await vi.waitFor(async () => {
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(1);
expect(runs[0]?.status).toBe("COMPLETED");
});
// /new archives the session.
await trigger(makeEvent("chat-6", "@_user_1 /new"), rt);
expect(rt.sentTexts).toContain("已开新会话,下次 @bot 将从头开始。");
const sessions = await prisma.agentSession.findMany();
expect(sessions).toHaveLength(1);
expect(sessions[0]?.archivedAt).not.toBeNull();
// No new run created for /new.
expect(await prisma.agentRun.findMany()).toHaveLength(1);
});
it("/resume un-archives the most recent session", async () => {
await seedProject("proj-7", "chat-7");
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
// Create + archive a session via /new.
await trigger(makeEvent("chat-7", "@_user_1 写教案"), rt);
await vi.waitFor(async () => {
expect(await prisma.agentRun.findMany()).toHaveLength(1);
});
await trigger(makeEvent("chat-7", "@_user_1 /new"), rt);
// /resume un-archives.
await trigger(makeEvent("chat-7", "@_user_1 /resume"), rt);
expect(rt.sentTexts).toContain("已恢复上一个会话。");
const sessions = await prisma.agentSession.findMany();
expect(sessions).toHaveLength(1);
expect(sessions[0]?.archivedAt).toBeNull();
});
it("/reset archives current session", async () => {
await seedProject("proj-8", "chat-8");
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
await trigger(makeEvent("chat-8", "@_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-8", "@_user_1 /reset"), rt);
expect(rt.sentTexts).toContain("已重置,下次 @bot 将从头开始。");
const sessions = await prisma.agentSession.findMany();
expect(sessions).toHaveLength(1);
expect(sessions[0]?.archivedAt).not.toBeNull();
});
it("unknown slash command falls through to agent", async () => {
await seedProject("proj-9", "chat-9");
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
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");
});
});
it("denies /review when sender has no role grant (per-role gate)", async () => {
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" },
});
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
await trigger(makeEvent("chat-10", "@_user_1 /review 看看这节"), rt);
expect(rt.sentTexts).toContain("无权限使用角色 review。");
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(0);
});
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" },
});
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
await trigger(makeEvent("chat-11", "@_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(runs[0]?.metadata).toMatchObject({ roleId: "review" });
});
});
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 });
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("写第三单元");
expect(runs[0]?.metadata).toMatchObject({ roleId: "draft" });
});
});
it("dedups a redelivered event by event_id (no second run)", async () => {
await seedProject("proj-13", "chat-13");
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
// 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("writes audit entries across the run lifecycle", async () => {
await seedProject("proj-14", "chat-14");
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
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);
});
});
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);
});
});
+16
View File
@@ -0,0 +1,16 @@
import { describe, it, expect } from "vitest";
import { roleGrantsEdit } from "../../src/permission.js";
describe("roleGrantsEdit (ADR-0004 monotonicity)", () => {
it("READ does not grant edit", () => {
expect(roleGrantsEdit("READ")).toBe(false);
});
it("EDIT grants edit", () => {
expect(roleGrantsEdit("EDIT")).toBe(true);
});
it("MANAGE grants edit (manage ⊇ edit, spec can_mono)", () => {
expect(roleGrantsEdit("MANAGE")).toBe(true);
});
});
+75
View File
@@ -0,0 +1,75 @@
import { describe, it, expect } from "vitest";
import { extractPrompt } from "../../src/feishu/trigger.js";
import type { MessageReceiveEvent } from "../../src/feishu/client.js";
const bot = { key: "@_user_1", id: { open_id: "ou_bot" }, name: "Bot" };
function msg(text: string, mentions: typeof bot[] | undefined = [bot]): MessageReceiveEvent["message"] {
return {
message_id: "m",
chat_id: "c",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text }),
mentions,
};
}
describe("extractPrompt (@bot mention parsing)", () => {
it("extracts the prompt after @bot", () => {
expect(extractPrompt(msg("@_user_1 帮我写教案"))).toBe("帮我写教案");
});
it("strips multiple mentions", () => {
const other = { key: "@_user_2", id: { open_id: "ou_other" }, name: "Other" };
expect(extractPrompt(msg("@_user_1 @_user_2 检查例题2", [bot, other]))).toBe("检查例题2");
});
it("returns null when only @bot with no prompt", () => {
expect(extractPrompt(msg("@_user_1"))).toBeNull();
});
it("returns null when @bot followed by whitespace only", () => {
expect(extractPrompt(msg("@_user_1 "))).toBeNull();
});
it("returns null when no mentions", () => {
const m: MessageReceiveEvent["message"] = {
message_id: "m", chat_id: "c", chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "hello" }),
mentions: undefined,
};
expect(extractPrompt(m)).toBeNull();
});
it("returns null for non-text message_type (content still parseable)", () => {
const m = msg("{}", [bot]);
// message_type check is the caller's job; extractPrompt only parses content.
// But with empty text it returns null.
expect(extractPrompt({ ...m, content: JSON.stringify({ text: "" }) })).toBeNull();
});
it("returns null when content is not valid JSON", () => {
const m: MessageReceiveEvent["message"] = {
message_id: "m",
chat_id: "c",
chat_type: "group",
message_type: "text",
content: "not json",
mentions: [bot],
};
expect(extractPrompt(m)).toBeNull();
});
it("returns null when JSON has no text field", () => {
const m: MessageReceiveEvent["message"] = {
message_id: "m",
chat_id: "c",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ foo: "bar" }),
mentions: [bot],
};
expect(extractPrompt(m)).toBeNull();
});
});
+39
View File
@@ -0,0 +1,39 @@
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 { 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" } }],
},
{ text: "done", finishReason: "stop" },
]);
const result = await runAgent(modelFactory, tools, {
prompt: "read lesson.txt",
model: "mock-model",
project: { projectId: "p", boundChatId: "c", workspaceDir: ws },
systemPrompt: "system",
maxIterations: 5,
});
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 });
}
});
});
+60
View File
@@ -0,0 +1,60 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtemp, rm, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { readTranscript, appendTranscript, transcriptPath, TRANSCRIPT_DIR } from "../../src/agent/transcript.js";
import type { ModelMessage } from "ai";
const sample: ModelMessage = { role: "user", content: "hello" };
const reply: ModelMessage = { role: "assistant", content: "hi" };
describe("transcript JSONL", () => {
let dir: string;
let path: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "hub-transcript-"));
path = join(dir, "session.jsonl");
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it("readTranscript returns empty array for nonexistent file", async () => {
expect(await readTranscript(path)).toEqual([]);
});
it("appendTranscript creates parent dirs and writes JSONL", async () => {
await appendTranscript(path, [sample]);
const content = await readFile(path, "utf8");
expect(content.trim()).toBe(JSON.stringify(sample));
});
it("readTranscript reads back appended messages", async () => {
await appendTranscript(path, [sample, reply]);
const messages = await readTranscript(path);
expect(messages).toHaveLength(2);
expect(messages[0]).toEqual(sample);
expect(messages[1]).toEqual(reply);
});
it("multiple appends accumulate", async () => {
await appendTranscript(path, [sample]);
await appendTranscript(path, [reply]);
const messages = await readTranscript(path);
expect(messages).toHaveLength(2);
});
it("skips corrupt lines (partial write recovery)", async () => {
await appendTranscript(path, [sample]);
// Append a corrupt line (simulating a crash mid-write).
await writeFile(path, '{"broken json\n', { flag: "a" });
const messages = await readTranscript(path);
expect(messages).toHaveLength(1); // only the valid line
});
it("transcriptPath builds the expected path", () => {
const p = transcriptPath("/ws", "sess-1");
expect(p).toBe(join("/ws", TRANSCRIPT_DIR, "sess-1.jsonl"));
});
});
+88
View File
@@ -0,0 +1,88 @@
import { describe, it, expect, beforeEach } from "vitest";
import { mkdtemp, writeFile, mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ToolRegistry, type ToolContext } from "../../src/agent/tools.js";
import { readFileTool, writeFileTool, listFilesTool, PathEscape } from "../../src/agent/workspace.js";
describe("workspace path confinement", () => {
let ws: string;
let tools: ToolRegistry;
beforeEach(async () => {
ws = await mkdtemp(join(tmpdir(), "hub-unit-"));
tools = new ToolRegistry();
tools.register("read_file", readFileTool);
tools.register("write_file", writeFileTool);
tools.register("list_files", listFilesTool);
await mkdir(join(ws, "sub"));
await writeFile(join(ws, "a.txt"), "hello");
await writeFile(join(ws, "sub", "b.txt"), "world");
});
const ctx = () => ({ runId: "r", projectId: "p", boundChatId: "c", workspaceDir: ws });
it("reads a file inside the workspace", async () => {
expect(await executeTool(tools, "read_file", { path: "a.txt" }, ctx())).toBe("hello");
});
it("reads nested files", async () => {
expect(await executeTool(tools, "read_file", { path: "sub/b.txt" }, ctx())).toBe("world");
});
it("rejects .. escapes as error JSON, not a throw", async () => {
const out = await executeTool(tools, "read_file", { path: "../../etc/passwd" }, ctx());
expect((JSON.parse(out) as { error: string }).error).toContain("escapes workspace");
});
it("rejects absolute paths outside the workspace", async () => {
const out = await executeTool(tools, "read_file", { path: "/etc/passwd" }, ctx());
expect((JSON.parse(out) as { error: string }).error).toContain("escapes workspace");
});
it("writes a file and reads it back", async () => {
await executeTool(tools, "write_file", { path: "sub/c.txt", content: "new" }, ctx());
expect(await executeTool(tools, "read_file", { path: "sub/c.txt" }, ctx())).toBe("new");
});
it("creates parent dirs on write", async () => {
await executeTool(tools, "write_file", { path: "deep/nested/d.txt", content: "x" }, ctx());
expect(await executeTool(tools, "read_file", { path: "deep/nested/d.txt" }, ctx())).toBe("x");
});
it("lists the workspace root", async () => {
const out = await executeTool(tools, "list_files", {}, ctx());
const entries = JSON.parse(out) as Array<{ name: string; kind: string }>;
expect(entries).toContainEqual({ name: "a.txt", kind: "file" });
expect(entries).toContainEqual({ name: "sub", kind: "dir" });
});
it("lists a subdirectory", async () => {
const out = await executeTool(tools, "list_files", { path: "sub" }, ctx());
const entries = JSON.parse(out) as Array<{ name: string; kind: string }>;
expect(entries).toContainEqual({ name: "b.txt", kind: "file" });
});
it("PathEscape is an Error subclass", () => {
expect(new PathEscape("../x", "/ws")).toBeInstanceOf(Error);
expect(new PathEscape("../x", "/ws").name).toBe("PathEscape");
});
it("read of nonexistent file returns error JSON", async () => {
const out = await executeTool(tools, "read_file", { path: "nope.txt" }, ctx());
expect((JSON.parse(out) as { error: string }).error).toBeTruthy();
});
it("write with .. escape returns error JSON", async () => {
const out = await executeTool(tools, "write_file", { path: "../escape.txt", content: "x" }, ctx());
expect((JSON.parse(out) as { error: string }).error).toContain("escapes workspace");
});
});
async function executeTool(tools: ToolRegistry, name: string, args: unknown, ctx: ToolContext): Promise<string> {
const def = tools.build(ctx)[name];
if (def?.execute === undefined) {
throw new Error(`missing executable tool: ${name}`);
}
return def.execute(args, { toolCallId: "call", messages: [], context: undefined }) as Promise<string>;
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"declaration": false,
"sourceMap": true
},
"include": ["src/**/*.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["test/**/*.test.ts"],
// Integration tests share one DB; run files sequentially to avoid
// concurrent truncate/insert races. Unit tests are fast either way.
pool: "forks",
fileParallelism: false,
env: { NODE_ENV: "test" },
},
});
+10 -3
View File
@@ -1,7 +1,7 @@
/-! /-!
# Prelude —— System 层共享标识符 # Prelude —— System 层共享标识符
平台层反复引用一组标识符(项目、run、session、principal)。其内部表示从未被决策 平台层反复引用一组标识符(项目、run、session、principal、chat)。其内部表示从未被决策
(UUID / 复合键、principal 子类型学),也非分歧点,故收口成 opaque 载体 (UUID / 复合键、principal 子类型学),也非分歧点,故收口成 opaque 载体
`Identifiers`,System 各模块在其上参数化——契约谈得了"锁 owner 是哪个 run"这类 `Identifiers`,System 各模块在其上参数化——契约谈得了"锁 owner 是哪个 run"这类
**关系**,却不对标识符表示作承诺。 **关系**,却不对标识符表示作承诺。
@@ -13,12 +13,19 @@ namespace Spec.System
structure Identifiers where structure Identifiers where
/-- 课程项目标识(`OPEN` 表示;聚合根,likec4 `Project`)。 -/ /-- 课程项目标识(`OPEN` 表示;聚合根,likec4 `Project`)。 -/
ProjectId : Type ProjectId : Type
/-- 一次 Claude 任务的标识(`OPEN` 表示;锁的 owner、审计主体,`AgentRun`)。 -/ /-- 一次 agent 任务的标识(`OPEN` 表示;锁的 owner、审计主体,`AgentRun`。provider 无关,
ADR-0017;`@Claude` 仅为触发品牌,不承诺 provider)。 -/
RunId : Type RunId : Type
/-- 长生命周期 Claude 会话标识(`OPEN` 表示;跨多 run 复用,ADR-0002)。 -/ /-- 长生命周期 agent 会话标识(`OPEN` 表示;**provider/model 绑定, ADR-0017**——一次
session 不跨 provider/model;切 model 即新 session,跨 session 连续性由 ADR-0003 项目
记忆/锚点重建,不由 session 自带。同 provider/model 内可跨多 run 复用, ADR-0002)。 -/
SessionId : Type SessionId : Type
/-- 权限主体标识(`OPEN` 表示及其子类型学;ADR-0004 的 user/chat/department/ /-- 权限主体标识(`OPEN` 表示及其子类型学;ADR-0004 的 user/chat/department/
子类型学未定且非本层分歧点,纯 plumbing,故只留 opaque 键)。 -/ 子类型学未定且非本层分歧点,纯 plumbing,故只留 opaque 键)。 -/
Principal : Type Principal : Type
/-- 飞书项目群 chat 标识(`OPEN` 表示;ADR-0001 协作空间、ADR-0003 锚点引用、
ADR-0004 `feishu_chat` principal 三处共用同一实体。独立成载体而非 `Principal` 子
类型——principal 子类型学 OPEN 见上,本层不预设"chat 是 principal 的哪种子型")。 -/
ChatId : Type
end Spec.System end Spec.System
+8 -1
View File
@@ -1,18 +1,25 @@
import Spec.System.ProjectGroup
import Spec.System.Run import Spec.System.Run
import Spec.System.Lock import Spec.System.Lock
import Spec.System.Memory
import Spec.System.Permission import Spec.System.Permission
import Spec.System.PermissionGrant
import Spec.System.Audit import Spec.System.Audit
/-! /-!
# System —— Hub 平台层契约 # System —— Hub 平台层契约
协作与执行的平台:项目、飞书群、AgentRun、锁、权限、审计。likec4 协作与执行的平台:项目、飞书群、AgentRun、锁、权限、审计、按需上下文。likec4
(`docs/architecture/likec4/`)已画出这一层的**结构**;本层只补 likec4 画不出的 (`docs/architecture/likec4/`)已画出这一层的**结构**;本层只补 likec4 画不出的
**语义分歧点**: **语义分歧点**:
- `ProjectGroup` —— project↔飞书群 1:1 长生命周期绑定(ADR-0001);群是协作空间,不持锁。
- `Run` —— AgentRun 状态与终止判定(状态集合完整性 OPEN)。 - `Run` —— AgentRun 状态与终止判定(状态集合完整性 OPEN)。
- `Lock` —— 锁 owner=run(ADR-0002),及"持锁者必为非终止 run"的核心不变式。 - `Lock` —— 锁 owner=run(ADR-0002),及"持锁者必为非终止 run"的核心不变式。
- `Memory` —— 按需上下文:锚点类别(ADR-0003)+ MCP 工具按 run/project 上下文授权的不变式。
- `Permission` —— read⊂edit⊂manage 角色格、能力推导、单调性;force-release 在格外。 - `Permission` —— read⊂edit⊂manage 角色格、能力推导、单调性;force-release 在格外。
- `PermissionGrant` —— grant(resource×principal×role)与 settings(六 policy 旋钮)结构
(ADR-0004);role-capability 与 settings-policy 的组合规则 OPEN。
- `Audit` —— 有意从简(内容多为 plumbing,OPEN)。 - `Audit` —— 有意从简(内容多为 plumbing,OPEN)。
标识符见 `Spec.Prelude`。决策出处:ADR-0001..0004。 标识符见 `Spec.Prelude`。决策出处:ADR-0001..0004。
+51
View File
@@ -0,0 +1,51 @@
import Spec.Prelude
/-!
# Memory —— 按需上下文:锚点与项目记忆(ADR-0003)
ADR-0003:Hub **只存锚点与项目记忆**,不存全量飞书消息历史;Claude 需要更多上下文时经
飞书 API 按需读取。本模块刻画所存锚点的**类别**(ADR 列定,枚举完整性 OPEN——ADR 是
"例如"式列举,新增类别不违反契约),并钉死一条 likec4 画不出的安全不变式:**MCP 工具
按 run/project 上下文授权,Claude 不得传任意 chat id**(ADR-0003 Consequences 末条)。
"chat id 与 project 绑定"这一锚点类别由 `ProjectGroup.GroupBinding`(ADR-0001)权威承载,
本模块不重复声明,只覆盖其余飞书侧指针(触发消息、状态卡片、回复、线程)。
-/
namespace Spec.System
variable (I : Identifiers)
variable (MessageId CardId : Type)
/-- 上下文锚点(`PINNED` 类别, ADR-0003 列定;**枚举完整性 `OPEN`**——ADR 是"例如"式
列举,实现若需新类别须 surface,不得默认本枚举已穷尽)。承载 Hub 保留的飞书侧最小指针,
而非消息正文。 -/
inductive Anchor where
/-- 触发某次 run 的消息(`PINNED` 类别, ADR-0003 "trigger message id")。 -/
| triggerMessage : MessageId Anchor
/-- 某 run 的状态卡片(`PINNED` 类别, ADR-0003 "run id and status card id")。 -/
| statusCard : I.RunId CardId Anchor
/-- 回复锚点(`PINNED` 类别, ADR-0003 "reply anchors")。 -/
| reply : MessageId Anchor
/-- 线程锚点(`PINNED` 类别, ADR-0003 "thread anchors")。 -/
| thread : MessageId Anchor
/-- MCP 读上下文请求:由某 run 发起、指向某 chat(`PINNED` 关系, ADR-0003 "Claude calls
MCP tools to read … through Feishu APIs")。 -/
structure McpReadRequest where
/-- 发起请求的 run(授权上下文主体, ADR-0003)。 -/
run : I.RunId
/-- 请求读取的 chat(是否允许越界由下方 `Authorized` 钉死:不允许)。 -/
chat : I.ChatId
/-- 请求获授权:其 chat 必须等于该 run 所属 project 的绑定群(`PINNED` 安全不变式,
ADR-0003 Consequences "MCP tools must authorize by run/project context; Claude cannot
pass arbitrary chat ids")。`runProject`/`boundChat` 由平台提供(表示 `OPEN`);本谓词只
钉死"chat 必须匹配 run 的 project 绑定",杜绝 Claude 传任意 chat id 越权读取。 -/
def McpReadRequest.Authorized
(req : McpReadRequest I)
(runProject : I.RunId Option I.ProjectId)
(boundChat : I.ProjectId Option I.ChatId) : Prop :=
p, runProject req.run = some p boundChat p = some req.chat
end Spec.System
+69
View File
@@ -0,0 +1,69 @@
import Spec.Prelude
import Spec.System.Permission
/-!
# PermissionGrant —— 授权与设置(ADR-0004)
ADR-0004 的"飞书云文档式"权限:**grant**(`resource × principal × role`)与 **settings**
(各 policy 旋钮)分离;role 决定"谁能"(能力,见 `Permission`),settings 决定"此资源
是否开某类操作"(策略)。本模块把 grant/settings 的结构钉死——`Permission` 已落 role 能
力格,本模块补"授权如何挂到资源/主体上"。
principal 子类型学(user/chat/department/…)与各 policy 值域均为 `OPEN`(ADR 未定,非本
层分歧点)。**role-capability 与 settings-policy 如何组合成最终授权决策**亦 `OPEN`——
ADR-0004 把二者列为分离的闸,但未明文规定组合规则(AND?settings 能否超出 role?),实现
须 surface,不得默认。
-/
namespace Spec.System
variable (I : Identifiers)
variable (ArtifactId Policy : Type)
/-- 受权限调控的资源(`PINNED` 三类, ADR-0004 `resource_type: project | artifact |
project_group`);每类携带其 id,故 resource_id 由 resource_type 决定(结构无洞)。 -/
inductive Resource where
/-- 课程项目(`PINNED` 类别, ADR-0004)。 -/
| project : I.ProjectId Resource
/-- 教研产物(`PINNED` 类别, ADR-0004;id 表示 `OPEN`,语义向 Courseware 半边对齐)。 -/
| artifact : ArtifactId Resource
/-- 飞书项目群(`PINNED` 类别, ADR-0004 `project_group`;chat 标识见
`Identifiers.ChatId`,绑定见 `ProjectGroup`)。 -/
| projectGroup : I.ChatId Resource
/-- 授权项(`PINNED` 结构, ADR-0004 `PermissionGrant`):把一个 role 授予一个 principal 对
某个 resource。role 取自 `Permission.Role`(read/edit/manage 累积格)。principal 表示
`OPEN`(子类型学未定,见 `Identifiers.Principal`)。 -/
structure PermissionGrant where
/-- 被授权的资源(`PINNED`, ADR-0004 `resource_id`)。 -/
resource : Resource I ArtifactId
/-- 被授权的主体(`PINNED` 字段/`OPEN` 表示, ADR-0004 `principal_id`;user/chat/
department/user_group/app 子类型学未定)。 -/
principal : I.Principal
/-- 授予的角色(`PINNED`, ADR-0004 `role`;read⊂edit⊂manage 见 `Permission.Role`)。 -/
role : Role
/-- 资源策略设置(`PINNED` 结构 + 六旋钮, ADR-0004 `PermissionSettings`):与 grant 分离,
控制"此资源是否开某类操作"。六旋钮由 ADR 逐字列名;各旋钮值域 `OPEN`(ADR 未定,非本层
分歧点)。共享同一 opaque `Policy` 类型:契约只钉死"旋钮存在且相互独立",不钉死"各旋钮
值域互异"——值域是实现/后续 ADR 的事。 -/
structure PermissionSettings where
/-- 设置所属资源(`PINNED`, ADR-0004)。 -/
resource : Resource I ArtifactId
/-- 对外分享策略(`PINNED` 旋钮名, ADR-0004 `external_share_policy`;值域 `OPEN`)。 -/
externalShare : Policy
/-- 评论策略(`PINNED` 旋钮名, ADR-0004 `comment_policy`;值域 `OPEN`)。 -/
comment : Policy
/-- 复制/下载策略(`PINNED` 旋钮名, ADR-0004 `copy_download_policy`;值域 `OPEN`)。 -/
copyDownload : Policy
/-- 协作者管理策略(`PINNED` 旋钮名, ADR-0004 `collaborator_management_policy`;
值域 `OPEN`)。 -/
collaboratorMgmt : Policy
/-- agent 触发策略(`PINNED` 旋钮名, ADR-0004 `agent_trigger_policy`;值域 `OPEN`。与
`agentCancel` 分立——"谁能触发"≠"谁能取消",ADR-0004 故意拆开)。 -/
agentTrigger : Policy
/-- agent 取消策略(`PINNED` 旋钮名, ADR-0004 `agent_cancel_policy`;值域 `OPEN`。与
`agentTrigger` 分立,见上)。 -/
agentCancel : Policy
end Spec.System
+40
View File
@@ -0,0 +1,40 @@
import Spec.Prelude
/-!
# ProjectGroup —— 飞书项目群作为协作空间(ADR-0001)
ADR-0001 的核心:一个 project 对应一个**长生命周期**飞书项目群;群是协作空间,**不是锁
owner**(锁归 `AgentRun`,见 `Lock` / ADR-0002),不是临时处理 session。群可在无 Claude
处理时保持开启;教师离群/静音与项目权限、与 Claude 生命周期相互独立。本模块钉死
project↔group 的一对一绑定——likec4 画得出"project has group",画不出"恰好一个、且群
不持锁"。
**群失效态与绑定快照(`OPEN`, ADR-0001 Consequences):** 群解散/归档/不可达时,
`GroupBinding` 快照应变为 `none`(解绑、允许 rebind)还是保持 `some` 但指向失效 chat
(悬挂、待管理员干预)未决策。ADR-0001 已点名 "rebinding or group archival needs
explicit product rules";本模块只刻画健康态 1:1 不变式,不臆造失效态语义。实现遇到
群解散必须 surface,不得默认任一方。
-/
namespace Spec.System
variable (I : Identifiers)
/-- 飞书项目群(`PINNED` 长生命周期协作空间, ADR-0001)。承载 project 与飞书 chat 的绑定;
**不是锁 owner**(锁归 `AgentRun`,见 `Lock`);不是临时 session。 -/
structure ProjectGroup where
/-- 群对应的飞书 chat(`PINNED` 关系, ADR-0001 "one project has one Feishu project
group";chat 标识见 `Identifiers.ChatId`)。 -/
chat : I.ChatId
/-- 项目↔群绑定表(`PINNED` 每项目至多一个群, ADR-0001)。`ProjectId → Option ChatId` 的
结构本身即"一个 project 至多绑一个群"(由 `Option` 自带);良构补另一半——单射。 -/
def GroupBinding := I.ProjectId Option I.ChatId
/-- 绑定良构:**单射**——不同 project 不绑同一 chat(`PINNED` 1:1 的另一半, ADR-0001)。
"每 project 至多一个群"由 `Option` 结构自带;这条钉死"每群至多属于一个 project"。群
rebinding/archival 是生命周期事件(ADR-0001 Consequences),不在本快照不变式内。 -/
def GroupBinding.WellFormed (b : GroupBinding I) : Prop :=
p₁ p₂ c, b p₁ = some c b p₂ = some c p₁ = p₂
end Spec.System