Commit Graph

37 Commits

Author SHA1 Message Date
hongjr03 a0df8b6bcf fix: repair streaming output broken by markdown rendering changes
Two bugs were introduced when sendTextMessage was changed to auto-detect
message format (text/post/interactive):

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

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

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

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

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

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

ADR-0018: 状态改为 Accepted+implemented,机制段写定 SDK 沙箱,网络决定为开放,
Open Questions 更新。
2026-07-08 13:11:14 +08:00
hongjr03 6178664696 fix(hub): send Feishu files explicitly 2026-07-08 11:48:25 +08:00
hongjr03 c73b41e1da fix(hub): resume Claude SDK sessions 2026-07-08 11:48:20 +08:00
hongjr03 012c8a7d89 fix(hub): default to OpenRouter Sonnet model 2026-07-08 11:48:16 +08:00
hongjr03 250f918346 chore: delete dead code 2026-07-08 02:37:44 +08:00
hongjr03 30d20421b3 fix(hub): busy 表情从 Hourglass 改为 OnIt(飞书支持的 emoji_type) 2026-07-08 02:22:05 +08:00
hongjr03 20bf7e271f fix(hub): 启动重置状态 + lazy-init 修复空白消息 + busy 改表情
三个修复:
1. 启动时清锁+标死run为FAILED(防重启后卡死)
2. 空白消息修复:lazy-init 改为第一个 text-delta 立即创建消息
  (不走 throttle);.then 加 fallback:没收到 text-delta 时用
  result.text 发完整回复
3. busy 提示从文本'项目正在处理中'改为表情回复

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

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

tsc rc=0。
2026-07-08 02:13:51 +08:00
hongjr03 085f7c7186 fix(hub): 去掉冗余 OPENROUTER_API_KEY,直接用 ANTHROPIC_AUTH_TOKEN 2026-07-08 01:48:01 +08:00
hongjr03 b9f50407be fix(hub): Claude Code SDK 经 OpenRouter Anthropic Skin,保留 provider-agnostic
我之前判断错了——OpenRouter 有 Anthropic Messages API 兼容端点
(https://openrouter.ai/api),Claude Code SDK 直连不需要代理。
ANTHROPIC_AUTH_TOKEN=OpenRouter key,ANTHROPIC_API_KEY='' 即可。

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

ADR-0017 重写:从'Anthropic 专有'改为'Claude Code SDK via OpenRouter,
保留 provider-agnostic'。tsc rc=0。
2026-07-08 01:46:09 +08:00
hongjr03 4bd5b03bb1 refactor(hub): Vercel AI SDK → Claude Code SDK
用 @anthropic-ai/claude-agent-sdk 的 query() 替换 Vercel AI SDK 的
streamText。SDK 自带 Read/Write/Bash/Glob/Grep 工具——read_file/
write_file/list_files/cph_check/cph_build 全部不需要了,Bash 跑
cph 直接。

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

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

tsc rc=0。旧文件(workspace.ts/cph.ts/provider.ts/tools.ts)暂留,
下个 commit 清理。
2026-07-08 01:36:59 +08:00
hongjr03 e7924f78d5 feat(hub): 结构化运行历史(A-3) + lock 心跳(A-5)
A-3 结构化历史:
- 新增 AgentMessage 表(每条消息的 queryable 投影,transcript 文件仍是 agent 读回记忆)
- 新增 AgentFileChange 表(run 期间文件变更 before/after hash + operation)
- runner.ts: generateText 后 persistAgentMessages 落库 result.responseMessages
- workspace.ts writeFileTool: 写前算 beforeHash(SHA-256),写后算 afterHash,通过 ctx.onFileChange sink 记录
- runner.ts flushFileChanges: 收集 sink 的变更批量写入 AgentFileChange
- ToolContext 加 onFileChange sink 字段
- trigger.ts 接线 runId/sessionId/prisma 到 RunRequest

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

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

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

59 tests 全过。
2026-07-07 21:34:15 +08:00
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 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