Compare commits

..

11 Commits

Author SHA1 Message Date
hongjr03 dc2d1c2f9e Merge pull request 'chore: remove Lean spec; ADRs are the single source of truth' (#13) from chore/remove-lean-spec into main
Reviewed-on: EduCraft/curriculum-project-hub#13
2026-07-20 17:21:14 +08:00
hongjr03 3f9b60f692 chore: remove Lean spec; ADRs are the single source of truth
The spec/ Lean semantic master had no conformance gate, no codegen, and
no CI tie to implementations — alignment was carried entirely by human
review, the same mechanism that carries the ADRs. In practice the ADRs
plus greppable code comments were already the load-bearing artifacts,
so spec/ was the most expensive kind of stale documentation.

- delete spec/ and the spec-check CI workflow
- README: constitution rewritten around ADRs as decision truth
- AGENTS.md/CLAUDE.md: discipline re-anchored (new decisions -> new ADR,
  never rewrite ADR history; supersede instead)
- code comments: re-anchor 'Mirrors Spec.X' invariants to ADR numbers
  (cph-diag, cph-check, cph-model, hub runner/capacity/org, prisma)
- leave ADR bodies and .scratch audit snapshots untouched (history);
  fix live references in open readiness tickets
2026-07-20 09:07:26 +00:00
hongjr03 4234ba4c96 Merge pull request 'fix(hub): mark bootstrap Inbox as SYSTEM_INBOX' (#12) from fix/hub-bootstrap-system-inbox into main 2026-07-19 20:16:09 +08:00
hongjr03 15f9443d3d fix(hub): mark bootstrap Inbox as SYSTEM_INBOX
Alpha silo bootstrap created the root Inbox without kind=SYSTEM_INBOX, so
Feishu card project creation tried to insert a second Inbox and hit the
sibling-name unique index. Tag the bootstrap folder correctly and promote
any legacy root Inbox on ensure.
2026-07-19 20:08:44 +08:00
hongjr03 7f09fb1f13 feat(hub): drop redundant /admin/org/:slug path + release v0.0.35 (#11)
Silo hostname already carries tenancy. Admin SPA routes become /admin/..., legacy bookmarks redirect, login lands on /admin.

Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-19 01:36:10 +08:00
hongjr03 eb0be43eac feat(hub): usage fact breakdown API + admin usage/session UI + release v0.0.34 (#10)
Expose UsageFact kind/capability rollups on org and project usage reports, and add admin pages that separate model tokens from external-capability meters.

Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-19 01:19:59 +08:00
hongjr03 ce18740870 feat(hub): expose pdf_to_md_bundle as MCP tool to agent + skill (ADR-0027) (#9)
Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-18 21:43:00 +08:00
hongjr03 ef96f8d33d feat(hub): capability connection admin API + UI + release v0.0.32 (#8)
Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-18 17:57:04 +08:00
hongjr03 5e10419fc8 fix(hub): docmind client stream upload + correct API response parsing (#7)
Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-18 17:26:11 +08:00
hongjr03 64b3d1fc64 feat(hub): switch capability provider to Aliyun Doc Mind (ADR-0027) (#6)
Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-18 16:42:42 +08:00
hongjr03 b673dd1fe9 feat(hub): external capability registry for PDF/ASR transforms (ADR-0027) (#5)
Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-18 15:55:02 +08:00
114 changed files with 2981 additions and 2516 deletions
+5 -5
View File
@@ -1,12 +1,12 @@
name: checker check
# Builds and lints the Rust implementation crates under crates/ (the rule-based
# checker that "stands in Lean's position" at product runtime).
# lesson checker).
#
# Like spec-check, this is an INTERNAL gate on the implementation's own health
# (does it build, pass its tests, satisfy clippy + rustfmt?). It is NOT a
# spec-to-implementation conformance gate — implementations align to the Lean
# contract by human review, not by CI. See the repo README.
# This is an INTERNAL gate on the implementation's own health
# (does it build, pass its tests, satisfy clippy + rustfmt?). There is no
# decision-to-implementation conformance gate — implementations align to the
# ADRs by human review, not by CI. See the repo README.
on:
push:
+2 -2
View File
@@ -1,8 +1,8 @@
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
# The Hub is the Feishu-group collaboration + agent runtime half.
# This is an INTERNAL gate on the Hub's own
# health, like checker-check is for the Rust half.
on:
-20
View File
@@ -1,20 +0,0 @@
name: spec check
# Builds the Lean semantic master spec under spec/.
# This is an INTERNAL well-formedness gate (does the contract type-check?),
# NOT a spec-to-implementation conformance gate — implementations align to the
# contract by human review, not by CI. See repo README.
on:
push:
pull_request:
workflow_dispatch:
jobs:
spec-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: leanprover/lean-action@v1
with:
lake-package-directory: spec
-4
View File
@@ -1,7 +1,3 @@
# Lean / Lake build artifacts (spec/ has its own .gitignore too)
.lake/
**/.lake/
# Rust / Cargo build artifacts (repo-wide cargo workspace at root)
/target
**/*.pdf
@@ -29,7 +29,7 @@ workload brakes.
The full current-state inventory, accepted behavior, and release evidence are
recorded in [Initial abuse and capacity controls](../assets/initial-abuse-capacity-controls.md),
with the durable decision in ADR-0022 and `Spec.System.Capacity`. Numerical
with the durable decision in ADR-0022. Numerical
ceilings remain open until production-like calibration.
The implementation frontier is:
@@ -7,6 +7,6 @@ Blocked by: 01, 02, 03, 04, 05, 06, 07, 09, 10, 11, 12, 13, 14, 15, 16, 17, 18,
## Question
After the readiness investigations and resulting fixes are resolved, can one
repeatable release procedure prove build/test/spec health, deploy a clean
repeatable release procedure prove build/test health, deploy a clean
production-like environment, exercise critical tenant and agent journeys,
verify observability and recovery, and either roll forward or roll back safely?
@@ -8,7 +8,7 @@ Blocked by: 04
Separate or unify run-bound audit entries, pre-run security/permission events,
structured messages, and operational recovery events without weakening
`Spec.System.Audit`'s pinned AuditEntry-to-run relation. Decide durability,
the pinned AuditEntry-to-run relation. Decide durability,
failure, retention, and query semantics; then enforce referential integrity and
observable/recoverable writes instead of silently swallowing lost evidence.
Do not merge these customer Project/Run records with ADR-0023's already-decided
@@ -40,9 +40,7 @@ an off-host recovery key, an incident and reason, and issues only an expiring
Emergency Platform Grant.
The complete accepted decision and implementation divergences are in
[ADR-0023](../../../docs/adr/0023-platform-administrator-identity-and-audit.md).
The pinned semantic invariants are in
[`Spec.System.PlatformAdministration`](../../../spec/Spec/System/PlatformAdministration.lean),
[ADR-0023](../../../docs/adr/0023-platform-administrator-identity-and-audit.md),
and the canonical terms are in [`CONTEXT.md`](../../../CONTEXT.md).
Exact numeric session/invitation/step-up limits and browser mechanics remain
+12 -16
View File
@@ -1,29 +1,25 @@
# AGENTS.md —— agent 操作手册(全 repo)
本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
本 repo 是 monorepo。先读根 `README.md` 的"宪法"4 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
## 这个 repo 是什么
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
- `docs/adr/` 是系统级决策的唯一权威来源;`CONTEXT.md` 是平台语言词汇表;代码注释把关键不变量锚到 ADR 编号,可 grep
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020)。
- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 /
`Spec.System.ProjectWorkspace`)。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021)。
- 每个 org 自选 BYOK 或平台托管 model provider connection;平台托管也必须是该 org
独享的 key/base URL,不得让无关 org 共用 process-global provider key(见 ADR-0021 /
`Spec.System.Organization`)。
独享的 key/base URL,不得让无关 org 共用 process-global provider key(见 ADR-0021)。
- Feishu/provider secret 使用本地版本化 master-key keyring 的信封加密;生产由 systemd
credential 注入,运行时只允许显式 org/project scope 的 fail-closed resolver,不得回退
process-global credential;Agent child 只接收 run-scoped loopback proxy capability,
不接收 org provider credential(见 ADR-0024 / `Spec.System.Organization`)。
不接收 org provider credential(见 ADR-0024)。
- 生产容量按不可突破的 platform ceiling 与 org 可下调 policy 分层;有效限制取两者较低值。
Agent admission 必须持久、有界、跨 org 公平且显式背压(见 ADR-0022 /
`Spec.System.Capacity`)。
Agent admission 必须持久、有界、跨 org 公平且显式背压(见 ADR-0022)。
- 平台管理员只通过独立的 platform-owned 飞书应用与可撤销 Platform Session 认证,不复用
客户 `User`/org membership;平台写操作与 append-only audit 同事务,break-glass 只走
双因子的离线恢复流程(见 ADR-0023 / `Spec.System.PlatformAdministration`)。
双因子的离线恢复流程(见 ADR-0023)。
- 受控 alpha 暂采用一 Organization 一具名 systemd Silo:独立 database role/database、
service identity、workspace、keyring 与 Feishu/provider connection;进程必须由
`HUB_SILO_ORGANIZATION_ID` fail-closed 绑定唯一 org,平台后台不开放。共享 SaaS
@@ -44,12 +40,12 @@
## 纪律
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。契约里 prose doc 注释是语义的唯一权威来源;契约没写的,就是没定的。
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。ADR 与 `CONTEXT.md` 是语义的唯一权威来源;没写的,就是没定的。
2. **凡契约未写明者,不得假设。** 遇到标了 `OPEN` 的地方,或契约根本没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
2. **凡 ADR 未写明者,不得假设。** 遇到没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
3. **改 `spec/` 必须保持其 `lake build` 通过。**`spec/` 目录下跑 `lake build`。新增声明必须带 `/-- … -/` doc 注释和恰当标签(`PINNED` / `OPEN` / `ADR-NNNN`)。规范见 `spec/README.md`。不准用 `sorry` 把 build 糊绿
3. **新语义决策进 ADR。** 跨部件的语义分歧点按编号顺延新增 `docs/adr/NNNN-*.md`;代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。已有 ADR 正文不改写历史——推翻旧决策就写新 ADR 标记 supersede
4. **实现向契约对齐;偏离必须 surface。** 没有 CI gate 替你把关 spec↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与契约不一致时,报告它,不要默默让其中一边将就另一边。
4. **实现向 ADR 对齐;偏离必须 surface。** 没有 CI gate 替你把关 ADR↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与决策不一致时,报告它,不要默默让其中一边将就另一边。
5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。
+8 -10
View File
@@ -1,25 +1,23 @@
# CLAUDE.md —— agent 操作手册(全 repo)
本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
本 repo 是 monorepo。先读根 `README.md` 的"宪法"4 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
## 这个 repo 是什么
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
- `docs/adr/` 是系统级决策的唯一权威来源;`CONTEXT.md` 是平台语言词汇表;代码注释把关键不变量锚到 ADR 编号,可 grep
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020)。
- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 /
`Spec.System.ProjectWorkspace`)。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021)。
## 纪律
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。契约里 prose doc 注释是语义的唯一权威来源;契约没写的,就是没定的。
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。ADR 与 `CONTEXT.md` 是语义的唯一权威来源;没写的,就是没定的。
2. **凡契约未写明者,不得假设。** 遇到标了 `OPEN` 的地方,或契约根本没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
2. **凡 ADR 未写明者,不得假设。** 遇到没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
3. **改 `spec/` 必须保持其 `lake build` 通过。**`spec/` 目录下跑 `lake build`。新增声明必须带 `/-- … -/` doc 注释和恰当标签(`PINNED` / `OPEN` / `ADR-NNNN`)。规范见 `spec/README.md`。不准用 `sorry` 把 build 糊绿
3. **新语义决策进 ADR。** 跨部件的语义分歧点按编号顺延新增 `docs/adr/NNNN-*.md`;代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。已有 ADR 正文不改写历史——推翻旧决策就写新 ADR 标记 supersede
4. **实现向契约对齐;偏离必须 surface。** 没有 CI gate 替你把关 spec↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与契约不一致时,报告它,不要默默让其中一边将就另一边。
4. **实现向 ADR 对齐;偏离必须 surface。** 没有 CI gate 替你把关 ADR↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与决策不一致时,报告它,不要默默让其中一边将就另一边。
5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。
+16 -23
View File
@@ -2,7 +2,7 @@
教研生产的数字化解决方案。核心思路:课程像 DAW / 剪辑软件那样有一个**结构化的工程文件**;coding agent 协助编辑它;一个 rule-based checker(类编译器)校验其合法性并给出 helpful fix hint。目标是把教研从一次性的文档,沉淀成**可累积、可校验、可复用的资产**。
这是一个 **monorepo**。它的组织方式本身就表达了一条原则:**`spec/` 是上游的语义母本,其余部件是向它对齐的实现。**
这是一个 **monorepo**。它的组织方式本身就表达了一条原则:**`docs/adr/` 是系统级决策的唯一权威来源,代码注释把关键不变量锚到 ADR 编号,可 grep。**
## 安装 `cph` 命令行
@@ -33,47 +33,40 @@ cph completions zsh > ~/.zfunc/_cph # 或 bash/fish/powershell/elvish
```
README.md ← 本文件:总览 + 宪法(下面 5 条)
CLAUDE.md ← 全局 agent 操作手册(管整个 repo)
docs/adr/ ← 系统级架构决策记录(跨部件,被 spec 契约引用)
spec/ ← Lean 语义母本(自包含的 Lean 工程)。见 spec/README.md
docs/adr/ ← 系统级架构决策记录(跨部件,决策的唯一权威来源)
CONTEXT.md ← 平台语言词汇表(术语与禁用说法)
Cargo.toml ← 仓库级 cargo workspace(实现部件共用,便于跨部件复用 crate)
crates/ ← 实现:rule-based checker(向 spec 对齐)。见 crates/README.md
crates/ ← 实现:rule-based checker(语义由 ADR 锚定)。见 crates/README.md
cph-diag / cph-model / cph-schema / cph-typst ← 可复用基础(模型/校验/typst 引擎)
cph-check / cph-cli ← checker 本体 + `cph` 命令行
render/ ← typst 渲染包 cph-render(母本的渲染后端之一,ADR-0005)
render/ ← typst 渲染包 cph-render(checker 的渲染后端,ADR-0005)
examples/ ← 样例工程文件(如 TH-141),流水线的真实输入
hub/ ← SaaS Hub:飞书协作、org 管理、agent runtime 与生产部署
(exporter/ …) ← 将来的其他部件,平级于 spec/
(exporter/ …) ← 将来的其他部件,平级于 crates/
```
`spec/` 与实现部件**物理分离、平级共存**:谁是上游、谁向谁对齐,一眼可见。
实现部件共用一个仓库根的 cargo workspace,使基础 crate(模型、typst 引擎)能被
未来部件(如 exporter)复用,而非各自重造。
## 宪法
5 条是 `spec/` 这份语义母本的定位与约束,是本仓库一切工作的前提。
4 条是本仓库的协作约定,是一切工作的前提。
1. **角色 —— Lean 是研发侧的上游参照**
`spec/` 用 Lean 编写,是开发者(领域专家)与 coding agent **共用**的 spec 工具,用来沉淀产品各部件的**语义**。它**不进入产品运行时**——产品里"站在 Lean 这个位置"的那个 checker 用什么技术实现,尚未决定;但那个东西的语义,先在 `spec/` 里固定下来
1. **角色 —— ADR 是决策真相**
跨部件的语义决策只记录在 `docs/adr/`,一份决策一份 ADR,编号顺延、正文不改写历史。代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。没有第二份权威文档
2. **对齐机制 —— Lean 只做上游参照**
不做 extract / codegen,不派生 conformance test,CI 里**没有** spec→实现的 gate。实现对齐 spec,由"开发者 review + agent 巡逻 diff"这个人肉环节承载。
(CI 里的 `spec check` 只验 spec **自身**能否 type-check,即契约内部良构,不是 spec↔实现的对齐检查。)
2. **对齐机制 —— 人肉承载,无机器兜底**
CI 只验各部件自身良构(build / test / clippy),**没有**决策↔实现的一致性 gate。实现对齐 ADR,由"开发者 review + agent 巡逻 diff"这个人肉环节承载。发现漂移,报告它,不要默默让其中一边将就另一边。
3. **资产性 —— 由 review 纪律承载,无机器兜底**
这份仓库给你的是"精确、自洽、机器验内部良构的语义共识",**不是**"实现正确性保证"。spec 与实现之间那道缝,是我们自愿用人来守的——清醒地守,它就是资产;放任实现漂移而不回头同步,它就退化成最贵的过期文档
3. **形态 —— 自包含**
凡 ADR 未明文规定的,开发者与 agent 双方都不该假设;遇到没覆盖的地方,**显式 surface** 出来让开发者决定
4. **形态 —— 它是人机共识的契约**
契约必须**自包含**:凡契约未明文规定的,开发者与 agent 双方都不该假设。这比"文档"严格——type checker 会逼这份契约在结构上无洞
5. **深度判据 —— 只收录分歧点。**
一条语义该不该写进 Lean,取决于一句话:**"不写明,开发者与 agent 会不会各自做出不同假设?"** 会 → 进契约;显然的东西 / 纯 plumbing / 普通 CRUD 字段 → 不进(写进去只稀释信噪比、增加维护面)。
深度上限不是 Lean 的表达力,而是**你愿意在每次实现变更时手动回头同步的量**——写得比你能维护的更深,多出来的部分会率先过期、反过来误导实现。
4. **深度判据 —— 只收录分歧点**
一条语义该不该写进 ADR,取决于一句话:**"不写明,开发者与 agent 会不会各自做出不同假设?"** 会 → 进 ADR;显然的东西 / 纯 plumbing / 普通 CRUD 字段 → 不进(写进去只稀释信噪比、增加维护面)
深度上限是**你愿意在每次实现变更时手动回头同步的量**——写得比你能维护的更深,多出来的部分会率先过期、反过来误导实现。
## CI
`.gitea/workflows/spec-check.yml` 在每次 push / PR 时于 `spec/` 下跑 `lake build`,确保契约始终 type-check 通过(从第一天起就是"绿"的)。这是良构 gate,见宪法第 2 条。
Rust checker 的本地与 CI 工具链由根 `rust-toolchain.toml` 固定;`.gitea/workflows/checker-check.yml`
必须安装同一精确版本并执行 `cargo fmt --all --check`、Clippy `-D warnings` 与 workspace
全测试。升级 Rust 时这两处必须在同一提交更新并通过完整 checker gate。
+3 -2
View File
@@ -1,7 +1,8 @@
# crates/
These crates implement the rule-based lesson checker that aligns to the
semantic master in `spec/`: it reads an engineering-file (one lesson, ADR-0005)
These crates implement the rule-based lesson checker whose semantics are
pinned by the ADRs in `docs/adr/`: it reads an engineering-file (one lesson,
ADR-0005)
laid out per ADR-0008 (declarative `manifest.toml` + per-element
`element.toml`), validates structure and content, and emits diagnostics.
`cph-diag` (the shared diagnostic vocabulary), `cph-model` (the ADR-0008 loader),
+9 -11
View File
@@ -19,13 +19,11 @@ const DEFAULT_TARGET: &str = "student";
/// Severity of the render-coverage ("element ignored under a target") diagnostic.
///
/// **PINNED to `warning` by the contract.** Mirrors the Lean master's
/// `Spec.Courseware.renderIgnoredSeverity : Severity := .warning`
/// (`spec/Spec/Courseware/Check/Diagnostic.lean`), itself citing ADR-0005: when a
/// **PINNED to `warning` by ADR-0005:** when a
/// `(kind, target)` pair has no render rule the checker reports that the element
/// is ignored under that target and **does not block the export**. Naming the
/// severity as a const makes "it is a warning, not an error" a greppable,
/// alignable fact rather than an inline literal.
/// severity as a const makes "it is a warning, not an error" a greppable
/// fact rather than an inline literal.
const RENDER_IGNORED_SEVERITY: Severity = Severity::Warning;
/// The result of running [`check`] (or the check phases of [`build`]).
@@ -57,12 +55,12 @@ impl CheckReport {
/// Whether any collected diagnostic is `Error`-severity.
///
/// **Legality decision (spec alignment).** `!has_errors()` is the
/// implementation of `Spec.Courseware.Legal` (`spec/Spec/Courseware/Check/Diagnostic.lean`):
/// a lesson is *legal* iff its diagnostics contain no error-level diagnostic
/// (warnings are non-blocking — see `Severity` / ADR-0010). There is no CI
/// gate enforcing this alignment (repo constitution); it is kept greppable
/// here so a reviewer can tie the orchestrator's gate to the Lean master.
/// **Legality decision (ADR-0010).** `!has_errors()` decides lesson
/// legality: a lesson is *legal* iff its diagnostics contain no error-level
/// diagnostic (warnings are non-blocking — see `Severity`). There is no CI
/// gate enforcing ADR↔implementation alignment (repo constitution); it is
/// kept greppable here so a reviewer can tie the orchestrator's gate to
/// the ADR.
pub fn has_errors(&self) -> bool {
self.diagnostics
.iter()
+10 -15
View File
@@ -2,7 +2,7 @@
//!
//! Every other crate in the workspace depends on these types to report
//! problems. The vocabulary is intentionally small and stable: a [`Severity`]
//! (mirroring the Lean master), a closed set of machine-stable [`DiagCode`]s, an
//! (two-valued, ADR-0010), a closed set of machine-stable [`DiagCode`]s, an
//! optional [`SourceSpan`] pointing back at the offending source, and a
//! [`Diagnostic`] tying them together with a human message and a fix hint.
//!
@@ -17,32 +17,27 @@ use serde::Serialize;
/// Severity of a diagnostic.
///
/// **Mirrors `Spec.Courseware.Diagnostic.Severity`** in the Lean semantic
/// master (`spec/Spec/Courseware/Check/Diagnostic.lean`), whose definition is
/// exactly:
/// **Pinned by ADR-0005 / ADR-0010: exactly two values.**
///
/// ```text
/// inductive Severity where
/// | warning
/// | error
/// warning | error
/// ```
///
/// This two-valued shape is a **contract decision**, not an accident: the Lean
/// module pins `Severity` to exactly `warning | error` and states the finer
/// levels (`info` / `hint` / `note`) are deliberately undecided. We therefore
/// This two-valued shape is a **contract decision**, not an accident: the
/// finer levels (`info` / `hint` / `note`) are deliberately undecided, so we
/// do **not** add an info/note level here. `error` blocks (the artifact is
/// invalid); `warning` does not block (the artifact still exports, but with
/// loss / an ignored element — e.g. ADR-0005's "missing render ⇒ warning").
///
/// There is no CI gate enforcing this alignment (see the repo constitution);
/// it is maintained by review, which is why this correspondence is documented
/// here rather than only in the spec.
/// There is no CI gate enforcing ADR↔implementation alignment (see the repo
/// constitution); it is maintained by review, which is why the decision is
/// documented here rather than only in the ADR.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum Severity {
/// Non-blocking: the artifact still exports, but is lossy / has an ignored
/// element. Mirrors Lean `Severity.warning`.
/// element. ADR-0010 `warning`.
Warning,
/// Blocking: the artifact is invalid. Mirrors Lean `Severity.error`.
/// Blocking: the artifact is invalid. ADR-0010 `error`.
Error,
}
+25 -38
View File
@@ -3,9 +3,7 @@
//! This crate is the **loader**, not the full checker. It reads
//! `<root>/manifest.toml` (project / info / ordered `[[parts]]` / declared
//! `[targets.*]`) and each part's `<root>/<path>/element.toml`, and produces an
//! ordered [`Lesson`] — mirroring the Lean master's `Lesson = List (Element P)`
//! (`spec/Spec/Courseware/Model/Lesson.lean`), where the order of `parts` carries
//! teaching semantics.
//! ordered [`Lesson`], where the order of `parts` carries teaching semantics.
//!
//! Scope boundaries (deliberately staying in lane):
//! - It validates **structure** only: manifest shape, element.toml shape, and
@@ -23,7 +21,7 @@ use serde::{Deserialize, Serialize};
/// An ordered, in-memory lesson loaded from an engineering file.
///
/// Mirrors the Lean master's `Lesson = List (Element P)`: `parts` is an ordered
/// `parts` is an ordered
/// `Vec`, and that order is the lesson's order (ADR-0008 §"the lesson manifest
/// is declarative" — the `[[parts]]` array order is the single source of truth).
#[derive(Debug, Clone, PartialEq, Serialize)]
@@ -86,9 +84,8 @@ pub struct TargetConfig {
/// with template `exports/<name>.typ` when no `[[steps]]` are given.
pub steps: Vec<Step>,
/// The **render-coverage declaration**: which element kinds this target
/// renders. Realizes `Spec.Courseware.TargetSpec.covers : KindId → Prop`
/// (`spec/Spec/Courseware/Export/Render.lean`) and ADR-0011's "render
/// coverage is a declaration, not a payload": the contract keeps *which
/// renders. Realizes ADR-0011's "render
/// coverage is a declaration, not a payload": the declaration keeps *which
/// kinds a target renders* (used by the `renderIgnored` seed diagnostic),
/// while the rendering "how" lives in the template/steps.
///
@@ -102,13 +99,10 @@ pub struct TargetConfig {
/// The artifact an export target produces (ADR-0009/0011).
///
/// **Mirrors `Spec.Courseware.Artifact`** in the Lean semantic master
/// (`spec/Spec/Courseware/Export/Artifact.lean`), whose definition is exactly:
/// **Pinned by ADR-0011** as an ADT with fields:
///
/// ```text
/// inductive Artifact where
/// | singleFile (filepath : String)
/// | fileTree (root : String) (outputs : String)
/// Artifact = singleFile (filepath) | fileTree (root, outputs)
/// ```
///
/// ADR-0011 pinned the artifact as an ADT **with fields**: "what the product
@@ -120,20 +114,20 @@ pub struct TargetConfig {
/// `"single-file"` → [`Artifact::SingleFile`], `"file-tree"` →
/// [`Artifact::FileTree`].
///
/// As with `cph-diag`'s `Severity`, there is no CI gate enforcing this
/// alignment (see the repo constitution) — it is maintained by review, which is
/// why the correspondence is documented here.
/// As with `cph-diag`'s `Severity`, there is no CI gate enforcing ADR↔
/// implementation alignment (see the repo constitution) — it is maintained by
/// review, which is why the decision is documented here.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum Artifact {
/// One bundled document landing at `filepath` (relative to the engineering
/// root). Mirrors Lean `Artifact.singleFile`. The default artifact shape.
/// root). ADR-0011 `singleFile`. The default artifact shape.
SingleFile {
/// Where the single product is written (relative to the engineering
/// root), e.g. `build/student.pdf`.
filepath: PathBuf,
},
/// A set of files under `root` matching the `outputs` glob. Mirrors Lean
/// `Artifact.fileTree`.
/// A set of files under `root` matching the `outputs` glob. ADR-0011
/// `fileTree`.
FileTree {
/// The output directory (relative to the engineering root).
root: PathBuf,
@@ -156,14 +150,10 @@ impl Artifact {
/// One typed build step (ADR-0011).
///
/// **Mirrors `Spec.Courseware.Step`** in the Lean semantic master
/// (`spec/Spec/Courseware/Export/Render.lean`), whose definition is exactly:
/// **Pinned by ADR-0011** as an ADT:
///
/// ```text
/// inductive Step where
/// | typstCompile (template : String)
/// | shell (run : String)
/// | assembleMarkdown (field : String)
/// Step = typstCompile (template) | shell (run) | assembleMarkdown (field)
/// ```
///
/// A step is a *typed* operation (extensible): `TypstCompile` compiles a
@@ -183,20 +173,20 @@ impl Artifact {
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum Step {
/// Compile a template file (relative to the engineering root) into the
/// artifact; the framework injects the manifest. Mirrors Lean
/// `Step.typstCompile`.
/// artifact; the framework injects the manifest. ADR-0011
/// `typstCompile`.
TypstCompile {
/// The template file to compile as main, e.g. `exports/student.typ`.
template: PathBuf,
},
/// Run a shell command — the escape hatch. Mirrors Lean `Step.shell`.
/// Run a shell command — the escape hatch. ADR-0011 `shell`.
Shell {
/// The command line to run.
run: String,
},
/// Assemble a single-file markdown deliverable by concatenating each
/// element's `field` markdown content file in `[[parts]]` order. Mirrors
/// Lean `Step.assembleMarkdown` (ADR-0015). Not a typst build — the
/// element's `field` markdown content file in `[[parts]]` order. ADR-0011
/// `assembleMarkdown` (ADR-0015). Not a typst build — the
/// framework owns the read/concatenate/write itself.
AssembleMarkdown {
/// The per-element markdown content field to assemble (e.g. `slides`,
@@ -226,12 +216,11 @@ pub struct Project {
/// `[info]` table (passed through to render targets verbatim).
///
/// **Mirrors `Spec.Courseware.Info`** in the Lean semantic master
/// (`spec/Spec/Courseware/Model/Info.lean`): the *canonical* model whose
/// The *canonical* model whose
/// `authors` is always a list. The authoring-surface form (string-or-array
/// `author`) is the separate [`RawInfo`] / [`RawAuthor`], normalized into this
/// at the load boundary — mirroring the Lean `RawInfo` / `RawAuthor` split. No
/// CI gate enforces this alignment (repo constitution); it is kept greppable.
/// at the load boundary. No
/// CI gate enforces ADR↔implementation alignment (repo constitution); it is kept greppable.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Info {
/// Lesson title.
@@ -240,7 +229,6 @@ pub struct Info {
/// so this is a list, not a single name. Empty when `[info]` declares no
/// `author`. The on-disk `author` accepts either a bare string (one author)
/// or an array of strings (see [`RawAuthor`]); both load into this `Vec`.
/// Mirrors Lean `Info.authors : List String`.
pub authors: Vec<String>,
}
@@ -290,8 +278,7 @@ struct RawProject {
name: String,
}
/// The authoring-surface `[info]` (mirrors Lean `RawInfo` in
/// `spec/Spec/Courseware/Model/Info.lean`): the raw form that exists for
/// The authoring-surface `[info]`: the raw form that exists for
/// fill-in convenience, normalized into the canonical [`Info`] at the load
/// boundary. Not the form the rest of the model traffics in.
#[derive(Debug, Deserialize)]
@@ -301,7 +288,7 @@ struct RawInfo {
}
/// On-disk `author`: either a single name (`author = "…"`) or a list
/// (`author = ["…", "…"]`). Mirrors Lean `RawAuthor`: a fill-in convenience whose
/// (`author = ["…", "…"]`). A fill-in convenience whose
/// string-or-array union lives **only** at the load boundary — [`RawAuthor::into_vec`]
/// folds it into the canonical [`Info::authors`] `Vec`, after which it never appears.
#[derive(Debug, Deserialize)]
@@ -312,7 +299,7 @@ enum RawAuthor {
}
impl RawAuthor {
/// Flatten to the ordered author list (Lean `RawAuthor.normalize`): a single
/// Flatten to the ordered author list: a single
/// name becomes a one-element list; a list passes through verbatim.
fn into_vec(self) -> Vec<String> {
match self {
+105
View File
@@ -168,6 +168,16 @@ export interface FeishuApplicationConnection {
updatedAt: string;
}
export interface CapabilityConnection {
id: string;
capabilityId: string;
status: 'DRAFT' | 'ACTIVE' | 'DISABLED';
activeVersion: number | null;
keyId: string | null;
createdAt: string;
updatedAt: string;
}
export interface UsageTotals {
runCount: number;
runsWithCost: number;
@@ -183,13 +193,81 @@ export interface ProjectUsageRow extends UsageTotals {
folderId: string | null;
}
/** Ledger slice from UsageFact (ADR-0026): separates model tokens vs external meters. */
export interface UsageBreakdownRow {
kind: string;
provider: string;
model: string | null;
capabilityId: string | null;
unit: string | null;
factCount: number;
factsWithCost: number;
factsWithoutCost: number;
inputTokens: number;
outputTokens: number;
quantity: number | null;
costUsd: number | null;
}
export interface UsageReport {
from: string | null;
to: string | null;
projects: ProjectUsageRow[];
totals: UsageTotals;
breakdown: UsageBreakdownRow[];
}
export interface ProjectUsageReport extends ProjectUsageRow {
from: string | null;
to: string | null;
breakdown: UsageBreakdownRow[];
}
export interface UsageFactRow {
id: string;
occurredAt: string;
kind: string;
provider: string;
model: string | null;
inputTokens: number | null;
outputTokens: number | null;
quantity: number | null;
unit: string | null;
costUsd: number | null;
costSource: string;
capabilityId: string | null;
correlationId: string | null;
}
export interface SessionRunRow {
id: string;
status: string;
model: string;
provider: string;
inputTokens: number | null;
outputTokens: number | null;
costUsd: number | null;
costSource: string | null;
startedAt: string;
finishedAt: string | null;
error: string | null;
usageFacts: UsageFactRow[];
}
export interface SessionDetail {
id: string;
provider: string;
roleId: string;
model: string;
title: string | null;
createdAt: string;
updatedAt: string;
archivedAt: string | null;
project: { id: string; name: string };
runs: SessionRunRow[];
}
export type CapacityDimension =
| 'requestRate'
| 'requestBodySize'
@@ -343,6 +421,8 @@ export const api = {
get(`${orgBase(slug)}/projects/${projectId}/sessions${limit !== undefined ? `?limit=${limit}` : ''}`) as Promise<{
sessions: SessionSummary[];
}>,
session: (slug: string, sessionId: string) =>
get(`${orgBase(slug)}/sessions/${encodeURIComponent(sessionId)}`) as Promise<SessionDetail>,
usage: (slug: string, params?: { from?: string; to?: string; folderId?: string }) => {
const q = new URLSearchParams();
if (params?.from) q.set('from', params.from);
@@ -351,6 +431,16 @@ export const api = {
const qs = q.toString();
return get(`${orgBase(slug)}/usage${qs ? `?${qs}` : ''}`) as Promise<UsageReport>;
},
projectUsage: (slug: string, projectId: string, params?: { from?: string; to?: string }) => {
const q = new URLSearchParams();
if (params?.from) q.set('from', params.from);
if (params?.to) q.set('to', params.to);
const qs = q.toString();
return get(
`${orgBase(slug)}/projects/${encodeURIComponent(projectId)}/usage${qs ? `?${qs}` : ''}`,
) as Promise<ProjectUsageReport>;
},
providerConnections: (slug: string) =>
get(`${orgBase(slug)}/provider-connections`) as Promise<{ connections: ProviderConnectionRow[] }>,
@@ -381,6 +471,21 @@ export const api = {
disableFeishuApplication: (slug: string) =>
del(`${orgBase(slug)}/feishu-application-connection`) as Promise<FeishuApplicationConnection>,
capabilityConnections: (slug: string) =>
get(`${orgBase(slug)}/capability-connections`) as Promise<{ connections: CapabilityConnection[] }>,
capabilityConnection: (slug: string, capabilityId: string) =>
get(`${orgBase(slug)}/capability-connections/${encodeURIComponent(capabilityId)}`) as Promise<{
connection: CapabilityConnection | null;
}>,
rotateCapabilityConnection: (
slug: string,
capabilityId: string,
body: { accessKeyId: string; accessKeySecret: string; endpoint: string },
) =>
put(`${orgBase(slug)}/capability-connections/${encodeURIComponent(capabilityId)}`, body) as Promise<CapabilityConnection>,
disableCapabilityConnection: (slug: string, capabilityId: string) =>
del(`${orgBase(slug)}/capability-connections/${encodeURIComponent(capabilityId)}`) as Promise<CapabilityConnection>,
capacityPolicy: (slug: string) => get(`${orgBase(slug)}/capacity-policy`) as Promise<CapacityPolicyView>,
setCapacityPolicy: (slug: string, body: { limits: Partial<Record<CapacityDimension, number | null>> }) =>
put(`${orgBase(slug)}/capacity-policy`, body) as Promise<CapacityPolicyView>,
@@ -33,7 +33,7 @@
<div class="space-y-0.5">
{#each childProjects as p (p.id)}
<a
href={`/admin/org/${slug}/projects/${p.id}`}
href={`/admin/projects/${p.id}`}
class="flex items-center gap-2.5 px-3 py-2.5 text-sm transition hover:bg-surface-100"
>
<span class="flex h-7 w-7 items-center justify-center border border-primary-200 bg-primary-50 text-primary-700">
+46
View File
@@ -29,6 +29,52 @@ export function fmtNum(n: number): string {
return n.toLocaleString();
}
const USAGE_KIND_LABELS: Record<string, string> = {
model_completion: '模型完成',
external_capability: '外部能力',
tool_proxy: '工具代理',
};
const METER_UNIT_LABELS: Record<string, string> = {
pages: '页',
audio_seconds: '音频秒',
invocations: '次调用',
};
export function usageKindLabel(kind: string): string {
return USAGE_KIND_LABELS[kind] ?? kind;
}
export function meterUnitLabel(unit: string | null | undefined): string {
if (!unit) return '—';
return METER_UNIT_LABELS[unit] ?? unit;
}
export function fmtQuantity(quantity: number | null | undefined, unit: string | null | undefined): string {
if (quantity === null || quantity === undefined) return '—';
const u = meterUnitLabel(unit);
return u === '—' ? fmtNum(quantity) : `${fmtNum(quantity)} ${u}`;
}
export function fmtTokens(input: number | null | undefined, output: number | null | undefined): string {
const hasIn = input !== null && input !== undefined;
const hasOut = output !== null && output !== undefined;
if (!hasIn && !hasOut) return '—';
return `${fmtNum(input ?? 0)} / ${fmtNum(output ?? 0)}`;
}
export function runStatusLabel(status: string): string {
const key = status.toUpperCase();
if (key === 'COMPLETED') return '完成';
if (key === 'FAILED') return '失败';
if (key === 'CANCELED') return '取消';
if (key === 'TIMED_OUT') return '超时';
if (key === 'RUNNING') return '运行中';
if (key === 'QUEUED') return '排队';
return status;
}
export function orgRoleLabel(role: string): string {
const key = role.toUpperCase() as OrgRole;
return ORG_ROLE_LABELS[key] ?? role;
+40
View File
@@ -0,0 +1,40 @@
import type { MeResponse, OrgMembership } from './api';
/** Alpha Silo host prefix: <slug>.educraft[.dev].… */
export function hostOrgSlug(hostname: string = typeof window !== 'undefined' ? window.location.hostname : ''): string | null {
const host = hostname.toLowerCase();
const m = host.match(/^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.educraft(?:-dev)?\./);
return m?.[1] ?? null;
}
export function isOrgAdmin(org: OrgMembership | null | undefined): boolean {
if (!org) return false;
const role = String(org.role ?? '').toUpperCase();
return role === 'OWNER' || role === 'ADMIN';
}
/**
* Resolve the tenancy for this browser session.
* Prefers hostname slug (silo), then ?org=, then first admin membership, then first membership.
*/
export function resolveOrg(me: MeResponse | null | undefined, search: string = ''): OrgMembership | null {
if (!me || me.organizations.length === 0) return null;
const host = hostOrgSlug();
if (host) {
const byHost = me.organizations.find((o) => o.slug === host);
if (byHost) return byHost;
}
const q = new URLSearchParams(search).get('org')?.trim();
if (q) {
const byQuery = me.organizations.find((o) => o.slug === q);
if (byQuery) return byQuery;
}
const admin = me.organizations.find((o) => isOrgAdmin(o));
return admin ?? me.organizations[0] ?? null;
}
/** SPA paths no longer embed org slug (subdomain carries tenancy). */
export function adminPath(rest: string = ''): string {
const cleaned = rest.replace(/^\/+/, '');
return cleaned === '' ? '/admin' : `/admin/${cleaned}`;
}
+7 -4
View File
@@ -35,12 +35,9 @@ export async function loadSession(): Promise<void> {
/**
* Resolve org slug for org-scoped Feishu OAuth (`GET /auth/feishu/:orgSlug`).
* Unscoped `/auth/feishu` is disabled unless allowLegacyFeishuOAuth is on.
* Path no longer carries tenancy: prefer hostname silo slug, then ?org=.
*/
export function resolveLoginOrgSlug(): string | null {
const path = window.location.pathname.split('/').filter(Boolean);
if (path[0] === 'admin' && path[1] === 'org' && path[2]) {
return decodeURIComponent(path[2]);
}
const q = new URLSearchParams(window.location.search).get('org');
if (q && q.trim() !== '') return q.trim();
@@ -48,6 +45,12 @@ export function resolveLoginOrgSlug(): string | null {
const host = window.location.hostname.toLowerCase();
const m = host.match(/^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.educraft(?:-dev)?\./);
if (m?.[1]) return m[1];
// Legacy path while old bookmarks still land briefly before redirect.
const path = window.location.pathname.split('/').filter(Boolean);
if (path[0] === 'admin' && path[1] === 'org' && path[2]) {
return decodeURIComponent(path[2]);
}
return null;
}
+53 -76
View File
@@ -5,6 +5,7 @@
import { page } from '$app/state';
import { session, loadSession, logout, redirectToLogin } from '$lib/session';
import type { OrgMembership } from '$lib/api';
import { adminPath, isOrgAdmin, resolveOrg } from '$lib/org';
import { orgRoleLabel } from '$lib/format';
import Icon from '$lib/components/Icon.svelte';
import ToastHost from '$lib/components/ToastHost.svelte';
@@ -20,6 +21,7 @@
const navItems = [
{ key: 'overview', label: '概览', icon: 'overview' as const },
{ key: 'usage', label: '用量', icon: 'overview' as const },
{ key: 'members', label: '成员', icon: 'members' as const },
{ key: 'teams', label: '团队', icon: 'teams' as const },
{ key: 'projects', label: '项目', icon: 'projects' as const },
@@ -28,68 +30,52 @@
{ key: 'skills', label: '技能', icon: 'roles' as const },
{ key: 'roles', label: '角色', icon: 'roles' as const },
{ key: 'feishu', label: '飞书', icon: 'feishu' as const },
{ key: 'capabilities', label: '能力', icon: 'provider' as const },
];
function isAdmin(org: OrgMembership): boolean {
const role = String(org.role ?? '').toUpperCase();
return role === 'OWNER' || role === 'ADMIN';
}
function orgSlugFromPath(): string | null {
const parts = page.url.pathname.split('/').filter(Boolean);
if (parts[0] === 'admin' && parts[1] === 'org' && parts[2]) {
return decodeURIComponent(parts[2]);
}
return null;
}
function isOnProjectRoute(): boolean {
const parts = page.url.pathname.split('/').filter(Boolean);
return parts[0] === 'admin' && parts[1] === 'org' && parts[3] === 'projects';
}
function memberships(): OrgMembership[] {
return $session.me?.organizations ?? [];
}
function adminOrgs(): OrgMembership[] {
return memberships().filter(isAdmin);
return memberships().filter((o) => isOrgAdmin(o));
}
function currentOrg(): OrgMembership | null {
const slug = orgSlugFromPath();
if (!slug) return null;
return memberships().find((o) => o.slug === slug) ?? null;
return resolveOrg($session.me, page.url.search);
}
function pickHomeOrg(): OrgMembership | null {
const admin = adminOrgs()[0];
if (admin) return admin;
return memberships()[0] ?? null;
function isOnProjectRoute(): boolean {
const parts = page.url.pathname.split('/').filter(Boolean);
// /admin/projects or /admin/projects/:id
return parts[0] === 'admin' && parts[1] === 'projects';
}
function activeKey(): string {
const parts = page.url.pathname.split('/').filter(Boolean);
if (parts[0] !== 'admin' || parts[1] !== 'org' || !parts[2]) return '';
return parts[3] ?? 'overview';
if (parts[0] !== 'admin') return '';
// /admin → overview; /admin/usage → usage; /admin/projects/x → projects
return parts[1] ?? 'overview';
}
function navHref(key: string): string {
const slug = currentOrg()?.slug ?? pickHomeOrg()?.slug;
if (!slug) return '/';
if (key === 'overview') return `/admin/org/${slug}`;
return `/admin/org/${slug}/${key}`;
if (key === 'overview') return adminPath();
return adminPath(key);
}
function pageTitle(): string {
const key = activeKey();
if (key === 'overview' || key === '') return '概览';
if (key === 'sessions') return '会话详情';
return navItems.find((i) => i.key === key)?.label ?? '管理后台';
}
function switchOrg(nextSlug: string) {
if (!nextSlug || nextSlug === currentOrg()?.slug) return;
void goto(`/admin/org/${nextSlug}`);
// Path is tenancy-free; keep optional ?org= for local multi-membership debugging.
const url = new URL(page.url.href);
url.searchParams.set('org', nextSlug);
void goto(`${url.pathname}${url.search}`, { replaceState: true });
}
function handleLogout(e: Event) {
@@ -112,33 +98,23 @@
$effect(() => {
if ($session.loading || !$session.me) return;
const slug = orgSlugFromPath();
const matched = slug ? memberships().find((o) => o.slug === slug) : null;
const path = page.url.pathname;
// Legacy /admin/org/:slug… is handled by admin/org/[...path] page.
// Org admins: route to their first admin org if none matched as admin.
const org = currentOrg();
const admins = adminOrgs();
if (matched && isAdmin(matched)) {
if (org && isOrgAdmin(org)) {
redirecting = false;
return;
}
if (!matched && admins.length > 0) {
const target = `/admin/org/${admins[0].slug}`;
if (page.url.pathname !== target && !page.url.pathname.startsWith(`${target}/`)) {
redirecting = true;
void goto(target, { replaceState: true });
}
return;
}
// Members (non-admin): project pages are open to project MANAGE holders;
// the org overview and other admin-only surfaces are not for them.
if (matched && !isAdmin(matched)) {
// Member only: allow project routes; bounce admin-only surfaces to projects.
if (org && !isOrgAdmin(org)) {
redirecting = false;
const parts = page.url.pathname.split('/').filter(Boolean);
const onOverview = parts.length === 3; // /admin/org/:slug
if (onOverview) {
const target = `/admin/org/${matched.slug}/projects`;
if (page.url.pathname !== target) {
if (!isOnProjectRoute()) {
const target = adminPath('projects');
if (path !== target) {
redirecting = true;
void goto(target, { replaceState: true });
}
@@ -146,12 +122,11 @@
return;
}
// No matched org and no admin orgs: route a member to their first org's
// projects page so they can reach project MANAGE surfaces.
if (!matched && memberships().length > 0) {
const home = memberships()[0];
const target = `/admin/org/${home.slug}/projects`;
if (page.url.pathname !== target && !page.url.pathname.startsWith(`${target}/`)) {
// No org resolved but user has memberships → land on first org's projects/overview via resolveOrg next tick
if (!org && memberships().length > 0) {
const home = admins[0] ?? memberships()[0]!;
const target = isOrgAdmin(home) ? adminPath() : adminPath('projects');
if (path !== target && !path.startsWith(`${target}/`)) {
redirecting = true;
void goto(target, { replaceState: true });
}
@@ -172,14 +147,14 @@
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<p class="text-sm">{redirecting ? '正在进入组织…' : '正在加载会话…'}</p>
<p class="text-sm">加载中…</p>
</div>
</div>
{:else if $session.error}
<div class="saas-status-panel">
<div class="saas-status-card">
<div
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center border border-error-300 bg-error-100 text-error-700 font-bold"
class="mx-auto mb-3 flex h-12 w-12 items-center justify-center border border-error-200 bg-error-50 text-error-700"
>
!
</div>
@@ -192,7 +167,7 @@
<div class="saas-status-panel">
<div class="saas-status-card">
<div
class="mx-auto mb-5 flex h-12 w-12 items-center justify-center border border-primary-700 bg-primary-600 text-white font-bold"
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center border border-primary-700 bg-primary-600 text-sm font-bold text-white"
>
CPH
</div>
@@ -201,7 +176,7 @@
<button class="saas-btn-primary w-full" onclick={() => redirectToLogin()}>使用飞书登录</button>
</div>
</div>
{:else if currentOrg() && isAdmin(currentOrg()!)}
{:else if currentOrg() && isOrgAdmin(currentOrg()!)}
{@const org = currentOrg()!}
{@const me = $session.me!}
<div class="saas-shell">
@@ -226,17 +201,19 @@
</div>
<div class="min-w-0">
<div class="truncate text-sm font-semibold text-surface-900">组织后台</div>
<div class="truncate text-xs text-surface-600">Curriculum Hub</div>
<div class="truncate text-xs text-surface-600">{org.name}</div>
</div>
</div>
<div class="px-3 pb-3">
<div class="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-surface-700">
<Icon name="org" class="h-3.5 w-3.5" />
组织
{#if me.organizations.length > 1}
<div class="px-3 pb-3">
<div class="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-surface-700">
<Icon name="org" class="h-3.5 w-3.5" />
组织
</div>
<SelectField items={orgSelectItems(me.organizations)} value={org.slug} onchange={switchOrg} />
</div>
<SelectField items={orgSelectItems(me.organizations)} value={org.slug} onchange={switchOrg} />
</div>
{/if}
<nav class="flex-1 space-y-0.5 overflow-y-auto px-2 pb-3">
<p class="px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wider text-surface-600">工作台</p>
@@ -306,13 +283,13 @@
</main>
</div>
</div>
{:else if currentOrg() && !isAdmin(currentOrg()!) && isOnProjectRoute()}
{:else if currentOrg() && !isOrgAdmin(currentOrg()!) && isOnProjectRoute()}
{@const org = currentOrg()!}
{@const me = $session.me!}
<div class="saas-shell">
<div class="saas-main">
<header class="saas-topbar">
<a href={`/admin/org/${org.slug}/projects`} class="saas-btn-ghost px-2!" aria-label="返回项目列表">
<a href={adminPath('projects')} class="saas-btn-ghost px-2!" aria-label="返回项目列表">
<Icon name="menu" class="h-5 w-5" />
</a>
<div class="min-w-0">
@@ -341,7 +318,7 @@
</main>
</div>
</div>
{:else if currentOrg() && !isAdmin(currentOrg()!)}
{:else if currentOrg() && !isOrgAdmin(currentOrg()!)}
{@const denied = currentOrg()!}
<div class="saas-status-panel">
<div class="saas-status-card">
@@ -350,7 +327,7 @@
组织 <strong>{denied.name}</strong>/{denied.slug})中你的角色是
<span class="saas-badge-neutral mx-1">{orgRoleLabel(denied.role)}</span>。普通成员仅可访问自己有授权的项目。
</p>
<a class="saas-btn-primary" href={`/admin/org/${denied.slug}/projects`}>查看我的项目</a>
<a class="saas-btn-primary" href={adminPath('projects')}>查看我的项目</a>
{#if memberships().length > 1}
<p class="saas-label text-left mb-1.5 mt-3">切换到其他组织</p>
<div class="mb-4">
@@ -361,14 +338,14 @@
</div>
</div>
{:else if memberships().length > 0}
{@const denied = pickHomeOrg()!}
{@const denied = resolveOrg($session.me) ?? memberships()[0]!}
<div class="saas-status-panel">
<div class="saas-status-card">
<h2 class="mb-2 text-lg font-semibold">正在跳转…</h2>
<p class="mb-5 text-sm text-surface-700">
即将进入 <strong>{denied.name}</strong>/{denied.slug})的项目。
</p>
<a class="saas-btn-primary" href={`/admin/org/${denied.slug}/projects`}>立即进入</a>
<a class="saas-btn-primary" href={adminPath('projects')}>立即进入</a>
<button class="saas-btn-ghost mt-3" onclick={handleLogout}>退出登录</button>
</div>
</div>
+1 -1
View File
@@ -11,7 +11,7 @@
return r === 'OWNER' || r === 'ADMIN';
});
const target = admin ?? me.organizations[0];
return target ? `/admin/org/${target.slug}` : null;
return target ? `/admin` : null;
}
onMount(() => {
@@ -2,6 +2,7 @@
import { page } from '$app/state';
import { api, type OrgMembership } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtCost, fmtNum, orgRoleLabel } from '$lib/format';
import PageHeader from '$lib/components/PageHeader.svelte';
import StatCard from '$lib/components/StatCard.svelte';
@@ -10,7 +11,8 @@
import SwitchControl from '$lib/components/SwitchControl.svelte';
import { toastError, toastSuccess } from '$lib/toast';
let orgSlug = $derived(page.params.slug ?? '');
const orgFromSession = $derived(resolveOrg($session.me, page.url.search));
let orgSlug = $derived(orgFromSession?.slug ?? '');
let org = $derived($session.me?.organizations.find((o) => o.slug === orgSlug) as OrgMembership | undefined);
let settings = $state<{ membersCanCreateProjects: boolean } | null>(null);
@@ -94,19 +96,56 @@
<div class="mb-4 flex items-end justify-between gap-3">
<div>
<h2 class="saas-section-title">用量概览</h2>
<p class="saas-muted">全组织智能体运行汇总</p>
<p class="saas-muted">totals 含全部 UsageFact;分账明细见用量页。</p>
</div>
<a class="saas-btn-secondary py-1.5! text-sm" href={`/admin/usage`}>完整用量报告</a>
</div>
<div class="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<StatCard label="运行总数" value={fmtNum(usage.totals.runCount)} />
<StatCard label="有成本运行" value={fmtNum(usage.totals.runsWithCost)} />
<StatCard label="无成本运行" value={fmtNum(usage.totals.runsWithoutCost)} />
<StatCard label="无成本运行" value={fmtNum(usage.totals.runsWithoutCost)} hint="未知 $0" />
<StatCard label="输入 tokens" value={fmtNum(usage.totals.inputTokens)} />
<StatCard label="输出 tokens" value={fmtNum(usage.totals.outputTokens)} />
<StatCard label="成本 (USD)" value={fmtCost(usage.totals.costUsd)} />
</div>
{#if usage.breakdown.length > 0}
<div class="saas-card overflow-hidden mb-6">
<div class="border-b border-surface-200 px-5 py-3 flex items-center justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-surface-800">消费来源(Top</h3>
<p class="saas-muted text-xs">模型完成 vs 外部能力,按成本降序前 5</p>
</div>
<a class="text-sm text-primary-700 hover:underline" href={`/admin/usage`}>查看全部分账</a>
</div>
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>类型</th>
<th>供应方</th>
<th>模型 / 能力</th>
<th>次数</th>
<th>成本</th>
</tr>
</thead>
<tbody>
{#each [...usage.breakdown].sort((a, b) => (b.costUsd ?? -1) - (a.costUsd ?? -1)).slice(0, 5) as row}
<tr>
<td class="text-sm">{row.kind === 'external_capability' ? '外部能力' : row.kind === 'model_completion' ? '模型完成' : row.kind}</td>
<td class="font-mono text-xs">{row.provider}</td>
<td class="font-mono text-xs">{row.capabilityId ?? row.model ?? '—'}</td>
<td class="tabular-nums">{fmtNum(row.factCount)}</td>
<td class="tabular-nums">{fmtCost(row.costUsd)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
<div class="saas-card overflow-hidden">
<div class="border-b border-surface-200 px-5 py-3">
<h3 class="text-sm font-semibold text-surface-800">按项目用量</h3>
@@ -129,7 +168,11 @@
<tbody>
{#each usage.projects as p}
<tr>
<td class="font-medium">{p.projectName}</td>
<td class="font-medium">
<a class="hover:text-primary-700 hover:underline" href={`/admin/projects/${p.projectId}`}>
{p.projectName}
</a>
</td>
<td class="tabular-nums">{fmtNum(p.runCount)}</td>
<td class="tabular-nums text-surface-600">{fmtNum(p.inputTokens)} / {fmtNum(p.outputTokens)}</td>
<td class="tabular-nums">{fmtCost(p.costUsd)}</td>
@@ -0,0 +1,205 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type CapabilityConnection } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtDate } from '$lib/format';
import { Label } from 'bits-ui';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
const KNOWN_CAPABILITIES = [
{ id: 'pdf_to_md_bundle', label: 'PDF → Markdown', description: '将 PDF 转换为带图片的 Markdown bundle(阿里云文档智能,含公式 LaTeX 识别)' },
{ id: 'audio_video_to_text', label: '音视频 → 文本', description: '将音频/视频转写为文本(阿里云文档智能,按秒计费)' },
] as const;
let connections = $state<Map<string, CapabilityConnection>>(new Map());
let loading = $state(true);
let error = $state<string | null>(null);
let editingCap = $state<string | null>(null);
let accessKeyId = $state('');
let accessKeySecret = $state('');
let endpoint = $state('docmind-api.cn-hangzhou.aliyuncs.com');
let saving = $state(false);
let disabling = $state<string | null>(null);
async function load() {
loading = true;
error = null;
try {
const res = await api.capabilityConnections(slug);
connections = new Map(res.connections.map((c) => [c.capabilityId, c]));
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
function startEdit(capId: string) {
editingCap = capId;
accessKeyId = '';
accessKeySecret = '';
endpoint = 'docmind-api.cn-hangzhou.aliyuncs.com';
}
function cancelEdit() {
editingCap = null;
}
async function save(capId: string) {
if (accessKeyId.trim() === '' || accessKeySecret.trim() === '' || endpoint.trim() === '') {
toastError('AccessKey ID、AccessKey Secret、Endpoint 均为必填');
return;
}
saving = true;
try {
const result = await api.rotateCapabilityConnection(slug, capId, {
accessKeyId: accessKeyId.trim(),
accessKeySecret: accessKeySecret.trim(),
endpoint: endpoint.trim(),
});
connections.set(capId, result);
connections = new Map(connections);
editingCap = null;
toastSuccess('能力凭据已保存');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
}
}
async function disable(capId: string) {
if (!confirm('停用后该能力将不可用,确定停用?')) return;
disabling = capId;
try {
const result = await api.disableCapabilityConnection(slug, capId);
connections.set(capId, result);
connections = new Map(connections);
toastSuccess('已停用能力连接');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
disabling = null;
}
}
function statusBadge(status: string): string {
if (status === 'ACTIVE') return 'saas-badge-primary';
if (status === 'DISABLED') return 'saas-badge-error';
return 'saas-badge-muted';
}
function statusLabel(status: string): string {
if (status === 'ACTIVE') return '已启用';
if (status === 'DISABLED') return '已停用';
return '草稿';
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="外部能力"
description="管理文档/媒体转换服务的组织级凭据(ADR-0027)。凭据按组织隔离、版本化信封存储,缺失或校验失败即 fail-closed。"
/>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
<div class="space-y-6">
{#each KNOWN_CAPABILITIES as cap}
{@const conn = connections.get(cap.id)}
<div class="saas-card-pad">
<div class="mb-3 flex items-start justify-between gap-3">
<div>
<div class="flex items-center gap-2">
<h3 class="saas-section-title">{cap.label}</h3>
{#if conn}
<span class={statusBadge(conn.status)}>{statusLabel(conn.status)}</span>
{:else}
<span class="saas-badge-muted">未配置</span>
{/if}
</div>
<p class="saas-muted mt-1 text-sm">{cap.description}</p>
<p class="mt-0.5 font-mono text-xs text-surface-500">{cap.id}</p>
</div>
<div class="flex items-center gap-2">
{#if conn?.status === 'ACTIVE'}
<button
class="saas-btn-ghost text-sm"
onclick={() => disable(cap.id)}
disabled={disabling === cap.id}
>
{disabling === cap.id ? '停用中…' : '停用'}
</button>
{/if}
<button
class="saas-btn-primary text-sm"
onclick={() => startEdit(cap.id)}
disabled={editingCap === cap.id}
>
{conn ? '轮换凭据' : '配置凭据'}
</button>
</div>
</div>
{#if conn}
<dl class="space-y-1.5 text-sm text-surface-700">
<div class="flex justify-between">
<dt class="text-surface-500">版本</dt>
<dd class="font-mono">{conn.activeVersion ?? '—'}</dd>
</div>
<div class="flex justify-between">
<dt class="text-surface-500">密钥 ID</dt>
<dd class="font-mono text-xs">{conn.keyId ?? '—'}</dd>
</div>
<div class="flex justify-between">
<dt class="text-surface-500">更新时间</dt>
<dd>{fmtDate(conn.updatedAt)}</dd>
</div>
</dl>
{/if}
{#if editingCap === cap.id}
<div class="mt-4 border-t border-surface-100 pt-4">
<p class="saas-muted mb-3 text-sm">
阿里云 RAM 用户的 AccessKey。密钥仅写入新版本,旧版本归档。
</p>
<div class="grid gap-4">
<div>
<Label.Root class="saas-label" for="ak-id-{cap.id}">AccessKey ID</Label.Root>
<input id="ak-id-{cap.id}" class="saas-input font-mono text-sm" bind:value={accessKeyId} />
</div>
<div>
<Label.Root class="saas-label" for="ak-secret-{cap.id}">AccessKey Secret</Label.Root>
<input id="ak-secret-{cap.id}" class="saas-input" type="password" bind:value={accessKeySecret} />
</div>
<div>
<Label.Root class="saas-label" for="endpoint-{cap.id}">Endpoint</Label.Root>
<input id="endpoint-{cap.id}" class="saas-input font-mono text-sm" bind:value={endpoint} />
</div>
</div>
<div class="mt-4 flex items-center justify-end gap-3">
<button class="saas-btn-ghost" onclick={cancelEdit} disabled={saving}>取消</button>
<button class="saas-btn-primary" onclick={() => save(cap.id)} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
{/if}
</div>
{/each}
</div>
{/if}
@@ -1,13 +1,16 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type CapacityDimension, type CapacityDimensionRow, type CapacityPolicyView } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
// Friendlier, user-facing labels. No spec jargon (墙钟 → 运行时长, etc.).
const DIMENSION_LABELS: Record<CapacityDimension, string> = {
@@ -1,6 +1,8 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type FeishuApplicationConnection } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtDate } from '$lib/format';
import { Label } from 'bits-ui';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -8,7 +10,8 @@
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
let connection = $state<FeishuApplicationConnection | null>(null);
let loading = $state(true);
@@ -1,6 +1,8 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type OrgMember } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtDate } from '$lib/format';
import { ORG_ROLES, ORG_ROLE_LABELS, PERMISSION_ROLE_LABELS } from '$lib/constants';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -10,7 +12,8 @@
import SelectField from '$lib/components/SelectField.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
const roleItems = ORG_ROLES.map((r) => ({ value: r, label: ORG_ROLE_LABELS[r] }));
const permHint = Object.values(PERMISSION_ROLE_LABELS).join(' / ');
@@ -0,0 +1,23 @@
<script lang="ts">
/**
* Legacy bookmarks: /admin/org/:slug[/...] → /admin[/...]
* Tenancy lives on the silo host, not the path.
*/
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
onMount(() => {
const raw = page.params.path ?? '';
const segments = raw.split('/').filter(Boolean);
// Drop the old org slug (first segment) when present.
const rest = segments.length > 0 ? segments.slice(1).join('/') : '';
const target = rest === '' ? '/admin' : `/admin/${rest}`;
const q = page.url.search;
void goto(`${target}${q}`, { replaceState: true });
});
</script>
<div class="saas-status-panel">
<p class="text-sm text-surface-600">正在重定向到新地址…</p>
</div>
@@ -2,6 +2,7 @@
import { page } from '$app/state';
import { api, type ExplorerData, type ExplorerFolder, type ExplorerProject, type OrgMembership } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import FolderTree from '$lib/components/FolderTree.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
@@ -13,8 +14,8 @@
import { fmtDate } from '$lib/format';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
const org = $derived(($session.me?.organizations.find((o) => o.slug === slug) as OrgMembership | undefined) ?? null);
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
const isAdmin = $derived(!!org && (org.role === 'OWNER' || org.role === 'ADMIN'));
let data = $state<ExplorerData | null>(null);
@@ -87,7 +88,7 @@
projectName = '';
projectFolder = '';
showProjectModal = false;
window.location.href = `/admin/org/${slug}/projects/${res.id}`;
window.location.href = `/admin/projects/${res.id}`;
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
@@ -207,7 +208,7 @@
</thead>
<tbody>
{#each myProjects as p}
<tr class="cursor-pointer" onclick={() => (window.location.href = `/admin/org/${slug}/projects/${p.id}`)}>
<tr class="cursor-pointer" onclick={() => (window.location.href = `/admin/projects/${p.id}`)}>
<td class="font-medium">{p.name}</td>
<td class="font-mono text-xs">{p.binding ? `群 ${p.binding.chatId}` : '—'}</td>
<td class="text-surface-700">{fmtDate(p.createdAt)}</td>
@@ -7,8 +7,19 @@
type TeamRow,
type SessionSummary,
type ExplorerData,
type ProjectUsageReport,
} from '$lib/api';
import { fmtDate, permissionRoleLabel } from '$lib/format';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import {
fmtCost,
fmtDate,
fmtNum,
fmtQuantity,
fmtTokens,
permissionRoleLabel,
usageKindLabel,
} from '$lib/format';
import { PERMISSION_ROLES, PERMISSION_ROLE_LABELS } from '$lib/constants';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
@@ -18,7 +29,8 @@
import Icon from '$lib/components/Icon.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
const projectId = $derived(page.params.projectId ?? '');
const roleItems = PERMISSION_ROLES.map((r) => ({ value: r, label: PERMISSION_ROLE_LABELS[r] }));
const roleChain = `${PERMISSION_ROLE_LABELS.READ} ⊂ ${PERMISSION_ROLE_LABELS.EDIT} ⊂ ${PERMISSION_ROLE_LABELS.MANAGE}`;
@@ -26,6 +38,7 @@
let proj = $state<ProjectDetail | null>(null);
let access = $state<TeamAccessEntry[]>([]);
let sessions = $state<SessionSummary[]>([]);
let projectUsage = $state<ProjectUsageReport | null>(null);
let teams = $state<TeamRow[]>([]);
let explorer = $state<ExplorerData | null>(null);
let loading = $state(true);
@@ -49,14 +62,18 @@
// Team list is needed for grant UI whenever the actor has project MANAGE
// (org admin or member). Sessions/explorer stay org-admin oversight only.
const needTeams = p.actorIsOrgAdmin === true || p.actorCanManageProject === true;
const [s, t, e] = await Promise.all([
const [s, t, e, u] = await Promise.all([
p.actorIsOrgAdmin ? api.sessions(slug, projectId) : Promise.resolve({ sessions: [] as SessionSummary[] }),
needTeams ? api.teams(slug) : Promise.resolve({ teams: [] as TeamRow[] }),
p.actorIsOrgAdmin ? api.explorer(slug) : Promise.resolve(null as ExplorerData | null),
p.actorIsOrgAdmin
? api.projectUsage(slug, projectId)
: Promise.resolve(null as ProjectUsageReport | null),
]);
sessions = s.sessions;
teams = t.teams;
explorer = e;
projectUsage = u;
if (p.actorIsOrgAdmin) {
moveFolder = p.folderId ?? '';
}
@@ -95,7 +112,7 @@
if (!confirm(`归档项目 ${proj?.name}?`)) return;
try {
await api.archiveProject(slug, projectId);
window.location.href = `/admin/org/${slug}/projects`;
window.location.href = `/admin/projects`;
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
@@ -156,7 +173,7 @@
{:else if proj}
<div class="mb-2">
<a
href={`/admin/org/${slug}/projects`}
href={`/admin/projects`}
class="inline-flex items-center gap-1 text-sm text-surface-700 hover:text-primary-600"
>
<Icon name="arrow-left" class="h-4 w-4" />
@@ -269,9 +286,67 @@
</div>
{#if actorIsOrgAdmin}
{#if projectUsage}
<div class="saas-card overflow-hidden mb-6">
<div class="border-b border-surface-200 px-5 py-3 flex items-center justify-between gap-3">
<div>
<h3 class="text-sm font-semibold">项目用量分账</h3>
<p class="saas-muted mt-0.5 text-xs">
{fmtNum(projectUsage.runCount)} 次运行 · 成本 {fmtCost(projectUsage.costUsd)} · tokens
{fmtTokens(projectUsage.inputTokens, projectUsage.outputTokens)}
</p>
</div>
<a class="text-sm text-primary-700 hover:underline" href={`/admin/usage`}>组织报告</a>
</div>
{#if projectUsage.breakdown.length === 0}
<div class="px-5 py-4 text-sm text-surface-600">尚无 UsageFact。</div>
{:else}
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>类型</th>
<th>供应方</th>
<th>模型 / 能力</th>
<th>次数</th>
<th>计量</th>
<th>成本</th>
</tr>
</thead>
<tbody>
{#each projectUsage.breakdown as row}
<tr>
<td>
<span
class={row.kind === 'external_capability' ? 'saas-badge-primary' : 'saas-badge-success'}
>
{usageKindLabel(row.kind)}
</span>
</td>
<td class="font-mono text-xs">{row.provider}</td>
<td class="font-mono text-xs">{row.capabilityId ?? row.model ?? '—'}</td>
<td class="tabular-nums">{fmtNum(row.factCount)}</td>
<td class="tabular-nums text-xs">
{#if row.unit}
{fmtQuantity(row.quantity, row.unit)}
{:else}
{fmtTokens(row.inputTokens, row.outputTokens)}
{/if}
</td>
<td class="tabular-nums">{fmtCost(row.costUsd)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{/if}
<div class="saas-card overflow-hidden">
<div class="border-b border-surface-200 px-5 py-3">
<h3 class="text-sm font-semibold">智能体会话</h3>
<p class="saas-muted mt-0.5 text-xs">点进会话可查看每次 run 的 UsageFact 分账(模型 / 外部能力)。</p>
</div>
{#if sessions.length === 0}
<EmptyState title="暂无会话" description="飞书侧触发智能体后会显示在此。" />
@@ -283,6 +358,7 @@
<th>模型</th>
<th>运行次数</th>
<th>更新</th>
<th></th>
</tr>
</thead>
<tbody>
@@ -292,6 +368,14 @@
<td class="font-mono text-xs">{s.model}</td>
<td class="tabular-nums">{s.runCount}</td>
<td class="text-surface-700">{fmtDate(s.updatedAt)}</td>
<td class="text-right">
<a
class="text-sm text-primary-700 hover:underline"
href={`/admin/sessions/${s.id}`}
>
详情
</a>
</td>
</tr>
{/each}
</tbody>
@@ -1,6 +1,8 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type ProviderConnectionRow } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtDate, providerModeLabel } from '$lib/format';
import { Label } from 'bits-ui';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -8,7 +10,8 @@
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
let connections = $state<ProviderConnectionRow[]>([]);
let loading = $state(true);
@@ -1,6 +1,8 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type AgentRoleRow, type AgentModelRow, type AgentSkillRow } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
@@ -8,7 +10,8 @@
import RoleCard from '$lib/components/RoleCard.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
let roles = $state<AgentRoleRow[]>([]);
let models = $state<AgentModelRow[]>([]);
@@ -0,0 +1,238 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type SessionDetail, type SessionRunRow, type UsageFactRow } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import {
fmtCost,
fmtDate,
fmtNum,
fmtQuantity,
fmtTokens,
runStatusLabel,
usageKindLabel,
} from '$lib/format';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import Icon from '$lib/components/Icon.svelte';
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
const sessionId = $derived(page.params.sessionId ?? '');
let detail = $state<SessionDetail | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let expandedRunId = $state<string | null>(null);
async function load() {
if (!slug || !sessionId) return;
loading = true;
error = null;
try {
detail = await api.session(slug, sessionId);
if (detail.runs.length > 0) {
expandedRunId = detail.runs[0]!.id;
}
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
function statusClass(status: string): string {
const key = status.toUpperCase();
if (key === 'COMPLETED') return 'saas-badge-success';
if (key === 'FAILED' || key === 'TIMED_OUT' || key === 'CANCELED') return 'saas-badge-error';
return 'saas-badge-primary';
}
function factMeter(f: UsageFactRow): string {
if (f.unit) return fmtQuantity(f.quantity, f.unit);
if (f.inputTokens !== null || f.outputTokens !== null) {
return fmtTokens(f.inputTokens, f.outputTokens);
}
return '—';
}
function factSource(f: UsageFactRow): string {
if (f.capabilityId) return f.capabilityId;
if (f.model) return f.model;
return '—';
}
function runCostHint(run: SessionRunRow): string {
const factCost = run.usageFacts.reduce<number | null>((acc, f) => {
if (f.costUsd === null) return acc;
return (acc ?? 0) + f.costUsd;
}, null);
const cache = run.costUsd;
if (factCost !== null && cache !== null && Math.abs(factCost - cache) > 1e-9) {
return `运行缓存 ${fmtCost(cache)};事实合计 ${fmtCost(factCost)}(缓存可能未含外部能力)`;
}
if (factCost !== null) return `事实合计 ${fmtCost(factCost)}`;
if (cache !== null) return `运行缓存 ${fmtCost(cache)}`;
return '成本未知';
}
function toggleRun(id: string) {
expandedRunId = expandedRunId === id ? null : id;
}
$effect(() => {
if (slug && sessionId) void load();
});
</script>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else if detail}
<div class="mb-2">
<a
class="inline-flex items-center gap-1 text-sm text-surface-700 hover:text-primary-700"
href={`/admin/projects/${detail.project.id}`}
>
<Icon name="arrow-left" class="h-4 w-4" />
返回项目 {detail.project.name}
</a>
</div>
<PageHeader
title={detail.title?.trim() || '未命名会话'}
description={`${detail.provider} · ${detail.roleId} · ${detail.model}`}
/>
<div class="saas-card-pad mb-6">
<dl class="grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
<div>
<dt class="text-surface-600">会话 ID</dt>
<dd class="mt-0.5 break-all font-mono text-xs">{detail.id}</dd>
</div>
<div>
<dt class="text-surface-600">项目</dt>
<dd class="mt-0.5">
<a class="text-primary-700 hover:underline" href={`/admin/projects/${detail.project.id}`}>
{detail.project.name}
</a>
</dd>
</div>
<div>
<dt class="text-surface-600">创建 / 更新</dt>
<dd class="mt-0.5 text-surface-800">{fmtDate(detail.createdAt)} · {fmtDate(detail.updatedAt)}</dd>
</div>
{#if detail.archivedAt}
<div>
<dt class="text-surface-600">已归档</dt>
<dd class="mt-0.5">{fmtDate(detail.archivedAt)}</dd>
</div>
{/if}
<div>
<dt class="text-surface-600">运行数</dt>
<dd class="mt-0.5 tabular-nums">{fmtNum(detail.runs.length)}</dd>
</div>
</dl>
</div>
<div class="mb-3">
<h2 class="saas-section-title">运行与计费事实</h2>
<p class="saas-muted">每条 UsageFact 是一次可计费消费;外部能力与模型完成分开列出。</p>
</div>
{#if detail.runs.length === 0}
<div class="saas-card">
<EmptyState title="尚无运行" description="此会话还没有 Agent run。" />
</div>
{:else}
<div class="space-y-3">
{#each detail.runs as run (run.id)}
{@const open = expandedRunId === run.id}
<div class="saas-card overflow-hidden">
<button
type="button"
class="flex w-full items-start justify-between gap-3 px-5 py-4 text-left hover:bg-surface-50"
onclick={() => toggleRun(run.id)}
>
<div class="min-w-0 space-y-1">
<div class="flex flex-wrap items-center gap-2">
<span class={statusClass(run.status)}>{runStatusLabel(run.status)}</span>
<span class="font-mono text-xs text-surface-700">{run.provider} / {run.model}</span>
<span class="font-mono text-[11px] text-surface-500">{run.id}</span>
</div>
<div class="text-xs text-surface-700">
{fmtDate(run.startedAt)}
{#if run.finishedAt}
{fmtDate(run.finishedAt)}
{/if}
</div>
<div class="text-xs text-surface-600">{runCostHint(run)}</div>
{#if run.error}
<div class="text-xs text-error-700">{run.error}</div>
{/if}
</div>
<div class="shrink-0 text-right text-sm">
<div class="tabular-nums text-surface-800">{fmtTokens(run.inputTokens, run.outputTokens)}</div>
<div class="tabular-nums font-medium">{fmtCost(run.costUsd)}</div>
<div class="mt-1 text-[11px] text-surface-600">{open ? '收起事实' : `${run.usageFacts.length} 条事实`}</div>
</div>
</button>
{#if open}
<div class="border-t border-surface-200">
{#if run.usageFacts.length === 0}
<div class="px-5 py-4">
<p class="text-sm text-surface-600">此 run 没有 UsageFact(可能尚未结束或未记费)。</p>
</div>
{:else}
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>时间</th>
<th>类型</th>
<th>供应方</th>
<th>模型 / 能力</th>
<th>计量</th>
<th>成本</th>
<th>来源</th>
<th>关联 ID</th>
</tr>
</thead>
<tbody>
{#each run.usageFacts as fact}
<tr>
<td class="whitespace-nowrap text-xs text-surface-700">{fmtDate(fact.occurredAt)}</td>
<td>
<span
class={fact.kind === 'external_capability'
? 'saas-badge-primary'
: 'saas-badge-success'}
>
{usageKindLabel(fact.kind)}
</span>
</td>
<td class="font-mono text-xs">{fact.provider}</td>
<td class="font-mono text-xs">{factSource(fact)}</td>
<td class="tabular-nums text-xs">{factMeter(fact)}</td>
<td class="tabular-nums">{fmtCost(fact.costUsd)}</td>
<td class="font-mono text-[11px] text-surface-600">{fact.costSource}</td>
<td class="max-w-[10rem] truncate font-mono text-[11px] text-surface-500" title={fact.correlationId ?? ''}>
{fact.correlationId ?? '—'}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
{/if}
@@ -1,6 +1,8 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type AgentSkillRow, type SkillFileEntry } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
@@ -8,7 +10,8 @@
import SkillEditor from '$lib/components/SkillEditor.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
let skills = $state<AgentSkillRow[]>([]);
let loading = $state(true);
@@ -2,6 +2,8 @@
import { Collapsible } from 'bits-ui';
import { page } from '$app/state';
import { api, type TeamRow, type TeamMemberRow } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtDate } from '$lib/format';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
@@ -9,7 +11,8 @@
import EmptyState from '$lib/components/EmptyState.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
let teams = $state<TeamRow[]>([]);
let loading = $state(true);
@@ -0,0 +1,241 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type UsageReport, type UsageBreakdownRow } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import {
fmtCost,
fmtDateOnly,
fmtNum,
fmtQuantity,
fmtTokens,
usageKindLabel,
} from '$lib/format';
import PageHeader from '$lib/components/PageHeader.svelte';
import StatCard from '$lib/components/StatCard.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
let usage = $state<UsageReport | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let from = $state('');
let to = $state('');
function toIsoStart(dateLocal: string): string | undefined {
if (!dateLocal) return undefined;
const d = new Date(`${dateLocal}T00:00:00`);
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
}
function toIsoEnd(dateLocal: string): string | undefined {
if (!dateLocal) return undefined;
const d = new Date(`${dateLocal}T23:59:59.999`);
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
}
async function load() {
if (!slug) return;
loading = true;
error = null;
try {
usage = await api.usage(slug, {
...(toIsoStart(from) !== undefined ? { from: toIsoStart(from) } : {}),
...(toIsoEnd(to) !== undefined ? { to: toIsoEnd(to) } : {}),
});
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
function clearRange() {
from = '';
to = '';
void load();
}
function sourceLabel(row: UsageBreakdownRow): string {
if (row.capabilityId) return row.capabilityId;
if (row.model) return row.model;
return '—';
}
function meterCell(row: UsageBreakdownRow): string {
if (row.unit) return fmtQuantity(row.quantity, row.unit);
if (row.inputTokens > 0 || row.outputTokens > 0) return fmtTokens(row.inputTokens, row.outputTokens);
return '—';
}
function meterHint(row: UsageBreakdownRow): string {
if (row.unit) return '非 token 计量';
if (row.inputTokens > 0 || row.outputTokens > 0) return 'in / out tokens';
return '无计量';
}
$effect(() => {
if (slug) void load();
});
</script>
{#if loading && !usage}
<LoadingState />
{:else if error && !usage}
<ErrorBanner message={error} onretry={load} />
{:else if usage}
<PageHeader
title="用量报告"
description="按 UsageFact 分账:模型完成与外部能力(PDF→MD、ASR 等)分开汇总。缺失成本计为未知,不为 0。"
/>
<div class="saas-card-pad mb-6">
<div class="flex flex-wrap items-end gap-3">
<div>
<label class="saas-label" for="usage-from"></label>
<input id="usage-from" class="saas-input" type="date" bind:value={from} />
</div>
<div>
<label class="saas-label" for="usage-to"></label>
<input id="usage-to" class="saas-input" type="date" bind:value={to} />
</div>
<button class="saas-btn-primary py-1.5! text-sm" type="button" onclick={load} disabled={loading}>
{loading ? '加载中…' : '应用筛选'}
</button>
<button class="saas-btn-secondary py-1.5! text-sm" type="button" onclick={clearRange} disabled={loading}>
清除
</button>
{#if usage.from || usage.to}
<p class="saas-muted grow text-right text-xs">
窗口:
{usage.from ? fmtDateOnly(usage.from) : '—'}
{usage.to ? fmtDateOnly(usage.to) : '—'}
</p>
{/if}
</div>
{#if error}
<p class="mt-3 text-sm text-error-700">{error}</p>
{/if}
</div>
<div class="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<StatCard label="运行总数" value={fmtNum(usage.totals.runCount)} />
<StatCard
label="有成本 / 无成本"
value={`${fmtNum(usage.totals.runsWithCost)} / ${fmtNum(usage.totals.runsWithoutCost)}`}
hint="无成本 = 成本未知不是 $0"
/>
<StatCard label="成本 (USD)" value={fmtCost(usage.totals.costUsd)} hint="仅汇总已知 costUsd" />
<StatCard label="输入 tokens" value={fmtNum(usage.totals.inputTokens)} hint="主要来自模型完成" />
<StatCard label="输出 tokens" value={fmtNum(usage.totals.outputTokens)} hint="主要来自模型完成" />
<StatCard
label="分账条目"
value={fmtNum(usage.breakdown.reduce((n, b) => n + b.factCount, 0))}
hint={`${fmtNum(usage.breakdown.length)} 个分项`}
/>
</div>
<div class="saas-card overflow-hidden mb-6">
<div class="border-b border-surface-200 px-5 py-3">
<h3 class="text-sm font-semibold text-surface-800">按来源分账</h3>
<p class="saas-muted mt-0.5 text-xs">
kind × provider × model/capability。外部能力显示页数/秒等计量,不与 tokens 混排。
</p>
</div>
{#if usage.breakdown.length === 0}
<EmptyState title="暂无用量事实" description="跑过智能体后,模型与外部能力消费会出现在此。" />
{:else}
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>类型</th>
<th>供应方</th>
<th>模型 / 能力</th>
<th>次数</th>
<th>计量</th>
<th>有成本 / 未知</th>
<th>成本</th>
</tr>
</thead>
<tbody>
{#each usage.breakdown as row}
<tr>
<td>
<span
class={row.kind === 'external_capability'
? 'saas-badge-primary'
: row.kind === 'model_completion'
? 'saas-badge-success'
: 'saas-badge-primary'}
>
{usageKindLabel(row.kind)}
</span>
</td>
<td class="font-mono text-xs">{row.provider}</td>
<td class="font-mono text-xs">{sourceLabel(row)}</td>
<td class="tabular-nums">{fmtNum(row.factCount)}</td>
<td class="tabular-nums">
<div>{meterCell(row)}</div>
<div class="text-[11px] text-surface-600">{meterHint(row)}</div>
</td>
<td class="tabular-nums text-surface-700">
{fmtNum(row.factsWithCost)} / {fmtNum(row.factsWithoutCost)}
</td>
<td class="tabular-nums font-medium">{fmtCost(row.costUsd)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<div class="saas-card overflow-hidden">
<div class="border-b border-surface-200 px-5 py-3">
<h3 class="text-sm font-semibold text-surface-800">按项目</h3>
<p class="saas-muted mt-0.5 text-xs">项目仍是权限边界;行内成本已含该项目全部 fact 类型。</p>
</div>
{#if usage.projects.length === 0}
<EmptyState title="暂无项目" description="创建项目并触发智能体后会出现用量。" />
{:else}
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>项目</th>
<th>运行</th>
<th>有成本 / 未知</th>
<th>in / out tokens</th>
<th>成本</th>
<th></th>
</tr>
</thead>
<tbody>
{#each usage.projects as p}
<tr>
<td class="font-medium">{p.projectName}</td>
<td class="tabular-nums">{fmtNum(p.runCount)}</td>
<td class="tabular-nums text-surface-700">
{fmtNum(p.runsWithCost)} / {fmtNum(p.runsWithoutCost)}
</td>
<td class="tabular-nums text-surface-600">{fmtTokens(p.inputTokens, p.outputTokens)}</td>
<td class="tabular-nums">{fmtCost(p.costUsd)}</td>
<td class="text-right">
<a class="text-sm text-primary-700 hover:underline" href={`/admin/projects/${p.projectId}`}>
查看项目
</a>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{/if}
+351 -3
View File
@@ -1,13 +1,16 @@
{
"name": "@paradigm/hub",
"version": "0.0.31",
"version": "0.0.35",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@paradigm/hub",
"version": "0.0.31",
"version": "0.0.35",
"dependencies": {
"@alicloud/credentials": "^2.4.5",
"@alicloud/docmind-api20220711": "^1.4.15",
"@alicloud/tea-util": "^1.4.11",
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
"@fastify/cookie": "^11.0.2",
"@larksuiteoapi/node-sdk": "^1.70.0",
@@ -74,6 +77,175 @@
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@alicloud/credentials": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/@alicloud/credentials/-/credentials-2.4.5.tgz",
"integrity": "sha512-od1ufCxOO7cP2R4EVFliOB0kGo9lUXCibyj/mzmI6yLhxeqhqsegTzVsx5p2NJJsceKJnYcmye7FWKyLJAFBkw==",
"license": "MIT",
"dependencies": {
"@alicloud/tea-typescript": "^1.8.0",
"httpx": "^2.3.3",
"ini": "^1.3.5",
"kitx": "^2.0.0"
}
},
"node_modules/@alicloud/darabonba-array": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@alicloud/darabonba-array/-/darabonba-array-0.1.2.tgz",
"integrity": "sha512-ZPuQ+bJyjrd8XVVm55kl+ypk7OQoi1ZH/DiToaAEQaGvgEjrTcvQkg71//vUX/6cvbLIF5piQDvhrLb+lUEIPQ==",
"license": "ISC",
"dependencies": {
"@alicloud/tea-typescript": "^1.7.1"
}
},
"node_modules/@alicloud/darabonba-encode-util": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@alicloud/darabonba-encode-util/-/darabonba-encode-util-0.0.2.tgz",
"integrity": "sha512-mlsNctkeqmR0RtgE1Rngyeadi5snLOAHBCWEtYf68d7tyKskosXDTNeZ6VCD/UfrUu4N51ItO8zlpfXiOgeg3A==",
"license": "ISC",
"dependencies": {
"moment": "^2.29.1"
}
},
"node_modules/@alicloud/darabonba-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@alicloud/darabonba-map/-/darabonba-map-0.0.1.tgz",
"integrity": "sha512-2ep+G3YDvuI+dRYVlmER1LVUQDhf9kEItmVB/bbEu1pgKzelcocCwAc79XZQjTcQGFgjDycf3vH87WLDGLFMlw==",
"license": "ISC",
"dependencies": {
"@alicloud/tea-typescript": "^1.7.1"
}
},
"node_modules/@alicloud/darabonba-signature-util": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/@alicloud/darabonba-signature-util/-/darabonba-signature-util-0.0.4.tgz",
"integrity": "sha512-I1TtwtAnzLamgqnAaOkN0IGjwkiti//0a7/auyVThdqiC/3kyafSAn6znysWOmzub4mrzac2WiqblZKFcN5NWg==",
"license": "ISC",
"dependencies": {
"@alicloud/darabonba-encode-util": "^0.0.1"
}
},
"node_modules/@alicloud/darabonba-signature-util/node_modules/@alicloud/darabonba-encode-util": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@alicloud/darabonba-encode-util/-/darabonba-encode-util-0.0.1.tgz",
"integrity": "sha512-Sl5vCRVAYMqwmvXpJLM9hYoCHOMsQlGxaWSGhGWulpKk/NaUBArtoO1B0yHruJf1C5uHhEJIaylYcM48icFHgw==",
"license": "ISC",
"dependencies": {
"@alicloud/tea-typescript": "^1.7.1",
"moment": "^2.29.1"
}
},
"node_modules/@alicloud/darabonba-string": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@alicloud/darabonba-string/-/darabonba-string-1.0.3.tgz",
"integrity": "sha512-NyWwrU8cAIesWk3uHL1Q7pTDTqLkCI/0PmJXC4/4A0MFNAZ9Ouq0iFBsRqvfyUujSSM+WhYLuTfakQXiVLkTMA==",
"license": "Apache-2.0",
"dependencies": {
"@alicloud/tea-typescript": "^1.5.1"
}
},
"node_modules/@alicloud/docmind-api20220711": {
"version": "1.4.15",
"resolved": "https://registry.npmjs.org/@alicloud/docmind-api20220711/-/docmind-api20220711-1.4.15.tgz",
"integrity": "sha512-QmjSDPV52d2B5bd/Dk3Zeofu+cJFPKYS+MphIHqlulfYsOynLgOaDqTRMGTK/RhcmVCwG14CyZ/15GBF00GRFw==",
"license": "Apache-2.0",
"dependencies": {
"@alicloud/credentials": "^2.4.2",
"@alicloud/openapi-core": "^1.0.0",
"@darabonba/typescript": "^1.0.0"
}
},
"node_modules/@alicloud/endpoint-util": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@alicloud/endpoint-util/-/endpoint-util-0.0.1.tgz",
"integrity": "sha512-+pH7/KEXup84cHzIL6UJAaPqETvln4yXlD9JzlrqioyCSaWxbug5FUobsiI6fuUOpw5WwoB3fWAtGbFnJ1K3Yg==",
"license": "Apache-2.0",
"dependencies": {
"@alicloud/tea-typescript": "^1.5.1",
"kitx": "^2.0.0"
}
},
"node_modules/@alicloud/gateway-pop": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@alicloud/gateway-pop/-/gateway-pop-0.0.6.tgz",
"integrity": "sha512-KF4I+JvfYuLKc3fWeWYIZ7lOVJ9jRW0sQXdXidZn1DKZ978ncfGf7i0LBfONGk4OxvNb/HD3/0yYhkgZgPbKtA==",
"license": "ISC",
"dependencies": {
"@alicloud/credentials": "^2",
"@alicloud/darabonba-array": "^0.1.0",
"@alicloud/darabonba-encode-util": "^0.0.2",
"@alicloud/darabonba-map": "^0.0.1",
"@alicloud/darabonba-signature-util": "^0.0.4",
"@alicloud/darabonba-string": "^1.0.2",
"@alicloud/endpoint-util": "^0.0.1",
"@alicloud/gateway-spi": "^0.0.8",
"@alicloud/openapi-util": "^0.3.2",
"@alicloud/tea-typescript": "^1.7.1",
"@alicloud/tea-util": "^1.4.8"
}
},
"node_modules/@alicloud/gateway-spi": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/@alicloud/gateway-spi/-/gateway-spi-0.0.8.tgz",
"integrity": "sha512-KM7fu5asjxZPmrz9sJGHJeSU+cNQNOxW+SFmgmAIrITui5hXL2LB+KNRuzWmlwPjnuA2X3/keq9h6++S9jcV5g==",
"license": "ISC",
"dependencies": {
"@alicloud/credentials": "^2",
"@alicloud/tea-typescript": "^1.7.1"
}
},
"node_modules/@alicloud/openapi-core": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@alicloud/openapi-core/-/openapi-core-1.0.8.tgz",
"integrity": "sha512-xs8LdgMDcEUqv13kZ4nl+Vd+Fc1mixTF0g1lm5QSrNWDaLUUD5vqpuMtvUZnKNnq9PITfUQ+8KunAvNQJpFo8g==",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
"@alicloud/credentials": "^2.4.2",
"@alicloud/gateway-pop": "0.0.6",
"@alicloud/gateway-spi": "^0.0.8",
"@darabonba/typescript": "^1.0.5"
}
},
"node_modules/@alicloud/openapi-util": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/@alicloud/openapi-util/-/openapi-util-0.3.3.tgz",
"integrity": "sha512-vf0cQ/q8R2U7ZO88X5hDiu1yV3t/WexRj+YycWxRutkH/xVXfkmpRgps8lmNEk7Ar+0xnY8+daN2T+2OyB9F4A==",
"license": "ISC",
"dependencies": {
"@alicloud/tea-typescript": "^1.7.1",
"@alicloud/tea-util": "^1.3.0",
"kitx": "^2.1.0",
"sm3": "^1.0.3"
}
},
"node_modules/@alicloud/tea-typescript": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@alicloud/tea-typescript/-/tea-typescript-1.8.0.tgz",
"integrity": "sha512-CWXWaquauJf0sW30mgJRVu9aaXyBth5uMBCUc+5vKTK1zlgf3hIqRUjJZbjlwHwQ5y9anwcu18r48nOZb7l2QQ==",
"license": "ISC",
"dependencies": {
"@types/node": "^12.0.2",
"httpx": "^2.2.6"
}
},
"node_modules/@alicloud/tea-typescript/node_modules/@types/node": {
"version": "12.20.55",
"resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz",
"integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==",
"license": "MIT"
},
"node_modules/@alicloud/tea-util": {
"version": "1.4.11",
"resolved": "https://registry.npmjs.org/@alicloud/tea-util/-/tea-util-1.4.11.tgz",
"integrity": "sha512-HyPEEQ8F0WoZegiCp7sVdrdm6eBOB+GCvGl4182u69LDFktxfirGLcAx3WExUr1zFWkq2OSmBroTwKQ4w/+Yww==",
"license": "Apache-2.0",
"dependencies": {
"@alicloud/tea-typescript": "^1.5.1",
"@darabonba/typescript": "^1.0.0",
"kitx": "^2.0.0"
}
},
"node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.202.tgz",
@@ -234,6 +406,24 @@
"node": ">=6.9.0"
}
},
"node_modules/@darabonba/typescript": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@darabonba/typescript/-/typescript-1.0.5.tgz",
"integrity": "sha512-pfxHFVM8I3h8K2o8skpDQLMmR5iAeQ2eNpP1HrKDEm/9ZPF8aKwDKPwwEszT1NnIo0Y3++E7x1YsVkVpGjA/xw==",
"license": "Apache License 2.0",
"dependencies": {
"@alicloud/tea-typescript": "^1.5.1",
"http-proxy-agent": "^5.0.0",
"https-proxy-agent": "^5.0.1",
"httpx": "^2.3.2",
"lodash": "^4.17.21",
"moment": "^2.30.1",
"moment-timezone": "^0.5.45",
"socks-proxy-agent": "^6.2.1",
"ws": "^8.18.0",
"xml2js": "^0.6.2"
}
},
"node_modules/@emnapi/core": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
@@ -1396,6 +1586,15 @@
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@tootallnate/once": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz",
"integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==",
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
@@ -2830,6 +3029,20 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/http-proxy-agent": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
"integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==",
"license": "MIT",
"dependencies": {
"@tootallnate/once": "2",
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
@@ -2843,6 +3056,25 @@
"node": ">= 6"
}
},
"node_modules/httpx": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/httpx/-/httpx-2.3.3.tgz",
"integrity": "sha512-k1qv94u1b6e+XKCxVbLgYlOypVP9MPGpnN5G/vxFf6tDO4V3xpz3d6FUOY/s8NtPgaq5RBVVgSB+7IHpVxMYzw==",
"license": "MIT",
"dependencies": {
"@types/node": "^20",
"debug": "^4.1.1"
}
},
"node_modules/httpx/node_modules/@types/node": {
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/iconv-lite": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
@@ -2867,12 +3099,17 @@
"license": "ISC",
"peer": true
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 12"
}
@@ -2972,6 +3209,15 @@
"license": "BSD-2-Clause",
"peer": true
},
"node_modules/kitx": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/kitx/-/kitx-2.2.0.tgz",
"integrity": "sha512-tBMwe6AALTBQJb0woQDD40734NKzb0Kzi3k7wQj9ar3AbP9oqhoVrdXPh7rk2r00/glIgd0YbToIUJsnxWMiIg==",
"license": "MIT",
"dependencies": {
"@types/node": "^22.5.4"
}
},
"node_modules/light-my-request": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
@@ -3270,6 +3516,12 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash.identity": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/lodash.identity/-/lodash.identity-3.0.0.tgz",
@@ -3357,6 +3609,27 @@
"node": ">= 0.6"
}
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/moment-timezone": {
"version": "0.5.48",
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz",
"integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==",
"license": "MIT",
"dependencies": {
"moment": "^2.29.4"
},
"engines": {
"node": "*"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -3976,6 +4249,15 @@
"license": "MIT",
"peer": true
},
"node_modules/sax": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
"integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
}
},
"node_modules/secure-json-parse": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
@@ -4193,6 +4475,50 @@
"dev": true,
"license": "ISC"
},
"node_modules/sm3": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sm3/-/sm3-1.0.3.tgz",
"integrity": "sha512-KyFkIfr8QBlFG3uc3NaljaXdYcsbRy1KrSfc4tsQV8jW68jAktGeOcifu530Vx/5LC+PULHT0Rv8LiI8Gw+c1g==",
"license": "MIT"
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.1.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks-proxy-agent": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz",
"integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==",
"license": "MIT",
"dependencies": {
"agent-base": "^6.0.2",
"debug": "^4.3.3",
"socks": "^2.6.2"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
@@ -4700,6 +5026,28 @@
}
}
},
"node_modules/xml2js": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
"license": "MIT",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+5 -2
View File
@@ -1,12 +1,15 @@
{
"name": "@paradigm/hub",
"version": "0.0.31",
"version": "0.0.35",
"private": true,
"type": "module",
"engines": {
"node": ">=24"
},
"dependencies": {
"@alicloud/credentials": "^2.4.5",
"@alicloud/docmind-api20220711": "^1.4.15",
"@alicloud/tea-util": "^1.4.11",
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
"@fastify/cookie": "^11.0.2",
"@larksuiteoapi/node-sdk": "^1.70.0",
@@ -27,7 +30,7 @@
"axios": "1.18.1"
}
},
"description": "Curriculum Project Hub — org-scoped Feishu collaboration and confined Agent runtime. Aligns to spec/System through ADR-0024.",
"description": "Curriculum Project Hub — org-scoped Feishu collaboration and confined Agent runtime. Semantics pinned by docs/adr/ (ADR-0001 through ADR-0027).",
"scripts": {
"dev": "npm run prisma:migrate && tsx watch src/server.ts",
"build": "tsc -p tsconfig.json && npm run admin:build",
@@ -1,6 +1,6 @@
-- ADR-0023 rejected the legacy `PlatformRoleAssignment` / `PlatformRole`{ADMIN,TEACHER}
-- model: the platform administration control plane is a separate identity/session/
-- audit surface (see `Spec.System.PlatformAdministration`), intentionally not built
-- audit surface, intentionally not built
-- in alpha (ADR-0025, `hub/deploy/README.md`). The legacy table has no runtime
-- reader — no guard, route, or service queries it for an authorization decision —
-- and ADR-0023 requires it to be migrated/replaced before the platform panel ships.
+4 -4
View File
@@ -1,6 +1,6 @@
// Prisma schema for Curriculum Project Hub.
//
// Aligns to spec/System (ADR-0001..0004, 0017). Key divergences from the
// Aligns to ADR-0001..0004, 0017. Key divergences from the
// legacy teaching-material-host-service schema, each deliberate:
//
// - AgentSession is provider/model-bound. Provider runtime cursors such as
@@ -11,8 +11,8 @@
// - ProjectGroupBinding is project→chat only (ADR-0001 1:1); legacy mixed
// user/chat targets into one binding table.
// - PermissionGrant + PermissionSettings land (ADR-0004), missing in legacy.
// - AgentRunStatus adds WAITING_FOR_USER + TIMED_OUT (spec RunState; enum
// completeness OPEN — add states without a schema migration war).
// - AgentRunStatus adds WAITING_FOR_USER + TIMED_OUT (the run-state set is
// open — add states without a schema migration war).
generator client {
provider = "prisma-client-js"
@@ -61,7 +61,7 @@ enum OrganizationStatus {
}
/// Org-scoped membership role. Distinct from project PermissionRole and from
/// the platform administrator surface (ADR-0023 / Spec.System.PlatformAdministration),
/// the platform administrator surface (ADR-0023),
/// which is a separate control plane not modeled in alpha (ADR-0025).
model OrganizationMembership {
id String @id @default(cuid())
+89
View File
@@ -0,0 +1,89 @@
/**
* Smoke test: real Aliyun docmind API call using createReadStream.
* Run: node --experimental-strip-types scripts/smoke-docmind.ts <pdf-path>
*/
import DocmindClient from "@alicloud/docmind-api20220711";
import { RuntimeOptions } from "@alicloud/tea-util";
import { createReadStream } from "node:fs";
import { basename } from "node:path";
const accessKeyId = process.env["ALIBABA_CLOUD_ACCESS_KEY_ID"];
const accessKeySecret = process.env["ALIBABA_CLOUD_ACCESS_KEY_SECRET"];
if (accessKeyId === undefined || accessKeySecret === undefined) {
console.error("Set ALIBABA_CLOUD_ACCESS_KEY_ID and ALIBABA_CLOUD_ACCESS_KEY_SECRET env vars.");
process.exit(1);
}
const pdfPath = process.argv[2] ?? "../examples/trial/prime_sieve.pdf";
console.log(`Parsing: ${pdfPath}`);
const client = new DocmindClient.default({
endpoint: "docmind-api.cn-hangzhou.aliyuncs.com",
accessKeyId,
accessKeySecret,
type: "access_key",
regionId: "cn-hangzhou",
} as never);
const fileStream = createReadStream(pdfPath);
const runtime = new RuntimeOptions({});
console.log("Submitting job...");
const submitResp = await client.submitDocParserJobAdvance(
new DocmindClient.SubmitDocParserJobAdvanceRequest({
fileUrlObject: fileStream,
fileName: basename(pdfPath),
outputFormat: ["markdown"],
formulaEnhancement: true,
}),
runtime,
);
const jobId = submitResp.body?.data?.id;
console.log("Job ID:", jobId);
if (jobId === undefined) {
console.error("No job id:", JSON.stringify(submitResp.body));
process.exit(1);
}
console.log("Polling...");
const deadline = Date.now() + 5 * 60_000;
let status = "";
let markdownUrl = "";
let pageCount = 0;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 10_000));
const resp = await client.queryDocParserStatus(
new DocmindClient.QueryDocParserStatusRequest({ id: jobId }),
);
const data = (resp.body as { data?: { status?: string; pageCountEstimate?: number; outputFormatResult?: Array<{ outputFileUrl?: string; outputType?: string }> } }).data;
status = data?.status ?? "";
console.log(` status: ${status}`);
if (status === "success") {
const md = data?.outputFormatResult?.find((r) => r.outputType === "markdown");
markdownUrl = md?.outputFileUrl ?? "";
pageCount = data?.pageCountEstimate ?? 0;
break;
}
if (status === "fail") {
console.error("Job failed!");
process.exit(1);
}
}
if (status !== "success" || markdownUrl === "") {
console.error("Failed or timed out:", status);
process.exit(1);
}
console.log("Downloading markdown from OSS...");
const mdResp = await fetch(markdownUrl);
const markdown = await mdResp.text();
console.log("--- Result ---");
console.log("Pages:", pageCount);
console.log("Cost (USD, est):", ((pageCount > 0 ? pageCount : 1) * 0.0056).toFixed(4));
console.log("Job ID:", jobId);
console.log("Markdown length:", markdown.length, "chars");
console.log("--- Markdown (first 1000 chars) ---");
console.log(markdown.slice(0, 1000));
+63
View File
@@ -0,0 +1,63 @@
---
name: pdf-to-md
description: >
Convert PDF documents to Markdown bundles using the convert_pdf_to_md tool.
Handles PDFs from Feishu messages, local workspace files, and produces
high-quality Markdown with LaTeX formulas and extracted images.
---
# PDF to Markdown Conversion
## When to use
Use this skill when the user asks to convert a PDF to Markdown, extract text
from a PDF, or turn a PDF document into an editable format.
## How it works
The `convert_pdf_to_md` tool (provided by the `cph_hub` MCP server) calls
Alibaba Cloud Document Mind to parse the PDF. It:
- Extracts text in reading order (handles multi-column, scanned, and
multi-language documents)
- Converts mathematical formulas to **LaTeX** (`$...$` inline, `$$...$$` block)
- Extracts tables as Markdown tables
- Downloads embedded images into the output directory
- Writes a single `document.md` file plus image files
## Workflow
### PDF from a Feishu message
1. Use `feishu_read_context` to find the `file_key` of the PDF attachment.
2. Use `feishu_download_resource` to download it into the workspace.
3. Use `convert_pdf_to_md` with the downloaded file path and an output directory.
### PDF already in the workspace
1. Use `convert_pdf_to_md` directly with the file path and an output directory.
## Important rules
- **Always** use `convert_pdf_to_md` for PDF→Markdown. Do NOT attempt to parse
PDFs yourself with Read, Bash, Python, or any other method. The tool provides
accurate formula, table, and image extraction that manual methods cannot
match.
- If `convert_pdf_to_md` fails because no capability connection is configured,
tell the user to ask their organization admin to configure the Aliyun
docmind credential in the admin web UI (组织后台 → 能力).
- The output directory will be created if it does not exist.
- After conversion, use `send_file` to send the generated markdown back to the
user if they requested it.
## Output
The tool returns a list of generated files:
- `document.md` — the main markdown file
- `*.jpg` / `*.png` — extracted images, referenced from the markdown
## Cost
The conversion is billed per page (0.04 CNY/page ≈ $0.0056/page for the
enhanced formula mode). The cost is automatically recorded on the run's
usage ledger.
+17 -6
View File
@@ -432,16 +432,16 @@ async function resolvePostLoginRedirect(
select: { organization: { select: { slug: true, name: true } } },
});
if (intended === null) return "/admin?error=not_an_active_org_member";
const orgRoot = `/admin/org/${intended.organization.slug}`;
const orgRoot = "/admin";
// Default / missing returnTo sanitizes to "/admin". Always land in the org
// admin SPA (not the legacy static "close this tab" complete page).
if (returnTo === "/admin") {
return orgRoot;
}
return returnTo === orgRoot || returnTo.startsWith(`${orgRoot}/`) ? returnTo : orgRoot;
return normalizeAdminReturnTo(returnTo) ?? orgRoot;
}
if (returnTo !== "/admin" && returnTo.startsWith("/admin")) {
return returnTo;
return normalizeAdminReturnTo(returnTo) ?? "/admin";
}
const membership = await prisma.organizationMembership.findFirst({
where: {
@@ -454,7 +454,7 @@ async function resolvePostLoginRedirect(
orderBy: { createdAt: "asc" },
});
if (membership !== null) {
return `/admin/org/${membership.organization.slug}`;
return "/admin";
}
// Member-only or no org: still land on a shell page (SPA will explain).
const any = await prisma.organizationMembership.findFirst({
@@ -463,7 +463,7 @@ async function resolvePostLoginRedirect(
orderBy: { createdAt: "asc" },
});
if (any !== null) {
return `/admin/org/${any.organization.slug}`;
return "/admin/projects";
}
return "/admin/login?error=no_organization";
}
@@ -489,7 +489,18 @@ export function sanitizeReturnTo(raw: string): string {
if (!raw.startsWith("/admin")) {
return "/admin";
}
return raw;
return normalizeAdminReturnTo(raw) ?? "/admin";
}
/** Map legacy `/admin/org/:slug[...]` bookmarks onto slugless `/admin[...]` paths. */
export function normalizeAdminReturnTo(path: string): string | null {
if (!path.startsWith("/admin")) return null;
const legacy = path.match(/^\/admin\/org\/[^/]+(\/.*)?$/);
if (legacy) {
const rest = legacy[1] ?? "";
return rest === "" ? "/admin" : `/admin${rest}`;
}
return path;
}
function trimTrailingSlash(url: string): string {
@@ -0,0 +1,131 @@
/**
* ADR-0027: Admin routes for organization-scoped capability connections.
* GET /api/org/:orgSlug/capability-connections — list all
* GET /api/org/:orgSlug/capability-connections/:capId — read one
* PUT /api/org/:orgSlug/capability-connections/:capId — rotate/create
* DELETE /api/org/:orgSlug/capability-connections/:capId — disable
*/
import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
import { CapabilityConnectionService } from "../../capability/capabilityConnectionService.js";
import { CapabilityReadinessError, type CapabilityReadinessProbe } from "../../capability/capabilityReadiness.js";
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
import { handleRouteError } from "../errors.js";
export interface CapabilityConnectionRouteConfig {
readonly prisma: PrismaClient;
readonly sessionSecret: string;
readonly secretEnvelope: LocalSecretEnvelope;
readonly readinessProbe?: CapabilityReadinessProbe;
}
export async function registerCapabilityConnectionRoutes(
app: FastifyInstance,
config: CapabilityConnectionRouteConfig,
): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
const connections = new CapabilityConnectionService(
config.prisma,
config.secretEnvelope,
config.readinessProbe,
);
app.get("/api/org/:orgSlug/capability-connections", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return { connections: await connections.list(auth.organization.id) };
} catch (error) {
request.log.error({ requestId: request.id, operation: "capability_connection.list" }, "list failed");
return handleRouteError(reply, error);
}
});
app.get("/api/org/:orgSlug/capability-connections/:capabilityId", async (request, reply) => {
try {
const { orgSlug, capabilityId } = request.params as { orgSlug: string; capabilityId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return { connection: await connections.read(auth.organization.id, capabilityId) };
} catch (error) {
request.log.error({ requestId: request.id, operation: "capability_connection.read" }, "read failed");
return handleRouteError(reply, error);
}
});
app.put("/api/org/:orgSlug/capability-connections/:capabilityId", async (request, reply) => {
try {
const { orgSlug, capabilityId } = request.params as { orgSlug: string; capabilityId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = parseBody(request.body);
const result = await connections.rotate({
organizationId: auth.organization.id,
capabilityId,
actorUserId: auth.user.id,
...body,
});
request.log.info({
organizationId: auth.organization.id,
capabilityId,
connectionId: result.id,
status: result.status,
secretVersion: result.activeVersion,
}, result.created ? "Capability Connection created" : "Capability Connection rotated");
const { created, ...metadata } = result;
return reply.status(created ? 201 : 200).send(metadata);
} catch (error) {
const facts = error instanceof CapabilityReadinessError
? {
errorCode: error.code,
failureCategory: error.category,
...(error.upstreamStatus !== undefined ? { upstreamStatus: error.upstreamStatus } : {}),
}
: { errorCode: "capability_connection_write_failed" };
request.log.error({ requestId: request.id, operation: "capability_connection.rotate", ...facts }, "rotate failed");
return handleRouteError(reply, error);
}
});
app.delete("/api/org/:orgSlug/capability-connections/:capabilityId", async (request, reply) => {
try {
const { orgSlug, capabilityId } = request.params as { orgSlug: string; capabilityId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const result = await connections.disable({
organizationId: auth.organization.id,
capabilityId,
actorUserId: auth.user.id,
});
request.log.info({
organizationId: auth.organization.id,
capabilityId,
connectionId: result.id,
status: result.status,
}, "Capability Connection disabled");
return reply.send(result);
} catch (error) {
request.log.error({ requestId: request.id, operation: "capability_connection.disable" }, "disable failed");
return handleRouteError(reply, error);
}
});
}
function parseBody(value: unknown): { readonly accessKeyId: string; readonly accessKeySecret: string; readonly endpoint: string } {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("invalid capability credential body");
}
const body = value as Record<string, unknown>;
for (const name of ["accessKeyId", "accessKeySecret", "endpoint"] as const) {
if (typeof body[name] !== "string" || (body[name] as string).trim() === "") {
throw new Error(`${name} is required`);
}
}
return {
accessKeyId: body["accessKeyId"] as string,
accessKeySecret: body["accessKeySecret"] as string,
endpoint: body["endpoint"] as string,
};
}
+11
View File
@@ -22,6 +22,8 @@ import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
import type { ProviderReadinessProbe } from "../../connections/providerReadiness.js";
import type { FeishuReadinessProbe } from "../../connections/feishuReadiness.js";
import { registerFeishuApplicationConnectionRoutes } from "./feishuApplicationConnectionRoutes.js";
import { registerCapabilityConnectionRoutes } from "./capabilityConnectionRoutes.js";
import type { CapabilityReadinessProbe } from "../../capability/capabilityReadiness.js";
export interface OrgRouteConfig {
readonly prisma: PrismaClient;
@@ -30,6 +32,7 @@ export interface OrgRouteConfig {
readonly secretEnvelope: LocalSecretEnvelope;
readonly providerReadinessProbe?: ProviderReadinessProbe;
readonly feishuConnectionReadinessProbe?: FeishuReadinessProbe;
readonly capabilityReadinessProbe?: CapabilityReadinessProbe;
}
export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteConfig): Promise<void> {
@@ -138,4 +141,12 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo
? { readinessProbe: config.feishuConnectionReadinessProbe }
: {}),
});
await registerCapabilityConnectionRoutes(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
secretEnvelope: config.secretEnvelope,
...(config.capabilityReadinessProbe !== undefined
? { readinessProbe: config.capabilityReadinessProbe }
: {}),
});
}
+3
View File
@@ -14,6 +14,7 @@ export const CPH_HUB_MCP_TOOL_IDS = [
"feishu_read_context",
"feishu_download_resource",
"request_approval",
"convert_pdf_to_md",
] as const;
export type CphHubMcpToolId = (typeof CPH_HUB_MCP_TOOL_IDS)[number];
@@ -50,10 +51,12 @@ const ROLE_TOOL_TO_CPH_HUB_MCP_TOOL = new Map<string, CphHubMcpToolId>([
["feishu_read_context", "feishu_read_context"],
["feishu_download_resource", "feishu_download_resource"],
["request_approval", "request_approval"],
["convert_pdf_to_md", "convert_pdf_to_md"],
["mcp__cph_hub__send_file", "send_file"],
["mcp__cph_hub__feishu_read_context", "feishu_read_context"],
["mcp__cph_hub__feishu_download_resource", "feishu_download_resource"],
["mcp__cph_hub__request_approval", "request_approval"],
["mcp__cph_hub__convert_pdf_to_md", "convert_pdf_to_md"],
]);
const SUPPORTED_ROLE_TOOLS = new Set([
+3 -3
View File
@@ -24,10 +24,10 @@
* denied by default and re-opened only for the workspace plus named system
* runtimes, and `failIfUnavailable` hard-fails if the sandbox can't start. The
* subprocess gets a minimal environment and SDK credential protection removes
* provider secrets from Bash. This upholds `AgentFileOp.Authorized`
* (ADR-0018 / `Spec.System.AgentSurface`) without re-implementing the
* provider secrets from Bash. This upholds the workspace-bounded file-op
* invariant (ADR-0018) without re-implementing the
* `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox
* is the mechanism, the contract pins the invariant.
* is the mechanism, the ADR pins the invariant.
*/
import { query, type HookCallback, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKUserMessage, type SDKResultMessage, type SDKPartialAssistantMessage, type SDKSystemMessage } from "@anthropic-ai/claude-agent-sdk";
import type { PrismaClient } from "@prisma/client";
@@ -0,0 +1,266 @@
/**
* ADR-0027: Organization-scoped capability connection service. Manages the
* lifecycle (rotate / read / disable) of capability credentials stored in
* ADR-0024 encrypted envelopes with purpose="capability".
*
* Mirrors FeishuApplicationConnectionService, but keyed by (organizationId,
* capabilityId) instead of 1:1 — an org may have multiple capabilities.
*/
import { randomUUID } from "node:crypto";
import type { Prisma, PrismaClient } from "@prisma/client";
import { lockActiveOrganization } from "../org/status.js";
import { LocalSecretEnvelope } from "../security/secretEnvelope.js";
import { probeDocmindCredential, type CapabilityReadinessProbe } from "./capabilityReadiness.js";
import type { CapabilitySecretPayload } from "./types.js";
const CAPABILITY_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const KNOWN_CAPABILITY_IDS = new Set(["pdf_to_md_bundle", "audio_video_to_text"]);
export interface CapabilityCredentialInput {
readonly accessKeyId: string;
readonly accessKeySecret: string;
readonly endpoint: string;
}
export interface RotateCapabilityInput extends CapabilityCredentialInput {
readonly organizationId: string;
readonly capabilityId: string;
readonly actorUserId: string;
}
export interface CapabilityConnectionMetadata {
readonly id: string;
readonly capabilityId: string;
readonly status: "DRAFT" | "ACTIVE" | "DISABLED";
readonly activeVersion: number | null;
readonly keyId: string | null;
readonly createdAt: Date;
readonly updatedAt: Date;
}
export interface CapabilityConnectionWriteResult extends CapabilityConnectionMetadata {
readonly created: boolean;
}
export type CapabilitySecretPayloadV1 = CapabilitySecretPayload;
export class CapabilityConnectionService {
constructor(
private readonly prisma: PrismaClient,
private readonly secrets: LocalSecretEnvelope,
private readonly readinessProbe: CapabilityReadinessProbe = probeDocmindCredential,
) {}
async rotate(input: RotateCapabilityInput): Promise<CapabilityConnectionWriteResult> {
if (!CAPABILITY_ID_PATTERN.test(input.capabilityId)) {
throw new Error(`invalid capabilityId: ${input.capabilityId}`);
}
const payload = validateCredential(input);
await this.prisma.$transaction(async (tx) => {
await requireCapabilityAdmin(tx, input);
});
await this.readinessProbe({
endpoint: payload.endpoint,
accessKeyId: payload.accessKeyId,
accessKeySecret: payload.accessKeySecret,
});
return this.prisma.$transaction(async (tx) => {
await requireCapabilityAdmin(tx, input);
const connection = await tx.organizationCapabilityConnection.upsert({
where: {
organizationId_capabilityId: {
organizationId: input.organizationId,
capabilityId: input.capabilityId,
},
},
update: {},
create: {
id: randomUUID(),
organizationId: input.organizationId,
capabilityId: input.capabilityId,
status: "DRAFT",
},
});
await tx.$queryRaw`SELECT "id" FROM "OrganizationCapabilityConnection" WHERE "id" = ${connection.id} FOR UPDATE`;
const locked = await tx.organizationCapabilityConnection.findUniqueOrThrow({
where: { id: connection.id },
include: {
activeSecretVersion: true,
secretVersions: { orderBy: { version: "desc" }, take: 1, select: { version: true } },
},
});
if (locked.organizationId !== input.organizationId) {
throw new Error("Capability Connection scope changed during rotation");
}
const version = (locked.secretVersions[0]?.version ?? 0) + 1;
const secretVersionId = randomUUID();
const envelope = this.secrets.encryptJson(
{
purpose: "capability",
organizationId: input.organizationId,
connectionId: locked.id,
secretVersionId,
},
payload,
);
const now = new Date();
const secretVersion = await tx.capabilityCredentialVersion.create({
data: {
id: secretVersionId,
connectionId: locked.id,
version,
envelopeVersion: envelope.version,
keyId: envelope.keyId,
envelope: envelope as unknown as Prisma.InputJsonValue,
createdByUserId: input.actorUserId,
},
});
if (locked.activeSecretVersion !== null) {
await tx.capabilityCredentialVersion.update({
where: { id: locked.activeSecretVersion.id },
data: { retiredAt: now },
});
}
const activated = await tx.organizationCapabilityConnection.update({
where: { id: locked.id },
data: {
status: "ACTIVE",
activeSecretVersionId: secretVersion.id,
activatedAt: now,
disabledAt: null,
},
});
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
actorUserId: input.actorUserId,
action: version === 1 ? "capability.created" : "capability.rotated",
metadata: {
connectionId: locked.id,
capabilityId: input.capabilityId,
status: "ACTIVE",
secretVersion: version,
keyId: envelope.keyId,
},
},
});
return {
...toMetadata(activated, { version, keyId: secretVersion.keyId }),
created: version === 1,
};
});
}
async list(organizationId: string): Promise<CapabilityConnectionMetadata[]> {
const connections = await this.prisma.organizationCapabilityConnection.findMany({
where: { organizationId },
include: { activeSecretVersion: { select: { version: true, keyId: true } } },
orderBy: { capabilityId: "asc" },
});
return connections.map((c) => toMetadata(c, c.activeSecretVersion));
}
async read(organizationId: string, capabilityId: string): Promise<CapabilityConnectionMetadata | null> {
const connection = await this.prisma.organizationCapabilityConnection.findFirst({
where: { organizationId, capabilityId },
include: { activeSecretVersion: { select: { version: true, keyId: true } } },
});
return connection === null ? null : toMetadata(connection, connection.activeSecretVersion);
}
async disable(input: {
readonly organizationId: string;
readonly capabilityId: string;
readonly actorUserId: string;
}): Promise<CapabilityConnectionMetadata> {
return this.prisma.$transaction(async (tx) => {
await requireCapabilityAdmin(tx, input);
const connection = await tx.organizationCapabilityConnection.findFirst({
where: { organizationId: input.organizationId, capabilityId: input.capabilityId },
select: { id: true },
});
if (connection === null) throw new Error("Capability Connection not found");
await tx.$queryRaw`SELECT "id" FROM "OrganizationCapabilityConnection" WHERE "id" = ${connection.id} FOR UPDATE`;
const locked = await tx.organizationCapabilityConnection.findUniqueOrThrow({
where: { id: connection.id },
include: { activeSecretVersion: { select: { version: true, keyId: true } } },
});
if (locked.status === "DISABLED") return toMetadata(locked, locked.activeSecretVersion);
const disabled = await tx.organizationCapabilityConnection.update({
where: { id: locked.id },
data: { status: "DISABLED", disabledAt: new Date() },
});
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
actorUserId: input.actorUserId,
action: "capability.disabled",
metadata: {
connectionId: locked.id,
capabilityId: input.capabilityId,
previousStatus: locked.status,
status: "DISABLED",
},
},
});
return toMetadata(disabled, locked.activeSecretVersion);
});
}
}
function validateCredential(input: RotateCapabilityInput): CapabilitySecretPayloadV1 {
if (!KNOWN_CAPABILITY_IDS.has(input.capabilityId)) {
throw new Error(`unsupported capabilityId: ${input.capabilityId}`);
}
const accessKeyId = nonEmpty(input.accessKeyId, "accessKeyId");
const accessKeySecret = nonEmpty(input.accessKeySecret, "accessKeySecret");
const endpoint = nonEmpty(input.endpoint, "endpoint");
return { schemaVersion: 1, accessKeyId, accessKeySecret, endpoint };
}
function toMetadata(
connection: {
readonly id: string;
readonly capabilityId: string;
readonly status: string;
readonly createdAt: Date;
readonly updatedAt: Date;
},
secret: { readonly version: number; readonly keyId: string } | null,
): CapabilityConnectionMetadata {
return {
id: connection.id,
capabilityId: connection.capabilityId,
status: connection.status as CapabilityConnectionMetadata["status"],
activeVersion: secret?.version ?? null,
keyId: secret?.keyId ?? null,
createdAt: connection.createdAt,
updatedAt: connection.updatedAt,
};
}
async function requireCapabilityAdmin(
tx: Prisma.TransactionClient,
input: { readonly organizationId: string; readonly actorUserId: string },
): Promise<void> {
await lockActiveOrganization(tx, input.organizationId);
const membership = await tx.organizationMembership.findFirst({
where: {
organizationId: input.organizationId,
userId: input.actorUserId,
role: { in: ["OWNER", "ADMIN"] },
revokedAt: null,
},
select: { id: true },
});
if (membership === null) {
throw new Error("only Organization OWNER or ADMIN may manage capability connections");
}
}
function nonEmpty(value: string, label: string): string {
const trimmed = value.trim();
if (trimmed === "") throw new Error(`${label} must not be empty`);
return trimmed;
}
+3 -3
View File
@@ -63,8 +63,8 @@ export async function resolveCapabilityCredential(
organizationId: connection.organizationId,
capabilityId: connection.capabilityId,
schemaVersion: 1,
baseUrl: payload.baseUrl,
apiToken: payload.apiToken,
projectId: payload.projectId,
accessKeyId: payload.accessKeyId,
accessKeySecret: payload.accessKeySecret,
endpoint: payload.endpoint,
};
}
+69
View File
@@ -0,0 +1,69 @@
/**
* ADR-0027: Capability readiness probe. Validates the Alibaba Cloud docmind
* credential by calling QueryDocParserStatus with a dummy id — a 400 (bad
* request) means the credential is valid (the API accepted auth but rejected
* the id); a 401/403 means the credential is bad.
*/
import { classifyNetworkFailure, type NetworkFailureCategory } from "../connections/networkFailure.js";
export interface CapabilityReadinessInput {
readonly endpoint: string;
readonly accessKeyId: string;
readonly accessKeySecret: string;
}
export type CapabilityReadinessProbe = (input: CapabilityReadinessInput) => Promise<void>;
export class CapabilityReadinessError extends Error {
constructor(
readonly code: "capability_readiness_unsupported" | "capability_readiness_unreachable" | "capability_readiness_rejected",
message: string,
readonly category: NetworkFailureCategory | "configuration" | "http",
readonly upstreamStatus?: number,
) {
super(message);
this.name = "CapabilityReadinessError";
}
}
/**
* Probe the Alibaba Cloud docmind credential. We call QueryDocParserStatus
* with a dummy id. The API will return:
* - 400 (InvalidParameter) → credential valid, just a bad id → probe passes
* - 401/403 (InvalidAccessKey/Forbidden) → credential invalid → probe fails
* - network error → unreachable
*/
export const probeDocmindCredential: CapabilityReadinessProbe = async (input) => {
const url = `https://${input.endpoint}/?Action=QueryDocParserStatus&Id=probe-test&Version=2022-07-11`;
const authHeader = makeBasicAuth(input.accessKeyId, input.accessKeySecret);
let response: Response;
try {
response = await fetch(url, {
method: "GET",
headers: { authorization: authHeader, accept: "application/json" },
redirect: "manual",
signal: AbortSignal.timeout(10_000),
});
} catch (error) {
throw new CapabilityReadinessError(
"capability_readiness_unreachable",
"docmind credential readiness check could not reach the API",
classifyNetworkFailure(error),
);
}
await response.body?.cancel();
if (response.status === 401 || response.status === 403) {
throw new CapabilityReadinessError(
"capability_readiness_rejected",
`docmind credential rejected: status ${response.status}`,
"http",
response.status,
);
}
};
function makeBasicAuth(accessKeyId: string, accessKeySecret: string): string {
const credentials = Buffer.from(`${accessKeyId}:${accessKeySecret}`).toString("base64");
return `Basic ${credentials}`;
}
+231
View File
@@ -0,0 +1,231 @@
/**
* ADR-0027: Alibaba Cloud Document Mind (docmind) client.
*
* Uses the official @alicloud/docmind-api20220711 SDK to call the document
* parsing (large model version) API. The API is asynchronous:
* 1. SubmitDocParserJobAdvance — upload local file as a stream, get job id
* 2. QueryDocParserStatus — poll until data.status === "success";
* the markdown output URL is returned in outputFormatResult
* 3. Download markdown from OSS, extract and download referenced images
*
* Pricing (2025-07, aliyun docmind):
* - 图文文档基础链路: 0.02元/页
* - 图文文档增强链路 (含公式 LaTeX): 0.04元/页
* - 视频: 0.002元/秒
* - 音频: 0.00035元/秒
*/
import $DocmindClient, {
SubmitDocParserJobAdvanceRequest,
QueryDocParserStatusRequest,
} from "@alicloud/docmind-api20220711";
import { RuntimeOptions } from "@alicloud/tea-util";
import { createReadStream } from "node:fs";
import { basename } from "node:path";
import type { CapabilitySecretPayload } from "./types.js";
/** A single extracted image downloaded from the markdown's OSS image URLs. */
export interface DocmindExtractedImage {
readonly filename: string;
readonly data: Uint8Array;
}
/** The structured result of parsing one document. */
export interface DocmindParseResult {
readonly markdown: string;
readonly images: readonly DocmindExtractedImage[];
readonly pageCount: number;
readonly costUsd: number | null;
readonly requestId: string | null;
}
export interface DocmindParseOptions {
readonly inputFilePath: string;
}
export interface CapabilityProviderClient {
parse(credential: CapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult>;
}
export class DocmindClientError extends Error {
constructor(
message: string,
readonly code: "docmind_unreachable" | "docmind_rejected" | "docmind_invalid_response" | "docmind_no_output" | "docmind_timeout",
readonly upstreamStatus?: number,
) {
super(message);
this.name = "DocmindClientError";
}
}
/** Cost per page in USD (0.04 CNY/page ≈ 0.0056 USD). */
const COST_PER_PAGE_USD = 0.0056;
const POLL_INTERVAL_MS = 10_000;
const POLL_TIMEOUT_MS = 5 * 60_000;
type DocmindConfig = ConstructorParameters<typeof $DocmindClient.default>[0];
export class AliyunDocmindClient implements CapabilityProviderClient {
async parse(credential: CapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult> {
const config: DocmindConfig = {
endpoint: credential.endpoint,
accessKeyId: credential.accessKeyId,
accessKeySecret: credential.accessKeySecret,
type: "access_key",
regionId: "cn-hangzhou",
} as DocmindConfig;
const client = new $DocmindClient.default(config);
// 1. Submit job with local file as a ReadStream (not a Buffer — the SDK
// serializes Buffers as JSON {type:"Buffer",data:[...]} which the API
// can't read; a Stream is uploaded as multipart form data).
const fileName = basename(options.inputFilePath);
const fileStream = createReadStream(options.inputFilePath);
const advanceRequest = new SubmitDocParserJobAdvanceRequest({
fileUrlObject: fileStream,
fileName,
outputFormat: ["markdown"],
formulaEnhancement: true,
});
const runtime = new RuntimeOptions({});
let submitResponse;
try {
submitResponse = await client.submitDocParserJobAdvance(advanceRequest, runtime);
} catch (e) {
throw new DocmindClientError(
e instanceof Error ? e.message : String(e),
"docmind_unreachable",
);
}
const jobId = submitResponse.body?.data?.id;
if (jobId === undefined || jobId === null || jobId === "") {
throw new DocmindClientError("docmind submit returned no job id", "docmind_invalid_response");
}
// 2. Poll QueryDocParserStatus until data.status === "success" or "fail".
const deadline = Date.now() + POLL_TIMEOUT_MS;
let status = "";
let markdownUrl: string | null = null;
let pageCount = 0;
while (Date.now() < deadline) {
await sleep(POLL_INTERVAL_MS);
const statusReq = new QueryDocParserStatusRequest({ id: jobId });
const statusResponse = await client.queryDocParserStatus(statusReq);
const body = statusResponse.body as {
data?: {
status?: string;
pageCountEstimate?: number;
outputFormatResult?: Array<{ outputFileUrl?: string; outputType?: string }>;
};
};
status = body.data?.status ?? "";
if (status === "success") {
const mdResult = body.data?.outputFormatResult?.find((r) => r.outputType === "markdown");
markdownUrl = mdResult?.outputFileUrl ?? null;
pageCount = body.data?.pageCountEstimate ?? 0;
break;
}
if (status === "fail") {
throw new DocmindClientError(`docmind job ${jobId} failed`, "docmind_rejected");
}
}
if (status !== "success") {
throw new DocmindClientError(`docmind job ${jobId} timed out (status: ${status})`, "docmind_timeout");
}
if (markdownUrl === null) {
throw new DocmindClientError("docmind returned no markdown output URL", "docmind_no_output");
}
// 3. Download the markdown file from OSS.
let markdown: string;
try {
const mdResp = await fetch(markdownUrl);
if (!mdResp.ok) {
throw new DocmindClientError(`failed to download markdown: HTTP ${mdResp.status}`, "docmind_unreachable");
}
markdown = await mdResp.text();
} catch (e) {
if (e instanceof DocmindClientError) throw e;
throw new DocmindClientError(
e instanceof Error ? e.message : String(e),
"docmind_unreachable",
);
}
if (markdown === "") {
throw new DocmindClientError("docmind returned empty markdown", "docmind_no_output");
}
// 4. Download images referenced in the markdown (OSS URLs).
// Markdown contains ![filename](http://...oss.../image.png?...) entries.
// We rewrite them to local relative paths and download the images.
const images: DocmindExtractedImage[] = [];
const imageRefRegex = /!\[([^\]]*)\]\((https?:\/\/[^)]+)\)/g;
const localMarkdown = markdown.replace(imageRefRegex, (match, altText: string, url: string) => {
const idx = images.findIndex((img) => img.filename === extractFilename(altText, url, images.length));
if (idx >= 0) {
const img = images[idx]!;
return `![${altText}](${img.filename})`;
}
return match;
});
// Collect all image URLs first, then download.
const imageUrls: Array<{ url: string; altText: string }> = [];
let match: RegExpExecArray | null;
const collectRegex = /!\[([^\]]*)\]\((https?:\/\/[^)]+)\)/g;
while ((match = collectRegex.exec(markdown)) !== null) {
imageUrls.push({ url: match[2]!, altText: match[1]! });
}
for (let i = 0; i < imageUrls.length; i++) {
const { url, altText } = imageUrls[i]!;
const filename = extractFilename(altText, url, i);
try {
const imgResp = await fetch(url);
if (!imgResp.ok) continue;
const data = new Uint8Array(await imgResp.arrayBuffer());
images.push({ filename, data });
} catch {
// Best-effort: skip images that fail to download.
}
}
// Rewrite markdown with local image paths.
let finalMarkdown = markdown;
let imageIdx = 0;
finalMarkdown = finalMarkdown.replace(imageRefRegex, (match, altText: string, _url: string) => {
if (imageIdx < images.length) {
const img = images[imageIdx]!;
imageIdx++;
return `![${altText}](${img.filename})`;
}
return match;
});
if (pageCount === 0) {
pageCount = Math.max(1, images.length);
}
const costUsd = (pageCount > 0 ? pageCount : 1) * COST_PER_PAGE_USD;
return { markdown: finalMarkdown, images, pageCount, costUsd, requestId: jobId };
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
/** Derive a clean filename from the alt text or URL. */
function extractFilename(altText: string, url: string, index: number): string {
// Try alt text first (docmind often puts the original filename).
if (altText !== "" && altText.length < 100) {
const cleaned = altText.replace(/[^a-zA-Z0-9._-]/g, "_");
if (cleaned.length > 0) return cleaned;
}
// Fall back to URL path.
const urlPath = new URL(url).pathname;
const base = basename(urlPath);
if (base !== "" && base !== "/") return base;
return `image_${index + 1}.png`;
}
-66
View File
@@ -1,66 +0,0 @@
/**
* ADR-0027: MinerU client interface. Isolates the real HTTP client so the
* adapter is testable without network access. The real implementation (filling
* in actual MinerU API calls) is deferred until credentials and pricing are
* confirmed; the interface and a mock implementation land now so the adapter
* wiring, UsageFact attribution, and workspace containment are provable.
*
* MinerU cloud API shape (from mineru.net docs / GitHub README):
* - REST API, async task endpoint POST /tasks (v3.0+) + sync POST /file_parse
* - Auth: Bearer token (apiToken)
* - Output: Markdown + extracted images, returned as a zip or structured JSON
* - Cost: reported per-page (pricing requires account confirmation)
*
* The interface models the synchronous parse path for simplicity; the real
* client may poll the async endpoint internally and is free to do so behind
* this signature.
*/
import type { CapabilitySecretPayload } from "./types.js";
/** A single extracted image from the parsed document. */
export interface MineruExtractedImage {
/** Suggested relative filename (e.g. "page_1_fig_0.jpg"). */
readonly filename: string;
/** Raw image bytes. */
readonly data: Uint8Array;
}
/** The structured result of parsing one document. */
export interface MineruParseResult {
/** Markdown text with image references (relative to output dir). */
readonly markdown: string;
/** Images extracted from the document, to be written alongside the md. */
readonly images: readonly MineruExtractedImage[];
/** Number of pages processed (for UsageFact quantity, unit "pages"). */
readonly pageCount: number;
/** USD cost if the API reported it; null if unknown (ADR-0022). */
readonly costUsd: number | null;
/** External task/request id for the UsageFact correlationId. */
readonly requestId: string | null;
}
/** Options passed to the client. */
export interface MineruParseOptions {
/** Absolute path to the input PDF on the Hub's filesystem. */
readonly inputFilePath: string;
}
/**
* Client interface for the MinerU document parsing service. The real
* implementation makes authenticated HTTP calls; tests inject a mock.
*/
export interface MineruClient {
parse(credential: CapabilitySecretPayload, options: MineruParseOptions): Promise<MineruParseResult>;
}
/** Errors raised by the MinerU client. */
export class MineruClientError extends Error {
constructor(
message: string,
readonly code: "mineru_unreachable" | "mineru_rejected" | "mineru_invalid_response" | "mineru_no_output",
readonly upstreamStatus?: number,
) {
super(message);
this.name = "MineruClientError";
}
}
+8 -8
View File
@@ -20,7 +20,7 @@ import { join, resolve, relative, isAbsolute } from "node:path";
import type { PrismaClient } from "@prisma/client";
import { LocalSecretEnvelope } from "../security/secretEnvelope.js";
import { resolveCapabilityCredential } from "./capabilityConnections.js";
import { MineruClientError, type MineruClient } from "./mineruClient.js";
import { DocmindClientError, type CapabilityProviderClient } from "./docmindClient.js";
import {
CAPABILITIES,
type CapabilityAdapter,
@@ -30,7 +30,7 @@ import {
} from "./types.js";
const CAPABILITY_ID = "pdf_to_md_bundle" as const;
const PROVIDER_ID = "mineru";
const PROVIDER_ID = "aliyun_docmind";
/** Thrown when a requested path escapes the workspace root (ADR-0018). */
export class CapabilityPathEscape extends Error {
@@ -59,11 +59,11 @@ function confineToWorkspace(requestedPath: string, workspaceDir: string): string
export interface PdfToMdBundleDeps {
readonly secrets: LocalSecretEnvelope;
readonly client: MineruClient;
readonly client: CapabilityProviderClient;
readonly prisma: PrismaClient;
}
/** Build the pdf_to_md_bundle adapter. The MineruClient is injectable for testing. */
/** Build the pdf_to_md_bundle adapter. The client is injectable for testing. */
export function createPdfToMdBundleAdapter(deps: PdfToMdBundleDeps): CapabilityAdapter {
return {
capabilityId: CAPABILITY_ID,
@@ -85,14 +85,14 @@ export function createPdfToMdBundleAdapter(deps: PdfToMdBundleDeps): CapabilityA
try {
result = await deps.client.parse(credential, { inputFilePath: absoluteInput });
} catch (e) {
if (e instanceof MineruClientError) throw e;
throw new MineruClientError(
if (e instanceof DocmindClientError) throw e;
throw new DocmindClientError(
e instanceof Error ? e.message : String(e),
"mineru_unreachable",
"docmind_unreachable",
);
}
if (result.markdown === "") {
throw new MineruClientError("MinerU returned empty markdown", "mineru_no_output");
throw new DocmindClientError("docmind returned empty markdown", "docmind_no_output");
}
// 4. Write outputs into the workspace.
+5 -4
View File
@@ -80,12 +80,13 @@ export interface CapabilityAdapter {
invoke(input: CapabilityInvocationInput): Promise<CapabilityInvocationResult>;
}
/** Decrypted capability credential (CapabilitySecretPayloadV1, ADR-0027). */
/** Decrypted capability credential (CapabilitySecretPayloadV1, ADR-0027).
* Alibaba Cloud Document Mind (docmind) uses AccessKey ID + Secret + endpoint. */
export interface CapabilitySecretPayload {
readonly schemaVersion: 1;
readonly baseUrl: string;
readonly apiToken: string;
readonly projectId: string | null;
readonly accessKeyId: string;
readonly accessKeySecret: string;
readonly endpoint: string;
}
/** Thrown when an org has no ACTIVE capability connection (fail-closed, ADR-0024). */
+2 -2
View File
@@ -1,6 +1,6 @@
/**
* ADR-0022 capacity dimensions (spec `Spec.System.Capacity.CapacityDimension`).
* The 23 PINNED dimensions; exact numeric ceilings are `OPEN` and calibrated by
* ADR-0022 capacity dimensions.
* The 23 pinned dimensions; exact numeric ceilings are open and calibrated by
* capacity testing. This module is the single source of the dimension set shared
* by the platform-ceiling config and the org capacity-policy service.
*/
+1
View File
@@ -250,6 +250,7 @@ async function initializeSilo(
data: {
organizationId: input.organization.id,
name: "Inbox",
kind: "SYSTEM_INBOX",
sortKey: "000000",
},
});
+59
View File
@@ -7,11 +7,16 @@ import { readFeishuContext } from "./read.js";
import type { ApprovalManager } from "./approval.js";
import { CPH_HUB_MCP_TOOL_IDS, type CphHubMcpToolId } from "../agent/roleTools.js";
import { WorkspaceFileBoundaryError } from "../security/workspaceFiles.js";
import type { PrismaClient } from "@prisma/client";
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
import { createPdfToMdBundleAdapter } from "../capability/pdfToMdBundle.js";
import { AliyunDocmindClient } from "../capability/docmindClient.js";
export interface FileDeliveryToolOptions {
readonly rt: FeishuRuntime;
readonly chatId: string;
readonly projectId: string;
readonly organizationId: string;
readonly runId: string;
readonly workspaceRoot?: string | undefined;
readonly workspaceDir: string;
@@ -20,6 +25,8 @@ export interface FileDeliveryToolOptions {
readonly approvalManager: ApprovalManager;
readonly onDelivered?: (path: string) => void;
readonly tools?: readonly CphHubMcpToolId[] | undefined;
readonly prisma: PrismaClient;
readonly secretEnvelope: LocalSecretEnvelope;
}
export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): McpSdkServerConfigWithInstance {
@@ -230,6 +237,51 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
);
}
if (enabledTools.has("convert_pdf_to_md")) {
const adapter = createPdfToMdBundleAdapter({
secrets: options.secretEnvelope,
client: new AliyunDocmindClient(),
prisma: options.prisma,
});
tools.push(
tool(
"convert_pdf_to_md",
"Convert a PDF file in the workspace to a Markdown bundle (markdown + extracted images) using Alibaba Cloud Document Mind. The PDF must already be in the workspace (use feishu_download_resource first if it came from Feishu). Returns the path to the generated markdown file and the list of extracted image paths. Mathematical formulas are converted to LaTeX.",
{
input_path: z.string().describe("Relative path to the input PDF within the workspace."),
output_dir: z.string().describe("Relative directory within the workspace to write the markdown and images into. Will be created if it does not exist."),
},
async (args) => {
try {
const result = await adapter.invoke({
runId: options.runId,
organizationId: options.organizationId,
projectId: options.projectId,
workspaceDir: options.workspaceDir,
inputPath: args.input_path,
outputDir: args.output_dir,
prisma: options.prisma,
});
const lines = [`Converted PDF to markdown. ${result.artifacts.length} files written:`];
for (const artifact of result.artifacts) {
lines.push(` - ${artifact.path} (${artifact.kind})`);
}
lines.push(`Pages: ${result.consumption.quantity}, Cost: $${(result.consumption.costUsd ?? 0).toFixed(4)}`);
return {
content: [{ type: "text", text: lines.join("\n") }],
};
} 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",
@@ -260,5 +312,12 @@ function mcpInstructions(enabledTools: ReadonlySet<CphHubMcpToolId>): string {
if (enabledTools.has("request_approval")) {
instructions.push("Use request_approval when explicit human approval or confirmation is required before continuing.");
}
if (enabledTools.has("convert_pdf_to_md")) {
instructions.push(
"Use convert_pdf_to_md when the user asks to convert a PDF to Markdown.",
"If the PDF came from a Feishu message, first use feishu_download_resource to save it to the workspace, then call convert_pdf_to_md.",
"Do NOT attempt to parse PDFs yourself with Read or Bash — always use convert_pdf_to_md for accurate text, formula, and image extraction.",
);
}
return instructions.join(" ");
}
+6 -1
View File
@@ -14,6 +14,7 @@ import { join } from "node:path";
import type { Prisma, PrismaClient } from "@prisma/client";
import { z } from "zod";
import type { FastifyBaseLogger } from "fastify";
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
import {
sendText,
sendTextMessage,
@@ -93,6 +94,7 @@ interface TriggerDeps {
readonly prisma: PrismaClient;
readonly settings: RuntimeSettings;
readonly logger: FastifyBaseLogger;
readonly secretEnvelope: LocalSecretEnvelope;
readonly runAgent?: (req: RunRequest) => Promise<RunResult>;
readonly authorizer?: PermissionAuthorizer | undefined;
readonly messageBatcherOptions?: MessageBatcherOptions | undefined;
@@ -486,6 +488,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
// Streaming agent card: single interactive card through the full run
// lifecycle (thinking → tool calls → streaming text → complete).
// Shows tool-use trace panel + reasoning panel + answer text.
const deliveredFiles: string[] = [];
const card = new StreamingAgentCard({
runId: run.id,
rt,
@@ -494,11 +497,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
patchIntervalMs: undefined,
maxMessageLength: undefined,
});
const deliveredFiles: string[] = [];
const fileDeliveryMcpServer = createFileDeliveryMcpServer({
rt,
chatId,
projectId,
organizationId: siloOrganizationId,
runId: run.id,
workspaceRoot: projectWorkspaceRoot,
workspaceDir: project.workspaceDir,
@@ -506,6 +509,8 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
sendOptions,
approvalManager,
tools: cphHubMcpToolsForRole(roleTools),
prisma: deps.prisma,
secretEnvelope: deps.secretEnvelope,
onDelivered: (path) => {
deliveredFiles.push(path);
},
+1
View File
@@ -162,6 +162,7 @@ export async function startHub(): Promise<void> {
prisma,
settings: runtimeSettings,
logger: app.log,
secretEnvelope,
projectWorkspaceRoot,
publicBaseUrl,
siloOrganizationId: siloOrganization.id,
+3 -3
View File
@@ -1,9 +1,9 @@
/**
* Org capacity policy service (ADR-0022 / spec `Spec.System.Capacity`).
* Org capacity policy service (ADR-0022).
*
* Stores per-Organization lower `organizationLimit` overrides per
* `CapacityDimension`. Enforces `LayeredLimit.Valid`: a set limit must be ≤ the
* platform ceiling for that dimension. `LayeredLimit.effective` (min of the two)
* `CapacityDimension`. Enforces the layered-limit invariant: a set limit must be ≤ the
* platform ceiling for that dimension. The effective limit (min of the two)
* is the value capacity admission should use; dimensions with no org override
* fall back to the platform ceiling.
*/
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* Organization membership management for org admin (ADR-0021).
*
* Role rules (product pin, not yet in Lean):
* Role rules (product pin, not yet recorded in an ADR):
* 1. Actor must be OWNER or ADMIN (enforced at HTTP layer).
* 2. Only OWNER can grant/revoke OWNER or modify another OWNER.
* 3. Cannot revoke or demote the last remaining OWNER.
+285 -124
View File
@@ -9,13 +9,14 @@
* external-capability consumption (PDF→MD, ASR, …) is attributed correctly,
* not just the main model loop. A run with no cost-bearing fact is
* `runsWithoutCost` — ADR-0022: missing cost ≠ zero.
*
* Breakdown buckets keep kind / provider / model / capability / unit, so the
* admin UI can separate model tokens from non-token external meters instead of
* collapsing everything into a single input/output token total.
*/
import type { Prisma, PrismaClient } from "@prisma/client";
export interface ProjectUsageRow {
readonly projectId: string;
readonly projectName: string;
readonly folderId: string | null;
export interface UsageTotals {
readonly runCount: number;
readonly runsWithCost: number;
readonly runsWithoutCost: number;
@@ -24,21 +25,50 @@ export interface ProjectUsageRow {
readonly costUsd: number | null;
}
export interface ProjectUsageRow extends UsageTotals {
readonly projectId: string;
readonly projectName: string;
readonly folderId: string | null;
}
/** One ledger slice: kind + provider + model + capability + unit. */
export interface UsageBreakdownRow {
readonly kind: string;
readonly provider: string;
readonly model: string | null;
readonly capabilityId: string | null;
readonly unit: string | null;
readonly factCount: number;
readonly factsWithCost: number;
readonly factsWithoutCost: number;
readonly inputTokens: number;
readonly outputTokens: number;
/** Sum of quantity when unit is non-null; null when this bucket is token-only. */
readonly quantity: number | null;
readonly costUsd: number | null;
}
export interface UsageReport {
readonly from: string | null;
readonly to: string | null;
readonly projects: readonly ProjectUsageRow[];
readonly totals: {
readonly runCount: number;
readonly runsWithCost: number;
readonly runsWithoutCost: number;
readonly inputTokens: number;
readonly outputTokens: number;
readonly costUsd: number | null;
};
readonly totals: UsageTotals;
readonly breakdown: readonly UsageBreakdownRow[];
}
export interface ProjectUsageReport extends ProjectUsageRow {
readonly from: string | null;
readonly to: string | null;
readonly breakdown: readonly UsageBreakdownRow[];
}
type FactRow = {
readonly kind: string;
readonly provider: string;
readonly model: string | null;
readonly capabilityId: string | null;
readonly unit: string | null;
readonly quantity: unknown;
readonly inputTokens: number | null;
readonly outputTokens: number | null;
readonly costUsd: unknown;
@@ -49,26 +79,91 @@ type RunWithFacts = {
readonly usageFacts: readonly FactRow[];
};
/** A run is "recorded with cost" if any of its facts carries a known costUsd. */
function runHasRecordedCost(facts: readonly FactRow[]): boolean {
return facts.some((f) => f.costUsd !== null && f.costUsd !== undefined);
}
type MutableTotals = {
runCount: number;
runsWithCost: number;
runsWithoutCost: number;
inputTokens: number;
outputTokens: number;
costUsd: number | null;
};
type MutableBreakdown = {
kind: string;
provider: string;
model: string | null;
capabilityId: string | null;
unit: string | null;
factCount: number;
factsWithCost: number;
factsWithoutCost: number;
inputTokens: number;
outputTokens: number;
quantity: number | null;
costUsd: number | null;
};
type MutableProjectRow = MutableTotals & {
readonly projectId: string;
readonly projectName: string;
readonly folderId: string | null;
};
const FACT_SELECT = {
kind: true,
provider: true,
model: true,
capabilityId: true,
unit: true,
quantity: true,
inputTokens: true,
outputTokens: true,
costUsd: true,
} as const;
/** Decimal→number for Prisma Decimal; null/undefined → null. */
function factCostUsdToNumber(value: unknown): number | null {
if (value === null || value === undefined) return null;
const n = typeof value === "number" ? value : Number(value);
return Number.isFinite(n) ? n : null;
}
interface RunRollup {
function factQuantityToNumber(value: unknown): number | null {
if (value === null || value === undefined) return null;
const n = typeof value === "number" ? value : Number(value);
return Number.isFinite(n) ? n : null;
}
function breakdownKey(f: FactRow): string {
return [
f.kind,
f.provider,
f.model ?? "",
f.capabilityId ?? "",
f.unit ?? "",
].join("\u0000");
}
function emptyMutableTotals(): MutableTotals {
return {
runCount: 0,
runsWithCost: 0,
runsWithoutCost: 0,
inputTokens: 0,
outputTokens: 0,
costUsd: null,
};
}
function addCost(existing: number | null, add: number): number {
return (existing ?? 0) + add;
}
function rollupRunTokensAndCost(facts: readonly FactRow[]): {
readonly hasCost: boolean;
readonly inputTokens: number;
readonly outputTokens: number;
readonly costUsd: number | null;
}
function rollupRun(facts: readonly FactRow[]): RunRollup {
} {
let inputTokens = 0;
let outputTokens = 0;
let costUsd: number | null = null;
@@ -79,12 +174,118 @@ function rollupRun(facts: readonly FactRow[]): RunRollup {
const c = factCostUsdToNumber(f.costUsd);
if (c !== null) {
hasCost = true;
costUsd = (costUsd ?? 0) + c;
costUsd = addCost(costUsd, c);
}
}
return { hasCost, inputTokens, outputTokens, costUsd };
}
function accumulateBreakdown(
buckets: Map<string, MutableBreakdown>,
facts: readonly FactRow[],
): void {
for (const f of facts) {
const key = breakdownKey(f);
let bucket = buckets.get(key);
if (bucket === undefined) {
bucket = {
kind: f.kind,
provider: f.provider,
model: f.model,
capabilityId: f.capabilityId,
unit: f.unit,
factCount: 0,
factsWithCost: 0,
factsWithoutCost: 0,
inputTokens: 0,
outputTokens: 0,
quantity: null,
costUsd: null,
};
buckets.set(key, bucket);
}
bucket.factCount += 1;
bucket.inputTokens += f.inputTokens ?? 0;
bucket.outputTokens += f.outputTokens ?? 0;
const qty = factQuantityToNumber(f.quantity);
if (qty !== null) {
bucket.quantity = (bucket.quantity ?? 0) + qty;
}
const c = factCostUsdToNumber(f.costUsd);
if (c !== null) {
bucket.factsWithCost += 1;
bucket.costUsd = addCost(bucket.costUsd, c);
} else {
bucket.factsWithoutCost += 1;
}
}
}
function freezeBreakdown(buckets: Map<string, MutableBreakdown>): UsageBreakdownRow[] {
return [...buckets.values()]
.map((b) => ({
kind: b.kind,
provider: b.provider,
model: b.model,
capabilityId: b.capabilityId,
unit: b.unit,
factCount: b.factCount,
factsWithCost: b.factsWithCost,
factsWithoutCost: b.factsWithoutCost,
inputTokens: b.inputTokens,
outputTokens: b.outputTokens,
quantity: b.quantity,
costUsd: b.costUsd,
}))
.sort((a, b) => {
const kindCmp = a.kind.localeCompare(b.kind);
if (kindCmp !== 0) return kindCmp;
const provCmp = a.provider.localeCompare(b.provider);
if (provCmp !== 0) return provCmp;
const capA = a.capabilityId ?? "";
const capB = b.capabilityId ?? "";
const capCmp = capA.localeCompare(capB);
if (capCmp !== 0) return capCmp;
const modelA = a.model ?? "";
const modelB = b.model ?? "";
return modelA.localeCompare(modelB);
});
}
function freezeTotals(t: MutableTotals): UsageTotals {
return {
runCount: t.runCount,
runsWithCost: t.runsWithCost,
runsWithoutCost: t.runsWithoutCost,
inputTokens: t.inputTokens,
outputTokens: t.outputTokens,
costUsd: t.costUsd,
};
}
function applyRunToTotals(totals: MutableTotals, facts: readonly FactRow[]): void {
const roll = rollupRunTokensAndCost(facts);
totals.runCount += 1;
if (roll.hasCost) {
totals.runsWithCost += 1;
totals.costUsd = addCost(totals.costUsd, roll.costUsd ?? 0);
} else {
totals.runsWithoutCost += 1;
}
totals.inputTokens += roll.inputTokens;
totals.outputTokens += roll.outputTokens;
}
function emptyReport(from?: Date, to?: Date): UsageReport {
return {
from: from?.toISOString() ?? null,
to: to?.toISOString() ?? null,
projects: [],
totals: freezeTotals(emptyMutableTotals()),
breakdown: [],
};
}
export async function getOrgUsage(
prisma: PrismaClient,
input: {
@@ -108,101 +309,65 @@ export async function getOrgUsage(
}
const projectIds = projects.map((p) => p.id);
const runWhere: Prisma.AgentRunWhereInput = {
projectId: { in: projectIds },
...(input.from !== undefined || input.to !== undefined
? {
startedAt: {
...(input.from !== undefined ? { gte: input.from } : {}),
...(input.to !== undefined ? { lte: input.to } : {}),
},
}
: {}),
};
// Date range filters by run.startedAt, matching the pre-ADR-0026 semantics:
// a run is in or out of the window based on when it started, and all of its
// facts come along. Filtering facts by occurredAt independently would let a
// run contribute partial cost to a window it doesn't belong to.
const runs = await prisma.agentRun.findMany({
where: runWhere,
where: {
projectId: { in: projectIds },
...(input.from !== undefined || input.to !== undefined
? {
startedAt: {
...(input.from !== undefined ? { gte: input.from } : {}),
...(input.to !== undefined ? { lte: input.to } : {}),
},
}
: {}),
},
select: {
projectId: true,
usageFacts: {
select: { inputTokens: true, outputTokens: true, costUsd: true },
select: FACT_SELECT,
orderBy: { occurredAt: "asc" },
},
},
});
type MutableRow = {
readonly projectId: string;
readonly projectName: string;
readonly folderId: string | null;
runCount: number;
runsWithCost: number;
runsWithoutCost: number;
inputTokens: number;
outputTokens: number;
costUsd: number | null;
};
const byProject = new Map<string, MutableRow>();
const byProject = new Map<string, MutableProjectRow>();
for (const p of projects) {
byProject.set(p.id, {
projectId: p.id,
projectName: p.name,
folderId: p.folderId,
runCount: 0,
runsWithCost: 0,
runsWithoutCost: 0,
inputTokens: 0,
outputTokens: 0,
costUsd: null,
...emptyMutableTotals(),
});
}
const breakdownBuckets = new Map<string, MutableBreakdown>();
for (const run of runs as readonly RunWithFacts[]) {
const row = byProject.get(run.projectId);
if (row === undefined) continue;
const roll = rollupRun(run.usageFacts);
row.runCount += 1;
if (roll.hasCost) {
row.runsWithCost += 1;
row.costUsd = (row.costUsd ?? 0) + (roll.costUsd ?? 0);
} else {
row.runsWithoutCost += 1;
}
row.inputTokens += roll.inputTokens;
row.outputTokens += roll.outputTokens;
applyRunToTotals(row, run.usageFacts);
accumulateBreakdown(breakdownBuckets, run.usageFacts);
}
const projectsOut: ProjectUsageRow[] = [...byProject.values()].map((r) => ({
projectId: r.projectId,
projectName: r.projectName,
folderId: r.folderId,
runCount: r.runCount,
runsWithCost: r.runsWithCost,
runsWithoutCost: r.runsWithoutCost,
inputTokens: r.inputTokens,
outputTokens: r.outputTokens,
costUsd: r.costUsd,
...freezeTotals(r),
}));
let runCount = 0;
let runsWithCost = 0;
let runsWithoutCost = 0;
let inputTokens = 0;
let outputTokens = 0;
let costUsd: number | null = null;
const totals = emptyMutableTotals();
for (const row of projectsOut) {
runCount += row.runCount;
runsWithCost += row.runsWithCost;
runsWithoutCost += row.runsWithoutCost;
inputTokens += row.inputTokens;
outputTokens += row.outputTokens;
totals.runCount += row.runCount;
totals.runsWithCost += row.runsWithCost;
totals.runsWithoutCost += row.runsWithoutCost;
totals.inputTokens += row.inputTokens;
totals.outputTokens += row.outputTokens;
if (row.costUsd !== null) {
costUsd = (costUsd ?? 0) + row.costUsd;
totals.costUsd = addCost(totals.costUsd, row.costUsd);
}
}
@@ -210,14 +375,8 @@ export async function getOrgUsage(
from: input.from?.toISOString() ?? null,
to: input.to?.toISOString() ?? null,
projects: projectsOut,
totals: {
runCount,
runsWithCost,
runsWithoutCost,
inputTokens,
outputTokens,
costUsd,
},
totals: freezeTotals(totals),
breakdown: freezeBreakdown(breakdownBuckets),
};
}
@@ -229,7 +388,7 @@ export async function getProjectUsage(
readonly from?: Date | undefined;
readonly to?: Date | undefined;
},
): Promise<ProjectUsageRow> {
): Promise<ProjectUsageReport> {
const project = await prisma.project.findFirst({
where: { id: input.projectId, organizationId: input.organizationId },
select: { id: true, name: true, folderId: true },
@@ -237,39 +396,41 @@ export async function getProjectUsage(
if (project === null) {
throw new Error(`project not found: ${input.projectId}`);
}
const report = await getOrgUsage(prisma, {
organizationId: input.organizationId,
from: input.from,
to: input.to,
});
const row = report.projects.find((p) => p.projectId === project.id);
return (
row ?? {
projectId: project.id,
projectName: project.name,
folderId: project.folderId,
runCount: 0,
runsWithCost: 0,
runsWithoutCost: 0,
inputTokens: 0,
outputTokens: 0,
costUsd: null,
}
);
}
function emptyReport(from?: Date, to?: Date): UsageReport {
return {
from: from?.toISOString() ?? null,
to: to?.toISOString() ?? null,
projects: [],
totals: {
runCount: 0,
runsWithCost: 0,
runsWithoutCost: 0,
inputTokens: 0,
outputTokens: 0,
costUsd: null,
const runs = await prisma.agentRun.findMany({
where: {
projectId: project.id,
...(input.from !== undefined || input.to !== undefined
? {
startedAt: {
...(input.from !== undefined ? { gte: input.from } : {}),
...(input.to !== undefined ? { lte: input.to } : {}),
},
}
: {}),
},
select: {
usageFacts: {
select: FACT_SELECT,
orderBy: { occurredAt: "asc" },
},
},
});
const totals = emptyMutableTotals();
const breakdownBuckets = new Map<string, MutableBreakdown>();
for (const run of runs) {
applyRunToTotals(totals, run.usageFacts as readonly FactRow[]);
accumulateBreakdown(breakdownBuckets, run.usageFacts as readonly FactRow[]);
}
return {
projectId: project.id,
projectName: project.name,
folderId: project.folderId,
...freezeTotals(totals),
from: input.from?.toISOString() ?? null,
to: input.to?.toISOString() ?? null,
breakdown: freezeBreakdown(breakdownBuckets),
};
}
+20
View File
@@ -558,6 +558,26 @@ async function ensureInboxFolder(prisma: Prisma.TransactionClient, organizationI
select: { id: true },
});
if (existing !== null) return existing;
// Bootstrap once created a root "Inbox" without kind=SYSTEM_INBOX. Sibling-name
// uniqueness then makes a second create fail. Recover by promoting that row.
const legacyRootInbox = await prisma.folder.findFirst({
where: {
organizationId,
parentId: null,
name: "Inbox",
archivedAt: null,
},
select: { id: true },
});
if (legacyRootInbox !== null) {
return prisma.folder.update({
where: { id: legacyRootInbox.id },
data: { kind: "SYSTEM_INBOX" },
select: { id: true },
});
}
return prisma.folder.create({
data: { organizationId, name: "Inbox", kind: "SYSTEM_INBOX", sortKey: "000000" },
select: { id: true },
+6 -6
View File
@@ -170,7 +170,7 @@ describe("admin auth + org API guards", () => {
try {
const res = await app.inject({
method: "GET",
url: "/auth/feishu?returnTo=/admin/org/test-default",
url: "/auth/feishu?returnTo=/admin",
});
expect(res.statusCode).toBe(302);
const location = res.headers.location;
@@ -233,7 +233,7 @@ describe("admin auth + org API guards", () => {
try {
const nonce = "nonce-test-1";
const state = signOAuthState(
{ nonce, returnTo: "/admin/org/test-default" },
{ nonce, returnTo: "/admin" },
SESSION_SECRET,
);
const res = await app.inject({
@@ -242,7 +242,7 @@ describe("admin auth + org API guards", () => {
headers: { cookie: `${OAUTH_STATE_COOKIE_NAME}=${nonce}` },
});
expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe("/admin/org/test-default");
expect(res.headers.location).toBe("/admin");
expect(JSON.stringify(res.headers["set-cookie"])).toContain("cph_session=");
const user = await prisma.user.findUnique({ where: { feishuOpenId: "ou_new" } });
@@ -293,7 +293,7 @@ describe("admin auth + org API guards", () => {
try {
const start = await app.inject({
method: "GET",
url: "/auth/feishu/test-default?returnTo=/admin/org/test-default/settings",
url: "/auth/feishu/test-default?returnTo=/admin/settings",
});
expect(start.statusCode).toBe(302);
const authorize = new URL(String(start.headers.location));
@@ -308,7 +308,7 @@ describe("admin auth + org API guards", () => {
headers: { cookie: nonceCookie },
});
expect(callback.statusCode).toBe(302);
expect(callback.headers.location).toBe("/admin/org/test-default/settings");
expect(callback.headers.location).toBe("/admin/settings");
const sessionCookie = cookiePair(callback.headers["set-cookie"], "cph_session");
const me = await app.inject({ method: "GET", url: "/api/me", headers: { cookie: sessionCookie } });
expect(me.statusCode).toBe(200);
@@ -346,7 +346,7 @@ describe("admin auth + org API guards", () => {
headers: { cookie: cookiePair(defaultStart.headers["set-cookie"], OAUTH_STATE_COOKIE_NAME) },
});
expect(defaultCallback.statusCode).toBe(302);
expect(defaultCallback.headers.location).toBe("/admin/org/test-default");
expect(defaultCallback.headers.location).toBe("/admin");
await connections.disable({ organizationId: DEFAULT_ORG_ID, actorUserId: "scoped-owner" });
const revoked = await app.inject({ method: "GET", url: "/api/me", headers: { cookie: sessionCookie } });
@@ -4,11 +4,11 @@ import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { prisma, resetDb, seedTestOrganization, testSecretEnvelope, DEFAULT_ORG_ID } from "./helpers.js";
import { createPdfToMdBundleAdapter, CapabilityPathEscape } from "../../src/capability/pdfToMdBundle.js";
import { MineruClientError, type MineruClient, type MineruParseResult } from "../../src/capability/mineruClient.js";
import { DocmindClientError, type CapabilityProviderClient, type DocmindParseResult } from "../../src/capability/docmindClient.js";
import type { CapabilitySecretPayload } from "../../src/capability/types.js";
const CAPABILITY_ID = "pdf_to_md_bundle";
const PROVIDER_ID = "mineru";
const PROVIDER_ID = "aliyun_docmind";
/**
* ADR-0027: pdf_to_md_bundle adapter contract tests. Proves:
@@ -16,8 +16,8 @@ const PROVIDER_ID = "mineru";
* - fail-closed credential resolution (no ACTIVE connection → error)
* - UsageFact attribution with non-token meter (pages), costSource rules
* - outputs (md + images) written into workspace
* - MinerU client errors propagate
* The MineruClient is mocked; the prisma + envelope + filesystem are real.
* - docmind client errors propagate
* The client is mocked; the prisma + envelope + filesystem are real.
*/
describe("pdf_to_md_bundle capability adapter (ADR-0027)", () => {
let workspaceRoot: string;
@@ -58,9 +58,9 @@ describe("pdf_to_md_bundle capability adapter (ADR-0027)", () => {
async function seedActiveCapabilityConnection(): Promise<void> {
const payload: CapabilitySecretPayload = {
schemaVersion: 1,
baseUrl: "https://mineru.test/api",
apiToken: "mineru-secret-token",
projectId: null,
accessKeyId: "LTAI-test-key-id",
accessKeySecret: "test-secret-never-log",
endpoint: "docmind-api.cn-hangzhou.aliyuncs.com",
};
const connection = await prisma.organizationCapabilityConnection.create({
data: {
@@ -95,23 +95,23 @@ describe("pdf_to_md_bundle capability adapter (ADR-0027)", () => {
});
}
function mockMineruClient(result: Partial<MineruParseResult> = {}): MineruClient {
const full: MineruParseResult = {
markdown: "# Parsed Document\n\nHello world.\n\n![fig](page_1_fig_0.jpg)\n",
function mockDocmindClient(result: Partial<DocmindParseResult> = {}): CapabilityProviderClient {
const full: DocmindParseResult = {
markdown: "# Parsed Document\n\nHello world.\n\n![fig](page_1.jpg)\n",
images: [
{ filename: "page_1_fig_0.jpg", data: new Uint8Array([0xff, 0xd8, 0xff, 0xe0]) },
{ filename: "page_1.jpg", data: new Uint8Array([0xff, 0xd8, 0xff, 0xe0]) },
],
pageCount: 3,
costUsd: 0.015,
requestId: "mineru-task-abc",
costUsd: 0.0168,
requestId: "docmind-job-abc",
...result,
};
return {
parse: vi.fn(async (): Promise<MineruParseResult> => full),
parse: vi.fn(async (): Promise<DocmindParseResult> => full),
};
}
function makeAdapter(client: MineruClient) {
function makeAdapter(client: CapabilityProviderClient) {
return createPdfToMdBundleAdapter({
secrets: testSecretEnvelope,
client,
@@ -130,7 +130,7 @@ describe("pdf_to_md_bundle capability adapter (ADR-0027)", () => {
it("writes md + images into the workspace and records a UsageFact", async () => {
await seedActiveCapabilityConnection();
const inputPath = await seedInputPdf();
const client = mockMineruClient();
const client = mockDocmindClient();
const adapter = makeAdapter(client);
const result = await adapter.invoke({
@@ -147,12 +147,12 @@ describe("pdf_to_md_bundle capability adapter (ADR-0027)", () => {
const md = await readFile(join(workspaceRoot, "output", "document.md"), "utf8");
expect(md).toContain("# Parsed Document");
expect(result.artifacts.some((a) => a.kind === "markdown" && a.path === "output/document.md")).toBe(true);
expect(result.artifacts.some((a) => a.kind === "image" && a.path === "output/page_1_fig_0.jpg")).toBe(true);
expect(result.artifacts.some((a) => a.kind === "image" && a.path === "output/page_1.jpg")).toBe(true);
// Client received the decrypted credential, not the env.
const call = (client.parse as ReturnType<typeof vi.fn>).mock.calls[0]?.[0] as CapabilitySecretPayload;
expect(call.apiToken).toBe("mineru-secret-token");
expect(call.baseUrl).toBe("https://mineru.test/api");
expect(call.accessKeyId).toBe("LTAI-test-key-id");
expect(call.endpoint).toBe("docmind-api.cn-hangzhou.aliyuncs.com");
// UsageFact written with non-token meter and provider_reported cost.
const facts = await prisma.usageFact.findMany({ where: { runId } });
@@ -163,15 +163,15 @@ describe("pdf_to_md_bundle capability adapter (ADR-0027)", () => {
expect(fact.capabilityId).toBe(CAPABILITY_ID);
expect(Number(fact.quantity)).toBe(3);
expect(fact.unit).toBe("pages");
expect(Number(fact.costUsd!)).toBeCloseTo(0.015, 8);
expect(Number(fact.costUsd!)).toBeCloseTo(0.0168, 8);
expect(fact.costSource).toBe("provider_reported");
expect(fact.correlationId).toBe("mineru-task-abc");
expect(fact.correlationId).toBe("docmind-job-abc");
});
it("writes UsageFact with costSource=unknown when MinerU reports no cost", async () => {
it("writes UsageFact with costSource=unknown when docmind reports no cost", async () => {
await seedActiveCapabilityConnection();
const inputPath = await seedInputPdf();
const client = mockMineruClient({ costUsd: null, requestId: null });
const client = mockDocmindClient({ costUsd: null, requestId: null });
const adapter = makeAdapter(client);
await adapter.invoke({
@@ -194,7 +194,7 @@ describe("pdf_to_md_bundle capability adapter (ADR-0027)", () => {
it("fails closed when the org has no ACTIVE capability connection", async () => {
// No connection seeded.
const inputPath = await seedInputPdf();
const adapter = makeAdapter(mockMineruClient());
const adapter = makeAdapter(mockDocmindClient());
await expect(adapter.invoke({
runId,
@@ -213,7 +213,7 @@ describe("pdf_to_md_bundle capability adapter (ADR-0027)", () => {
it("rejects an input path that escapes the workspace", async () => {
await seedActiveCapabilityConnection();
const adapter = makeAdapter(mockMineruClient());
const adapter = makeAdapter(mockDocmindClient());
await expect(adapter.invoke({
runId,
@@ -229,7 +229,7 @@ describe("pdf_to_md_bundle capability adapter (ADR-0027)", () => {
it("rejects an output path that escapes the workspace", async () => {
await seedActiveCapabilityConnection();
const inputPath = await seedInputPdf();
const adapter = makeAdapter(mockMineruClient());
const adapter = makeAdapter(mockDocmindClient());
await expect(adapter.invoke({
runId,
@@ -242,12 +242,12 @@ describe("pdf_to_md_bundle capability adapter (ADR-0027)", () => {
})).rejects.toBeInstanceOf(CapabilityPathEscape);
});
it("propagates MinerU client errors without writing a UsageFact", async () => {
it("propagates docmind client errors without writing a UsageFact", async () => {
await seedActiveCapabilityConnection();
const inputPath = await seedInputPdf();
const client: MineruClient = {
parse: vi.fn(async (): Promise<MineruParseResult> => {
throw new MineruClientError("upstream 502", "mineru_rejected", 502);
const client: CapabilityProviderClient = {
parse: vi.fn(async (): Promise<DocmindParseResult> => {
throw new DocmindClientError("upstream 502", "docmind_rejected", 502);
}),
};
const adapter = makeAdapter(client);
@@ -269,7 +269,7 @@ describe("pdf_to_md_bundle capability adapter (ADR-0027)", () => {
it("rejects empty markdown output as a parse failure", async () => {
await seedActiveCapabilityConnection();
const inputPath = await seedInputPdf();
const client = mockMineruClient({ markdown: "" });
const client = mockDocmindClient({ markdown: "" });
const adapter = makeAdapter(client);
await expect(adapter.invoke({
@@ -101,6 +101,43 @@ describe("ADR-0021 project onboarding", () => {
]));
});
it("promotes a legacy root Inbox when Feishu chat creates a project", async () => {
await seedUser("u-member", "ou_member", "MEMBER");
// Production drift after bootstrap omitted kind=SYSTEM_INBOX. The protect
// trigger refuses demotion, so the test plants the legacy shape directly.
await prisma.$executeRawUnsafe(`ALTER TABLE "Folder" DISABLE TRIGGER cph_protect_system_inbox`);
try {
await prisma.folder.updateMany({
where: { organizationId: DEFAULT_ORG_ID, kind: "SYSTEM_INBOX", archivedAt: null },
data: { kind: "REGULAR" },
});
} finally {
await prisma.$executeRawUnsafe(`ALTER TABLE "Folder" ENABLE TRIGGER cph_protect_system_inbox`);
}
const legacy = await prisma.folder.findFirstOrThrow({
where: { organizationId: DEFAULT_ORG_ID, parentId: null, name: "Inbox", archivedAt: null },
select: { id: true, kind: true },
});
expect(legacy.kind).toBe("REGULAR");
const result = await createProjectFromFeishuChat(prisma, {
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_member",
chatId: "chat-legacy-inbox",
name: "Recovered Inbox Project",
workspaceRoot: await tempWorkspaceRoot(),
});
expect(result.folderId).toBe(legacy.id);
await expect(prisma.folder.findUniqueOrThrow({
where: { id: legacy.id },
select: { kind: true },
})).resolves.toEqual({ kind: "SYSTEM_INBOX" });
expect(await prisma.folder.count({
where: { organizationId: DEFAULT_ORG_ID, name: "Inbox", archivedAt: null },
})).toBe(1);
});
it("blocks ordinary Feishu project creation when the org setting is off", async () => {
await seedUser("u-member", "ou_member", "MEMBER");
await setMembersCanCreateProjects(prisma, {
@@ -62,6 +62,11 @@ describe("Alpha Silo bootstrap", () => {
{ roleId: "draft", label: "草稿", isDefault: true },
{ roleId: "review", label: "审校", isDefault: false },
]);
await expect(prisma.folder.findMany({
where: { organizationId: "org_alpha", archivedAt: null },
select: { name: true, kind: true, parentId: true },
})).resolves.toEqual([{ name: "Inbox", kind: "SYSTEM_INBOX", parentId: null }]);
const persisted = JSON.stringify({
feishu: await prisma.feishuApplicationCredentialVersion.findMany(),
+31 -1
View File
@@ -71,7 +71,7 @@ describe("usage rollup from UsageFact (ADR-0026)", () => {
occurredAt: new Date(startedAt.getTime() + i * 1000),
kind: f.kind ?? "model_completion",
provider: f.provider ?? "openrouter",
model: f.model ?? "mock-model",
model: f.model !== undefined ? f.model : "mock-model",
inputTokens: f.inputTokens ?? null,
outputTokens: f.outputTokens ?? null,
costUsd: f.costUsd ?? null,
@@ -158,6 +158,32 @@ describe("usage rollup from UsageFact (ADR-0026)", () => {
expect(report.totals.inputTokens).toBe(100);
expect(report.totals.outputTokens).toBe(50);
expect(report.totals.costUsd).toBeCloseTo(0.035, 8);
// ADR-0026 breakdown: model tokens vs external non-token meter must not collapse.
expect(report.breakdown).toHaveLength(2);
const modelBucket = report.breakdown.find((b) => b.kind === "model_completion");
const capBucket = report.breakdown.find((b) => b.kind === "external_capability");
expect(modelBucket).toMatchObject({
provider: "openrouter",
model: "mock-model",
capabilityId: null,
inputTokens: 100,
outputTokens: 50,
quantity: null,
costUsd: 0.02,
factCount: 1,
});
expect(capBucket).toMatchObject({
provider: "mineru",
model: null,
capabilityId: "pdf_to_md_bundle",
unit: "pages",
quantity: 12,
inputTokens: 0,
outputTokens: 0,
costUsd: 0.015,
factCount: 1,
});
});
it("a run with no facts at all is runsWithoutCost and contributes nothing", async () => {
@@ -180,6 +206,8 @@ describe("usage rollup from UsageFact (ADR-0026)", () => {
expect(row.projectId).toBe("proj-x");
expect(row.runCount).toBe(1);
expect(row.costUsd).toBeCloseTo(0.1, 8);
expect(row.breakdown).toHaveLength(1);
expect(row.breakdown[0]).toMatchObject({ kind: "model_completion", costUsd: 0.1, inputTokens: 1 });
});
it("returns an empty report for an org with no projects", async () => {
@@ -187,5 +215,7 @@ describe("usage rollup from UsageFact (ADR-0026)", () => {
expect(report.projects).toHaveLength(0);
expect(report.totals.runCount).toBe(0);
expect(report.totals.costUsd).toBeNull();
expect(report.breakdown).toEqual([]);
});
});
+9 -6
View File
@@ -68,14 +68,14 @@ describe("session cookie signing", () => {
describe("oauth state signing", () => {
it("round-trips state with returnTo", () => {
const token = signOAuthState(
{ nonce: "abc", returnTo: "/admin/org/acme" },
{ nonce: "abc", returnTo: "/admin" },
SECRET,
600,
1_700_000_000,
);
expect(verifyOAuthState(token, SECRET, 1_700_000_100)).toEqual({
nonce: "abc",
returnTo: "/admin/org/acme",
returnTo: "/admin",
exp: 1_700_000_600,
});
});
@@ -83,13 +83,13 @@ describe("oauth state signing", () => {
it("binds state to an Organization and connection as one inseparable scope", () => {
const token = signOAuthState({
nonce: "scoped",
returnTo: "/admin/org/acme",
returnTo: "/admin",
organizationId: "org-acme",
connectionId: "connection-acme",
}, SECRET, 600, 1_700_000_000);
expect(verifyOAuthState(token, SECRET, 1_700_000_100)).toEqual({
nonce: "scoped",
returnTo: "/admin/org/acme",
returnTo: "/admin",
organizationId: "org-acme",
connectionId: "connection-acme",
exp: 1_700_000_600,
@@ -98,8 +98,11 @@ describe("oauth state signing", () => {
});
describe("sanitizeReturnTo", () => {
it("allows admin paths", () => {
expect(sanitizeReturnTo("/admin/org/acme")).toBe("/admin/org/acme");
it("allows admin paths and rewrites legacy org slug prefixes", () => {
expect(sanitizeReturnTo("/admin")).toBe("/admin");
expect(sanitizeReturnTo("/admin/usage")).toBe("/admin/usage");
expect(sanitizeReturnTo("/admin/org/acme")).toBe("/admin");
expect(sanitizeReturnTo("/admin/org/acme/usage")).toBe("/admin/usage");
});
it("blocks open redirects", () => {
+1
View File
@@ -23,6 +23,7 @@ describe("isSiloHttpRateLimitExempt", () => {
expect(isSiloHttpRateLimitExempt("/favicon.svg")).toBe(true);
expect(isSiloHttpRateLimitExempt("/robots.txt")).toBe(true);
expect(isSiloHttpRateLimitExempt("/admin")).toBe(true);
expect(isSiloHttpRateLimitExempt("/admin/members")).toBe(true);
expect(isSiloHttpRateLimitExempt("/admin/org/para-26071100/members")).toBe(true);
});
-1
View File
@@ -1 +0,0 @@
/.lake
-55
View File
@@ -1,55 +0,0 @@
# spec —— Lean 语义母本
这是本 monorepo 的**契约**:产品各部件语义的上游参照,用 Lean 编写。它的定位与约束见仓库根 `README.md` 的"宪法"5 条——本文件只讲**怎么往这份契约里写东西**。
## 现状
刚初始化的 Lean 工程(`lake init`),目前只有占位内容(`Spec/Basic.lean`)。实质领域内容(System 平台层、Courseware 产品层)将逐个概念加入,每个都遵循下面的规范。
## 构建
```sh
cd spec
lake build
```
工具链锁定在 `lean-toolchain`(`leanprover/lean4:v4.31.0`)。无外部依赖——Mathlib / Batteries 等留待第一个真正需要它的定理出现时再引入(依赖碰到再加)。
## 写作规范
### 双半契约:prose + type
每个 top-level 声明**必须**带 `/-- … -/` doc 注释,用自然语言陈述其语义意图。两半缺一不可:
- **prose 半**给人读——说清"这在领域里是什么、为什么"。
- **type 半**给机器读、给 type checker 把关——保证结构无洞。
agent 不得用预训练先验脑补本领域(领域很新,无先验);prose 是 agent 理解语义的唯一权威来源。
### 标签分类法
在 doc 注释里用以下标签标注每条语义的状态:
- **`PINNED`** —— 已解决的分歧点,契约在此处权威,双方据此对齐。
- **`OPEN`** —— 故意未规定。双方均**不得假设**其解;实现遇到时必须 surface 出来讨论,而不是擅自决定。
- **`ADR-NNNN`** —— 链接到根 `docs/adr/` 下的对应决策记录(如 `ADR-0002`),交代该语义的决策出处。
### 分歧点测试(写之前先过一遍)
新增任何概念前,先问:**"不写明,开发者与 agent 会不会各自做出不同假设?"**
- 会 → 它是分歧点,入契约。
- 不会(显然的东西 / 纯 plumbing / 普通 CRUD 字段)→ 不入。
详见根 README 宪法第 5 条。
### 不用 `sorry`
无法陈述清楚的东西,用 `OPEN` 在 prose 里标注,而**不是**用 `sorry` 留一个假装成立的定理。`sorry` 会让 `lake build` 仍然变绿,却在契约里埋一个谎——这与"契约自包含、无洞"直接冲突。
### 命名
- **模块 / 命名空间**:PascalCase,对应分层,如 `Spec.System.Agent.Run``Spec.Courseware.Validity`
- **类型**:PascalCase。
- **谓词 / `Prop`**:用意图清晰的命名,如 `Legal…``ValidTransition``Can…`
- **文件粒度**:原则上"一个带独立不变式的概念一个文件"。
-5
View File
@@ -1,5 +0,0 @@
-- This module serves as the root of the `Spec` library.
-- Import modules here that should be built as part of the library.
import Spec.Prelude
import Spec.System
import Spec.Courseware
-21
View File
@@ -1,21 +0,0 @@
import Spec.Courseware.Model
import Spec.Courseware.Export
import Spec.Courseware.Check
import Spec.Courseware.Open
/-!
# Courseware —— 产品层契约(课程工程文件)
护城河:课程"工程文件"的语义母本与合法性规则。决策出处 ADR-0005..0016。按四组组织:
- **`Model`** —— 工程文件的内容模型:基元 `Primitives`、富内容 `RichContent`、原子
单位 `Element`、单节课 `Lesson`(element 的有序序列)。
- **`Export`** —— export target = artifact + 有序 typed steps:产物 ADT `Artifact`
(`singleFile` / `fileTree`),build 规格 `TargetSpec`(artifact + steps + 覆盖声明
`covers`)与 `RenderConfig`。
- **`Check`** —— checker 语义:`Severity` + 6 类诊断 + **合法 lesson = 无 error 级
诊断**(模型外设施诊断以抽象谓词 + `Oracle` 表示);检查管线的 5 阶段、序、compile
门控。
- **`Open`** —— OPEN 骨架(核心关系 OPEN,已 surface):题库 `QuestionBank`、
课程编排 `Course`。
-/
-9
View File
@@ -1,9 +0,0 @@
import Spec.Courseware.Check.Diagnostic
import Spec.Courseware.Check.Pipeline
/-!
# Courseware.Check —— checker 语义
诊断分类与合法 lesson(`Diagnostic`)、检查管线的阶段与序(`Pipeline`)。决策出处
ADR-0010。
-/
-107
View File
@@ -1,107 +0,0 @@
import Spec.Courseware.Model.Lesson
import Spec.Courseware.Export.Render
/-!
# Diagnostic —— checker 诊断:分类、严重级别、合法 lesson
产品里"站在 Lean 位置"的 rule-based checker,语义在此沉淀(ADR-0010,经 ADR-0012
修订)。它对 lesson 提诊断,每条有**分类**(`DiagKind`)与**严重级别**(`Severity`)。
定级别类型(二分);定 7 类诊断各自的含义与级别,并把"**合法 lesson = 无
error 级诊断**"建成判定(ADR-0005 deferred 的"完整合法判定"的回填);对**模型外设施**
型诊断(typst 编过否、数据合 schema 否)用**抽象谓词 + `Oracle` 实现边界**表示——契约
说"存在这条诊断、什么意思、什么级别",真值由实现提供,不在 Lean 内计算(不内嵌 typst
编译器)。引用解析(`@ref`、相对 import)不另设诊断:它们都是 typst 编译期失败,归
`typstCompile`(ADR-0012)。版本契约诊断 `cphVersionMismatch`(ADR-0016)是第 7 类。
-/
namespace Spec.Courseware
/-- 诊断严重级别(`PINNED` 二分, ADR-0005)。`error` 阻断(产物不合法),`warning` 不
阻断(产物仍可导出,只是有损)。更细级别(info/hint)未决策,故只二分。 -/
inductive Severity where
| warning
| error
/-- 诊断**分类**(`PINNED` 7 类, ADR-0010,经 ADR-0012 修订为 6 类,经 ADR-0016 增至 7
类)。按"谁来判定"分三层:**结构型**(模型自身可判):`partPathMissing`/`unknownKind`/
`cphVersionMismatch`;**schema/外部设施型**(靠实现 oracle):
`missingContentFile`/`schemaViolation`/`typstCompile`;**语义型**:`renderIgnored`。 -/
inductive DiagKind where
/-- manifest 的 part 指向不存在的文件夹(或经 `..` 逃出根)。结构型。 -/
| partPathMissing
/-- part 声明了未知 kind(含 part 与 element.toml 的 kind 不一致)。结构型。 -/
| unknownKind
/-- kind schema 要求的某 `content` 字段缺对应 `<field>.typ`。结构/schema 型。 -/
| missingContentFile
/-- 实例数据不合其 kind 的 JSON Schema;亦作 manifest/element.toml 畸形的兜底。 -/
| schemaViolation
/-- 拼装出的 typst 源编译失败:语法错、未解析的交叉引用 `@ref`、越界或缺失的相对
`import`/`include`(后两者即旧 `danglingReference` 的两种情形——typst 在编译期
检出,故归此类,ADR-0012)。外部设施型。 -/
| typstCompile
/-- 某被用到的 kind 在某声明的 target 下无渲染规则,该 element 被忽略。语义型。 -/
| renderIgnored
/-- 工程文件的 `.cph-version` 与 CLI(cph)版本不相容(ADR-0016)。**结构型**(加载期
判)。工程文件根的 `.cph-version` 声明它所面向的 cph 版本;CLI 加载时按**兼容性判
定**比对自身版本(实现侧 `CARGO_PKG_VERSION`),不相容即产此类。**当前判定为版本
完全相等才相容**(MVP;后续可放宽为 semver 区间,判定逻辑可逐步修改而不动本分类)。
`error` 级——版本不相容的工程文件不应被该 CLI 处理。 -/
| cphVersionMismatch
/-- 每类诊断的**严重级别**(`PINNED`, ADR-0010)。六类 `error`(阻断);**唯
`renderIgnored` 为 `warning`**——ADR-0005 种子规则"缺渲染 ⇒ warning,不阻断导出"。
全函数使"哪类阻断"成为可引用、可对齐的事实(实现侧 `DiagCode` 级别据此对齐)。 -/
def DiagKind.severity : DiagKind Severity
| .partPathMissing => .error
| .unknownKind => .error
| .missingContentFile => .error
| .schemaViolation => .error
| .typstCompile => .error
| .renderIgnored => .warning
| .cphVersionMismatch => .error
/-- 缺渲染诊断的级别 = **warning**(`PINNED`, ADR-0005/0010,**非 error**)。具名常量,
使"它是 warning"可被实现侧 grep 对齐(实现里同名常量 `RENDER_IGNORED_SEVERITY` 引用
本定义)。等价于 `DiagKind.renderIgnored.severity`。 -/
def renderIgnoredSeverity : Severity := DiagKind.renderIgnored.severity
variable (P : Primitives)
/-- **缺渲染诊断**:lesson 在 target `t` 下存在无法渲染的 element(`PINNED`,
ADR-0005/0009)。成立 ⟺ 存在某 element,其 kind 在 `t` 下 `covers` 为假。语义型诊断,
级别 warning。 -/
def renderIgnored (l : Lesson P) (c : RenderConfig P) (t : P.TargetId) : Prop :=
e l, ¬ c.covers e.kind t
/-!
## 模型外设施型诊断:抽象谓词 + 实现边界(ADR-0010)
`typstCompile`/`schemaViolation`(的 schema-合规面)断言的是模型自身无法判定的事实——
要跑 typst 编译器、schema 校验器。契约把它们建成**抽象谓词**,真值由实现提供的 oracle
给出。下面用 `Oracle` 收口这些判定:它不是要在 Lean 里实现 checker,而是把"这些事实
来自模型外"显式化、类型化。
-/
/-- **实现侧判定 oracle**(`PINNED` 实现边界, ADR-0010)。每个字段是一个谓词,真值由
实现(checker)提供。结构型诊断(part 路径、未知 kind)不入此 oracle——那些模型自身可判。 -/
structure Oracle (l : Lesson P) (c : RenderConfig P) where
/-- target `t` 下拼装源可编译(否 ⇒ `typstCompile`)。**含引用解析**:源能编译即蕴含
其 `@ref`、相对 import 全部解析(ADR-0012 已把引用诊断并入 `typstCompile`)。 -/
compiles : P.TargetId Prop
/-- 每个 element 数据合 schema(否 ⇒ `schemaViolation`)。 -/
dataConforms : Prop
/-- schema 要求的 content 文件齐备(否 ⇒ `missingContentFile`)。 -/
contentFilesPresent : Prop
/-- **合法 lesson**(`PINNED`, ADR-0010;回填 ADR-0005 deferred 的"完整合法判定")。
合法 ⟺ 检查管线产出**零条 error 级诊断**。展开为:模型外设施判定(经 `Oracle`)全为真,
**且**每个声明的 target 都编译通过。结构型诊断由 `cph-model` 在加载期判定;能走到这步
谈合法性意味着已加载成功,故此处聚焦 schema/外部设施层。引用解析不单列——已被
`compiles` 蕴含(ADR-0012)。`renderIgnored` 是 warning,**不**进合取——ADR-0005 种子
规则的体现。`targets` 用全称式表达以不绑定 `TargetId` 的可枚举性。 -/
def Legal (l : Lesson P) (c : RenderConfig P) (o : Oracle P l c) : Prop :=
o.dataConforms o.contentFilesPresent
( t : P.TargetId, (c.spec t).isSome o.compiles t)
end Spec.Courseware
-54
View File
@@ -1,54 +0,0 @@
import Spec.Courseware.Check.Diagnostic
/-!
# Pipeline —— checker 检查管线的阶段与序(ADR-0010)
checker 的 `check` 按**固定顺序**跑五个阶段,逐阶段收集诊断;`compile` 阶段有**门控**。
顺序与门控是契约——它决定用户看到哪些诊断(藏在缺文件背后的语法错,在文件补齐前不
显示,这是有意的)。每阶段的**算法**不进 Lean(深度上限:只定**阶段、序、
门控**)。阶段对应上游模块:`load`←`cph-model`;`structural`←part 路径/未知 kind;
`schema`←`cph-schema`;`compile`←`cph-typst`(模型外设施);`coverage`←`renderIgnored`。
-/
namespace Spec.Courseware
/-- 检查管线的**阶段**(`PINNED` 5 阶段, ADR-0010)。
- `load` —— 解析 manifest + 各 element.toml。**含 `.cph-version` 兼容性判定**
(ADR-0016:工程根 `.cph-version` 与 CLI 版本不相容 ⇒ `cphVersionMismatch` error)。
硬失败(无法解析 lesson)则**停**整条管线。
- `structural` —— part 路径存在、无 `..`、kind 已知且一致。此处判缺的 part 后续跳过。
- `schema` —— 每个"存在且 kind 已知"的 part 按其 kind schema 校验。
- `compile` —— 模型外设施阶段(typst 编译)。**门控:仅当前序零 error 才跑**。
- `coverage` —— 语义型 warning(`renderIgnored`);**不**受门控,总跑。 -/
inductive Phase where
| load
| structural
| schema
| compile
| coverage
deriving DecidableEq
/-- 管线阶段的**执行序**(`PINNED`, ADR-0010)。`order p` 越小越先跑。序是契约:
`compile`(3)排在 `structural`(1)/`schema`(2)之后,正因前者门控于后者无 error。 -/
def Phase.order : Phase Nat
| .load => 0
| .structural => 1
| .schema => 2
| .compile => 3
| .coverage => 4
/-- 某阶段是否**受"前序零 error"门控**(`PINNED`, ADR-0010)。唯 `compile` 受门控:藏在
结构/schema 错背后的编译错,在前者修好前不显示——有意降噪。 -/
def Phase.gated : Phase Bool
| .compile => true
| _ => false
/-- 管线在 `load` 硬失败时**停**(`PINNED`, ADR-0010)。`load` 拿不到可解析 lesson 时,
无 lesson 可喂下游,整条管线终止——唯一会截断后续所有阶段的情形(区别于 `compile`
门控只跳过自己)。 -/
def Phase.haltsPipelineOnFailure : Phase Bool
| .load => true
| _ => false
end Spec.Courseware
-8
View File
@@ -1,8 +0,0 @@
import Spec.Courseware.Export.Artifact
import Spec.Courseware.Export.Render
/-!
# Courseware.Export —— export target = artifact + 有序 typed steps
产物 ADT(`Artifact`)、build 规格与渲染覆盖(`Render`)。决策出处 ADR-0009 / 0011。
-/
-23
View File
@@ -1,23 +0,0 @@
/-!
# Artifact —— export target 的产物(ADR-0009 / 0011)
ADR-0009:一个 export target 是**一次 build**,产出一个**有类型的产物**。ADR-0011
固定:产物是**带字段的 ADT**——"产物到底指什么"(单文件落在哪 / 一棵树产出哪些文件)
是不好猜的领域语义,必须写进字段 + doc,而非抹成两个空构造子。路径/glob 用 `String`
承载并由 doc 赋义(它们就是文本),不复刻文件系统类型。
-/
namespace Spec.Courseware
/-- export 产物(`PINNED` 带字段 ADT, ADR-0011)。产物形状决定 `cph build` 吐文件还是
目录、reduce 怎么折叠(`singleFile` 把有序片段拼成一份再编译——交叉引用 `@ref`、例题
计数器能工作的前提;`fileTree` 每 part 落一文件)。后端/格式仍 OPEN(ADR-0009)。 -/
inductive Artifact where
/-- 单文件产物,落在 `filepath`(相对工程根)。讲义/教案 PDF 即此。 -/
| singleFile (filepath : String)
/-- 多文件产物:`root` 目录下匹配 `outputs` **glob** 的文件集(第三方平台 archive
即此)。用 glob 而非显式清单:轻,又让消费方/checker 知道该产出哪些文件、可校验
完整性。 -/
| fileTree (root : String) (outputs : String)
end Spec.Courseware
-93
View File
@@ -1,93 +0,0 @@
import Spec.Courseware.Model.Primitives
import Spec.Courseware.Export.Artifact
/-!
# Render —— export target = artifact + 有序 typed steps(ADR-0009 / 0011)
ADR-0009:export target 是一次 build,产出一个有类型的 `Artifact`。ADR-0011 固定 build
的**形状**:一个 target 是 `artifact` + 一串**有序 typed step**。
- `typstCompile template` —— 把**模板文件**(如 `exports/student.typ`)编译成产物。它是
*typed* 而非裸 shell,正因框架要把 **manifest 注入**模板(经 `--input manifest=…`),
裸字符串表达不了这个 wiring。presentation(编号、样式)住模板里,不在 manifest。
- `shell run` —— 逃生口,给难以声明的步骤(ADR-0005 的 (b) 类 medium-only)。
- `assembleMarkdown field` —— 按 parts 顺序把每个 element 的 `field`(markdown content 叶子,
ADR-0015)拼成**单文件 markdown** 产物。typed 而非 `cat`,因为按 manifest `[[parts]]` 顺序读取 +
跳过缺失项 + 写到单文件产物路径这件事,框架 own 比裸 shell 稳。
**渲染覆盖**:ADR-0011 废止了 per-target `RenderRule` 载荷——渲染的"how"已移进模板。
契约只保留覆盖声明 `covers`(该 target 渲染哪些 kind),供种子诊断用。
**shell step 的执行语义(ADR-0013)。** `shell` 不再只是占位:它**会被执行**,语义是把
`run` 交给平台 shell、以**工程根为工作目录**运行,产物由被调外部工具自己写出(框架不装配
内容)。三条边界是真分歧点,故定为契约:
1. **opt-in by construction** —— 任意命令执行只在用户**显式** build 一个 shell target 时发生,
绝不在 `check` 里跑。`check` 只校验结构(lesson 是否合法),不执行外部工具、不验其产物。
2. **失败归属** —— shell step 退出非零是一次 **build-过程失败**,不是 lesson 的合法性缺陷;
因此它**不**进 `Diagnostic` 的 6 类(那 6 类是 lesson 自身的诊断,见 `Check/Diagnostic.lean`),
而由 build 执行层报告。诊断分类保持 6 类不变(ADR-0013 显式拒绝新增 `ShellStep` 诊断码)。
3. **非-typst target 不过 typst 编译** —— 一个只含 `shell` step 的 target(教具包即此)由
执行器跑命令,而非走 typst 引擎;`check` 的 compile 阶段跳过它。
**assembleMarkdown step 的执行语义(ADR-0015)。** 与 `shell` 同属"非-typst target":框架 own
读取每个 element 的 `<field>.md`、按 `[[parts]]` 顺序拼接、写到单文件产物路径(产物是 markdown,
不经 typst 引擎)。同 shell 的三条边界:① 只在显式 `build --target` 跑,`check` 不跑;② 装配失败
(如写盘失败、引用图缺失)是 build-过程错误,不入 6 类诊断;③ 非-typst target,`check` 的 compile
阶段跳过它。**单文件产物**(现):装配器注入课程级标题为文档 h1(课程元数据,非 element 内容),
每份 `slides.md`/`transcript.md` 贡献其 `##` part 与 `###` 小节(presentation 在内容里, ADR-0011),
按 `[[parts]]` 顺序拼接(分隔符空行)。**自包含**:装配器扫描产物里的 `![](rel)` 图引用,把引用到的
本地图从工程根按原相对路径复制进产物所在 build 根,使该 build 目录可独立交付;引用图在工程根缺失属
build-过程错误(不入 6 类)。外部 URL(`http(s)://`/`data:`)不复制。结构化 FileTree 产物延后
(待 parts 树形重组, ADR-0015 OPEN)。
-/
namespace Spec.Courseware
variable (P : Primitives)
/-- 一个 build **step**(`PINNED` typed, ADR-0011;可扩展)。MVP 仅一个 `typstCompile`;
`steps` 是 list 因为 FileTree / 第三方 build 会需多步。不把模板内部、shell 命令的
解析结构写进来(实现细节, ADR-0011 OPEN)。 -/
inductive Step where
/-- 编译模板文件 `template`(相对工程根)成产物;框架注入 manifest。typed 的理由:
注入这件事裸 shell 写不出。 -/
| typstCompile (template : String)
/-- shell 逃生口:执行命令 `run`(ADR-0005 (b) 类落这)。**已实现**(ADR-0013):以工程根
为 cwd 执行,opt-in(只在显式 build 该 target 时跑,`check` 不跑),失败属 build-过程错误
而非 lesson 诊断。教具包(如 KenKen 交互 HTML 由外部 `kendoku` 生成)即走此 step。 -/
| shell (run : String)
/-- 装配 markdown:按 `[[parts]]` 顺序读取每个 element 的 `field`(markdown content 叶子,
ADR-0015),注入课程 h1 后拼接成**单文件 markdown** 产物,并收集 `![](rel)` 引用的本地图进
build 根使产物自包含。typed 而非 `cat`:按序读取+跳过缺失+注入标题+写盘+收集图由框架 own。
**已实现**(ADR-0015):非-typst target(`check` 不跑,失败属 build-过程错误不入 6 类诊断)。
slides 大纲面 / 逐字稿口播面即走此 step(直接 markdown+KaTeX 撰写,绕开 typst→md 公式转换,
ADR-0014 R2)。 -/
| assembleMarkdown (field : String)
/-- 一个 export target 的 build 规格(`PINNED` artifact + 有序 steps, ADR-0011)。 -/
structure TargetSpec where
/-- 产物(带字段, ADR-0011)。决定 build 折叠成单文件还是文件树。 -/
artifact : Artifact
/-- **有序** build steps。按序执行;MVP 仅一个 `typstCompile`。 -/
steps : List Step
/-- **覆盖声明**:`covers k` 表示此 target 渲染 kind `k`。ADR-0011 把旧
`renders : KindId → Option RenderRule` 降级后的产物——契约只声明"渲染哪些 kind"
(种子诊断 `renderIgnored` 用),"how"由 `steps` 的模板实现。 -/
covers : P.KindId Prop
/-- 渲染配置(`PINNED` target-中心, ADR-0009/0011)。`spec t = none` 表示 target `t`
未声明(不导出);`some s` 给出其 build 规格。 -/
structure RenderConfig where
/-- target ↦ 该 target 的 build 规格(未声明则 `none`)。 -/
spec : P.TargetId Option (TargetSpec P)
/-- kind `k` 在 target `t` 下**被渲染**(`PINNED`, ADR-0009/0011;承接 ADR-0005)。成立
⟺ `t` 已声明(`spec t = some s`)**且** `s.covers k`。为假即"此 kind 在此 target 下不
被渲染"——checker 据此报 warning(见 `Diagnostic.renderIgnored`)。`P` 隐式以便点记法。 -/
def RenderConfig.covers {P : Primitives} (c : RenderConfig P)
(k : P.KindId) (t : P.TargetId) : Prop :=
match c.spec t with
| none => False
| some s => s.covers k
end Spec.Courseware
-13
View File
@@ -1,13 +0,0 @@
import Spec.Courseware.Model.Primitives
import Spec.Courseware.Model.RichContent
import Spec.Courseware.Model.Element
import Spec.Courseware.Model.Lesson
import Spec.Courseware.Model.Info
/-!
# Courseware.Model —— 工程文件的内容模型
基元(`Primitives`)、富内容锚点(`RichContent`)、原子单位(`Element`)、单节课
(`Lesson`)、课时元信息(`Info`:canonical author 为列表 vs `RawInfo` 撰写态)。
决策出处 ADR-0005 / 0006 / 0008。
-/
-24
View File
@@ -1,24 +0,0 @@
import Spec.Courseware.Model.Primitives
/-!
# Element —— 课程内容的原子单位
ADR-0005:element 实例 = 一个 kind 标签 + 符合该 kind schema 的数据。把它编码成
依赖结构,使"数据必须匹配其 kind"成为类型层面的事实而非运行时校验。
-/
namespace Spec.Courseware
variable (P : Primitives)
/-- element 实例(`PINNED`, ADR-0005)。`data : P.ElementData kind` 由 `kind` 决定——
无法构造数据与 kind 不符的 element,schema 合规由类型系统保证。ADR-0005 的 (a) 类
字段(逐字稿、重点圈划)落在具体 kind 的 `ElementData` 内,不出现在此通用结构上;
交互教具等"重型 kind"在此与例题、定理同构,仅 `kind` 不同,重实现在模型之外。 -/
structure Element where
/-- 该 element 的 kind。 -/
kind : P.KindId
/-- 符合 `kind` schema 的数据(类型随 `kind` 而变)。 -/
data : P.ElementData kind
end Spec.Courseware
-58
View File
@@ -1,58 +0,0 @@
/-!
# Info —— 课时元信息:canonical 模型 vs 撰写态(authoring surface)
`[info]`(标题、作者)大多是 passthrough 元数据(ADR-0008),本不入契约。但**作者的
基数**是一个真分歧点:一节课可由多人(教研组)署名,故 canonical 模型里 author 是一个
**有序列表**,不是单值或可选单值。
另一条模式:on-disk 的**撰写态**(用户实际填写的形态)是**语法糖**——单作者可写
`author = "…"`,多作者写 `author = ["…", "…"]`——但这个"字符串或数组"的二态**只活在加载
边界**:`RawInfo` 经归一化折叠成 canonical `Info`,其后不再出现。canonical 接收端始终是
`List String`,raw 形式不泄漏进模型其余部分。这正是 `Info`(canonical)与 `RawInfo`
(撰写态)两个结构存在的理由。
-/
namespace Spec.Courseware
/-- 作者的**撰写态形式**(`PINNED` 仅填写便利, ADR-0008)。on-disk 单作者可写裸
字符串、多作者写数组——填写便利,非语义分歧。此 union **只活在加载边界**,经
`RawAuthor.normalize` 折叠后不再出现。 -/
inductive RawAuthor where
/-- 单作者裸字符串 `author = "…"`。 -/
| one (name : String)
/-- 多作者数组 `author = ["…", "…"]`。 -/
| many (names : List String)
/-- raw 作者归一化为**有序作者列表**(`PINNED`, ADR-0008)。单作者 ⇒ 单元素列表;数组
⇒ 原样。canonical 接收端始终是 `List String`。 -/
def RawAuthor.normalize : RawAuthor List String
| .one n => [n]
| .many ns => ns
/-- 课时元信息的 **canonical 模型**(`PINNED` author 为列表, ADR-0008)。`authors` 是
**有序列表**:多人署名第一类,空列表 = 未署名。`title` 等其余字段是 passthrough 元数据,
不在此承诺更多。这是系统其余部分唯一所见的形态——author 在此**已**是列表,不再是
"字符串或数组"。 -/
structure Info where
/-- 标题(passthrough 元数据)。 -/
title : String
/-- 作者**有序列表**(空 = 未署名)。canonical 始终是列表。 -/
authors : List String
/-- 撰写态的 `[info]`(`PINNED` 仅填写便利, ADR-0008)。`author` 用 `RawAuthor`
(字符串或数组),`author` 缺省即未署名。此结构刻画"为便于填写而存在的 raw 形态",
**不**是模型其余部分流通的形式——它经 `RawInfo.toInfo` 归一化为 canonical `Info`。 -/
structure RawInfo where
/-- 标题。 -/
title : String
/-- 作者 raw 形式(可选;缺省即未署名)。 -/
author : Option RawAuthor
/-- raw `[info]` 归一化为 canonical `Info`(`PINNED` 加载边界归一化, ADR-0008)。缺省
author ⇒ 空列表,否则按 `RawAuthor.normalize`。raw 的"字符串或数组"二态在此被消解,
**不**泄漏进 `Info`——canonical 接收端恒为 `List String`。 -/
def RawInfo.toInfo (r : RawInfo) : Info :=
{ title := r.title
authors := (r.author.map RawAuthor.normalize).getD [] }
end Spec.Courseware
-16
View File
@@ -1,16 +0,0 @@
import Spec.Courseware.Model.Element
/-!
# Lesson —— 单节课工程文件
ADR-0005:一个工程文件 = 一节课,是 element 实例的**有序序列**。课程/单元不是工程
文件,而是 lesson 的编排(见 `Spec.Courseware.Course`,OPEN)。
-/
namespace Spec.Courseware
/-- 一节课(`PINNED`, ADR-0005)。用 `List` 因为 **element 次序承载教学语义**(先讲
定义再举例 ≠ 反过来);**不建模时长**——ADR-0005 决定 lesson 是内容编排而非时间轴。 -/
abbrev Lesson (P : Primitives) := List (Element P)
end Spec.Courseware
@@ -1,34 +0,0 @@
/-!
# Primitives —— Courseware 契约的基元
课程工程文件模型(ADR-0005)依赖一组基元:element kind 怎么标识、某 kind 的数据
schema 是什么、export target 怎么标识。收口成载体 `Primitives`,让模型在其上参数化
——契约谈得了 element / lesson / 渲染**之间的关系**,而把每个基元的**内部表示**留给
实现。注意:某基元语义已 PINNED(如 schema 形态由 ADR-0006 固定)与其表示进 Lean
是两回事——JSON Schema / typst 的内部结构属实现细节,不入 Lean,故基元在此仍以抽象
类型承载。富内容的母本见 `Courseware.RichContent`。
-/
namespace Spec.Courseware
/-- Courseware 契约基元载体(关系 `PINNED`, ADR-0005;各基元表示留给实现, ADR-0006)。 -/
structure Primitives where
/-- element kind 标识(`PINNED` **开放宇宙**, ADR-0005;表示 `OPEN`)。用抽象
类型而非 `inductive`:ADR-0005 决定 kind 是开放可扩展宇宙(stdlib + 第三方),
封闭枚举会违背它——此处开放是**已决策的**(区别于 `RunState` 的"尚未封闭")。 -/
KindId : Type
/-- 某 kind 的合法数据类型(`PINNED` 依赖关系, ADR-0005;schema 形态 `PINNED`
ADR-0006,表示仍抽象)。以 kind 为索引:`ElementData k` 即"符合 `k` schema 的
数据"。schema 形态(声明式 JSON Schema + `content` 叶子 = typst 源)由 ADR-0006
固定,但属 JSON/typst 内部结构、实现细节,不进 Lean;契约只锚定"数据符合
kind schema"这条关系,故此处仍是抽象类型。 -/
ElementData : KindId Type
/-- export target 标识(`PINNED` 角色, ADR-0005;表示 `OPEN`)。一个 target 是一次
build,产出对 lesson 的一种投影(讲义/教案/PPT/平台 archive…),见 `Render`。 -/
TargetId : Type
-- 注:原 `RenderRule : Type` 已随 ADR-0011 移除。渲染的"how"不再是契约层 per-target
-- 载荷,而由 `Render.TargetSpec.steps` 里 `typstCompile` step 引用的**模板文件**承载;
-- 契约只保留覆盖声明 `TargetSpec.covers`(该 target 渲染哪些 kind),供种子诊断用。
end Spec.Courseware
@@ -1,44 +0,0 @@
/-!
# RichContent —— 富内容(ADR-0006 的母本)
ADR-0006:element schema 的"叶子"可以是 `content` 类型,其值是一段**源文本**,
按其 **format** 决定语义(ADR-0015)。两种 format:
- **typst** —— 一段 typst 源,语义取该源作为 module 求值后的 body content(讲义/教案面)。
- **markdown** —— 一段**原样**的 markdown + KaTeX 源,**不经 typst 求值**(slides 大纲面 / 逐字稿口播面;
ADR-0015)。直接以 markdown 撰写**绕开** typst→markdown 的公式转换难题(ADR-0014 R2):公式一开始就是
KaTeX 源(`$…$`),没有"把 typst 公式转成 md"这一步。
关键约束(均 ADR-0006,源自 typst 源码事实):**typst** format 的富内容**不可无主**——typst 的源必须有
`FileId`,否则 span 脱锚、相对 import 报"cannot access file system from here"。故每段 typst 富内容是 World 里
的一等文件,坐落在一个**虚拟路径**上;相对 import 限本工程路径结构内 + `@package`(不跨工程)。markdown format
的富内容不参与 typst 求值,但同样由一个虚拟路径定位(供 markdown 装配 step 按序读取,见 `Export/Render`)。
只立锚点 + 最小抽象签名:typst 的 `Content`/`Module` 内部结构、JSON Schema 形状、format 的
具体判别属实现细节,不进 Lean,只承诺"富内容由一个虚拟路径定位"+"叶子带 format"这两条关系。
-/
namespace Spec.Courseware
/-- 富内容在工程文件路径结构中的**虚拟路径**(`OPEN` 表示, ADR-0006)。把一段富内容
定位为 World 里的一等文件(span 可解析、相对 import 可锚定)。落盘后即真实相对路径
(ADR-0007),不在本层承诺,故 opaque。 -/
opaque VPath : Type
/-- 富内容的 **format**(`PINNED`, ADR-0015)。`content` 叶子带 format:typst 叶子被 typst 求值;
markdown 叶子原样保留(markdown+KaTeX 源,不经求值)。 -/
inductive ContentFormat where
/-- typst 源:求值为 typst `Content`(讲义/教案面)。 -/
| typst
/-- markdown + KaTeX 源:原样保留,不经 typst 求值(slides 大纲面 / 逐字稿口播面;ADR-0015)。 -/
| markdown
/-- 对一段富内容的**引用**:它坐落在某个虚拟路径上(`PINNED` 关系, ADR-0006),并带一个
**format**(`PINNED`, ADR-0015)。不建模源文本、不建模求值出的 `Content`(那是实现侧的事);
"富内容经由一个 `VPath` 定位 + 带 format",作为 `Primitives.ElementData` 里 `content` 叶子的语义锚点。 -/
structure RichContentRef where
/-- 该富内容所在的虚拟路径(ADR-0006;落盘后为真实相对路径, ADR-0007)。 -/
vpath : VPath
/-- 该富内容的 format(ADR-0015)。 -/
format : ContentFormat
end Spec.Courseware
-9
View File
@@ -1,9 +0,0 @@
import Spec.Courseware.Open.QuestionBank
import Spec.Courseware.Open.Course
/-!
# Courseware.Open —— OPEN 骨架(核心关系)
题库与 element 的关系(`QuestionBank`)、课程编排规则(`Course`)。两者均为已 surface
但未决策的 OPEN 分歧点,待专门 ADR 落定。
-/
-10
View File
@@ -1,10 +0,0 @@
/-!
# Course —— 课程编排(骨架,规则 OPEN)
ADR-0005:工程文件的粒度是**单节课**;course / 单元**不是**工程文件,而是 lesson 的
**编排**。但"编排"的具体规则未决策:有序列表还是带层级(单元 → 课)的树?lesson 被
引用还是被包含?跨 lesson 有无约束(目标覆盖、前后置)?这些都是 `OPEN`。
此处不替它选解——不建 `Course := List Lesson`(那会偷偷承诺
"扁平有序、无层级")。只在此 surface:课程编排待专门 ADR。本文件当前不引入任何承诺性声明。
-/
@@ -1,10 +0,0 @@
/-!
# QuestionBank —— 题库(骨架,核心关系 OPEN)
题库是与工程文件并列的资产(类比 DAW 的 sample library):有自身结构的实体,又是最
典型的可复用单元,lesson 会引用它。但**题库与 element 的关系尚未决策**,且用户明确
指出"纯引用可能不够"——element 内联题目数据 / lesson 持指向题库条目的引用 / 两者并存?
这是一个 `OPEN` 分歧点。此处不替它选解——不建 `QuestionRef` 也不建
内联结构,只在此 surface。待专门 ADR 落定后再填。本文件当前不引入任何承诺性声明。
-/
-67
View File
@@ -1,67 +0,0 @@
/-!
# Prelude —— System 层共享标识符
平台层引用一组标识符(项目、run、session、principal、chat、platform identity/audit)。
其内部表示从未被决策(UUID / 复合键、principal 子类型学),也非分歧点,故收口成 opaque
载体 `Identifiers`,System 各模块在其上参数化——契约谈得了"锁 owner 是哪个 run"这类
关系,却不对标识符表示作承诺。
-/
namespace Spec.System
/-- System 层标识符载体(关系结构 `PINNED`;各字段表示 `OPEN`)。 -/
structure Identifiers where
/-- 课程项目标识(`OPEN` 表示;聚合根,likec4 `Project`)。 -/
ProjectId : Type
/-- SaaS 租户/组织标识(`OPEN` 表示;ADR-0020 tenant root)。 -/
OrganizationId : Type
/-- 租户层用户标识(`OPEN` 表示;独立实体,非飞书身份派生;见 `Hierarchy.User`)。 -/
UserId : Type
/-- 飞书用户 open_id(`OPEN` 表示;单应用作用域内唯一)。 -/
FeishuOpenId : Type
/-- 飞书 user_id(`OPEN` 表示;租户内唯一,换 app 不变)。 -/
FeishuUserId : Type
/-- 飞书企业应用 app_id(`OPEN` 表示)。 -/
FeishuAppId : Type
/-- 飞书 app_secret 信封引用(`OPEN` 表示;ADR-0024)。 -/
FeishuAppSecretRef : Type
/-- Hub teacher team 标识(`OPEN` 表示;ADR-0020 org-scoped team)。 -/
TeamId : Type
/-- Project explorer folder 标识(`OPEN` 表示;ADR-0021 透明组织节点,非权限资源)。 -/
FolderId : Type
/-- 一次 agent 任务的标识(`OPEN` 表示;锁的 owner、审计主体,`AgentRun`。provider 无关,
ADR-0017;`@bot` 仅为触发品牌,不承诺 provider)。 -/
RunId : Type
/-- 长生命周期 agent 会话标识(`OPEN` 表示;**provider/model 绑定, ADR-0017**——一次
session 不跨 provider/model;切 model 即新 session,跨 session 连续性由 ADR-0003 项目
记忆/锚点重建,不由 session 自带。同 provider/model 内可跨多 run 复用, ADR-0002)。 -/
SessionId : Type
/-- Organization-scoped Agent role 标识(`OPEN` 表示, ADR-0017);role 是动态运行配置,
不是 slash command 枚举。 -/
AgentRoleId : Type
/-- 权限主体标识(`OPEN` 表示及其子类型学;ADR-0004 的 user/chat/department/
子类型学未定且非分歧点,故只留 opaque 键)。 -/
Principal : Type
/-- 飞书项目群 chat 标识(`OPEN` 表示;ADR-0001 协作空间、ADR-0003 锚点引用、
ADR-0004 `feishu_chat` principal 三处共用同一实体。独立成载体而非 `Principal` 子
类型——principal 子类型学 OPEN,这里不预设"chat 是 principal 的哪种子型")。 -/
ChatId : Type
/-- 平台管理员身份标识(`OPEN` 表示;ADR-0023,不复用客户 `User` 标识)。 -/
PlatformIdentityId : Type
/-- 平台自有飞书应用标识(`OPEN` 表示;ADR-0023,与各 org 客户应用分离)。 -/
PlatformFeishuApplicationId : Type
/-- 平台应用所验证的外部飞书用户标识(`OPEN` 表示;ADR-0023,其具体 open_id/user_id
表示与迁移方式不在本层固定)。 -/
PlatformExternalUserId : Type
/-- 可撤销 Platform Session 标识(`OPEN` 表示;ADR-0023)。 -/
PlatformSessionId : Type
/-- Platform Administrator invitation 标识(`OPEN` 表示;ADR-0023)。 -/
PlatformInvitationId : Type
/-- 一次平台控制面 mutation 标识(`OPEN` 表示;ADR-0023)。 -/
PlatformMutationId : Type
/-- 独立 Platform Audit Entry 标识(`OPEN` 表示;ADR-0023)。 -/
PlatformAuditEntryId : Type
/-- 平台离线恢复所声明的 incident 标识(`OPEN` 表示;ADR-0023)。 -/
PlatformIncidentId : Type
end Spec.System
-57
View File
@@ -1,57 +0,0 @@
import Spec.System.Hierarchy
import Spec.System.ProjectGroup
import Spec.System.Organization
import Spec.System.User
import Spec.System.Connections
import Spec.System.ProjectWorkspace
import Spec.System.Capacity
import Spec.System.PlatformAdministration
import Spec.System.Agent.Run
import Spec.System.Agent.AgentRole
import Spec.System.Agent.Memory
import Spec.System.Agent.AgentSurface
import Spec.System.Agent.Usage
import Spec.System.Agent.Capability
import Spec.System.Lock
import Spec.System.Permission
import Spec.System.PermissionGrant
import Spec.System.Audit
/-!
# System —— Hub 平台层契约
协作与执行的平台:项目、飞书群、AgentRun、锁、权限、审计、按需上下文。
likec4 已画出结构;这里补语义:
- `Hierarchy` —— 三层主体:平台 → 组织 → 用户。
- `User` —— 用户创建路径(管理员直接创建;飞书注册 `OPEN`)。
- `Connections` —— 外部连接:提供商枚举(当前仅飞书) + 绑定/信息类型。
- `ProjectGroup` —— project↔飞书群 1:1 长生命周期绑定(ADR-0001);群是协作空间,不持锁。
- `Organization` —— SaaS 租户(ADR-0020);project/team 单归属,TEAM grant 不跨 org;
connection secret 信封与 fail-closed resolver(ADR-0024);
owner/admin/member(`OrganizationRole`)及其管理规则(最后所有者保护)。
- `ProjectWorkspace` —— org 后台 project explorer:folder 是透明组织节点,project 仍是权限边界
(ADR-0021)。
- `Capacity` —— platform ceiling 与 org policy 的分层限制、持久 admission request 状态和
平台紧急工作负载制动(ADR-0022)。
- `PlatformAdministration` —— 独立平台身份/会话、单一管理员角色、绑定邀请、最后管理员
保护、fail-closed 平台审计与离线 emergency grant(ADR-0023)。
- `AgentRole` —— org-scoped agent 角色配置 + 技能(ADR-0017/0018)。
- `Run` —— AgentRun 状态与终止判定(状态集合完整性 OPEN)。
- `Lock` —— 锁 owner=run(ADR-0002),及"持锁者必为非终止 run"的核心不变式。
- `Memory` —— 按需上下文:锚点类别(ADR-0003)+ MCP 工具按 run/project 上下文授权。
- `AgentSurface` —— agent 执行面被 run 的工作区所界定(ADR-0018);与 Lock 正交——
Lock 限定并发,Surface 限定波及面。机制 OPEN。
- `Usage` —— Run 内 append-only 用量计量事实账本(ADR-0026);主模型 completion、
外部能力调用、代理网关旁路共用同一账本。fact 不持锁、不跨 run;`costUsd = none`
表示未知而非零(ADR-0022)。Run 上的 cost/token 标量是其 rollup cache。
- `Capability` —— 外部能力(PDF→MD、音视频→文本等)注册与调用(ADR-0027);
org-scoped 凭证连接复用 ADR-0024 信封但独立于 model-provider;调用是 Run 内副作用,
产物落 workspace,消耗记 UsageFact。capability credential 不进 Agent 进程。
- `Permission` —— read⊂edit⊂manage 角色体系、能力推导、单调性;force-release 在格外。
- `PermissionGrant` —— grant(resource×principal×role)与 settings(六 policy 旋钮)
(ADR-0004);组合规则 OPEN。
- `Audit` —— customer Project/Run 审计从简(内容 OPEN);Platform Audit 由
`PlatformAdministration` 独立承载。
标识符见 `Spec.Prelude`。决策出处:ADR-0001..0004, 0018, 0020..0027。
-/
-60
View File
@@ -1,60 +0,0 @@
import Spec.Prelude
/-!
# AgentRole —— Agent 角色与技能配置 (ADR-0017, ADR-0018)
AgentRole 是 org-scoped 运行时配置:system prompt、tool allowlist、default model
和 skill 绑定。通过 CLI 管理,不需重启进程生效。
Role 的执行面(model/prompt/tools/skill 内容)变更时,该 role 的 active sessions
归档——下次 run 不能在旧指令下创建的 provider context 上恢复。
仅 label/排序变更不影响会话连续性(ADR-0017)。
Run 时只读加载 role 选中的 skill 不可变版本到 run-scoped 目录,
run 结束后删除(ADR-0018)。Skill 管理是 org-scoped;存储机制 `OPEN`。
-/
namespace Spec.System
variable (I : Identifiers)
/-- Agent 角色(`PINNED`, org-scoped, ADR-0017)。 -/
structure AgentRole where
/-- 所属组织(`PINNED`)。 -/
organization : I.OrganizationId
/-- 斜杠命令名(`OPEN` 表示;如 /draft)。 -/
roleId : String
/-- system prompt(`PINNED`)。 -/
systemPrompt : String
/-- tool allowlist(`PINNED`;tool 标识集合 `OPEN`)。 -/
tools : List String
/-- 默认 model(`PINNED`;model ID 表示 `OPEN`)。 -/
defaultModel : String
/-- Agent 技能(`PINNED`, org-scoped, ADR-0017/0018)。 -/
structure AgentSkill where
/-- 所属组织(`PINNED`)。 -/
organization : I.OrganizationId
/-- 名称(`PINNED`)。 -/
name : String
/-- 版本(`PINNED`)。 -/
version : String
/-- 内容摘要(`PINNED`;SHA-256 content-addressed)。 -/
contentDigest : String
/-- 描述(`OPEN`)。 -/
description : String
/-- Role-Skill 绑定(`PINNED`, ADR-0017)。一个 role 可绑定零或多个 skill。 -/
structure AgentRoleSkillBinding where
/-- 所属组织(`PINNED`)。 -/
organization : I.OrganizationId
/-- 绑定的 role(`PINNED`)。 -/
roleId : String
/-- skill 名称(`PINNED`)。 -/
skillName : String
/-- skill 版本(`PINNED`)。 -/
skillVersion : String
/-- 排序(`PINNED`)。 -/
sortOrder : Nat
end Spec.System
-40
View File
@@ -1,40 +0,0 @@
import Spec.Prelude
import Spec.System.Agent.Run
/-!
# AgentSurface —— Agent 执行面边界(ADR-0018)
Agent 在一次 run 内发起的文件操作,其路径必须落在该 run 所属 project 的工作区目录内
(ADR-0007)。逃逸即越权,拒绝。
与 `Lock`(ADR-0002)正交:Lock 限定并发(谁在改),Surface 限定波及面(能改到哪)。
二者都按 run × project 作用域。
shell 面的边界(命令的文件效果同样不得逃逸工作区)是同一不变式的推论。机制
——路径校验、OS 级沙箱、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`);本谓词约束"操作路径必须以 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
-104
View File
@@ -1,104 +0,0 @@
import Spec.Prelude
import Spec.System.Agent.Run
import Spec.System.Agent.Usage
/-!
# Capability —— 外部能力注册与调用(ADR-0027)
`AgentRun` 内可能调用**外部能力**:PDF→MD bundle、音视频→文本、OCR 等。这些能力
不是主 agent loop 的模型 completion,不持项目锁,不占 session 语义(ADR-0026 已拒绝
nested Run)。它们是 Run 内的副作用:读 workspace 输入、调外部服务、产物落 workspace、
写一条 `UsageFact`。
本模块 pin 三件事:
1. **ExternalCapability** —— 平台注册的、org 启用的文档/媒体转换服务,由稳定
`capabilityId` 标识。
2. **CapabilityConnection** —— org-scoped 凭证连接,复用 ADR-0024 信封机制但独立于
model-provider connection。只有 `active` connection 可被 resolver 使用。
3. **CapabilityInvocation 良构** —— 调用的输入/输出必须落在 run 的 workspace 内
(ADR-0018 `AgentSurface`),且调用产生的消耗必须以 `UsageFact` 记录。
凭证不经 Agent 子进程:Agent 只通过 capability adapter 间接调用,adapter 在 Hub 侧
解析 org-scoped 凭证并调用外部服务(ADR-0024 resolver boundary)。这与 model-provider
的 loopback proxy 是不同的机制——capability 调用不走 agent 的网络面,走 Hub 直连。
不变式(`PINNED`, ADR-0027):
- **凭证隔离** —— capability credential 不进 Agent 进程环境;adapter 在 Hub 侧解析。
- **workspace 边界** —— 输入路径与输出目录都必须落在 run 的 workspace 内(ADR-0018)。
- **必记 fact** —— 一次成功调用必须产生 ≥1 条 `UsageFact`(`kind = externalCapability`),
即使 `costUsd = none`(unknown ≠ 零,ADR-0022/0026)。
`CapabilityInvocation` 作为一等持久记录(status/retries/partial output)在 pilot 不做;
`UsageFact` 的 `capabilityId + correlationId` 是唯一可追溯痕迹(ADR-0027 Deferred)。
-/
namespace Spec.System.Agent
variable (I : Identifiers) (Path : Type)
/-- 外部能力的输入种类(`PINNED`, ADR-0027)。集合 `OPEN`——新增 kind 须 surface。 -/
inductive CapabilityInputKind where
/-- PDF 文档(`PINNED`)。 -/
| pdf
/-- 图片(`PINNED`)。 -/
| image
/-- 音频(`PINNED`)。 -/
| audio
/-- 视频(`PINNED`)。 -/
| video
/-- 外部能力(`PINNED`, ADR-0027)。平台注册的文档/媒体转换服务,由 org 启用。
一个 capability 由稳定 `capabilityId` 标识(如 `pdf_to_md_bundle`),声明接受的输入
种类与计量单位。org 通过 `CapabilityConnection` 启用它;adapter 是平台侧实现。 -/
structure ExternalCapability where
/-- 稳定能力标识(`PINNED`;如 `pdf_to_md_bundle`、`audio_video_to_text`)。 -/
capabilityId : String
/-- 接受的输入种类(`PINNED`, ADR-0027)。 -/
inputKind : CapabilityInputKind
/-- 计量单位(`OPEN`;如 `"pages"`、`"audio_seconds"`)。与 UsageFact.unit 对齐。 -/
meteringUnit : String
/-- capability connection 的运行态(`PINNED`, ADR-0024/0027):与 model-provider /
Feishu connection 同构,只有 `active` 可被 resolver 使用。 -/
inductive CapabilityConnectionStatus where
| draft
| active
| disabled
/-- Organization 的外部能力凭证连接(`PINNED`, ADR-0027)。复用 ADR-0024 信封机制
但独立于 model-provider connection:capability 服务(如 MinerU、Whisper)有自己的 auth
形状与 readiness probe,不走 OpenRouter `/v1/models`。唯一性键为
`(organization, capabilityId)`。 -/
structure OrganizationCapabilityConnection where
/-- connection 所属 organization(`PINNED`, ADR-0027)。 -/
organization : I.OrganizationId
/-- 该 connection 启用的 capability(`PINNED`, ADR-0027)。 -/
capability : ExternalCapability
/-- 运行态(`PINNED`, ADR-0024/0027)。 -/
status : CapabilityConnectionStatus
/-- 一次 capability 调用的良构约束(`PINNED`, ADR-0027)。输入路径与输出目录都必须落在
发起 run 的 workspace 内(ADR-0018 `AgentSurface`);`runWorkspace` 与 `pathWithin`
由平台提供(表示 `OPEN`)。逃逸即越权,拒绝。 -/
structure CapabilityInvocation where
/-- 发起调用的 run(`PINNED`;调用是 Run 内副作用,不跨 run,ADR-0026/0027)。 -/
run : I.RunId
/-- 被调用的 capability(`PINNED`)。 -/
capability : ExternalCapability
/-- 输入路径(workspace 内,`PINNED`, ADR-0018)。 -/
inputPath : Path
/-- 输出目录(workspace 内,`PINNED`, ADR-0018)。 -/
outputDir : Path
/-- capability 调用良构:输入与输出路径都落在 run 的 workspace 内(`PINNED`,
ADR-0018/0027)。这是 `AgentFileOp.Authorized` 在 capability 调用上的对应物。 -/
def CapabilityInvocation.Authorized
(inv : CapabilityInvocation I Path)
(runWorkspace : I.RunId Option Path)
(pathWithin : Path Path Prop) : Prop :=
w, runWorkspace inv.run = some w pathWithin inv.inputPath w pathWithin inv.outputDir w
end Spec.System.Agent
-48
View File
@@ -1,48 +0,0 @@
import Spec.Prelude
/-!
# Memory —— 按需上下文:锚点与项目记忆(ADR-0003)
ADR-0003:Hub 只存锚点与项目记忆,不存全量飞书消息历史;Claude 需要更多上下文时经
飞书 API 按需读取。这里刻画所存锚点的类别(ADR 列定,枚举完整性 OPEN),并约束
一条安全不变式:MCP 工具按 run/project 上下文授权,Claude 不得传任意 chat id
(ADR-0003 Consequences 末条)。
"chat id 与 project 绑定"这一锚点类别由 `ProjectGroup.GroupBinding`(ADR-0001)权威承载,
这里只覆盖其余飞书侧指针(触发消息、状态卡片、回复、线程)。
-/
namespace Spec.System
variable (I : Identifiers)
variable (MessageId CardId : Type)
/-- 上下文锚点(`PINNED` 类别, ADR-0003;枚举完整性 `OPEN`——ADR 是"例如"式列举,
实现若需新类别须 surface)。承载 Hub 保留的飞书侧最小指针,而非消息正文。 -/
inductive Anchor where
/-- 触发某次 run 的消息(`PINNED` 类别, ADR-0003 "trigger message id")。 -/
| triggerMessage : MessageId Anchor
/-- 某 run 的状态卡片(`PINNED` 类别, ADR-0003 "run id and status card id")。 -/
| statusCard : I.RunId CardId Anchor
/-- 回复锚点(`PINNED` 类别, ADR-0003 "reply anchors")。 -/
| reply : MessageId Anchor
/-- 线程锚点(`PINNED` 类别, ADR-0003 "thread anchors")。 -/
| thread : MessageId Anchor
/-- MCP 读上下文请求:由某 run 发起、指向某 chat(`PINNED` 关系, ADR-0003 "Claude calls
MCP tools to read … through Feishu APIs")。 -/
structure McpReadRequest where
/-- 发起请求的 run(授权上下文主体, ADR-0003)。 -/
run : I.RunId
/-- 请求读取的 chat(授权由下方 `Authorized` 约束:不允许越界)。 -/
chat : I.ChatId
/-- 请求获授权:其 chat 必须等于该 run 所属 project 的绑定群(`PINNED` 安全不变式,
ADR-0003)。"chat 必须匹配 run 的 project 绑定",杜绝 Claude 传任意 chat id。 -/
def McpReadRequest.Authorized
(req : McpReadRequest I)
(runProject : I.RunId Option I.ProjectId)
(boundChat : I.ProjectId Option I.ChatId) : Prop :=
p, runProject req.run = some p boundChat p = some req.chat
end Spec.System
-30
View File
@@ -1,30 +0,0 @@
/-!
# Run —— AgentRun 状态机
一次 `@bot` 创建一个 `AgentRun`(ADR-0001),它在终止时释放项目锁(ADR-0002)。
转移关系在任何 ADR 里都未定,这里只刻画状态与终止判定(后者是 Lock 排他不变式的
依赖),不定义转移边。
-/
namespace Spec.System
/-- AgentRun 运行状态(状态名 `PINNED`, ADR-0001..0003, ADR-0022;完整性 `OPEN`
——ADR 从未声明"状态就是这些";实现若需新状态(如 pending)须 surface)。终止态
见 `RunState.Terminal`。 -/
inductive RunState where
| active
| waitingForUser
| completed
| failed
| timedOut
| limitExceeded
| canceled
/-- run 处于**终止态**(`PINNED`, ADR-0002, ADR-0022:锁在 completes/fails/
timesOut/limitExceeded/canceled 时释放)。`active`/`waitingForUser` 非终止——后者仍
占用项目(锁未释放)。 -/
def RunState.Terminal : RunState Prop
| .completed | .failed | .timedOut | .limitExceeded | .canceled => True
| .active | .waitingForUser => False
end Spec.System
-92
View File
@@ -1,92 +0,0 @@
import Spec.Prelude
/-!
# Usage —— Run 内用量计量事实账本(ADR-0026)
一次 `AgentRun` 内可能产生**多条**可计费消耗:主模型 completion、外部能力调用
(PDF→MD bundle、音视频转写等)、或代理网关侧旁路调用。这些消耗**不**是子 Run——
它们不持项目锁、不占 session 语义、不复用 `AgentRun` 状态机。它们是 Run 内的
append-only **计量事实**(`UsageFact`)。
`AgentRun` 上的 cost/token 标量是 facts 的派生 rollup cache,不是真相来源;真相在
`UsageFact` 行。这一层抽象让"主模型"与"外部能力"两类消耗共享同一账本,而非给后者
各开一种特化字段或 nested Run。
核心不变式(`PINNED`, ADR-0022 + ADR-0026):
- **append-only** —— fact 一旦写入只读不更不删;`costUsd` 修正走新 fact,不改旧行。
- **`costUsd = none` 表示"未知",不是"零成本"** —— ADR-0022:missing provider cost
remains unknown rather than zero。聚合时不得把 none 当 0 求和。
- **fact 不持锁、不跨 run** —— 它是 Run 内的副作用账本,不是又一次 `@bot` 生命周期。
外部能力调用亦同:它的语义边界是 `capabilityId` + `correlationId`,不是 `RunId`。
- **价目回算是派生** —— `costSource = pricebookDerived` 时,`costUsd` 由
`occurredAt × (provider, model)` 查价目表派生;provider 实报(`providerReported`)
优先于派生。无价目可查时 `costSource = unknown`,`costUsd = none`。
外部能力(capability)注册表、价目表(pricebook)、商业结算均 `OPEN`,pilot 不做
(ADR-0021 仍 defer commercial billing)。本模块只 pin **账本结构与不变式**。
-/
namespace Spec.System.Agent
variable (I : Identifiers) (Time Cost Quantity : Type)
/-- 计量事实类型(`PINNED`, ADR-0026)。集合完整性 `OPEN`——新增 kind 须 surface,
不得静默把外部消耗塞进既有 kind。 -/
inductive UsageFactKind where
/-- 主 agent loop 的模型 completion(`PINNED`)。 -/
| modelCompletion
/-- 外部能力调用(`PINNED`;如 pdf_to_md_bundle、audio_video_to_text)。 -/
| externalCapability
/-- 代理网关侧旁路调用(`PINNED`;非主 loop 的模型调用,如嵌入工具内的子请求)。 -/
| toolProxy
/-- 成本来源(`PINNED`, ADR-0026)。优先级:provider 实报 > 价目派生 > unknown。 -/
inductive CostSource where
/-- provider/gateway 实报(`PINNED`)。 -/
| providerReported
/-- 用 `occurredAt × (provider, model)` 价目表派生(`PINNED`)。 -/
| pricebookDerived
/-- 缺失,而非零(`PINNED`;ADR-0022 missing cost ≠ zero)。 -/
| unknown
/-- 一次可计费消耗的计量事实(`PINNED`, ADR-0026)。append-only;一次 run ≥0 条。
字段分两组:**归属与时间**(run/occurredAt/kind)用于聚合与价目回算;
**计量与成本**(tokens/quantity/unit/costUsd/costSource)用于账目本身。
非 token 计量(页//次)走 `quantity + unit`,与 token 并存而非取代。 -/
structure UsageFact where
/-- 所属 run(`PINNED`;fact 不跨 run,fact 不持锁,ADR-0026)。 -/
run : I.RunId
/-- 消耗发生时刻(`PINNED`);价目回算依赖此字段而非 run.finishedAt,
因为外部能力可能早于 run 结束。 -/
occurredAt : Time
/-- 事实类型(`PINNED`, ADR-0026)。 -/
kind : UsageFactKind
/-- provider 标识(`PINNED`;如 openrouter、mineru、openai_whisper)。 -/
provider : String
/-- 模型标识(`OPEN`;非模型计费可为 none)。 -/
model : Option String
/-- 输入 tokens(`OPEN`;非 token 计量可为 none)。 -/
inputTokens : Option Nat
/-- 输出 tokens(`OPEN`)。 -/
outputTokens : Option Nat
/-- 非 token 计量数值(`OPEN`;如页数、音频秒数)。与 tokens 并存。 -/
quantity : Option Quantity
/-- 计量单位(`OPEN`;如 "pages"、"audio_seconds"、"invocations")。 -/
unit : Option String
/-- 成本(`PINNED`;none = 未知,非零,ADR-0022)。 -/
costUsd : Option Cost
/-- 成本来源(`PINNED`;costUsd ≠ none 时必填,costUsd = none 时为 `unknown`)。 -/
costSource : CostSource
/-- 外部能力标识(`OPEN`;kind = externalCapability 时填,如 pdf_to_md_bundle)。 -/
capabilityId : Option String
/-- 外部请求关联 id(`OPEN`;对账/幂等用,不参与聚合)。 -/
correlationId : Option String
/-- Fact 的成本是否**已知**(`PINNED`, ADR-0026)。聚合 rollup 时,只对已知成本求和;
未知成本的 fact 不贡献 0,而是让汇总保持未知(若所有 fact 均未知)或部分未知。 -/
def UsageFact.HasKnownCost (fact : UsageFact I Time Cost Quantity) : Prop :=
fact.costUsd none
end Spec.System.Agent
-20
View File
@@ -1,20 +0,0 @@
import Spec.Prelude
/-!
# Audit —— Project/Run 审计日志
审计记录里装什么(事件 schema、保留策略、可查询维度)在任何 ADR 里都未决策,
且大多是实现细节。这里只固定"审计以 run 为主体记录其生命周期事件"这一条已决策
关系,其余 `OPEN`。ADR-0023 的 Platform Audit 是另一个控制面,见
`Spec.System.PlatformAdministration`,不复用本结构。
-/
namespace Spec.System
/-- 审计条目的最小骨架(关系 `PINNED` / 内容 `OPEN`, likec4)。只承诺"一条审计记录
关联到某个 run";事件类型、时间、actor、详情等字段 `OPEN`。 -/
structure AuditEntry (I : Identifiers) where
/-- 该审计条目所属的 run(`PINNED` 关系, likec4)。 -/
run : I.RunId
end Spec.System
-89
View File
@@ -1,89 +0,0 @@
import Spec.Prelude
/-!
# Capacity —— SaaS capacity admission and abuse controls (ADR-0022)
初始生产服务共享有限的单机资源,但不能让一个 Organization 垄断容量或让无界输入拖垮
其他租户。ADR-0022 定义分层限制、持久 admission、显式背压和紧急制动;具体数值由
生产式容量测试校准,保持 `OPEN`。
-/
namespace Spec.System
/-- 首个生产版本必须有硬边界的容量维度(`PINNED`, ADR-0022)。金额与 token 用量是
统计和软告警,不在本硬限制集合中;各维度的具体数值为 `OPEN`,须由容量测试决定。 -/
inductive CapacityDimension where
| requestRate
| requestBodySize
| agentConcurrency
| admissionQueueLength
| admissionQueueWait
| fileSize
| attachmentCount
| archiveExpansion
| projectStorage
| organizationStorage
| memberCount
| projectCount
| teamCount
| folderCount
| sessionCount
| runWallTime
| runTurns
| runToolCalls
| toolWallTime
| runOutputSize
| processMemory
| processCpu
| processCount
/-- 一个容量维度的分层限制(`PINNED`, ADR-0022):platform ceiling 永远存在且不可被
Organization 突破;Organization policy 可以缺省,也只能配置更低的限制。 -/
structure LayeredLimit where
/-- 由版本化生产部署配置提供的不可突破上限(`PINNED`, ADR-0022)。 -/
platformCeiling : Nat
/-- Organization 管理员可选的更低策略限制(`PINNED`, ADR-0022)。 -/
organizationLimit : Option Nat
/-- 分层限制是良构的 iff Organization policy 未设置或不高于 platform ceiling
(`PINNED`, ADR-0022)。不允许用 `none` 表示 platform unlimited。 -/
def LayeredLimit.Valid (limit : LayeredLimit) : Prop :=
match limit.organizationLimit with
| none => True
| some organizationLimit => organizationLimit limit.platformCeiling
/-- 有效限制(`PINNED`, ADR-0022)取 platform ceiling 与 Organization policy 中较低者;
Organization policy 缺省时直接采用 platform ceiling,永不退化为 unlimited。 -/
def LayeredLimit.effective (limit : LayeredLimit) : Nat :=
match limit.organizationLimit with
| none => limit.platformCeiling
| some organizationLimit => min limit.platformCeiling organizationLimit
/-- 已被服务接受的 agent run request 状态(`PINNED`, ADR-0022)。`expired` 和
`canceled` 都是可审计终态且不得自动执行;`started` 表示已从 admission queue 移交给
一个 AgentRun。被容量规则拒绝的输入从未被接受,因此不伪装成 queued request。 -/
inductive RunRequestState where
| queued
| started
| expired
| canceled
/-- 平台紧急工作负载制动模式(`PINNED`, ADR-0022)。`drain` 停止新 admission 与队列
启动但允许当前 run 完成;`stopNow` 还会取消当前 run。它们不等同于删除或封禁 org。 -/
inductive WorkloadBrakeMode where
| open
| drain
| stopNow
/-- 紧急制动是否允许接收新的 agent 工作(`PINNED`, ADR-0022):只有 `open` 允许。 -/
def WorkloadBrakeMode.AcceptsNew : WorkloadBrakeMode Prop
| .open => True
| .drain | .stopNow => False
/-- 紧急制动是否允许当前 agent run 继续(`PINNED`, ADR-0022):`drain` 允许收尾,
`stopNow` 要求取消。 -/
def WorkloadBrakeMode.AllowsActive : WorkloadBrakeMode Prop
| .open | .drain => True
| .stopNow => False
end Spec.System
-24
View File
@@ -1,24 +0,0 @@
import Spec.System.Connections.Prelude
import Spec.System.Connections.Feishu
/-!
# Connections —— 连接绑定与用户信息
组织/用户的外部连接。提供商枚举见 `Connections.Prelude`;
飞书具体类型见 `Connections.Feishu`。
-/
namespace Spec.System
/-- 组织连接绑定(`PINNED`)。 -/
inductive ConnectionBinding (I : Identifiers) where
/-- 飞书(`PINNED`)。 -/
| feishu : FeishuAppBinding I ConnectionBinding I
/-- 用户连接信息(`PINNED`)。 -/
inductive ConnectionProfile (I : Identifiers) where
/-- 飞书(`PINNED`)。 -/
| feishu : FeishuProfile I ConnectionProfile I
end Spec.System

Some files were not shown because too many files have changed in this diff Show More