forked from EduCraft/curriculum-project-hub
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b7aa3294f | |||
| 5d315fff21 | |||
| 716101eed0 | |||
| fc5a5365d8 | |||
| fc0df7b823 | |||
| f1d29176d3 | |||
| f260195439 | |||
| d01ea0e1cb | |||
| d6724e5a83 | |||
| 346aee5a68 | |||
| 491fd13ca8 | |||
| 79505e6d44 | |||
| a0df8b6bcf | |||
| 36c0801e6f | |||
| 4479f88f4f | |||
| 2736006262 | |||
| 294d51dbfe | |||
| bef10149a9 | |||
| 4736610cf3 | |||
| ad65d93b2e | |||
| 6227e1931b | |||
| a00e4f9871 | |||
| a025d23af8 | |||
| dfcc2d70f8 | |||
| 3f486232a8 | |||
| a766d99573 | |||
| 1cfda3ccd2 | |||
| ead5cf04d6 | |||
| ecbc2c8666 | |||
| 5b7fd70124 | |||
| 6178664696 | |||
| c73b41e1da | |||
| 012c8a7d89 | |||
| 250f918346 | |||
| 30d20421b3 | |||
| 20bf7e271f | |||
| eaa7fd4fd8 | |||
| 1ad78dbcce | |||
| 085f7c7186 | |||
| b9f50407be | |||
| 082562ca08 | |||
| 4bd5b03bb1 | |||
| 1abcc495f4 | |||
| e7924f78d5 |
@@ -0,0 +1,132 @@
|
||||
name: hub check
|
||||
|
||||
# Builds, type-checks, and tests the Hub TS package under hub/.
|
||||
# The Hub is the Feishu-group collaboration + agent runtime half
|
||||
# (spec/System implementation). This is an INTERNAL gate on the Hub's own
|
||||
# health, like checker-check is for the Rust half.
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
hub-check:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_USER: paradigm
|
||||
POSTGRES_PASSWORD: paradigm
|
||||
POSTGRES_DB: cph_hub_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U paradigm -d cph_hub_test"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
defaults:
|
||||
run:
|
||||
working-directory: hub
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
cache-dependency-path: hub/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Wait for Postgres
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const net = require("node:net");
|
||||
const deadline = Date.now() + 60000;
|
||||
function tryConnect() {
|
||||
const socket = net.createConnection({ host: "127.0.0.1", port: 5432 });
|
||||
socket.once("connect", () => {
|
||||
socket.end();
|
||||
process.exit(0);
|
||||
});
|
||||
socket.once("error", () => {
|
||||
socket.destroy();
|
||||
if (Date.now() > deadline) {
|
||||
console.error("Postgres did not become reachable at 127.0.0.1:5432");
|
||||
process.exit(1);
|
||||
}
|
||||
setTimeout(tryConnect, 1000);
|
||||
});
|
||||
}
|
||||
tryConnect();
|
||||
NODE
|
||||
|
||||
# Prisma client must be generated before tsc can check imports from
|
||||
# @prisma/client. Stub DATABASE_URL so prisma generate works.
|
||||
- name: Generate Prisma client
|
||||
run: DATABASE_URL="postgresql://stub:stub@127.0.0.1:5432/stub" npx prisma generate --schema prisma/schema.prisma
|
||||
|
||||
- name: Type-check
|
||||
run: npx tsc -p tsconfig.json --noEmit
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Validate Prisma schema
|
||||
run: DATABASE_URL="postgresql://stub:stub@127.0.0.1:5432/stub" npx prisma validate --schema prisma/schema.prisma
|
||||
|
||||
- name: Install cph binary
|
||||
run: |
|
||||
cd ..
|
||||
cargo install --path crates/cph-cli --locked
|
||||
|
||||
- name: Run unit tests
|
||||
run: npx vitest run test/unit
|
||||
|
||||
# Integration tests need PostgreSQL + cph. cph is installed above.
|
||||
# PostgreSQL is set up as a service container below.
|
||||
- name: Run integration tests (mock provider, real prisma + cph)
|
||||
run: |
|
||||
npx prisma migrate deploy --schema prisma/schema.prisma
|
||||
npx vitest run test/integration --exclude test/integration/real-model.test.ts
|
||||
env:
|
||||
DATABASE_URL: postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test
|
||||
|
||||
- name: Smoke health endpoint
|
||||
run: |
|
||||
PORT=8878 \
|
||||
NODE_ENV=production \
|
||||
DATABASE_URL=postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test \
|
||||
ANTHROPIC_AUTH_TOKEN=smoke \
|
||||
FEISHU_APP_ID=cli_smoke \
|
||||
FEISHU_APP_SECRET=smoke_secret \
|
||||
FEISHU_BOT_OPEN_ID=ou_smoke \
|
||||
HUB_FEISHU_LISTENER_ENABLED=false \
|
||||
npm start &
|
||||
pid=$!
|
||||
trap 'kill "$pid" 2>/dev/null || true' EXIT
|
||||
for attempt in $(seq 1 30); do
|
||||
if curl --fail --silent "http://127.0.0.1:8878/api/healthz" >/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
echo "hub server exited before health check passed"
|
||||
wait "$pid"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
curl --fail --silent --show-error "http://127.0.0.1:8878/api/healthz" >/dev/null
|
||||
|
||||
# Real-model tests are opt-in: set RUN_REAL_MODEL_TESTS=true and provide
|
||||
# OPENROUTER_API_KEY when a branch should hit live OpenRouter.
|
||||
- name: Run real-model integration tests (optional, needs secret)
|
||||
run: npx vitest run test/integration/real-model.test.ts
|
||||
env:
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
RUN_REAL_MODEL_TESTS: ${{ vars.RUN_REAL_MODEL_TESTS }}
|
||||
@@ -0,0 +1,22 @@
|
||||
# AGENTS.md —— agent 操作手册(全 repo)
|
||||
|
||||
本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
|
||||
|
||||
## 这个 repo 是什么
|
||||
|
||||
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照。
|
||||
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
|
||||
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
|
||||
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
|
||||
|
||||
## 纪律
|
||||
|
||||
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。契约里 prose doc 注释是语义的唯一权威来源;契约没写的,就是没定的。
|
||||
|
||||
2. **凡契约未写明者,不得假设。** 遇到标了 `OPEN` 的地方,或契约根本没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
|
||||
|
||||
3. **改 `spec/` 必须保持其 `lake build` 通过。** 在 `spec/` 目录下跑 `lake build`。新增声明必须带 `/-- … -/` doc 注释和恰当标签(`PINNED` / `OPEN` / `ADR-NNNN`)。规范见 `spec/README.md`。不准用 `sorry` 把 build 糊绿。
|
||||
|
||||
4. **实现向契约对齐;偏离必须 surface。** 没有 CI gate 替你把关 spec↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与契约不一致时,报告它,不要默默让其中一边将就另一边。
|
||||
|
||||
5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照。
|
||||
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
|
||||
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
|
||||
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
|
||||
|
||||
## 纪律
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# ADR 0017: AgentSession Is Provider-Bound
|
||||
# ADR 0017: Agent Runtime — Claude Code SDK via OpenRouter
|
||||
|
||||
## Status
|
||||
|
||||
@@ -6,45 +6,62 @@ Accepted.
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0002 says a long-lived `AgentSession` can be reused by many runs, but never
|
||||
addressed the provider/model dimension. The agent layer is provider-agnostic
|
||||
(ADR-0001..0003 never required Claude specifically; the `@Claude` trigger name
|
||||
is a product brand, not a provider commitment). In practice the Hub routes
|
||||
model calls through an OpenAI-compatible client (e.g. OpenRouter), so a run may
|
||||
target different models — and the motivation for multi-model is **role-based
|
||||
routing**: drafting, reviewing, and triage suit different models.
|
||||
The original ADR-0017 mandated a provider-agnostic agent layer: any
|
||||
OpenAI-compatible model via OpenRouter, custom agent loop built on Vercel AI
|
||||
SDK's `streamText`. The motivation was to avoid vendor lock-in.
|
||||
|
||||
That raises the question ADR-0002 left open: when a teacher switches model
|
||||
mid-project, does the `AgentSession` carry live context across the switch, or
|
||||
is it bound to a single provider/model?
|
||||
In practice, the hand-rolled agent loop lacked capabilities that a production
|
||||
agent needs: context compaction (long conversations), tool approval flows,
|
||||
partial-message streaming, and a battle-tested multi-turn loop. Building these
|
||||
ourselves meant re-implementing what the Claude Code SDK already provides.
|
||||
|
||||
The Feishu integration also proved costly to hand-roll — card schema quirks,
|
||||
patch limitations, file support — each API quirk cost a round-trip.
|
||||
|
||||
## Decision
|
||||
|
||||
An `AgentSession` is **provider/model-bound**. A model or provider switch
|
||||
starts a new `AgentSession`. The `AgentSession` does not carry live context
|
||||
across a switch.
|
||||
Adopt `@anthropic-ai/claude-agent-sdk` as the agent runtime, routed through
|
||||
OpenRouter's **"Anthropic Skin"** (`https://openrouter.ai/api`).
|
||||
|
||||
Cross-session continuity is not the session's job — it is carried by ADR-0003's
|
||||
project memory and anchors, which the Hub reads on demand when seeding a new
|
||||
run. This keeps B consistent with ADR-0003 ("the Hub avoids becoming a full
|
||||
chat history store"): session continuity and history continuity are the same
|
||||
mechanism, both via memory/anchors, never via a live cross-provider session.
|
||||
OpenRouter exposes an Anthropic Messages API-compatible endpoint. Claude Code
|
||||
SDK speaks its native protocol directly to OpenRouter — no proxy, no format
|
||||
conversion. The SDK's `query()` owns the agent loop (tool dispatch, compaction,
|
||||
streaming). Built-in tools (Read, Write, Bash, Glob, Grep) cover the entire
|
||||
tool surface — no custom tools needed. `cph check` / `cph build` run via Bash.
|
||||
|
||||
Per-run model selection (the run carries its model) is the switching point.
|
||||
The Hub `AgentSession` is provider/role/model-bound, and it must persist the
|
||||
provider runtime cursor needed to continue a conversation. For Claude Code SDK,
|
||||
that cursor is the `result.session_id`; store it in `AgentSession.metadata` as
|
||||
`claudeSessionId` and pass it back to the next `query()` call as
|
||||
`options.resume`. Role is part of the session binding because role prompts and
|
||||
tool surfaces can differ even when the underlying model is the same; `/draft`
|
||||
and `/review` must not resume the same Claude runtime cursor by accident.
|
||||
|
||||
Environment variables:
|
||||
```
|
||||
ANTHROPIC_BASE_URL=https://openrouter.ai/api
|
||||
ANTHROPIC_AUTH_TOKEN=<OpenRouter API key>
|
||||
ANTHROPIC_API_KEY="" # must be explicitly empty
|
||||
```
|
||||
|
||||
Model routing: `ANTHROPIC_DEFAULT_SONNET_MODEL` etc. accept OpenRouter model
|
||||
IDs (e.g. `z-ai/glm-4.7`, `anthropic/claude-sonnet-4-20250514`). This gives
|
||||
limited OpenRouter-level model routing, but the runtime is still Claude Agent
|
||||
SDK-shaped: non-Claude models are best-effort and may not support every
|
||||
Claude Code feature.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Switching model mid-project = new session; the new run seeds from project
|
||||
memory/anchors (ADR-0003), not the prior session's live context.
|
||||
- The agent layer is provider-agnostic: 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.
|
||||
- Provider-agnosticism is not fully preserved. OpenRouter can route to GLM,
|
||||
Claude, GPT, etc., but the runtime protocol and agent loop remain Claude
|
||||
Agent SDK-bound.
|
||||
- The agent loop is Claude Code SDK's (compaction, tool approval, partial
|
||||
message streaming) — not hand-rolled.
|
||||
- Custom tools (read_file, write_file, cph_check, cph_build) are replaced by
|
||||
the SDK's built-in Read/Write/Bash/Glob/Grep.
|
||||
- The SSE event format matches Claude-to-IM's expected protocol natively.
|
||||
- Per-run model selection works via role→model mapping; models are OpenRouter
|
||||
IDs, not Anthropic-only aliases.
|
||||
- OpenRouter recommends setting Anthropic as the top-priority provider for
|
||||
Claude Code; non-Anthropic models may have reduced compatibility with some
|
||||
SDK features (e.g. thinking blocks).
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
# ADR 0018: The Agent Execution Surface Is Bounded By The Project Workspace
|
||||
|
||||
## Status
|
||||
|
||||
Accepted, and implemented. The mechanism is settled: the Claude Code SDK's
|
||||
built-in sandbox (bubblewrap on Linux, seatbelt on macOS) confines the agent
|
||||
process and its Bash subprocesses at the OS level. `bypassPermissions`
|
||||
remains (headless server — no interactive prompts), but the sandbox is the
|
||||
hard boundary, not the permission-prompt layer. Constitution rule 4
|
||||
divergence (ADR-0017's unbounded tools) is resolved.
|
||||
|
||||
Builds on **ADR-0001** (project group as collaboration space), **ADR-0002**
|
||||
(project lock scoped to `run_id`), **ADR-0004** (permission grants govern
|
||||
`triggerAgent`), **ADR-0007** (the engineering file is a directory tree on
|
||||
disk), and **ADR-0017** (Claude Code SDK as the agent runtime).
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0001/0002/0004 together cover *collaboration governance* up to the
|
||||
moment a teacher `@Claude`s: who may trigger an `AgentRun`, that the run
|
||||
holds a project-scoped lock, that the group is a long-lived space. They stop
|
||||
at `triggerAgent`. What the agent may do *after* the trigger — which files it
|
||||
may read or write, what commands it may run — is not named in any ADR, not
|
||||
modeled in `spec/`, and not enforced by any contract-level invariant.
|
||||
|
||||
The gap is not theoretical. ADR-0017 adopted the Claude Code SDK with
|
||||
`allowedTools: ["Read", "Write", "Bash", "Glob", "Grep"]` and
|
||||
`permissionMode: "bypassPermissions"`. Under that configuration the SDK's
|
||||
built-in tools operate over the whole host filesystem with no confinement, and
|
||||
`Bash` executes arbitrary shell with no approval gate. The Hub's
|
||||
`hub/src/agent/workspace.ts` was *intended* to be "the agent's only file
|
||||
surface" (its module doc says so) and ships a `confine()` path validator that
|
||||
rejects `..` and absolute escapes. But ADR-0017's cutover left those custom
|
||||
tools unwired — the SDK's built-in tools replaced them — so `confine()` is
|
||||
dead code and the boundary it was meant to enforce does not exist at runtime.
|
||||
Compounding this, the systemd unit's default `SERVICE_USER` is `root`
|
||||
(`hub/deploy/install_service.sh`), so the unbounded agent runs as root.
|
||||
|
||||
This is a real divergence point by the constitution's test: "if unwritten,
|
||||
will the developer and the agent make different assumptions?" They already
|
||||
have. ADR-0017 assumed the boundary was an implementation concern; the
|
||||
`workspace.ts` author assumed it was a contract. The next person to touch the
|
||||
runner has nothing telling them the boundary must be enforced, and ADR-0017
|
||||
silently overrode the one that existed.
|
||||
|
||||
## Decision
|
||||
|
||||
### The agent execution surface is bounded by the run's project workspace
|
||||
|
||||
During an `AgentRun`, every file operation the agent performs — read, write,
|
||||
glob, grep target — must fall **within the run's project workspace directory**
|
||||
(the on-disk engineering-file tree of ADR-0007). Operations whose target path
|
||||
escapes that tree are refused. This is a platform core invariant, pinned in
|
||||
`Spec.System.AgentSurface`.
|
||||
|
||||
The boundary is **per-run, anchored to the run's project**, the same scope as
|
||||
the Lock (ADR-0002). This keeps the two invariants composable:
|
||||
|
||||
- `Lock` (ADR-0002) bounds **concurrency** — who may be modifying the project.
|
||||
- `AgentSurface` (this ADR) bounds **blast radius** — what the agent may touch.
|
||||
|
||||
Both scope to `run × project`. Acquiring the lock and entering the surface
|
||||
happen together at run start; releasing the lock and leaving the surface
|
||||
happen together at run termination.
|
||||
|
||||
### Shell execution is subject to the same boundary, by effect
|
||||
|
||||
A shell command the agent runs executes with the workspace as its working
|
||||
directory (already true for the `cph` subprocess, ADR-0016) and its **file
|
||||
effects** — files read, written, created, or deleted — are subject to the same
|
||||
workspace boundary. The agent may invoke **named external tools** whose
|
||||
binaries live outside the workspace (notably `cph` via `CPH_BIN`); the
|
||||
boundary governs *file effects*, not *binary location*. Arbitrary
|
||||
`cat /etc/shadow` or `curl … > /etc/...` is out of bounds regardless of how it
|
||||
is invoked.
|
||||
|
||||
### The Hub process runs as a non-privileged service user
|
||||
|
||||
The systemd unit must not run as `root`. The default in
|
||||
`hub/deploy/install_service.sh` changes from `root` to a dedicated unprivileged
|
||||
user. This is a deployment invariant, recorded here because the unbounded-agent
|
||||
risk is compounded by root; it is enforced in the unit, not in `spec/` (it is
|
||||
not a semantic divergence between developer and agent — it is operational
|
||||
discipline).
|
||||
|
||||
### The mechanism: SDK sandbox (OS-level, per agent process)
|
||||
|
||||
The boundary is enforced by the Claude Code SDK's built-in sandbox
|
||||
(bubblewrap on Linux, seatbelt on macOS), configured via `query()` options in
|
||||
`hub/src/agent/runner.ts`:
|
||||
|
||||
- `sandbox.enabled: true` + `failIfUnavailable: true` — the agent process and
|
||||
its Bash subprocesses run sandboxed; if the sandbox can't start, `query()`
|
||||
emits an error and exits rather than running unsandboxed.
|
||||
- `sandbox.filesystem.allowWrite: [workspaceDir]` — writes confined to the
|
||||
workspace (the ADR-0007 directory tree). Reads outside are allowed by
|
||||
default (the agent may read system files for context), but sensitive host
|
||||
paths are denied via `denyRead` (`/etc`, `/root`, `~/.ssh`, `~/.aws`, …).
|
||||
- `sandbox.filesystem.denyRead: SENSITIVE_READ_PATHS` — defense-in-depth;
|
||||
extends via `CPH_SANDBOX_EXTRA_DENY_READ` for deployment-specific secrets.
|
||||
- Network: open (see Open Questions).
|
||||
|
||||
`bypassPermissions` is kept (headless server — no interactive prompts); the
|
||||
sandbox is the hard boundary, not the permission-prompt layer. This is
|
||||
subprocess-level, not process-level: the Hub host process is not sandboxed
|
||||
(it manages DB, Feishu ws, logs), only the Claude Code agent process and its
|
||||
children. This keeps the sandbox config surface small and lets future
|
||||
external CLIs (Gitea, Lark) run inside the sandbox by giving their binaries
|
||||
read access, without widening the Hub's own filesystem surface.
|
||||
|
||||
The contract pins the **invariant** (operations stay in the workspace), not
|
||||
the **mechanism**. The SDK sandbox is the current mechanism; switching to a
|
||||
different one (tool wrappers, container) would not be a spec change as long
|
||||
as it upholds `AgentFileOp.Authorized`.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `Spec.System.AgentSurface` gains an `AgentFileOp` structure and an
|
||||
`Authorized` predicate mirroring `Memory.McpReadRequest.Authorized`: the op
|
||||
carries a `run` and a `path`; `Authorized` holds iff the path falls within
|
||||
the run's workspace, via platform-provided `runWorkspace` and `pathWithin`
|
||||
(representations `OPEN`).
|
||||
- `hub/src/agent/workspace.ts`'s `confine()` intent is now contract-backed.
|
||||
Re-wiring it (or replacing it with an OS sandbox) is **mandatory**, not a
|
||||
preference. Its current dead-code status under ADR-0017 is a divergence to
|
||||
be addressed, not silently retained.
|
||||
- ADR-0017's `bypassPermissions` + unrestricted built-in tools is a
|
||||
**divergence from this ADR** and is surfaced as such (constitution rule 4).
|
||||
The reconciliation — restrict the SDK tool set, wrap Bash, or sandbox at the
|
||||
OS level — is the open mechanism question above. Whichever is chosen must
|
||||
uphold the `AgentSurface` invariant.
|
||||
- Inbound file/image delivery (`hub/src/feishu/trigger.ts`) lands files under
|
||||
`${workspaceDir}/.cph/inbox/`, which is inside the boundary — consistent, no
|
||||
change to the delivery path.
|
||||
- The `cph` subprocess (ADR-0016) already runs with `cwd = workspaceDir`
|
||||
(`hub/src/agent/cph.ts`), so it is inside the boundary by construction.
|
||||
|
||||
## Open Questions / Deferred
|
||||
|
||||
- **Network egress.** Decided: network is **open** in the initial
|
||||
production deployment. The sandbox confines filesystem writes to the
|
||||
workspace and denies reads of sensitive host paths, but does not restrict
|
||||
outbound network. Rationale: future Gitea/Lark CLI integration needs
|
||||
unconstrained network egress; `cph build` may fetch typst packages. The
|
||||
exfiltration risk (agent `curl`s workspace content out) is accepted as a
|
||||
tradeoff for CLI integration simplicity. This is revisitable — a network
|
||||
allowlist (`sandbox.network.allowedDomains`) can be added without a spec
|
||||
change if the threat model tightens.
|
||||
- **Sensitive read path coverage.** `SENSITIVE_READ_PATHS` in `runner.ts`
|
||||
covers `/etc`, `/root`, `/var/log`, `/proc`, `/sys`, and common
|
||||
credential dirs (`~/.ssh`, `~/.aws`, `~/.config/gcloud`, `~/.gnupg`).
|
||||
Deployments with additional secret locations extend via
|
||||
`CPH_SANDBOX_EXTRA_DENY_READ` (colon-separated). Whether this set should be
|
||||
spec-pinned rather than implementation-chosen is `OPEN` — it's plumbing, not
|
||||
a semantic divergence, so currently left out of `spec/`.
|
||||
- **Shell command vocabulary.** Whether the agent's Bash is restricted to a
|
||||
whitelist (e.g. `cph`, `ls`, `grep`) or allowed arbitrary commands whose
|
||||
*effects* are then confined, is a policy choice left to implementation. The
|
||||
invariant governs effects either way; current choice is arbitrary commands
|
||||
(sandbox confines effects).
|
||||
- **Per-user rate limiting and token budgets.** Orthogonal to the surface
|
||||
boundary (this ADR is about *what* the agent may touch, not *how often* it
|
||||
may run). Deferred to a separate concern.
|
||||
- **Inbound file size / MIME limits.** The inbox lives inside the boundary,
|
||||
but unbounded file size is a DoS vector distinct from the boundary
|
||||
invariant. Deferred.
|
||||
@@ -0,0 +1,129 @@
|
||||
# ADR 0019: Resolve Actors to Principal Sets for Team Permissions
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0004 deliberately chose a Feishu Docs-like permission model:
|
||||
`grant(resource, principal, role)` plus separate resource settings. The first
|
||||
Hub implementation stored `principal` as an opaque string and the Feishu trigger
|
||||
used the sender's `open_id` directly. That was enough for individual teacher
|
||||
grants, but not for production collaboration:
|
||||
|
||||
- a curriculum team needs one grant to apply to many teachers;
|
||||
- Feishu departments, user groups, and project chats should be grantable
|
||||
principals;
|
||||
- role-specific agent grants (`/review`, `/draft`) must compose with project
|
||||
permissions consistently;
|
||||
- audit/debug output must explain which principal and grant allowed or denied a
|
||||
run.
|
||||
|
||||
## Decision
|
||||
|
||||
Introduce typed principals and resolve every actor to a principal set before
|
||||
authorization.
|
||||
|
||||
### Principal types
|
||||
|
||||
`PrincipalType` is:
|
||||
|
||||
- `USER`: a Feishu user, identified by `feishuOpenId`.
|
||||
- `TEAM`: a Hub-managed teacher team.
|
||||
- `FEISHU_CHAT`: a Feishu chat/group.
|
||||
- `FEISHU_DEPARTMENT`: a Feishu department.
|
||||
- `FEISHU_USER_GROUP`: a Feishu user group.
|
||||
- `APP`: an integration or bot principal.
|
||||
|
||||
`PermissionGrant` and `RoleTriggerGrant` store `principalType` and
|
||||
`principalId`. Legacy opaque principal strings are backfilled as
|
||||
`USER/<old principal>`.
|
||||
|
||||
### Team membership
|
||||
|
||||
Hub teams are first-class. A user can be a direct active member of a team. A
|
||||
team can also be bound to external Feishu principals: chat, department, or user
|
||||
group. If an actor resolves to any bound external principal, the actor also
|
||||
resolves to that Hub team.
|
||||
|
||||
Membership changes are effective immediately because authorization resolves the
|
||||
principal set at request time.
|
||||
|
||||
### External Feishu principal sync
|
||||
|
||||
Feishu department, user group, and chat memberships are not scattered through
|
||||
callers. They are synchronized into `ExternalPrincipalMembership` rows and then
|
||||
read by `PrincipalResolver`. The Feishu message context can also contribute the
|
||||
current `FEISHU_CHAT` principal directly because receiving a group event is
|
||||
already contextual proof that the actor is speaking in that group.
|
||||
|
||||
### Grant merging
|
||||
|
||||
Authorization evaluates all active grants matching any resolved principal for
|
||||
the requested resource. There is no explicit deny in this ADR. Among matching
|
||||
grants, the highest role wins:
|
||||
|
||||
```text
|
||||
READ < EDIT < MANAGE
|
||||
```
|
||||
|
||||
Action thresholds:
|
||||
|
||||
- project read requires `READ`.
|
||||
- project edit requires `EDIT`.
|
||||
- collaborator management requires `MANAGE`.
|
||||
- agent trigger requires `EDIT`.
|
||||
- normal agent cancel requires `MANAGE`.
|
||||
|
||||
`PermissionSettings` never grants access beyond grants. Settings only constrain
|
||||
an already-granted capability. For `agentTrigger`:
|
||||
|
||||
- missing setting or `ROLE` means role-derived grants decide;
|
||||
- `MANAGE_ONLY` raises the threshold to `MANAGE`;
|
||||
- `DISABLED` denies the action.
|
||||
|
||||
Other settings keep their existing string storage, but the same rule applies:
|
||||
settings are policy constraints, not positive grants.
|
||||
|
||||
### Role-trigger composition
|
||||
|
||||
Role-trigger grants are a second gate after the project-level `agent.trigger`
|
||||
gate:
|
||||
|
||||
1. The actor must pass project `agent.trigger` for the project.
|
||||
2. If no `RoleTriggerGrant` row has ever existed for `(projectId, roleId)`,
|
||||
the role is open for backward compatibility.
|
||||
3. Once any row exists for `(projectId, roleId)`, including a revoked row, the
|
||||
role is configured. The actor must match an active role grant by any resolved
|
||||
principal.
|
||||
|
||||
This keeps `/review` restrictions orthogonal to ordinary edit access while
|
||||
preventing a fully revoked role from silently reopening.
|
||||
|
||||
### Module seam
|
||||
|
||||
Callers use a single authorization module:
|
||||
|
||||
```ts
|
||||
authorizer.can({
|
||||
actor,
|
||||
action,
|
||||
resource,
|
||||
roleId,
|
||||
})
|
||||
```
|
||||
|
||||
The caller does not know how users, teams, Feishu departments, user groups, or
|
||||
chats expand. `PrincipalResolver` owns that implementation, and
|
||||
`PermissionAuthorizer` owns grant/settings/role-trigger composition.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Teacher teams become production-grade principals rather than UI sugar.
|
||||
- Feishu external organization concepts can be synchronized and granted without
|
||||
leaking Feishu-specific checks into the trigger path.
|
||||
- A single audit decision can report actor, principal set, matched grant, and
|
||||
matched role grant.
|
||||
- Backward compatibility is explicit: old principal strings become user
|
||||
principals, and unconfigured role grants remain open.
|
||||
@@ -0,0 +1,92 @@
|
||||
# ADR 0020: Organization Is The SaaS Tenant Root
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
## Context
|
||||
|
||||
The Hub is moving from a single-organization deployment assumption toward a
|
||||
SaaS shape. The existing permission model already supports team principals via
|
||||
`PermissionGrant(PROJECT, TEAM, role)`, but without a tenant root the data model
|
||||
cannot safely answer:
|
||||
|
||||
- which customer owns a project;
|
||||
- which organization a team belongs to;
|
||||
- whether a team grant crosses customer boundaries;
|
||||
- which external directory sync produced a Feishu department/user-group
|
||||
membership;
|
||||
- where future org admin, billing, audit, and platform break-glass controls
|
||||
attach.
|
||||
|
||||
Retrofitting this after projects, teams, runs, and audit records grow would
|
||||
force tenant assumptions into every caller.
|
||||
|
||||
## Decision
|
||||
|
||||
Introduce `Organization` as the SaaS tenant root.
|
||||
|
||||
Customer-side collaboration data is organization-scoped:
|
||||
|
||||
```text
|
||||
Organization
|
||||
OrganizationMembership
|
||||
Team
|
||||
TeamMembership
|
||||
Project
|
||||
ExternalDirectoryConnection
|
||||
PermissionGrant
|
||||
```
|
||||
|
||||
`User` remains global. A user can belong to multiple organizations through
|
||||
`OrganizationMembership`. Project-level authorization still uses
|
||||
`PermissionGrant` and `PermissionAuthorizer`; the authorizer derives the
|
||||
organization from the requested resource, then resolves teams and external
|
||||
principals in that organization scope.
|
||||
|
||||
`Team` belongs to exactly one organization. `Project` belongs to exactly one
|
||||
organization. A `PermissionGrant` that grants a `TEAM` principal on a `PROJECT`
|
||||
resource is valid only when the team and project are in the same organization.
|
||||
This invariant is pinned in `Spec.System.Organization`.
|
||||
|
||||
External directory membership is scoped through `ExternalDirectoryConnection`:
|
||||
|
||||
```text
|
||||
ExternalDirectoryConnection(
|
||||
organizationId,
|
||||
provider,
|
||||
providerTenantId,
|
||||
source
|
||||
)
|
||||
```
|
||||
|
||||
`ExternalPrincipalMembership` points at a connection rather than carrying a
|
||||
free-floating `source` string. `source` names a sync pipeline; it is not the
|
||||
tenant identity.
|
||||
|
||||
The SaaS platform control plane is separate from customer project permissions.
|
||||
`READ/EDIT/MANAGE` remains the customer-side project permission lattice.
|
||||
Platform staff access, tenant suspension, billing, and break-glass access are a
|
||||
separate control plane and must not be modeled as project `MANAGE`.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Existing single-org data is backfilled into a default organization during
|
||||
migration.
|
||||
- `Team.slug` becomes organization-scoped instead of globally unique.
|
||||
- Actor resolution is organization-aware: direct teams and synchronized external
|
||||
principals only expand inside the resource's organization.
|
||||
- Direct `USER` grants remain possible because a global user can collaborate
|
||||
across organizations when explicitly granted.
|
||||
- Future org admin UI can be built on this model without changing the
|
||||
authorization core.
|
||||
|
||||
## Open Questions / Deferred
|
||||
|
||||
- Full platform admin UI, billing, limits, and tenant suspension workflows are
|
||||
deferred.
|
||||
- Whether direct `USER` project grants should require active
|
||||
`OrganizationMembership` is deferred; explicit direct grants remain allowed
|
||||
for now.
|
||||
- Provider-specific directory semantics beyond Feishu are deferred, but the
|
||||
connection model leaves room for them.
|
||||
@@ -0,0 +1 @@
|
||||
Hello, World!
|
||||
+22
-12
@@ -1,23 +1,33 @@
|
||||
# Hub runtime configuration. Copy to .env and fill in.
|
||||
|
||||
# PostgreSQL connection string. Used by Prisma and the Hub server.
|
||||
# PostgreSQL connection string.
|
||||
DATABASE_URL="postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm"
|
||||
|
||||
# OpenRouter (or any OpenAI-compatible) API key + base URL.
|
||||
# The Hub's agent layer is provider-agnostic (ADR-0017); this points the
|
||||
# OpenAI-compatible client at OpenRouter by default.
|
||||
OPENROUTER_API_KEY=""
|
||||
# Optional: override the base URL (e.g. direct vendor API, local gateway).
|
||||
# OPENROUTER_BASE_URL="https://openrouter.ai/api/v1"
|
||||
# Claude Code SDK auth via OpenRouter's Anthropic Skin.
|
||||
# ANTHROPIC_AUTH_TOKEN = your OpenRouter API key (https://openrouter.ai/keys)
|
||||
# ANTHROPIC_API_KEY must be explicitly empty to avoid auth conflicts.
|
||||
ANTHROPIC_BASE_URL="https://openrouter.ai/api"
|
||||
ANTHROPIC_AUTH_TOKEN=""
|
||||
ANTHROPIC_API_KEY=""
|
||||
|
||||
# Hub server port. Defaults to 8788.
|
||||
PORT=8788
|
||||
# Model override (optional). Defaults to anthropic/claude-sonnet-5.
|
||||
# Use an OpenRouter model slug, for example:
|
||||
# ANTHROPIC_DEFAULT_SONNET_MODEL="~anthropic/claude-sonnet-latest"
|
||||
# To use GLM:
|
||||
# ANTHROPIC_DEFAULT_SONNET_MODEL="z-ai/glm-4.7"
|
||||
# ANTHROPIC_DEFAULT_OPUS_MODEL="z-ai/glm-4.7"
|
||||
# ANTHROPIC_DEFAULT_HAIKU_MODEL="z-ai/glm-4.6"
|
||||
|
||||
# Feishu (lark) bot credentials. The bot receives @mentions in project groups.
|
||||
# Agent run policy (optional). Defaults to 25.
|
||||
# HUB_AGENT_MAX_TURNS=25
|
||||
|
||||
# Feishu (lark) bot credentials.
|
||||
FEISHU_APP_ID=""
|
||||
FEISHU_APP_SECRET=""
|
||||
FEISHU_BOT_OPEN_ID=""
|
||||
|
||||
# Path to the `cph` binary (the Courseware-side checker, ADR-0016).
|
||||
# Defaults to `cph` on PATH if unset.
|
||||
# Path to the `cph` binary (ADR-0016). Defaults to `cph` on PATH.
|
||||
# CPH_BIN="/usr/local/bin/cph"
|
||||
|
||||
# Hub server port. Defaults to 8788.
|
||||
PORT=8788
|
||||
|
||||
@@ -16,6 +16,14 @@ RestartSec=5
|
||||
# Graceful shutdown: lark ws close + in-flight runs.
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=30
|
||||
# ADR-0018: the agent sandbox (bubblewrap) must be available on the host —
|
||||
# failIfUnavailable makes the Hub refuse to run unsandboxed. Install bwrap.
|
||||
AssertPathExists=/usr/bin/bwrap
|
||||
# Hardening: no new privileges. ProtectSystem/ProtectHome are NOT set here —
|
||||
# they would break cph's render-cache extraction (~/.cache/cph/) and the
|
||||
# bwrap user-namespace setup. The OS-level sandbox (ADR-0018) is the hard
|
||||
# boundary; systemd hardening is kept minimal to avoid interfering with it.
|
||||
NoNewPrivileges=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -7,12 +7,11 @@
|
||||
# PLATFORM_DEPLOY_USER optional, defaults to deploy
|
||||
# PLATFORM_DEPLOY_PORT optional, defaults to 22
|
||||
# PLATFORM_DEPLOY_BASE optional, defaults to /srv/curriculum-project-hub
|
||||
#
|
||||
# The target host must have Node.js, npm, rsync, PostgreSQL, systemd, and a
|
||||
# writable $PLATFORM_DEPLOY_BASE. Use a dedicated `deploy` user that has
|
||||
# passwordless sudo for:
|
||||
# systemctl restart cph-hub.service
|
||||
# systemctl is-active --quiet cph-hub.service
|
||||
# PLATFORM_DEPLOY_HEALTH_URL optional, defaults to http://127.0.0.1:8788/api/healthz
|
||||
# The target host must have Node.js, npm, rsync, PostgreSQL, systemd, bubblewrap
|
||||
# (`bwrap`, for the ADR-0018 agent sandbox), and a writable $PLATFORM_DEPLOY_BASE.
|
||||
# Use a dedicated `deploy` user that has passwordless sudo for systemctl and
|
||||
# writing /etc/systemd/system/cph-hub.service through deploy/install_service.sh.
|
||||
set -euo pipefail
|
||||
|
||||
HOST="${PLATFORM_DEPLOY_HOST:?PLATFORM_DEPLOY_HOST required}"
|
||||
@@ -20,12 +19,15 @@ SSH_KEY="${PLATFORM_DEPLOY_SSH_KEY:?PLATFORM_DEPLOY_SSH_KEY required}"
|
||||
DEPLOY_USER="${PLATFORM_DEPLOY_USER:-deploy}"
|
||||
PORT="${PLATFORM_DEPLOY_PORT:-22}"
|
||||
BASE="${PLATFORM_DEPLOY_BASE:-/srv/curriculum-project-hub}"
|
||||
HEALTH_URL="${PLATFORM_DEPLOY_HEALTH_URL:-http://127.0.0.1:8788/api/healthz}"
|
||||
HUB_DIR="$BASE/hub"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
SSH_OPTS=(-i "$SSH_KEY" -p "$PORT" -o StrictHostKeyChecking=accept-new)
|
||||
|
||||
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "mkdir -p '$HUB_DIR'"
|
||||
|
||||
# 1. Rsync the hub/ directory (exclude node_modules/dist, they rebuild on host).
|
||||
rsync -az --delete \
|
||||
--exclude node_modules --exclude dist --exclude .env \
|
||||
@@ -37,11 +39,23 @@ ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "cd '$HUB_DIR' && npm ci && npm run bu
|
||||
|
||||
# 3. Ensure the service is installed (idempotent), then restart.
|
||||
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "
|
||||
BASE='$BASE' HUB_DIR='$HUB_DIR' bash '$HUB_DIR/deploy/install_service.sh' || true
|
||||
sudo systemctl restart cph-hub.service
|
||||
sleep 2
|
||||
sudo systemctl is-active --quiet cph-hub.service || { echo 'service failed to start'; exit 1; }
|
||||
curl -sf http://127.0.0.1:8788/api/healthz || { echo 'healthz failed'; exit 1; }
|
||||
set -euo pipefail
|
||||
if [ \"\$(id -u)\" = \"0\" ]; then
|
||||
BASE='$BASE' HUB_DIR='$HUB_DIR' bash '$HUB_DIR/deploy/install_service.sh'
|
||||
systemctl restart cph-hub.service
|
||||
systemctl is-active --quiet cph-hub.service
|
||||
else
|
||||
sudo -n BASE='$BASE' HUB_DIR='$HUB_DIR' bash '$HUB_DIR/deploy/install_service.sh'
|
||||
sudo -n systemctl restart cph-hub.service
|
||||
sudo -n systemctl is-active --quiet cph-hub.service
|
||||
fi
|
||||
for attempt in \$(seq 1 30); do
|
||||
if curl --fail --silent '$HEALTH_URL' >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
curl --fail --silent --show-error '$HEALTH_URL' >/dev/null
|
||||
"
|
||||
|
||||
echo "Deployed cph-hub to $HOST ($HUB_DIR)"
|
||||
|
||||
@@ -11,11 +11,12 @@ set -euo pipefail
|
||||
|
||||
BASE="${BASE:-/srv/curriculum-project-hub}"
|
||||
HUB_DIR="${HUB_DIR:-$BASE/hub}"
|
||||
SERVICE_USER="${SERVICE_USER:-root}"
|
||||
SERVICE_USER="${SERVICE_USER:-cph-hub}"
|
||||
HOST="${HOST:-127.0.0.1}"
|
||||
PORT="${PORT:-8788}"
|
||||
ENV_FILE="${ENV_FILE:-$BASE/.secrets/platform.env}"
|
||||
NODE_BIN="${NODE_BIN:-$(command -v node || true)}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
if [ -z "$NODE_BIN" ]; then
|
||||
echo "node must be on PATH, or set NODE_BIN." >&2
|
||||
@@ -29,6 +30,10 @@ if [ ! -f "$HUB_DIR/dist/server.js" ]; then
|
||||
echo "build missing: run 'npm ci && npm run build' in $HUB_DIR first." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$SCRIPT_DIR/cph-hub.service" ]; then
|
||||
echo "service template missing: $SCRIPT_DIR/cph-hub.service" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Seed the env file if absent. Secrets stay on the server, never in the repo.
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
@@ -36,25 +41,34 @@ if [ ! -f "$ENV_FILE" ]; then
|
||||
cat >"$ENV_FILE" <<EOF
|
||||
NODE_ENV=production
|
||||
DATABASE_URL=postgresql://paradigm:change-me@127.0.0.1:5432/paradigm
|
||||
OPENROUTER_API_KEY=
|
||||
# Claude Code SDK auth via OpenRouter's Anthropic Skin (ADR-0017).
|
||||
# ANTHROPIC_AUTH_TOKEN = your OpenRouter API key; ANTHROPIC_API_KEY must be empty.
|
||||
ANTHROPIC_BASE_URL=https://openrouter.ai/api
|
||||
ANTHROPIC_AUTH_TOKEN=
|
||||
ANTHROPIC_API_KEY=
|
||||
FEISHU_APP_ID=
|
||||
FEISHU_APP_SECRET=
|
||||
FEISHU_BOT_OPEN_ID=
|
||||
PORT=$PORT
|
||||
HOST=$HOST
|
||||
EOF
|
||||
echo "Seeded $ENV_FILE — edit it to fill in OPENROUTER_API_KEY and Feishu creds, then re-run."
|
||||
chmod 0600 "$ENV_FILE"
|
||||
echo "Seeded $ENV_FILE — edit it to fill in ANTHROPIC_AUTH_TOKEN and Feishu creds, then re-run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Render the unit with concrete paths.
|
||||
UNIT=/etc/systemd/system/cph-hub.service
|
||||
TMP_UNIT="$(mktemp)"
|
||||
sed \
|
||||
-e "s|__SERVICE_USER__|$SERVICE_USER|g" \
|
||||
-e "s|__HUB_DIR__|$HUB_DIR|g" \
|
||||
-e "s|__BASE_DIR__|$BASE|g" \
|
||||
-e "s|__ENV_FILE__|$ENV_FILE|g" \
|
||||
-e "s|__NODE_BIN__|$NODE_BIN|g" \
|
||||
"$HUB_DIR/deploy/cph-hub.service" >"$UNIT"
|
||||
"$SCRIPT_DIR/cph-hub.service" >"$TMP_UNIT"
|
||||
install -m 0644 "$TMP_UNIT" "$UNIT"
|
||||
rm -f "$TMP_UNIT"
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable cph-hub.service
|
||||
|
||||
Generated
+1172
-17
File diff suppressed because it is too large
Load Diff
+4
-4
@@ -7,7 +7,7 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai-compatible": "^3.0.5",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@larksuiteoapi/node-sdk": "^1.66.1",
|
||||
"@prisma/client": "^6.19.3",
|
||||
@@ -22,11 +22,11 @@
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"description": "Curriculum Project Hub — Feishu-group collaboration + provider-agnostic agent runtime. Aligns to spec/System (ADR-0001..0004, 0017).",
|
||||
"description": "Curriculum Project Hub — Feishu-group collaboration + Claude Code SDK agent runtime. Aligns to spec/System (ADR-0001..0004, 0017).",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"dev": "npm run prisma:migrate && tsx watch src/server.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/server.js",
|
||||
"start": "npm run prisma:migrate && node dist/server.js",
|
||||
"check": "tsc -p tsconfig.json --noEmit",
|
||||
"prisma:generate": "prisma generate --schema prisma/schema.prisma",
|
||||
"prisma:validate": "DATABASE_URL=${DATABASE_URL:-postgresql://stub:stub@127.0.0.1:5432/stub} prisma validate --schema prisma/schema.prisma",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
-- AgentMessage: per-message record of the agent loop (queryable projection
|
||||
-- alongside the transcript JSONL file, which remains the agent's read-back memory).
|
||||
CREATE TABLE "AgentMessage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sessionId" TEXT NOT NULL,
|
||||
"runId" TEXT,
|
||||
"role" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"attachments" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AgentMessage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "AgentMessage_sessionId_createdAt_idx" ON "AgentMessage"("sessionId", "createdAt");
|
||||
CREATE INDEX "AgentMessage_runId_idx" ON "AgentMessage"("runId");
|
||||
|
||||
ALTER TABLE "AgentMessage" ADD CONSTRAINT "AgentMessage_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "AgentSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "AgentMessage" ADD CONSTRAINT "AgentMessage_runId_fkey" FOREIGN KEY ("runId") REFERENCES "AgentRun"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AgentFileChange: file change captured during a run (write/rename/delete).
|
||||
CREATE TABLE "AgentFileChange" (
|
||||
"id" TEXT NOT NULL,
|
||||
"runId" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"path" TEXT NOT NULL,
|
||||
"operation" TEXT NOT NULL,
|
||||
"beforeHash" TEXT,
|
||||
"afterHash" TEXT,
|
||||
"diff" TEXT,
|
||||
"metadata" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AgentFileChange_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "AgentFileChange_runId_idx" ON "AgentFileChange"("runId");
|
||||
CREATE INDEX "AgentFileChange_projectId_idx" ON "AgentFileChange"("projectId");
|
||||
CREATE INDEX "AgentFileChange_path_idx" ON "AgentFileChange"("path");
|
||||
|
||||
ALTER TABLE "AgentFileChange" ADD CONSTRAINT "AgentFileChange_runId_fkey" FOREIGN KEY ("runId") REFERENCES "AgentRun"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "AgentFileChange" ADD CONSTRAINT "AgentFileChange_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- ProjectAgentLock: add heartbeatAt for liveness (A-5).
|
||||
ALTER TABLE "ProjectAgentLock" ADD COLUMN "heartbeatAt" TIMESTAMP(3);
|
||||
@@ -0,0 +1,143 @@
|
||||
-- ADR-0019: typed principals, Hub teams, and external Feishu principal sync.
|
||||
|
||||
CREATE TYPE "PrincipalType" AS ENUM (
|
||||
'USER',
|
||||
'TEAM',
|
||||
'FEISHU_CHAT',
|
||||
'FEISHU_DEPARTMENT',
|
||||
'FEISHU_USER_GROUP',
|
||||
'APP'
|
||||
);
|
||||
|
||||
CREATE TABLE "Team" (
|
||||
"id" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"archivedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "Team_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "Team_slug_key" ON "Team"("slug");
|
||||
CREATE INDEX "Team_archivedAt_idx" ON "Team"("archivedAt");
|
||||
|
||||
CREATE TABLE "TeamMembership" (
|
||||
"id" TEXT NOT NULL,
|
||||
"teamId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "TeamMembership_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "TeamMembership_teamId_revokedAt_idx" ON "TeamMembership"("teamId", "revokedAt");
|
||||
CREATE INDEX "TeamMembership_userId_revokedAt_idx" ON "TeamMembership"("userId", "revokedAt");
|
||||
CREATE UNIQUE INDEX "TeamMembership_teamId_userId_revokedAt_key" ON "TeamMembership"("teamId", "userId", "revokedAt");
|
||||
CREATE UNIQUE INDEX "TeamMembership_active_key" ON "TeamMembership"("teamId", "userId") WHERE "revokedAt" IS NULL;
|
||||
|
||||
ALTER TABLE "TeamMembership" ADD CONSTRAINT "TeamMembership_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "TeamMembership" ADD CONSTRAINT "TeamMembership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
CREATE TABLE "TeamExternalBinding" (
|
||||
"id" TEXT NOT NULL,
|
||||
"teamId" TEXT NOT NULL,
|
||||
"principalType" "PrincipalType" NOT NULL,
|
||||
"principalId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "TeamExternalBinding_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "TeamExternalBinding_principalType_principalId_revokedAt_idx" ON "TeamExternalBinding"("principalType", "principalId", "revokedAt");
|
||||
CREATE INDEX "TeamExternalBinding_teamId_revokedAt_idx" ON "TeamExternalBinding"("teamId", "revokedAt");
|
||||
CREATE UNIQUE INDEX "TeamExternalBinding_teamId_principalType_principalId_revokedAt_key" ON "TeamExternalBinding"("teamId", "principalType", "principalId", "revokedAt");
|
||||
CREATE UNIQUE INDEX "TeamExternalBinding_active_key" ON "TeamExternalBinding"("teamId", "principalType", "principalId") WHERE "revokedAt" IS NULL;
|
||||
|
||||
ALTER TABLE "TeamExternalBinding" ADD CONSTRAINT "TeamExternalBinding_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
CREATE TABLE "ExternalPrincipalMembership" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"principalType" "PrincipalType" NOT NULL,
|
||||
"principalId" TEXT NOT NULL,
|
||||
"source" TEXT NOT NULL,
|
||||
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "ExternalPrincipalMembership_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "ExternalPrincipalMembership_userId_revokedAt_idx" ON "ExternalPrincipalMembership"("userId", "revokedAt");
|
||||
CREATE INDEX "ExternalPrincipalMembership_principalType_principalId_revokedAt_idx" ON "ExternalPrincipalMembership"("principalType", "principalId", "revokedAt");
|
||||
CREATE INDEX "ExternalPrincipalMembership_source_syncedAt_idx" ON "ExternalPrincipalMembership"("source", "syncedAt");
|
||||
CREATE UNIQUE INDEX "ExternalPrincipalMembership_userId_principalType_principalId_source_revokedAt_key" ON "ExternalPrincipalMembership"("userId", "principalType", "principalId", "source", "revokedAt");
|
||||
CREATE UNIQUE INDEX "ExternalPrincipalMembership_active_key" ON "ExternalPrincipalMembership"("userId", "principalType", "principalId", "source") WHERE "revokedAt" IS NULL;
|
||||
|
||||
ALTER TABLE "ExternalPrincipalMembership" ADD CONSTRAINT "ExternalPrincipalMembership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- PermissionGrant: backfill legacy opaque principal strings as USER principals.
|
||||
ALTER TABLE "PermissionGrant" DROP CONSTRAINT IF EXISTS "PermissionGrant_resourceId_fkey";
|
||||
DROP INDEX IF EXISTS "PermissionGrant_resourceType_resourceId_principal_role_revo_key";
|
||||
DROP INDEX IF EXISTS "PermissionGrant_principal_revokedAt_idx";
|
||||
|
||||
ALTER TABLE "PermissionGrant" ADD COLUMN "principalType" "PrincipalType" NOT NULL DEFAULT 'USER';
|
||||
ALTER TABLE "PermissionGrant" ADD COLUMN "principalId" TEXT;
|
||||
UPDATE "PermissionGrant" SET "principalId" = "principal";
|
||||
ALTER TABLE "PermissionGrant" ALTER COLUMN "principalId" SET NOT NULL;
|
||||
ALTER TABLE "PermissionGrant" ALTER COLUMN "principalType" DROP DEFAULT;
|
||||
ALTER TABLE "PermissionGrant" DROP COLUMN "principal";
|
||||
|
||||
WITH ranked_permission_grants AS (
|
||||
SELECT
|
||||
"id",
|
||||
row_number() OVER (
|
||||
PARTITION BY "resourceType", "resourceId", "principalType", "principalId", "role"
|
||||
ORDER BY "createdAt", "id"
|
||||
) AS rn
|
||||
FROM "PermissionGrant"
|
||||
WHERE "revokedAt" IS NULL
|
||||
)
|
||||
DELETE FROM "PermissionGrant"
|
||||
WHERE "id" IN (
|
||||
SELECT "id" FROM ranked_permission_grants WHERE rn > 1
|
||||
);
|
||||
|
||||
CREATE INDEX "PermissionGrant_principalType_principalId_revokedAt_idx" ON "PermissionGrant"("principalType", "principalId", "revokedAt");
|
||||
CREATE UNIQUE INDEX "PermissionGrant_active_key" ON "PermissionGrant"("resourceType", "resourceId", "principalType", "principalId", "role") WHERE "revokedAt" IS NULL;
|
||||
|
||||
-- PermissionSettings resource is polymorphic; do not FK resourceId to Project.
|
||||
ALTER TABLE "PermissionSettings" DROP CONSTRAINT IF EXISTS "PermissionSettings_resourceId_fkey";
|
||||
|
||||
-- RoleTriggerGrant: same backfill as PermissionGrant.
|
||||
DROP INDEX IF EXISTS "RoleTriggerGrant_projectId_roleId_principal_revokedAt_key";
|
||||
DROP INDEX IF EXISTS "RoleTriggerGrant_principal_revokedAt_idx";
|
||||
|
||||
ALTER TABLE "RoleTriggerGrant" ADD COLUMN "principalType" "PrincipalType" NOT NULL DEFAULT 'USER';
|
||||
ALTER TABLE "RoleTriggerGrant" ADD COLUMN "principalId" TEXT;
|
||||
UPDATE "RoleTriggerGrant" SET "principalId" = "principal";
|
||||
ALTER TABLE "RoleTriggerGrant" ALTER COLUMN "principalId" SET NOT NULL;
|
||||
ALTER TABLE "RoleTriggerGrant" ALTER COLUMN "principalType" DROP DEFAULT;
|
||||
ALTER TABLE "RoleTriggerGrant" DROP COLUMN "principal";
|
||||
|
||||
WITH ranked_role_trigger_grants AS (
|
||||
SELECT
|
||||
"id",
|
||||
row_number() OVER (
|
||||
PARTITION BY "projectId", "roleId", "principalType", "principalId"
|
||||
ORDER BY "createdAt", "id"
|
||||
) AS rn
|
||||
FROM "RoleTriggerGrant"
|
||||
WHERE "revokedAt" IS NULL
|
||||
)
|
||||
DELETE FROM "RoleTriggerGrant"
|
||||
WHERE "id" IN (
|
||||
SELECT "id" FROM ranked_role_trigger_grants WHERE rn > 1
|
||||
);
|
||||
|
||||
CREATE INDEX "RoleTriggerGrant_principalType_principalId_revokedAt_idx" ON "RoleTriggerGrant"("principalType", "principalId", "revokedAt");
|
||||
CREATE UNIQUE INDEX "RoleTriggerGrant_active_key" ON "RoleTriggerGrant"("projectId", "roleId", "principalType", "principalId") WHERE "revokedAt" IS NULL;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- ADR-0017 refinement: Hub sessions are provider/role/model-bound.
|
||||
-- Existing sessions predate role-aware binding; treat them as draft sessions.
|
||||
ALTER TABLE "AgentSession" ADD COLUMN "roleId" TEXT NOT NULL DEFAULT 'draft';
|
||||
ALTER TABLE "AgentSession" ALTER COLUMN "roleId" DROP DEFAULT;
|
||||
|
||||
CREATE INDEX "AgentSession_provider_roleId_model_idx" ON "AgentSession"("provider", "roleId", "model");
|
||||
CREATE INDEX "AgentSession_projectId_provider_roleId_model_archivedAt_idx" ON "AgentSession"("projectId", "provider", "roleId", "model", "archivedAt");
|
||||
|
||||
DROP INDEX "AgentSession_provider_model_idx";
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Record real provider-reported run cost for /cost reporting.
|
||||
-- No backfill: pre-existing test runs remain cost-unrecorded by design.
|
||||
ALTER TABLE "AgentRun" ADD COLUMN "costUsd" DECIMAL(18, 8);
|
||||
ALTER TABLE "AgentRun" ADD COLUMN "costSource" TEXT;
|
||||
|
||||
CREATE INDEX "AgentRun_projectId_finishedAt_idx" ON "AgentRun"("projectId", "finishedAt");
|
||||
@@ -0,0 +1,145 @@
|
||||
-- ADR-0020: organization is the SaaS tenant root.
|
||||
-- Backfill all existing data into one default organization so old databases
|
||||
-- upgrade without a manual bootstrap step.
|
||||
|
||||
CREATE TYPE "OrganizationStatus" AS ENUM ('ACTIVE', 'SUSPENDED', 'ARCHIVED');
|
||||
CREATE TYPE "OrganizationMemberRole" AS ENUM ('OWNER', 'ADMIN', 'MEMBER');
|
||||
|
||||
CREATE TABLE "Organization" (
|
||||
"id" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"status" "OrganizationStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Organization_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "Organization_slug_key" ON "Organization"("slug");
|
||||
CREATE INDEX "Organization_status_idx" ON "Organization"("status");
|
||||
|
||||
INSERT INTO "Organization" ("id", "slug", "name", "status")
|
||||
VALUES ('org_default', 'legacy-default', 'Legacy Default Organization', 'ACTIVE');
|
||||
|
||||
CREATE TABLE "OrganizationMembership" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"role" "OrganizationMemberRole" NOT NULL DEFAULT 'MEMBER',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "OrganizationMembership_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
INSERT INTO "OrganizationMembership" ("id", "organizationId", "userId", "role")
|
||||
SELECT
|
||||
'orgmem_default_' || md5("User"."id"),
|
||||
'org_default',
|
||||
"User"."id",
|
||||
CASE
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM "PlatformRoleAssignment"
|
||||
WHERE "PlatformRoleAssignment"."userId" = "User"."id"
|
||||
AND "PlatformRoleAssignment"."role" = 'ADMIN'
|
||||
AND "PlatformRoleAssignment"."revokedAt" IS NULL
|
||||
)
|
||||
THEN 'OWNER'::"OrganizationMemberRole"
|
||||
ELSE 'MEMBER'::"OrganizationMemberRole"
|
||||
END
|
||||
FROM "User";
|
||||
|
||||
CREATE INDEX "OrganizationMembership_organizationId_revokedAt_idx" ON "OrganizationMembership"("organizationId", "revokedAt");
|
||||
CREATE INDEX "OrganizationMembership_userId_revokedAt_idx" ON "OrganizationMembership"("userId", "revokedAt");
|
||||
CREATE UNIQUE INDEX "OrganizationMembership_organizationId_userId_revokedAt_key" ON "OrganizationMembership"("organizationId", "userId", "revokedAt");
|
||||
CREATE UNIQUE INDEX "OrganizationMembership_active_key" ON "OrganizationMembership"("organizationId", "userId") WHERE "revokedAt" IS NULL;
|
||||
|
||||
ALTER TABLE "OrganizationMembership" ADD CONSTRAINT "OrganizationMembership_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "OrganizationMembership" ADD CONSTRAINT "OrganizationMembership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "Project" ADD COLUMN "organizationId" TEXT;
|
||||
UPDATE "Project" SET "organizationId" = 'org_default';
|
||||
ALTER TABLE "Project" ALTER COLUMN "organizationId" SET NOT NULL;
|
||||
ALTER TABLE "Project" ADD CONSTRAINT "Project_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
CREATE INDEX "Project_organizationId_archivedAt_idx" ON "Project"("organizationId", "archivedAt");
|
||||
|
||||
ALTER TABLE "Team" ADD COLUMN "organizationId" TEXT;
|
||||
UPDATE "Team" SET "organizationId" = 'org_default';
|
||||
ALTER TABLE "Team" ALTER COLUMN "organizationId" SET NOT NULL;
|
||||
ALTER TABLE "Team" ADD CONSTRAINT "Team_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
DROP INDEX IF EXISTS "Team_slug_key";
|
||||
CREATE INDEX "Team_organizationId_archivedAt_idx" ON "Team"("organizationId", "archivedAt");
|
||||
CREATE INDEX "Team_organizationId_slug_archivedAt_idx" ON "Team"("organizationId", "slug", "archivedAt");
|
||||
CREATE UNIQUE INDEX "Team_active_slug_key" ON "Team"("organizationId", "slug") WHERE "archivedAt" IS NULL;
|
||||
|
||||
CREATE TABLE "ExternalDirectoryConnection" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"providerTenantId" TEXT,
|
||||
"source" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "ExternalDirectoryConnection_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
INSERT INTO "ExternalDirectoryConnection" ("id", "organizationId", "provider", "source")
|
||||
SELECT DISTINCT
|
||||
'extconn_default_' || md5("source"),
|
||||
'org_default',
|
||||
'FEISHU',
|
||||
"source"
|
||||
FROM "ExternalPrincipalMembership";
|
||||
|
||||
CREATE UNIQUE INDEX "ExternalDirectoryConnection_organizationId_provider_source_key" ON "ExternalDirectoryConnection"("organizationId", "provider", "source");
|
||||
CREATE INDEX "ExternalDirectoryConnection_organizationId_revokedAt_idx" ON "ExternalDirectoryConnection"("organizationId", "revokedAt");
|
||||
CREATE INDEX "ExternalDirectoryConnection_provider_providerTenantId_idx" ON "ExternalDirectoryConnection"("provider", "providerTenantId");
|
||||
ALTER TABLE "ExternalDirectoryConnection" ADD CONSTRAINT "ExternalDirectoryConnection_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
DROP INDEX IF EXISTS "ExternalPrincipalMembership_userId_principalType_principalId_source_revokedAt_key";
|
||||
DROP INDEX IF EXISTS "ExternalPrincipalMembership_active_key";
|
||||
DROP INDEX IF EXISTS "ExternalPrincipalMembership_source_syncedAt_idx";
|
||||
|
||||
ALTER TABLE "ExternalPrincipalMembership" ADD COLUMN "connectionId" TEXT;
|
||||
UPDATE "ExternalPrincipalMembership"
|
||||
SET "connectionId" = 'extconn_default_' || md5("source");
|
||||
ALTER TABLE "ExternalPrincipalMembership" ALTER COLUMN "connectionId" SET NOT NULL;
|
||||
ALTER TABLE "ExternalPrincipalMembership" DROP COLUMN "source";
|
||||
ALTER TABLE "ExternalPrincipalMembership" ADD CONSTRAINT "ExternalPrincipalMembership_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "ExternalDirectoryConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
CREATE INDEX "ExternalPrincipalMembership_connectionId_revokedAt_idx" ON "ExternalPrincipalMembership"("connectionId", "revokedAt");
|
||||
CREATE UNIQUE INDEX "ExternalPrincipalMembership_userId_principalType_principalId_connectionId_revokedAt_key" ON "ExternalPrincipalMembership"("userId", "principalType", "principalId", "connectionId", "revokedAt");
|
||||
CREATE UNIQUE INDEX "ExternalPrincipalMembership_active_key" ON "ExternalPrincipalMembership"("userId", "principalType", "principalId", "connectionId") WHERE "revokedAt" IS NULL;
|
||||
|
||||
-- A principal should have one active role on a resource. Keep the strongest
|
||||
-- grant and revoke lower active grants to preserve history without ambiguity.
|
||||
DROP INDEX IF EXISTS "PermissionGrant_active_key";
|
||||
|
||||
WITH ranked_permission_grants AS (
|
||||
SELECT
|
||||
"id",
|
||||
row_number() OVER (
|
||||
PARTITION BY "resourceType", "resourceId", "principalType", "principalId"
|
||||
ORDER BY
|
||||
CASE "role"
|
||||
WHEN 'MANAGE' THEN 3
|
||||
WHEN 'EDIT' THEN 2
|
||||
WHEN 'READ' THEN 1
|
||||
END DESC,
|
||||
"createdAt" ASC,
|
||||
"id" ASC
|
||||
) AS rn
|
||||
FROM "PermissionGrant"
|
||||
WHERE "revokedAt" IS NULL
|
||||
)
|
||||
UPDATE "PermissionGrant"
|
||||
SET "revokedAt" = CURRENT_TIMESTAMP
|
||||
WHERE "id" IN (
|
||||
SELECT "id" FROM ranked_permission_grants WHERE rn > 1
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "PermissionGrant_active_principal_key" ON "PermissionGrant"("resourceType", "resourceId", "principalType", "principalId") WHERE "revokedAt" IS NULL;
|
||||
+276
-66
@@ -3,9 +3,9 @@
|
||||
// Aligns to spec/System (ADR-0001..0004, 0017). Key divergences from the
|
||||
// legacy teaching-material-host-service schema, each deliberate:
|
||||
//
|
||||
// - No AgentSession.claudeSessionId. ADR-0017: session is provider/model-
|
||||
// bound, not Claude-bound; `provider`+`model` replace it. Switching model
|
||||
// = new session, so the unique constraint is on the session id alone.
|
||||
// - AgentSession is provider/model-bound. Provider runtime cursors such as
|
||||
// Claude SDK `session_id` live in `metadata`, so switching model still means
|
||||
// a new Hub session while same-session runs can resume provider context.
|
||||
// - PermissionRole enum = read/edit/manage (ADR-0004 capability lattice),
|
||||
// distinct from platform UserRole (admin/teacher) — legacy conflated them.
|
||||
// - ProjectGroupBinding is project→chat only (ADR-0001 1:1); legacy mixed
|
||||
@@ -25,6 +25,54 @@ datasource db {
|
||||
|
||||
// --- Platform identity ---------------------------------------------------
|
||||
|
||||
/// ADR-0020: SaaS tenant root. Every project and team belongs to exactly one
|
||||
/// organization; platform operator access remains outside project roles.
|
||||
model Organization {
|
||||
id String @id @default(cuid())
|
||||
slug String @unique
|
||||
name String
|
||||
status OrganizationStatus @default(ACTIVE)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
memberships OrganizationMembership[]
|
||||
projects Project[]
|
||||
teams Team[]
|
||||
externalDirectoryConnections ExternalDirectoryConnection[]
|
||||
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
enum OrganizationStatus {
|
||||
ACTIVE
|
||||
SUSPENDED
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
/// Org-scoped platform role. Distinct from project PermissionRole and from
|
||||
/// global PlatformRoleAssignment, which is reserved for SaaS/platform control.
|
||||
model OrganizationMembership {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
userId String
|
||||
role OrganizationMemberRole @default(MEMBER)
|
||||
createdAt DateTime @default(now())
|
||||
revokedAt DateTime?
|
||||
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([organizationId, userId, revokedAt])
|
||||
@@index([organizationId, revokedAt])
|
||||
@@index([userId, revokedAt])
|
||||
}
|
||||
|
||||
enum OrganizationMemberRole {
|
||||
OWNER
|
||||
ADMIN
|
||||
MEMBER
|
||||
}
|
||||
|
||||
/// A Feishu user known to the Hub. principal sub-typology is OPEN (ADR-0004).
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
@@ -34,14 +82,17 @@ model User {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
platformRoles PlatformRoleAssignment[]
|
||||
createdProjects Project[] @relation("projectCreator")
|
||||
requestedRuns AgentRun[] @relation("runRequester")
|
||||
heldLocks ProjectAgentLock[] @relation("lockHolder")
|
||||
feishuBindings ProjectGroupBinding[] @relation("bindingCreator")
|
||||
permissionGrants PermissionGrant[] @relation("grantCreator")
|
||||
roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator")
|
||||
auditEntries AuditEntry[] @relation("auditActor")
|
||||
platformRoles PlatformRoleAssignment[]
|
||||
organizationMemberships OrganizationMembership[]
|
||||
createdProjects Project[] @relation("projectCreator")
|
||||
requestedRuns AgentRun[] @relation("runRequester")
|
||||
heldLocks ProjectAgentLock[] @relation("lockHolder")
|
||||
feishuBindings ProjectGroupBinding[] @relation("bindingCreator")
|
||||
teamMemberships TeamMembership[]
|
||||
externalPrincipalMemberships ExternalPrincipalMembership[]
|
||||
permissionGrants PermissionGrant[] @relation("grantCreator")
|
||||
roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator")
|
||||
auditEntries AuditEntry[] @relation("auditActor")
|
||||
}
|
||||
|
||||
/// Platform-level role (admin/teacher). Distinct from ADR-0004 PermissionRole.
|
||||
@@ -64,10 +115,116 @@ enum PlatformRole {
|
||||
TEACHER
|
||||
}
|
||||
|
||||
/// ADR-0019: typed principals for permission grants and actor resolution.
|
||||
/// USER is identified by Feishu open_id; TEAM by Hub Team.id; Feishu external
|
||||
/// principals by their Feishu ids.
|
||||
enum PrincipalType {
|
||||
USER
|
||||
TEAM
|
||||
FEISHU_CHAT
|
||||
FEISHU_DEPARTMENT
|
||||
FEISHU_USER_GROUP
|
||||
APP
|
||||
}
|
||||
|
||||
/// Hub-managed teacher team. A team is a first-class permission principal.
|
||||
model Team {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
slug String
|
||||
name String
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
archivedAt DateTime?
|
||||
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
memberships TeamMembership[]
|
||||
externalBindings TeamExternalBinding[]
|
||||
|
||||
@@index([organizationId, archivedAt])
|
||||
@@index([organizationId, slug, archivedAt])
|
||||
}
|
||||
|
||||
/// Direct Hub team membership. External Feishu groups can also map to teams via
|
||||
/// TeamExternalBinding; both sources are resolved at authorization time.
|
||||
model TeamMembership {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
userId String
|
||||
createdAt DateTime @default(now())
|
||||
revokedAt DateTime?
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([teamId, userId, revokedAt])
|
||||
@@index([teamId, revokedAt])
|
||||
@@index([userId, revokedAt])
|
||||
}
|
||||
|
||||
/// Bind a Hub team to an external Feishu principal. When an actor resolves to
|
||||
/// the external principal, the actor also resolves to this team.
|
||||
model TeamExternalBinding {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
principalType PrincipalType
|
||||
principalId String
|
||||
createdAt DateTime @default(now())
|
||||
revokedAt DateTime?
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([teamId, principalType, principalId, revokedAt])
|
||||
@@index([principalType, principalId, revokedAt])
|
||||
@@index([teamId, revokedAt])
|
||||
}
|
||||
|
||||
/// Locally synchronized Feishu external principal membership.
|
||||
model ExternalDirectoryConnection {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
provider String
|
||||
providerTenantId String?
|
||||
source String
|
||||
status String @default("ACTIVE")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
revokedAt DateTime?
|
||||
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
memberships ExternalPrincipalMembership[]
|
||||
|
||||
@@unique([organizationId, provider, source])
|
||||
@@index([organizationId, revokedAt])
|
||||
@@index([provider, providerTenantId])
|
||||
}
|
||||
|
||||
/// Locally synchronized Feishu external principal membership, scoped through
|
||||
/// an organization-owned ExternalDirectoryConnection.
|
||||
model ExternalPrincipalMembership {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
connectionId String
|
||||
principalType PrincipalType
|
||||
principalId String
|
||||
syncedAt DateTime @default(now())
|
||||
revokedAt DateTime?
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
connection ExternalDirectoryConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, principalType, principalId, connectionId, revokedAt])
|
||||
@@index([userId, revokedAt])
|
||||
@@index([connectionId, revokedAt])
|
||||
@@index([principalType, principalId, revokedAt])
|
||||
}
|
||||
|
||||
// --- Project & Feishu binding (ADR-0001) ---------------------------------
|
||||
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
name String
|
||||
workspaceDir String
|
||||
createdByUserId String?
|
||||
@@ -75,16 +232,17 @@ model Project {
|
||||
updatedAt DateTime @updatedAt
|
||||
archivedAt DateTime?
|
||||
|
||||
createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||||
groupBinding ProjectGroupBinding?
|
||||
agentSessions AgentSession[]
|
||||
agentRuns AgentRun[]
|
||||
agentLock ProjectAgentLock?
|
||||
permissionGrants PermissionGrant[] @relation("projectGrants")
|
||||
permissionSettings PermissionSettings[] @relation("projectSettings")
|
||||
roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants")
|
||||
auditEntries AuditEntry[] @relation("projectAudit")
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||||
groupBinding ProjectGroupBinding?
|
||||
agentSessions AgentSession[]
|
||||
agentRuns AgentRun[]
|
||||
agentLock ProjectAgentLock?
|
||||
roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants")
|
||||
auditEntries AuditEntry[] @relation("projectAudit")
|
||||
fileChanges AgentFileChange[] @relation("projectFileChanges")
|
||||
|
||||
@@index([organizationId, archivedAt])
|
||||
@@index([archivedAt])
|
||||
}
|
||||
|
||||
@@ -109,13 +267,15 @@ model ProjectGroupBinding {
|
||||
|
||||
// --- AgentRun, session, lock (ADR-0002, 0017) -----------------------------
|
||||
|
||||
/// ADR-0017: session is provider/model-bound. No claudeSessionId; `provider`
|
||||
/// + `model` capture the binding. Same provider+model ⇒ reuse across runs
|
||||
/// (ADR-0002); a switch ⇒ new session.
|
||||
/// ADR-0017: session is provider/role/model-bound. `provider` + `roleId` +
|
||||
/// `model` capture the binding; provider-specific runtime cursors live in
|
||||
/// `metadata` (e.g. metadata.claudeSessionId). Same provider+role+model ⇒
|
||||
/// reuse across runs (ADR-0002); a switch ⇒ new Hub session.
|
||||
model AgentSession {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
provider String
|
||||
roleId String
|
||||
model String
|
||||
title String?
|
||||
metadata Json
|
||||
@@ -123,11 +283,13 @@ model AgentSession {
|
||||
updatedAt DateTime @updatedAt
|
||||
archivedAt DateTime?
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
runs AgentRun[]
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
runs AgentRun[]
|
||||
messages AgentMessage[] @relation("sessionMessages")
|
||||
|
||||
@@index([projectId, archivedAt])
|
||||
@@index([provider, model])
|
||||
@@index([provider, roleId, model])
|
||||
@@index([projectId, provider, roleId, model, archivedAt])
|
||||
@@index([updatedAt])
|
||||
}
|
||||
|
||||
@@ -150,30 +312,37 @@ enum AgentEntrypoint {
|
||||
}
|
||||
|
||||
model AgentRun {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
sessionId String?
|
||||
requestedByUserId String?
|
||||
entrypoint AgentEntrypoint
|
||||
status AgentRunStatus @default(ACTIVE)
|
||||
status AgentRunStatus @default(ACTIVE)
|
||||
prompt String
|
||||
model String
|
||||
provider String
|
||||
summary String?
|
||||
inputTokens Int?
|
||||
outputTokens Int?
|
||||
/// Provider/gateway-reported cost in USD. Null means this run has no trusted cost fact.
|
||||
costUsd Decimal? @db.Decimal(18, 8)
|
||||
/// Semantic source of costUsd, e.g. provider_reported. Not a pricing-estimate fallback.
|
||||
costSource String?
|
||||
metadata Json
|
||||
error String?
|
||||
startedAt DateTime @default(now())
|
||||
startedAt DateTime @default(now())
|
||||
finishedAt DateTime?
|
||||
updatedAt DateTime @updatedAt
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
session AgentSession? @relation(fields: [sessionId], references: [id], onDelete: SetNull)
|
||||
requestedBy User? @relation("runRequester", fields: [requestedByUserId], references: [id], onDelete: SetNull)
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
session AgentSession? @relation(fields: [sessionId], references: [id], onDelete: SetNull)
|
||||
requestedBy User? @relation("runRequester", fields: [requestedByUserId], references: [id], onDelete: SetNull)
|
||||
projectLock ProjectAgentLock?
|
||||
messages AgentMessage[] @relation("runMessages")
|
||||
fileChanges AgentFileChange[] @relation("runFileChanges")
|
||||
|
||||
@@index([projectId, status])
|
||||
@@index([projectId, finishedAt])
|
||||
@@index([sessionId])
|
||||
@@index([requestedByUserId])
|
||||
@@index([updatedAt])
|
||||
@@ -184,15 +353,16 @@ model AgentRun {
|
||||
/// a run holds at most one lock. WellFormed (holder is non-terminal) is an
|
||||
/// app-level invariant checked on read/write, not a DB constraint.
|
||||
model ProjectAgentLock {
|
||||
projectId String @id
|
||||
runId String @unique
|
||||
projectId String @id
|
||||
runId String @unique
|
||||
holderUserId String?
|
||||
acquiredAt DateTime @default(now())
|
||||
expiresAt DateTime?
|
||||
acquiredAt DateTime @default(now())
|
||||
heartbeatAt DateTime?
|
||||
expiresAt DateTime?
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
run AgentRun @relation(fields: [runId], references: [id], onDelete: Cascade)
|
||||
holder User? @relation("lockHolder", fields: [holderUserId], references: [id], onDelete: SetNull)
|
||||
run AgentRun @relation(fields: [runId], references: [id], onDelete: Cascade)
|
||||
holder User? @relation("lockHolder", fields: [holderUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([expiresAt])
|
||||
}
|
||||
@@ -218,34 +388,31 @@ enum PermissionResourceType {
|
||||
}
|
||||
|
||||
/// ADR-0004 PermissionGrant: resource × principal × role.
|
||||
/// `principal` is an opaque string (principal sub-typology OPEN, ADR-0004).
|
||||
/// The compound unique covers "one active grant per (resource, principal, role)"
|
||||
/// — revoked rows keep `revokedAt` set, so a re-grant after revocation is a new
|
||||
/// row, not a conflict.
|
||||
/// ADR-0019 replaces the old opaque `principal` with typed
|
||||
/// `principalType/principalId` so user/team/Feishu principals compose through
|
||||
/// the same authorization path.
|
||||
model PermissionGrant {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
resourceType PermissionResourceType
|
||||
resourceId String
|
||||
principal String
|
||||
principalType PrincipalType
|
||||
principalId String
|
||||
role PermissionRole
|
||||
createdByUserId String?
|
||||
createdAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
revokedAt DateTime?
|
||||
|
||||
project Project? @relation("projectGrants", fields: [resourceId], references: [id], onDelete: Cascade)
|
||||
createdBy User? @relation("grantCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||||
createdBy User? @relation("grantCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([resourceType, resourceId, principal, role, revokedAt])
|
||||
@@index([resourceType, resourceId, revokedAt])
|
||||
@@index([principal, revokedAt])
|
||||
@@index([principalType, principalId, revokedAt])
|
||||
}
|
||||
|
||||
/// ADR-0004 PermissionSettings: six policy knobs, values OPEN. Stored as one
|
||||
/// opaque string column each; the enum/value-domain is decided by admin policy,
|
||||
/// not by this schema. `resourceType`+`resourceId` identify the resource.
|
||||
/// Related to Project only when resourceType=PROJECT (null otherwise).
|
||||
/// opaque string column each. ADR-0019 pins `agentTrigger` values used by the
|
||||
/// authorizer: ROLE, MANAGE_ONLY, DISABLED.
|
||||
model PermissionSettings {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
resourceType PermissionResourceType
|
||||
resourceId String
|
||||
externalShare String
|
||||
@@ -254,41 +421,38 @@ model PermissionSettings {
|
||||
collaboratorMgmt String
|
||||
agentTrigger String
|
||||
agentCancel String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
project Project? @relation("projectSettings", fields: [resourceId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([resourceType, resourceId])
|
||||
@@index([resourceType, resourceId])
|
||||
}
|
||||
|
||||
|
||||
/// Per-role agent trigger grant. Orthogonal to ADR-0004's PermissionGrant:
|
||||
/// ADR-0004 decides "can trigger an agent at all" (triggerAgent capability,
|
||||
/// edit+ role); this table decides "can trigger *which* agent role" (e.g.
|
||||
/// /review vs /draft). Two gates in series — both must pass.
|
||||
///
|
||||
/// `roleId` is an opaque string matching RoleEntry.id (ADR-0017: roles are
|
||||
/// data, not a code enum). `principal` mirrors ADR-0004's opaque principal
|
||||
/// (sub-typology OPEN). A project-scoped row grants the role on that project;
|
||||
/// data, not a code enum). ADR-0019 makes the principal typed. A project-scoped
|
||||
/// row grants the role on that project;
|
||||
/// the compound unique covers "one active grant per (project, principal, role)".
|
||||
/// Revocation via `revokedAt`, same pattern as PermissionGrant.
|
||||
model RoleTriggerGrant {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
roleId String
|
||||
principal String
|
||||
principalType PrincipalType
|
||||
principalId String
|
||||
createdByUserId String?
|
||||
createdAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
revokedAt DateTime?
|
||||
|
||||
project Project @relation("projectRoleGrants", fields: [projectId], references: [id], onDelete: Cascade)
|
||||
createdBy User? @relation("roleGrantCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([projectId, roleId, principal, revokedAt])
|
||||
@@index([projectId, roleId, revokedAt])
|
||||
@@index([principal, revokedAt])
|
||||
@@index([principalType, principalId, revokedAt])
|
||||
}
|
||||
|
||||
// --- Audit (ADR Audit, content OPEN) -------------------------------------
|
||||
@@ -331,3 +495,49 @@ model FeishuEventReceipt {
|
||||
@@index([messageId])
|
||||
@@index([receivedAt])
|
||||
}
|
||||
|
||||
// --- Structured run history (ADR-0003 Memory) ----------------------------
|
||||
|
||||
/// Per-message record of the agent loop. The transcript JSONL file remains the
|
||||
/// agent's own read-back memory (runner loads it to seed the next run); these
|
||||
/// rows are the queryable/auditable projection for admin APIs. ADR-0003:
|
||||
/// session messages ≠ Feishu group chat history.
|
||||
model AgentMessage {
|
||||
id String @id @default(cuid())
|
||||
sessionId String
|
||||
runId String?
|
||||
role String
|
||||
content String
|
||||
attachments Json
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
session AgentSession @relation("sessionMessages", fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
run AgentRun? @relation("runMessages", fields: [runId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([sessionId, createdAt])
|
||||
@@index([runId])
|
||||
}
|
||||
|
||||
/// File change captured during a run (writeFile/rename/delete in workspace).
|
||||
/// before/after hash + diff for audit and admin review. Linked to the run
|
||||
/// that produced it; the tool's execute closure records via a sink the runner
|
||||
/// injects into ToolContext (A-3 file-change capture).
|
||||
model AgentFileChange {
|
||||
id String @id @default(cuid())
|
||||
runId String
|
||||
projectId String
|
||||
path String
|
||||
operation String
|
||||
beforeHash String?
|
||||
afterHash String?
|
||||
diff String?
|
||||
metadata Json
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
run AgentRun @relation("runFileChanges", fields: [runId], references: [id], onDelete: Cascade)
|
||||
project Project @relation("projectFileChanges", fields: [projectId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([runId])
|
||||
@@index([projectId])
|
||||
@@index([path])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
export function formatRunCostLine(costUsd: number | undefined): string {
|
||||
return costUsd === undefined
|
||||
? "本次成本: 未记录"
|
||||
: `本次成本: ${formatUsd(costUsd)}`;
|
||||
}
|
||||
|
||||
export function formatInteger(value: number): string {
|
||||
return new Intl.NumberFormat("en-US").format(value);
|
||||
}
|
||||
|
||||
export function formatUsd(value: number): string {
|
||||
const digits = value > 0 && value < 0.01 ? 4 : 2;
|
||||
return `$${value.toFixed(digits)}`;
|
||||
}
|
||||
|
||||
export function decimalToNumberOrNull(value: unknown): number | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
const numberValue = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(numberValue)) {
|
||||
throw new Error("invalid cost value");
|
||||
}
|
||||
return numberValue;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
createDefaultModelRegistry,
|
||||
defaultSonnetModel,
|
||||
type Env,
|
||||
} from "../settings/runtime.js";
|
||||
+12
-8
@@ -17,10 +17,11 @@
|
||||
*
|
||||
* A role bundles everything that distinguishes one agent persona from another:
|
||||
* model, system prompt, and the tool surface (files / cph / feishu / skills /
|
||||
* mcps). Per-run tool whitelisting is enforced by {@link ToolRegistry.subset};
|
||||
* the model never sees tools outside its role's whitelist, even if it tries to
|
||||
* call them — security does not rely on the system prompt.
|
||||
* mcps). Per-run tool whitelisting is enforced by the Claude SDK runner and
|
||||
* the cph_hub MCP server builder; security does not rely on the system prompt.
|
||||
*/
|
||||
import { assertSupportedRoleTools } from "./roleTools.js";
|
||||
|
||||
export interface RoleEntry {
|
||||
readonly id: string;
|
||||
/** Human label for the teacher-side switcher UX. */
|
||||
@@ -29,16 +30,16 @@ export interface RoleEntry {
|
||||
readonly defaultModel: string | undefined;
|
||||
/**
|
||||
* System prompt seeding the agent's persona/instructions. Prepended to the
|
||||
* run's messages only at session start (resume reads it from the transcript,
|
||||
* see runner.ts). `undefined` ⇒ no system prompt (bare run).
|
||||
* run's messages only at session start; Claude SDK resume restores later
|
||||
* turns from the provider session. `undefined` ⇒ no system prompt (bare run).
|
||||
*/
|
||||
readonly systemPrompt: string | undefined;
|
||||
readonly systemPrompt?: string | undefined;
|
||||
/**
|
||||
* Tool names this role may use (whitelist). `undefined` ⇒ the full registered
|
||||
* set (back-compat / unrestricted roles). An empty array ⇒ no tools at all.
|
||||
* Names not present in the registry are silently dropped at run setup.
|
||||
* Invalid names fail fast when settings are loaded or the run is set up.
|
||||
*/
|
||||
readonly tools: readonly string[] | undefined;
|
||||
readonly tools?: readonly string[] | undefined;
|
||||
}
|
||||
|
||||
/** A model the admin has enabled for use by the Hub. */
|
||||
@@ -72,6 +73,9 @@ export class InMemoryModelRegistry implements ModelRegistry {
|
||||
|
||||
constructor(models: readonly ModelEntry[], roles: readonly RoleEntry[] = []) {
|
||||
this.models = models;
|
||||
for (const role of roles) {
|
||||
if (role.tools !== undefined) assertSupportedRoleTools(role.tools);
|
||||
}
|
||||
this.roles = new Map(roles.map((r) => [r.id, r]));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* OpenRouter model factory.
|
||||
*
|
||||
* ADR-0017: the agent layer keeps role/model resolution separate from provider
|
||||
* construction. A run carries a resolved model id, and switching model still
|
||||
* means a new provider-bound session.
|
||||
*/
|
||||
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
||||
import type { LanguageModel } from "ai";
|
||||
|
||||
export function createModelFactory(): (modelId: string) => LanguageModel {
|
||||
const provider = createOpenAICompatible({
|
||||
name: "openrouter",
|
||||
baseURL: process.env.OPENROUTER_BASE_URL ?? "https://openrouter.ai/api/v1",
|
||||
headers: { Authorization: `Bearer ${process.env.OPENROUTER_API_KEY ?? ""}` },
|
||||
});
|
||||
return (modelId: string) => provider(modelId);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
export const DEFAULT_CLAUDE_BUILT_IN_TOOLS = ["Read", "Write", "Bash", "Glob", "Grep"] as const;
|
||||
|
||||
export const CPH_HUB_MCP_SERVER_NAME = "cph_hub";
|
||||
export const CPH_HUB_MCP_TOOL_IDS = ["send_file", "feishu_read_context", "request_approval"] as const;
|
||||
|
||||
export type CphHubMcpToolId = (typeof CPH_HUB_MCP_TOOL_IDS)[number];
|
||||
|
||||
export interface ClaudeSdkToolConfig {
|
||||
readonly tools: readonly string[];
|
||||
readonly allowedTools: readonly string[];
|
||||
}
|
||||
|
||||
const ROLE_TOOL_TO_CLAUDE_BUILT_INS = new Map<string, readonly string[]>([
|
||||
["read_file", ["Read"]],
|
||||
["write_file", ["Write"]],
|
||||
["list_files", ["Glob"]],
|
||||
["search_files", ["Grep"]],
|
||||
["bash", ["Bash"]],
|
||||
// ADR-0017 replaced cph custom tools with Bash commands. Granting either
|
||||
// cph role tool therefore exposes the SDK Bash tool; cph-only Bash narrowing
|
||||
// would need a separate command-policy layer.
|
||||
["cph_check", ["Bash"]],
|
||||
["cph_build", ["Bash"]],
|
||||
["Read", ["Read"]],
|
||||
["Write", ["Write"]],
|
||||
["Bash", ["Bash"]],
|
||||
["Glob", ["Glob"]],
|
||||
["Grep", ["Grep"]],
|
||||
]);
|
||||
|
||||
const ROLE_TOOL_TO_CPH_HUB_MCP_TOOL = new Map<string, CphHubMcpToolId>([
|
||||
["send_file", "send_file"],
|
||||
["feishu_read_context", "feishu_read_context"],
|
||||
["request_approval", "request_approval"],
|
||||
["mcp__cph_hub__send_file", "send_file"],
|
||||
["mcp__cph_hub__feishu_read_context", "feishu_read_context"],
|
||||
["mcp__cph_hub__request_approval", "request_approval"],
|
||||
]);
|
||||
|
||||
const SUPPORTED_ROLE_TOOLS = new Set([
|
||||
...ROLE_TOOL_TO_CLAUDE_BUILT_INS.keys(),
|
||||
...ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.keys(),
|
||||
]);
|
||||
|
||||
export function claudeSdkToolConfigForRole(roleTools: readonly string[] | undefined): ClaudeSdkToolConfig {
|
||||
if (roleTools === undefined) {
|
||||
const mcpTools = CPH_HUB_MCP_TOOL_IDS.map(claudeMcpToolName);
|
||||
return {
|
||||
tools: [...DEFAULT_CLAUDE_BUILT_IN_TOOLS],
|
||||
allowedTools: [...DEFAULT_CLAUDE_BUILT_IN_TOOLS, ...mcpTools],
|
||||
};
|
||||
}
|
||||
|
||||
const builtIns: string[] = [];
|
||||
const allowedTools: string[] = [];
|
||||
for (const roleTool of roleTools) {
|
||||
assertSupportedRoleTool(roleTool);
|
||||
for (const tool of ROLE_TOOL_TO_CLAUDE_BUILT_INS.get(roleTool) ?? []) {
|
||||
pushUnique(builtIns, tool);
|
||||
pushUnique(allowedTools, tool);
|
||||
}
|
||||
|
||||
const mcpTool = ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.get(roleTool);
|
||||
if (mcpTool !== undefined) {
|
||||
pushUnique(allowedTools, claudeMcpToolName(mcpTool));
|
||||
}
|
||||
}
|
||||
|
||||
return { tools: builtIns, allowedTools };
|
||||
}
|
||||
|
||||
export function cphHubMcpToolsForRole(roleTools: readonly string[] | undefined): readonly CphHubMcpToolId[] {
|
||||
if (roleTools === undefined) return [...CPH_HUB_MCP_TOOL_IDS];
|
||||
|
||||
const tools: CphHubMcpToolId[] = [];
|
||||
for (const roleTool of roleTools) {
|
||||
assertSupportedRoleTool(roleTool);
|
||||
const mcpTool = ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.get(roleTool);
|
||||
if (mcpTool !== undefined) pushUnique(tools, mcpTool);
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
|
||||
export function roleToolsAllow(roleTools: readonly string[] | undefined, roleTool: string): boolean {
|
||||
if (roleTools === undefined) return true;
|
||||
for (const configured of roleTools) {
|
||||
assertSupportedRoleTool(configured);
|
||||
if (configured === roleTool) return true;
|
||||
if (ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.get(configured) === roleTool) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function assertSupportedRoleTools(roleTools: readonly string[]): void {
|
||||
for (const roleTool of roleTools) assertSupportedRoleTool(roleTool);
|
||||
}
|
||||
|
||||
function claudeMcpToolName(tool: CphHubMcpToolId): string {
|
||||
return `mcp__${CPH_HUB_MCP_SERVER_NAME}__${tool}`;
|
||||
}
|
||||
|
||||
function assertSupportedRoleTool(roleTool: string): void {
|
||||
if (!SUPPORTED_ROLE_TOOLS.has(roleTool)) {
|
||||
throw new Error(`unknown role tool id: ${roleTool}`);
|
||||
}
|
||||
}
|
||||
|
||||
function pushUnique<T>(items: T[], item: T): void {
|
||||
if (!items.includes(item)) items.push(item);
|
||||
}
|
||||
+282
-98
@@ -1,140 +1,324 @@
|
||||
/**
|
||||
* Provider-bound agent runner.
|
||||
* Claude Code SDK agent runner.
|
||||
*
|
||||
* The AI SDK owns tool-call dispatch and message folding. The Hub owns the
|
||||
* per-run tool context, role/model selection, transcript persistence, and the
|
||||
* ADR-0017 session boundary: a run is bound to one model. Switching model is a
|
||||
* new run + new session; cross-run continuity is carried by project
|
||||
* memory/anchors (ADR-0003), not by this runner.
|
||||
* Uses `@anthropic-ai/claude-agent-sdk`'s `query()` as the agent loop. The SDK
|
||||
* owns tool dispatch, context compaction, streaming — we just map its events
|
||||
* to the Feishu streaming callback.
|
||||
*
|
||||
* Built-in tools (Read, Write, Bash, Glob, Grep) cover our entire tool surface:
|
||||
* - read_file → Read
|
||||
* - write_file → Write
|
||||
* - list_files → Glob
|
||||
* - cph_check / cph_build → Bash (`cph check .`, `cph build . --target student`)
|
||||
* No custom tools needed — the SDK's Bash tool runs cph directly.
|
||||
*
|
||||
* ADR-0017 update: this drops provider-agnosticism. Claude Code SDK is
|
||||
* Anthropic-specific. The tradeoff: we get compaction, tool approval, partial
|
||||
* message streaming, and a battle-tested agent loop — capabilities we'd have
|
||||
* to build ourselves with a generic provider.
|
||||
*
|
||||
* ADR-0018: the agent execution surface is bounded by the run's workspace.
|
||||
* The SDK sandbox (bubblewrap on Linux, seatbelt on macOS) confines the Claude
|
||||
* Code process and its subprocesses at the OS level — writes are confined to
|
||||
* the workspace (`sandbox.filesystem.allowWrite`), sensitive host paths are
|
||||
* denied reads (`denyRead`), and `failIfUnavailable` hard-fails if the sandbox
|
||||
* can't start (never run unsandboxed). This upholds `AgentFileOp.Authorized`
|
||||
* (ADR-0018 / `Spec.System.AgentSurface`) without re-implementing the
|
||||
* `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox
|
||||
* is the mechanism, the contract pins the invariant.
|
||||
*/
|
||||
import { generateText, stepCountIs, type LanguageModel, type ModelMessage } from "ai";
|
||||
import type { ToolContext, ToolRegistry } from "./tools.js";
|
||||
import { readTranscript, appendTranscript } from "./transcript.js";
|
||||
import { homedir } from "node:os";
|
||||
import { query, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKUserMessage, type SDKResultMessage, type SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { claudeSdkToolConfigForRole } from "./roleTools.js";
|
||||
|
||||
export type ModelFactory = (modelId: string) => LanguageModel;
|
||||
|
||||
/** What the runner needs about the project to seed and bound a run. */
|
||||
export interface ProjectContext {
|
||||
readonly projectId: string;
|
||||
/** ADR-0001 binding - the chat this project is bound to. */
|
||||
readonly boundChatId: string;
|
||||
/** Absolute path to the curriculum engineering-file workspace. */
|
||||
readonly workspaceDir: string;
|
||||
}
|
||||
|
||||
export type StreamEvent =
|
||||
| { readonly type: "text-delta"; readonly text: string }
|
||||
| { readonly type: "thinking-delta"; readonly text: string }
|
||||
| { readonly type: "tool-start"; readonly toolName: string; readonly toolUseId: string }
|
||||
| { readonly type: "tool-end"; readonly toolName: string; readonly toolUseId: string; readonly input: unknown; readonly durationMs?: number }
|
||||
| { readonly type: "tool-result"; readonly toolUseId: string; readonly toolName: string; readonly result: string; readonly isError: boolean; readonly durationMs?: number }
|
||||
| { readonly type: "finish" };
|
||||
|
||||
export type StreamCallback = (event: StreamEvent) => void;
|
||||
|
||||
export interface RunRequest {
|
||||
readonly prompt: string;
|
||||
/** Resolved model id (already chosen by the caller per ADR-0017). */
|
||||
readonly model: string;
|
||||
readonly model: string | undefined;
|
||||
readonly project: ProjectContext;
|
||||
readonly systemPrompt: string | undefined;
|
||||
/** Iteration cap before the run is returned as `length`. Default 25. */
|
||||
readonly maxIterations?: number;
|
||||
/** Per-role tool whitelist (ADR-0017). Undefined means all registered tools. */
|
||||
readonly toolWhitelist?: readonly string[] | undefined;
|
||||
/** Path to the session transcript JSONL. If set, prior messages are loaded
|
||||
* from it before the run, and new messages are appended after. */
|
||||
readonly transcriptPath?: string;
|
||||
readonly providerEnv?: Record<string, string | undefined> | undefined;
|
||||
readonly resumeSessionId?: string | undefined;
|
||||
/**
|
||||
* RoleEntry.tools from admin/runtime settings. Values are Hub role tool ids
|
||||
* (for example read_file/cph_check/send_file), mapped to Claude SDK tool
|
||||
* names at run setup. `undefined` means the full supported tool surface; []
|
||||
* means no tools.
|
||||
*/
|
||||
readonly tools?: readonly string[] | undefined;
|
||||
readonly mcpServers?: Record<string, McpServerConfig> | undefined;
|
||||
readonly maxTurns?: number;
|
||||
readonly runId: string;
|
||||
readonly sessionId: string;
|
||||
readonly prisma: PrismaClient;
|
||||
readonly onStream?: StreamCallback;
|
||||
/**
|
||||
* Abort controller for the run. Aborting the signal interrupts the SDK query
|
||||
* (it stops the agent loop and cleans up); the run resolves with status
|
||||
* "interrupted". The caller owns the controller and triggers abort.
|
||||
*/
|
||||
readonly abortController?: AbortController | undefined;
|
||||
}
|
||||
|
||||
export type RunStatus = "completed" | "length" | "failed";
|
||||
export type RunStatus = "completed" | "length" | "failed" | "interrupted";
|
||||
|
||||
export interface RunResult {
|
||||
readonly status: RunStatus;
|
||||
/** Full transcript of the run, for audit (ADR Audit, content OPEN). */
|
||||
readonly messages: readonly ModelMessage[];
|
||||
readonly text: string;
|
||||
readonly usage: { inputTokens: number; outputTokens: number };
|
||||
/** Provider- or gateway-reported cost for this run, in USD. */
|
||||
readonly costUsd?: number | undefined;
|
||||
readonly numTurns: number;
|
||||
readonly sdkSessionId?: string | undefined;
|
||||
readonly error?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_ITERATIONS = 25;
|
||||
const DEFAULT_MAX_TURNS = 25;
|
||||
|
||||
export async function runAgent(
|
||||
modelFactory: ModelFactory,
|
||||
tools: ToolRegistry,
|
||||
req: RunRequest,
|
||||
): Promise<RunResult> {
|
||||
const ctx: ToolContext = {
|
||||
runId: cryptoRandomId(),
|
||||
projectId: req.project.projectId,
|
||||
boundChatId: req.project.boundChatId,
|
||||
workspaceDir: req.project.workspaceDir,
|
||||
/**
|
||||
* Host paths the agent may not read (ADR-0018 defense-in-depth). The sandbox
|
||||
* confines writes to the workspace; these deny reads of credentials/secrets
|
||||
* the process could otherwise see (read-only mounts are still readable). The
|
||||
* workspace itself is never in this list — it's writable by `allowWrite`.
|
||||
* Extend via `CPH_SANDBOX_EXTRA_DENY_READ` (colon-separated) for deployments
|
||||
* with additional secret locations.
|
||||
*/
|
||||
const SENSITIVE_READ_PATHS: readonly string[] = (() => {
|
||||
const home = homedir();
|
||||
const base = [
|
||||
"/etc",
|
||||
"/root",
|
||||
"/var/log",
|
||||
"/proc",
|
||||
"/sys",
|
||||
`${home}/.ssh`,
|
||||
`${home}/.aws`,
|
||||
`${home}/.config/gcloud`,
|
||||
`${home}/.gnupg`,
|
||||
];
|
||||
const extra = process.env["CPH_SANDBOX_EXTRA_DENY_READ"];
|
||||
return extra !== undefined && extra !== ""
|
||||
? [...base, ...extra.split(":").filter((p) => p !== "")]
|
||||
: base;
|
||||
})();
|
||||
|
||||
export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
const onStream = req.onStream;
|
||||
const cap = req.maxTurns ?? DEFAULT_MAX_TURNS;
|
||||
|
||||
// Lock heartbeat: refresh on each step. Best-effort.
|
||||
const heartbeat = async () => {
|
||||
try {
|
||||
await req.prisma.projectAgentLock.update({
|
||||
where: { runId: req.runId },
|
||||
data: { heartbeatAt: new Date() },
|
||||
});
|
||||
} catch { /* best-effort */ }
|
||||
};
|
||||
const aiTools = tools.build(ctx, req.toolWhitelist);
|
||||
|
||||
// Load prior session messages from transcript (continuity across @bot calls
|
||||
// in the same session). ADR-0003: session messages != group chat history.
|
||||
let messages: ModelMessage[] = req.transcriptPath !== undefined ? await readTranscript(req.transcriptPath) : [];
|
||||
const loadedCount = messages.length;
|
||||
if (req.systemPrompt !== undefined && messages.length === 0) {
|
||||
// System prompt only at the very start of a session; on resume it's
|
||||
// already in the transcript.
|
||||
messages = [{ role: "system", content: req.systemPrompt }];
|
||||
}
|
||||
messages = [...messages, { role: "user", content: req.prompt }];
|
||||
|
||||
const cap = req.maxIterations ?? DEFAULT_MAX_ITERATIONS;
|
||||
|
||||
let fullText = "";
|
||||
let usage = { inputTokens: 0, outputTokens: 0 };
|
||||
let costUsd: number | undefined;
|
||||
let numTurns = 0;
|
||||
let sdkSessionId: string | undefined;
|
||||
let error: string | undefined;
|
||||
try {
|
||||
const result = await generateText({
|
||||
model: modelFactory(req.model),
|
||||
messages,
|
||||
allowSystemInMessages: true,
|
||||
tools: aiTools,
|
||||
stopWhen: stepCountIs(cap),
|
||||
});
|
||||
const allMessages: ModelMessage[] = [...messages, ...result.responseMessages];
|
||||
await appendTranscriptIfSet(req, allMessages, loadedCount);
|
||||
await persistAgentMessage(req, "user", req.prompt);
|
||||
const toolConfig = claudeSdkToolConfigForRole(req.tools);
|
||||
|
||||
const stoppedByStepCap = result.finishReason === "tool-calls" && result.steps.length >= cap;
|
||||
const status: RunStatus =
|
||||
result.finishReason === "stop"
|
||||
? "completed"
|
||||
: result.finishReason === "length" || stoppedByStepCap
|
||||
? "length"
|
||||
: "failed";
|
||||
const error =
|
||||
status === "failed"
|
||||
? `finishReason=${result.finishReason}`
|
||||
: stoppedByStepCap
|
||||
? `exceeded maxIterations=${cap}`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
status,
|
||||
messages: allMessages,
|
||||
usage: {
|
||||
inputTokens: result.usage.inputTokens ?? 0,
|
||||
outputTokens: result.usage.outputTokens ?? 0,
|
||||
const options: Parameters<typeof query>[0]["options"] = {
|
||||
cwd: req.project.workspaceDir,
|
||||
tools: [...toolConfig.tools],
|
||||
allowedTools: [...toolConfig.allowedTools],
|
||||
maxTurns: cap,
|
||||
includePartialMessages: true,
|
||||
// ADR-0018: bypass interactive prompts (headless server); the sandbox
|
||||
// below is the hard boundary, not the permission-prompt layer.
|
||||
permissionMode: "bypassPermissions" as const,
|
||||
allowDangerouslySkipPermissions: true,
|
||||
// ADR-0018: OS-level sandbox confines the agent process + its Bash
|
||||
// subprocesses. Writes confined to the workspace; sensitive host paths
|
||||
// denied reads; network open. failIfUnavailable hard-fails if the
|
||||
// sandbox can't start — never run unsandboxed.
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
failIfUnavailable: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
filesystem: {
|
||||
allowWrite: [req.project.workspaceDir],
|
||||
denyRead: [...SENSITIVE_READ_PATHS],
|
||||
},
|
||||
},
|
||||
};
|
||||
if (req.systemPrompt !== undefined) options.systemPrompt = req.systemPrompt;
|
||||
if (req.model !== undefined) options.model = req.model;
|
||||
if (req.providerEnv !== undefined) options.env = { ...process.env, ...req.providerEnv };
|
||||
if (req.resumeSessionId !== undefined) options.resume = req.resumeSessionId;
|
||||
if (req.mcpServers !== undefined) options.mcpServers = req.mcpServers;
|
||||
if (req.abortController !== undefined) options.abortController = req.abortController;
|
||||
|
||||
const conversation = query({
|
||||
prompt: req.prompt,
|
||||
options,
|
||||
});
|
||||
|
||||
// Track tool start timestamps for duration calculation
|
||||
const toolStartTimestamps = new Map<string, number>();
|
||||
|
||||
for await (const message of conversation) {
|
||||
switch (message.type) {
|
||||
case "stream_event": {
|
||||
const evt = (message as SDKPartialAssistantMessage).event;
|
||||
if (evt.type === "content_block_delta" && evt.delta.type === "text_delta") {
|
||||
onStream?.({ type: "text-delta", text: evt.delta.text });
|
||||
}
|
||||
if (evt.type === "content_block_delta" && evt.delta.type === "thinking_delta") {
|
||||
onStream?.({ type: "thinking-delta", text: evt.delta.thinking });
|
||||
}
|
||||
if (evt.type === "content_block_start" && evt.content_block.type === "tool_use") {
|
||||
const toolUseId = evt.content_block.id;
|
||||
toolStartTimestamps.set(toolUseId, Date.now());
|
||||
onStream?.({ type: "tool-start", toolName: evt.content_block.name, toolUseId });
|
||||
}
|
||||
if (evt.type === "content_block_stop") {
|
||||
// Tool end is attributed from the assistant message below.
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "assistant": {
|
||||
const msg = (message as SDKAssistantMessage).message;
|
||||
await heartbeat();
|
||||
numTurns++;
|
||||
let assistantText = "";
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "text" && typeof block.text === "string") {
|
||||
fullText += block.text;
|
||||
assistantText += block.text;
|
||||
}
|
||||
if (block.type === "tool_use") {
|
||||
const durationMs = toolStartTimestamps.has(block.id)
|
||||
? Date.now() - (toolStartTimestamps.get(block.id) ?? 0)
|
||||
: undefined;
|
||||
onStream?.({
|
||||
type: "tool-end",
|
||||
toolName: block.name,
|
||||
toolUseId: block.id,
|
||||
input: block.input,
|
||||
...(durationMs !== undefined ? { durationMs } : {}),
|
||||
});
|
||||
}
|
||||
if (block.type === "thinking" && typeof block.thinking === "string") {
|
||||
// Complete thinking block from the assistant message.
|
||||
// Stream deltas already delivered this; no separate event needed.
|
||||
}
|
||||
}
|
||||
await persistAgentMessage(req, "assistant", assistantText);
|
||||
if (msg.usage !== undefined) {
|
||||
usage.inputTokens += msg.usage.input_tokens ?? 0;
|
||||
usage.outputTokens += msg.usage.output_tokens ?? 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "user": {
|
||||
// User messages carry tool_result blocks — what each tool returned.
|
||||
const msg = (message as SDKUserMessage).message;
|
||||
const content = msg.content;
|
||||
if (!Array.isArray(content)) break;
|
||||
for (const block of content) {
|
||||
if (typeof block !== "object" || block === null || block.type !== "tool_result") continue;
|
||||
// Narrowed to BetaToolResultBlockParam by discriminant + field presence
|
||||
if (!("tool_use_id" in block)) continue;
|
||||
const toolUseId = block.tool_use_id;
|
||||
const isError = block.is_error === true;
|
||||
const resultText = extractToolResultText(block.content);
|
||||
const durationMs = toolStartTimestamps.get(toolUseId);
|
||||
onStream?.({
|
||||
type: "tool-result",
|
||||
toolUseId,
|
||||
toolName: toolUseId,
|
||||
result: resultText,
|
||||
isError,
|
||||
...(durationMs !== undefined ? { durationMs: Date.now() - durationMs } : {}),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "result": {
|
||||
const result = message as SDKResultMessage;
|
||||
sdkSessionId = result.session_id;
|
||||
costUsd = Number.isFinite(result.total_cost_usd) ? result.total_cost_usd : undefined;
|
||||
if (result.subtype !== "success") {
|
||||
error = `result_${result.subtype}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onStream?.({ type: "finish" });
|
||||
return {
|
||||
status: error !== undefined ? "failed" : "completed",
|
||||
text: fullText,
|
||||
usage,
|
||||
...(costUsd !== undefined ? { costUsd } : {}),
|
||||
numTurns,
|
||||
sdkSessionId,
|
||||
...(error !== undefined ? { error } : {}),
|
||||
};
|
||||
} catch (e) {
|
||||
await appendTranscriptIfSet(req, messages, loadedCount);
|
||||
const aborted = req.abortController?.signal.aborted === true;
|
||||
return {
|
||||
status: "failed",
|
||||
messages,
|
||||
usage: { inputTokens: 0, outputTokens: 0 },
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
status: aborted ? "interrupted" : "failed",
|
||||
text: fullText,
|
||||
usage,
|
||||
...(costUsd !== undefined ? { costUsd } : {}),
|
||||
numTurns,
|
||||
sdkSessionId,
|
||||
...(aborted ? {} : { error: e instanceof Error ? e.message : String(e) }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Append only the messages produced this run (skip the `loadedCount` prior
|
||||
/// ones already in the file). Errors are swallowed - a transcript write
|
||||
/// failure must not lose the run result.
|
||||
async function appendTranscriptIfSet(req: RunRequest, messages: readonly ModelMessage[], loadedCount: number): Promise<void> {
|
||||
if (req.transcriptPath === undefined) return;
|
||||
const newMessages = messages.slice(loadedCount);
|
||||
if (newMessages.length === 0) return;
|
||||
async function persistAgentMessage(req: RunRequest, role: string, content: string): Promise<void> {
|
||||
if (content === "") return;
|
||||
try {
|
||||
await appendTranscript(req.transcriptPath, newMessages);
|
||||
await req.prisma.agentMessage.create({
|
||||
data: {
|
||||
sessionId: req.sessionId,
|
||||
runId: req.runId,
|
||||
role,
|
||||
content,
|
||||
attachments: [],
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Swallow - transcript is best-effort persistence; the run result is
|
||||
// returned regardless. ADR-0002 lock ensures no concurrent writer.
|
||||
// Best-effort projection: a history write failure must not break the run.
|
||||
}
|
||||
}
|
||||
|
||||
function cryptoRandomId(): string {
|
||||
return globalThis.crypto.randomUUID();
|
||||
function extractToolResultText(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
const parts: string[] = [];
|
||||
for (const part of content) {
|
||||
if (typeof part === "object" && part !== null && "type" in part && part.type === "text" && "text" in part && typeof part.text === "string") {
|
||||
parts.push(part.text);
|
||||
}
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
@@ -24,6 +24,11 @@ export interface ToolContext {
|
||||
readonly boundChatId: string;
|
||||
/** Absolute path to the project's curriculum engineering-file workspace. */
|
||||
readonly workspaceDir: string;
|
||||
/** Optional sink for file-change capture (A-3). The writeFile tool calls
|
||||
* this with before/after hash when it modifies a file; the runner injects
|
||||
* a collector that flushes to AgentFileChange at run end. Undefined when
|
||||
* the runner doesn't wire capture (e.g. unit tests). */
|
||||
readonly onFileChange?: (change: { path: string; operation: "CREATE" | "UPDATE" | "DELETE" | "RENAME"; beforeHash: string | null; afterHash: string | null; diff: string | null }) => void;
|
||||
}
|
||||
|
||||
export type ToolDefinition = Tool;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* surface; the Hub agent is narrower than a general coding agent.
|
||||
*/
|
||||
import { readFile, writeFile, readdir, mkdir } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { join, resolve, relative, isAbsolute } from "node:path";
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
@@ -67,8 +68,39 @@ export function writeFileTool(ctx: ToolContext): ToolDefinition {
|
||||
execute: async (args): Promise<string> => {
|
||||
try {
|
||||
const full = confine(args.path, ctx.workspaceDir);
|
||||
// A-3 file-change capture: compute before/after hashes around the write.
|
||||
// A hashing failure must not block the write — skip the sink in that case.
|
||||
let beforeHash: string | null = null;
|
||||
let afterHash: string | null = null;
|
||||
let operation: "CREATE" | "UPDATE" | null = null;
|
||||
try {
|
||||
let oldContent: string | null;
|
||||
try {
|
||||
oldContent = await readFile(full, "utf8");
|
||||
} catch (e) {
|
||||
if (e !== null && typeof e === "object" && "code" in e && e.code === "ENOENT") {
|
||||
oldContent = null;
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
afterHash = createHash("sha256").update(args.content).digest("hex");
|
||||
beforeHash = oldContent === null ? null : createHash("sha256").update(oldContent).digest("hex");
|
||||
if (beforeHash === null) {
|
||||
operation = "CREATE";
|
||||
} else if (beforeHash === afterHash) {
|
||||
operation = null;
|
||||
} else {
|
||||
operation = "UPDATE";
|
||||
}
|
||||
} catch {
|
||||
operation = null;
|
||||
}
|
||||
await mkdir(join(full, ".."), { recursive: true });
|
||||
await writeFile(full, args.content, "utf8");
|
||||
if (operation !== null && ctx.onFileChange !== undefined) {
|
||||
ctx.onFileChange({ path: args.path, operation, beforeHash, afterHash, diff: null });
|
||||
}
|
||||
return JSON.stringify({ ok: true, path: args.path, bytes: args.content.length });
|
||||
} catch (e) {
|
||||
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
export interface ApprovalResult {
|
||||
readonly action: string;
|
||||
readonly resolvedBy: string;
|
||||
}
|
||||
|
||||
export interface PendingApproval {
|
||||
readonly messageId: string;
|
||||
readonly chatId: string;
|
||||
readonly resolve: (result: ApprovalResult) => void;
|
||||
}
|
||||
|
||||
export interface ApprovalManagerOptions {
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
|
||||
export class ApprovalManager {
|
||||
private readonly timeoutMs: number;
|
||||
private pending = new Map<string, PendingApproval>();
|
||||
|
||||
constructor(options: ApprovalManagerOptions = {}) {
|
||||
this.timeoutMs = options.timeoutMs ?? 5 * 60 * 1000;
|
||||
}
|
||||
|
||||
register(messageId: string, chatId: string): Promise<ApprovalResult> {
|
||||
return new Promise<ApprovalResult>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.pending.delete(messageId);
|
||||
reject(new Error(`approval timed out after ${this.timeoutMs}ms`));
|
||||
}, this.timeoutMs);
|
||||
const unref = (timeout as { unref?: () => void }).unref;
|
||||
if (unref !== undefined) unref.call(timeout);
|
||||
|
||||
this.pending.set(messageId, {
|
||||
messageId,
|
||||
chatId,
|
||||
resolve: (result) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(result);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
resolve(messageId: string, action: string, resolvedBy: string): void {
|
||||
const pending = this.pending.get(messageId);
|
||||
if (pending === undefined) return;
|
||||
this.pending.delete(messageId);
|
||||
pending.resolve({ action, resolvedBy });
|
||||
}
|
||||
|
||||
hasPending(messageId: string): boolean {
|
||||
return this.pending.has(messageId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* Feishu interactive card builder for agent run output.
|
||||
*
|
||||
* Produces card JSON with:
|
||||
* 1. A collapsible tool-use panel (tool steps with status, params, results)
|
||||
* 2. A collapsible reasoning panel (thinking text)
|
||||
* 3. The streaming/final answer text (markdown)
|
||||
*
|
||||
* Adapted from openclaw-lark's builder.ts, simplified for our
|
||||
* message.patch-based approach (no CardKit 2.0 streaming_mode).
|
||||
* Uses JSON 1.0 card format with `collapsible_panel` and `markdown` tags.
|
||||
*/
|
||||
|
||||
import type { ToolUseTraceStep } from "./trace-store.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FeishuCard {
|
||||
readonly config: Record<string, unknown>;
|
||||
readonly elements: ReadonlyArray<unknown>;
|
||||
}
|
||||
|
||||
export type CardPhase = "thinking" | "streaming" | "complete";
|
||||
|
||||
const STEP_INDENT = "0px 0px 0px 22px";
|
||||
const MAX_TEXT_LENGTH = 7500; // Feishu card content limit ~30k; leave room for panels
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool name → icon mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TOOL_ICONS: Record<string, string> = {
|
||||
read: "doc-filled",
|
||||
write: "edit-filled",
|
||||
bash: "terminal-filled",
|
||||
glob: "search-filled",
|
||||
grep: "search-filled",
|
||||
edit: "edit-filled",
|
||||
send_file: "send-filled",
|
||||
request_approval: "thumb-up-filled",
|
||||
feishu_read_context: "search-filled",
|
||||
};
|
||||
|
||||
function toolIcon(toolName: string): string {
|
||||
const normalized = toolName.toLowerCase().replace(/^mcp_/, "");
|
||||
return TOOL_ICONS[normalized] ?? "tool-filled";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildAgentCard(params: {
|
||||
phase: CardPhase;
|
||||
text: string;
|
||||
reasoningText: string | undefined;
|
||||
toolUseSteps: ToolUseTraceStep[];
|
||||
toolUseElapsedMs: number | undefined;
|
||||
isError: boolean | undefined;
|
||||
interrupted: boolean | undefined;
|
||||
runId: string | undefined;
|
||||
}): Record<string, unknown> {
|
||||
const { phase, text, reasoningText, toolUseSteps, toolUseElapsedMs, isError, interrupted } = params;
|
||||
const elements: unknown[] = [];
|
||||
|
||||
// Tool-use panel (always present if there are steps)
|
||||
if (toolUseSteps.length > 0) {
|
||||
elements.push(buildToolUsePanel(toolUseSteps, toolUseElapsedMs, phase !== "complete"));
|
||||
} else if (phase === "thinking" || (phase === "streaming" && text === "")) {
|
||||
elements.push(buildPendingToolUsePanel());
|
||||
}
|
||||
|
||||
// Reasoning panel
|
||||
if (reasoningText !== undefined && reasoningText !== "") {
|
||||
if (phase === "streaming" && text === "") {
|
||||
// Still thinking: show reasoning inline
|
||||
elements.push({
|
||||
tag: "markdown",
|
||||
content: `\u{1F4AD} \u601d\u8003\u4E2D...\n\n${reasoningText}`,
|
||||
text_size: "notation",
|
||||
});
|
||||
} else if (phase === "complete") {
|
||||
// Collapsed reasoning panel in complete state
|
||||
elements.push(buildReasoningPanel(reasoningText));
|
||||
}
|
||||
}
|
||||
|
||||
// Main text content
|
||||
if (text !== "") {
|
||||
elements.push({
|
||||
tag: "markdown",
|
||||
content: truncateText(text, MAX_TEXT_LENGTH),
|
||||
});
|
||||
} else if (phase === "thinking" && toolUseSteps.length === 0 && (reasoningText === undefined || reasoningText === "")) {
|
||||
elements.push({
|
||||
tag: "markdown",
|
||||
content: "\u{1F4AD} \u601d\u8003\u4E2D...",
|
||||
text_size: "notation",
|
||||
});
|
||||
}
|
||||
|
||||
// Interrupt button while the run is live. The button carries the run id in
|
||||
// its action value; the card action handler aborts the run. Feishu's native
|
||||
// `confirm` dialog is the "are you sure" step — the action only fires after
|
||||
// the user confirms, so no second round-trip is needed.
|
||||
if (phase !== "complete" && params.runId !== undefined) {
|
||||
elements.push(buildInterruptAction(params.runId));
|
||||
}
|
||||
|
||||
// Footer for complete state
|
||||
if (phase === "complete") {
|
||||
const footerParts: string[] = [];
|
||||
if (interrupted === true) footerParts.push("\u{1F6D1} \u5DF2\u4E2D\u65AD");
|
||||
else if (isError === true) footerParts.push("\u274C \u5931\u8D25");
|
||||
else footerParts.push("\u2705 \u5B8C\u6210");
|
||||
if (toolUseElapsedMs !== undefined) {
|
||||
footerParts.push(formatElapsed(toolUseElapsedMs));
|
||||
}
|
||||
elements.push({
|
||||
tag: "div",
|
||||
text: {
|
||||
tag: "plain_text",
|
||||
content: footerParts.join(" \u00B7 "),
|
||||
text_size: "notation",
|
||||
text_color: "grey",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
config: { wide_screen_mode: true, update_multi: true },
|
||||
elements,
|
||||
};
|
||||
}
|
||||
|
||||
/** Action row with a single danger "interrupt" button for a live run. */
|
||||
function buildInterruptAction(runId: string): unknown {
|
||||
return {
|
||||
tag: "action",
|
||||
actions: [
|
||||
{
|
||||
tag: "button",
|
||||
text: { tag: "plain_text", content: "\u{1F6D1} \u4E2D\u65AD" },
|
||||
type: "danger",
|
||||
value: { interrupt_run: runId },
|
||||
confirm: {
|
||||
title: { tag: "plain_text", content: "\u786E\u8BA4\u4E2D\u65AD" },
|
||||
text: {
|
||||
tag: "plain_text",
|
||||
content: "\u786E\u5B9A\u8981\u4E2D\u65AD\u5F53\u524D\u8FD0\u884C\u5417\uFF1F\u4E2D\u65AD\u540E\u65E0\u6CD5\u6062\u590D\uFF0C\u5DF2\u4EA7\u51FA\u7684\u5185\u5BB9\u4F1A\u4FDD\u7559\u3002",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool-use panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildPendingToolUsePanel(): unknown {
|
||||
return {
|
||||
tag: "collapsible_panel",
|
||||
expanded: false,
|
||||
header: {
|
||||
title: {
|
||||
tag: "plain_text",
|
||||
content: "\u{1F6E0}\uFE0F \u7B49\u5F85\u5DE5\u5177\u6267\u884C",
|
||||
text_color: "grey",
|
||||
text_size: "notation",
|
||||
},
|
||||
vertical_align: "center",
|
||||
icon: {
|
||||
tag: "standard_icon",
|
||||
token: "down-small-ccm_outlined",
|
||||
color: "grey",
|
||||
size: "16px 16px",
|
||||
},
|
||||
icon_position: "right",
|
||||
icon_expanded_angle: -180,
|
||||
},
|
||||
border: { color: "grey", corner_radius: "5px" },
|
||||
vertical_spacing: "4px",
|
||||
padding: "8px 8px 8px 8px",
|
||||
elements: [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildToolUsePanel(steps: ToolUseTraceStep[], elapsedMs?: number, expanded = false): unknown {
|
||||
const parts: string[] = ["\u{1F6E0}\uFE0F \u5DE5\u5177\u6267\u884C"];
|
||||
if (steps.length > 0) {
|
||||
parts.push(`${steps.length} \u6B65`);
|
||||
}
|
||||
if (elapsedMs !== undefined && elapsedMs > 0) {
|
||||
parts.push(`(${formatElapsed(elapsedMs)})`);
|
||||
}
|
||||
|
||||
const stepElements = steps.flatMap((step) => buildToolStepElements(step));
|
||||
|
||||
return {
|
||||
tag: "collapsible_panel",
|
||||
expanded,
|
||||
header: {
|
||||
title: {
|
||||
tag: "plain_text",
|
||||
content: parts.join(" \u00B7 "),
|
||||
text_color: "grey",
|
||||
text_size: "notation",
|
||||
},
|
||||
vertical_align: "center",
|
||||
icon: {
|
||||
tag: "standard_icon",
|
||||
token: "down-small-ccm_outlined",
|
||||
color: "grey",
|
||||
size: "16px 16px",
|
||||
},
|
||||
icon_position: "right",
|
||||
icon_expanded_angle: -180,
|
||||
},
|
||||
border: { color: "grey", corner_radius: "5px" },
|
||||
vertical_spacing: "4px",
|
||||
padding: "8px 8px 8px 8px",
|
||||
elements: stepElements,
|
||||
};
|
||||
}
|
||||
|
||||
function buildToolStepElements(step: ToolUseTraceStep): unknown[] {
|
||||
const elements: unknown[] = [buildToolStepTitle(step)];
|
||||
const detailElement = buildToolStepDetail(step);
|
||||
if (detailElement !== undefined) {
|
||||
elements.push(detailElement);
|
||||
}
|
||||
const outputElement = buildToolStepOutput(step);
|
||||
if (outputElement !== undefined) {
|
||||
elements.push(outputElement);
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
|
||||
function buildToolStepTitle(step: ToolUseTraceStep): unknown {
|
||||
const status = formatStepStatus(step.status);
|
||||
return {
|
||||
tag: "div",
|
||||
icon: {
|
||||
tag: "standard_icon",
|
||||
token: toolIcon(step.toolName),
|
||||
color: "grey",
|
||||
},
|
||||
text: {
|
||||
tag: "lark_md",
|
||||
content: `**${escapeMarkdown(step.toolName)}** \u00B7 <font color='${status.color}'>${status.label}</font>`,
|
||||
text_size: "notation",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildToolStepDetail(step: ToolUseTraceStep): unknown | undefined {
|
||||
const detail = extractStepDetail(step);
|
||||
if (detail === undefined) return undefined;
|
||||
return {
|
||||
tag: "div",
|
||||
margin: STEP_INDENT,
|
||||
text: {
|
||||
tag: "plain_text",
|
||||
content: detail,
|
||||
text_color: "grey",
|
||||
text_size: "notation",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildToolStepOutput(step: ToolUseTraceStep): unknown | undefined {
|
||||
const content = buildStepOutputMarkdown(step);
|
||||
if (content === undefined) return undefined;
|
||||
return {
|
||||
tag: "div",
|
||||
margin: STEP_INDENT,
|
||||
text: {
|
||||
tag: "lark_md",
|
||||
content,
|
||||
text_size: "notation",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildStepOutputMarkdown(step: ToolUseTraceStep): string | undefined {
|
||||
const lines: string[] = [];
|
||||
if (step.error !== undefined) {
|
||||
lines.push("**\u9519\u8BEF**");
|
||||
lines.push(formatCodeBlock(step.error, "text"));
|
||||
} else if (step.result !== undefined && step.result !== "") {
|
||||
const resultStr = typeof step.result === "string" ? step.result : JSON.stringify(step.result, null, 2);
|
||||
lines.push("**\u7ED3\u679C**");
|
||||
lines.push(formatCodeBlock(resultStr, typeof step.result === "string" ? "text" : "json"));
|
||||
}
|
||||
if (lines.length === 0) return undefined;
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function extractStepDetail(step: ToolUseTraceStep): string | undefined {
|
||||
if (step.input === undefined || step.input === null) return undefined;
|
||||
if (typeof step.input === "string") return truncateText(step.input, 200);
|
||||
const input = step.input as Record<string, unknown>;
|
||||
// Extract the most relevant field based on tool name
|
||||
const toolLower = step.toolName.toLowerCase().replace(/^mcp_/, "");
|
||||
if (toolLower === "bash" && typeof input.command === "string") {
|
||||
return truncateText(input.command, 200);
|
||||
}
|
||||
if (toolLower === "read" && typeof input.file_path === "string") {
|
||||
return input.file_path;
|
||||
}
|
||||
if (toolLower === "write" && typeof input.file_path === "string") {
|
||||
return input.file_path;
|
||||
}
|
||||
if (toolLower === "edit" && typeof input.file_path === "string") {
|
||||
return input.file_path;
|
||||
}
|
||||
if (toolLower === "glob" && typeof input.pattern === "string") {
|
||||
return input.pattern;
|
||||
}
|
||||
if (toolLower === "grep" && typeof input.pattern === "string") {
|
||||
return input.pattern;
|
||||
}
|
||||
// Fallback: show keys
|
||||
const keys = Object.keys(input);
|
||||
if (keys.length === 0) return undefined;
|
||||
return keys.slice(0, 4).join(", ");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reasoning panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildReasoningPanel(reasoningText: string): unknown {
|
||||
return {
|
||||
tag: "collapsible_panel",
|
||||
expanded: false,
|
||||
header: {
|
||||
title: {
|
||||
tag: "markdown",
|
||||
content: "\u{1F4AD} \u601D\u8003",
|
||||
text_color: "grey",
|
||||
},
|
||||
vertical_align: "center",
|
||||
icon: {
|
||||
tag: "standard_icon",
|
||||
token: "down-small-ccm_outlined",
|
||||
size: "16px 16px",
|
||||
},
|
||||
icon_position: "follow_text",
|
||||
icon_expanded_angle: -180,
|
||||
},
|
||||
border: { color: "grey", corner_radius: "5px" },
|
||||
vertical_spacing: "8px",
|
||||
padding: "8px 8px 8px 8px",
|
||||
elements: [
|
||||
{
|
||||
tag: "markdown",
|
||||
content: truncateText(reasoningText, MAX_TEXT_LENGTH),
|
||||
text_size: "notation",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formatting helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatStepStatus(status: ToolUseTraceStep["status"]): { label: string; color: string } {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return { label: "\u8FD0\u884C\u4E2D", color: "turquoise" };
|
||||
case "error":
|
||||
return { label: "\u5931\u8D25", color: "red" };
|
||||
case "success":
|
||||
default:
|
||||
return { label: "\u6210\u529F", color: "green" };
|
||||
}
|
||||
}
|
||||
|
||||
function formatCodeBlock(content: string, language: "json" | "text"): string {
|
||||
const normalized = content.replace(/\r\n/g, "\n").trim();
|
||||
const fence = "`".repeat(Math.max(3, longestBacktickRun(normalized) + 1));
|
||||
return `${fence}${language}\n${normalized}\n${fence}`;
|
||||
}
|
||||
|
||||
function longestBacktickRun(value: string): number {
|
||||
const matches = value.match(/`+/g);
|
||||
if (matches === null) return 0;
|
||||
return matches.reduce((max, run) => Math.max(max, run.length), 0);
|
||||
}
|
||||
|
||||
function escapeMarkdown(value: string): string {
|
||||
return value.replace(/\\/g, "\\\\").replace(/([`*_{}[\]<>])/g, "\\$1");
|
||||
}
|
||||
|
||||
function truncateText(value: string, maxLength: number): string {
|
||||
return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`;
|
||||
}
|
||||
|
||||
function formatElapsed(ms: number): string {
|
||||
if (ms < 1000) return `${ms} ms`;
|
||||
return `${(ms / 1000).toFixed(1)} s`;
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Streaming card controller for agent runs.
|
||||
*
|
||||
* Replaces PatchableTextStream for the agent-run use case. Manages a single
|
||||
* Feishu interactive card through the full run lifecycle:
|
||||
*
|
||||
* thinking → tool calls → streaming text → complete
|
||||
*
|
||||
* The card is rebuilt from scratch on each flush (throttled at 400ms) using
|
||||
* the trace store for tool steps + accumulated reasoning/text. This is
|
||||
* simpler than CardKit 2.0 element-level streaming and works with the
|
||||
* existing message.patch API.
|
||||
*
|
||||
* Lifecycle:
|
||||
* 1. appendText(delta) — streaming answer text
|
||||
* 2. appendReasoning(delta) — streaming thinking text
|
||||
* 3. onToolStart(name, id) — register a tool step in the trace store
|
||||
* 4. onToolEnd(name, id, input, result?, error?) — complete a tool step
|
||||
* 5. finish(finalText) — flush + transition to complete card
|
||||
* 6. fail(errorText) — flush + transition to error card
|
||||
*/
|
||||
|
||||
import type { FeishuRuntime, SendMessageOptions } from "../client.js";
|
||||
import { sendCard, patchCard, sendText } from "../client.js";
|
||||
import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "../textStream.js";
|
||||
import {
|
||||
startToolUseTraceRun,
|
||||
clearToolUseTraceRun,
|
||||
recordToolUseStart,
|
||||
recordToolUseEnd,
|
||||
getToolUseTraceSteps,
|
||||
} from "./trace-store.js";
|
||||
import { buildAgentCard, type CardPhase } from "./builder.js";
|
||||
|
||||
export interface StreamingCardSink {
|
||||
readonly create: (card: Record<string, unknown>) => Promise<string | null>;
|
||||
readonly patch: (messageId: string, card: Record<string, unknown>) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface StreamingCardOptions {
|
||||
readonly runId: string;
|
||||
readonly rt: FeishuRuntime;
|
||||
readonly chatId: string;
|
||||
readonly sendOptions?: SendMessageOptions | undefined;
|
||||
readonly patchIntervalMs: number | undefined;
|
||||
readonly maxMessageLength: number | undefined;
|
||||
}
|
||||
|
||||
const DEFAULT_PATCH_INTERVAL_MS = 400;
|
||||
|
||||
export class StreamingAgentCard {
|
||||
private currentMessageId: string | null = null;
|
||||
private text = "";
|
||||
private reasoningText = "";
|
||||
private runStartedAt = Date.now();
|
||||
private toolUseElapsedMs: number | undefined;
|
||||
private flushChain: Promise<void> = Promise.resolve();
|
||||
private flushScheduled = false;
|
||||
private lastPatchAt = 0;
|
||||
private interrupted = false;
|
||||
|
||||
private readonly runId: string;
|
||||
private readonly rt: FeishuRuntime;
|
||||
private readonly chatId: string;
|
||||
private readonly sendOptions: SendMessageOptions | undefined;
|
||||
private readonly patchIntervalMs: number;
|
||||
private readonly maxMessageLength: number;
|
||||
|
||||
constructor(options: StreamingCardOptions) {
|
||||
this.runId = options.runId;
|
||||
this.rt = options.rt;
|
||||
this.chatId = options.chatId;
|
||||
this.sendOptions = options.sendOptions;
|
||||
this.patchIntervalMs = options.patchIntervalMs ?? DEFAULT_PATCH_INTERVAL_MS;
|
||||
this.maxMessageLength = options.maxMessageLength ?? DEFAULT_MAX_MESSAGE_LENGTH;
|
||||
startToolUseTraceRun(this.runId);
|
||||
}
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
appendText(delta: string): void {
|
||||
if (delta === "") return;
|
||||
this.text += delta;
|
||||
this.scheduleFlush();
|
||||
}
|
||||
|
||||
appendReasoning(delta: string): void {
|
||||
if (delta === "") return;
|
||||
this.reasoningText += delta;
|
||||
this.scheduleFlush();
|
||||
}
|
||||
|
||||
onToolStart(toolName: string, toolUseId: string | undefined): void {
|
||||
recordToolUseStart({ runId: this.runId, toolName, toolUseId, input: undefined });
|
||||
this.scheduleFlush();
|
||||
}
|
||||
|
||||
onToolEnd(params: {
|
||||
toolName: string;
|
||||
toolUseId: string | undefined;
|
||||
input: unknown;
|
||||
result: unknown;
|
||||
error: string | undefined;
|
||||
durationMs: number | undefined;
|
||||
}): void {
|
||||
recordToolUseEnd({ runId: this.runId, ...params });
|
||||
this.scheduleFlush();
|
||||
}
|
||||
async finish(fallbackText: string, options: { readonly interrupted?: boolean; readonly footerText?: string | undefined } = {}): Promise<void> {
|
||||
await this.flushChain;
|
||||
this.interrupted = options.interrupted === true;
|
||||
const footerText = options.footerText ?? "";
|
||||
const fallbackWithFooter = appendFooter(fallbackText, footerText);
|
||||
try {
|
||||
let updated = true;
|
||||
if (this.text.length > 0) {
|
||||
this.text = appendFooter(this.text, footerText);
|
||||
updated = await this.flushCard("complete", this.text);
|
||||
} else if (this.currentMessageId === null && fallbackWithFooter.length > 0) {
|
||||
// No streaming text was sent. If we never created a card, send one now.
|
||||
this.text = fallbackWithFooter;
|
||||
updated = await this.flushCard("complete", this.text);
|
||||
} else if (this.currentMessageId !== null) {
|
||||
// Patch the existing card with the final text.
|
||||
updated = await this.flushCard("complete", fallbackWithFooter);
|
||||
}
|
||||
if (!updated && this.interrupted) {
|
||||
await sendText(this.rt, this.chatId, "\u5DF2\u4E2D\u65AD\u5F53\u524D\u8FD0\u884C\u3002", this.sendOptions);
|
||||
}
|
||||
} finally {
|
||||
clearToolUseTraceRun(this.runId);
|
||||
}
|
||||
}
|
||||
|
||||
async fail(errorText: string): Promise<void> {
|
||||
await this.flushChain;
|
||||
try {
|
||||
this.text = errorText;
|
||||
await this.flushCard("complete", errorText, true);
|
||||
} finally {
|
||||
clearToolUseTraceRun(this.runId);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Internal flush logic ---
|
||||
|
||||
private scheduleFlush(): void {
|
||||
if (this.flushScheduled) return;
|
||||
this.flushScheduled = true;
|
||||
this.flushChain = this.flushChain.then(async () => {
|
||||
this.flushScheduled = false;
|
||||
await this.flush();
|
||||
});
|
||||
}
|
||||
|
||||
private async flush(): Promise<boolean> {
|
||||
if (this.currentMessageId === null) {
|
||||
const updated = await this.flushCard(this.currentPhase(), this.text);
|
||||
this.lastPatchAt = Date.now();
|
||||
return updated;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (now - this.lastPatchAt < this.patchIntervalMs) return true;
|
||||
this.lastPatchAt = now;
|
||||
return this.flushCard(this.currentPhase(), this.text);
|
||||
}
|
||||
|
||||
private async flushCard(phase: CardPhase, text: string, isError = false): Promise<boolean> {
|
||||
const chunks = splitAtBoundary(text, this.maxMessageLength);
|
||||
const firstChunk = chunks[0];
|
||||
if (firstChunk === undefined) return true;
|
||||
|
||||
const toolUseSteps = getToolUseTraceSteps(this.runId);
|
||||
const card = buildAgentCard({
|
||||
phase,
|
||||
text: firstChunk,
|
||||
reasoningText: this.reasoningText || undefined,
|
||||
toolUseSteps,
|
||||
toolUseElapsedMs: this.toolUseElapsedMs,
|
||||
isError,
|
||||
interrupted: this.interrupted,
|
||||
runId: this.runId,
|
||||
});
|
||||
|
||||
if (this.currentMessageId === null) {
|
||||
this.currentMessageId = await sendCard(this.rt, this.chatId, card, this.sendOptions);
|
||||
let updated = this.currentMessageId !== null;
|
||||
// Send overflow chunks as new messages (rare for agent output)
|
||||
for (const chunk of chunks.slice(1)) {
|
||||
const overflowCard = buildAgentCard({
|
||||
phase,
|
||||
text: chunk,
|
||||
reasoningText: undefined,
|
||||
toolUseSteps: [],
|
||||
toolUseElapsedMs: undefined,
|
||||
isError,
|
||||
interrupted: this.interrupted,
|
||||
runId: undefined,
|
||||
});
|
||||
const overflowMessageId = await sendCard(this.rt, this.chatId, overflowCard, this.sendOptions);
|
||||
updated = updated && overflowMessageId !== null;
|
||||
this.currentMessageId = overflowMessageId;
|
||||
}
|
||||
return updated;
|
||||
} else {
|
||||
let updated = await patchCard(this.rt, this.currentMessageId, card);
|
||||
// For overflow, create new messages
|
||||
for (const chunk of chunks.slice(1)) {
|
||||
const overflowCard = buildAgentCard({
|
||||
phase,
|
||||
text: chunk,
|
||||
reasoningText: undefined,
|
||||
toolUseSteps: [],
|
||||
toolUseElapsedMs: undefined,
|
||||
isError,
|
||||
interrupted: this.interrupted,
|
||||
runId: undefined,
|
||||
});
|
||||
const overflowMessageId = await sendCard(this.rt, this.chatId, overflowCard, this.sendOptions);
|
||||
updated = updated && overflowMessageId !== null;
|
||||
this.currentMessageId = overflowMessageId;
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
private currentPhase(): CardPhase {
|
||||
if (this.text.length > 0) return "streaming";
|
||||
if (this.reasoningText.length > 0) return "streaming";
|
||||
return "thinking";
|
||||
}
|
||||
}
|
||||
|
||||
function appendFooter(text: string, footerText: string): string {
|
||||
if (footerText === "") return text;
|
||||
if (text === "") return footerText;
|
||||
return `${text.trimEnd()}\n\n${footerText}`;
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* In-memory tool-use trace store, adapted from openclaw-lark's
|
||||
* tool-use-trace-store. One trace per agent run; the card builder
|
||||
* reads steps from here to render the tool-use panel.
|
||||
*
|
||||
* The store is per-run (not per-session) because each run has its
|
||||
* own card lifecycle. Steps are capped to prevent unbounded memory.
|
||||
*/
|
||||
|
||||
export interface ToolUseTraceStep {
|
||||
readonly id: string;
|
||||
readonly seq: number;
|
||||
readonly toolName: string;
|
||||
readonly toolUseId: string | undefined;
|
||||
readonly input: unknown;
|
||||
readonly result: unknown;
|
||||
readonly error: string | undefined;
|
||||
readonly durationMs: number | undefined;
|
||||
readonly status: "running" | "success" | "error";
|
||||
readonly startedAt: number;
|
||||
readonly finishedAt: number | undefined;
|
||||
}
|
||||
|
||||
interface RunTraceState {
|
||||
nextSeq: number;
|
||||
updatedAt: number;
|
||||
steps: ToolUseTraceStep[];
|
||||
}
|
||||
|
||||
const MAX_STEPS = 64;
|
||||
const STEP_RUNNING_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const RUN_TTL_MS = 30 * 60 * 1000;
|
||||
const RESULT_LIMIT = 2048;
|
||||
const GENERIC_LIMIT = 512;
|
||||
|
||||
const runs = new Map<string, RunTraceState>();
|
||||
|
||||
export function startToolUseTraceRun(runId: string): void {
|
||||
pruneStaleRuns();
|
||||
runs.set(runId, { nextSeq: 1, updatedAt: Date.now(), steps: [] });
|
||||
}
|
||||
|
||||
export function clearToolUseTraceRun(runId: string): void {
|
||||
runs.delete(runId);
|
||||
}
|
||||
|
||||
export function recordToolUseStart(params: {
|
||||
runId: string;
|
||||
toolName: string;
|
||||
toolUseId: string | undefined;
|
||||
input: unknown;
|
||||
}): void {
|
||||
const state = runs.get(params.runId);
|
||||
if (state === undefined) return;
|
||||
|
||||
if (state.steps.length >= MAX_STEPS) {
|
||||
state.steps.splice(0, state.steps.length - MAX_STEPS + 1);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
state.steps.push({
|
||||
id: `${state.nextSeq}`,
|
||||
seq: state.nextSeq,
|
||||
toolName: params.toolName,
|
||||
toolUseId: params.toolUseId,
|
||||
input: sanitizeTraceValue(params.input),
|
||||
result: undefined,
|
||||
error: undefined,
|
||||
durationMs: undefined,
|
||||
status: "running",
|
||||
startedAt: now,
|
||||
finishedAt: undefined,
|
||||
});
|
||||
state.nextSeq += 1;
|
||||
state.updatedAt = now;
|
||||
}
|
||||
|
||||
export function recordToolUseEnd(params: {
|
||||
runId: string;
|
||||
toolName: string;
|
||||
toolUseId: string | undefined;
|
||||
result: unknown;
|
||||
error: string | undefined;
|
||||
durationMs: number | undefined;
|
||||
}): void {
|
||||
const state = runs.get(params.runId);
|
||||
if (state === undefined) return;
|
||||
|
||||
const now = Date.now();
|
||||
const pendingIndex = findPendingStepIndex(state.steps, params.toolUseId, params.toolName);
|
||||
|
||||
if (pendingIndex >= 0) {
|
||||
const step = state.steps[pendingIndex];
|
||||
if (step !== undefined) {
|
||||
(state.steps as Array<ToolUseTraceStep>)[pendingIndex] = {
|
||||
...step,
|
||||
status: params.error !== undefined ? "error" : "success",
|
||||
result: sanitizeTraceValue(params.result),
|
||||
error: params.error !== undefined ? truncate(params.error, 160) : undefined,
|
||||
durationMs: params.durationMs,
|
||||
finishedAt: now,
|
||||
};
|
||||
state.updatedAt = now;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// No matching pending step — record as already-completed
|
||||
state.steps.push({
|
||||
id: `${state.nextSeq}`,
|
||||
seq: state.nextSeq,
|
||||
toolName: params.toolName,
|
||||
toolUseId: params.toolUseId,
|
||||
input: undefined,
|
||||
result: sanitizeTraceValue(params.result),
|
||||
error: params.error !== undefined ? truncate(params.error, 160) : undefined,
|
||||
durationMs: params.durationMs,
|
||||
status: params.error !== undefined ? "error" : "success",
|
||||
startedAt: now,
|
||||
finishedAt: now,
|
||||
});
|
||||
state.nextSeq += 1;
|
||||
state.updatedAt = now;
|
||||
}
|
||||
|
||||
export function getToolUseTraceSteps(runId: string): ToolUseTraceStep[] {
|
||||
const state = runs.get(runId);
|
||||
if (state === undefined) return [];
|
||||
if (Date.now() - state.updatedAt > RUN_TTL_MS) {
|
||||
runs.delete(runId);
|
||||
return [];
|
||||
}
|
||||
const now = Date.now();
|
||||
return state.steps.map((step) => {
|
||||
if (step.status === "running" && now - step.startedAt > STEP_RUNNING_TIMEOUT_MS) {
|
||||
return { ...step, status: "error" as const, error: "timed out", finishedAt: now };
|
||||
}
|
||||
return { ...step };
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function findPendingStepIndex(
|
||||
steps: ToolUseTraceStep[],
|
||||
toolUseId: string | undefined,
|
||||
toolName: string | undefined,
|
||||
): number {
|
||||
if (toolUseId !== undefined) {
|
||||
for (let i = steps.length - 1; i >= 0; i -= 1) {
|
||||
const step = steps[i];
|
||||
if (step !== undefined && step.status === "running" && step.toolUseId === toolUseId) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (toolName !== undefined) {
|
||||
for (let i = steps.length - 1; i >= 0; i -= 1) {
|
||||
const step = steps[i];
|
||||
if (step !== undefined && step.status === "running" && step.toolName === toolName) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function pruneStaleRuns(): void {
|
||||
const now = Date.now();
|
||||
for (const [runId, state] of runs) {
|
||||
if (now - state.updatedAt > RUN_TTL_MS) {
|
||||
runs.delete(runId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(value: string, maxLength: number): string {
|
||||
return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a tool input or result value for display.
|
||||
* Truncates strings, redacts sensitive keys, limits depth.
|
||||
*/
|
||||
function sanitizeTraceValue(
|
||||
value: unknown,
|
||||
depth = 0,
|
||||
context: { source: "input" | "result" | undefined; key: string | undefined } = { source: undefined, key: undefined },
|
||||
): unknown {
|
||||
if (value === null || value === undefined) return undefined;
|
||||
|
||||
if (typeof value === "string") {
|
||||
const limit = context.source === "result" ? RESULT_LIMIT : GENERIC_LIMIT;
|
||||
return truncate(value, limit);
|
||||
}
|
||||
|
||||
if (typeof value === "number" || typeof value === "boolean") return value;
|
||||
if (depth >= 2) return "[truncated]";
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.slice(0, 8).map((item) => sanitizeTraceValue(item, depth + 1, { source: context.source, key: undefined }));
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
const input = value as Record<string, unknown>;
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, entryValue] of Object.entries(input).slice(0, 12)) {
|
||||
output[key] = isSensitiveKey(key)
|
||||
? "[redacted]"
|
||||
: sanitizeTraceValue(entryValue, depth + 1, { source: context.source, key });
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
return truncate(String(value), 180);
|
||||
}
|
||||
|
||||
const SENSITIVE_KEY_RE =
|
||||
/token|secret|password|api[_-]?key|authorization|cookie|credential|bearer|session[_-]?id|client[_-]?secret|access[_-]?key/i;
|
||||
|
||||
function isSensitiveKey(key: string): boolean {
|
||||
return SENSITIVE_KEY_RE.test(key);
|
||||
}
|
||||
+818
-93
@@ -1,17 +1,16 @@
|
||||
/**
|
||||
* Feishu lark client + long-connection event dispatcher.
|
||||
*
|
||||
* Uses lark's WebSocket long-connection mode (no public callback endpoint). The
|
||||
* `EventDispatcher` registers `im.message.receive_v1`; the SDK delivers the raw
|
||||
* event to our handler. This version of the SDK does not auto-normalize on the
|
||||
* ws path — `normalize()` is a separate helper — so we work with the raw event
|
||||
* shape directly (it carries chat_id, message_type, content, mentions).
|
||||
*
|
||||
* ADR-0001: the bot operates inside a project's bound group. It does not own
|
||||
* the lock (ADR-0002) and is not the session owner.
|
||||
* Handles: ws event receiving (im.message.receive_v1 + card.action.trigger),
|
||||
* message sending, streaming patch via interactive cards, emoji
|
||||
* reactions, file download/upload, and the card action callback.
|
||||
*/
|
||||
import * as lark from "@larksuiteoapi/node-sdk";
|
||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "./textStream.js";
|
||||
import type { SenderNameCache } from "./senderCache.js";
|
||||
|
||||
export interface FeishuConfig {
|
||||
readonly appId: string;
|
||||
@@ -24,14 +23,55 @@ export interface FeishuRuntime {
|
||||
readonly logger: FastifyBaseLogger;
|
||||
}
|
||||
|
||||
/** Raw `im.message.receive_v1` event payload (subset we use). */
|
||||
export interface OutboundPayload {
|
||||
readonly msgType: string;
|
||||
readonly content: string;
|
||||
}
|
||||
|
||||
export interface SendMessageOptions {
|
||||
readonly replyToMessageId?: string | undefined;
|
||||
}
|
||||
|
||||
export async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
options: {
|
||||
maxAttempts?: number;
|
||||
baseDelayMs?: number;
|
||||
shouldRetry?: (error: unknown) => boolean;
|
||||
} = {},
|
||||
): Promise<T> {
|
||||
const maxAttempts = Math.max(1, options.maxAttempts ?? 3);
|
||||
const baseDelayMs = options.baseDelayMs ?? 1000;
|
||||
const shouldRetry = options.shouldRetry ?? (() => true);
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
if (attempt === maxAttempts || !shouldRetry(error)) {
|
||||
throw error;
|
||||
}
|
||||
const delay = baseDelayMs * 2 ** (attempt - 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("withRetry reached an unreachable state");
|
||||
}
|
||||
|
||||
/** Raw `im.message.receive_v1` event payload. */
|
||||
export interface MessageReceiveEvent {
|
||||
readonly header?: {
|
||||
readonly event_id?: string;
|
||||
readonly event_type?: string;
|
||||
};
|
||||
/** Feishu's SDK dispatcher flattens v2 headers onto the top-level payload. */
|
||||
readonly event_id?: string;
|
||||
readonly event_type?: string;
|
||||
readonly header?: { readonly event_id?: string; readonly event_type?: string };
|
||||
readonly message: {
|
||||
readonly message_id: string;
|
||||
readonly root_id?: string;
|
||||
readonly parent_id?: string;
|
||||
readonly thread_id?: string;
|
||||
readonly create_time?: string;
|
||||
readonly update_time?: string;
|
||||
readonly chat_id: string;
|
||||
readonly chat_type: string;
|
||||
readonly message_type: string;
|
||||
@@ -48,86 +88,771 @@ export interface MessageReceiveEvent {
|
||||
};
|
||||
}
|
||||
|
||||
/** Send a text message to a chat. */
|
||||
export async function sendText(rt: FeishuRuntime, chatId: string, text: string): Promise<void> {
|
||||
// SDK exposes im.v1 dynamically at runtime; the generated types are weak
|
||||
// there, so cast through the known shape.
|
||||
const client = rt.client as unknown as {
|
||||
im: { v1: { message: { create: (p: unknown) => Promise<unknown> } } };
|
||||
/** Card action trigger event (button clicks, select changes). */
|
||||
export interface CardActionEvent {
|
||||
readonly operator: { readonly open_id?: string };
|
||||
readonly action: {
|
||||
readonly value?: unknown;
|
||||
readonly tag?: string;
|
||||
readonly option?: string;
|
||||
};
|
||||
await client.im.v1.message.create({
|
||||
params: { receive_id_type: "chat_id" },
|
||||
data: {
|
||||
receive_id: chatId,
|
||||
msg_type: "text",
|
||||
content: JSON.stringify({ text }),
|
||||
},
|
||||
});
|
||||
readonly context?: { readonly open_message_id?: string; readonly open_chat_id?: string };
|
||||
readonly token?: string;
|
||||
}
|
||||
|
||||
/** Send an interactive card to a chat. `card` is the lark card schema object. */
|
||||
interface ContactBasicUser {
|
||||
readonly name?: string | undefined;
|
||||
readonly i18n_name?: {
|
||||
readonly zh_cn?: string | undefined;
|
||||
readonly en_us?: string | undefined;
|
||||
readonly ja_jp?: string | undefined;
|
||||
} | undefined;
|
||||
}
|
||||
|
||||
type ContactUserBasicBatchResponse = {
|
||||
readonly data?: {
|
||||
readonly users?: readonly ContactBasicUser[] | undefined;
|
||||
} | undefined;
|
||||
} | null;
|
||||
|
||||
// --- Message helpers ---
|
||||
|
||||
export function buildOutboundPayload(content: string): OutboundPayload {
|
||||
if (containsMarkdownTable(content)) {
|
||||
return { msgType: "text", content: JSON.stringify({ text: content }) };
|
||||
}
|
||||
if (containsMarkdownSyntax(content)) {
|
||||
return {
|
||||
msgType: "post",
|
||||
content: JSON.stringify({ zh_cn: { content: _buildMarkdownPostRows(content) } }),
|
||||
};
|
||||
}
|
||||
return { msgType: "text", content: JSON.stringify({ text: content }) };
|
||||
}
|
||||
|
||||
export function _buildMarkdownPostRows(content: string): unknown[][] {
|
||||
if (!content.includes("```")) {
|
||||
return [[{ tag: "md", text: content }]];
|
||||
}
|
||||
|
||||
const rows: unknown[][] = [];
|
||||
const codeBlockPattern = /```[\s\S]*?```/g;
|
||||
let cursor = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = codeBlockPattern.exec(content)) !== null) {
|
||||
if (match.index > cursor) {
|
||||
rows.push([{ tag: "md", text: content.slice(cursor, match.index) }]);
|
||||
}
|
||||
rows.push([{ tag: "md", text: match[0] }]);
|
||||
cursor = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return [[{ tag: "md", text: content }]];
|
||||
}
|
||||
if (cursor < content.length) {
|
||||
rows.push([{ tag: "md", text: content.slice(cursor) }]);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function _stripMarkdownToPlainText(text: string): string {
|
||||
return text
|
||||
.replace(/```[^\n`]*\n?([\s\S]*?)```/g, "$1")
|
||||
.replace(/`([^`\n]+)`/g, "$1")
|
||||
.replace(/!\[([^\]\n]*)\]\([^)]+\)/g, "$1")
|
||||
.replace(/\[([^\]\n]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/^ {0,3}#{1,6}\s+/gm, "")
|
||||
.replace(/^ {0,3}>\s?/gm, "")
|
||||
.replace(/^(\s*)[-*+]\s+/gm, "$1")
|
||||
.replace(/^(\s*)\d+[.)]\s+/gm, "$1")
|
||||
.replace(/\*\*([^*\n]+)\*\*/g, "$1")
|
||||
.replace(/__([^_\n]+)__/g, "$1")
|
||||
.replace(/~~([^~\n]+)~~/g, "$1")
|
||||
.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1$2")
|
||||
.replace(/(^|[^_])_([^_\n]+)_/g, "$1$2");
|
||||
}
|
||||
|
||||
export function parsePostMessage(content: string): { text: string; imageKeys: string[]; fileKeys: string[] } {
|
||||
const parsed = parseJsonObject(content);
|
||||
const root = parsed === null ? null : postRoot(parsed);
|
||||
const locale = root === null ? null : selectPostLocale(root);
|
||||
if (locale === null) {
|
||||
return { text: "", imageKeys: [], fileKeys: [] };
|
||||
}
|
||||
|
||||
const imageKeys: string[] = [];
|
||||
const fileKeys: string[] = [];
|
||||
const rows = Array.isArray(locale["content"]) ? locale["content"] : [];
|
||||
const body = rows
|
||||
.map((row) => {
|
||||
const elements = Array.isArray(row) ? row : [row];
|
||||
return elements.map((element) => renderPostElement(element, imageKeys, fileKeys)).join("");
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const title = stringField(locale, "title");
|
||||
const text = title === undefined || title === "" ? body : body === "" ? `# ${title}` : `# ${title}\n\n${body}`;
|
||||
return { text, imageKeys, fileKeys };
|
||||
}
|
||||
|
||||
/** Send a text message using the best Feishu message type for the content. */
|
||||
export async function sendTextMessage(
|
||||
rt: FeishuRuntime,
|
||||
chatId: string,
|
||||
text: string,
|
||||
options?: SendMessageOptions,
|
||||
): Promise<string | null> {
|
||||
const payload = buildOutboundPayload(text);
|
||||
try {
|
||||
return await sendMessagePayload(rt, chatId, payload, options);
|
||||
} catch (e) {
|
||||
if (payload.msgType === "post" && isPostContentFormatError(e)) {
|
||||
try {
|
||||
return await sendMessagePayload(
|
||||
rt,
|
||||
chatId,
|
||||
{ msgType: "text", content: JSON.stringify({ text: _stripMarkdownToPlainText(text) }) },
|
||||
options,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Send long text as multiple Feishu messages. Returns the last successful message_id. */
|
||||
export async function sendLongText(
|
||||
rt: FeishuRuntime,
|
||||
chatId: string,
|
||||
text: string,
|
||||
options?: SendMessageOptions,
|
||||
): Promise<string | null> {
|
||||
let lastMessageId: string | null = null;
|
||||
for (const chunk of splitAtBoundary(text, DEFAULT_MAX_MESSAGE_LENGTH)) {
|
||||
const messageId = await sendTextMessage(rt, chatId, chunk, options);
|
||||
if (messageId !== null) {
|
||||
lastMessageId = messageId;
|
||||
}
|
||||
}
|
||||
return lastMessageId;
|
||||
}
|
||||
|
||||
/** Send a plain text message (fire-and-forget). */
|
||||
export async function sendText(
|
||||
rt: FeishuRuntime,
|
||||
chatId: string,
|
||||
text: string,
|
||||
options?: SendMessageOptions,
|
||||
): Promise<void> {
|
||||
await sendTextMessage(rt, chatId, text, options);
|
||||
}
|
||||
|
||||
/** Send an interactive card message (always patchable). Used for streaming. */
|
||||
export async function sendInteractiveCardMessage(
|
||||
rt: FeishuRuntime,
|
||||
chatId: string,
|
||||
text: string,
|
||||
options?: SendMessageOptions,
|
||||
): Promise<string | null> {
|
||||
const payload = buildOutboundPayload(text);
|
||||
const card = buildInteractiveCardFromOutboundPayload(payload);
|
||||
try {
|
||||
return await sendMessagePayload(
|
||||
rt,
|
||||
chatId,
|
||||
{ msgType: "interactive", content: JSON.stringify(card) },
|
||||
options,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Patch a text-as-card message in-place (streaming updates). */
|
||||
export async function patchTextMessage(rt: FeishuRuntime, messageId: string, text: string): Promise<void> {
|
||||
const payload = buildOutboundPayload(text);
|
||||
const card = buildInteractiveCardFromOutboundPayload(payload);
|
||||
const client = rt.client as unknown as {
|
||||
im: { v1: { message: { patch: (p: unknown) => Promise<unknown> } } };
|
||||
};
|
||||
try {
|
||||
await client.im.v1.message.patch({
|
||||
path: { message_id: messageId },
|
||||
params: { msg_type: "interactive" },
|
||||
data: { content: JSON.stringify(card) },
|
||||
});
|
||||
} catch (e) {
|
||||
rt.logger.warn({ messageId, err: e instanceof Error ? e.message : String(e) }, "patchTextMessage failed");
|
||||
}
|
||||
}
|
||||
|
||||
/** Send a raw interactive card (arbitrary JSON). Returns message_id on success. */
|
||||
export async function sendCard(
|
||||
rt: FeishuRuntime,
|
||||
chatId: string,
|
||||
card: unknown,
|
||||
card: Record<string, unknown>,
|
||||
options?: SendMessageOptions,
|
||||
): Promise<string | null> {
|
||||
const client = rt.client as unknown as {
|
||||
im: { v1: { message: { create: (p: unknown) => Promise<{ data?: { message_id?: string } }> } } };
|
||||
};
|
||||
const res = await client.im.v1.message.create({
|
||||
params: { receive_id_type: "chat_id" },
|
||||
data: {
|
||||
receive_id: chatId,
|
||||
msg_type: "interactive",
|
||||
content: JSON.stringify(card),
|
||||
},
|
||||
});
|
||||
return res.data?.message_id ?? null;
|
||||
try {
|
||||
return await sendMessagePayload(
|
||||
rt,
|
||||
chatId,
|
||||
{ msgType: "interactive", content: JSON.stringify(card) },
|
||||
options,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a run-status card: status text + model selector + cancel button. */
|
||||
export function buildRunStatusCard(opts: {
|
||||
readonly status: string;
|
||||
readonly model: string;
|
||||
readonly models: readonly { readonly id: string; readonly label: string }[];
|
||||
readonly runId: string;
|
||||
}): unknown {
|
||||
return {
|
||||
/** Patch an interactive card in-place with raw JSON. */
|
||||
export async function patchCard(rt: FeishuRuntime, messageId: string, card: Record<string, unknown>): Promise<boolean> {
|
||||
const client = rt.client as unknown as {
|
||||
im: { v1: { message: { patch: (p: unknown) => Promise<unknown> } } };
|
||||
};
|
||||
try {
|
||||
await client.im.v1.message.patch({
|
||||
path: { message_id: messageId },
|
||||
params: { msg_type: "interactive" },
|
||||
data: { content: JSON.stringify(card) },
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
rt.logger.warn({ messageId, err: e instanceof Error ? e.message : String(e) }, "patchCard failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Send an approval card with buttons. Returns message_id on success. */
|
||||
export async function sendApprovalCard(
|
||||
rt: FeishuRuntime,
|
||||
chatId: string,
|
||||
title: string,
|
||||
body: string,
|
||||
buttons: ReadonlyArray<{ label: string; value: string; style?: "primary" | "default" | "danger" }>,
|
||||
options?: SendMessageOptions,
|
||||
): Promise<string | null> {
|
||||
const card = {
|
||||
config: { wide_screen_mode: true },
|
||||
header: {
|
||||
title: { tag: "plain_text", content: `@Claude · ${opts.status}` },
|
||||
template: opts.status === "处理完成" ? "green" : opts.status === "处理出错" ? "red" : "blue",
|
||||
},
|
||||
header: { title: { content: title, tag: "plain_text" }, template: "orange" },
|
||||
elements: [
|
||||
{ tag: "div", text: { tag: "lark_md", content: `**model:** ${opts.model}` } },
|
||||
{ tag: "markdown", content: body },
|
||||
{
|
||||
tag: "action",
|
||||
actions: [
|
||||
{
|
||||
tag: "select",
|
||||
placeholder: { tag: "plain_text", content: "切换 model" },
|
||||
options: opts.models.map((m) => ({ text: { tag: "plain_text", content: m.label }, value: m.id })),
|
||||
value: opts.model,
|
||||
},
|
||||
{
|
||||
tag: "button",
|
||||
text: { tag: "plain_text", content: "取消" },
|
||||
type: "danger",
|
||||
value: JSON.stringify({ action: "cancel", runId: opts.runId }),
|
||||
},
|
||||
],
|
||||
actions: buttons.map((button) => ({
|
||||
tag: "button",
|
||||
text: { tag: "plain_text", content: button.label },
|
||||
type: button.style ?? "default",
|
||||
value: { approval_action: button.value },
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
try {
|
||||
return await sendMessagePayload(
|
||||
rt,
|
||||
chatId,
|
||||
{ msgType: "interactive", content: JSON.stringify(card) },
|
||||
options,
|
||||
);
|
||||
} catch (e) {
|
||||
rt.logger.warn({ chatId, err: e instanceof Error ? e.message : String(e) }, "sendApprovalCard failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the lark WebSocket client and route `im.message.receive_v1` events to
|
||||
* `onMessage`. The ws connection lives in the background; this resolves the
|
||||
* runtime handle.
|
||||
*/
|
||||
/** Build a lark Client (HTTP API surface). Used before the ws starts, so tools
|
||||
* that need to call Feishu APIs can hold it from server boot. */
|
||||
/** Update a card to show it has been resolved. */
|
||||
export async function resolveCard(
|
||||
rt: FeishuRuntime,
|
||||
messageId: string,
|
||||
icon: string,
|
||||
label: string,
|
||||
template: "green" | "red",
|
||||
resolvedBy: string,
|
||||
): Promise<void> {
|
||||
const card = {
|
||||
config: { wide_screen_mode: true },
|
||||
header: { title: { content: `${icon} ${label}`, tag: "plain_text" }, template },
|
||||
elements: [{ tag: "markdown", content: `${icon} **${label}** by ${resolvedBy}` }],
|
||||
};
|
||||
const client = rt.client as unknown as {
|
||||
im: { v1: { message: { patch: (p: unknown) => Promise<unknown> } } };
|
||||
};
|
||||
try {
|
||||
await client.im.v1.message.patch({
|
||||
path: { message_id: messageId },
|
||||
params: { msg_type: "interactive" },
|
||||
data: { content: JSON.stringify(card) },
|
||||
});
|
||||
} catch (e) {
|
||||
rt.logger.warn({ messageId, err: e instanceof Error ? e.message : String(e) }, "resolveCard failed");
|
||||
}
|
||||
}
|
||||
|
||||
/** React to a message with an emoji. */
|
||||
export async function reactToMessage(rt: FeishuRuntime, messageId: string, emoji: string): Promise<void> {
|
||||
try {
|
||||
await rt.client.request({
|
||||
method: "POST",
|
||||
url: `/open-apis/im/v1/messages/${messageId}/reactions`,
|
||||
data: { reaction_type: { emoji_type: emoji } },
|
||||
});
|
||||
} catch (e) {
|
||||
rt.logger.warn({ messageId, err: e instanceof Error ? e.message : String(e) }, "reactToMessage failed");
|
||||
}
|
||||
}
|
||||
|
||||
/** Add a reaction to a message, returning the reaction_id (needed for later removal). */
|
||||
export async function addReaction(rt: FeishuRuntime, messageId: string, emojiType: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await rt.client.request({
|
||||
method: "POST",
|
||||
url: `/open-apis/im/v1/messages/${messageId}/reactions`,
|
||||
data: { reaction_type: { emoji_type: emojiType } },
|
||||
}) as ReactionCreateResponse;
|
||||
const reactionId = reactionIdFromResponse(res);
|
||||
if (reactionId === null) {
|
||||
rt.logger.warn({ messageId, emojiType }, "addReaction failed: response missing reaction_id");
|
||||
}
|
||||
return reactionId;
|
||||
} catch (e) {
|
||||
rt.logger.warn({ messageId, emojiType, err: e instanceof Error ? e.message : String(e) }, "addReaction failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a previously added reaction by its reaction_id. */
|
||||
export async function removeReaction(rt: FeishuRuntime, messageId: string, reactionId: string): Promise<boolean> {
|
||||
try {
|
||||
await rt.client.request({
|
||||
method: "DELETE",
|
||||
url: `/open-apis/im/v1/messages/${messageId}/reactions/${reactionId}`,
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
rt.logger.warn({ messageId, reactionId, err: e instanceof Error ? e.message : String(e) }, "removeReaction failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a Feishu sender display name from open_id, optionally using a cache. */
|
||||
export async function resolveSenderName(
|
||||
rt: FeishuRuntime,
|
||||
openId: string,
|
||||
cache?: SenderNameCache,
|
||||
): Promise<string | null> {
|
||||
if (openId === "") return null;
|
||||
|
||||
const fetchName = async (id: string): Promise<string | null> => {
|
||||
try {
|
||||
const res = await rt.client.contact.v3.user.basicBatch({
|
||||
params: { user_id_type: "open_id" },
|
||||
data: { user_ids: [id] },
|
||||
}) as ContactUserBasicBatchResponse;
|
||||
const user = res?.data?.users?.[0];
|
||||
return user === undefined ? null : contactBasicUserName(user);
|
||||
} catch (e) {
|
||||
rt.logger.warn({ openId: id, err: e instanceof Error ? e.message : String(e) }, "resolveSenderName failed");
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (cache !== undefined) {
|
||||
return cache.getOrFetch(openId, fetchName);
|
||||
}
|
||||
return fetchName(openId);
|
||||
}
|
||||
|
||||
function contactBasicUserName(user: ContactBasicUser): string | null {
|
||||
const candidates = [
|
||||
user.name,
|
||||
user.i18n_name?.zh_cn,
|
||||
user.i18n_name?.en_us,
|
||||
user.i18n_name?.ja_jp,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === "string" && candidate.trim() !== "") return candidate.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- File helpers ---
|
||||
|
||||
/** Download a file/image from a Feishu message to a local path. */
|
||||
export async function downloadMessageFile(
|
||||
rt: FeishuRuntime,
|
||||
messageId: string,
|
||||
fileKey: string,
|
||||
savePath: string,
|
||||
): Promise<void> {
|
||||
await mkdir(dirname(savePath), { recursive: true });
|
||||
try {
|
||||
const res = await withRetry(async () => {
|
||||
return rt.client.request({
|
||||
method: "GET",
|
||||
url: `/open-apis/im/v1/messages/${messageId}/resources/${fileKey}`,
|
||||
params: { type: "file" },
|
||||
responseType: "arraybuffer",
|
||||
}) as Promise<{ data: Buffer }>;
|
||||
});
|
||||
await writeFile(savePath, res.data);
|
||||
} catch (e) {
|
||||
rt.logger.warn({ messageId, fileKey, err: e instanceof Error ? e.message : String(e) }, "downloadMessageFile failed");
|
||||
}
|
||||
}
|
||||
|
||||
/** Upload and send a file to a chat. */
|
||||
export async function sendFile(
|
||||
rt: FeishuRuntime,
|
||||
chatId: string,
|
||||
filePath: string,
|
||||
fileName: string,
|
||||
options?: SendMessageOptions,
|
||||
): Promise<string | null> {
|
||||
const client = rt.client as unknown as {
|
||||
im: { v1: {
|
||||
file: { create: (p: unknown) => Promise<FileCreateResponse> }
|
||||
} };
|
||||
};
|
||||
try {
|
||||
const buf = await readFile(filePath);
|
||||
const ext = fileName.split(".").pop() ?? "";
|
||||
const fileType = ext === "pdf" ? "pdf" : ext === "doc" ? "doc" : ext === "xls" ? "xls" : ext === "ppt" ? "ppt" : "stream";
|
||||
const uploadRes = await withRetry(async () =>
|
||||
client.im.v1.file.create({ data: { file_type: fileType, file_name: fileName, file: buf } }),
|
||||
);
|
||||
const fileKey = fileKeyFromResponse(uploadRes);
|
||||
if (fileKey === undefined) {
|
||||
rt.logger.warn({ chatId, filePath }, "sendFile failed: upload response missing file_key");
|
||||
return null;
|
||||
}
|
||||
const messageId = await withRetry(async () =>
|
||||
sendMessagePayload(rt, chatId, { msgType: "file", content: JSON.stringify({ file_key: fileKey }) }, options),
|
||||
);
|
||||
if (messageId === null) {
|
||||
rt.logger.warn({ chatId, filePath }, "sendFile failed: message response missing message_id");
|
||||
}
|
||||
return messageId;
|
||||
} catch (e) {
|
||||
rt.logger.warn({ chatId, filePath, err: e instanceof Error ? e.message : String(e) }, "sendFile failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessagePayload(
|
||||
rt: FeishuRuntime,
|
||||
chatId: string,
|
||||
payload: OutboundPayload,
|
||||
options?: SendMessageOptions,
|
||||
): Promise<string | null> {
|
||||
const client = rt.client as unknown as {
|
||||
im: { v1: { message: {
|
||||
create: (p: unknown) => Promise<MessageCreateResponse>;
|
||||
reply: (p: unknown) => Promise<MessageCreateResponse>;
|
||||
} } };
|
||||
};
|
||||
const replyToMessageId = normalizeReplyMessageId(options?.replyToMessageId);
|
||||
if (replyToMessageId !== null) {
|
||||
const res = await client.im.v1.message.reply({
|
||||
path: { message_id: replyToMessageId },
|
||||
data: {
|
||||
msg_type: payload.msgType,
|
||||
content: payload.content,
|
||||
},
|
||||
});
|
||||
return messageIdFromResponse(res);
|
||||
}
|
||||
|
||||
const res = await client.im.v1.message.create({
|
||||
params: { receive_id_type: "chat_id" },
|
||||
data: { receive_id: chatId, msg_type: payload.msgType, content: payload.content },
|
||||
});
|
||||
return messageIdFromResponse(res);
|
||||
}
|
||||
|
||||
function normalizeReplyMessageId(messageId: string | undefined): string | null {
|
||||
return messageId === undefined || messageId === "" ? null : messageId;
|
||||
}
|
||||
|
||||
type MessageCreateResponse = {
|
||||
readonly message_id?: string | undefined;
|
||||
readonly data?: { readonly message_id?: string | undefined } | undefined;
|
||||
} | null;
|
||||
|
||||
type FileCreateResponse = {
|
||||
readonly file_key?: string | undefined;
|
||||
readonly data?: { readonly file_key?: string | undefined } | undefined;
|
||||
} | null;
|
||||
|
||||
type ReactionCreateResponse = {
|
||||
readonly reaction_id?: string | undefined;
|
||||
readonly data?: { readonly reaction_id?: string | undefined } | undefined;
|
||||
} | null;
|
||||
|
||||
function messageIdFromResponse(res: MessageCreateResponse): string | null {
|
||||
return res?.data?.message_id ?? res?.message_id ?? null;
|
||||
}
|
||||
|
||||
function fileKeyFromResponse(res: FileCreateResponse): string | undefined {
|
||||
return res?.file_key ?? res?.data?.file_key;
|
||||
}
|
||||
|
||||
function reactionIdFromResponse(res: ReactionCreateResponse): string | null {
|
||||
return res?.data?.reaction_id ?? res?.reaction_id ?? null;
|
||||
}
|
||||
|
||||
function parseJsonObject(content: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(content) as unknown;
|
||||
return isRecord(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function postRoot(parsed: Record<string, unknown>): Record<string, unknown> {
|
||||
const post = parsed["post"];
|
||||
return isRecord(post) ? post : parsed;
|
||||
}
|
||||
|
||||
function selectPostLocale(root: Record<string, unknown>): Record<string, unknown> | null {
|
||||
if (Array.isArray(root["content"])) return root;
|
||||
|
||||
for (const locale of ["zh_cn", "en_us"]) {
|
||||
const candidate = root[locale];
|
||||
if (isPostLocale(candidate)) return candidate;
|
||||
}
|
||||
|
||||
for (const candidate of Object.values(root)) {
|
||||
if (isPostLocale(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPostLocale(value: unknown): value is Record<string, unknown> {
|
||||
return isRecord(value) && (Array.isArray(value["content"]) || typeof value["title"] === "string");
|
||||
}
|
||||
|
||||
function renderPostElement(element: unknown, imageKeys: string[], fileKeys: string[]): string {
|
||||
if (!isRecord(element)) return "";
|
||||
|
||||
switch (stringField(element, "tag")) {
|
||||
case "text":
|
||||
return applyTextStyles(stringField(element, "text") ?? "", element);
|
||||
case "a":
|
||||
return renderLink(element);
|
||||
case "at":
|
||||
return renderMention(element);
|
||||
case "img":
|
||||
case "image":
|
||||
pushStringField(imageKeys, element, "image_key");
|
||||
return "[Image]";
|
||||
case "media":
|
||||
case "file":
|
||||
case "audio":
|
||||
case "video":
|
||||
pushStringField(fileKeys, element, "file_key");
|
||||
return renderAttachmentPlaceholder(element);
|
||||
case "code":
|
||||
case "code_block":
|
||||
return renderCodeBlock(element);
|
||||
case "br":
|
||||
return "\n";
|
||||
case "hr":
|
||||
case "divider":
|
||||
return "\n\n---\n\n";
|
||||
case "emotion":
|
||||
case "emoji":
|
||||
return renderEmoji(element);
|
||||
case "md":
|
||||
return stringField(element, "text") ?? "";
|
||||
default:
|
||||
return stringField(element, "text") ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
function renderLink(element: Record<string, unknown>): string {
|
||||
const text = stringField(element, "text") ?? "";
|
||||
const href = stringField(element, "href") ?? "";
|
||||
if (href === "") return text;
|
||||
return `[${text}](${href})`;
|
||||
}
|
||||
|
||||
function renderMention(element: Record<string, unknown>): string {
|
||||
if (stringField(element, "user_id") === "@_all") return "@all";
|
||||
const name =
|
||||
stringField(element, "user_name") ??
|
||||
stringField(element, "name") ??
|
||||
stringField(element, "text") ??
|
||||
stringField(element, "user_id") ??
|
||||
"";
|
||||
return name === "" ? "" : `@${name.replace(/^@/, "")}`;
|
||||
}
|
||||
|
||||
function renderAttachmentPlaceholder(element: Record<string, unknown>): string {
|
||||
const fileName = stringField(element, "file_name") ?? stringField(element, "name");
|
||||
return fileName === undefined || fileName === "" ? "[Attachment]" : `[Attachment: ${fileName}]`;
|
||||
}
|
||||
|
||||
function renderCodeBlock(element: Record<string, unknown>): string {
|
||||
const language = stringField(element, "language") ?? "";
|
||||
const text = stringField(element, "text") ?? "";
|
||||
return `\`\`\`${language}\n${text}\n\`\`\``;
|
||||
}
|
||||
|
||||
function renderEmoji(element: Record<string, unknown>): string {
|
||||
const emojiType =
|
||||
stringField(element, "emoji_type") ??
|
||||
stringField(element, "emotion_key") ??
|
||||
stringField(element, "name") ??
|
||||
stringField(element, "text");
|
||||
return emojiType === undefined || emojiType === "" ? "" : `:${emojiType}:`;
|
||||
}
|
||||
|
||||
function applyTextStyles(text: string, element: Record<string, unknown>): string {
|
||||
let rendered = text;
|
||||
if (hasPostStyle(element, "bold")) rendered = `**${rendered}**`;
|
||||
if (hasPostStyle(element, "italic")) rendered = `*${rendered}*`;
|
||||
if (hasPostStyle(element, "strikethrough", "strike", "line_through", "lineThrough")) rendered = `~~${rendered}~~`;
|
||||
if (hasPostStyle(element, "code", "inline_code", "inlineCode")) rendered = `\`${rendered}\``;
|
||||
return rendered;
|
||||
}
|
||||
|
||||
function hasPostStyle(element: Record<string, unknown>, ...names: string[]): boolean {
|
||||
const style = element["style"] ?? element["styles"];
|
||||
if (styleMatches(style, names)) return true;
|
||||
return names.some((name) => element[name] === true);
|
||||
}
|
||||
|
||||
function styleMatches(style: unknown, names: readonly string[]): boolean {
|
||||
if (Array.isArray(style)) {
|
||||
return style.some((entry) => typeof entry === "string" && names.includes(entry));
|
||||
}
|
||||
if (typeof style === "string") {
|
||||
const tokens = style.split(/[\s,]+/).filter((token) => token !== "");
|
||||
return tokens.some((token) => names.includes(token));
|
||||
}
|
||||
if (isRecord(style)) {
|
||||
return names.some((name) => style[name] === true || style[name] === "true");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function pushStringField(target: string[], record: Record<string, unknown>, field: string): void {
|
||||
const value = stringField(record, field);
|
||||
if (value !== undefined && value !== "") target.push(value);
|
||||
}
|
||||
|
||||
function stringField(record: Record<string, unknown>, field: string): string | undefined {
|
||||
const value = record[field];
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function containsMarkdownSyntax(content: string): boolean {
|
||||
const patterns = [
|
||||
/^ {0,3}#{1,6}\s+\S/m,
|
||||
/^ {0,3}>\s+\S/m,
|
||||
/^ {0,3}(?:[-*+]\s+\S|\d+[.)]\s+\S)/m,
|
||||
/```/,
|
||||
/`[^`\n]+`/,
|
||||
/\*\*[^*\n]+\*\*/,
|
||||
/__[^_\n]+__/,
|
||||
/~~[^~\n]+~~/,
|
||||
/(^|[^*])\*[^*\s][^*\n]*\*(?!\*)/,
|
||||
/(^|[^_])_[^_\s][^_\n]*_(?!_)/,
|
||||
/\[[^\]\n]+\]\([^)]+\)/,
|
||||
];
|
||||
return patterns.some((pattern) => pattern.test(content));
|
||||
}
|
||||
|
||||
function containsMarkdownTable(content: string): boolean {
|
||||
const lines = content.split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length - 1; i += 1) {
|
||||
const current = lines[i];
|
||||
const next = lines[i + 1];
|
||||
if (current !== undefined && next !== undefined && isTableHeaderLine(current) && isTableSeparatorLine(next)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isTableHeaderLine(line: string): boolean {
|
||||
const trimmed = line.trim();
|
||||
return trimmed.startsWith("|") && trimmed.includes("|", 1);
|
||||
}
|
||||
|
||||
function isTableSeparatorLine(line: string): boolean {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("|")) return false;
|
||||
const cells = trimmed.replace(/^\|/, "").replace(/\|$/, "").split("|");
|
||||
return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()));
|
||||
}
|
||||
|
||||
function buildInteractiveCardFromOutboundPayload(payload: OutboundPayload): { elements: Array<{ tag: "div"; text: { tag: "lark_md"; content: string } }> } {
|
||||
return {
|
||||
elements: rowsFromOutboundPayload(payload).map((row) => ({
|
||||
tag: "div",
|
||||
text: { tag: "lark_md", content: row.map(markdownTextFromPostElement).join("") },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function rowsFromOutboundPayload(payload: OutboundPayload): unknown[][] {
|
||||
if (payload.msgType !== "post") {
|
||||
return [[{ tag: "md", text: textFromTextPayload(payload.content) }]];
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(payload.content) as { zh_cn?: { content?: unknown } };
|
||||
return Array.isArray(parsed.zh_cn?.content) ? parsed.zh_cn.content as unknown[][] : [[{ tag: "md", text: "" }]];
|
||||
} catch {
|
||||
return [[{ tag: "md", text: "" }]];
|
||||
}
|
||||
}
|
||||
|
||||
function textFromTextPayload(content: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(content) as { text?: unknown };
|
||||
return typeof parsed.text === "string" ? parsed.text : content;
|
||||
} catch {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
function markdownTextFromPostElement(element: unknown): string {
|
||||
if (typeof element !== "object" || element === null) return "";
|
||||
const text = (element as { text?: unknown }).text;
|
||||
return typeof text === "string" ? text : "";
|
||||
}
|
||||
|
||||
function isPostContentFormatError(error: unknown): boolean {
|
||||
return errorText(error).includes("content format of the post type is incorrect");
|
||||
}
|
||||
|
||||
function errorText(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return `${error.message} ${errorText((error as { response?: unknown }).response)}`;
|
||||
}
|
||||
if (typeof error === "string") return error;
|
||||
if (typeof error === "object" && error !== null) {
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch {
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
// --- Lark client + ws listener ---
|
||||
|
||||
export function createLarkClient(config: FeishuConfig): lark.Client {
|
||||
return new lark.Client({
|
||||
appId: config.appId,
|
||||
@@ -136,17 +861,12 @@ export function createLarkClient(config: FeishuConfig): lark.Client {
|
||||
});
|
||||
}
|
||||
|
||||
export interface FeishuListener {
|
||||
readonly runtime: FeishuRuntime;
|
||||
readonly stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Start the ws listener using an already-built client. Returns the runtime. */
|
||||
export function startFeishuListenerWithClient(
|
||||
config: FeishuConfig,
|
||||
client: lark.Client,
|
||||
logger: FastifyBaseLogger,
|
||||
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
|
||||
onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>,
|
||||
): FeishuRuntime {
|
||||
const wsClient = new lark.WSClient({
|
||||
appId: config.appId,
|
||||
@@ -155,23 +875,28 @@ export function startFeishuListenerWithClient(
|
||||
loggerLevel: lark.LoggerLevel.info,
|
||||
});
|
||||
const rt: FeishuRuntime = { client, logger };
|
||||
void wsClient.start({
|
||||
eventDispatcher: new lark.EventDispatcher({}).register({
|
||||
"im.message.receive_v1": async (data) => {
|
||||
try {
|
||||
await onMessage(data as MessageReceiveEvent, rt);
|
||||
} catch (e) {
|
||||
logger.error({ err: e }, "feishu message handler threw");
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
const handlers: Record<string, (data: unknown) => Promise<void>> = {
|
||||
"im.message.receive_v1": async (data) => {
|
||||
try { await onMessage(data as MessageReceiveEvent, rt); }
|
||||
catch (e) { logger.error({ err: e }, "feishu message handler threw"); }
|
||||
},
|
||||
};
|
||||
if (onCardAction !== undefined) {
|
||||
handlers["card.action.trigger"] = async (data) => {
|
||||
void Promise.resolve()
|
||||
.then(() => onCardAction(data as CardActionEvent, rt))
|
||||
.catch((e) => { logger.error({ err: e }, "feishu card action handler threw"); });
|
||||
};
|
||||
}
|
||||
void wsClient.start({ eventDispatcher: new lark.EventDispatcher({}).register(handlers) });
|
||||
return rt;
|
||||
}
|
||||
|
||||
export function startFeishuListener(
|
||||
config: FeishuConfig,
|
||||
logger: FastifyBaseLogger,
|
||||
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
|
||||
onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>,
|
||||
): FeishuRuntime {
|
||||
return startFeishuListenerWithClient(config, createLarkClient(config), logger, onMessage);
|
||||
return startFeishuListenerWithClient(config, createLarkClient(config), logger, onMessage, onCardAction);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { access, stat } from "node:fs/promises";
|
||||
import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
|
||||
export interface DeliverableFile {
|
||||
readonly path: string;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
const DELIVERABLE_EXTENSIONS = new Set([
|
||||
".csv",
|
||||
".doc",
|
||||
".docx",
|
||||
".gif",
|
||||
".jpeg",
|
||||
".jpg",
|
||||
".md",
|
||||
".pdf",
|
||||
".png",
|
||||
".ppt",
|
||||
".pptx",
|
||||
".svg",
|
||||
".txt",
|
||||
".typ",
|
||||
".xls",
|
||||
".xlsx",
|
||||
".zip",
|
||||
]);
|
||||
|
||||
export async function resolveDeliverableFile(
|
||||
requestedPath: string,
|
||||
workspaceDir: string,
|
||||
): Promise<DeliverableFile | null> {
|
||||
const token = requestedPath.trim();
|
||||
if (token === "" || !DELIVERABLE_EXTENSIONS.has(extname(token).toLowerCase())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const roots = await allowedRoots(workspaceDir);
|
||||
for (const candidate of candidatePaths(token, workspaceDir, roots)) {
|
||||
if (!isAllowedPath(candidate, roots)) continue;
|
||||
const existing = await existingFile(candidate);
|
||||
if (existing !== null) return { path: existing, name: basename(existing) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function allowedRoots(workspaceDir: string): Promise<string[]> {
|
||||
const roots = [resolve(workspaceDir)];
|
||||
const gitRoot = await findGitRoot(workspaceDir);
|
||||
if (gitRoot !== undefined) roots.push(gitRoot);
|
||||
return [...new Set(roots)];
|
||||
}
|
||||
|
||||
async function findGitRoot(startDir: string): Promise<string | undefined> {
|
||||
let current = resolve(startDir);
|
||||
while (true) {
|
||||
try {
|
||||
await access(join(current, ".git"));
|
||||
return current;
|
||||
} catch {
|
||||
const parent = resolve(current, "..");
|
||||
if (parent === current) return undefined;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function candidatePaths(token: string, workspaceDir: string, roots: readonly string[]): string[] {
|
||||
if (isAbsolute(token)) return [resolve(token)];
|
||||
|
||||
const candidates: string[] = [];
|
||||
for (const root of roots) {
|
||||
candidates.push(resolve(root, token));
|
||||
}
|
||||
if (!token.includes("/") && !token.includes("\\")) {
|
||||
candidates.push(resolve(workspaceDir, "build", token));
|
||||
}
|
||||
return [...new Set(candidates)];
|
||||
}
|
||||
|
||||
function isAllowedPath(path: string, roots: readonly string[]): boolean {
|
||||
return roots.some((root) => {
|
||||
const rel = relative(root, path);
|
||||
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
||||
});
|
||||
}
|
||||
|
||||
async function existingFile(path: string): Promise<string | null> {
|
||||
try {
|
||||
const s = await stat(path);
|
||||
return s.isFile() ? resolve(path) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { createSdkMcpServer, tool, type McpSdkServerConfigWithInstance, type SdkMcpToolDefinition } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { z } from "zod";
|
||||
import { sendApprovalCard, sendFile, type FeishuRuntime, type SendMessageOptions } from "./client.js";
|
||||
import { resolveDeliverableFile } from "./fileDelivery.js";
|
||||
import { readFeishuContext } from "./read.js";
|
||||
import type { ApprovalManager } from "./approval.js";
|
||||
import { CPH_HUB_MCP_TOOL_IDS, type CphHubMcpToolId } from "../agent/roleTools.js";
|
||||
|
||||
export interface FileDeliveryToolOptions {
|
||||
readonly rt: FeishuRuntime;
|
||||
readonly chatId: string;
|
||||
readonly projectId: string;
|
||||
readonly runId: string;
|
||||
readonly workspaceDir: string;
|
||||
readonly sendOptions?: SendMessageOptions | undefined;
|
||||
readonly approvalManager: ApprovalManager;
|
||||
readonly onDelivered?: (path: string) => void;
|
||||
readonly tools?: readonly CphHubMcpToolId[] | undefined;
|
||||
}
|
||||
|
||||
export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): McpSdkServerConfigWithInstance {
|
||||
const enabledTools = new Set(options.tools ?? CPH_HUB_MCP_TOOL_IDS);
|
||||
const tools: Array<SdkMcpToolDefinition<any>> = [];
|
||||
|
||||
if (enabledTools.has("send_file")) {
|
||||
tools.push(
|
||||
tool(
|
||||
"send_file",
|
||||
"Upload an existing project/repository file to the current Feishu chat. The path must point to a concrete file, for example build/student.pdf or README.md.",
|
||||
{
|
||||
path: z.string().describe("Workspace-relative, repository-relative, or absolute path to the existing file."),
|
||||
name: z.string().optional().describe("Optional display filename. Defaults to the file's basename."),
|
||||
},
|
||||
async (args) => {
|
||||
const file = await resolveDeliverableFile(args.path, options.workspaceDir);
|
||||
if (file === null) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text", text: `File not found or not deliverable: ${args.path}` }],
|
||||
};
|
||||
}
|
||||
|
||||
const messageId = await sendFile(options.rt, options.chatId, file.path, args.name ?? file.name, options.sendOptions);
|
||||
if (messageId === null) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text", text: `Failed to send file: ${file.path}` }],
|
||||
};
|
||||
}
|
||||
|
||||
options.onDelivered?.(file.path);
|
||||
return {
|
||||
content: [{ type: "text", text: `Sent file: ${file.path}` }],
|
||||
};
|
||||
},
|
||||
{ alwaysLoad: true },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (enabledTools.has("feishu_read_context")) {
|
||||
tools.push(
|
||||
tool(
|
||||
"feishu_read_context",
|
||||
"Read Feishu context for the current project's bound chat. Use anchor ids from the trigger context; this tool never reads arbitrary chats.",
|
||||
{
|
||||
anchor: z.enum(["trigger_message", "status_card", "reply", "thread"]).describe("Which kind of Feishu anchor to read."),
|
||||
id: z.string().describe("The message/thread/status anchor id to read."),
|
||||
},
|
||||
async (args) => {
|
||||
const result = await readFeishuContext(
|
||||
{ chat_id: options.chatId, anchor: args.anchor, id: args.id },
|
||||
{
|
||||
runId: options.runId,
|
||||
projectId: options.projectId,
|
||||
boundChatId: options.chatId,
|
||||
workspaceDir: options.workspaceDir,
|
||||
},
|
||||
options.rt,
|
||||
);
|
||||
return { content: [{ type: "text", text: result }] };
|
||||
},
|
||||
{ alwaysLoad: true },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (enabledTools.has("request_approval")) {
|
||||
tools.push(
|
||||
tool(
|
||||
"request_approval",
|
||||
"Send an interactive Feishu approval/confirmation card to the current chat and wait for a button click.",
|
||||
{
|
||||
title: z.string().describe("Card title."),
|
||||
body: z.string().describe("Markdown card body explaining what needs approval or confirmation."),
|
||||
options: z
|
||||
.array(z.object({
|
||||
label: z.string().describe("Button label shown to the user."),
|
||||
value: z.string().describe("Action value returned to the agent when this button is clicked."),
|
||||
style: z.enum(["primary", "default", "danger"]).optional().describe("Optional Feishu button style."),
|
||||
}))
|
||||
.min(1)
|
||||
.describe("Button choices for the approval card."),
|
||||
},
|
||||
async (args) => {
|
||||
const messageId = await sendApprovalCard(
|
||||
options.rt,
|
||||
options.chatId,
|
||||
args.title,
|
||||
args.body,
|
||||
args.options.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
...(option.style === undefined ? {} : { style: option.style }),
|
||||
})),
|
||||
options.sendOptions,
|
||||
);
|
||||
if (messageId === null) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text", text: "Failed to send approval card." }],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await options.approvalManager.register(messageId, options.chatId);
|
||||
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
||||
} catch (e) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
|
||||
};
|
||||
}
|
||||
},
|
||||
{ alwaysLoad: true },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const instructions = mcpInstructions(enabledTools);
|
||||
return createSdkMcpServer({
|
||||
name: "cph_hub",
|
||||
version: "0.0.0",
|
||||
alwaysLoad: true,
|
||||
...(instructions === "" ? {} : { instructions }),
|
||||
tools,
|
||||
});
|
||||
}
|
||||
|
||||
function mcpInstructions(enabledTools: ReadonlySet<CphHubMcpToolId>): string {
|
||||
const instructions: string[] = [];
|
||||
if (enabledTools.has("send_file")) {
|
||||
instructions.push(
|
||||
"Use send_file when the user asks to receive, resend, download, or attach a file.",
|
||||
"Do not claim a file was sent unless send_file returns success.",
|
||||
"If a generated file path is uncertain, list or inspect the workspace first, then call send_file with the actual path.",
|
||||
);
|
||||
}
|
||||
if (enabledTools.has("feishu_read_context")) {
|
||||
instructions.push("Use feishu_read_context when the trigger context contains a reply, thread, or message anchor and more Feishu context is needed.");
|
||||
}
|
||||
if (enabledTools.has("request_approval")) {
|
||||
instructions.push("Use request_approval when explicit human approval or confirmation is required before continuing.");
|
||||
}
|
||||
return instructions.join(" ");
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
export interface MessageBatcherOptions {
|
||||
/** Default: 600. */
|
||||
readonly debounceMs?: number;
|
||||
/** Default: 8. */
|
||||
readonly maxMessages?: number;
|
||||
/** Default: 4000. */
|
||||
readonly maxChars?: number;
|
||||
/** Default: 3500. */
|
||||
readonly splitThreshold?: number;
|
||||
/** Default: 1200. */
|
||||
readonly extendedDebounceMs?: number;
|
||||
}
|
||||
|
||||
export type MessageBatcherCallback = (mergedText: string, key?: string) => Promise<void>;
|
||||
|
||||
interface PendingBatch {
|
||||
readonly texts: string[];
|
||||
charCount: number;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS = {
|
||||
debounceMs: 600,
|
||||
maxMessages: 8,
|
||||
maxChars: 4000,
|
||||
splitThreshold: 3500,
|
||||
extendedDebounceMs: 1200,
|
||||
} as const;
|
||||
|
||||
export class MessageBatcher {
|
||||
private readonly debounceMs: number;
|
||||
private readonly maxMessages: number;
|
||||
private readonly maxChars: number;
|
||||
private readonly splitThreshold: number;
|
||||
private readonly extendedDebounceMs: number;
|
||||
private readonly pending = new Map<string, PendingBatch>();
|
||||
private readonly chains = new Map<string, Promise<void>>();
|
||||
|
||||
constructor(
|
||||
private readonly onFlush: MessageBatcherCallback,
|
||||
options: MessageBatcherOptions = {},
|
||||
) {
|
||||
this.debounceMs = options.debounceMs ?? DEFAULT_OPTIONS.debounceMs;
|
||||
this.maxMessages = options.maxMessages ?? DEFAULT_OPTIONS.maxMessages;
|
||||
this.maxChars = options.maxChars ?? DEFAULT_OPTIONS.maxChars;
|
||||
this.splitThreshold = options.splitThreshold ?? DEFAULT_OPTIONS.splitThreshold;
|
||||
this.extendedDebounceMs = options.extendedDebounceMs ?? DEFAULT_OPTIONS.extendedDebounceMs;
|
||||
}
|
||||
|
||||
async enqueue(chatId: string, senderOpenId: string, text: string): Promise<void> {
|
||||
const key = messageBatchKey(chatId, senderOpenId);
|
||||
await this.runSerial(key, async () => {
|
||||
const existing = this.pending.get(key);
|
||||
const batch =
|
||||
existing ??
|
||||
{
|
||||
texts: [],
|
||||
charCount: 0,
|
||||
timer: null,
|
||||
};
|
||||
|
||||
if (existing === undefined) {
|
||||
this.pending.set(key, batch);
|
||||
}
|
||||
|
||||
batch.texts.push(text);
|
||||
batch.charCount += text.length + (batch.texts.length === 1 ? 0 : 1);
|
||||
|
||||
if (batch.texts.length >= this.maxMessages || batch.charCount >= this.maxChars) {
|
||||
await this.flushPendingBatch(key, batch);
|
||||
return;
|
||||
}
|
||||
|
||||
this.scheduleFlush(key, batch, text.length >= this.splitThreshold ? this.extendedDebounceMs : this.debounceMs);
|
||||
});
|
||||
}
|
||||
|
||||
async flushNow(key: string): Promise<void> {
|
||||
await this.runSerial(key, async () => {
|
||||
const batch = this.pending.get(key);
|
||||
if (batch === undefined) return;
|
||||
await this.flushPendingBatch(key, batch);
|
||||
});
|
||||
}
|
||||
|
||||
async flushAll(): Promise<void> {
|
||||
const keys = [...this.pending.keys()];
|
||||
await Promise.all(keys.map((key) => this.flushNow(key)));
|
||||
}
|
||||
|
||||
private scheduleFlush(key: string, batch: PendingBatch, delayMs: number): void {
|
||||
if (batch.timer !== null) {
|
||||
clearTimeout(batch.timer);
|
||||
}
|
||||
batch.timer = setTimeout(() => {
|
||||
void this.flushNow(key).catch(() => undefined);
|
||||
}, delayMs);
|
||||
(batch.timer as { unref?: () => void }).unref?.();
|
||||
}
|
||||
|
||||
private async flushPendingBatch(key: string, batch: PendingBatch): Promise<void> {
|
||||
if (batch.timer !== null) {
|
||||
clearTimeout(batch.timer);
|
||||
batch.timer = null;
|
||||
}
|
||||
if (this.pending.get(key) !== batch) return;
|
||||
this.pending.delete(key);
|
||||
await this.onFlush(batch.texts.join("\n"), key);
|
||||
}
|
||||
|
||||
private runSerial(key: string, operation: () => Promise<void>): Promise<void> {
|
||||
const previous = this.chains.get(key) ?? Promise.resolve();
|
||||
const next = previous.catch(() => undefined).then(operation);
|
||||
this.chains.set(key, next);
|
||||
return next.finally(() => {
|
||||
if (this.chains.get(key) === next) {
|
||||
this.chains.delete(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function messageBatchKey(chatId: string, senderOpenId: string): string {
|
||||
return `${chatId}:${senderOpenId}`;
|
||||
}
|
||||
@@ -24,11 +24,14 @@ interface LarkMessage {
|
||||
message_id?: string;
|
||||
msg_type?: string;
|
||||
content?: string;
|
||||
body?: { content?: string };
|
||||
create_time?: string;
|
||||
sender?: { id?: string; sender_type?: string };
|
||||
chat_id?: string;
|
||||
parent_id?: string;
|
||||
parent_message_id?: string;
|
||||
root_id?: string;
|
||||
thread_id?: string;
|
||||
}
|
||||
|
||||
interface ImV1Message {
|
||||
@@ -84,14 +87,17 @@ export async function readFeishuContext(
|
||||
|
||||
/** Project a lark message down to the fields the model actually needs. */
|
||||
function compact(m: LarkMessage): Record<string, unknown> {
|
||||
const parentId = m.parent_id ?? m.parent_message_id;
|
||||
return {
|
||||
message_id: m.message_id,
|
||||
msg_type: m.msg_type,
|
||||
content: m.content,
|
||||
content: m.body?.content ?? m.content,
|
||||
create_time: m.create_time,
|
||||
chat_id: m.chat_id,
|
||||
sender_id: m.sender?.id,
|
||||
parent_message_id: m.parent_message_id,
|
||||
parent_id: parentId,
|
||||
parent_message_id: parentId,
|
||||
root_id: m.root_id,
|
||||
thread_id: m.thread_id,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
export interface SenderCacheOptions {
|
||||
readonly ttlMs?: number;
|
||||
readonly maxSize?: number;
|
||||
}
|
||||
|
||||
interface SenderCacheEntry {
|
||||
readonly name: string;
|
||||
readonly expiresAt: number;
|
||||
}
|
||||
|
||||
const DEFAULT_TTL_MS = 600_000;
|
||||
const DEFAULT_MAX_SIZE = 2_048;
|
||||
|
||||
export class SenderNameCache {
|
||||
private readonly ttlMs: number;
|
||||
private readonly maxSize: number;
|
||||
private readonly entries = new Map<string, SenderCacheEntry>();
|
||||
|
||||
constructor(options: SenderCacheOptions = {}) {
|
||||
this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
||||
this.maxSize = Math.max(0, options.maxSize ?? DEFAULT_MAX_SIZE);
|
||||
}
|
||||
|
||||
get(openId: string): string | null {
|
||||
const entry = this.entries.get(openId);
|
||||
if (entry === undefined) return null;
|
||||
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
this.entries.delete(openId);
|
||||
return null;
|
||||
}
|
||||
|
||||
this.entries.delete(openId);
|
||||
this.entries.set(openId, entry);
|
||||
return entry.name;
|
||||
}
|
||||
|
||||
set(openId: string, name: string): void {
|
||||
if (this.maxSize === 0) return;
|
||||
|
||||
this.entries.delete(openId);
|
||||
this.entries.set(openId, { name, expiresAt: Date.now() + this.ttlMs });
|
||||
|
||||
while (this.entries.size > this.maxSize) {
|
||||
const oldest = this.entries.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
this.entries.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
async getOrFetch(
|
||||
openId: string,
|
||||
fetchFn: (openId: string) => Promise<string | null>,
|
||||
): Promise<string | null> {
|
||||
const cached = this.get(openId);
|
||||
if (cached !== null) return cached;
|
||||
|
||||
const fetched = await fetchFn(openId);
|
||||
if (fetched !== null) {
|
||||
this.set(openId, fetched);
|
||||
}
|
||||
return fetched;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.entries.clear();
|
||||
}
|
||||
|
||||
size(): number {
|
||||
this.pruneExpired();
|
||||
return this.entries.size;
|
||||
}
|
||||
|
||||
private pruneExpired(): void {
|
||||
const now = Date.now();
|
||||
for (const [openId, entry] of this.entries) {
|
||||
if (entry.expiresAt <= now) {
|
||||
this.entries.delete(openId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { decimalToNumberOrNull, formatInteger, formatUsd } from "../agent/cost.js";
|
||||
import type { ModelRegistry, RoleEntry } from "../agent/models.js";
|
||||
import type { RuntimeSettings } from "../settings/runtime.js";
|
||||
import { sendText, type FeishuRuntime, type SendMessageOptions } from "./client.js";
|
||||
import type { TriggerQueue } from "./triggerQueue.js";
|
||||
|
||||
export interface SlashInvocation {
|
||||
readonly name: string;
|
||||
readonly args: readonly string[];
|
||||
}
|
||||
|
||||
export interface SlashCommandRunContext {
|
||||
readonly invocation: SlashInvocation;
|
||||
readonly projectId: string;
|
||||
readonly chatId: string;
|
||||
readonly rt: FeishuRuntime;
|
||||
readonly sendOptions?: SendMessageOptions | undefined;
|
||||
}
|
||||
|
||||
export interface SlashCommandDefinition {
|
||||
readonly name: string;
|
||||
readonly usage: string;
|
||||
readonly summary: string;
|
||||
readonly details: readonly string[];
|
||||
run(context: SlashCommandRunContext): Promise<void>;
|
||||
}
|
||||
|
||||
export interface SlashCommandRegistryDeps {
|
||||
readonly prisma: PrismaClient;
|
||||
readonly settings: RuntimeSettings;
|
||||
readonly logger: FastifyBaseLogger;
|
||||
readonly triggerQueue: TriggerQueue;
|
||||
}
|
||||
|
||||
const TERMINAL_RUN_STATUSES = ["COMPLETED", "FAILED", "TIMED_OUT", "CANCELED"] as const;
|
||||
|
||||
export function parseSlashInvocation(prompt: string): SlashInvocation | null {
|
||||
const trimmed = prompt.trim();
|
||||
if (!trimmed.startsWith("/")) return null;
|
||||
const tokens = trimmed.split(/\s+/);
|
||||
const rawCommand = tokens[0];
|
||||
if (rawCommand === undefined || rawCommand.length <= 1) return null;
|
||||
return {
|
||||
name: rawCommand.slice(1),
|
||||
args: tokens.slice(1),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSlashHelpSubcommand(invocation: SlashInvocation): string | null {
|
||||
if (invocation.name === "help") return null;
|
||||
return invocation.args.length === 1 && invocation.args[0] === "help"
|
||||
? invocation.name
|
||||
: null;
|
||||
}
|
||||
|
||||
export function createSlashCommandRegistry(deps: SlashCommandRegistryDeps): ReadonlyMap<string, SlashCommandDefinition> {
|
||||
const commands = new Map<string, SlashCommandDefinition>();
|
||||
const add = (command: SlashCommandDefinition): void => {
|
||||
if (commands.has(command.name)) {
|
||||
throw new Error(`duplicate slash command definition: /${command.name}`);
|
||||
}
|
||||
commands.set(command.name, command);
|
||||
};
|
||||
|
||||
add({
|
||||
name: "help",
|
||||
usage: "/help [command]",
|
||||
summary: "查看可用 slash 命令或单个命令说明。",
|
||||
details: [
|
||||
"不创建 agent run,也不改变当前会话。",
|
||||
"支持 /help new 和 /new help 两种写法。",
|
||||
],
|
||||
run: async ({ invocation, projectId, chatId, rt, sendOptions }) => {
|
||||
const registry = await deps.settings.modelRegistry({ projectId });
|
||||
await sendText(rt, chatId, formatHelpCommandInvocation(invocation, registry, commands), sendOptions);
|
||||
},
|
||||
});
|
||||
|
||||
add({
|
||||
name: "new",
|
||||
usage: "/new",
|
||||
summary: "开新会话,下次 @bot 将从头开始。",
|
||||
details: [
|
||||
"归档当前未归档的 agent session。",
|
||||
"不会清空当前项目的等待队列。",
|
||||
],
|
||||
run: async ({ projectId, chatId, rt, sendOptions }) => {
|
||||
await deps.prisma.agentSession.updateMany({
|
||||
where: { projectId, archivedAt: null },
|
||||
data: { archivedAt: new Date() },
|
||||
});
|
||||
await sendText(rt, chatId, "已开新会话,下次 @bot 将从头开始。", sendOptions);
|
||||
},
|
||||
});
|
||||
|
||||
add({
|
||||
name: "resume",
|
||||
usage: "/resume",
|
||||
summary: "恢复最近一次已归档的会话。",
|
||||
details: [
|
||||
"只恢复当前项目最近归档的 agent session。",
|
||||
"没有可恢复会话时只回复提示,不会创建 agent run。",
|
||||
],
|
||||
run: async ({ projectId, chatId, rt, sendOptions }) => {
|
||||
const latest = await deps.prisma.agentSession.findFirst({
|
||||
where: { projectId, archivedAt: { not: null } },
|
||||
orderBy: { archivedAt: "desc" },
|
||||
select: { id: true },
|
||||
});
|
||||
if (latest === null) {
|
||||
await sendText(rt, chatId, "没有可恢复的会话。", sendOptions);
|
||||
return;
|
||||
}
|
||||
await deps.prisma.agentSession.update({
|
||||
where: { id: latest.id },
|
||||
data: { archivedAt: null },
|
||||
});
|
||||
await sendText(rt, chatId, "已恢复上一个会话。", sendOptions);
|
||||
},
|
||||
});
|
||||
|
||||
add({
|
||||
name: "cost",
|
||||
usage: "/cost",
|
||||
summary: "查看当前会话已记录的 agent 成本。",
|
||||
details: [
|
||||
"只读取当前项目未归档 agent session 下已经结束的 run,不创建 agent run。",
|
||||
"只统计运行时真实记录到 AgentRun.costUsd 的成本;未记录成本的 run 会单独列出。",
|
||||
],
|
||||
run: async ({ invocation, projectId, chatId, rt, sendOptions }) => {
|
||||
if (invocation.args.length > 0) {
|
||||
await sendText(rt, chatId, ["用法错误: /cost 暂不接受参数。", "", formatBuiltinSlashCommandHelp(commands.get("cost")!)].join("\n"), sendOptions);
|
||||
return;
|
||||
}
|
||||
|
||||
const sessions = await deps.prisma.agentSession.findMany({
|
||||
where: { projectId, archivedAt: null },
|
||||
orderBy: { updatedAt: "asc" },
|
||||
select: { id: true },
|
||||
});
|
||||
if (sessions.length === 0) {
|
||||
await sendText(rt, chatId, "当前会话还没有 agent session。", sendOptions);
|
||||
return;
|
||||
}
|
||||
|
||||
const runs = await deps.prisma.agentRun.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
sessionId: { in: sessions.map((session) => session.id) },
|
||||
status: { in: [...TERMINAL_RUN_STATUSES] },
|
||||
finishedAt: { not: null },
|
||||
},
|
||||
orderBy: { finishedAt: "asc" },
|
||||
select: {
|
||||
model: true,
|
||||
provider: true,
|
||||
inputTokens: true,
|
||||
outputTokens: true,
|
||||
costUsd: true,
|
||||
},
|
||||
});
|
||||
|
||||
await sendText(rt, chatId, formatCostReport(runs), sendOptions);
|
||||
},
|
||||
});
|
||||
|
||||
add({
|
||||
name: "reset",
|
||||
usage: "/reset",
|
||||
summary: "重置当前会话并清空等待队列。",
|
||||
details: [
|
||||
"归档当前未归档的 agent session。",
|
||||
"清空当前项目已经排队、尚未开始的触发请求。",
|
||||
],
|
||||
run: async ({ projectId, chatId, rt, sendOptions }) => {
|
||||
await deps.prisma.agentSession.updateMany({
|
||||
where: { projectId, archivedAt: null },
|
||||
data: { archivedAt: new Date() },
|
||||
});
|
||||
const cleared = deps.triggerQueue.clear(projectId);
|
||||
if (cleared > 0) {
|
||||
deps.logger.info({ projectId, cleared }, "feishu trigger: cleared queued triggers on reset");
|
||||
}
|
||||
await sendText(rt, chatId, "已重置,下次 @bot 将从头开始。", sendOptions);
|
||||
},
|
||||
});
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
interface CostReportRun {
|
||||
readonly model: string;
|
||||
readonly provider: string;
|
||||
readonly inputTokens: number | null;
|
||||
readonly outputTokens: number | null;
|
||||
readonly costUsd: unknown;
|
||||
}
|
||||
|
||||
interface CostReportBucket {
|
||||
readonly provider: string;
|
||||
readonly model: string;
|
||||
runs: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
costUsd: number;
|
||||
}
|
||||
|
||||
function formatCostReport(runs: readonly CostReportRun[]): string {
|
||||
if (runs.length === 0) {
|
||||
return "当前会话还没有已结束的 agent run。";
|
||||
}
|
||||
|
||||
const buckets = new Map<string, CostReportBucket>();
|
||||
let recordedRuns = 0;
|
||||
let unrecordedRuns = 0;
|
||||
let totalInputTokens = 0;
|
||||
let totalOutputTokens = 0;
|
||||
let totalCostUsd = 0;
|
||||
|
||||
for (const run of runs) {
|
||||
const costUsd = decimalToNumberOrNull(run.costUsd);
|
||||
if (costUsd === null) {
|
||||
unrecordedRuns++;
|
||||
continue;
|
||||
}
|
||||
|
||||
recordedRuns++;
|
||||
const inputTokens = run.inputTokens ?? 0;
|
||||
const outputTokens = run.outputTokens ?? 0;
|
||||
totalInputTokens += inputTokens;
|
||||
totalOutputTokens += outputTokens;
|
||||
totalCostUsd += costUsd;
|
||||
|
||||
const key = `${run.provider}\u0000${run.model}`;
|
||||
let bucket = buckets.get(key);
|
||||
if (bucket === undefined) {
|
||||
bucket = {
|
||||
provider: run.provider,
|
||||
model: run.model,
|
||||
runs: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
costUsd: 0,
|
||||
};
|
||||
buckets.set(key, bucket);
|
||||
}
|
||||
bucket.runs++;
|
||||
bucket.inputTokens += inputTokens;
|
||||
bucket.outputTokens += outputTokens;
|
||||
bucket.costUsd += costUsd;
|
||||
}
|
||||
|
||||
if (recordedRuns === 0) {
|
||||
return [
|
||||
"当前会话已有已结束 agent run,但还没有任何 run 记录到真实成本。",
|
||||
`未记录成本: ${formatInteger(unrecordedRuns)} runs。`,
|
||||
"后续 run 需要 SDK 返回 total_cost_usd 才会进入 /cost 合计。",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const lines = [
|
||||
"当前会话已记录 agent 成本:",
|
||||
`总计: ${formatUsd(totalCostUsd)}`,
|
||||
`Runs: ${formatInteger(recordedRuns)} 已记录${unrecordedRuns > 0 ? ` / ${formatInteger(unrecordedRuns)} 未记录` : ""}`,
|
||||
`Tokens: input ${formatInteger(totalInputTokens)} / output ${formatInteger(totalOutputTokens)}`,
|
||||
"",
|
||||
"按模型:",
|
||||
];
|
||||
|
||||
const sortedBuckets = [...buckets.values()].sort((a, b) => b.costUsd - a.costUsd);
|
||||
for (const bucket of sortedBuckets) {
|
||||
lines.push(
|
||||
`- ${bucket.provider} / ${bucket.model}: ${formatInteger(bucket.runs)} runs, ${formatUsd(bucket.costUsd)}, input ${formatInteger(bucket.inputTokens)} / output ${formatInteger(bucket.outputTokens)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function formatHelpCommandInvocation(
|
||||
invocation: SlashInvocation,
|
||||
registry: ModelRegistry,
|
||||
slashCommands: ReadonlyMap<string, SlashCommandDefinition>,
|
||||
): string {
|
||||
if (invocation.args.length > 1) {
|
||||
const helpCommand = slashCommands.get("help");
|
||||
if (helpCommand === undefined) {
|
||||
throw new Error("slash command registry is missing /help");
|
||||
}
|
||||
return [
|
||||
"用法错误: /help 只接受一个命令名。",
|
||||
"",
|
||||
formatBuiltinSlashCommandHelp(helpCommand),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const target = invocation.args[0];
|
||||
if (target === undefined) return formatSlashOverview(registry, slashCommands);
|
||||
const normalizedTarget = normalizeHelpTarget(target);
|
||||
if (normalizedTarget === "") return formatSlashOverview(registry, slashCommands);
|
||||
return formatSlashHelpTarget(normalizedTarget, registry, slashCommands);
|
||||
}
|
||||
|
||||
export function formatSlashHelpTarget(
|
||||
target: string,
|
||||
registry: ModelRegistry,
|
||||
slashCommands: ReadonlyMap<string, SlashCommandDefinition>,
|
||||
): string {
|
||||
const normalizedTarget = normalizeHelpTarget(target);
|
||||
if (normalizedTarget === "") return formatSlashOverview(registry, slashCommands);
|
||||
return formatSlashCommandHelp(normalizedTarget, registry, slashCommands) ?? formatUnknownSlashHelp(normalizedTarget, registry, slashCommands);
|
||||
}
|
||||
|
||||
function normalizeHelpTarget(target: string): string {
|
||||
return target.trim().replace(/^\/+/, "");
|
||||
}
|
||||
|
||||
function formatSlashOverview(
|
||||
registry: ModelRegistry,
|
||||
slashCommands: ReadonlyMap<string, SlashCommandDefinition>,
|
||||
): string {
|
||||
const lines = [
|
||||
"可用 slash 命令:",
|
||||
...[...slashCommands.values()].map((command) => `/${command.name} - ${command.summary}`),
|
||||
];
|
||||
const roles = visibleRoleCommands(registry, slashCommands);
|
||||
if (roles.length > 0) {
|
||||
lines.push("", "角色命令:");
|
||||
for (const role of roles) {
|
||||
lines.push(`/${role.id} <需求> - 使用“${role.label}”角色发起请求。`);
|
||||
}
|
||||
} else {
|
||||
lines.push("", "当前没有配置角色命令。");
|
||||
}
|
||||
lines.push("", "查看单个命令: /help new 或 /new help。");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function formatSlashCommandHelp(
|
||||
commandName: string,
|
||||
registry: ModelRegistry,
|
||||
slashCommands: ReadonlyMap<string, SlashCommandDefinition>,
|
||||
): string | null {
|
||||
const normalizedName = normalizeHelpTarget(commandName);
|
||||
const slashCommand = slashCommands.get(normalizedName);
|
||||
if (slashCommand !== undefined) return formatBuiltinSlashCommandHelp(slashCommand);
|
||||
|
||||
const role = registry.role(normalizedName);
|
||||
if (role === undefined) return null;
|
||||
if (slashCommands.has(role.id)) return null;
|
||||
return formatRoleSlashCommandHelp(role);
|
||||
}
|
||||
|
||||
function formatBuiltinSlashCommandHelp(command: SlashCommandDefinition): string {
|
||||
const helpUsage = command.name === "help"
|
||||
? "帮助: /help help"
|
||||
: `帮助: /help ${command.name} 或 /${command.name} help`;
|
||||
return [
|
||||
`/${command.name}`,
|
||||
command.summary,
|
||||
"",
|
||||
`用法: ${command.usage}`,
|
||||
...command.details.map((detail) => `- ${detail}`),
|
||||
"",
|
||||
helpUsage,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function formatRoleSlashCommandHelp(role: RoleEntry): string {
|
||||
const lines = [
|
||||
`/${role.id}`,
|
||||
`使用“${role.label}”角色发起一次 agent run。`,
|
||||
"",
|
||||
`用法: /${role.id} <需求>`,
|
||||
"- 真正运行时仍会按当前项目的 role.trigger 授权检查。",
|
||||
];
|
||||
if (role.defaultModel !== undefined) {
|
||||
lines.push(`- 默认模型: ${role.defaultModel}`);
|
||||
}
|
||||
lines.push(`- 工具范围: ${roleToolsDescription(role)}`, "", `帮助: /help ${role.id} 或 /${role.id} help`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function roleToolsDescription(role: RoleEntry): string {
|
||||
if (role.tools === undefined) return "全部已注册工具";
|
||||
if (role.tools.length === 0) return "无";
|
||||
return role.tools.join(", ");
|
||||
}
|
||||
|
||||
function formatUnknownSlashHelp(
|
||||
commandName: string,
|
||||
registry: ModelRegistry,
|
||||
slashCommands: ReadonlyMap<string, SlashCommandDefinition>,
|
||||
): string {
|
||||
const normalizedName = normalizeHelpTarget(commandName);
|
||||
return [
|
||||
`未知 slash 命令 /${normalizedName}。`,
|
||||
"",
|
||||
formatSlashOverview(registry, slashCommands),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function visibleRoleCommands(
|
||||
registry: ModelRegistry,
|
||||
slashCommands: ReadonlyMap<string, SlashCommandDefinition>,
|
||||
): readonly RoleEntry[] {
|
||||
return registry
|
||||
.listRoles()
|
||||
.filter((role) => !slashCommands.has(role.id));
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
export interface PatchableTextSink {
|
||||
readonly create: (text: string) => Promise<string | null>;
|
||||
readonly patch: (messageId: string, text: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface PatchableTextStreamOptions {
|
||||
readonly patchIntervalMs?: number;
|
||||
readonly maxMessageLength?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_PATCH_INTERVAL_MS = 400;
|
||||
export const DEFAULT_MAX_MESSAGE_LENGTH = 8000;
|
||||
|
||||
interface CodeBlockRange {
|
||||
readonly start: number;
|
||||
readonly end: number;
|
||||
}
|
||||
|
||||
export function splitAtBoundary(text: string, maxLength: number): string[] {
|
||||
if (!Number.isInteger(maxLength) || maxLength <= 0) {
|
||||
throw new RangeError("maxLength must be a positive integer");
|
||||
}
|
||||
if (text.length <= maxLength) return [text];
|
||||
|
||||
const chunks: string[] = [];
|
||||
let remaining = text;
|
||||
while (remaining.length > maxLength) {
|
||||
const splitPoint = chooseSplitPoint(remaining, maxLength);
|
||||
if (splitPoint <= 0) {
|
||||
const codeBlockEnd = codeBlockAtStartEnd(remaining);
|
||||
const forcedSplitPoint = codeBlockEnd ?? maxLength;
|
||||
chunks.push(remaining.slice(0, forcedSplitPoint));
|
||||
remaining = remaining.slice(forcedSplitPoint);
|
||||
continue;
|
||||
}
|
||||
|
||||
chunks.push(remaining.slice(0, splitPoint));
|
||||
remaining = remaining.slice(splitPoint);
|
||||
}
|
||||
if (remaining.length > 0) {
|
||||
chunks.push(remaining);
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function chooseSplitPoint(text: string, maxLength: number): number {
|
||||
const codeBlocks = findFencedCodeBlocks(text);
|
||||
const splitCodeBlock = codeBlocks.find((range) => maxLength > range.start && maxLength < range.end);
|
||||
if (splitCodeBlock !== undefined) {
|
||||
return splitCodeBlock.start;
|
||||
}
|
||||
|
||||
return (
|
||||
lastBoundary(text, maxLength, /\r?\n\r?\n/g, codeBlocks) ??
|
||||
lastBoundary(text, maxLength, /\r?\n/g, codeBlocks) ??
|
||||
lastBoundary(text, maxLength, / /g, codeBlocks) ??
|
||||
maxLength
|
||||
);
|
||||
}
|
||||
|
||||
function lastBoundary(text: string, maxLength: number, pattern: RegExp, codeBlocks: ReadonlyArray<CodeBlockRange>): number | undefined {
|
||||
let boundary: number | undefined;
|
||||
let match: RegExpExecArray | null;
|
||||
pattern.lastIndex = 0;
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
const splitPoint = match.index + match[0].length;
|
||||
if (splitPoint > maxLength) break;
|
||||
if (splitPoint > 0 && !isInsideCodeBlock(splitPoint, codeBlocks)) {
|
||||
boundary = splitPoint;
|
||||
}
|
||||
}
|
||||
return boundary;
|
||||
}
|
||||
|
||||
function isInsideCodeBlock(splitPoint: number, codeBlocks: ReadonlyArray<CodeBlockRange>): boolean {
|
||||
return codeBlocks.some((range) => splitPoint > range.start && splitPoint < range.end);
|
||||
}
|
||||
|
||||
function codeBlockAtStartEnd(text: string): number | undefined {
|
||||
const firstCodeBlock = findFencedCodeBlocks(text)[0];
|
||||
return firstCodeBlock?.start === 0 ? firstCodeBlock.end : undefined;
|
||||
}
|
||||
|
||||
function findFencedCodeBlocks(text: string): CodeBlockRange[] {
|
||||
const ranges: CodeBlockRange[] = [];
|
||||
let openStart: number | null = null;
|
||||
let lineStart = 0;
|
||||
|
||||
while (lineStart < text.length) {
|
||||
const newlineIndex = text.indexOf("\n", lineStart);
|
||||
const lineEnd = newlineIndex === -1 ? text.length : newlineIndex;
|
||||
const nextLineStart = newlineIndex === -1 ? text.length : newlineIndex + 1;
|
||||
const line = text.slice(lineStart, lineEnd);
|
||||
|
||||
if (/^ {0,3}```/.test(line)) {
|
||||
if (openStart === null) {
|
||||
openStart = lineStart;
|
||||
} else {
|
||||
ranges.push({ start: openStart, end: nextLineStart });
|
||||
openStart = null;
|
||||
}
|
||||
}
|
||||
|
||||
lineStart = nextLineStart;
|
||||
}
|
||||
|
||||
if (openStart !== null) {
|
||||
ranges.push({ start: openStart, end: text.length });
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes text-card creation/patching for streaming output.
|
||||
*
|
||||
* Feishu returns the message id asynchronously. Without this queue, multiple
|
||||
* early text deltas can all observe "no message yet" and create duplicate
|
||||
* prefix messages before the first create call resolves.
|
||||
*/
|
||||
export class PatchableTextStream {
|
||||
private currentMessageId: string | null = null;
|
||||
private text = "";
|
||||
private lastPatchAt = 0;
|
||||
private flushChain: Promise<void> = Promise.resolve();
|
||||
private flushScheduled = false;
|
||||
|
||||
private readonly patchIntervalMs: number;
|
||||
private readonly maxMessageLength: number;
|
||||
|
||||
constructor(
|
||||
private readonly sink: PatchableTextSink,
|
||||
options: PatchableTextStreamOptions = {},
|
||||
) {
|
||||
this.patchIntervalMs = options.patchIntervalMs ?? DEFAULT_PATCH_INTERVAL_MS;
|
||||
this.maxMessageLength = options.maxMessageLength ?? DEFAULT_MAX_MESSAGE_LENGTH;
|
||||
}
|
||||
|
||||
append(delta: string): void {
|
||||
if (delta === "") return;
|
||||
this.text += delta;
|
||||
this.scheduleFlush();
|
||||
}
|
||||
|
||||
async finish(fallbackText: string): Promise<void> {
|
||||
await this.flushChain;
|
||||
// If there's pending text to flush, do it now.
|
||||
if (this.text.length > 0) {
|
||||
await this.flushTextToSink();
|
||||
return;
|
||||
}
|
||||
// No pending text. If we never sent anything, send the fallback.
|
||||
if (this.currentMessageId === null && fallbackText.length > 0) {
|
||||
this.text = fallbackText;
|
||||
await this.flushTextToSink();
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleFlush(): void {
|
||||
if (this.flushScheduled) return;
|
||||
this.flushScheduled = true;
|
||||
this.flushChain = this.flushChain.then(async () => {
|
||||
this.flushScheduled = false;
|
||||
await this.flush();
|
||||
});
|
||||
}
|
||||
|
||||
private async flush(): Promise<void> {
|
||||
if (this.currentMessageId === null) {
|
||||
await this.flushTextToSink();
|
||||
this.lastPatchAt = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (now - this.lastPatchAt < this.patchIntervalMs) return;
|
||||
this.lastPatchAt = now;
|
||||
|
||||
await this.flushTextToSink();
|
||||
}
|
||||
|
||||
private async flushTextToSink(): Promise<void> {
|
||||
const textToFlush = this.text;
|
||||
const chunks = splitAtBoundary(textToFlush, this.maxMessageLength);
|
||||
const firstChunk = chunks[0];
|
||||
if (firstChunk === undefined) return;
|
||||
|
||||
if (this.currentMessageId === null) {
|
||||
// First message: create it with the first chunk, then send overflow
|
||||
// chunks as new messages.
|
||||
this.currentMessageId = await this.sink.create(firstChunk);
|
||||
for (const chunk of chunks.slice(1)) {
|
||||
this.currentMessageId = await this.sink.create(chunk);
|
||||
}
|
||||
// Keep only the last chunk for future patches; remove already-sent prefix.
|
||||
const sentLength = chunks.slice(0, -1).reduce((sum, c) => sum + c.length, 0);
|
||||
this.text = this.text.slice(sentLength);
|
||||
} else {
|
||||
// Patch the current message with the first chunk, then create new
|
||||
// messages for overflow chunks.
|
||||
await this.sink.patch(this.currentMessageId, firstChunk);
|
||||
for (const chunk of chunks.slice(1)) {
|
||||
this.currentMessageId = await this.sink.create(chunk);
|
||||
}
|
||||
// Keep only the last chunk for future patches; remove already-sent prefix.
|
||||
const sentLength = chunks.slice(0, -1).reduce((sum, c) => sum + c.length, 0);
|
||||
this.text = this.text.slice(sentLength);
|
||||
}
|
||||
}
|
||||
}
|
||||
+963
-247
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,100 @@
|
||||
import type { MessageReceiveEvent } from "./client.js";
|
||||
|
||||
export interface QueuedTrigger {
|
||||
readonly projectId: string;
|
||||
readonly chatId: string;
|
||||
readonly prompt: string;
|
||||
readonly msg: MessageReceiveEvent["message"];
|
||||
readonly senderOpenId: string;
|
||||
readonly actor: { readonly feishuOpenId: string; readonly chatId: string };
|
||||
readonly enqueuedAt: number;
|
||||
}
|
||||
|
||||
export interface TriggerQueueOptions {
|
||||
readonly maxQueueSize?: number;
|
||||
readonly maxWaitMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_QUEUE_SIZE = 5;
|
||||
const DEFAULT_MAX_WAIT_MS = 300_000;
|
||||
|
||||
export class TriggerQueue {
|
||||
readonly maxQueueSize: number;
|
||||
readonly maxWaitMs: number;
|
||||
|
||||
private readonly queues = new Map<string, QueuedTrigger[]>();
|
||||
|
||||
constructor(options: TriggerQueueOptions = {}) {
|
||||
this.maxQueueSize = Math.max(0, Math.trunc(options.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE));
|
||||
this.maxWaitMs = Math.max(0, Math.trunc(options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS));
|
||||
}
|
||||
|
||||
enqueue(projectId: string, trigger: Omit<QueuedTrigger, "projectId" | "enqueuedAt">): number {
|
||||
const queue = this.queues.get(projectId) ?? [];
|
||||
if (queue.length >= this.maxQueueSize) return 0;
|
||||
|
||||
queue.push({
|
||||
...trigger,
|
||||
projectId,
|
||||
enqueuedAt: Date.now(),
|
||||
});
|
||||
if (!this.queues.has(projectId)) {
|
||||
this.queues.set(projectId, queue);
|
||||
}
|
||||
return queue.length;
|
||||
}
|
||||
|
||||
dequeue(projectId: string): QueuedTrigger | null {
|
||||
const queue = this.queues.get(projectId);
|
||||
if (queue === undefined || queue.length === 0) return null;
|
||||
|
||||
const next = queue.shift() ?? null;
|
||||
if (queue.length === 0) {
|
||||
this.queues.delete(projectId);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
peek(projectId: string): QueuedTrigger | null {
|
||||
return this.queues.get(projectId)?.[0] ?? null;
|
||||
}
|
||||
|
||||
length(projectId: string): number {
|
||||
return this.queues.get(projectId)?.length ?? 0;
|
||||
}
|
||||
|
||||
hasPending(projectId: string): boolean {
|
||||
return this.length(projectId) > 0;
|
||||
}
|
||||
|
||||
purgeExpired(): number {
|
||||
const now = Date.now();
|
||||
let removed = 0;
|
||||
for (const [projectId, queue] of this.queues) {
|
||||
const fresh = queue.filter((trigger) => !this.isExpired(trigger, now));
|
||||
removed += queue.length - fresh.length;
|
||||
if (fresh.length === 0) {
|
||||
this.queues.delete(projectId);
|
||||
} else if (fresh.length !== queue.length) {
|
||||
this.queues.set(projectId, fresh);
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
clear(projectId: string): number {
|
||||
const count = this.length(projectId);
|
||||
this.queues.delete(projectId);
|
||||
return count;
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
this.queues.clear();
|
||||
}
|
||||
|
||||
isExpired(trigger: QueuedTrigger, now = Date.now()): boolean {
|
||||
return trigger.enqueuedAt + this.maxWaitMs < now;
|
||||
}
|
||||
}
|
||||
|
||||
export const triggerQueue = new TriggerQueue();
|
||||
+45
-74
@@ -1,89 +1,70 @@
|
||||
/**
|
||||
* Permission gate — ADR-0004 in code, project-scope.
|
||||
* Permission compatibility exports.
|
||||
*
|
||||
* `triggerAgent` requires the `edit` capability (ADR-0004). `edit` is granted
|
||||
* by a `PermissionGrant` with role `edit` or `manage` (manage ⊇ edit, spec
|
||||
* `Role.can_mono`). This module checks that for the run's requester against
|
||||
* the project resource.
|
||||
*
|
||||
* Scope decision (project-wise): the resource is `project`, not
|
||||
* `project_group` — the run and lock scope to the project (ADR-0002), so the
|
||||
* capability is checked against the project. per-artifact permission is `OPEN`
|
||||
* pending a Courseware-side artifact id (ADR-0004 + ADR-0007 mapping).
|
||||
*
|
||||
* principal→user mapping is `OPEN` (ADR-0004 principal sub-typology). Here we
|
||||
* use the sender's Feishu open_id as the principal string literal — a pragmatic
|
||||
* choice for the first cut, not a spec decision. When a real principal model
|
||||
* lands, this is the single seam to change.
|
||||
*
|
||||
* settings.agentTrigger composition is `OPEN` (ADR-0004 separates role-derived
|
||||
* capabilities from settings policy knobs but doesn't pin the composition
|
||||
* rule). This gate implements the role half only; settings composition is
|
||||
* deferred and clearly marked.
|
||||
* ADR-0019 moves production authorization behind PrincipalResolver +
|
||||
* PermissionAuthorizer. The wrappers below preserve the old helper names for
|
||||
* tests and incremental callers while routing project trigger checks through
|
||||
* the new authorizer.
|
||||
*/
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { PermissionRole } from "@prisma/client";
|
||||
import { createPermissionAuthorizer } from "./permissions/authorizer.js";
|
||||
|
||||
/// The roles that grant `edit` capability (edit itself + manage, by monotonicity).
|
||||
const EDIT_OR_HIGHER: ReadonlySet<PermissionRole> = new Set(["EDIT", "MANAGE"]);
|
||||
export { createPermissionAuthorizer, PrismaPermissionAuthorizer, roleGrantsEdit } from "./permissions/authorizer.js";
|
||||
export { PrismaPrincipalResolver, principalKey } from "./permissions/principals.js";
|
||||
export { syncExternalPrincipalMemberships } from "./permissions/externalSync.js";
|
||||
export { grantTeamProjectAccess, listProjectTeamAccess, revokeTeamProjectAccess } from "./permissions/projectTeamAccess.js";
|
||||
export type {
|
||||
AuthorizationAction,
|
||||
AuthorizationDecision,
|
||||
AuthorizationRequest,
|
||||
AuthorizationResource,
|
||||
PermissionAuthorizer,
|
||||
} from "./permissions/authorizer.js";
|
||||
export type {
|
||||
ActorInput,
|
||||
PrincipalRef,
|
||||
PrincipalResolution,
|
||||
PrincipalResolver,
|
||||
ResolvedPrincipal,
|
||||
} from "./permissions/principals.js";
|
||||
export type {
|
||||
ExternalPrincipalMembershipInput,
|
||||
ExternalPrincipalType,
|
||||
SyncExternalPrincipalMembershipsInput,
|
||||
SyncExternalPrincipalMembershipsResult,
|
||||
} from "./permissions/externalSync.js";
|
||||
export type {
|
||||
GrantTeamProjectAccessInput,
|
||||
ProjectTeamAccessEntry,
|
||||
RevokeTeamProjectAccessInput,
|
||||
} from "./permissions/projectTeamAccess.js";
|
||||
|
||||
export interface PermissionResult {
|
||||
readonly allowed: boolean;
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether `principal` may trigger an agent run on `projectId`.
|
||||
*
|
||||
* Returns `{allowed, reason}`. On allow, the caller proceeds; on deny, the
|
||||
* caller replies with the reason. Never throws — a DB error is a deny with the
|
||||
* error as reason, so the run is not started on a broken state.
|
||||
*/
|
||||
export async function canTriggerAgent(
|
||||
prisma: PrismaClient,
|
||||
projectId: string,
|
||||
principal: string,
|
||||
): Promise<PermissionResult> {
|
||||
try {
|
||||
// Look for an active (non-revoked) grant on the project resource for this
|
||||
// principal, with a role that grants edit or higher.
|
||||
const grant = await prisma.permissionGrant.findFirst({
|
||||
where: {
|
||||
resourceType: "PROJECT",
|
||||
resourceId: projectId,
|
||||
principal,
|
||||
role: { in: ["EDIT", "MANAGE"] },
|
||||
revokedAt: null,
|
||||
},
|
||||
select: { id: true, role: true },
|
||||
const decision = await createPermissionAuthorizer(prisma).can({
|
||||
actor: { feishuOpenId: principal },
|
||||
action: "agent.trigger",
|
||||
resource: { type: "PROJECT", id: projectId },
|
||||
});
|
||||
if (grant !== null) {
|
||||
return { allowed: true, reason: `granted by role ${grant.role}` };
|
||||
}
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `no active edit+ grant for ${principal} on project ${projectId}`,
|
||||
};
|
||||
return { allowed: decision.allowed, reason: decision.reason };
|
||||
} catch (e) {
|
||||
return { allowed: false, reason: `permission check failed: ${e instanceof Error ? e.message : String(e)}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-role trigger gate (ADR-0017: roles are product config). Orthogonal to
|
||||
* {@link canTriggerAgent}: that decides "can trigger an agent at all"
|
||||
* (ADR-0004 triggerAgent capability); this decides "can trigger *which* role".
|
||||
* Two gates in series — both must pass.
|
||||
*
|
||||
* Semantics: if **no** `RoleTriggerGrant` row exists for `(projectId, roleId)`
|
||||
* (regardless of principal), the role is unconfigured on this project →
|
||||
* **allow** (back-compat: a project that hasn't configured per-role grants
|
||||
* doesn't get locked down). Once any grant exists for that role on the
|
||||
* project, only principals with an active grant may trigger it. "Grants exist
|
||||
* but this principal has none" → deny (admin explicitly restricted the role).
|
||||
*
|
||||
* `roleId` matches `RoleEntry.id` (the slash-command name). `principal` is the
|
||||
* same opaque string ADR-0004 uses (sender open_id here; principal
|
||||
* sub-typology OPEN).
|
||||
* Role-only legacy helper. New trigger code uses `role.trigger` on
|
||||
* PermissionAuthorizer so project edit+ and role grant composition happen in
|
||||
* one decision.
|
||||
*/
|
||||
export async function canTriggerRole(
|
||||
prisma: PrismaClient,
|
||||
@@ -92,10 +73,6 @@ export async function canTriggerRole(
|
||||
principal: string,
|
||||
): Promise<PermissionResult> {
|
||||
try {
|
||||
// Any grant record (active OR revoked) for this (project, role)? If none,
|
||||
// the role is unconfigured → allow (back-compat: no per-role restriction).
|
||||
// A revoked grant still counts as "configured" — revoking one person must
|
||||
// not silently reopen the role to everyone.
|
||||
const anyGrant = await prisma.roleTriggerGrant.findFirst({
|
||||
where: { projectId, roleId },
|
||||
select: { id: true },
|
||||
@@ -103,9 +80,8 @@ export async function canTriggerRole(
|
||||
if (anyGrant === null) {
|
||||
return { allowed: true, reason: `role ${roleId} unconfigured on project (open)` };
|
||||
}
|
||||
// Grants exist — this principal must hold one.
|
||||
const grant = await prisma.roleTriggerGrant.findFirst({
|
||||
where: { projectId, roleId, principal, revokedAt: null },
|
||||
where: { projectId, roleId, principalType: "USER", principalId: principal, revokedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (grant !== null) {
|
||||
@@ -119,8 +95,3 @@ export async function canTriggerRole(
|
||||
return { allowed: false, reason: `role permission check failed: ${e instanceof Error ? e.message : String(e)}` };
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a role grants edit capability (edit or manage). Exported for tests.
|
||||
export function roleGrantsEdit(role: PermissionRole): boolean {
|
||||
return EDIT_OR_HIGHER.has(role);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import type { PermissionResourceType, PermissionRole, PrismaClient, PrincipalType } from "@prisma/client";
|
||||
import { PrismaPrincipalResolver, type ActorInput, type PrincipalRef, type PrincipalResolution, type PrincipalResolver } from "./principals.js";
|
||||
|
||||
export type AuthorizationAction =
|
||||
| "project.read"
|
||||
| "project.edit"
|
||||
| "collaborator.manage"
|
||||
| "agent.trigger"
|
||||
| "agent.cancel"
|
||||
| "role.trigger";
|
||||
|
||||
export interface AuthorizationResource {
|
||||
readonly type: PermissionResourceType;
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export interface AuthorizationRequest {
|
||||
readonly actor: ActorInput;
|
||||
readonly action: AuthorizationAction;
|
||||
readonly resource: AuthorizationResource;
|
||||
readonly roleId?: string | undefined;
|
||||
}
|
||||
|
||||
export interface MatchedPermissionGrant {
|
||||
readonly id: string;
|
||||
readonly principal: PrincipalRef;
|
||||
readonly role: PermissionRole;
|
||||
}
|
||||
|
||||
export interface MatchedRoleTriggerGrant {
|
||||
readonly id: string;
|
||||
readonly principal: PrincipalRef;
|
||||
}
|
||||
|
||||
export interface AuthorizationDecision {
|
||||
readonly allowed: boolean;
|
||||
readonly reason: string;
|
||||
readonly action: AuthorizationAction;
|
||||
readonly resource: AuthorizationResource;
|
||||
readonly actor: ActorInput;
|
||||
readonly organizationId: string;
|
||||
readonly actorUserId?: string | undefined;
|
||||
readonly principals: readonly PrincipalRef[];
|
||||
readonly requiredRole: PermissionRole;
|
||||
readonly effectiveRole?: PermissionRole | undefined;
|
||||
readonly matchedGrant?: MatchedPermissionGrant | undefined;
|
||||
readonly matchedRoleGrant?: MatchedRoleTriggerGrant | undefined;
|
||||
}
|
||||
|
||||
export interface PermissionAuthorizer {
|
||||
can(req: AuthorizationRequest): Promise<AuthorizationDecision>;
|
||||
}
|
||||
|
||||
export class PrismaPermissionAuthorizer implements PermissionAuthorizer {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly resolver: PrincipalResolver = new PrismaPrincipalResolver(prisma),
|
||||
) {}
|
||||
|
||||
async can(req: AuthorizationRequest): Promise<AuthorizationDecision> {
|
||||
if (req.action === "role.trigger" && (req.roleId === undefined || req.roleId === "")) {
|
||||
throw new Error("role.trigger authorization requires roleId");
|
||||
}
|
||||
const organizationId = await this.organizationForResource(req.resource);
|
||||
const resolution = await this.resolver.resolveActor(req.actor, { organizationId });
|
||||
const threshold = await this.requiredRole(req);
|
||||
if (threshold === "DISABLED") {
|
||||
return this.decision(req, resolution, {
|
||||
allowed: false,
|
||||
reason: "permission setting disables this action",
|
||||
requiredRole: defaultRequiredRole(req.action),
|
||||
});
|
||||
}
|
||||
|
||||
const grant = await this.bestGrant(req.resource, resolution.principals, threshold);
|
||||
if (grant === undefined) {
|
||||
return this.decision(req, resolution, {
|
||||
allowed: false,
|
||||
reason: `no active ${threshold}+ grant for resolved principals`,
|
||||
requiredRole: threshold,
|
||||
});
|
||||
}
|
||||
|
||||
if (req.action !== "role.trigger") {
|
||||
return this.decision(req, resolution, {
|
||||
allowed: true,
|
||||
reason: `granted by ${grant.principal.type}:${grant.principal.id} ${grant.role}`,
|
||||
requiredRole: threshold,
|
||||
effectiveRole: grant.role,
|
||||
matchedGrant: grant,
|
||||
});
|
||||
}
|
||||
|
||||
const roleGate = await this.roleTriggerGrant(req.resource, req.roleId!, resolution.principals);
|
||||
if (roleGate.status === "open") {
|
||||
return this.decision(req, resolution, {
|
||||
allowed: true,
|
||||
reason: `role ${req.roleId} unconfigured on project (open)`,
|
||||
requiredRole: threshold,
|
||||
effectiveRole: grant.role,
|
||||
matchedGrant: grant,
|
||||
});
|
||||
}
|
||||
if (roleGate.grant !== undefined) {
|
||||
return this.decision(req, resolution, {
|
||||
allowed: true,
|
||||
reason: `role ${req.roleId} granted by ${roleGate.grant.principal.type}:${roleGate.grant.principal.id}`,
|
||||
requiredRole: threshold,
|
||||
effectiveRole: grant.role,
|
||||
matchedGrant: grant,
|
||||
matchedRoleGrant: roleGate.grant,
|
||||
});
|
||||
}
|
||||
|
||||
return this.decision(req, resolution, {
|
||||
allowed: false,
|
||||
reason: `no active role ${req.roleId} grant for resolved principals`,
|
||||
requiredRole: threshold,
|
||||
effectiveRole: grant.role,
|
||||
matchedGrant: grant,
|
||||
});
|
||||
}
|
||||
|
||||
private async requiredRole(req: AuthorizationRequest): Promise<PermissionRole | "DISABLED"> {
|
||||
let required = defaultRequiredRole(req.action);
|
||||
// ADR-0019 + PermissionGrant spec: agentTrigger and agentCancel are
|
||||
// distinct policy knobs ("谁能触发" ≠ "谁能取消"). Each can tighten the
|
||||
// default role to MANAGE_ONLY or DISABLE the action entirely. The default
|
||||
// role (trigger=EDIT, cancel=MANAGE) is the floor when no policy is set.
|
||||
if (req.resource.type === "PROJECT" && (req.action === "agent.trigger" || req.action === "role.trigger")) {
|
||||
const settings = await this.prisma.permissionSettings.findFirst({
|
||||
where: { resourceType: req.resource.type, resourceId: req.resource.id },
|
||||
select: { agentTrigger: true },
|
||||
});
|
||||
const policy = normalizePolicy(settings?.agentTrigger);
|
||||
if (policy === "DISABLED") return "DISABLED";
|
||||
if (policy === "MANAGE_ONLY") required = "MANAGE";
|
||||
} else if (req.resource.type === "PROJECT" && req.action === "agent.cancel") {
|
||||
const settings = await this.prisma.permissionSettings.findFirst({
|
||||
where: { resourceType: req.resource.type, resourceId: req.resource.id },
|
||||
select: { agentCancel: true },
|
||||
});
|
||||
const policy = normalizePolicy(settings?.agentCancel);
|
||||
if (policy === "DISABLED") return "DISABLED";
|
||||
if (policy === "MANAGE_ONLY") required = "MANAGE";
|
||||
}
|
||||
return required;
|
||||
}
|
||||
|
||||
private async bestGrant(
|
||||
resource: AuthorizationResource,
|
||||
principals: readonly PrincipalRef[],
|
||||
threshold: PermissionRole,
|
||||
): Promise<MatchedPermissionGrant | undefined> {
|
||||
const grants = await this.prisma.permissionGrant.findMany({
|
||||
where: {
|
||||
resourceType: resource.type,
|
||||
resourceId: resource.id,
|
||||
revokedAt: null,
|
||||
OR: principalWhere(principals),
|
||||
},
|
||||
select: { id: true, principalType: true, principalId: true, role: true },
|
||||
});
|
||||
const sufficient = grants
|
||||
.filter((grant) => roleRank(grant.role) >= roleRank(threshold))
|
||||
.sort((a, b) => roleRank(b.role) - roleRank(a.role))[0];
|
||||
if (sufficient === undefined) return undefined;
|
||||
return {
|
||||
id: sufficient.id,
|
||||
principal: { type: sufficient.principalType, id: sufficient.principalId },
|
||||
role: sufficient.role,
|
||||
};
|
||||
}
|
||||
|
||||
private async roleTriggerGrant(
|
||||
resource: AuthorizationResource,
|
||||
roleId: string,
|
||||
principals: readonly PrincipalRef[],
|
||||
): Promise<{ readonly status: "open" } | { readonly status: "configured"; readonly grant?: MatchedRoleTriggerGrant | undefined }> {
|
||||
if (resource.type !== "PROJECT") {
|
||||
return { status: "configured" };
|
||||
}
|
||||
const anyGrant = await this.prisma.roleTriggerGrant.findFirst({
|
||||
where: { projectId: resource.id, roleId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (anyGrant === null) return { status: "open" };
|
||||
|
||||
const grant = await this.prisma.roleTriggerGrant.findFirst({
|
||||
where: {
|
||||
projectId: resource.id,
|
||||
roleId,
|
||||
revokedAt: null,
|
||||
OR: principalWhere(principals),
|
||||
},
|
||||
select: { id: true, principalType: true, principalId: true },
|
||||
});
|
||||
if (grant === null) return { status: "configured" };
|
||||
return {
|
||||
status: "configured",
|
||||
grant: {
|
||||
id: grant.id,
|
||||
principal: { type: grant.principalType, id: grant.principalId },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private decision(
|
||||
req: AuthorizationRequest,
|
||||
resolution: PrincipalResolution,
|
||||
result: Omit<AuthorizationDecision, "action" | "resource" | "actor" | "organizationId" | "actorUserId" | "principals">,
|
||||
): AuthorizationDecision {
|
||||
return {
|
||||
action: req.action,
|
||||
resource: req.resource,
|
||||
actor: req.actor,
|
||||
organizationId: resolution.organizationId,
|
||||
...(resolution.userId !== undefined ? { actorUserId: resolution.userId } : {}),
|
||||
principals: resolution.principals,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
private async organizationForResource(resource: AuthorizationResource): Promise<string> {
|
||||
switch (resource.type) {
|
||||
case "PROJECT": {
|
||||
const project = await this.prisma.project.findUnique({
|
||||
where: { id: resource.id },
|
||||
select: { organizationId: true },
|
||||
});
|
||||
if (project === null) {
|
||||
throw new Error(`authorization resource missing: PROJECT ${resource.id}`);
|
||||
}
|
||||
return project.organizationId;
|
||||
}
|
||||
case "PROJECT_GROUP": {
|
||||
const binding = await this.prisma.projectGroupBinding.findUnique({
|
||||
where: { chatId: resource.id },
|
||||
select: { project: { select: { organizationId: true } } },
|
||||
});
|
||||
if (binding === null) {
|
||||
throw new Error(`authorization resource missing: PROJECT_GROUP ${resource.id}`);
|
||||
}
|
||||
return binding.project.organizationId;
|
||||
}
|
||||
case "ARTIFACT":
|
||||
throw new Error("authorization for ARTIFACT resources requires an organization resolver");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createPermissionAuthorizer(
|
||||
prisma: PrismaClient,
|
||||
resolver?: PrincipalResolver,
|
||||
): PermissionAuthorizer {
|
||||
return new PrismaPermissionAuthorizer(prisma, resolver);
|
||||
}
|
||||
|
||||
export function roleGrantsEdit(role: PermissionRole): boolean {
|
||||
return roleRank(role) >= roleRank("EDIT");
|
||||
}
|
||||
|
||||
export function roleRank(role: PermissionRole): number {
|
||||
switch (role) {
|
||||
case "READ": return 1;
|
||||
case "EDIT": return 2;
|
||||
case "MANAGE": return 3;
|
||||
}
|
||||
}
|
||||
|
||||
function defaultRequiredRole(action: AuthorizationAction): PermissionRole {
|
||||
switch (action) {
|
||||
case "project.read": return "READ";
|
||||
case "project.edit": return "EDIT";
|
||||
case "agent.trigger": return "EDIT";
|
||||
case "role.trigger": return "EDIT";
|
||||
case "collaborator.manage": return "MANAGE";
|
||||
case "agent.cancel": return "MANAGE";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePolicy(policy: string | undefined): "ROLE" | "MANAGE_ONLY" | "DISABLED" {
|
||||
if (policy === undefined || policy.trim() === "") return "ROLE";
|
||||
const normalized = policy.trim().toUpperCase();
|
||||
if (normalized === "DISABLED" || normalized === "DENY" || normalized === "OFF") return "DISABLED";
|
||||
if (normalized === "MANAGE_ONLY") return "MANAGE_ONLY";
|
||||
return "ROLE";
|
||||
}
|
||||
|
||||
function principalWhere(principals: readonly PrincipalRef[]): Array<{ principalType: PrincipalType; principalId: string }> {
|
||||
return principals.map((principal) => ({
|
||||
principalType: principal.type,
|
||||
principalId: principal.id,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { PrismaClient, PrincipalType } from "@prisma/client";
|
||||
|
||||
export type ExternalPrincipalType = Extract<
|
||||
PrincipalType,
|
||||
"FEISHU_CHAT" | "FEISHU_DEPARTMENT" | "FEISHU_USER_GROUP"
|
||||
>;
|
||||
|
||||
export interface ExternalPrincipalMembershipInput {
|
||||
readonly feishuOpenId: string;
|
||||
readonly displayName: string;
|
||||
readonly principalType: ExternalPrincipalType;
|
||||
readonly principalId: string;
|
||||
}
|
||||
|
||||
export interface SyncExternalPrincipalMembershipsInput {
|
||||
readonly organizationId: string;
|
||||
readonly provider?: string | undefined;
|
||||
readonly providerTenantId?: string | undefined;
|
||||
readonly source: string;
|
||||
readonly replaceSource?: boolean | undefined;
|
||||
readonly memberships: readonly ExternalPrincipalMembershipInput[];
|
||||
}
|
||||
|
||||
export interface SyncExternalPrincipalMembershipsResult {
|
||||
readonly synced: number;
|
||||
readonly revoked: number;
|
||||
}
|
||||
|
||||
export async function syncExternalPrincipalMemberships(
|
||||
prisma: PrismaClient,
|
||||
input: SyncExternalPrincipalMembershipsInput,
|
||||
): Promise<SyncExternalPrincipalMembershipsResult> {
|
||||
const syncedAt = new Date();
|
||||
const incomingKeys = new Set<string>();
|
||||
let synced = 0;
|
||||
const connection = await prisma.externalDirectoryConnection.upsert({
|
||||
where: {
|
||||
organizationId_provider_source: {
|
||||
organizationId: input.organizationId,
|
||||
provider: input.provider ?? "FEISHU",
|
||||
source: input.source,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
revokedAt: null,
|
||||
...(input.providerTenantId !== undefined ? { providerTenantId: input.providerTenantId } : {}),
|
||||
},
|
||||
create: {
|
||||
organizationId: input.organizationId,
|
||||
provider: input.provider ?? "FEISHU",
|
||||
...(input.providerTenantId !== undefined ? { providerTenantId: input.providerTenantId } : {}),
|
||||
source: input.source,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
for (const item of input.memberships) {
|
||||
assertExternalPrincipalType(item.principalType);
|
||||
const user = await prisma.user.upsert({
|
||||
where: { feishuOpenId: item.feishuOpenId },
|
||||
update: { displayName: item.displayName },
|
||||
create: {
|
||||
feishuOpenId: item.feishuOpenId,
|
||||
displayName: item.displayName,
|
||||
platformRoles: { create: { role: "TEACHER" } },
|
||||
organizationMemberships: { create: { organizationId: input.organizationId, role: "MEMBER" } },
|
||||
},
|
||||
});
|
||||
await ensureOrganizationMembership(prisma, input.organizationId, user.id);
|
||||
incomingKeys.add(externalMembershipKey(user.id, item.principalType, item.principalId, connection.id));
|
||||
const existing = await prisma.externalPrincipalMembership.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
principalType: item.principalType,
|
||||
principalId: item.principalId,
|
||||
connectionId: connection.id,
|
||||
revokedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing === null) {
|
||||
await prisma.externalPrincipalMembership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
connectionId: connection.id,
|
||||
principalType: item.principalType,
|
||||
principalId: item.principalId,
|
||||
syncedAt,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await prisma.externalPrincipalMembership.update({
|
||||
where: { id: existing.id },
|
||||
data: { syncedAt },
|
||||
});
|
||||
}
|
||||
synced++;
|
||||
}
|
||||
|
||||
let revoked = 0;
|
||||
if (input.replaceSource === true) {
|
||||
const active = await prisma.externalPrincipalMembership.findMany({
|
||||
where: { connectionId: connection.id, revokedAt: null },
|
||||
select: { id: true, userId: true, principalType: true, principalId: true, connectionId: true },
|
||||
});
|
||||
const revokeIds = active
|
||||
.filter((membership) => !incomingKeys.has(externalMembershipKey(
|
||||
membership.userId,
|
||||
membership.principalType,
|
||||
membership.principalId,
|
||||
membership.connectionId,
|
||||
)))
|
||||
.map((membership) => membership.id);
|
||||
if (revokeIds.length > 0) {
|
||||
const result = await prisma.externalPrincipalMembership.updateMany({
|
||||
where: { id: { in: revokeIds } },
|
||||
data: { revokedAt: syncedAt },
|
||||
});
|
||||
revoked = result.count;
|
||||
}
|
||||
}
|
||||
|
||||
return { synced, revoked };
|
||||
}
|
||||
|
||||
async function ensureOrganizationMembership(
|
||||
prisma: PrismaClient,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
const active = await prisma.organizationMembership.findFirst({
|
||||
where: { organizationId, userId, revokedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (active !== null) return;
|
||||
await prisma.organizationMembership.create({
|
||||
data: { organizationId, userId, role: "MEMBER" },
|
||||
});
|
||||
}
|
||||
|
||||
function externalMembershipKey(userId: string, type: PrincipalType, id: string, connectionId: string): string {
|
||||
return `${connectionId}:${userId}:${type}:${id}`;
|
||||
}
|
||||
|
||||
function assertExternalPrincipalType(type: PrincipalType): asserts type is ExternalPrincipalType {
|
||||
if (type !== "FEISHU_CHAT" && type !== "FEISHU_DEPARTMENT" && type !== "FEISHU_USER_GROUP") {
|
||||
throw new Error(`unsupported external principal type: ${type}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { PrismaClient, PrincipalType } from "@prisma/client";
|
||||
|
||||
export interface PrincipalRef {
|
||||
readonly type: PrincipalType;
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export interface ActorInput {
|
||||
readonly feishuOpenId: string;
|
||||
readonly chatId?: string | undefined;
|
||||
}
|
||||
|
||||
export interface PrincipalResolutionScope {
|
||||
readonly organizationId: string;
|
||||
}
|
||||
|
||||
export type PrincipalSource =
|
||||
| "actor-user"
|
||||
| "context-chat"
|
||||
| "team-membership"
|
||||
| "external-membership"
|
||||
| "team-external-binding";
|
||||
|
||||
export interface ResolvedPrincipal {
|
||||
readonly principal: PrincipalRef;
|
||||
readonly source: PrincipalSource;
|
||||
readonly via?: PrincipalRef | undefined;
|
||||
}
|
||||
|
||||
export interface PrincipalResolution {
|
||||
readonly actor: ActorInput;
|
||||
readonly organizationId: string;
|
||||
readonly userId?: string | undefined;
|
||||
readonly principals: readonly PrincipalRef[];
|
||||
readonly resolved: readonly ResolvedPrincipal[];
|
||||
}
|
||||
|
||||
export interface PrincipalResolver {
|
||||
resolveActor(actor: ActorInput, scope: PrincipalResolutionScope): Promise<PrincipalResolution>;
|
||||
}
|
||||
|
||||
export class PrismaPrincipalResolver implements PrincipalResolver {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
async resolveActor(actor: ActorInput, scope: PrincipalResolutionScope): Promise<PrincipalResolution> {
|
||||
const resolved = new PrincipalCollector();
|
||||
resolved.add({ type: "USER", id: actor.feishuOpenId }, "actor-user");
|
||||
if (actor.chatId !== undefined && actor.chatId !== "") {
|
||||
resolved.add({ type: "FEISHU_CHAT", id: actor.chatId }, "context-chat");
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { feishuOpenId: actor.feishuOpenId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (user !== null) {
|
||||
const memberships = await this.prisma.teamMembership.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
revokedAt: null,
|
||||
team: { organizationId: scope.organizationId, archivedAt: null },
|
||||
},
|
||||
select: { teamId: true },
|
||||
});
|
||||
for (const membership of memberships) {
|
||||
resolved.add({ type: "TEAM", id: membership.teamId }, "team-membership");
|
||||
}
|
||||
|
||||
const externalMemberships = await this.prisma.externalPrincipalMembership.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
revokedAt: null,
|
||||
connection: {
|
||||
organizationId: scope.organizationId,
|
||||
revokedAt: null,
|
||||
},
|
||||
},
|
||||
select: { principalType: true, principalId: true },
|
||||
});
|
||||
for (const membership of externalMemberships) {
|
||||
resolved.add(
|
||||
{ type: membership.principalType, id: membership.principalId },
|
||||
"external-membership",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const externalPrincipals = resolved.principals().filter((principal) => isExternalPrincipal(principal.type));
|
||||
if (externalPrincipals.length > 0) {
|
||||
const bindings = await this.prisma.teamExternalBinding.findMany({
|
||||
where: {
|
||||
revokedAt: null,
|
||||
team: { organizationId: scope.organizationId, archivedAt: null },
|
||||
OR: externalPrincipals.map((principal) => ({
|
||||
principalType: principal.type,
|
||||
principalId: principal.id,
|
||||
})),
|
||||
},
|
||||
select: { teamId: true, principalType: true, principalId: true },
|
||||
});
|
||||
for (const binding of bindings) {
|
||||
resolved.add(
|
||||
{ type: "TEAM", id: binding.teamId },
|
||||
"team-external-binding",
|
||||
{ type: binding.principalType, id: binding.principalId },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
actor,
|
||||
organizationId: scope.organizationId,
|
||||
...(user !== null ? { userId: user.id } : {}),
|
||||
principals: resolved.principals(),
|
||||
resolved: resolved.entries(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function principalKey(principal: PrincipalRef): string {
|
||||
return `${principal.type}:${principal.id}`;
|
||||
}
|
||||
|
||||
function isExternalPrincipal(type: PrincipalType): boolean {
|
||||
return type === "FEISHU_CHAT" || type === "FEISHU_DEPARTMENT" || type === "FEISHU_USER_GROUP";
|
||||
}
|
||||
|
||||
class PrincipalCollector {
|
||||
private readonly refs = new Map<string, PrincipalRef>();
|
||||
private readonly provenance: ResolvedPrincipal[] = [];
|
||||
|
||||
add(principal: PrincipalRef, source: PrincipalSource, via?: PrincipalRef): void {
|
||||
const key = principalKey(principal);
|
||||
if (!this.refs.has(key)) {
|
||||
this.refs.set(key, principal);
|
||||
}
|
||||
this.provenance.push({
|
||||
principal,
|
||||
source,
|
||||
...(via !== undefined ? { via } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
principals(): readonly PrincipalRef[] {
|
||||
return [...this.refs.values()];
|
||||
}
|
||||
|
||||
entries(): readonly ResolvedPrincipal[] {
|
||||
return this.provenance;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import type { PermissionRole, Prisma, PrismaClient } from "@prisma/client";
|
||||
|
||||
export interface GrantTeamProjectAccessInput {
|
||||
readonly projectId: string;
|
||||
readonly teamId?: string | undefined;
|
||||
readonly teamSlug?: string | undefined;
|
||||
readonly role: PermissionRole;
|
||||
readonly createdByUserId?: string | undefined;
|
||||
}
|
||||
|
||||
export interface RevokeTeamProjectAccessInput {
|
||||
readonly projectId: string;
|
||||
readonly teamId?: string | undefined;
|
||||
readonly teamSlug?: string | undefined;
|
||||
}
|
||||
|
||||
export interface ProjectTeamAccessEntry {
|
||||
readonly grantId: string;
|
||||
readonly projectId: string;
|
||||
readonly organizationId: string;
|
||||
readonly teamId: string;
|
||||
readonly teamSlug: string;
|
||||
readonly teamName: string;
|
||||
readonly role: PermissionRole;
|
||||
}
|
||||
|
||||
export async function grantTeamProjectAccess(
|
||||
prisma: PrismaClient,
|
||||
input: GrantTeamProjectAccessInput,
|
||||
): Promise<ProjectTeamAccessEntry> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const { project, team } = await resolveProjectAndTeam(tx, input);
|
||||
const now = new Date();
|
||||
const existing = await tx.permissionGrant.findFirst({
|
||||
where: {
|
||||
resourceType: "PROJECT",
|
||||
resourceId: project.id,
|
||||
principalType: "TEAM",
|
||||
principalId: team.id,
|
||||
revokedAt: null,
|
||||
},
|
||||
select: { id: true, role: true },
|
||||
});
|
||||
if (existing?.role === input.role) {
|
||||
return entryFromGrant({
|
||||
grantId: existing.id,
|
||||
projectId: project.id,
|
||||
organizationId: project.organizationId,
|
||||
team,
|
||||
role: existing.role,
|
||||
});
|
||||
}
|
||||
await tx.permissionGrant.updateMany({
|
||||
where: {
|
||||
resourceType: "PROJECT",
|
||||
resourceId: project.id,
|
||||
principalType: "TEAM",
|
||||
principalId: team.id,
|
||||
revokedAt: null,
|
||||
},
|
||||
data: { revokedAt: now },
|
||||
});
|
||||
const grant = await tx.permissionGrant.create({
|
||||
data: {
|
||||
resourceType: "PROJECT",
|
||||
resourceId: project.id,
|
||||
principalType: "TEAM",
|
||||
principalId: team.id,
|
||||
role: input.role,
|
||||
...(input.createdByUserId !== undefined ? { createdByUserId: input.createdByUserId } : {}),
|
||||
},
|
||||
select: { id: true, role: true },
|
||||
});
|
||||
return entryFromGrant({
|
||||
grantId: grant.id,
|
||||
projectId: project.id,
|
||||
organizationId: project.organizationId,
|
||||
team,
|
||||
role: grant.role,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function revokeTeamProjectAccess(
|
||||
prisma: PrismaClient,
|
||||
input: RevokeTeamProjectAccessInput,
|
||||
): Promise<number> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const { project, team } = await resolveProjectAndTeam(tx, input);
|
||||
const result = await tx.permissionGrant.updateMany({
|
||||
where: {
|
||||
resourceType: "PROJECT",
|
||||
resourceId: project.id,
|
||||
principalType: "TEAM",
|
||||
principalId: team.id,
|
||||
revokedAt: null,
|
||||
},
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
return result.count;
|
||||
});
|
||||
}
|
||||
|
||||
export async function listProjectTeamAccess(
|
||||
prisma: PrismaClient,
|
||||
projectId: string,
|
||||
): Promise<readonly ProjectTeamAccessEntry[]> {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: { id: true, organizationId: true },
|
||||
});
|
||||
if (project === null) {
|
||||
throw new Error(`project not found: ${projectId}`);
|
||||
}
|
||||
const grants = await prisma.permissionGrant.findMany({
|
||||
where: {
|
||||
resourceType: "PROJECT",
|
||||
resourceId: projectId,
|
||||
principalType: "TEAM",
|
||||
revokedAt: null,
|
||||
},
|
||||
select: { id: true, principalId: true, role: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
if (grants.length === 0) return [];
|
||||
|
||||
const teams = await prisma.team.findMany({
|
||||
where: {
|
||||
organizationId: project.organizationId,
|
||||
id: { in: grants.map((grant) => grant.principalId) },
|
||||
archivedAt: null,
|
||||
},
|
||||
select: { id: true, slug: true, name: true },
|
||||
});
|
||||
const teamsById = new Map(teams.map((team) => [team.id, team]));
|
||||
const missing = grants.filter((grant) => !teamsById.has(grant.principalId));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`project ${projectId} has active TEAM grants outside organization ${project.organizationId}: ${missing.map((grant) => grant.principalId).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
return grants.map((grant) => entryFromGrant({
|
||||
grantId: grant.id,
|
||||
projectId: project.id,
|
||||
organizationId: project.organizationId,
|
||||
team: teamsById.get(grant.principalId)!,
|
||||
role: grant.role,
|
||||
}));
|
||||
}
|
||||
|
||||
type ProjectForAccess = {
|
||||
readonly id: string;
|
||||
readonly organizationId: string;
|
||||
};
|
||||
|
||||
type TeamForAccess = {
|
||||
readonly id: string;
|
||||
readonly slug: string;
|
||||
readonly name: string;
|
||||
};
|
||||
|
||||
async function resolveProjectAndTeam(
|
||||
prisma: PrismaClient | Prisma.TransactionClient,
|
||||
input: { readonly projectId: string; readonly teamId?: string | undefined; readonly teamSlug?: string | undefined },
|
||||
): Promise<{ readonly project: ProjectForAccess; readonly team: TeamForAccess }> {
|
||||
if ((input.teamId === undefined || input.teamId === "") && (input.teamSlug === undefined || input.teamSlug === "")) {
|
||||
throw new Error("granting team project access requires teamId or teamSlug");
|
||||
}
|
||||
if (input.teamId !== undefined && input.teamId !== "" && input.teamSlug !== undefined && input.teamSlug !== "") {
|
||||
throw new Error("granting team project access accepts only one of teamId or teamSlug");
|
||||
}
|
||||
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
select: { id: true, organizationId: true },
|
||||
});
|
||||
if (project === null) {
|
||||
throw new Error(`project not found: ${input.projectId}`);
|
||||
}
|
||||
|
||||
const team = await prisma.team.findFirst({
|
||||
where:
|
||||
input.teamId !== undefined && input.teamId !== ""
|
||||
? { id: input.teamId, archivedAt: null }
|
||||
: { organizationId: project.organizationId, slug: input.teamSlug!, archivedAt: null },
|
||||
select: { id: true, slug: true, name: true, organizationId: true },
|
||||
});
|
||||
if (team === null) {
|
||||
const label = input.teamId ?? input.teamSlug;
|
||||
throw new Error(`active team not found for project ${input.projectId}: ${label}`);
|
||||
}
|
||||
if (team.organizationId !== project.organizationId) {
|
||||
throw new Error(
|
||||
`cross-organization team grant refused: project ${project.id} is in ${project.organizationId}, team ${team.id} is in ${team.organizationId}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { project, team };
|
||||
}
|
||||
|
||||
function entryFromGrant(input: {
|
||||
readonly grantId: string;
|
||||
readonly projectId: string;
|
||||
readonly organizationId: string;
|
||||
readonly team: TeamForAccess;
|
||||
readonly role: PermissionRole;
|
||||
}): ProjectTeamAccessEntry {
|
||||
return {
|
||||
grantId: input.grantId,
|
||||
projectId: input.projectId,
|
||||
organizationId: input.organizationId,
|
||||
teamId: input.team.id,
|
||||
teamSlug: input.team.slug,
|
||||
teamName: input.team.name,
|
||||
role: input.role,
|
||||
};
|
||||
}
|
||||
+40
-57
@@ -8,19 +8,15 @@
|
||||
* loop are ADR-0003-authorized (chat must match binding).
|
||||
*
|
||||
* Env (see .env.example):
|
||||
* DATABASE_URL, OPENROUTER_API_KEY, FEISHU_APP_ID/SECRET/BOT_OPEN_ID, PORT
|
||||
* DATABASE_URL, ANTHROPIC_AUTH_TOKEN, FEISHU_APP_ID/SECRET/BOT_OPEN_ID, PORT
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import Fastify from "fastify";
|
||||
import { prisma } from "./db.js";
|
||||
import { createModelFactory } from "./agent/provider.js";
|
||||
import { InMemoryModelRegistry } from "./agent/models.js";
|
||||
import { ToolRegistry, feishuContextTool } from "./agent/tools.js";
|
||||
import { readFileTool, writeFileTool, listFilesTool } from "./agent/workspace.js";
|
||||
import { cphCheckTool, cphBuildTool } from "./agent/cph.js";
|
||||
import { createLarkClient, startFeishuListenerWithClient, type FeishuRuntime } from "./feishu/client.js";
|
||||
import { readFeishuContext } from "./feishu/read.js";
|
||||
import { createEnvRuntimeSettings } from "./settings/runtime.js";
|
||||
import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
|
||||
import { makeTriggerHandler } from "./feishu/trigger.js";
|
||||
import { triggerQueue } from "./feishu/triggerQueue.js";
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const v = process.env[name];
|
||||
@@ -31,10 +27,19 @@ function requireEnv(name: string): string {
|
||||
return v;
|
||||
}
|
||||
|
||||
function booleanEnv(name: string, fallback: boolean): boolean {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw === "") return fallback;
|
||||
return !["0", "false", "no", "off"].includes(raw.trim().toLowerCase());
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const databaseUrl = requireEnv("DATABASE_URL");
|
||||
void databaseUrl; // prisma reads DATABASE_URL from env directly
|
||||
requireEnv("OPENROUTER_API_KEY");
|
||||
void databaseUrl;
|
||||
|
||||
const runtimeSettings = createEnvRuntimeSettings();
|
||||
await runtimeSettings.provider("openrouter");
|
||||
|
||||
const feishuAppId = requireEnv("FEISHU_APP_ID");
|
||||
const feishuAppSecret = requireEnv("FEISHU_APP_SECRET");
|
||||
const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID");
|
||||
@@ -42,55 +47,33 @@ async function main(): Promise<void> {
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
// Startup reset: clear stale locks + mark dead runs as FAILED.
|
||||
await prisma.projectAgentLock.deleteMany({});
|
||||
await prisma.agentRun.updateMany({
|
||||
where: { status: "ACTIVE" },
|
||||
data: { status: "FAILED", error: "process restart", finishedAt: new Date() },
|
||||
});
|
||||
app.log.info("startup: cleared stale locks + dead runs");
|
||||
|
||||
app.get("/api/healthz", async () => ({ ok: true, ts: Date.now() }));
|
||||
|
||||
// --- Agent seam wiring ---
|
||||
const modelFactory = createModelFactory();
|
||||
const models = new InMemoryModelRegistry(
|
||||
[
|
||||
{ id: "z-ai/glm-4.6", label: "GLM-4.6", toolCapable: true },
|
||||
{ id: "anthropic/claude-3.5-sonnet", label: "Claude 3.5 Sonnet", toolCapable: true },
|
||||
],
|
||||
[
|
||||
{
|
||||
id: "draft",
|
||||
label: "草稿",
|
||||
defaultModel: "z-ai/glm-4.6",
|
||||
systemPrompt: undefined,
|
||||
// Drafting needs full workspace + cph access.
|
||||
tools: ["read_file", "write_file", "list_files", "cph_check", "cph_build", "feishu_read_context"],
|
||||
},
|
||||
{
|
||||
id: "review",
|
||||
label: "审校",
|
||||
defaultModel: "anthropic/claude-3.5-sonnet",
|
||||
systemPrompt: undefined,
|
||||
// Review is read-only on artifacts; no write_file, no build.
|
||||
tools: ["read_file", "list_files", "cph_check", "feishu_read_context"],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
// Build the lark client first — tools that call Feishu APIs hold it from boot.
|
||||
const feishuConfig = { appId: feishuAppId, appSecret: feishuAppSecret, botOpenId: feishuBotOpenId };
|
||||
const larkClient = createLarkClient(feishuConfig);
|
||||
const feishuRt: FeishuRuntime = { client: larkClient, logger: app.log };
|
||||
|
||||
const tools = new ToolRegistry();
|
||||
// ADR-0003: the handler enforces chat==boundChat before any API call;
|
||||
// real read wraps @larksuiteoapi/node-sdk's im.v1.message get/list.
|
||||
tools.register("feishu_read_context", feishuContextTool(readFeishuContext, feishuRt));
|
||||
// Workspace file tools (ADR-0007 directory tree, path-confined).
|
||||
tools.register("read_file", readFileTool);
|
||||
tools.register("write_file", writeFileTool);
|
||||
tools.register("list_files", listFilesTool);
|
||||
// cph CLI tools (ADR-0016 version contract enforced by cph itself).
|
||||
tools.register("cph_check", cphCheckTool);
|
||||
tools.register("cph_build", cphBuildTool);
|
||||
|
||||
// --- Feishu listener (reuses the same lark client) ---
|
||||
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: app.log });
|
||||
startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger);
|
||||
// --- Feishu listener ---
|
||||
const feishuListenerEnabled = booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true);
|
||||
if (feishuListenerEnabled) {
|
||||
const feishuConfig = { appId: feishuAppId, appSecret: feishuAppSecret, botOpenId: feishuBotOpenId };
|
||||
const larkClient = createLarkClient(feishuConfig);
|
||||
const triggerQueuePurgeTimer = setInterval(() => {
|
||||
const removed = triggerQueue.purgeExpired();
|
||||
if (removed > 0) {
|
||||
app.log.info({ removed }, "feishu trigger queue: purged expired triggers");
|
||||
}
|
||||
}, 60_000);
|
||||
triggerQueuePurgeTimer.unref();
|
||||
const trigger = makeTriggerHandler({ prisma, settings: runtimeSettings, logger: app.log });
|
||||
startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger, trigger.onCardAction);
|
||||
} else {
|
||||
app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED");
|
||||
}
|
||||
|
||||
app.listen({ port, host: "0.0.0.0" }, (err, address) => {
|
||||
if (err !== null) {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { InMemoryModelRegistry, type ModelRegistry } from "../agent/models.js";
|
||||
|
||||
export type Env = Readonly<Record<string, string | undefined>>;
|
||||
|
||||
type EnvSource = Env | (() => Env);
|
||||
|
||||
const DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api";
|
||||
const DEFAULT_SONNET_MODEL = "anthropic/claude-sonnet-5";
|
||||
const DEFAULT_SONNET_LABEL = "Claude Sonnet 5";
|
||||
const DEFAULT_AGENT_MAX_TURNS = 25;
|
||||
|
||||
export interface ProviderRuntimeSettings {
|
||||
readonly id: string;
|
||||
readonly baseUrl: string;
|
||||
readonly authToken: string;
|
||||
readonly anthropicApiKey: string;
|
||||
readonly sdkEnv: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
export interface RuntimeScope {
|
||||
readonly projectId?: string;
|
||||
}
|
||||
|
||||
export interface RunPolicyInput {
|
||||
readonly projectId: string;
|
||||
readonly roleId: string;
|
||||
}
|
||||
|
||||
export interface RunPolicy {
|
||||
readonly maxTurns: number;
|
||||
}
|
||||
|
||||
export interface RuntimeSettings {
|
||||
provider(providerId: string, scope?: RuntimeScope): Promise<ProviderRuntimeSettings>;
|
||||
modelRegistry(scope?: RuntimeScope): Promise<ModelRegistry>;
|
||||
runPolicy(input: RunPolicyInput): Promise<RunPolicy>;
|
||||
}
|
||||
|
||||
export class EnvRuntimeSettings implements RuntimeSettings {
|
||||
private readonly envSource: () => Env;
|
||||
|
||||
constructor(env: EnvSource = process.env) {
|
||||
this.envSource = typeof env === "function" ? env : () => env;
|
||||
}
|
||||
|
||||
async provider(providerId: string, scope?: RuntimeScope): Promise<ProviderRuntimeSettings> {
|
||||
void scope;
|
||||
if (providerId !== "openrouter") {
|
||||
throw new Error(`unknown provider runtime settings: ${providerId}`);
|
||||
}
|
||||
const env = this.envSource();
|
||||
const baseUrl = readEnv(env, "ANTHROPIC_BASE_URL") ?? DEFAULT_OPENROUTER_BASE_URL;
|
||||
const authToken = requireSetting(env, "ANTHROPIC_AUTH_TOKEN");
|
||||
const anthropicApiKey = readEnv(env, "ANTHROPIC_API_KEY") ?? "";
|
||||
|
||||
return {
|
||||
id: providerId,
|
||||
baseUrl,
|
||||
authToken,
|
||||
anthropicApiKey,
|
||||
sdkEnv: {
|
||||
ANTHROPIC_BASE_URL: baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: authToken,
|
||||
ANTHROPIC_API_KEY: anthropicApiKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async modelRegistry(scope?: RuntimeScope): Promise<ModelRegistry> {
|
||||
void scope;
|
||||
return createDefaultModelRegistry(this.envSource());
|
||||
}
|
||||
|
||||
async runPolicy(input: RunPolicyInput): Promise<RunPolicy> {
|
||||
void input;
|
||||
return {
|
||||
maxTurns: positiveIntegerEnv(this.envSource(), "HUB_AGENT_MAX_TURNS", DEFAULT_AGENT_MAX_TURNS),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createEnvRuntimeSettings(env: EnvSource = process.env): RuntimeSettings {
|
||||
return new EnvRuntimeSettings(env);
|
||||
}
|
||||
|
||||
export function defaultSonnetModel(env: Env = process.env): string {
|
||||
return readEnv(env, "ANTHROPIC_DEFAULT_SONNET_MODEL") ?? DEFAULT_SONNET_MODEL;
|
||||
}
|
||||
|
||||
export function createDefaultModelRegistry(env: Env = process.env): InMemoryModelRegistry {
|
||||
const sonnetModel = defaultSonnetModel(env);
|
||||
const sonnetLabel =
|
||||
readEnv(env, "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME") ??
|
||||
(sonnetModel === DEFAULT_SONNET_MODEL ? DEFAULT_SONNET_LABEL : sonnetModel);
|
||||
|
||||
return new InMemoryModelRegistry(
|
||||
[
|
||||
{ id: sonnetModel, label: sonnetLabel, toolCapable: true },
|
||||
],
|
||||
[
|
||||
{ id: "draft", label: "草稿", defaultModel: sonnetModel },
|
||||
{ id: "review", label: "审校", defaultModel: sonnetModel },
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function readEnv(env: Env, name: string): string | undefined {
|
||||
const value = env[name]?.trim();
|
||||
return value === undefined || value === "" ? undefined : value;
|
||||
}
|
||||
|
||||
function requireSetting(env: Env, name: string): string {
|
||||
const value = readEnv(env, name);
|
||||
if (value === undefined) {
|
||||
throw new Error(`missing required runtime setting: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function positiveIntegerEnv(env: Env, name: string, fallback: number): number {
|
||||
const raw = readEnv(env, name);
|
||||
if (raw === undefined) return fallback;
|
||||
const value = Number(raw);
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`runtime setting ${name} must be a positive integer`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization } from "./helpers.js";
|
||||
import {
|
||||
createPermissionAuthorizer,
|
||||
grantTeamProjectAccess,
|
||||
listProjectTeamAccess,
|
||||
PrismaPrincipalResolver,
|
||||
syncExternalPrincipalMemberships,
|
||||
} from "../../src/permission.js";
|
||||
|
||||
describe("ADR-0019 authorization", () => {
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it("resolves actor principals from user, chat, direct teams, external memberships, and external team bindings", async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: { id: "u-auth-1", feishuOpenId: "ou_auth_1", displayName: "Teacher A" },
|
||||
});
|
||||
const directTeam = await prisma.team.create({
|
||||
data: { organizationId: DEFAULT_ORG_ID, slug: "direct-auth", name: "Direct Team" },
|
||||
});
|
||||
const departmentTeam = await prisma.team.create({
|
||||
data: { organizationId: DEFAULT_ORG_ID, slug: "department-auth", name: "Department Team" },
|
||||
});
|
||||
const chatTeam = await prisma.team.create({
|
||||
data: { organizationId: DEFAULT_ORG_ID, slug: "chat-auth", name: "Chat Team" },
|
||||
});
|
||||
await prisma.teamMembership.create({
|
||||
data: { teamId: directTeam.id, userId: user.id },
|
||||
});
|
||||
const connection = await prisma.externalDirectoryConnection.create({
|
||||
data: {
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
provider: "FEISHU",
|
||||
source: "test",
|
||||
},
|
||||
});
|
||||
await prisma.externalPrincipalMembership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
connectionId: connection.id,
|
||||
principalType: "FEISHU_DEPARTMENT",
|
||||
principalId: "dep-physics",
|
||||
},
|
||||
});
|
||||
await prisma.teamExternalBinding.create({
|
||||
data: { teamId: departmentTeam.id, principalType: "FEISHU_DEPARTMENT", principalId: "dep-physics" },
|
||||
});
|
||||
await prisma.teamExternalBinding.create({
|
||||
data: { teamId: chatTeam.id, principalType: "FEISHU_CHAT", principalId: "chat-auth" },
|
||||
});
|
||||
|
||||
const resolution = await new PrismaPrincipalResolver(prisma).resolveActor({
|
||||
feishuOpenId: "ou_auth_1",
|
||||
chatId: "chat-auth",
|
||||
}, { organizationId: DEFAULT_ORG_ID });
|
||||
|
||||
expect(resolution.userId).toBe(user.id);
|
||||
expect(resolution.principals).toEqual(expect.arrayContaining([
|
||||
{ type: "USER", id: "ou_auth_1" },
|
||||
{ type: "FEISHU_CHAT", id: "chat-auth" },
|
||||
{ type: "FEISHU_DEPARTMENT", id: "dep-physics" },
|
||||
{ type: "TEAM", id: directTeam.id },
|
||||
{ type: "TEAM", id: departmentTeam.id },
|
||||
{ type: "TEAM", id: chatTeam.id },
|
||||
]));
|
||||
});
|
||||
|
||||
it("allows agent trigger through a team project grant", async () => {
|
||||
const { team } = await seedUserTeamProject("auth-team-grant");
|
||||
await prisma.permissionGrant.create({
|
||||
data: {
|
||||
resourceType: "PROJECT",
|
||||
resourceId: "p-auth-team-grant",
|
||||
principalType: "TEAM",
|
||||
principalId: team.id,
|
||||
role: "EDIT",
|
||||
},
|
||||
});
|
||||
|
||||
const decision = await createPermissionAuthorizer(prisma).can({
|
||||
actor: { feishuOpenId: "ou_auth_team_grant" },
|
||||
action: "agent.trigger",
|
||||
resource: { type: "PROJECT", id: "p-auth-team-grant" },
|
||||
});
|
||||
|
||||
expect(decision.allowed).toBe(true);
|
||||
expect(decision.matchedGrant).toMatchObject({
|
||||
principal: { type: "TEAM", id: team.id },
|
||||
role: "EDIT",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses external Feishu principal memberships as grantable principals", async () => {
|
||||
await prisma.project.create({
|
||||
data: { id: "p-auth-ext", organizationId: DEFAULT_ORG_ID, name: "External", workspaceDir: "/tmp/auth-ext" },
|
||||
});
|
||||
await syncExternalPrincipalMemberships(prisma, {
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
source: "feishu-test",
|
||||
memberships: [
|
||||
{
|
||||
feishuOpenId: "ou_auth_ext",
|
||||
displayName: "Teacher Ext",
|
||||
principalType: "FEISHU_USER_GROUP",
|
||||
principalId: "ug-lesson-design",
|
||||
},
|
||||
],
|
||||
});
|
||||
await prisma.permissionGrant.create({
|
||||
data: {
|
||||
resourceType: "PROJECT",
|
||||
resourceId: "p-auth-ext",
|
||||
principalType: "FEISHU_USER_GROUP",
|
||||
principalId: "ug-lesson-design",
|
||||
role: "EDIT",
|
||||
},
|
||||
});
|
||||
|
||||
const decision = await createPermissionAuthorizer(prisma).can({
|
||||
actor: { feishuOpenId: "ou_auth_ext" },
|
||||
action: "agent.trigger",
|
||||
resource: { type: "PROJECT", id: "p-auth-ext" },
|
||||
});
|
||||
|
||||
expect(decision.allowed).toBe(true);
|
||||
expect(decision.matchedGrant?.principal).toEqual({
|
||||
type: "FEISHU_USER_GROUP",
|
||||
id: "ug-lesson-design",
|
||||
});
|
||||
});
|
||||
|
||||
it("revokes missing external memberships when replacing a sync source", async () => {
|
||||
await syncExternalPrincipalMemberships(prisma, {
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
source: "feishu-directory",
|
||||
memberships: [
|
||||
{
|
||||
feishuOpenId: "ou_sync_a",
|
||||
displayName: "Teacher A",
|
||||
principalType: "FEISHU_DEPARTMENT",
|
||||
principalId: "dep-a",
|
||||
},
|
||||
{
|
||||
feishuOpenId: "ou_sync_b",
|
||||
displayName: "Teacher B",
|
||||
principalType: "FEISHU_DEPARTMENT",
|
||||
principalId: "dep-b",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await syncExternalPrincipalMemberships(prisma, {
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
source: "feishu-directory",
|
||||
replaceSource: true,
|
||||
memberships: [
|
||||
{
|
||||
feishuOpenId: "ou_sync_a",
|
||||
displayName: "Teacher A",
|
||||
principalType: "FEISHU_DEPARTMENT",
|
||||
principalId: "dep-a",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual({ synced: 1, revoked: 1 });
|
||||
const activeB = await new PrismaPrincipalResolver(prisma).resolveActor(
|
||||
{ feishuOpenId: "ou_sync_b" },
|
||||
{ organizationId: DEFAULT_ORG_ID },
|
||||
);
|
||||
expect(activeB.principals).not.toContainEqual({ type: "FEISHU_DEPARTMENT", id: "dep-b" });
|
||||
});
|
||||
|
||||
it("lets PermissionSettings constrain agent trigger without granting by itself", async () => {
|
||||
await prisma.project.create({
|
||||
data: { id: "p-auth-policy", organizationId: DEFAULT_ORG_ID, name: "Policy", workspaceDir: "/tmp/auth-policy" },
|
||||
});
|
||||
await prisma.user.create({
|
||||
data: { id: "u-auth-policy", feishuOpenId: "ou_auth_policy", displayName: "Teacher Policy" },
|
||||
});
|
||||
await prisma.permissionGrant.create({
|
||||
data: {
|
||||
resourceType: "PROJECT",
|
||||
resourceId: "p-auth-policy",
|
||||
principalType: "USER",
|
||||
principalId: "ou_auth_policy",
|
||||
role: "EDIT",
|
||||
},
|
||||
});
|
||||
await prisma.permissionSettings.create({
|
||||
data: defaultProjectSettings("p-auth-policy", { agentTrigger: "MANAGE_ONLY" }),
|
||||
});
|
||||
|
||||
const editDecision = await createPermissionAuthorizer(prisma).can({
|
||||
actor: { feishuOpenId: "ou_auth_policy" },
|
||||
action: "agent.trigger",
|
||||
resource: { type: "PROJECT", id: "p-auth-policy" },
|
||||
});
|
||||
expect(editDecision.allowed).toBe(false);
|
||||
|
||||
await prisma.permissionGrant.updateMany({
|
||||
where: {
|
||||
resourceType: "PROJECT",
|
||||
resourceId: "p-auth-policy",
|
||||
principalType: "USER",
|
||||
principalId: "ou_auth_policy",
|
||||
revokedAt: null,
|
||||
},
|
||||
data: {
|
||||
role: "MANAGE",
|
||||
},
|
||||
});
|
||||
|
||||
const manageDecision = await createPermissionAuthorizer(prisma).can({
|
||||
actor: { feishuOpenId: "ou_auth_policy" },
|
||||
action: "agent.trigger",
|
||||
resource: { type: "PROJECT", id: "p-auth-policy" },
|
||||
});
|
||||
expect(manageDecision.allowed).toBe(true);
|
||||
expect(manageDecision.requiredRole).toBe("MANAGE");
|
||||
});
|
||||
|
||||
it("requires project agent.trigger and configured role trigger grant for role.trigger", async () => {
|
||||
const { team } = await seedUserTeamProject("auth-role");
|
||||
await prisma.permissionGrant.create({
|
||||
data: {
|
||||
resourceType: "PROJECT",
|
||||
resourceId: "p-auth-role",
|
||||
principalType: "TEAM",
|
||||
principalId: team.id,
|
||||
role: "EDIT",
|
||||
},
|
||||
});
|
||||
await prisma.roleTriggerGrant.create({
|
||||
data: {
|
||||
projectId: "p-auth-role",
|
||||
roleId: "review",
|
||||
principalType: "USER",
|
||||
principalId: "ou_someone_else",
|
||||
},
|
||||
});
|
||||
|
||||
const denied = await createPermissionAuthorizer(prisma).can({
|
||||
actor: { feishuOpenId: "ou_auth_role" },
|
||||
action: "role.trigger",
|
||||
resource: { type: "PROJECT", id: "p-auth-role" },
|
||||
roleId: "review",
|
||||
});
|
||||
expect(denied.allowed).toBe(false);
|
||||
|
||||
await prisma.roleTriggerGrant.create({
|
||||
data: {
|
||||
projectId: "p-auth-role",
|
||||
roleId: "review",
|
||||
principalType: "TEAM",
|
||||
principalId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
const allowed = await createPermissionAuthorizer(prisma).can({
|
||||
actor: { feishuOpenId: "ou_auth_role" },
|
||||
action: "role.trigger",
|
||||
resource: { type: "PROJECT", id: "p-auth-role" },
|
||||
roleId: "review",
|
||||
});
|
||||
expect(allowed.allowed).toBe(true);
|
||||
expect(allowed.matchedRoleGrant?.principal).toEqual({ type: "TEAM", id: team.id });
|
||||
});
|
||||
|
||||
it("keeps a role configured when all role grants are revoked", async () => {
|
||||
await prisma.project.create({
|
||||
data: { id: "p-auth-revoked-role", organizationId: DEFAULT_ORG_ID, name: "Revoked", workspaceDir: "/tmp/auth-revoked" },
|
||||
});
|
||||
await prisma.user.create({
|
||||
data: { id: "u-auth-revoked-role", feishuOpenId: "ou_auth_revoked", displayName: "Teacher Revoked" },
|
||||
});
|
||||
await prisma.permissionGrant.create({
|
||||
data: {
|
||||
resourceType: "PROJECT",
|
||||
resourceId: "p-auth-revoked-role",
|
||||
principalType: "USER",
|
||||
principalId: "ou_auth_revoked",
|
||||
role: "EDIT",
|
||||
},
|
||||
});
|
||||
await prisma.roleTriggerGrant.create({
|
||||
data: {
|
||||
projectId: "p-auth-revoked-role",
|
||||
roleId: "review",
|
||||
principalType: "USER",
|
||||
principalId: "ou_auth_revoked",
|
||||
revokedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const decision = await createPermissionAuthorizer(prisma).can({
|
||||
actor: { feishuOpenId: "ou_auth_revoked" },
|
||||
action: "role.trigger",
|
||||
resource: { type: "PROJECT", id: "p-auth-revoked-role" },
|
||||
roleId: "review",
|
||||
});
|
||||
|
||||
expect(decision.allowed).toBe(false);
|
||||
expect(decision.reason).toContain("no active role review grant");
|
||||
});
|
||||
it("agent.cancel defaults to MANAGE and is allowed for a manage grant", async () => {
|
||||
await prisma.project.create({ data: { id: "p-cancel", organizationId: DEFAULT_ORG_ID, name: "Cancel", workspaceDir: "/tmp/cancel" } });
|
||||
await prisma.user.create({ data: { id: "u-cancel", feishuOpenId: "ou_cancel", displayName: "Manager" } });
|
||||
await prisma.permissionGrant.create({
|
||||
data: { resourceType: "PROJECT", resourceId: "p-cancel", principalType: "USER", principalId: "ou_cancel", role: "MANAGE" },
|
||||
});
|
||||
|
||||
const decision = await createPermissionAuthorizer(prisma).can({
|
||||
actor: { feishuOpenId: "ou_cancel" },
|
||||
action: "agent.cancel",
|
||||
resource: { type: "PROJECT", id: "p-cancel" },
|
||||
});
|
||||
expect(decision.allowed).toBe(true);
|
||||
expect(decision.requiredRole).toBe("MANAGE");
|
||||
});
|
||||
|
||||
it("agent.cancel is denied to an edit grant (below the MANAGE default)", async () => {
|
||||
await prisma.project.create({ data: { id: "p-cancel-edit", organizationId: DEFAULT_ORG_ID, name: "Cancel Edit", workspaceDir: "/tmp/cancel-edit" } });
|
||||
await prisma.user.create({ data: { id: "u-cancel-edit", feishuOpenId: "ou_cancel_edit", displayName: "Editor" } });
|
||||
await prisma.permissionGrant.create({
|
||||
data: { resourceType: "PROJECT", resourceId: "p-cancel-edit", principalType: "USER", principalId: "ou_cancel_edit", role: "EDIT" },
|
||||
});
|
||||
|
||||
const decision = await createPermissionAuthorizer(prisma).can({
|
||||
actor: { feishuOpenId: "ou_cancel_edit" },
|
||||
action: "agent.cancel",
|
||||
resource: { type: "PROJECT", id: "p-cancel-edit" },
|
||||
});
|
||||
expect(decision.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("agentCancel DISABLED policy denies cancel even for a manage grant", async () => {
|
||||
await prisma.project.create({ data: { id: "p-cancel-off", organizationId: DEFAULT_ORG_ID, name: "Cancel Off", workspaceDir: "/tmp/cancel-off" } });
|
||||
await prisma.user.create({ data: { id: "u-cancel-off", feishuOpenId: "ou_cancel_off", displayName: "Manager Off" } });
|
||||
await prisma.permissionGrant.create({
|
||||
data: { resourceType: "PROJECT", resourceId: "p-cancel-off", principalType: "USER", principalId: "ou_cancel_off", role: "MANAGE" },
|
||||
});
|
||||
await prisma.permissionSettings.create({
|
||||
data: defaultProjectSettings("p-cancel-off", { agentCancel: "DISABLED" }),
|
||||
});
|
||||
|
||||
const decision = await createPermissionAuthorizer(prisma).can({
|
||||
actor: { feishuOpenId: "ou_cancel_off" },
|
||||
action: "agent.cancel",
|
||||
resource: { type: "PROJECT", id: "p-cancel-off" },
|
||||
});
|
||||
expect(decision.allowed).toBe(false);
|
||||
expect(decision.reason).toContain("disables this action");
|
||||
});
|
||||
|
||||
it("scopes team slug and project team access by organization", async () => {
|
||||
await seedTestOrganization("org-other", "other");
|
||||
await prisma.team.create({
|
||||
data: { organizationId: "org-other", slug: "curriculum", name: "Other Curriculum" },
|
||||
});
|
||||
const team = await prisma.team.create({
|
||||
data: { organizationId: DEFAULT_ORG_ID, slug: "curriculum", name: "Default Curriculum" },
|
||||
});
|
||||
await prisma.project.create({
|
||||
data: { id: "p-team-access", organizationId: DEFAULT_ORG_ID, name: "Team Access", workspaceDir: "/tmp/team-access" },
|
||||
});
|
||||
|
||||
const entry = await grantTeamProjectAccess(prisma, {
|
||||
projectId: "p-team-access",
|
||||
teamSlug: "curriculum",
|
||||
role: "EDIT",
|
||||
});
|
||||
|
||||
expect(entry).toMatchObject({
|
||||
projectId: "p-team-access",
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
teamId: team.id,
|
||||
teamSlug: "curriculum",
|
||||
role: "EDIT",
|
||||
});
|
||||
await grantTeamProjectAccess(prisma, {
|
||||
projectId: "p-team-access",
|
||||
teamSlug: "curriculum",
|
||||
role: "MANAGE",
|
||||
});
|
||||
const entries = await listProjectTeamAccess(prisma, "p-team-access");
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]).toMatchObject({ teamId: team.id, role: "MANAGE" });
|
||||
});
|
||||
|
||||
it("refuses cross-organization team project grants", async () => {
|
||||
await seedTestOrganization("org-other-deny", "other-deny");
|
||||
const otherTeam = await prisma.team.create({
|
||||
data: { organizationId: "org-other-deny", slug: "review", name: "Other Review" },
|
||||
});
|
||||
await prisma.project.create({
|
||||
data: { id: "p-cross-org", organizationId: DEFAULT_ORG_ID, name: "Cross Org", workspaceDir: "/tmp/cross-org" },
|
||||
});
|
||||
|
||||
await expect(grantTeamProjectAccess(prisma, {
|
||||
projectId: "p-cross-org",
|
||||
teamId: otherTeam.id,
|
||||
role: "EDIT",
|
||||
})).rejects.toThrow(/cross-organization team grant refused/);
|
||||
});
|
||||
});
|
||||
|
||||
async function seedUserTeamProject(suffix: string) {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
id: `u-${suffix}`,
|
||||
feishuOpenId: `ou_${suffix.replaceAll("-", "_")}`,
|
||||
displayName: "Teacher Team",
|
||||
},
|
||||
});
|
||||
const team = await prisma.team.create({
|
||||
data: { organizationId: DEFAULT_ORG_ID, slug: `team-${suffix}`, name: `Team ${suffix}` },
|
||||
});
|
||||
await prisma.teamMembership.create({
|
||||
data: { teamId: team.id, userId: user.id },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: { id: `p-${suffix}`, organizationId: DEFAULT_ORG_ID, name: `Project ${suffix}`, workspaceDir: `/tmp/${suffix}` },
|
||||
});
|
||||
return { user, team, project };
|
||||
}
|
||||
|
||||
function defaultProjectSettings(
|
||||
projectId: string,
|
||||
overrides: { agentTrigger?: string; agentCancel?: string } = {},
|
||||
) {
|
||||
return {
|
||||
resourceType: "PROJECT" as const,
|
||||
resourceId: projectId,
|
||||
externalShare: "ROLE",
|
||||
comment: "ROLE",
|
||||
copyDownload: "ROLE",
|
||||
collaboratorMgmt: "ROLE",
|
||||
agentTrigger: overrides.agentTrigger ?? "ROLE",
|
||||
agentCancel: overrides.agentCancel ?? "ROLE",
|
||||
};
|
||||
}
|
||||
+195
-13
@@ -12,12 +12,14 @@ import type {
|
||||
LanguageModelV4CallOptions,
|
||||
LanguageModelV4Content,
|
||||
LanguageModelV4GenerateResult,
|
||||
LanguageModelV4StreamPart,
|
||||
LanguageModelV4StreamResult,
|
||||
} from "@ai-sdk/provider";
|
||||
import type { FeishuRuntime } from "../../src/feishu/client.js";
|
||||
import type { ModelFactory } from "../../src/agent/runner.js";
|
||||
|
||||
export const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
|
||||
export const DEFAULT_ORG_ID = "org_test_default";
|
||||
|
||||
export const prisma = new PrismaClient({
|
||||
datasources: { db: { url: TEST_DATABASE_URL } },
|
||||
@@ -27,20 +29,45 @@ export const prisma = new PrismaClient({
|
||||
export async function resetDb(): Promise<void> {
|
||||
const tables = [
|
||||
"FeishuEventReceipt",
|
||||
"AgentFileChange",
|
||||
"AgentMessage",
|
||||
"AuditEntry",
|
||||
"PermissionSettings",
|
||||
"PermissionGrant",
|
||||
"RoleTriggerGrant",
|
||||
"ExternalPrincipalMembership",
|
||||
"ExternalDirectoryConnection",
|
||||
"TeamExternalBinding",
|
||||
"TeamMembership",
|
||||
"Team",
|
||||
"ProjectAgentLock",
|
||||
"AgentRun",
|
||||
"AgentSession",
|
||||
"ProjectGroupBinding",
|
||||
"OrganizationMembership",
|
||||
"PlatformRoleAssignment",
|
||||
"User",
|
||||
"Project",
|
||||
"Organization",
|
||||
];
|
||||
// Truncate with CASCADE to wipe dependent rows in one shot.
|
||||
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tables.map((t) => `"${t}"`).join(", ")} RESTART IDENTITY CASCADE`);
|
||||
await seedTestOrganization();
|
||||
}
|
||||
|
||||
export async function seedTestOrganization(
|
||||
id: string = DEFAULT_ORG_ID,
|
||||
slug: string = "test-default",
|
||||
): Promise<void> {
|
||||
await prisma.organization.upsert({
|
||||
where: { id },
|
||||
update: {},
|
||||
create: {
|
||||
id,
|
||||
slug,
|
||||
name: "Test Default Organization",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** A logger that discards everything (tests don't need fastify's pino). */
|
||||
@@ -54,33 +81,99 @@ export const silentLogger: FastifyBaseLogger = {
|
||||
export interface MockFeishuRuntime extends FeishuRuntime {
|
||||
readonly sentTexts: string[];
|
||||
readonly sentCards: unknown[];
|
||||
readonly sentPatches: unknown[];
|
||||
readonly sentReplies: Array<{
|
||||
readonly messageId: string;
|
||||
readonly msgType: string;
|
||||
readonly content: string;
|
||||
readonly replyInThread: unknown;
|
||||
}>;
|
||||
readonly reactions: Array<{ readonly messageId: string; readonly emoji: string }>;
|
||||
readonly readableMessages: Map<string, MockFeishuMessage>;
|
||||
readonly threadMessages: Map<string, readonly MockFeishuMessage[]>;
|
||||
}
|
||||
|
||||
export interface MockFeishuMessage {
|
||||
readonly message_id?: string;
|
||||
readonly root_id?: string;
|
||||
readonly parent_id?: string;
|
||||
readonly thread_id?: string;
|
||||
readonly msg_type?: string;
|
||||
readonly create_time?: string;
|
||||
readonly chat_id?: string;
|
||||
readonly sender?: { readonly id?: string; readonly sender_type?: string };
|
||||
readonly body?: { readonly content?: string };
|
||||
readonly content?: string;
|
||||
}
|
||||
|
||||
export function mockFeishuRuntime(): MockFeishuRuntime {
|
||||
const sentTexts: string[] = [];
|
||||
const sentCards: unknown[] = [];
|
||||
const sentPatches: unknown[] = [];
|
||||
const sentReplies: MockFeishuRuntime["sentReplies"] = [];
|
||||
const reactions: Array<{ messageId: string; emoji: string }> = [];
|
||||
const readableMessages = new Map<string, MockFeishuMessage>();
|
||||
const threadMessages = new Map<string, readonly MockFeishuMessage[]>();
|
||||
// Mock the client — sendText/sendCard are in client.ts, not on the runtime
|
||||
// object directly. We patch by providing a runtime whose client is a stub;
|
||||
// the actual send functions cast through shape, so a minimal stub works.
|
||||
const rt: MockFeishuRuntime = {
|
||||
client: {
|
||||
request: async (p: unknown) => {
|
||||
const payload = p as { url?: string; data?: { reaction_type?: { emoji_type?: string } } };
|
||||
const match = payload.url?.match(/\/messages\/([^/]+)\/reactions$/);
|
||||
if (match?.[1] !== undefined) {
|
||||
reactions.push({
|
||||
messageId: match[1],
|
||||
emoji: payload.data?.reaction_type?.emoji_type ?? "",
|
||||
});
|
||||
}
|
||||
return {};
|
||||
},
|
||||
im: {
|
||||
v1: {
|
||||
message: {
|
||||
create: async (p: unknown) => {
|
||||
const payload = p as { data?: { msg_type?: string; content?: string } };
|
||||
if (payload.data?.msg_type === "interactive") {
|
||||
sentCards.push(payload.data.content ? JSON.parse(payload.data.content) : null);
|
||||
} else {
|
||||
try {
|
||||
const c = JSON.parse(payload.data?.content ?? "{}") as { text?: string };
|
||||
sentTexts.push(c.text ?? payload.data?.content ?? "");
|
||||
} catch {
|
||||
sentTexts.push(payload.data?.content ?? "");
|
||||
}
|
||||
}
|
||||
recordSentMessage(payload.data, sentTexts, sentCards);
|
||||
return { data: { message_id: "mock-msg-id" } };
|
||||
},
|
||||
reply: async (p: unknown) => {
|
||||
const payload = p as {
|
||||
path?: { message_id?: string };
|
||||
data?: { msg_type?: string; content?: string; reply_in_thread?: unknown };
|
||||
};
|
||||
sentReplies.push({
|
||||
messageId: payload.path?.message_id ?? "",
|
||||
msgType: payload.data?.msg_type ?? "",
|
||||
content: payload.data?.content ?? "",
|
||||
replyInThread: payload.data?.reply_in_thread,
|
||||
});
|
||||
recordSentMessage(payload.data, sentTexts, sentCards);
|
||||
return { data: { message_id: "mock-msg-id" } };
|
||||
},
|
||||
patch: async (p: unknown) => {
|
||||
const payload = p as { data?: { content?: string } };
|
||||
const card = payload.data?.content ? JSON.parse(payload.data.content) : null;
|
||||
sentPatches.push(card);
|
||||
const text = textFromCard(card);
|
||||
if (text !== null) sentTexts.push(text);
|
||||
return {};
|
||||
},
|
||||
get: async (p: unknown) => {
|
||||
const payload = p as { path?: { message_id?: string } };
|
||||
const messageId = payload.path?.message_id ?? "";
|
||||
const message = readableMessages.get(messageId);
|
||||
return { data: { items: message === undefined ? [] : [message] } };
|
||||
},
|
||||
list: async (p: unknown) => {
|
||||
const payload = p as { params?: { container_id?: string } };
|
||||
const containerId = payload.params?.container_id ?? "";
|
||||
const items =
|
||||
threadMessages.get(containerId) ??
|
||||
[...readableMessages.values()].filter((message) => message.parent_id === containerId);
|
||||
return { data: { items } };
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -88,10 +181,60 @@ export function mockFeishuRuntime(): MockFeishuRuntime {
|
||||
logger: silentLogger,
|
||||
sentTexts,
|
||||
sentCards,
|
||||
sentPatches,
|
||||
sentReplies,
|
||||
reactions,
|
||||
readableMessages,
|
||||
threadMessages,
|
||||
};
|
||||
return rt;
|
||||
}
|
||||
|
||||
function recordSentMessage(
|
||||
data: { msg_type?: string; content?: string } | undefined,
|
||||
sentTexts: string[],
|
||||
sentCards: unknown[],
|
||||
): void {
|
||||
if (data?.msg_type === "interactive") {
|
||||
const card = data.content ? JSON.parse(data.content) : null;
|
||||
sentCards.push(card);
|
||||
const text = textFromCard(card);
|
||||
if (text !== null) sentTexts.push(text);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const c = JSON.parse(data?.content ?? "{}") as { text?: string };
|
||||
sentTexts.push(c.text ?? data?.content ?? "");
|
||||
} catch {
|
||||
sentTexts.push(data?.content ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
function textFromCard(card: unknown): string | null {
|
||||
if (typeof card !== "object" || card === null) return null;
|
||||
const elements = (card as { elements?: unknown }).elements;
|
||||
if (!Array.isArray(elements)) return null;
|
||||
const parts: string[] = [];
|
||||
for (const element of elements) {
|
||||
if (typeof element !== "object" || element === null) continue;
|
||||
// New format: { tag: "markdown", content: string }
|
||||
const tag = (element as { tag?: unknown }).tag;
|
||||
const directContent = (element as { content?: unknown }).content;
|
||||
if (tag === "markdown" && typeof directContent === "string") {
|
||||
parts.push(directContent);
|
||||
continue;
|
||||
}
|
||||
// Old format: { text: { content: string } }
|
||||
const text = (element as { text?: unknown }).text;
|
||||
if (typeof text === "object" && text !== null) {
|
||||
const content = (text as { content?: unknown }).content;
|
||||
if (typeof content === "string") parts.push(content);
|
||||
}
|
||||
}
|
||||
return parts.length === 0 ? null : parts.join("\n");
|
||||
}
|
||||
|
||||
export interface MockToolCall {
|
||||
readonly toolCallId: string;
|
||||
readonly toolName: string;
|
||||
@@ -151,8 +294,44 @@ export class MockLanguageModel implements LanguageModelV4 {
|
||||
};
|
||||
}
|
||||
|
||||
async doStream(_options: LanguageModelV4CallOptions): Promise<LanguageModelV4StreamResult> {
|
||||
throw new Error("MockLanguageModel does not implement streaming");
|
||||
async doStream(options: LanguageModelV4CallOptions): Promise<LanguageModelV4StreamResult> {
|
||||
this.calls.push(options);
|
||||
const response = this.responses[this.calls.length - 1 % this.responses.length] ?? this.responses[0]!;
|
||||
const text = response.text ?? "";
|
||||
const finishReason = response.finishReason ?? ((response.toolCalls?.length ?? 0) > 0 ? "tool-calls" : "stop");
|
||||
const id = `mock-${this.calls.length}`;
|
||||
|
||||
const parts: LanguageModelV4StreamPart[] = [
|
||||
{ type: "text-start", id },
|
||||
];
|
||||
if (text.length > 0) {
|
||||
parts.push({ type: "text-delta", id, delta: text });
|
||||
}
|
||||
parts.push({ type: "text-end", id });
|
||||
for (const call of response.toolCalls ?? []) {
|
||||
const tcId = `tc-${this.calls.length}-${call.toolName}`;
|
||||
parts.push({ type: "tool-input-start", id: tcId, toolName: call.toolName });
|
||||
parts.push({ type: "tool-input-delta", id: tcId, delta: JSON.stringify(call.input) });
|
||||
parts.push({ type: "tool-input-end", id: tcId });
|
||||
}
|
||||
parts.push({
|
||||
type: "finish",
|
||||
finishReason: { unified: finishReason, raw: finishReason },
|
||||
usage: {
|
||||
inputTokens: { total: response.inputTokens ?? 10, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },
|
||||
outputTokens: { total: response.outputTokens ?? 5, text: undefined, reasoning: undefined },
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
stream: new ReadableStream({
|
||||
start(controller) {
|
||||
for (const part of parts) controller.enqueue(part);
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
response: { id, timestamp: new Date(), modelId: "mock-model" },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +360,7 @@ export async function seedProject(
|
||||
await prisma.project.create({
|
||||
data: {
|
||||
id: projectId,
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
name: `Test ${projectId}`,
|
||||
workspaceDir: `/tmp/test-${projectId}`,
|
||||
},
|
||||
@@ -191,11 +371,13 @@ export async function seedProject(
|
||||
feishuOpenId: principal,
|
||||
displayName: "Test User",
|
||||
platformRoles: { create: { role: "TEACHER" } },
|
||||
organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role: "MEMBER" } },
|
||||
permissionGrants: {
|
||||
create: {
|
||||
resourceType: "PROJECT",
|
||||
resourceId: projectId,
|
||||
principal,
|
||||
principalType: "USER",
|
||||
principalId: principal,
|
||||
role,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, afterAll } from "vitest";
|
||||
import { prisma, resetDb } from "./helpers.js";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
|
||||
import { acquireLock, releaseLock, currentLockRunId, isTerminal } from "../../src/lock.js";
|
||||
|
||||
describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => {
|
||||
@@ -9,7 +9,7 @@ describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => {
|
||||
|
||||
it("acquireLock + currentLockRunId round-trip", async () => {
|
||||
const project = await prisma.project.create({
|
||||
data: { id: "p-lock-1", name: "Test", workspaceDir: "/tmp/x" },
|
||||
data: { id: "p-lock-1", organizationId: DEFAULT_ORG_ID, name: "Test", workspaceDir: "/tmp/x" },
|
||||
});
|
||||
const run = await prisma.agentRun.create({
|
||||
data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} },
|
||||
@@ -26,7 +26,7 @@ describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => {
|
||||
|
||||
it("acquireLock fails when project already locked (exclusivity)", async () => {
|
||||
const project = await prisma.project.create({
|
||||
data: { id: "p-lock-2", name: "Test", workspaceDir: "/tmp/x" },
|
||||
data: { id: "p-lock-2", organizationId: DEFAULT_ORG_ID, name: "Test", workspaceDir: "/tmp/x" },
|
||||
});
|
||||
const run1 = await prisma.agentRun.create({
|
||||
data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} },
|
||||
@@ -41,7 +41,7 @@ describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => {
|
||||
|
||||
it("currentLockRunId throws when a terminal run holds the lock (WellFormed)", async () => {
|
||||
const project = await prisma.project.create({
|
||||
data: { id: "p-lock-3", name: "Test", workspaceDir: "/tmp/x" },
|
||||
data: { id: "p-lock-3", organizationId: DEFAULT_ORG_ID, name: "Test", workspaceDir: "/tmp/x" },
|
||||
});
|
||||
// Create a run that is already COMPLETED (terminal), then manually insert a lock.
|
||||
const run = await prisma.agentRun.create({
|
||||
@@ -57,7 +57,7 @@ describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => {
|
||||
|
||||
it("releaseLock is idempotent", async () => {
|
||||
const project = await prisma.project.create({
|
||||
data: { id: "p-lock-4", name: "Test", workspaceDir: "/tmp/x" },
|
||||
data: { id: "p-lock-4", organizationId: DEFAULT_ORG_ID, name: "Test", workspaceDir: "/tmp/x" },
|
||||
});
|
||||
const run = await prisma.agentRun.create({
|
||||
data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} },
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Optional live-model smoke for the current Claude SDK runner.
|
||||
*
|
||||
* Skips unless explicitly enabled with RUN_REAL_MODEL_TESTS=true and an
|
||||
* OpenRouter/Anthropic token is available. When enabled, it verifies that
|
||||
* `runAgent` can complete one real SDK turn and project the user/assistant
|
||||
* messages into the structured AgentMessage table shape.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import "dotenv/config";
|
||||
import { runAgent } from "../../src/agent/runner.js";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
|
||||
const API_KEY = process.env["OPENROUTER_API_KEY"] ?? process.env["ANTHROPIC_AUTH_TOKEN"];
|
||||
const HAS_KEY = API_KEY !== undefined && API_KEY !== "";
|
||||
const ENABLED = HAS_KEY && process.env["RUN_REAL_MODEL_TESTS"] === "true";
|
||||
const describeOrSkip = ENABLED ? describe : describe.skip;
|
||||
|
||||
if (ENABLED) {
|
||||
process.env["ANTHROPIC_AUTH_TOKEN"] = API_KEY;
|
||||
process.env["ANTHROPIC_BASE_URL"] ??= "https://openrouter.ai/api";
|
||||
process.env["ANTHROPIC_API_KEY"] ??= "";
|
||||
}
|
||||
|
||||
const MODEL = process.env["SMOKE_MODEL"] ?? process.env["ANTHROPIC_DEFAULT_SONNET_MODEL"] ?? "z-ai/glm-4.7";
|
||||
|
||||
describeOrSkip("real model integration (OpenRouter)", () => {
|
||||
it("completes one live SDK turn and records structured messages", async () => {
|
||||
const workspace = await mkdtemp(join(tmpdir(), "hub-real-model-"));
|
||||
const messages: unknown[] = [];
|
||||
const stubPrisma = {
|
||||
projectAgentLock: { update: async () => ({}) },
|
||||
agentMessage: {
|
||||
create: async (input: unknown) => {
|
||||
messages.push(input);
|
||||
return {};
|
||||
},
|
||||
},
|
||||
} as unknown as PrismaClient;
|
||||
|
||||
try {
|
||||
const result = await runAgent({
|
||||
runId: "real-test-run",
|
||||
sessionId: "real-test-session",
|
||||
prompt: "请只回复: OK",
|
||||
model: MODEL,
|
||||
project: { projectId: "real-test-project", boundChatId: "chat-test", workspaceDir: workspace },
|
||||
prisma: stubPrisma,
|
||||
systemPrompt: "你是一个极简 smoke test 助手。请严格按用户要求回复。",
|
||||
maxTurns: 3,
|
||||
});
|
||||
|
||||
expect(result.status, result.error).toBe("completed");
|
||||
expect(result.text.length).toBeGreaterThan(0);
|
||||
expect(messages.length).toBeGreaterThanOrEqual(2);
|
||||
} finally {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, afterAll } from "vitest";
|
||||
import { prisma, resetDb } from "./helpers.js";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
|
||||
import { canTriggerRole } from "../../src/permission.js";
|
||||
|
||||
describe("canTriggerRole (integration, per-role gate)", () => {
|
||||
@@ -13,7 +13,7 @@ describe("canTriggerRole (integration, per-role gate)", () => {
|
||||
|
||||
it("allows when role is unconfigured on the project (back-compat: open)", async () => {
|
||||
const project = await prisma.project.create({
|
||||
data: { id: "p-role-1", name: "T", workspaceDir: "/tmp/x" },
|
||||
data: { id: "p-role-1", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" },
|
||||
});
|
||||
const r = await canTriggerRole(prisma, project.id, "draft", "ou_a");
|
||||
expect(r.allowed).toBe(true);
|
||||
@@ -21,10 +21,10 @@ describe("canTriggerRole (integration, per-role gate)", () => {
|
||||
|
||||
it("allows when principal holds an active grant for the role", async () => {
|
||||
const project = await prisma.project.create({
|
||||
data: { id: "p-role-2", name: "T", workspaceDir: "/tmp/x" },
|
||||
data: { id: "p-role-2", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" },
|
||||
});
|
||||
await prisma.roleTriggerGrant.create({
|
||||
data: { projectId: project.id, roleId: "review", principal: "ou_a" },
|
||||
data: { projectId: project.id, roleId: "review", principalType: "USER", principalId: "ou_a" },
|
||||
});
|
||||
const r = await canTriggerRole(prisma, project.id, "review", "ou_a");
|
||||
expect(r.allowed).toBe(true);
|
||||
@@ -32,11 +32,11 @@ describe("canTriggerRole (integration, per-role gate)", () => {
|
||||
|
||||
it("denies when grants exist for the role but principal has none", async () => {
|
||||
const project = await prisma.project.create({
|
||||
data: { id: "p-role-3", name: "T", workspaceDir: "/tmp/x" },
|
||||
data: { id: "p-role-3", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" },
|
||||
});
|
||||
// Someone else has the role; ou_b does not.
|
||||
await prisma.roleTriggerGrant.create({
|
||||
data: { projectId: project.id, roleId: "review", principal: "ou_a" },
|
||||
data: { projectId: project.id, roleId: "review", principalType: "USER", principalId: "ou_a" },
|
||||
});
|
||||
const r = await canTriggerRole(prisma, project.id, "review", "ou_b");
|
||||
expect(r.allowed).toBe(false);
|
||||
@@ -44,24 +44,24 @@ describe("canTriggerRole (integration, per-role gate)", () => {
|
||||
|
||||
it("denies when the principal's grant was revoked", async () => {
|
||||
const project = await prisma.project.create({
|
||||
data: { id: "p-role-4", name: "T", workspaceDir: "/tmp/x" },
|
||||
data: { id: "p-role-4", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" },
|
||||
});
|
||||
await prisma.roleTriggerGrant.create({
|
||||
data: { projectId: project.id, roleId: "review", principal: "ou_a", revokedAt: new Date() },
|
||||
data: { projectId: project.id, roleId: "review", principalType: "USER", principalId: "ou_a", revokedAt: new Date() },
|
||||
});
|
||||
const r = await canTriggerRole(prisma, project.id, "review", "ou_a");
|
||||
expect(r.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("scopes grants per-project (grant on p1 does not allow on p2)", async () => {
|
||||
const p1 = await prisma.project.create({ data: { id: "p-role-5a", name: "T", workspaceDir: "/tmp/x" } });
|
||||
const p2 = await prisma.project.create({ data: { id: "p-role-5b", name: "T", workspaceDir: "/tmp/x" } });
|
||||
const p1 = await prisma.project.create({ data: { id: "p-role-5a", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" } });
|
||||
const p2 = await prisma.project.create({ data: { id: "p-role-5b", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" } });
|
||||
await prisma.roleTriggerGrant.create({
|
||||
data: { projectId: p1.id, roleId: "review", principal: "ou_a" },
|
||||
data: { projectId: p1.id, roleId: "review", principalType: "USER", principalId: "ou_a" },
|
||||
});
|
||||
// p2 has the role configured for someone else; ou_a has no grant there.
|
||||
await prisma.roleTriggerGrant.create({
|
||||
data: { projectId: p2.id, roleId: "review", principal: "ou_other" },
|
||||
data: { projectId: p2.id, roleId: "review", principalType: "USER", principalId: "ou_other" },
|
||||
});
|
||||
const onP1 = await canTriggerRole(prisma, p1.id, "review", "ou_a");
|
||||
expect(onP1.allowed).toBe(true);
|
||||
|
||||
@@ -1,12 +1,38 @@
|
||||
import { describe, it, expect, beforeEach, afterAll, vi } from "vitest";
|
||||
import { prisma, resetDb, mockFeishuRuntime, createMockModelFactory, seedProject, silentLogger } from "./helpers.js";
|
||||
import { prisma, resetDb, mockFeishuRuntime, seedProject, silentLogger } from "./helpers.js";
|
||||
import { InMemoryModelRegistry } from "../../src/agent/models.js";
|
||||
import { ToolRegistry } from "../../src/agent/tools.js";
|
||||
import { makeTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
|
||||
import type { MessageReceiveEvent } from "../../src/feishu/client.js";
|
||||
import type { ModelFactory } from "../../src/agent/runner.js";
|
||||
import { TriggerQueue } from "../../src/feishu/triggerQueue.js";
|
||||
import type { MessageReceiveEvent, CardActionEvent } from "../../src/feishu/client.js";
|
||||
import type { RunRequest, RunResult } from "../../src/agent/runner.js";
|
||||
import type { RuntimeSettings } from "../../src/settings/runtime.js";
|
||||
|
||||
const bot = { key: "@_user_1", id: { open_id: "ou_bot" }, name: "Bot" };
|
||||
type TestRunner = (req: RunRequest) => Promise<RunResult>;
|
||||
|
||||
function makeTestSettings(models: InMemoryModelRegistry): RuntimeSettings {
|
||||
return {
|
||||
async provider(providerId) {
|
||||
return {
|
||||
id: providerId,
|
||||
baseUrl: "https://openrouter.ai/api",
|
||||
authToken: "test-token",
|
||||
anthropicApiKey: "",
|
||||
sdkEnv: {
|
||||
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
|
||||
ANTHROPIC_AUTH_TOKEN: "test-token",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
},
|
||||
};
|
||||
},
|
||||
async modelRegistry() {
|
||||
return models;
|
||||
},
|
||||
async runPolicy() {
|
||||
return { maxTurns: 7 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeEvent(chatId: string, text: string, senderOpenId = "ou_test_user", eventId?: string): MessageReceiveEvent {
|
||||
const header = eventId !== undefined ? { event_id: eventId, event_type: "im.message.receive_v1" } : undefined;
|
||||
@@ -24,16 +50,47 @@ function makeEvent(chatId: string, text: string, senderOpenId = "ou_test_user",
|
||||
};
|
||||
}
|
||||
|
||||
function expectPromptFromSender(prompt: string | undefined, senderOpenId: string, rawPrompt: string): void {
|
||||
expect(prompt).toContain("Feishu trigger context");
|
||||
expect(prompt).toContain(`"open_id": "${senderOpenId}"`);
|
||||
expect(prompt).toContain(`User request from ${senderOpenId}:\n${rawPrompt}`);
|
||||
}
|
||||
|
||||
function createMockRunAgent(calls: RunRequest[] = []): TestRunner {
|
||||
return async (req) => {
|
||||
calls.push(req);
|
||||
req.onStream?.({ type: "text-delta", text: "mock response" });
|
||||
await req.prisma.agentMessage.create({
|
||||
data: {
|
||||
sessionId: req.sessionId,
|
||||
runId: req.runId,
|
||||
role: "assistant",
|
||||
content: "mock response",
|
||||
attachments: [],
|
||||
},
|
||||
});
|
||||
return {
|
||||
status: "completed",
|
||||
text: "mock response",
|
||||
usage: { inputTokens: 10, outputTokens: 5 },
|
||||
costUsd: 0.0023,
|
||||
numTurns: 1,
|
||||
sdkSessionId: "sdk-session-1",
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
describe("trigger full lifecycle (integration)", () => {
|
||||
let modelFactory: ModelFactory;
|
||||
let tools: ToolRegistry;
|
||||
let models: InMemoryModelRegistry;
|
||||
let settings: RuntimeSettings;
|
||||
let rt: ReturnType<typeof mockFeishuRuntime>;
|
||||
let runAgentCalls: RunRequest[];
|
||||
let runAgent: TestRunner;
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
modelFactory = createMockModelFactory().modelFactory;
|
||||
tools = new ToolRegistry();
|
||||
runAgentCalls = [];
|
||||
runAgent = createMockRunAgent(runAgentCalls);
|
||||
models = new InMemoryModelRegistry(
|
||||
[{ id: "mock-model", label: "Mock", toolCapable: true }],
|
||||
[
|
||||
@@ -41,14 +98,16 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
{ id: "review", label: "审校", defaultModel: "mock-model", systemPrompt: undefined, tools: ["read_file"] },
|
||||
],
|
||||
);
|
||||
settings = makeTestSettings(models);
|
||||
rt = mockFeishuRuntime();
|
||||
});
|
||||
|
||||
it("creates a run, acquires + releases the lock, sends status card", async () => {
|
||||
await seedProject("proj-1", "chat-1");
|
||||
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
const event = makeEvent("chat-1", "@_user_1 写教案");
|
||||
|
||||
await trigger(makeEvent("chat-1", "@_user_1 写教案"), rt);
|
||||
await trigger(event, rt);
|
||||
|
||||
// Wait for the async run to complete (fire-and-forget in trigger).
|
||||
await vi.waitFor(async () => {
|
||||
@@ -58,26 +117,308 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
});
|
||||
|
||||
// Lock released (no lock row remains).
|
||||
const locks = await prisma.projectAgentLock.findMany();
|
||||
expect(locks).toHaveLength(0);
|
||||
await vi.waitFor(async () => {
|
||||
const locks = await prisma.projectAgentLock.findMany();
|
||||
expect(locks).toHaveLength(0);
|
||||
});
|
||||
|
||||
// A status card was sent.
|
||||
expect(rt.sentCards.length).toBeGreaterThanOrEqual(1);
|
||||
expect(rt.sentTexts).toContain("已开始处理(role: draft, model: mock-model)。");
|
||||
expect(rt.sentReplies[0]).toMatchObject({
|
||||
messageId: event.message.message_id,
|
||||
msgType: "interactive",
|
||||
replyInThread: undefined,
|
||||
});
|
||||
expect(rt.sentTexts.some((text) => text.includes("mock response") && text.includes("本次成本: $0.0023"))).toBe(true);
|
||||
expect(runAgentCalls).toHaveLength(1);
|
||||
expect(runAgentCalls[0]?.providerEnv).toMatchObject({
|
||||
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
|
||||
ANTHROPIC_AUTH_TOKEN: "test-token",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
});
|
||||
expect(runAgentCalls[0]?.maxTurns).toBe(7);
|
||||
const run = await prisma.agentRun.findFirstOrThrow();
|
||||
expect(Number(run.costUsd)).toBeCloseTo(0.0023);
|
||||
expect(run.costSource).toBe("provider_reported");
|
||||
});
|
||||
|
||||
it("batches quick text messages from the same chat and sender into one run", async () => {
|
||||
await seedProject("proj-1b", "chat-1b");
|
||||
const trigger = makeTriggerHandler({
|
||||
prisma,
|
||||
settings,
|
||||
logger: silentLogger,
|
||||
runAgent,
|
||||
messageBatcherOptions: { debounceMs: 10_000, maxMessages: 2 },
|
||||
});
|
||||
|
||||
await trigger(makeEvent("chat-1b", "@_user_1 第一段"), rt);
|
||||
expect(await prisma.agentRun.findMany()).toHaveLength(0);
|
||||
|
||||
await trigger(makeEvent("chat-1b", "@_user_1 第二段"), rt);
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]?.status).toBe("COMPLETED");
|
||||
expectPromptFromSender(runs[0]?.prompt, "ou_test_user", "第一段\n第二段");
|
||||
});
|
||||
expect(runAgentCalls).toHaveLength(1);
|
||||
expectPromptFromSender(runAgentCalls[0]?.prompt, "ou_test_user", "第一段\n第二段");
|
||||
});
|
||||
|
||||
it("keeps the project session shared while labeling each sender in the prompt", async () => {
|
||||
await seedProject("proj-speaker", "chat-speaker");
|
||||
await prisma.permissionGrant.create({
|
||||
data: {
|
||||
resourceType: "PROJECT",
|
||||
resourceId: "proj-speaker",
|
||||
principalType: "FEISHU_CHAT",
|
||||
principalId: "chat-speaker",
|
||||
role: "EDIT",
|
||||
},
|
||||
});
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-speaker", "@_user_1 Alice 的需求", "ou_alice"), rt);
|
||||
await vi.waitFor(() => {
|
||||
expect(runAgentCalls).toHaveLength(1);
|
||||
});
|
||||
await vi.waitFor(async () => {
|
||||
expect(await prisma.projectAgentLock.findMany()).toHaveLength(0);
|
||||
});
|
||||
|
||||
await trigger(makeEvent("chat-speaker", "@_user_1 Bob 的需求", "ou_bob"), rt);
|
||||
await vi.waitFor(() => {
|
||||
expect(runAgentCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
expectPromptFromSender(runAgentCalls[0]?.prompt, "ou_alice", "Alice 的需求");
|
||||
expectPromptFromSender(runAgentCalls[1]?.prompt, "ou_bob", "Bob 的需求");
|
||||
const sessions = await prisma.agentSession.findMany();
|
||||
expect(sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("/new bypasses message batching", async () => {
|
||||
await seedProject("proj-1c", "chat-1c");
|
||||
const trigger = makeTriggerHandler({
|
||||
prisma,
|
||||
settings,
|
||||
logger: silentLogger,
|
||||
runAgent,
|
||||
messageBatcherOptions: { debounceMs: 10_000 },
|
||||
});
|
||||
|
||||
await trigger(makeEvent("chat-1c", "@_user_1 /new"), rt);
|
||||
|
||||
expect(rt.sentTexts).toContain("已开新会话,下次 @bot 将从头开始。");
|
||||
expect(runAgentCalls).toHaveLength(0);
|
||||
expect(await prisma.agentRun.findMany()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("/help lists control and role commands without creating a run", async () => {
|
||||
await seedProject("proj-help", "chat-help");
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-help", "@_user_1 /help"), rt);
|
||||
|
||||
const helpText = rt.sentTexts.at(-1) ?? "";
|
||||
expect(helpText).toContain("可用 slash 命令");
|
||||
expect(helpText).toContain("/new");
|
||||
expect(helpText).toContain("/reset");
|
||||
expect(helpText).toContain("/draft <需求>");
|
||||
expect(helpText).toContain("/review <需求>");
|
||||
expect(runAgentCalls).toHaveLength(0);
|
||||
expect(await prisma.agentRun.findMany()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("/cost reports recorded current-session cost without creating a run", async () => {
|
||||
await seedProject("proj-cost", "chat-cost");
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-cost", "@_user_1 写教案"), rt);
|
||||
await vi.waitFor(async () => {
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]?.status).toBe("COMPLETED");
|
||||
});
|
||||
|
||||
await trigger(makeEvent("chat-cost", "@_user_1 /cost"), rt);
|
||||
|
||||
const costText = rt.sentTexts.at(-1) ?? "";
|
||||
expect(costText).toContain("当前会话已记录 agent 成本");
|
||||
expect(costText).toContain("总计: $0.0023");
|
||||
expect(costText).toContain("Runs: 1 已记录");
|
||||
expect(costText).toContain("openrouter / mock-model");
|
||||
expect(runAgentCalls).toHaveLength(1);
|
||||
expect(await prisma.agentRun.findMany()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("/cost surfaces finished runs without recorded cost", async () => {
|
||||
await seedProject("proj-cost-missing", "chat-cost-missing");
|
||||
const session = await prisma.agentSession.create({
|
||||
data: {
|
||||
projectId: "proj-cost-missing",
|
||||
provider: "openrouter",
|
||||
roleId: "draft",
|
||||
model: "mock-model",
|
||||
metadata: {},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
await prisma.agentRun.create({
|
||||
data: {
|
||||
projectId: "proj-cost-missing",
|
||||
sessionId: session.id,
|
||||
entrypoint: "FEISHU",
|
||||
status: "COMPLETED",
|
||||
prompt: "old test run",
|
||||
model: "mock-model",
|
||||
provider: "openrouter",
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
metadata: {},
|
||||
finishedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-cost-missing", "@_user_1 /cost"), rt);
|
||||
|
||||
const costText = rt.sentTexts.at(-1) ?? "";
|
||||
expect(costText).toContain("还没有任何 run 记录到真实成本");
|
||||
expect(costText).toContain("未记录成本: 1 runs");
|
||||
expect(runAgentCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("/help is built from the current registry on each request", async () => {
|
||||
await seedProject("proj-help-live", "chat-help-live");
|
||||
let currentModels = new InMemoryModelRegistry(
|
||||
[{ id: "mock-model", label: "Mock", toolCapable: true }],
|
||||
[{ id: "draft", label: "草稿", defaultModel: "mock-model", systemPrompt: undefined, tools: undefined }],
|
||||
);
|
||||
const dynamicSettings: RuntimeSettings = {
|
||||
async provider(providerId, scope) {
|
||||
return settings.provider(providerId, scope);
|
||||
},
|
||||
async modelRegistry() {
|
||||
return currentModels;
|
||||
},
|
||||
async runPolicy(input) {
|
||||
return settings.runPolicy(input);
|
||||
},
|
||||
};
|
||||
const trigger = makeTriggerHandler({ prisma, settings: dynamicSettings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-help-live", "@_user_1 /help"), rt);
|
||||
expect(rt.sentTexts.at(-1) ?? "").not.toContain("/coach <需求>");
|
||||
|
||||
currentModels = new InMemoryModelRegistry(
|
||||
[{ id: "mock-model", label: "Mock", toolCapable: true }],
|
||||
[
|
||||
{ id: "draft", label: "草稿", defaultModel: "mock-model", systemPrompt: undefined, tools: undefined },
|
||||
{ id: "coach", label: "教练", defaultModel: "mock-model", systemPrompt: undefined, tools: [] },
|
||||
],
|
||||
);
|
||||
|
||||
await trigger(makeEvent("chat-help-live", "@_user_1 /help"), rt);
|
||||
|
||||
const helpText = rt.sentTexts.at(-1) ?? "";
|
||||
expect(helpText).toContain("/coach <需求>");
|
||||
expect(runAgentCalls).toHaveLength(0);
|
||||
expect(await prisma.agentRun.findMany()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("/help <command> returns command-specific help", async () => {
|
||||
await seedProject("proj-help-reset", "chat-help-reset");
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-help-reset", "@_user_1 /help reset"), rt);
|
||||
|
||||
const helpText = rt.sentTexts.at(-1) ?? "";
|
||||
expect(helpText).toContain("/reset");
|
||||
expect(helpText).toContain("清空当前项目已经排队");
|
||||
expect(helpText).toContain("/reset help");
|
||||
expect(runAgentCalls).toHaveLength(0);
|
||||
expect(await prisma.agentRun.findMany()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("passes the selected role tool whitelist into the runner", async () => {
|
||||
await seedProject("proj-role-tools", "chat-role-tools");
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-role-tools", "@_user_1 /review 检查讲义"), rt);
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]?.status).toBe("COMPLETED");
|
||||
});
|
||||
|
||||
expect(runAgentCalls).toHaveLength(1);
|
||||
expect(runAgentCalls[0]?.tools).toEqual(["read_file"]);
|
||||
});
|
||||
|
||||
it("control commands support a help subcommand", async () => {
|
||||
await seedProject("proj-new-help", "chat-new-help");
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-new-help", "@_user_1 /new help"), rt);
|
||||
|
||||
const helpText = rt.sentTexts.at(-1) ?? "";
|
||||
expect(helpText).toContain("/new");
|
||||
expect(helpText).toContain("归档当前未归档");
|
||||
expect(helpText).toContain("/help new");
|
||||
expect(rt.sentTexts).not.toContain("已开新会话,下次 @bot 将从头开始。");
|
||||
expect(runAgentCalls).toHaveLength(0);
|
||||
expect(await prisma.agentRun.findMany()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("role commands support a help subcommand without consuming role grants", async () => {
|
||||
await seedProject("proj-role-help", "chat-role-help");
|
||||
await prisma.roleTriggerGrant.create({
|
||||
data: { projectId: "proj-role-help", roleId: "review", principalType: "USER", principalId: "ou_other" },
|
||||
});
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-role-help", "@_user_1 /review help"), rt);
|
||||
|
||||
const helpText = rt.sentTexts.at(-1) ?? "";
|
||||
expect(helpText).toContain("/review");
|
||||
expect(helpText).toContain("使用“审校”角色");
|
||||
expect(helpText).toContain("工具范围: read_file");
|
||||
expect(rt.sentTexts).not.toContain("无权限使用角色 review。");
|
||||
expect(runAgentCalls).toHaveLength(0);
|
||||
expect(await prisma.agentRun.findMany()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("/help unknown and /unknown help report the unknown command", async () => {
|
||||
await seedProject("proj-help-unknown", "chat-help-unknown");
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-help-unknown", "@_user_1 /help unknown"), rt);
|
||||
expect(rt.sentTexts.at(-1) ?? "").toContain("未知 slash 命令 /unknown");
|
||||
expect(runAgentCalls).toHaveLength(0);
|
||||
|
||||
await trigger(makeEvent("chat-help-unknown", "@_user_1 /unknown help"), rt);
|
||||
expect(rt.sentTexts.at(-1) ?? "").toContain("未知 slash 命令 /unknown");
|
||||
expect(runAgentCalls).toHaveLength(0);
|
||||
expect(await prisma.agentRun.findMany()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a sender without edit grant (ADR-0004)", async () => {
|
||||
await seedProject("proj-2", "chat-2", { role: "READ" });
|
||||
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-2", "@_user_1 写教案"), rt);
|
||||
|
||||
expect(rt.sentTexts).toContain("无权限触发。");
|
||||
expect(runAgentCalls).toHaveLength(0);
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("replies busy when project is already locked (ADR-0002)", async () => {
|
||||
it("queues a text trigger when project is already locked (ADR-0002)", async () => {
|
||||
await seedProject("proj-3", "chat-3");
|
||||
// Manually create a lock by inserting a run + lock.
|
||||
const existingRun = await prisma.agentRun.create({
|
||||
@@ -87,18 +428,65 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
data: { projectId: "proj-3", runId: existingRun.id },
|
||||
});
|
||||
|
||||
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
await trigger(makeEvent("chat-3", "@_user_1 写教案"), rt);
|
||||
|
||||
expect(rt.sentTexts).toContain("项目正在处理中,请稍候。");
|
||||
expect(rt.sentTexts).toContain("已加入队列(第1位),当前处理完成后将自动开始");
|
||||
expect(rt.reactions.some((reaction) => reaction.emoji === "OnIt")).toBe(false);
|
||||
expect(runAgentCalls).toHaveLength(0);
|
||||
// No new run created.
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("starts the next queued text trigger when the current run finishes", async () => {
|
||||
await seedProject("proj-3b", "chat-3b");
|
||||
const firstRun = deferred<RunResult>();
|
||||
const secondRun = deferred<RunResult>();
|
||||
const pendingRuns = [firstRun, secondRun];
|
||||
const queuedRunAgent: TestRunner = async (req) => {
|
||||
runAgentCalls.push(req);
|
||||
req.onStream?.({ type: "text-delta", text: `mock response ${runAgentCalls.length}` });
|
||||
const pendingRun = pendingRuns.shift();
|
||||
if (pendingRun === undefined) {
|
||||
throw new Error("unexpected extra run");
|
||||
}
|
||||
return pendingRun.promise;
|
||||
};
|
||||
const trigger = makeTriggerHandler({
|
||||
prisma,
|
||||
settings,
|
||||
logger: silentLogger,
|
||||
runAgent: queuedRunAgent,
|
||||
messageBatcherOptions: { maxMessages: 1 },
|
||||
});
|
||||
|
||||
await trigger(makeEvent("chat-3b", "@_user_1 第一个请求"), rt);
|
||||
await vi.waitFor(() => {
|
||||
expect(runAgentCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
await trigger(makeEvent("chat-3b", "@_user_1 第二个请求"), rt);
|
||||
expect(rt.sentTexts).toContain("已加入队列(第1位),当前处理完成后将自动开始");
|
||||
expect(runAgentCalls).toHaveLength(1);
|
||||
|
||||
firstRun.resolve(completedRunResult("first done", "sdk-session-first"));
|
||||
await vi.waitFor(() => {
|
||||
expect(runAgentCalls).toHaveLength(2);
|
||||
});
|
||||
expectPromptFromSender(runAgentCalls[1]?.prompt, "ou_test_user", "第二个请求");
|
||||
|
||||
secondRun.resolve(completedRunResult("second done", "sdk-session-second"));
|
||||
await vi.waitFor(async () => {
|
||||
const runs = await prisma.agentRun.findMany({ orderBy: { startedAt: "asc" } });
|
||||
expect(runs).toHaveLength(2);
|
||||
expect(runs.every((run) => run.status === "COMPLETED")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores messages from unbound chats (ADR-0001)", async () => {
|
||||
await seedProject("proj-4", "chat-4");
|
||||
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-UNKNOWN", "@_user_1 写教案"), rt);
|
||||
|
||||
@@ -110,7 +498,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
|
||||
it("ignores messages without @bot mention", async () => {
|
||||
await seedProject("proj-5", "chat-5");
|
||||
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
const event: MessageReceiveEvent = {
|
||||
message: {
|
||||
@@ -132,7 +520,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
|
||||
it("/new archives current session (no run created)", async () => {
|
||||
await seedProject("proj-6", "chat-6");
|
||||
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
// First @bot creates a session + run.
|
||||
await trigger(makeEvent("chat-6", "@_user_1 写教案"), rt);
|
||||
@@ -155,7 +543,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
|
||||
it("/resume un-archives the most recent session", async () => {
|
||||
await seedProject("proj-7", "chat-7");
|
||||
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
// Create + archive a session via /new.
|
||||
await trigger(makeEvent("chat-7", "@_user_1 写教案"), rt);
|
||||
@@ -175,7 +563,15 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
|
||||
it("/reset archives current session", async () => {
|
||||
await seedProject("proj-8", "chat-8");
|
||||
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
|
||||
const queue = new TriggerQueue();
|
||||
const trigger = makeTriggerHandler({
|
||||
prisma,
|
||||
settings,
|
||||
logger: silentLogger,
|
||||
runAgent,
|
||||
messageBatcherOptions: { maxMessages: 1 },
|
||||
triggerQueue: queue,
|
||||
});
|
||||
|
||||
await trigger(makeEvent("chat-8", "@_user_1 写教案"), rt);
|
||||
await vi.waitFor(async () => {
|
||||
@@ -184,8 +580,19 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
expect(runs[0]?.status).toBe("COMPLETED");
|
||||
});
|
||||
|
||||
const queuedEvent = makeEvent("chat-8", "@_user_1 后续需求");
|
||||
queue.enqueue("proj-8", {
|
||||
chatId: "chat-8",
|
||||
prompt: extractPrompt(queuedEvent.message) ?? "后续需求",
|
||||
msg: queuedEvent.message,
|
||||
senderOpenId: "ou_test_user",
|
||||
actor: { feishuOpenId: "ou_test_user", chatId: "chat-8" },
|
||||
});
|
||||
expect(queue.length("proj-8")).toBe(1);
|
||||
|
||||
await trigger(makeEvent("chat-8", "@_user_1 /reset"), rt);
|
||||
expect(rt.sentTexts).toContain("已重置,下次 @bot 将从头开始。");
|
||||
expect(queue.length("proj-8")).toBe(0);
|
||||
|
||||
const sessions = await prisma.agentSession.findMany();
|
||||
expect(sessions).toHaveLength(1);
|
||||
@@ -194,14 +601,14 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
|
||||
it("unknown slash command falls through to agent", async () => {
|
||||
await seedProject("proj-9", "chat-9");
|
||||
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-9", "@_user_1 /unknown"), rt);
|
||||
// Should create a run (falls through as a normal prompt).
|
||||
await vi.waitFor(async () => {
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]?.prompt).toBe("/unknown");
|
||||
expectPromptFromSender(runs[0]?.prompt, "ou_test_user", "/unknown");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -209,13 +616,14 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
await seedProject("proj-10", "chat-10");
|
||||
// Someone else holds review; ou_test_user does not.
|
||||
await prisma.roleTriggerGrant.create({
|
||||
data: { projectId: "proj-10", roleId: "review", principal: "ou_other" },
|
||||
data: { projectId: "proj-10", roleId: "review", principalType: "USER", principalId: "ou_other" },
|
||||
});
|
||||
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-10", "@_user_1 /review 看看这节"), rt);
|
||||
|
||||
expect(rt.sentTexts).toContain("无权限使用角色 review。");
|
||||
expect(runAgentCalls).toHaveLength(0);
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs).toHaveLength(0);
|
||||
});
|
||||
@@ -223,9 +631,9 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
it("allows /review when sender holds the role grant", async () => {
|
||||
await seedProject("proj-11", "chat-11");
|
||||
await prisma.roleTriggerGrant.create({
|
||||
data: { projectId: "proj-11", roleId: "review", principal: "ou_test_user" },
|
||||
data: { projectId: "proj-11", roleId: "review", principalType: "USER", principalId: "ou_test_user" },
|
||||
});
|
||||
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-11", "@_user_1 /review 看看这节"), rt);
|
||||
|
||||
@@ -239,21 +647,49 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
|
||||
it("extractRole: /draft sets roleId=draft, strips command from prompt", async () => {
|
||||
await seedProject("proj-12", "chat-12");
|
||||
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-12", "@_user_1 /draft 写第三单元"), rt);
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]?.prompt).toBe("写第三单元");
|
||||
expectPromptFromSender(runs[0]?.prompt, "ou_test_user", "写第三单元");
|
||||
expect(runs[0]?.metadata).toMatchObject({ roleId: "draft" });
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps role sessions separate even when roles share a model", async () => {
|
||||
await seedProject("proj-12b", "chat-12b");
|
||||
await prisma.roleTriggerGrant.create({
|
||||
data: { projectId: "proj-12b", roleId: "review", principalType: "USER", principalId: "ou_test_user" },
|
||||
});
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-12b", "@_user_1 /draft 写第三单元"), rt);
|
||||
await vi.waitFor(async () => {
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]?.status).toBe("COMPLETED");
|
||||
});
|
||||
|
||||
await trigger(makeEvent("chat-12b", "@_user_1 /review 看看这节"), rt);
|
||||
await vi.waitFor(async () => {
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs).toHaveLength(2);
|
||||
expect(runs.every((run) => run.status === "COMPLETED")).toBe(true);
|
||||
});
|
||||
|
||||
const sessions = await prisma.agentSession.findMany({ orderBy: { roleId: "asc" } });
|
||||
expect(sessions.map((session) => session.roleId)).toEqual(["draft", "review"]);
|
||||
expect(new Set(sessions.map((session) => session.model))).toEqual(new Set(["mock-model"]));
|
||||
expect(new Set(sessions.map((session) => session.id)).size).toBe(2);
|
||||
expect(runAgentCalls[1]?.resumeSessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("dedups a redelivered event by event_id (no second run)", async () => {
|
||||
await seedProject("proj-13", "chat-13");
|
||||
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
// First delivery: processes normally.
|
||||
await trigger(makeEvent("chat-13", "@_user_1 写教案", "ou_test_user", "evt-dedup-1"), rt);
|
||||
@@ -274,9 +710,91 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
expect(receipts[0]?.eventId).toBe("evt-dedup-1");
|
||||
});
|
||||
|
||||
it("dedups flattened websocket events by top-level event_id", async () => {
|
||||
await seedProject("proj-13b", "chat-13b");
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
const event: MessageReceiveEvent = {
|
||||
...makeEvent("chat-13b", "@_user_1 写教案"),
|
||||
event_id: "evt-flat-1",
|
||||
event_type: "im.message.receive_v1",
|
||||
};
|
||||
|
||||
await trigger(event, rt);
|
||||
await vi.waitFor(async () => {
|
||||
expect(await prisma.agentRun.findMany()).toHaveLength(1);
|
||||
});
|
||||
|
||||
await trigger(
|
||||
{
|
||||
...event,
|
||||
message: { ...event.message, message_id: "m_flat_redelivery" },
|
||||
},
|
||||
rt,
|
||||
);
|
||||
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs).toHaveLength(1);
|
||||
const receipts = await prisma.feishuEventReceipt.findMany();
|
||||
expect(receipts).toHaveLength(1);
|
||||
expect(receipts[0]?.eventId).toBe("evt-flat-1");
|
||||
});
|
||||
|
||||
it("seeds replied-to message context when @bot is used in a Feishu reply", async () => {
|
||||
await seedProject("proj-13c", "chat-13c");
|
||||
rt.readableMessages.set("m-parent", {
|
||||
message_id: "m-parent",
|
||||
root_id: "m-root",
|
||||
thread_id: "thread-1",
|
||||
msg_type: "text",
|
||||
chat_id: "chat-13c",
|
||||
sender: { id: "ou_teacher_2", sender_type: "user" },
|
||||
body: { content: JSON.stringify({ text: "上一条需求: 把第二题改成探究题。" }) },
|
||||
});
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
const baseEvent = makeEvent("chat-13c", "@_user_1 继续这个改法");
|
||||
const event: MessageReceiveEvent = {
|
||||
...baseEvent,
|
||||
message: {
|
||||
...baseEvent.message,
|
||||
message_id: "m-child",
|
||||
root_id: "m-root",
|
||||
parent_id: "m-parent",
|
||||
thread_id: "thread-1",
|
||||
},
|
||||
};
|
||||
|
||||
await trigger(event, rt);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(runAgentCalls).toHaveLength(1);
|
||||
});
|
||||
const prompt = runAgentCalls[0]?.prompt ?? "";
|
||||
expect(prompt).toContain("Feishu trigger context");
|
||||
expect(prompt).toContain("reply_to_message_id");
|
||||
expect(prompt).toContain("m-parent");
|
||||
expect(prompt).toContain("thread-1");
|
||||
expect(prompt).toContain("上一条需求");
|
||||
expectPromptFromSender(prompt, "ou_test_user", "继续这个改法");
|
||||
|
||||
const run = await prisma.agentRun.findFirst();
|
||||
expect(run?.prompt).toBe(prompt);
|
||||
expect(run?.metadata).toMatchObject({
|
||||
roleId: "draft",
|
||||
rawPrompt: "继续这个改法",
|
||||
feishuTriggerContext: {
|
||||
trigger_message_id: "m-child",
|
||||
chat_id: "chat-13c",
|
||||
sender: { open_id: "ou_test_user" },
|
||||
reply_to_message_id: "m-parent",
|
||||
root_id: "m-root",
|
||||
thread_id: "thread-1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("writes audit entries across the run lifecycle", async () => {
|
||||
await seedProject("proj-14", "chat-14");
|
||||
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-14", "@_user_1 写教案"), rt);
|
||||
await vi.waitFor(async () => {
|
||||
@@ -288,8 +806,188 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
expect(audit.some((a) => a.action === "run.created")).toBe(true);
|
||||
expect(audit.some((a) => a.action === "run.finished")).toBe(true);
|
||||
});
|
||||
|
||||
it("persists AgentMessage rows for the run (A-3 structured history)", async () => {
|
||||
await seedProject("proj-15", "chat-15");
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-15", "@_user_1 写教案"), rt);
|
||||
await vi.waitFor(async () => {
|
||||
const msgs = await prisma.agentMessage.findMany();
|
||||
expect(msgs.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
const msgs = await prisma.agentMessage.findMany({ orderBy: { createdAt: "asc" } });
|
||||
expect(msgs.length).toBeGreaterThanOrEqual(1);
|
||||
const run = await prisma.agentRun.findFirst();
|
||||
expect(msgs.every((m) => m.runId === run?.id)).toBe(true);
|
||||
expect(msgs.some((m) => m.role === "assistant")).toBe(true);
|
||||
});
|
||||
it("interrupts a running agent via the card interrupt button", async () => {
|
||||
await seedProject("proj-int", "chat-int", { role: "MANAGE" });
|
||||
runAgent = createHangingRunAgent();
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-int", "@_user_1 写教案"), rt);
|
||||
|
||||
// The run started: a card was sent (with an interrupt button) and the run is ACTIVE.
|
||||
await vi.waitFor(async () => {
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]?.status).toBe("ACTIVE");
|
||||
});
|
||||
expect(rt.sentCards.length).toBeGreaterThanOrEqual(1);
|
||||
const run = await prisma.agentRun.findFirst();
|
||||
expect(run).not.toBeNull();
|
||||
|
||||
await trigger.onCardAction(makeInterruptEvent("chat-int", run!.id, "ou_test_user"), rt);
|
||||
|
||||
// The run transitions to CANCELED (spec RunState.canceled is terminal).
|
||||
await vi.waitFor(async () => {
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs[0]?.status).toBe("CANCELED");
|
||||
});
|
||||
// The lock is released (terminal ⇒ release, ADR-0002).
|
||||
await vi.waitFor(async () => {
|
||||
const locks = await prisma.projectAgentLock.findMany();
|
||||
expect(locks).toHaveLength(0);
|
||||
});
|
||||
// Audit records the interrupt.
|
||||
const audit = await prisma.auditEntry.findMany();
|
||||
expect(audit.some((a) => a.action === "run.interrupted")).toBe(true);
|
||||
// The card was patched to a complete state with an "已中断" footer.
|
||||
expect(rt.sentPatches.some((card) => cardHasInterruptedFooter(card))).toBe(true);
|
||||
});
|
||||
|
||||
it("sends a fallback interrupt notice when the final card patch fails", async () => {
|
||||
await seedProject("proj-int-patch-fail", "chat-int-patch-fail", { role: "MANAGE" });
|
||||
runAgent = createHangingRunAgent();
|
||||
const patch = vi.fn(async () => {
|
||||
throw new Error("patch failed");
|
||||
});
|
||||
(rt.client as unknown as { im: { v1: { message: { patch: typeof patch } } } }).im.v1.message.patch = patch;
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-int-patch-fail", "@_user_1 写教案"), rt);
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs[0]?.status).toBe("ACTIVE");
|
||||
});
|
||||
const run = await prisma.agentRun.findFirst();
|
||||
expect(run).not.toBeNull();
|
||||
|
||||
await trigger.onCardAction(makeInterruptEvent("chat-int-patch-fail", run!.id, "ou_test_user"), rt);
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs[0]?.status).toBe("CANCELED");
|
||||
});
|
||||
expect(patch).toHaveBeenCalled();
|
||||
expect(rt.sentTexts).toContain("已中断当前运行。");
|
||||
});
|
||||
|
||||
it("denies interrupt when the operator lacks agent.cancel permission", async () => {
|
||||
// EDIT role can trigger but cannot cancel (agent.cancel requires MANAGE).
|
||||
await seedProject("proj-int-deny", "chat-int-deny", { role: "EDIT" });
|
||||
runAgent = createHangingRunAgent();
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-int-deny", "@_user_1 写教案"), rt);
|
||||
await vi.waitFor(async () => {
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs[0]?.status).toBe("ACTIVE");
|
||||
});
|
||||
const run = await prisma.agentRun.findFirst();
|
||||
expect(run).not.toBeNull();
|
||||
|
||||
await trigger.onCardAction(makeInterruptEvent("chat-int-deny", run!.id, "ou_test_user"), rt);
|
||||
|
||||
expect(rt.sentTexts).toContain("无权限中断该运行。");
|
||||
const audit = await prisma.auditEntry.findMany();
|
||||
expect(audit.some((a) => a.action === "run.interrupt_denied")).toBe(true);
|
||||
// The run is still active — it was not aborted.
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs[0]?.status).toBe("ACTIVE");
|
||||
// Clean up: release the hanging run so afterEach isolation isn't disturbed.
|
||||
const active = await prisma.agentRun.findFirst({ where: { status: "ACTIVE" } });
|
||||
if (active !== null) {
|
||||
await prisma.agentRun.update({ where: { id: active.id }, data: { status: "FAILED", finishedAt: new Date() } });
|
||||
await prisma.projectAgentLock.deleteMany({ where: { runId: active.id } });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
interface Deferred<T> {
|
||||
readonly promise: Promise<T>;
|
||||
readonly resolve: (value: T) => void;
|
||||
readonly reject: (reason?: unknown) => void;
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function completedRunResult(text: string, sdkSessionId: string): RunResult {
|
||||
return {
|
||||
status: "completed",
|
||||
text,
|
||||
usage: { inputTokens: 10, outputTokens: 5 },
|
||||
numTurns: 1,
|
||||
sdkSessionId,
|
||||
};
|
||||
}
|
||||
function createHangingRunAgent(): TestRunner {
|
||||
// Simulates a long-running agent: never resolves on its own, but resolves
|
||||
// with status "interrupted" when its abortController fires (mirrors the
|
||||
// real runner's abort path).
|
||||
return async (req) => {
|
||||
req.onStream?.({ type: "text-delta", text: "处理中..." });
|
||||
await req.prisma.agentMessage.create({
|
||||
data: { sessionId: req.sessionId, runId: req.runId, role: "assistant", content: "处理中...", attachments: [] },
|
||||
});
|
||||
const ac = req.abortController;
|
||||
return new Promise<RunResult>((resolve) => {
|
||||
if (ac === undefined) return;
|
||||
if (ac.signal.aborted) {
|
||||
resolve({ status: "interrupted", text: "处理中...", usage: { inputTokens: 1, outputTokens: 1 }, numTurns: 1, sdkSessionId: "sdk-int" });
|
||||
return;
|
||||
}
|
||||
ac.signal.addEventListener("abort", () => {
|
||||
resolve({ status: "interrupted", text: "处理中...", usage: { inputTokens: 1, outputTokens: 1 }, numTurns: 1, sdkSessionId: "sdk-int" });
|
||||
}, { once: true });
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function makeInterruptEvent(chatId: string, runId: string, openId: string): CardActionEvent {
|
||||
return {
|
||||
operator: { open_id: openId },
|
||||
action: { value: { interrupt_run: runId }, tag: "button" },
|
||||
context: { open_chat_id: chatId },
|
||||
};
|
||||
}
|
||||
|
||||
function cardHasInterruptedFooter(card: unknown): boolean {
|
||||
if (typeof card !== "object" || card === null) return false;
|
||||
if (!("elements" in card)) return false;
|
||||
const elements = card.elements;
|
||||
if (!Array.isArray(elements)) return false;
|
||||
return elements.some((el) => {
|
||||
if (typeof el !== "object" || el === null) return false;
|
||||
if (!("text" in el)) return false;
|
||||
const text = el.text;
|
||||
if (typeof text !== "object" || text === null || !("content" in text)) return false;
|
||||
const content = text.content;
|
||||
return typeof content === "string" && content.includes("已中断");
|
||||
});
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createDefaultModelRegistry } from "../../src/agent/defaultModels.js";
|
||||
|
||||
describe("default model registry", () => {
|
||||
it("uses OpenRouter model slugs for Sonnet defaults", () => {
|
||||
const models = createDefaultModelRegistry({});
|
||||
|
||||
expect(models.listModels()[0]).toMatchObject({
|
||||
id: "anthropic/claude-sonnet-5",
|
||||
label: "Claude Sonnet 5",
|
||||
toolCapable: true,
|
||||
});
|
||||
expect(models.resolve(undefined, "draft")).toBe("anthropic/claude-sonnet-5");
|
||||
expect(models.resolve(undefined, "review")).toBe("anthropic/claude-sonnet-5");
|
||||
});
|
||||
|
||||
it("honors ANTHROPIC_DEFAULT_SONNET_MODEL when configured", () => {
|
||||
const models = createDefaultModelRegistry({
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "z-ai/glm-4.7",
|
||||
});
|
||||
|
||||
expect(models.listModels()[0]?.id).toBe("z-ai/glm-4.7");
|
||||
expect(models.resolve(undefined, "draft")).toBe("z-ai/glm-4.7");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { PermissionAuthorizer } from "../../src/permission.js";
|
||||
import type { RuntimeSettings } from "../../src/settings/runtime.js";
|
||||
import { sendApprovalCard, resolveCard, type CardActionEvent, type FeishuRuntime } from "../../src/feishu/client.js";
|
||||
import { ApprovalManager, makeTriggerHandler } from "../../src/feishu/trigger.js";
|
||||
|
||||
describe("Feishu approval cards", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("sendApprovalCard builds correct card JSON", async () => {
|
||||
const messageCreate = vi.fn(async () => ({ data: { message_id: "message-1" } }));
|
||||
const rt = mockRuntime({ messageCreate });
|
||||
|
||||
await expect(sendApprovalCard(rt, "chat-1", "Approve?", "Please approve.", [
|
||||
{ label: "Approve", value: "approve", style: "primary" },
|
||||
{ label: "Reject", value: "reject", style: "danger" },
|
||||
{ label: "Later", value: "later" },
|
||||
])).resolves.toBe("message-1");
|
||||
|
||||
expect(messageCreate).toHaveBeenCalledTimes(1);
|
||||
const payload = messageCreate.mock.calls[0]?.[0] as { data: { content: string } };
|
||||
expect(payload).toMatchObject({
|
||||
params: { receive_id_type: "chat_id" },
|
||||
data: { receive_id: "chat-1", msg_type: "interactive" },
|
||||
});
|
||||
expect(JSON.parse(payload.data.content)).toEqual({
|
||||
config: { wide_screen_mode: true },
|
||||
header: { title: { content: "Approve?", tag: "plain_text" }, template: "orange" },
|
||||
elements: [
|
||||
{ tag: "markdown", content: "Please approve." },
|
||||
{
|
||||
tag: "action",
|
||||
actions: [
|
||||
{
|
||||
tag: "button",
|
||||
text: { tag: "plain_text", content: "Approve" },
|
||||
type: "primary",
|
||||
value: { approval_action: "approve" },
|
||||
},
|
||||
{
|
||||
tag: "button",
|
||||
text: { tag: "plain_text", content: "Reject" },
|
||||
type: "danger",
|
||||
value: { approval_action: "reject" },
|
||||
},
|
||||
{
|
||||
tag: "button",
|
||||
text: { tag: "plain_text", content: "Later" },
|
||||
type: "default",
|
||||
value: { approval_action: "later" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("resolveCard patches with resolved state", async () => {
|
||||
const messagePatch = vi.fn(async () => ({}));
|
||||
const rt = mockRuntime({ messagePatch });
|
||||
|
||||
await resolveCard(rt, "message-1", "OK", "Approved", "green", "ou_user");
|
||||
|
||||
expect(messagePatch).toHaveBeenCalledTimes(1);
|
||||
expect(messagePatch).toHaveBeenCalledWith({
|
||||
path: { message_id: "message-1" },
|
||||
params: { msg_type: "interactive" },
|
||||
data: {
|
||||
content: JSON.stringify({
|
||||
config: { wide_screen_mode: true },
|
||||
header: { title: { content: "OK Approved", tag: "plain_text" }, template: "green" },
|
||||
elements: [{ tag: "markdown", content: "OK **Approved** by ou_user" }],
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("ApprovalManager register+resolve lifecycle", async () => {
|
||||
const manager = new ApprovalManager({ timeoutMs: 10_000 });
|
||||
const result = manager.register("message-1", "chat-1");
|
||||
|
||||
expect(manager.hasPending("message-1")).toBe(true);
|
||||
manager.resolve("message-1", "approve", "ou_user");
|
||||
|
||||
await expect(result).resolves.toEqual({ action: "approve", resolvedBy: "ou_user" });
|
||||
expect(manager.hasPending("message-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("ApprovalManager timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
const manager = new ApprovalManager({ timeoutMs: 100 });
|
||||
const result = manager.register("message-1", "chat-1");
|
||||
const assertion = expect(result).rejects.toThrow("approval timed out after 100ms");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
await assertion;
|
||||
expect(manager.hasPending("message-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("routes card actions through the trigger handler", async () => {
|
||||
const messagePatch = vi.fn(async () => ({}));
|
||||
const rt = mockRuntime({ messagePatch });
|
||||
const trigger = makeTriggerHandler({
|
||||
prisma: {} as PrismaClient,
|
||||
settings: {} as RuntimeSettings,
|
||||
logger: silentLogger(),
|
||||
authorizer: allowAllAuthorizer(),
|
||||
});
|
||||
const result = trigger.approvalManager.register("message-1", "chat-1");
|
||||
|
||||
await trigger.onCardAction(makeCardActionEvent("message-1", "approve", "ou_user"), rt);
|
||||
|
||||
await expect(result).resolves.toEqual({ action: "approve", resolvedBy: "ou_user" });
|
||||
expect(messagePatch).toHaveBeenCalledTimes(1);
|
||||
const payload = messagePatch.mock.calls[0]?.[0] as { data: { content: string } };
|
||||
expect(payload).toMatchObject({
|
||||
path: { message_id: "message-1" },
|
||||
params: { msg_type: "interactive" },
|
||||
});
|
||||
expect(JSON.parse(payload.data.content)).toEqual({
|
||||
config: { wide_screen_mode: true },
|
||||
header: { title: { content: "✅ Approved", tag: "plain_text" }, template: "green" },
|
||||
elements: [{ tag: "markdown", content: "✅ **Approved** by ou_user" }],
|
||||
});
|
||||
expect(trigger.approvalManager.hasPending("message-1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function makeCardActionEvent(messageId: string, action: string, openId: string): CardActionEvent {
|
||||
return {
|
||||
operator: { open_id: openId },
|
||||
action: { value: { approval_action: action }, tag: "button" },
|
||||
context: { open_message_id: messageId, open_chat_id: "chat-1" },
|
||||
};
|
||||
}
|
||||
|
||||
function mockRuntime(options: {
|
||||
readonly messageCreate?: (payload: unknown) => Promise<unknown>;
|
||||
readonly messagePatch?: (payload: unknown) => Promise<unknown>;
|
||||
} = {}): FeishuRuntime {
|
||||
return {
|
||||
client: {
|
||||
im: {
|
||||
v1: {
|
||||
message: {
|
||||
create: options.messageCreate ?? vi.fn(async () => ({ data: { message_id: "message-1" } })),
|
||||
patch: options.messagePatch ?? vi.fn(async () => ({})),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as FeishuRuntime["client"],
|
||||
logger: silentLogger(),
|
||||
};
|
||||
}
|
||||
|
||||
function allowAllAuthorizer(): PermissionAuthorizer {
|
||||
return {
|
||||
async can(req) {
|
||||
return {
|
||||
allowed: true,
|
||||
reason: "test allow",
|
||||
action: req.action,
|
||||
resource: req.resource,
|
||||
actor: req.actor,
|
||||
organizationId: "org_test_default",
|
||||
actorUserId: "user-1",
|
||||
principals: [{ type: "USER", id: "ou_user" }],
|
||||
requiredRole: "EDIT",
|
||||
effectiveRole: "EDIT",
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function silentLogger(): FeishuRuntime["logger"] {
|
||||
return {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
fatal() {},
|
||||
child() { return this; },
|
||||
level: "silent",
|
||||
} as unknown as FeishuRuntime["logger"];
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { CardActionEvent, FeishuRuntime } from "../../src/feishu/client.js";
|
||||
import { startFeishuListenerWithClient } from "../../src/feishu/client.js";
|
||||
|
||||
const larkMock = vi.hoisted(() => {
|
||||
const starts: Array<{ readonly eventDispatcher?: unknown }> = [];
|
||||
class MockEventDispatcher {
|
||||
handlers: Record<string, (data: unknown) => Promise<void>> = {};
|
||||
|
||||
register(handlers: Record<string, (data: unknown) => Promise<void>>): this {
|
||||
this.handlers = handlers;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
class MockWSClient {
|
||||
start(params: { readonly eventDispatcher?: unknown }): void {
|
||||
starts.push(params);
|
||||
}
|
||||
}
|
||||
return { starts, MockEventDispatcher, MockWSClient };
|
||||
});
|
||||
|
||||
vi.mock("@larksuiteoapi/node-sdk", () => ({
|
||||
AppType: { SelfBuild: "SelfBuild" },
|
||||
Domain: { Feishu: "Feishu" },
|
||||
LoggerLevel: { info: "info" },
|
||||
Client: vi.fn(),
|
||||
EventDispatcher: larkMock.MockEventDispatcher,
|
||||
WSClient: larkMock.MockWSClient,
|
||||
}));
|
||||
|
||||
describe("Feishu card action callbacks", () => {
|
||||
beforeEach(() => {
|
||||
larkMock.starts.length = 0;
|
||||
});
|
||||
|
||||
it("acks card action callbacks before the business handler settles", async () => {
|
||||
const action = deferred<void>();
|
||||
const onCardAction = vi.fn(async () => action.promise);
|
||||
const logger = silentLogger();
|
||||
|
||||
startFeishuListenerWithClient(
|
||||
{ appId: "app-id", appSecret: "app-secret", botOpenId: "bot-open-id" },
|
||||
fakeClient(),
|
||||
logger,
|
||||
async () => {},
|
||||
onCardAction,
|
||||
);
|
||||
|
||||
const handler = cardActionHandler();
|
||||
const handlerDone = handler(makeCardActionEvent()).then(() => "done" as const);
|
||||
const earlyResult = await Promise.race([handlerDone, delay(25).then(() => "timeout" as const)]);
|
||||
|
||||
action.resolve();
|
||||
await handlerDone;
|
||||
|
||||
expect(earlyResult).toBe("done");
|
||||
expect(onCardAction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("logs background card action failures after acking", async () => {
|
||||
const logger = silentLogger();
|
||||
const err = new Error("handler failed");
|
||||
|
||||
startFeishuListenerWithClient(
|
||||
{ appId: "app-id", appSecret: "app-secret", botOpenId: "bot-open-id" },
|
||||
fakeClient(),
|
||||
logger,
|
||||
async () => {},
|
||||
async () => {
|
||||
throw err;
|
||||
},
|
||||
);
|
||||
|
||||
await cardActionHandler()(makeCardActionEvent());
|
||||
await vi.waitFor(() => {
|
||||
expect(logger.error).toHaveBeenCalledWith({ err }, "feishu card action handler threw");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function cardActionHandler(): (data: unknown) => Promise<void> {
|
||||
const start = larkMock.starts[0];
|
||||
if (start === undefined) throw new Error("WSClient.start was not called");
|
||||
const dispatcher = start.eventDispatcher as { readonly handlers?: Record<string, (data: unknown) => Promise<void>> };
|
||||
const handler = dispatcher.handlers?.["card.action.trigger"];
|
||||
if (handler === undefined) throw new Error("card.action.trigger handler was not registered");
|
||||
return handler;
|
||||
}
|
||||
|
||||
function makeCardActionEvent(): CardActionEvent {
|
||||
return {
|
||||
operator: { open_id: "ou_user" },
|
||||
action: { value: { interrupt_run: "run-1" }, tag: "button" },
|
||||
context: { open_chat_id: "chat-1", open_message_id: "message-1" },
|
||||
};
|
||||
}
|
||||
|
||||
function fakeClient(): FeishuRuntime["client"] {
|
||||
return {} as FeishuRuntime["client"];
|
||||
}
|
||||
|
||||
function silentLogger(): FastifyBaseLogger {
|
||||
return {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
fatal: vi.fn(),
|
||||
child() { return this; },
|
||||
level: "silent",
|
||||
} as unknown as FastifyBaseLogger;
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
interface Deferred<T> {
|
||||
readonly promise: Promise<T>;
|
||||
readonly resolve: (value: T) => void;
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { resolveSenderName, sendFile, sendLongText, sendTextMessage, type FeishuRuntime } from "../../src/feishu/client.js";
|
||||
import { SenderNameCache } from "../../src/feishu/senderCache.js";
|
||||
|
||||
describe("Feishu client helpers", () => {
|
||||
it("accepts top-level file_key from the Lark SDK file upload response", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "hub-feishu-client-"));
|
||||
try {
|
||||
const file = join(dir, "student.pdf");
|
||||
await writeFile(file, "pdf bytes");
|
||||
const fileCreate = vi.fn(async () => ({ file_key: "file-key-1" }));
|
||||
const messageCreate = vi.fn(async () => ({ data: { message_id: "message-1" } }));
|
||||
const rt = mockRuntime({ fileCreate, messageCreate });
|
||||
|
||||
await expect(sendFile(rt, "chat-1", file, "student.pdf")).resolves.toBe("message-1");
|
||||
expect(messageCreate).toHaveBeenCalledWith({
|
||||
params: { receive_id_type: "chat_id" },
|
||||
data: { receive_id: "chat-1", msg_type: "file", content: JSON.stringify({ file_key: "file-key-1" }) },
|
||||
});
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts top-level message_id from message create responses", async () => {
|
||||
const rt = mockRuntime({
|
||||
fileCreate: vi.fn(),
|
||||
messageCreate: vi.fn(async () => ({ message_id: "message-1" })),
|
||||
});
|
||||
|
||||
await expect(sendTextMessage(rt, "chat-1", "hello")).resolves.toBe("message-1");
|
||||
});
|
||||
|
||||
it("replies to a message without enabling Feishu topic mode", async () => {
|
||||
const messageCreate = vi.fn();
|
||||
const messageReply = vi.fn(async () => ({ data: { message_id: "reply-1" } }));
|
||||
const rt = mockRuntime({ fileCreate: vi.fn(), messageCreate, messageReply });
|
||||
|
||||
await expect(sendTextMessage(rt, "chat-1", "hello", { replyToMessageId: "m-trigger" })).resolves.toBe("reply-1");
|
||||
|
||||
expect(messageCreate).not.toHaveBeenCalled();
|
||||
expect(messageReply).toHaveBeenCalledWith({
|
||||
path: { message_id: "m-trigger" },
|
||||
data: {
|
||||
msg_type: "text",
|
||||
content: JSON.stringify({ text: "hello" }),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("sends long text in chunks and returns the last successful message id", async () => {
|
||||
let messageNumber = 0;
|
||||
const messageCreate = vi.fn(async () => ({ data: { message_id: `message-${++messageNumber}` } }));
|
||||
const rt = mockRuntime({ fileCreate: vi.fn(), messageCreate });
|
||||
|
||||
await expect(sendLongText(rt, "chat-1", "x".repeat(16_005))).resolves.toBe("message-3");
|
||||
|
||||
expect(messageCreate).toHaveBeenCalledTimes(3);
|
||||
const sentTexts = messageCreate.mock.calls.map(([payload]) => {
|
||||
const content = (payload as { data: { content: string } }).data.content;
|
||||
return (JSON.parse(content) as { text: string }).text;
|
||||
});
|
||||
expect(sentTexts.map((text) => text.length)).toEqual([8000, 8000, 5]);
|
||||
});
|
||||
|
||||
it("resolves sender names through the basic contact API and caches hits", async () => {
|
||||
const contactBasicBatch = vi.fn(async () => ({ data: { users: [{ name: "Jiarong" }] } }));
|
||||
const request = vi.fn();
|
||||
const rt = mockRuntime({ fileCreate: vi.fn(), messageCreate: vi.fn(), contactBasicBatch, request });
|
||||
const cache = new SenderNameCache();
|
||||
|
||||
await expect(resolveSenderName(rt, "ou_user_1", cache)).resolves.toBe("Jiarong");
|
||||
await expect(resolveSenderName(rt, "ou_user_1", cache)).resolves.toBe("Jiarong");
|
||||
|
||||
expect(contactBasicBatch).toHaveBeenCalledTimes(1);
|
||||
expect(contactBasicBatch).toHaveBeenCalledWith({
|
||||
params: { user_id_type: "open_id" },
|
||||
data: { user_ids: ["ou_user_1"] },
|
||||
});
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function mockRuntime(options: {
|
||||
readonly fileCreate: (payload: unknown) => Promise<unknown>;
|
||||
readonly messageCreate: (payload: unknown) => Promise<unknown>;
|
||||
readonly messageReply?: (payload: unknown) => Promise<unknown>;
|
||||
readonly contactBasicBatch?: (payload: unknown) => Promise<unknown>;
|
||||
readonly request?: (payload: unknown) => Promise<unknown>;
|
||||
}): FeishuRuntime {
|
||||
return {
|
||||
client: {
|
||||
request: options.request,
|
||||
contact: {
|
||||
v3: {
|
||||
user: {
|
||||
basicBatch: options.contactBasicBatch,
|
||||
},
|
||||
},
|
||||
},
|
||||
im: {
|
||||
v1: {
|
||||
file: { create: options.fileCreate },
|
||||
message: {
|
||||
create: options.messageCreate,
|
||||
reply: options.messageReply ?? vi.fn(async () => ({ data: { message_id: "reply-1" } })),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as FeishuRuntime["client"],
|
||||
logger: {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
fatal() {},
|
||||
child() { return this; },
|
||||
level: "silent",
|
||||
} as unknown as FeishuRuntime["logger"],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
_buildMarkdownPostRows,
|
||||
buildOutboundPayload,
|
||||
patchTextMessage,
|
||||
sendTextMessage,
|
||||
type FeishuRuntime,
|
||||
} from "../../src/feishu/client.js";
|
||||
|
||||
describe("Feishu markdown outbound payloads", () => {
|
||||
it("uses text messages for plain text", () => {
|
||||
const payload = buildOutboundPayload("hello from cph");
|
||||
|
||||
expect(payload.msgType).toBe("text");
|
||||
expect(JSON.parse(payload.content)).toEqual({ text: "hello from cph" });
|
||||
});
|
||||
|
||||
it("keeps fenced code blocks in separate post rows", () => {
|
||||
const content = "Before\n```ts\nconst x = 1;\n```\nAfter";
|
||||
|
||||
expect(_buildMarkdownPostRows(content)).toEqual([
|
||||
[{ tag: "md", text: "Before\n" }],
|
||||
[{ tag: "md", text: "```ts\nconst x = 1;\n```" }],
|
||||
[{ tag: "md", text: "\nAfter" }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("forces markdown tables to plain text", () => {
|
||||
const content = "| A | B |\n|---|---|\n| 1 | 2 |";
|
||||
const payload = buildOutboundPayload(content);
|
||||
|
||||
expect(payload.msgType).toBe("text");
|
||||
expect(JSON.parse(payload.content)).toEqual({ text: content });
|
||||
});
|
||||
|
||||
it("uses post messages for markdown headers, bold text, and lists", () => {
|
||||
const content = "# Title\n\nThis is **important**.\n\n- first\n- second";
|
||||
const payload = buildOutboundPayload(content);
|
||||
|
||||
expect(payload.msgType).toBe("post");
|
||||
expect(JSON.parse(payload.content)).toEqual({
|
||||
zh_cn: {
|
||||
content: [[{ tag: "md", text: content }]],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["header", "# Heading"],
|
||||
["list", "- item"],
|
||||
["code block", "```ts\nconst x = 1;\n```"],
|
||||
["inline code", "Use `code` here"],
|
||||
["bold", "**bold**"],
|
||||
["italic", "_italic_"],
|
||||
["strikethrough", "~~removed~~"],
|
||||
["link", "[docs](https://example.com)"],
|
||||
["blockquote", "> quoted"],
|
||||
])("detects markdown %s syntax", (_name, content) => {
|
||||
expect(buildOutboundPayload(content).msgType).toBe("post");
|
||||
});
|
||||
|
||||
it("falls back from rejected post payloads to stripped plain text", async () => {
|
||||
const messageCreate = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("content format of the post type is incorrect"))
|
||||
.mockResolvedValueOnce({ data: { message_id: "message-1" } });
|
||||
const rt = mockRuntime({ messageCreate });
|
||||
|
||||
await expect(sendTextMessage(rt, "chat-1", "# Title\n\n**bold** and `code`")).resolves.toBe("message-1");
|
||||
|
||||
expect(messageCreate).toHaveBeenCalledTimes(2);
|
||||
expect(messageCreate).toHaveBeenNthCalledWith(1, {
|
||||
params: { receive_id_type: "chat_id" },
|
||||
data: {
|
||||
receive_id: "chat-1",
|
||||
msg_type: "post",
|
||||
content: JSON.stringify({
|
||||
zh_cn: {
|
||||
content: [[{ tag: "md", text: "# Title\n\n**bold** and `code`" }]],
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
expect(messageCreate).toHaveBeenNthCalledWith(2, {
|
||||
params: { receive_id_type: "chat_id" },
|
||||
data: {
|
||||
receive_id: "chat-1",
|
||||
msg_type: "text",
|
||||
content: JSON.stringify({ text: "Title\n\nbold and code" }),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("patches markdown post rows as separate interactive card elements", async () => {
|
||||
const patch = vi.fn(async () => ({}));
|
||||
const rt = mockRuntime({ patch });
|
||||
|
||||
await patchTextMessage(rt, "message-1", "Before\n```ts\nconst x = 1;\n```\nAfter");
|
||||
|
||||
expect(patch).toHaveBeenCalledWith({
|
||||
path: { message_id: "message-1" },
|
||||
params: { msg_type: "interactive" },
|
||||
data: {
|
||||
content: JSON.stringify({
|
||||
elements: [
|
||||
{ tag: "div", text: { tag: "lark_md", content: "Before\n" } },
|
||||
{ tag: "div", text: { tag: "lark_md", content: "```ts\nconst x = 1;\n```" } },
|
||||
{ tag: "div", text: { tag: "lark_md", content: "\nAfter" } },
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function mockRuntime(options: {
|
||||
readonly messageCreate?: (payload: unknown) => Promise<unknown>;
|
||||
readonly patch?: (payload: unknown) => Promise<unknown>;
|
||||
}): FeishuRuntime {
|
||||
return {
|
||||
client: {
|
||||
im: {
|
||||
v1: {
|
||||
message: {
|
||||
create: options.messageCreate ?? vi.fn(async () => ({ data: { message_id: "message-1" } })),
|
||||
patch: options.patch ?? vi.fn(async () => ({})),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as FeishuRuntime["client"],
|
||||
logger: {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
fatal() {},
|
||||
child() { return this; },
|
||||
level: "silent",
|
||||
} as unknown as FeishuRuntime["logger"],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parsePostMessage } from "../../src/feishu/client.js";
|
||||
|
||||
describe("parsePostMessage", () => {
|
||||
it("parses a simple text post", () => {
|
||||
const result = parsePostMessage(postContent({
|
||||
zh_cn: { content: [[{ tag: "text", text: "Hello" }]] },
|
||||
}));
|
||||
|
||||
expect(result).toEqual({ text: "Hello", imageKeys: [], fileKeys: [] });
|
||||
});
|
||||
|
||||
it("prepends a post title as a markdown header", () => {
|
||||
const result = parsePostMessage(postContent({
|
||||
zh_cn: { title: "Lesson Notes", content: [[{ tag: "text", text: "Hello" }]] },
|
||||
}));
|
||||
|
||||
expect(result.text).toBe("# Lesson Notes\n\nHello");
|
||||
});
|
||||
|
||||
it("converts links to markdown", () => {
|
||||
const result = parsePostMessage(postContent({
|
||||
zh_cn: {
|
||||
content: [[
|
||||
{ tag: "text", text: "Read " },
|
||||
{ tag: "a", text: "docs", href: "https://example.com" },
|
||||
]],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(result.text).toBe("Read [docs](https://example.com)");
|
||||
});
|
||||
|
||||
it("converts mentions to readable @names", () => {
|
||||
const result = parsePostMessage(postContent({
|
||||
zh_cn: { content: [[{ tag: "at", user_id: "@_user_1", user_name: "John" }]] },
|
||||
}));
|
||||
|
||||
expect(result.text).toBe("@John");
|
||||
});
|
||||
|
||||
it("converts code blocks to fenced markdown", () => {
|
||||
const result = parsePostMessage(postContent({
|
||||
zh_cn: { content: [[{ tag: "code_block", language: "ts", text: "const x = 1;" }]] },
|
||||
}));
|
||||
|
||||
expect(result.text).toBe("```ts\nconst x = 1;\n```");
|
||||
});
|
||||
|
||||
it("collects image keys and inserts an image placeholder", () => {
|
||||
const result = parsePostMessage(postContent({
|
||||
zh_cn: { content: [[{ tag: "img", image_key: "img_key_here" }]] },
|
||||
}));
|
||||
|
||||
expect(result).toEqual({ text: "[Image]", imageKeys: ["img_key_here"], fileKeys: [] });
|
||||
});
|
||||
|
||||
it("collects file keys and inserts an attachment placeholder", () => {
|
||||
const result = parsePostMessage(postContent({
|
||||
zh_cn: { content: [[{ tag: "file", file_key: "file_key_here", file_name: "worksheet.pdf" }]] },
|
||||
}));
|
||||
|
||||
expect(result).toEqual({
|
||||
text: "[Attachment: worksheet.pdf]",
|
||||
imageKeys: [],
|
||||
fileKeys: ["file_key_here"],
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to en_us when zh_cn is missing", () => {
|
||||
const result = parsePostMessage(postContent({
|
||||
en_us: { content: [[{ tag: "text", text: "Hello from English" }]] },
|
||||
}));
|
||||
|
||||
expect(result.text).toBe("Hello from English");
|
||||
});
|
||||
|
||||
it("unwraps content nested under a post key", () => {
|
||||
const result = parsePostMessage(postContent({
|
||||
post: {
|
||||
zh_cn: { content: [[{ tag: "text", text: "Wrapped" }]] },
|
||||
},
|
||||
}));
|
||||
|
||||
expect(result.text).toBe("Wrapped");
|
||||
});
|
||||
|
||||
it("joins multiple rows with newlines", () => {
|
||||
const result = parsePostMessage(postContent({
|
||||
zh_cn: {
|
||||
content: [
|
||||
[{ tag: "text", text: "First row" }],
|
||||
[{ tag: "text", text: "Second row" }],
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(result.text).toBe("First row\nSecond row");
|
||||
});
|
||||
});
|
||||
|
||||
function postContent(value: unknown): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { InMemoryModelRegistry } from "../../src/agent/models.js";
|
||||
import type { RunRequest, RunResult } from "../../src/agent/runner.js";
|
||||
import type { FeishuRuntime, MessageReceiveEvent } from "../../src/feishu/client.js";
|
||||
import { addReaction, removeReaction } from "../../src/feishu/client.js";
|
||||
import { makeTriggerHandler } from "../../src/feishu/trigger.js";
|
||||
import type { PermissionAuthorizer } from "../../src/permission.js";
|
||||
import type { RuntimeSettings } from "../../src/settings/runtime.js";
|
||||
|
||||
describe("Feishu reactions", () => {
|
||||
it("addReaction returns reaction_id on success", async () => {
|
||||
const request = vi.fn(async () => ({ data: { reaction_id: "reaction-1" } }));
|
||||
const rt = mockRuntime({ request });
|
||||
|
||||
await expect(addReaction(rt, "message-1", "Typing")).resolves.toBe("reaction-1");
|
||||
expect(request).toHaveBeenCalledWith({
|
||||
method: "POST",
|
||||
url: "/open-apis/im/v1/messages/message-1/reactions",
|
||||
data: { reaction_type: { emoji_type: "Typing" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("addReaction returns null on API failure", async () => {
|
||||
const rt = mockRuntime({
|
||||
request: vi.fn(async () => {
|
||||
throw new Error("api failed");
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(addReaction(rt, "message-1", "Typing")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("removeReaction returns true on success", async () => {
|
||||
const request = vi.fn(async () => ({}));
|
||||
const rt = mockRuntime({ request });
|
||||
|
||||
await expect(removeReaction(rt, "message-1", "reaction-1")).resolves.toBe(true);
|
||||
expect(request).toHaveBeenCalledWith({
|
||||
method: "DELETE",
|
||||
url: "/open-apis/im/v1/messages/message-1/reactions/reaction-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("removeReaction returns false on API failure", async () => {
|
||||
const rt = mockRuntime({
|
||||
request: vi.fn(async () => {
|
||||
throw new Error("api failed");
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(removeReaction(rt, "message-1", "reaction-1")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("adds Typing on start and removes it on success", async () => {
|
||||
const run = deferred<RunResult>();
|
||||
const rt = mockRuntime();
|
||||
const runAgent = vi.fn((req: RunRequest) => {
|
||||
req.onStream?.({ type: "text-delta", text: "done" });
|
||||
return run.promise;
|
||||
});
|
||||
|
||||
await triggerWithRunAgent(runAgent, rt);
|
||||
|
||||
expect(rt.reactionRequests).toEqual([
|
||||
{ kind: "add", messageId: "message-1", emoji: "Typing", reactionId: "reaction-1" },
|
||||
]);
|
||||
|
||||
run.resolve(completedRunResult("done"));
|
||||
await vi.waitFor(() => {
|
||||
expect(rt.reactionRequests).toEqual([
|
||||
{ kind: "add", messageId: "message-1", emoji: "Typing", reactionId: "reaction-1" },
|
||||
{ kind: "remove", messageId: "message-1", reactionId: "reaction-1" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces Typing with CrossMark on failure", async () => {
|
||||
const run = deferred<RunResult>();
|
||||
const rt = mockRuntime();
|
||||
const runAgent = vi.fn(() => run.promise);
|
||||
|
||||
await triggerWithRunAgent(runAgent, rt);
|
||||
|
||||
run.reject(new Error("agent failed"));
|
||||
await vi.waitFor(() => {
|
||||
expect(rt.reactionRequests).toEqual([
|
||||
{ kind: "add", messageId: "message-1", emoji: "Typing", reactionId: "reaction-1" },
|
||||
{ kind: "remove", messageId: "message-1", reactionId: "reaction-1" },
|
||||
{ kind: "add", messageId: "message-1", emoji: "CrossMark", reactionId: "reaction-2" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function triggerWithRunAgent(
|
||||
runAgent: (req: RunRequest) => Promise<RunResult>,
|
||||
rt: MockRuntime,
|
||||
): Promise<void> {
|
||||
const trigger = makeTriggerHandler({
|
||||
prisma: mockPrisma(),
|
||||
settings: mockSettings(),
|
||||
logger: rt.logger,
|
||||
authorizer: allowAllAuthorizer(),
|
||||
runAgent,
|
||||
messageBatcherOptions: { maxMessages: 1 },
|
||||
});
|
||||
|
||||
await trigger(makeEvent(), rt);
|
||||
}
|
||||
|
||||
function completedRunResult(text: string): RunResult {
|
||||
return {
|
||||
status: "completed",
|
||||
text,
|
||||
usage: { inputTokens: 1, outputTokens: 1 },
|
||||
numTurns: 1,
|
||||
};
|
||||
}
|
||||
|
||||
interface Deferred<T> {
|
||||
readonly promise: Promise<T>;
|
||||
readonly resolve: (value: T) => void;
|
||||
readonly reject: (reason?: unknown) => void;
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
const bot = { key: "@_user_1", id: { open_id: "ou_bot" }, name: "Bot" };
|
||||
|
||||
function makeEvent(): MessageReceiveEvent {
|
||||
return {
|
||||
message: {
|
||||
message_id: "message-1",
|
||||
chat_id: "chat-1",
|
||||
chat_type: "group",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "@_user_1 do work" }),
|
||||
mentions: [bot],
|
||||
},
|
||||
sender: { sender_id: { open_id: "ou_user" }, sender_type: "user" },
|
||||
};
|
||||
}
|
||||
|
||||
type ReactionRequest =
|
||||
| { readonly kind: "add"; readonly messageId: string; readonly emoji: string; readonly reactionId: string }
|
||||
| { readonly kind: "remove"; readonly messageId: string; readonly reactionId: string };
|
||||
|
||||
interface MockRuntime extends FeishuRuntime {
|
||||
readonly reactionRequests: ReactionRequest[];
|
||||
}
|
||||
|
||||
function mockRuntime(options: { readonly request?: (payload: unknown) => Promise<unknown> } = {}): MockRuntime {
|
||||
const reactionRequests: ReactionRequest[] = [];
|
||||
let nextReactionId = 1;
|
||||
const request =
|
||||
options.request ??
|
||||
vi.fn(async (payload: unknown) => {
|
||||
const requestPayload = payload as { method?: string; url?: string; data?: { reaction_type?: { emoji_type?: string } } };
|
||||
const addMatch = requestPayload.url?.match(/\/messages\/([^/]+)\/reactions$/);
|
||||
if (requestPayload.method === "POST" && addMatch?.[1] !== undefined) {
|
||||
const reactionId = `reaction-${nextReactionId}`;
|
||||
nextReactionId += 1;
|
||||
reactionRequests.push({
|
||||
kind: "add",
|
||||
messageId: addMatch[1],
|
||||
emoji: requestPayload.data?.reaction_type?.emoji_type ?? "",
|
||||
reactionId,
|
||||
});
|
||||
return { data: { reaction_id: reactionId } };
|
||||
}
|
||||
|
||||
const removeMatch = requestPayload.url?.match(/\/messages\/([^/]+)\/reactions\/([^/]+)$/);
|
||||
if (requestPayload.method === "DELETE" && removeMatch?.[1] !== undefined && removeMatch[2] !== undefined) {
|
||||
reactionRequests.push({ kind: "remove", messageId: removeMatch[1], reactionId: removeMatch[2] });
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
return {
|
||||
client: {
|
||||
request,
|
||||
im: {
|
||||
v1: {
|
||||
message: {
|
||||
create: vi.fn(async () => ({ data: { message_id: "reply-message-1" } })),
|
||||
patch: vi.fn(async () => ({})),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as FeishuRuntime["client"],
|
||||
logger: silentLogger(),
|
||||
reactionRequests,
|
||||
};
|
||||
}
|
||||
|
||||
function mockSettings(): RuntimeSettings {
|
||||
const registry = new InMemoryModelRegistry(
|
||||
[{ id: "mock-model", label: "Mock", toolCapable: true }],
|
||||
[{ id: "draft", label: "Draft", defaultModel: "mock-model" }],
|
||||
);
|
||||
return {
|
||||
async provider(providerId) {
|
||||
return {
|
||||
id: providerId,
|
||||
baseUrl: "https://example.invalid",
|
||||
authToken: "test-token",
|
||||
anthropicApiKey: "",
|
||||
sdkEnv: {},
|
||||
};
|
||||
},
|
||||
async modelRegistry() {
|
||||
return registry;
|
||||
},
|
||||
async runPolicy() {
|
||||
return { maxTurns: 1 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function allowAllAuthorizer(): PermissionAuthorizer {
|
||||
return {
|
||||
async can(req) {
|
||||
return {
|
||||
allowed: true,
|
||||
reason: "test allow",
|
||||
action: req.action,
|
||||
resource: req.resource,
|
||||
actor: req.actor,
|
||||
organizationId: "org_test_default",
|
||||
actorUserId: "user-1",
|
||||
principals: [{ type: "USER", id: "ou_user" }],
|
||||
requiredRole: "EDIT",
|
||||
effectiveRole: "EDIT",
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mockPrisma(): PrismaClient {
|
||||
let lock: { projectId: string; runId: string } | null = null;
|
||||
const session = { id: "session-1", metadata: {} };
|
||||
|
||||
return {
|
||||
feishuEventReceipt: {
|
||||
findUnique: vi.fn(async () => null),
|
||||
create: vi.fn(async () => ({ id: "receipt-1" })),
|
||||
},
|
||||
projectGroupBinding: {
|
||||
findUnique: vi.fn(async () => ({ projectId: "project-1" })),
|
||||
},
|
||||
project: {
|
||||
findUnique: vi.fn(async () => ({ workspaceDir: "/tmp/cph-project" })),
|
||||
},
|
||||
agentSession: {
|
||||
findFirst: vi.fn(async () => null),
|
||||
create: vi.fn(async () => session),
|
||||
update: vi.fn(async () => session),
|
||||
},
|
||||
agentRun: {
|
||||
create: vi.fn(async () => ({ id: "run-1" })),
|
||||
findUnique: vi.fn(async () => null),
|
||||
update: vi.fn(async () => ({ id: "run-1" })),
|
||||
},
|
||||
projectAgentLock: {
|
||||
findUnique: vi.fn(async () => (lock === null ? null : { runId: lock.runId })),
|
||||
create: vi.fn(async (args: { data: { projectId: string; runId: string } }) => {
|
||||
lock = { projectId: args.data.projectId, runId: args.data.runId };
|
||||
return lock;
|
||||
}),
|
||||
deleteMany: vi.fn(async () => {
|
||||
lock = null;
|
||||
return { count: 1 };
|
||||
}),
|
||||
},
|
||||
auditEntry: {
|
||||
create: vi.fn(async () => ({ id: "audit-1" })),
|
||||
},
|
||||
} as unknown as PrismaClient;
|
||||
}
|
||||
|
||||
function silentLogger(): FeishuRuntime["logger"] {
|
||||
return {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
fatal() {},
|
||||
child() { return this; },
|
||||
level: "silent",
|
||||
} as unknown as FeishuRuntime["logger"];
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFeishuContext } from "../../src/feishu/read.js";
|
||||
import type { FeishuRuntime } from "../../src/feishu/client.js";
|
||||
|
||||
describe("readFeishuContext", () => {
|
||||
it("compacts Feishu message.get replies with parent_id, thread_id, and body.content", async () => {
|
||||
const rt = {
|
||||
client: {
|
||||
im: {
|
||||
v1: {
|
||||
message: {
|
||||
get: async () => ({
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
message_id: "m-child",
|
||||
root_id: "m-root",
|
||||
parent_id: "m-parent",
|
||||
thread_id: "thread-1",
|
||||
msg_type: "text",
|
||||
body: { content: JSON.stringify({ text: "parent text" }) },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
list: async () => ({ data: { items: [] } }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
logger: { warn() {}, info() {}, error() {}, debug() {} },
|
||||
} as unknown as FeishuRuntime;
|
||||
|
||||
const raw = await readFeishuContext(
|
||||
{ chat_id: "chat-1", anchor: "reply", id: "m-child" },
|
||||
{ runId: "run-1", projectId: "proj-1", boundChatId: "chat-1", workspaceDir: "/tmp/proj-1" },
|
||||
rt,
|
||||
);
|
||||
|
||||
expect(JSON.parse(raw)).toMatchObject({
|
||||
message_id: "m-child",
|
||||
content: JSON.stringify({ text: "parent text" }),
|
||||
parent_id: "m-parent",
|
||||
parent_message_id: "m-parent",
|
||||
root_id: "m-root",
|
||||
thread_id: "thread-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { sendFile, withRetry, type FeishuRuntime } from "../../src/feishu/client.js";
|
||||
|
||||
describe("withRetry", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("succeeds on first attempt without delaying", async () => {
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
const fn = vi.fn(async () => "ok");
|
||||
|
||||
await expect(withRetry(fn)).resolves.toBe("ok");
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(setTimeoutSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries on failure then succeeds", async () => {
|
||||
const fn = vi.fn()
|
||||
.mockRejectedValueOnce(new Error("temporary failure"))
|
||||
.mockResolvedValueOnce("ok");
|
||||
|
||||
const result = withRetry(fn);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
await expect(result).resolves.toBe("ok");
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("exhausts all attempts and rethrows", async () => {
|
||||
const error = new Error("still failing");
|
||||
const fn = vi.fn(async () => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
const result = withRetry(fn);
|
||||
const assertion = expect(result).rejects.toBe(error);
|
||||
await Promise.resolve();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
|
||||
await assertion;
|
||||
expect(fn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("respects shouldRetry predicate", async () => {
|
||||
const error = new Error("validation failure");
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
const shouldRetry = vi.fn(() => false);
|
||||
const fn = vi.fn(async () => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
await expect(withRetry(fn, { shouldRetry })).rejects.toBe(error);
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(shouldRetry).toHaveBeenCalledWith(error);
|
||||
expect(setTimeoutSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses exponential backoff delays", async () => {
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
const fn = vi.fn()
|
||||
.mockRejectedValueOnce(new Error("first failure"))
|
||||
.mockRejectedValueOnce(new Error("second failure"))
|
||||
.mockResolvedValueOnce("ok");
|
||||
|
||||
const result = withRetry(fn);
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
|
||||
await expect(result).resolves.toBe("ok");
|
||||
expect(setTimeoutSpy.mock.calls.map(([, delay]) => delay)).toEqual([1000, 2000]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendFile retry", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("retries upload on transient failure", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "hub-feishu-retry-"));
|
||||
try {
|
||||
const file = join(dir, "student.pdf");
|
||||
await writeFile(file, "pdf bytes");
|
||||
|
||||
let failFirstUpload: (error: Error) => void = () => {
|
||||
throw new Error("upload rejection was not initialized");
|
||||
};
|
||||
const firstUpload = new Promise<never>((_resolve, reject) => {
|
||||
failFirstUpload = reject;
|
||||
});
|
||||
const fileCreate = vi.fn()
|
||||
.mockReturnValueOnce(firstUpload)
|
||||
.mockResolvedValueOnce({ data: { file_key: "file-key-1" } });
|
||||
const messageCreate = vi.fn(async () => ({ data: { message_id: "message-1" } }));
|
||||
const rt = mockRuntime({ fileCreate, messageCreate });
|
||||
|
||||
const result = sendFile(rt, "chat-1", file, "student.pdf");
|
||||
await waitForCalls(fileCreate, 1);
|
||||
vi.useFakeTimers();
|
||||
failFirstUpload(new Error("temporary upload failure"));
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
await expect(result).resolves.toBe("message-1");
|
||||
expect(fileCreate).toHaveBeenCalledTimes(2);
|
||||
expect(messageCreate).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function waitForCalls(mock: Mock, expectedCalls: number): Promise<void> {
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
if (mock.mock.calls.length >= expectedCalls) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
throw new Error(`Timed out waiting for mock to be called ${expectedCalls} time(s)`);
|
||||
}
|
||||
|
||||
function mockRuntime(options: {
|
||||
readonly fileCreate: (payload: unknown) => Promise<unknown>;
|
||||
readonly messageCreate: (payload: unknown) => Promise<unknown>;
|
||||
}): FeishuRuntime {
|
||||
return {
|
||||
client: {
|
||||
im: {
|
||||
v1: {
|
||||
file: { create: options.fileCreate },
|
||||
message: { create: options.messageCreate },
|
||||
},
|
||||
},
|
||||
} as unknown as FeishuRuntime["client"],
|
||||
logger: {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
fatal() {},
|
||||
child() { return this; },
|
||||
level: "silent",
|
||||
} as unknown as FeishuRuntime["logger"],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { resolveDeliverableFile } from "../../src/feishu/fileDelivery.js";
|
||||
|
||||
describe("file delivery path resolution", () => {
|
||||
it("resolves a repo-root file from a project workspace only when explicitly named", async () => {
|
||||
const root = await makeRepo();
|
||||
try {
|
||||
const workspace = join(root, "examples", "TH-141");
|
||||
await writeFile(join(root, "README.md"), "# root readme\n");
|
||||
|
||||
await expect(resolveDeliverableFile("README.md", workspace)).resolves.toEqual({
|
||||
path: join(root, "README.md"),
|
||||
name: "README.md",
|
||||
});
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves a bare build filename against workspace/build", async () => {
|
||||
const root = await makeRepo();
|
||||
try {
|
||||
const workspace = join(root, "examples", "TH-141");
|
||||
const pdf = join(workspace, "build", "student.pdf");
|
||||
await writeFile(pdf, "pdf bytes");
|
||||
|
||||
await expect(resolveDeliverableFile("student.pdf", workspace)).resolves.toEqual({
|
||||
path: pdf,
|
||||
name: "student.pdf",
|
||||
});
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not infer files from natural-language prompts", async () => {
|
||||
const root = await makeRepo();
|
||||
try {
|
||||
const workspace = join(root, "examples", "TH-141");
|
||||
await writeFile(join(workspace, "build", "student.pdf"), "pdf bytes");
|
||||
|
||||
await expect(resolveDeliverableFile("cph build 生成 PDF 给我", workspace)).resolves.toBeNull();
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function makeRepo(): Promise<string> {
|
||||
const root = await mkdir(join(tmpdir(), `hub-file-delivery-${Date.now()}-${Math.random().toString(36).slice(2)}`), { recursive: true });
|
||||
await mkdir(join(root, ".git"));
|
||||
await mkdir(join(root, "examples", "TH-141", "build"), { recursive: true });
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MessageBatcher, messageBatchKey } from "../../src/feishu/messageBatcher.js";
|
||||
|
||||
describe("MessageBatcher", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("flushes a single message after the debounce period", async () => {
|
||||
const flushed: string[] = [];
|
||||
const batcher = new MessageBatcher(async (text) => {
|
||||
flushed.push(text);
|
||||
}, { debounceMs: 25 });
|
||||
|
||||
await batcher.enqueue("chat-1", "sender-1", "hello");
|
||||
|
||||
expect(flushed).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(24);
|
||||
expect(flushed).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(flushed).toEqual(["hello"]);
|
||||
});
|
||||
|
||||
it("merges two quick messages with a newline", async () => {
|
||||
const flushed: string[] = [];
|
||||
const batcher = new MessageBatcher(async (text) => {
|
||||
flushed.push(text);
|
||||
}, { debounceMs: 25 });
|
||||
|
||||
await batcher.enqueue("chat-1", "sender-1", "first");
|
||||
await batcher.enqueue("chat-1", "sender-1", "second");
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
expect(flushed).toEqual(["first\nsecond"]);
|
||||
});
|
||||
|
||||
it("flushes immediately when the max message count is reached", async () => {
|
||||
const flushed: string[] = [];
|
||||
const batcher = new MessageBatcher(async (text) => {
|
||||
flushed.push(text);
|
||||
}, { debounceMs: 1000, maxMessages: 2 });
|
||||
|
||||
await batcher.enqueue("chat-1", "sender-1", "first");
|
||||
expect(flushed).toEqual([]);
|
||||
await batcher.enqueue("chat-1", "sender-1", "second");
|
||||
|
||||
expect(flushed).toEqual(["first\nsecond"]);
|
||||
});
|
||||
|
||||
it("flushes immediately when the max character count is reached", async () => {
|
||||
const flushed: string[] = [];
|
||||
const batcher = new MessageBatcher(async (text) => {
|
||||
flushed.push(text);
|
||||
}, { debounceMs: 1000, maxChars: 5 });
|
||||
|
||||
await batcher.enqueue("chat-1", "sender-1", "abc");
|
||||
expect(flushed).toEqual([]);
|
||||
await batcher.enqueue("chat-1", "sender-1", "d");
|
||||
|
||||
expect(flushed).toEqual(["abc\nd"]);
|
||||
});
|
||||
|
||||
it("uses the extended debounce for a chunk near the split threshold", async () => {
|
||||
const flushed: string[] = [];
|
||||
const batcher = new MessageBatcher(async (text) => {
|
||||
flushed.push(text);
|
||||
}, { debounceMs: 10, splitThreshold: 5, extendedDebounceMs: 50 });
|
||||
|
||||
await batcher.enqueue("chat-1", "sender-1", "12345");
|
||||
await vi.advanceTimersByTimeAsync(49);
|
||||
expect(flushed).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(flushed).toEqual(["12345"]);
|
||||
});
|
||||
|
||||
it("keeps different chat/sender pairs in separate batches", async () => {
|
||||
const flushed: string[] = [];
|
||||
const batcher = new MessageBatcher(async (text) => {
|
||||
flushed.push(text);
|
||||
}, { debounceMs: 25 });
|
||||
|
||||
await batcher.enqueue("chat-1", "sender-1", "one");
|
||||
await batcher.enqueue("chat-1", "sender-2", "two");
|
||||
await batcher.enqueue("chat-2", "sender-1", "three");
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
expect(flushed.sort()).toEqual(["one", "three", "two"]);
|
||||
});
|
||||
|
||||
it("flushAll flushes every pending batch", async () => {
|
||||
const flushed: string[] = [];
|
||||
const batcher = new MessageBatcher(async (text) => {
|
||||
flushed.push(text);
|
||||
}, { debounceMs: 1000 });
|
||||
|
||||
await batcher.enqueue("chat-1", "sender-1", "one");
|
||||
await batcher.enqueue("chat-2", "sender-2", "two");
|
||||
await batcher.flushAll();
|
||||
|
||||
expect(flushed.sort()).toEqual(["one", "two"]);
|
||||
});
|
||||
|
||||
it("builds keys from chat and sender open ids", () => {
|
||||
expect(messageBatchKey("chat-1", "ou-1")).toBe("chat-1:ou-1");
|
||||
});
|
||||
|
||||
// Slash-command bypass is owned by trigger.ts, not MessageBatcher. Existing
|
||||
// trigger integration tests cover /new, /resume, /reset as immediate paths.
|
||||
});
|
||||
+255
-32
@@ -1,39 +1,262 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { runAgent } from "../../src/agent/runner.js";
|
||||
import { ToolRegistry } from "../../src/agent/tools.js";
|
||||
import { readFileTool } from "../../src/agent/workspace.js";
|
||||
import { createMockModelFactory } from "../integration/helpers.js";
|
||||
|
||||
describe("runAgent with AI SDK tool loop", () => {
|
||||
it("executes SDK tools and returns the final assistant response", async () => {
|
||||
const ws = await mkdtemp(join(tmpdir(), "hub-runner-"));
|
||||
try {
|
||||
await writeFile(join(ws, "lesson.txt"), "hello lesson", "utf8");
|
||||
const tools = new ToolRegistry();
|
||||
tools.register("read_file", readFileTool);
|
||||
const { modelFactory, models } = createMockModelFactory([
|
||||
{
|
||||
toolCalls: [{ toolCallId: "call-1", toolName: "read_file", input: { path: "lesson.txt" } }],
|
||||
const queryMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
||||
query: queryMock,
|
||||
}));
|
||||
|
||||
const stubPrisma = {
|
||||
projectAgentLock: { update: async () => ({}) },
|
||||
} as unknown as import("@prisma/client").PrismaClient;
|
||||
|
||||
function assistantMessage(text: string) {
|
||||
return {
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [{ type: "text", text }],
|
||||
usage: { input_tokens: 3, output_tokens: 5 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resultMessage(sessionId: string, costUsd?: number) {
|
||||
return {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
session_id: sessionId,
|
||||
...(costUsd !== undefined ? { total_cost_usd: costUsd } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function messages(...items: unknown[]) {
|
||||
return (async function* () {
|
||||
for (const item of items) yield item;
|
||||
})();
|
||||
}
|
||||
function abortableMessages(ac: AbortController, ...items: unknown[]) {
|
||||
return (async function* () {
|
||||
for (const item of items) yield item;
|
||||
// Simulate the SDK: after yielding, the query blocks until the abort
|
||||
// signal fires, then throws (the SDK raises AbortError).
|
||||
if (ac.signal.aborted) throw new Error("aborted");
|
||||
await new Promise<void>((resolve) => {
|
||||
ac.signal.addEventListener("abort", () => resolve(), { once: true });
|
||||
});
|
||||
throw new Error("aborted");
|
||||
})();
|
||||
}
|
||||
|
||||
describe("runAgent", () => {
|
||||
beforeEach(() => {
|
||||
queryMock.mockReset();
|
||||
});
|
||||
|
||||
it("passes the previous Claude SDK session id through resume", async () => {
|
||||
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-new")));
|
||||
|
||||
const result = await runAgent({
|
||||
prompt: "再发一次",
|
||||
model: "anthropic/claude-sonnet-5",
|
||||
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
|
||||
systemPrompt: undefined,
|
||||
resumeSessionId: "sdk-session-old",
|
||||
runId: "run-1",
|
||||
sessionId: "hub-session-1",
|
||||
prisma: stubPrisma,
|
||||
});
|
||||
|
||||
expect(queryMock).toHaveBeenCalledWith({
|
||||
prompt: "再发一次",
|
||||
options: expect.objectContaining({
|
||||
cwd: "/tmp/ws",
|
||||
model: "anthropic/claude-sonnet-5",
|
||||
resume: "sdk-session-old",
|
||||
// ADR-0018: agent execution surface bounded by workspace.
|
||||
permissionMode: "bypassPermissions",
|
||||
allowDangerouslySkipPermissions: true,
|
||||
sandbox: expect.objectContaining({
|
||||
enabled: true,
|
||||
failIfUnavailable: true,
|
||||
filesystem: expect.objectContaining({
|
||||
allowWrite: ["/tmp/ws"],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not send resume for a fresh Hub session", async () => {
|
||||
queryMock.mockReturnValue(messages(assistantMessage("fresh"), resultMessage("sdk-session-1")));
|
||||
|
||||
await runAgent({
|
||||
prompt: "你好",
|
||||
model: undefined,
|
||||
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
|
||||
systemPrompt: undefined,
|
||||
runId: "run-1",
|
||||
sessionId: "hub-session-1",
|
||||
prisma: stubPrisma,
|
||||
});
|
||||
|
||||
const call = queryMock.mock.calls[0]?.[0] as { options?: Record<string, unknown> } | undefined;
|
||||
expect(call?.options).not.toHaveProperty("resume");
|
||||
});
|
||||
|
||||
it("maps role tool ids to the Claude SDK tool whitelist", async () => {
|
||||
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
|
||||
|
||||
await runAgent({
|
||||
prompt: "审校一下",
|
||||
model: undefined,
|
||||
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
|
||||
systemPrompt: undefined,
|
||||
tools: ["read_file", "cph_check", "send_file"],
|
||||
runId: "run-1",
|
||||
sessionId: "hub-session-1",
|
||||
prisma: stubPrisma,
|
||||
});
|
||||
|
||||
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
options: {
|
||||
tools: ["Read", "Bash"],
|
||||
allowedTools: ["Read", "Bash", "mcp__cph_hub__send_file"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("disables all SDK tools for an empty role tool whitelist", async () => {
|
||||
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
|
||||
|
||||
await runAgent({
|
||||
prompt: "只回答",
|
||||
model: undefined,
|
||||
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
|
||||
systemPrompt: undefined,
|
||||
tools: [],
|
||||
runId: "run-1",
|
||||
sessionId: "hub-session-1",
|
||||
prisma: stubPrisma,
|
||||
});
|
||||
|
||||
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
options: {
|
||||
tools: [],
|
||||
allowedTools: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns SDK-reported cost when present", async () => {
|
||||
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1", 0.0042)));
|
||||
|
||||
const result = await runAgent({
|
||||
prompt: "算一下成本",
|
||||
model: undefined,
|
||||
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
|
||||
systemPrompt: undefined,
|
||||
runId: "run-1",
|
||||
sessionId: "hub-session-1",
|
||||
prisma: stubPrisma,
|
||||
});
|
||||
|
||||
expect(result.costUsd).toBe(0.0042);
|
||||
});
|
||||
|
||||
it("passes provider env per run without mutating process env", async () => {
|
||||
delete process.env["CPH_TEST_PROVIDER_ENV_MUTATION"];
|
||||
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
|
||||
|
||||
await runAgent({
|
||||
prompt: "你好",
|
||||
model: undefined,
|
||||
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
|
||||
systemPrompt: undefined,
|
||||
providerEnv: {
|
||||
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
|
||||
ANTHROPIC_AUTH_TOKEN: "test-token",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
CPH_TEST_PROVIDER_ENV_MUTATION: "per-run",
|
||||
},
|
||||
runId: "run-1",
|
||||
sessionId: "hub-session-1",
|
||||
prisma: stubPrisma,
|
||||
});
|
||||
|
||||
const call = queryMock.mock.calls[0]?.[0];
|
||||
expect(call).toMatchObject({
|
||||
options: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
|
||||
ANTHROPIC_AUTH_TOKEN: "test-token",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
CPH_TEST_PROVIDER_ENV_MUTATION: "per-run",
|
||||
},
|
||||
{ text: "done", finishReason: "stop" },
|
||||
]);
|
||||
},
|
||||
});
|
||||
expect(process.env["CPH_TEST_PROVIDER_ENV_MUTATION"]).toBeUndefined();
|
||||
});
|
||||
|
||||
const result = await runAgent(modelFactory, tools, {
|
||||
prompt: "read lesson.txt",
|
||||
model: "mock-model",
|
||||
project: { projectId: "p", boundChatId: "c", workspaceDir: ws },
|
||||
systemPrompt: "system",
|
||||
maxIterations: 5,
|
||||
});
|
||||
it("persists structured user and assistant messages best-effort", async () => {
|
||||
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
|
||||
const createMessage = vi.fn().mockResolvedValue({});
|
||||
const prisma = {
|
||||
projectAgentLock: { update: async () => ({}) },
|
||||
agentMessage: { create: createMessage },
|
||||
} as unknown as import("@prisma/client").PrismaClient;
|
||||
|
||||
expect(result.status).toBe("completed");
|
||||
expect(result.messages.at(-1)).toMatchObject({ role: "assistant" });
|
||||
expect(models.get("mock-model")?.calls).toHaveLength(2);
|
||||
} finally {
|
||||
await rm(ws, { recursive: true, force: true });
|
||||
}
|
||||
await runAgent({
|
||||
prompt: "你好",
|
||||
model: undefined,
|
||||
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
|
||||
systemPrompt: undefined,
|
||||
runId: "run-1",
|
||||
sessionId: "hub-session-1",
|
||||
prisma,
|
||||
});
|
||||
|
||||
expect(createMessage).toHaveBeenCalledTimes(2);
|
||||
expect(createMessage).toHaveBeenNthCalledWith(1, {
|
||||
data: expect.objectContaining({
|
||||
sessionId: "hub-session-1",
|
||||
runId: "run-1",
|
||||
role: "user",
|
||||
content: "你好",
|
||||
}),
|
||||
});
|
||||
expect(createMessage).toHaveBeenNthCalledWith(2, {
|
||||
data: expect.objectContaining({
|
||||
sessionId: "hub-session-1",
|
||||
runId: "run-1",
|
||||
role: "assistant",
|
||||
content: "ok",
|
||||
}),
|
||||
});
|
||||
});
|
||||
it("returns interrupted status when the abort controller fires", async () => {
|
||||
const ac = new AbortController();
|
||||
queryMock.mockReturnValue(abortableMessages(ac, assistantMessage("partial"), resultMessage("sdk-session-abort")));
|
||||
|
||||
const runPromise = runAgent({
|
||||
prompt: "长任务",
|
||||
model: undefined,
|
||||
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
|
||||
systemPrompt: undefined,
|
||||
runId: "run-abort",
|
||||
sessionId: "hub-session-abort",
|
||||
prisma: stubPrisma,
|
||||
abortController: ac,
|
||||
});
|
||||
// Let the query yield its messages and block on the abort wait.
|
||||
await Promise.resolve();
|
||||
ac.abort();
|
||||
|
||||
const result = await runPromise;
|
||||
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({ options: { abortController: ac } });
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.sdkSessionId).toBe("sdk-session-abort");
|
||||
const call = queryMock.mock.calls[0]?.[0] as { options?: { abortController?: AbortController } } | undefined;
|
||||
expect(call?.options?.abortController).toBe(ac);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createEnvRuntimeSettings } from "../../src/settings/runtime.js";
|
||||
|
||||
describe("runtime settings", () => {
|
||||
it("reads the model registry from the current env at call time", async () => {
|
||||
const env: Record<string, string | undefined> = {
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "anthropic/claude-sonnet-5",
|
||||
};
|
||||
const settings = createEnvRuntimeSettings(env);
|
||||
|
||||
expect((await settings.modelRegistry()).resolve(undefined, "draft")).toBe("anthropic/claude-sonnet-5");
|
||||
|
||||
env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = "z-ai/glm-4.7";
|
||||
|
||||
expect((await settings.modelRegistry()).resolve(undefined, "draft")).toBe("z-ai/glm-4.7");
|
||||
});
|
||||
|
||||
it("returns provider SDK env without requiring global process env mutation", async () => {
|
||||
const settings = createEnvRuntimeSettings({
|
||||
ANTHROPIC_BASE_URL: "https://example.test/api",
|
||||
ANTHROPIC_AUTH_TOKEN: "openrouter-token",
|
||||
ANTHROPIC_API_KEY: "explicit-api-key",
|
||||
});
|
||||
|
||||
await expect(settings.provider("openrouter")).resolves.toMatchObject({
|
||||
id: "openrouter",
|
||||
baseUrl: "https://example.test/api",
|
||||
authToken: "openrouter-token",
|
||||
anthropicApiKey: "explicit-api-key",
|
||||
sdkEnv: {
|
||||
ANTHROPIC_BASE_URL: "https://example.test/api",
|
||||
ANTHROPIC_AUTH_TOKEN: "openrouter-token",
|
||||
ANTHROPIC_API_KEY: "explicit-api-key",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults Anthropic API key to empty for OpenRouter's Anthropic skin", async () => {
|
||||
const settings = createEnvRuntimeSettings({
|
||||
ANTHROPIC_AUTH_TOKEN: "openrouter-token",
|
||||
});
|
||||
|
||||
await expect(settings.provider("openrouter")).resolves.toMatchObject({
|
||||
baseUrl: "https://openrouter.ai/api",
|
||||
anthropicApiKey: "",
|
||||
sdkEnv: {
|
||||
ANTHROPIC_API_KEY: "",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("requires an auth token for the OpenRouter provider", async () => {
|
||||
const settings = createEnvRuntimeSettings({});
|
||||
|
||||
await expect(settings.provider("openrouter")).rejects.toThrow("missing required runtime setting: ANTHROPIC_AUTH_TOKEN");
|
||||
});
|
||||
|
||||
it("honors HUB_AGENT_MAX_TURNS as a runtime run policy", async () => {
|
||||
const settings = createEnvRuntimeSettings({
|
||||
HUB_AGENT_MAX_TURNS: "9",
|
||||
});
|
||||
|
||||
await expect(settings.runPolicy({ projectId: "p", roleId: "draft" })).resolves.toEqual({ maxTurns: 9 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SenderNameCache } from "../../src/feishu/senderCache.js";
|
||||
|
||||
describe("SenderNameCache", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("get returns null for missing key", () => {
|
||||
const cache = new SenderNameCache();
|
||||
|
||||
expect(cache.get("ou_user")).toBeNull();
|
||||
});
|
||||
|
||||
it("set then get returns the name", () => {
|
||||
const cache = new SenderNameCache();
|
||||
|
||||
cache.set("ou_user", "Alice");
|
||||
|
||||
expect(cache.get("ou_user")).toBe("Alice");
|
||||
});
|
||||
|
||||
it("expired entry returns null", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
const cache = new SenderNameCache({ ttlMs: 100 });
|
||||
|
||||
cache.set("ou_user", "Alice");
|
||||
vi.advanceTimersByTime(101);
|
||||
|
||||
expect(cache.get("ou_user")).toBeNull();
|
||||
expect(cache.size()).toBe(0);
|
||||
});
|
||||
|
||||
it("getOrFetch uses cache on hit", async () => {
|
||||
const cache = new SenderNameCache();
|
||||
const fetchFn = vi.fn(async () => "Fetched Alice");
|
||||
cache.set("ou_user", "Alice");
|
||||
|
||||
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBe("Alice");
|
||||
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("getOrFetch calls fetchFn on miss", async () => {
|
||||
const cache = new SenderNameCache();
|
||||
const fetchFn = vi.fn(async () => "Alice");
|
||||
|
||||
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBe("Alice");
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
expect(fetchFn).toHaveBeenCalledWith("ou_user");
|
||||
});
|
||||
|
||||
it("getOrFetch caches the result of fetchFn", async () => {
|
||||
const cache = new SenderNameCache();
|
||||
const fetchFn = vi.fn(async () => "Alice");
|
||||
|
||||
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBe("Alice");
|
||||
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBe("Alice");
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("evicts the least recently used entry when full", () => {
|
||||
const cache = new SenderNameCache({ maxSize: 2 });
|
||||
|
||||
cache.set("ou_1", "Alice");
|
||||
cache.set("ou_2", "Bob");
|
||||
expect(cache.get("ou_1")).toBe("Alice");
|
||||
cache.set("ou_3", "Carol");
|
||||
|
||||
expect(cache.get("ou_1")).toBe("Alice");
|
||||
expect(cache.get("ou_2")).toBeNull();
|
||||
expect(cache.get("ou_3")).toBe("Carol");
|
||||
});
|
||||
|
||||
it("clear empties the cache", () => {
|
||||
const cache = new SenderNameCache();
|
||||
|
||||
cache.set("ou_user", "Alice");
|
||||
cache.clear();
|
||||
|
||||
expect(cache.get("ou_user")).toBeNull();
|
||||
expect(cache.size()).toBe(0);
|
||||
});
|
||||
|
||||
it("size returns correct count", () => {
|
||||
const cache = new SenderNameCache();
|
||||
|
||||
cache.set("ou_1", "Alice");
|
||||
cache.set("ou_2", "Bob");
|
||||
|
||||
expect(cache.size()).toBe(2);
|
||||
});
|
||||
|
||||
it("getOrFetch with null fetchFn result does not cache null", async () => {
|
||||
const cache = new SenderNameCache();
|
||||
const fetchFn = vi.fn(async () => null);
|
||||
|
||||
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBeNull();
|
||||
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBeNull();
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
expect(cache.size()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseSlashHelpSubcommand, parseSlashInvocation } from "../../src/feishu/slashCommands.js";
|
||||
|
||||
describe("slash command parser", () => {
|
||||
it("parses a slash invocation without resolving it", () => {
|
||||
expect(parseSlashInvocation("/new")).toEqual({ name: "new", args: [] });
|
||||
expect(parseSlashInvocation("/review 看看这节")).toEqual({ name: "review", args: ["看看这节"] });
|
||||
expect(parseSlashInvocation("写教案")).toBeNull();
|
||||
});
|
||||
|
||||
it("parses help subcommands only in the exact /<command> help form", () => {
|
||||
expect(parseSlashHelpSubcommand({ name: "new", args: ["help"] })).toBe("new");
|
||||
expect(parseSlashHelpSubcommand({ name: "unknown", args: ["help"] })).toBe("unknown");
|
||||
expect(parseSlashHelpSubcommand({ name: "new", args: ["help", "please"] })).toBeNull();
|
||||
expect(parseSlashHelpSubcommand({ name: "help", args: ["new"] })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { splitAtBoundary } from "../../src/feishu/textStream.js";
|
||||
|
||||
describe("splitAtBoundary", () => {
|
||||
it("keeps short text in a single chunk", () => {
|
||||
expect(splitAtBoundary("short text", 20)).toEqual(["short text"]);
|
||||
});
|
||||
|
||||
it("keeps text at exactly maxLength in a single chunk", () => {
|
||||
const text = "x".repeat(20);
|
||||
|
||||
expect(splitAtBoundary(text, 20)).toEqual([text]);
|
||||
});
|
||||
|
||||
it("splits text exceeding maxLength into chunks at or below maxLength", () => {
|
||||
const chunks = splitAtBoundary("x".repeat(45), 20);
|
||||
|
||||
expect(chunks).toHaveLength(3);
|
||||
expect(chunks.every((chunk) => chunk.length <= 20)).toBe(true);
|
||||
expect(chunks.join("")).toBe("x".repeat(45));
|
||||
});
|
||||
|
||||
it("moves a split before a fenced code block when the boundary lands inside it", () => {
|
||||
const prefix = "Before code starts\n";
|
||||
const codeBlock = "```ts\nx\n```\n";
|
||||
const text = `${prefix}${codeBlock}After`;
|
||||
const chunks = splitAtBoundary(text, prefix.length + 4);
|
||||
|
||||
expect(chunks).toEqual([prefix, `${codeBlock}After`]);
|
||||
});
|
||||
|
||||
it("prefers paragraph boundaries over newline boundaries", () => {
|
||||
const text = "para one\n\nline two\nline three";
|
||||
|
||||
expect(splitAtBoundary(text, 22)).toEqual(["para one\n\n", "line two\nline three"]);
|
||||
});
|
||||
|
||||
it("prefers newline boundaries over space boundaries", () => {
|
||||
const text = "alpha beta\ngamma delta epsilon";
|
||||
|
||||
expect(splitAtBoundary(text, 24)).toEqual(["alpha beta\n", "gamma delta epsilon"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PatchableTextStream } from "../../src/feishu/textStream.js";
|
||||
|
||||
describe("PatchableTextStream", () => {
|
||||
it("creates only one initial message when deltas arrive while create is pending", async () => {
|
||||
const creates: string[] = [];
|
||||
const patches: string[] = [];
|
||||
let resolveCreate: ((id: string) => void) | undefined;
|
||||
const firstCreate = new Promise<string>((resolve) => {
|
||||
resolveCreate = resolve;
|
||||
});
|
||||
const stream = new PatchableTextStream(
|
||||
{
|
||||
create: async (text) => {
|
||||
creates.push(text);
|
||||
return firstCreate;
|
||||
},
|
||||
patch: async (_messageId, text) => {
|
||||
patches.push(text);
|
||||
},
|
||||
},
|
||||
{ patchIntervalMs: 0 },
|
||||
);
|
||||
|
||||
stream.append("我目前运行在");
|
||||
await Promise.resolve();
|
||||
stream.append(" Claude Sonnet");
|
||||
resolveCreate?.("msg-1");
|
||||
await stream.finish("我目前运行在 Claude Sonnet");
|
||||
|
||||
expect(creates).toEqual(["我目前运行在"]);
|
||||
expect(patches.at(-1)).toBe("我目前运行在 Claude Sonnet");
|
||||
});
|
||||
|
||||
it("sends a fallback message when no stream delta arrived", async () => {
|
||||
const creates: string[] = [];
|
||||
const stream = new PatchableTextStream({
|
||||
create: async (text) => {
|
||||
creates.push(text);
|
||||
return "msg-1";
|
||||
},
|
||||
patch: async () => {},
|
||||
});
|
||||
|
||||
await stream.finish("完整回复");
|
||||
|
||||
expect(creates).toEqual(["完整回复"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TriggerQueue, type QueuedTrigger } from "../../src/feishu/triggerQueue.js";
|
||||
import type { MessageReceiveEvent } from "../../src/feishu/client.js";
|
||||
|
||||
describe("TriggerQueue", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("dequeues triggers in FIFO order", () => {
|
||||
const queue = new TriggerQueue();
|
||||
|
||||
queue.enqueue("project-1", makeTrigger("first"));
|
||||
queue.enqueue("project-1", makeTrigger("second"));
|
||||
|
||||
expect(queue.dequeue("project-1")?.prompt).toBe("first");
|
||||
expect(queue.dequeue("project-1")?.prompt).toBe("second");
|
||||
});
|
||||
|
||||
it("returns the 1-based queue position from enqueue", () => {
|
||||
const queue = new TriggerQueue();
|
||||
|
||||
expect(queue.enqueue("project-1", makeTrigger("first"))).toBe(1);
|
||||
expect(queue.enqueue("project-1", makeTrigger("second"))).toBe(2);
|
||||
expect(queue.enqueue("project-1", makeTrigger("third"))).toBe(3);
|
||||
});
|
||||
|
||||
it("returns 0 when the project queue is full", () => {
|
||||
const queue = new TriggerQueue({ maxQueueSize: 2 });
|
||||
|
||||
expect(queue.enqueue("project-1", makeTrigger("first"))).toBe(1);
|
||||
expect(queue.enqueue("project-1", makeTrigger("second"))).toBe(2);
|
||||
expect(queue.enqueue("project-1", makeTrigger("third"))).toBe(0);
|
||||
expect(queue.length("project-1")).toBe(2);
|
||||
});
|
||||
|
||||
it("returns null when dequeueing an empty queue", () => {
|
||||
const queue = new TriggerQueue();
|
||||
|
||||
expect(queue.dequeue("project-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("peeks without removing the trigger", () => {
|
||||
const queue = new TriggerQueue();
|
||||
|
||||
queue.enqueue("project-1", makeTrigger("first"));
|
||||
|
||||
expect(queue.peek("project-1")?.prompt).toBe("first");
|
||||
expect(queue.length("project-1")).toBe(1);
|
||||
expect(queue.dequeue("project-1")?.prompt).toBe("first");
|
||||
});
|
||||
|
||||
it("reports length and pending state per project", () => {
|
||||
const queue = new TriggerQueue();
|
||||
|
||||
expect(queue.length("project-1")).toBe(0);
|
||||
expect(queue.hasPending("project-1")).toBe(false);
|
||||
|
||||
queue.enqueue("project-1", makeTrigger("first"));
|
||||
|
||||
expect(queue.length("project-1")).toBe(1);
|
||||
expect(queue.hasPending("project-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("purges expired triggers", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_000);
|
||||
const queue = new TriggerQueue({ maxWaitMs: 100 });
|
||||
|
||||
queue.enqueue("project-1", makeTrigger("old"));
|
||||
vi.setSystemTime(1_050);
|
||||
queue.enqueue("project-1", makeTrigger("fresh"));
|
||||
vi.setSystemTime(1_101);
|
||||
|
||||
expect(queue.purgeExpired()).toBe(1);
|
||||
expect(queue.length("project-1")).toBe(1);
|
||||
expect(queue.dequeue("project-1")?.prompt).toBe("fresh");
|
||||
});
|
||||
|
||||
it("clears all triggers for a project", () => {
|
||||
const queue = new TriggerQueue();
|
||||
|
||||
queue.enqueue("project-1", makeTrigger("first"));
|
||||
queue.enqueue("project-1", makeTrigger("second"));
|
||||
queue.enqueue("project-2", makeTrigger("other"));
|
||||
|
||||
expect(queue.clear("project-1")).toBe(2);
|
||||
expect(queue.length("project-1")).toBe(0);
|
||||
expect(queue.length("project-2")).toBe(1);
|
||||
});
|
||||
|
||||
it("clears all project queues", () => {
|
||||
const queue = new TriggerQueue();
|
||||
|
||||
queue.enqueue("project-1", makeTrigger("first"));
|
||||
queue.enqueue("project-2", makeTrigger("other"));
|
||||
queue.clearAll();
|
||||
|
||||
expect(queue.hasPending("project-1")).toBe(false);
|
||||
expect(queue.hasPending("project-2")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps multiple project queues independent", () => {
|
||||
const queue = new TriggerQueue();
|
||||
|
||||
expect(queue.enqueue("project-1", makeTrigger("p1-first"))).toBe(1);
|
||||
expect(queue.enqueue("project-2", makeTrigger("p2-first"))).toBe(1);
|
||||
expect(queue.enqueue("project-1", makeTrigger("p1-second"))).toBe(2);
|
||||
|
||||
expect(queue.dequeue("project-2")?.prompt).toBe("p2-first");
|
||||
expect(queue.dequeue("project-1")?.prompt).toBe("p1-first");
|
||||
expect(queue.dequeue("project-1")?.prompt).toBe("p1-second");
|
||||
});
|
||||
});
|
||||
|
||||
function makeTrigger(prompt: string): Omit<QueuedTrigger, "projectId" | "enqueuedAt"> {
|
||||
return {
|
||||
chatId: "chat-1",
|
||||
prompt,
|
||||
msg: makeMessage(prompt),
|
||||
senderOpenId: "ou-user",
|
||||
actor: { feishuOpenId: "ou-user", chatId: "chat-1" },
|
||||
};
|
||||
}
|
||||
|
||||
function makeMessage(prompt: string): MessageReceiveEvent["message"] {
|
||||
return {
|
||||
message_id: `message-${prompt}`,
|
||||
chat_id: "chat-1",
|
||||
chat_type: "group",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: `@_user_1 ${prompt}` }),
|
||||
mentions: [{ key: "@_user_1", id: { open_id: "ou-bot" }, name: "Bot" }],
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,10 @@ namespace Spec.System
|
||||
structure Identifiers where
|
||||
/-- 课程项目标识(`OPEN` 表示;聚合根,likec4 `Project`)。 -/
|
||||
ProjectId : Type
|
||||
/-- SaaS 租户/组织标识(`OPEN` 表示;ADR-0020 tenant root)。 -/
|
||||
OrganizationId : Type
|
||||
/-- Hub teacher team 标识(`OPEN` 表示;ADR-0020 org-scoped team)。 -/
|
||||
TeamId : Type
|
||||
/-- 一次 agent 任务的标识(`OPEN` 表示;锁的 owner、审计主体,`AgentRun`。provider 无关,
|
||||
ADR-0017;`@Claude` 仅为触发品牌,不承诺 provider)。 -/
|
||||
RunId : Type
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import Spec.System.ProjectGroup
|
||||
import Spec.System.Organization
|
||||
import Spec.System.Run
|
||||
import Spec.System.Lock
|
||||
import Spec.System.Memory
|
||||
import Spec.System.AgentSurface
|
||||
import Spec.System.Permission
|
||||
import Spec.System.PermissionGrant
|
||||
import Spec.System.Audit
|
||||
|
||||
/-!
|
||||
# System —— Hub 平台层契约
|
||||
|
||||
@@ -14,13 +15,16 @@ import Spec.System.Audit
|
||||
**语义分歧点**:
|
||||
|
||||
- `ProjectGroup` —— project↔飞书群 1:1 长生命周期绑定(ADR-0001);群是协作空间,不持锁。
|
||||
- `Organization` —— SaaS tenant root(ADR-0020);project/team 单归属,TEAM grant 不跨 org。
|
||||
- `Run` —— AgentRun 状态与终止判定(状态集合完整性 OPEN)。
|
||||
- `Lock` —— 锁 owner=run(ADR-0002),及"持锁者必为非终止 run"的核心不变式。
|
||||
- `Memory` —— 按需上下文:锚点类别(ADR-0003)+ MCP 工具按 run/project 上下文授权的不变式。
|
||||
- `AgentSurface` —— agent 执行面被 run 的工作区所界定(ADR-0018);与 Lock 正交——
|
||||
Lock 限定并发,Surface 限定波及面。机制 OPEN。
|
||||
- `Permission` —— read⊂edit⊂manage 角色格、能力推导、单调性;force-release 在格外。
|
||||
- `PermissionGrant` —— grant(resource×principal×role)与 settings(六 policy 旋钮)结构
|
||||
(ADR-0004);role-capability 与 settings-policy 的组合规则 OPEN。
|
||||
- `Audit` —— 有意从简(内容多为 plumbing,OPEN)。
|
||||
|
||||
标识符见 `Spec.Prelude`。决策出处:ADR-0001..0004。
|
||||
标识符见 `Spec.Prelude`。决策出处:ADR-0001..0004, 0018, 0020。
|
||||
-/
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import Spec.Prelude
|
||||
import Spec.System.Run
|
||||
|
||||
/-!
|
||||
# AgentSurface —— Agent 执行面边界(ADR-0018)
|
||||
|
||||
ADR-0001/0002/0004 覆盖"协作治理"直到 `triggerAgent`:谁能触发一次 run。但触发之后
|
||||
agent 在执行层面能干什么——读哪些文件、跑什么命令——任何 ADR / 散文都未定。ADR-0017
|
||||
落地时采用 Claude Code SDK 的 `bypassPermissions` + 全量 Read/Write/Bash/Glob/Grep,
|
||||
agent 的文件与 shell 面对宿主**无界**:spec 与 ADR 均未钉死边界。本模块补这一层。
|
||||
|
||||
钉死的不变式:agent 在一次 run 内发起的文件操作,其路径必须落在该 run 所属 project
|
||||
的工作区目录内(ADR-0007:工程文件是目录树;此 ADR 固定"agent 操作落在该树内")。
|
||||
逃逸即越权,拒绝。与 `Lock`(ADR-0002)正交:Lock 限定**并发**(谁在改),Surface
|
||||
限定**波及面**(能改到哪)。二者都按 run × project 作用域。
|
||||
|
||||
shell 面的边界(命令的文件效果同样不得逃逸工作区)是同一不变式的推论,但**机制**
|
||||
——路径校验工具包装、OS 级沙箱(bubblewrap/容器)、SDK 权限钩子,或其组合——`OPEN`
|
||||
(ADR-0018)。契约钉死不变式,不钉死机制。
|
||||
-/
|
||||
|
||||
namespace Spec.System
|
||||
|
||||
variable (I : Identifiers) (Path : Type)
|
||||
|
||||
/-- Agent 在一次 run 内发起的文件操作(`PINNED` 关系, ADR-0018)。由某 run 发起、
|
||||
指向某路径;是否越权由下方 `Authorized` 钉死。 -/
|
||||
structure AgentFileOp where
|
||||
/-- 发起操作的 run(授权上下文主体, ADR-0018;与 `Lock` 同作用域 run × project)。 -/
|
||||
run : I.RunId
|
||||
/-- 操作目标路径(`PINNED` 字段, ADR-0018)。 -/
|
||||
path : Path
|
||||
|
||||
/-- 工作区边界良构:run 的文件操作路径必须落在该 run 所属 project 的工作区目录内
|
||||
(`PINNED` 平台核心安全不变式, ADR-0018)。`runWorkspace` 与 `pathWithin` 均由平台提供
|
||||
(表示 `OPEN`——路径如何表示、"在内"如何判定是纯 plumbing,非本层分歧点);本谓词只
|
||||
钉死"操作路径必须以 run 的工作区为根",杜绝 agent 越权读写宿主任意文件。 -/
|
||||
def AgentFileOp.Authorized
|
||||
(op : AgentFileOp I Path)
|
||||
(runWorkspace : I.RunId → Option Path)
|
||||
(pathWithin : Path → Path → Prop) : Prop :=
|
||||
∃ w, runWorkspace op.run = some w ∧ pathWithin op.path w
|
||||
|
||||
end Spec.System
|
||||
@@ -0,0 +1,51 @@
|
||||
import Spec.Prelude
|
||||
|
||||
/-!
|
||||
# Organization —— SaaS tenant root (ADR-0020)
|
||||
|
||||
ADR-0020:Hub 长期按 SaaS 形态演进,`Organization` 是客户侧 tenant root。
|
||||
`Project` 与 `Team` 都必须归属且只归属一个 organization;team 对 project 的授权
|
||||
必须在同一 organization 内。平台运营控制面(platform staff / break-glass / 账单)
|
||||
不复用 project 的 `read/edit/manage` 角色格,而是另一个控制面。
|
||||
|
||||
本模块只钉死会造成实现分歧的不变量:tenant root 存在、project/team 单归属、team grant
|
||||
不得跨 org。用户身份、外部目录 provider 细节、平台控制面角色集合仍为 `OPEN`。
|
||||
-/
|
||||
|
||||
namespace Spec.System
|
||||
|
||||
variable (I : Identifiers)
|
||||
|
||||
/-- 课程项目的租户归属(`PINNED`, ADR-0020):每个 project 必须有一个 organization。 -/
|
||||
structure ProjectTenancy where
|
||||
/-- 被归属的课程项目(`PINNED`, ADR-0020)。 -/
|
||||
project : I.ProjectId
|
||||
/-- project 所属 organization(`PINNED`, ADR-0020)。 -/
|
||||
organization : I.OrganizationId
|
||||
|
||||
/-- Hub team 的租户归属(`PINNED`, ADR-0020):team 是 org-scoped principal。 -/
|
||||
structure TeamTenancy where
|
||||
/-- 被归属的 Hub team(`PINNED`, ADR-0020)。 -/
|
||||
team : I.TeamId
|
||||
/-- team 所属 organization(`PINNED`, ADR-0020)。 -/
|
||||
organization : I.OrganizationId
|
||||
|
||||
/-- Team 对 project 的授权作用域(`PINNED`, ADR-0020):`PermissionGrant` 可以把
|
||||
TEAM principal 授给 PROJECT resource,但该 grant 必须同 org。role 本身仍由
|
||||
`PermissionGrant`/`Permission` 定义,这里仅刻画 tenant well-scopedness。 -/
|
||||
structure TeamProjectGrantScope where
|
||||
/-- 被授权的 project(`PINNED`, ADR-0020)。 -/
|
||||
project : I.ProjectId
|
||||
/-- 获得授权的 team principal(`PINNED`, ADR-0020)。 -/
|
||||
team : I.TeamId
|
||||
|
||||
/-- Team-project grant 是良构的 iff project 与 team 解析到同一 organization
|
||||
(`PINNED`, ADR-0020)。`projectOrg`/`teamOrg` 由平台提供(表示 `OPEN`);本谓词钉死
|
||||
跨 org team grant 必须被拒绝。 -/
|
||||
def TeamProjectGrantScope.WellScoped
|
||||
(grant : TeamProjectGrantScope I)
|
||||
(projectOrg : I.ProjectId → Option I.OrganizationId)
|
||||
(teamOrg : I.TeamId → Option I.OrganizationId) : Prop :=
|
||||
∃ o, projectOrg grant.project = some o ∧ teamOrg grant.team = some o
|
||||
|
||||
end Spec.System
|
||||
@@ -12,7 +12,8 @@ ADR-0004 的"飞书云文档式"权限:**grant**(`resource × principal × role`
|
||||
principal 子类型学(user/chat/department/…)与各 policy 值域均为 `OPEN`(ADR 未定,非本
|
||||
层分歧点)。**role-capability 与 settings-policy 如何组合成最终授权决策**亦 `OPEN`——
|
||||
ADR-0004 把二者列为分离的闸,但未明文规定组合规则(AND?settings 能否超出 role?),实现
|
||||
须 surface,不得默认。
|
||||
须 surface,不得默认。ADR-0020 另行钉死 TEAM principal 授权 PROJECT resource 时必须
|
||||
同 organization;该 tenant well-scopedness 见 `Spec.System.Organization`。
|
||||
-/
|
||||
|
||||
namespace Spec.System
|
||||
|
||||
Reference in New Issue
Block a user