Compare commits

..

2 Commits

Author SHA1 Message Date
ChickenPige0n 3f3628e4a0 feat(hub): add SearchableSelectField component and update RoleCard to use it 2026-07-19 14:32:01 +08:00
ChickenPige0n 854c6189bb feat(hub): org-scoped agent role/skill folder tree (ADR-0028)
Add a shared transparent OrganizationAgentConfigFolder tree for grouping
agent roles and skills in the admin UI without affecting identity, bindings,
run loading, or slash commands.
2026-07-19 14:18:19 +08:00
123 changed files with 3727 additions and 5009 deletions
+5 -5
View File
@@ -1,12 +1,12 @@
name: checker check name: checker check
# Builds and lints the Rust implementation crates under crates/ (the rule-based # Builds and lints the Rust implementation crates under crates/ (the rule-based
# lesson checker). # checker that "stands in Lean's position" at product runtime).
# #
# This is an INTERNAL gate on the implementation's own health # Like spec-check, this is an INTERNAL gate on the implementation's own health
# (does it build, pass its tests, satisfy clippy + rustfmt?). There is no # (does it build, pass its tests, satisfy clippy + rustfmt?). It is NOT a
# decision-to-implementation conformance gate — implementations align to the # spec-to-implementation conformance gate — implementations align to the Lean
# ADRs by human review, not by CI. See the repo README. # contract by human review, not by CI. See the repo README.
on: on:
push: push:
+2 -2
View File
@@ -1,8 +1,8 @@
name: hub check name: hub check
# Builds, type-checks, and tests the Hub TS package under hub/. # Builds, type-checks, and tests the Hub TS package under hub/.
# The Hub is the Feishu-group collaboration + agent runtime half. # The Hub is the Feishu-group collaboration + agent runtime half
# This is an INTERNAL gate on the Hub's own # (spec/System implementation). This is an INTERNAL gate on the Hub's own
# health, like checker-check is for the Rust half. # health, like checker-check is for the Rust half.
on: on:
+20
View File
@@ -0,0 +1,20 @@
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,3 +1,7 @@
# Lean / Lake build artifacts (spec/ has its own .gitignore too)
.lake/
**/.lake/
# Rust / Cargo build artifacts (repo-wide cargo workspace at root) # Rust / Cargo build artifacts (repo-wide cargo workspace at root)
/target /target
**/*.pdf **/*.pdf
@@ -29,7 +29,7 @@ workload brakes.
The full current-state inventory, accepted behavior, and release evidence are The full current-state inventory, accepted behavior, and release evidence are
recorded in [Initial abuse and capacity controls](../assets/initial-abuse-capacity-controls.md), recorded in [Initial abuse and capacity controls](../assets/initial-abuse-capacity-controls.md),
with the durable decision in ADR-0022. Numerical with the durable decision in ADR-0022 and `Spec.System.Capacity`. Numerical
ceilings remain open until production-like calibration. ceilings remain open until production-like calibration.
The implementation frontier is: 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 ## Question
After the readiness investigations and resulting fixes are resolved, can one After the readiness investigations and resulting fixes are resolved, can one
repeatable release procedure prove build/test health, deploy a clean repeatable release procedure prove build/test/spec health, deploy a clean
production-like environment, exercise critical tenant and agent journeys, production-like environment, exercise critical tenant and agent journeys,
verify observability and recovery, and either roll forward or roll back safely? 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, Separate or unify run-bound audit entries, pre-run security/permission events,
structured messages, and operational recovery events without weakening structured messages, and operational recovery events without weakening
the pinned AuditEntry-to-run relation. Decide durability, `Spec.System.Audit`'s pinned AuditEntry-to-run relation. Decide durability,
failure, retention, and query semantics; then enforce referential integrity and failure, retention, and query semantics; then enforce referential integrity and
observable/recoverable writes instead of silently swallowing lost evidence. observable/recoverable writes instead of silently swallowing lost evidence.
Do not merge these customer Project/Run records with ADR-0023's already-decided Do not merge these customer Project/Run records with ADR-0023's already-decided
@@ -40,7 +40,9 @@ an off-host recovery key, an incident and reason, and issues only an expiring
Emergency Platform Grant. Emergency Platform Grant.
The complete accepted decision and implementation divergences are in The complete accepted decision and implementation divergences are in
[ADR-0023](../../../docs/adr/0023-platform-administrator-identity-and-audit.md), [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),
and the canonical terms are in [`CONTEXT.md`](../../../CONTEXT.md). and the canonical terms are in [`CONTEXT.md`](../../../CONTEXT.md).
Exact numeric session/invitation/step-up limits and browser mechanics remain Exact numeric session/invitation/step-up limits and browser mechanics remain
+20 -12
View File
@@ -1,25 +1,29 @@
# AGENTS.md —— agent 操作手册(全 repo) # AGENTS.md —— agent 操作手册(全 repo)
本 repo 是 monorepo。先读根 `README.md` 的"宪法"4 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。 本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
## 这个 repo 是什么 ## 这个 repo 是什么
- `docs/adr/` 是系统级决策的唯一权威来源;`CONTEXT.md` 是平台语言词汇表;代码注释把关键不变量锚到 ADR 编号,可 grep - `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team` - `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020)。 必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。 - org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021)。 普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 /
`Spec.System.ProjectWorkspace`)。
- 每个 org 自选 BYOK 或平台托管 model provider connection;平台托管也必须是该 org - 每个 org 自选 BYOK 或平台托管 model provider connection;平台托管也必须是该 org
独享的 key/base URL,不得让无关 org 共用 process-global provider key(见 ADR-0021)。 独享的 key/base URL,不得让无关 org 共用 process-global provider key(见 ADR-0021 /
`Spec.System.Organization`)。
- Feishu/provider secret 使用本地版本化 master-key keyring 的信封加密;生产由 systemd - Feishu/provider secret 使用本地版本化 master-key keyring 的信封加密;生产由 systemd
credential 注入,运行时只允许显式 org/project scope 的 fail-closed resolver,不得回退 credential 注入,运行时只允许显式 org/project scope 的 fail-closed resolver,不得回退
process-global credential;Agent child 只接收 run-scoped loopback proxy capability, process-global credential;Agent child 只接收 run-scoped loopback proxy capability,
不接收 org provider credential(见 ADR-0024)。 不接收 org provider credential(见 ADR-0024 / `Spec.System.Organization`)。
- 生产容量按不可突破的 platform ceiling 与 org 可下调 policy 分层;有效限制取两者较低值。 - 生产容量按不可突破的 platform ceiling 与 org 可下调 policy 分层;有效限制取两者较低值。
Agent admission 必须持久、有界、跨 org 公平且显式背压(见 ADR-0022)。 Agent admission 必须持久、有界、跨 org 公平且显式背压(见 ADR-0022 /
`Spec.System.Capacity`)。
- 平台管理员只通过独立的 platform-owned 飞书应用与可撤销 Platform Session 认证,不复用 - 平台管理员只通过独立的 platform-owned 飞书应用与可撤销 Platform Session 认证,不复用
客户 `User`/org membership;平台写操作与 append-only audit 同事务,break-glass 只走 客户 `User`/org membership;平台写操作与 append-only audit 同事务,break-glass 只走
双因子的离线恢复流程(见 ADR-0023)。 双因子的离线恢复流程(见 ADR-0023 / `Spec.System.PlatformAdministration`)。
- 受控 alpha 暂采用一 Organization 一具名 systemd Silo:独立 database role/database、 - 受控 alpha 暂采用一 Organization 一具名 systemd Silo:独立 database role/database、
service identity、workspace、keyring 与 Feishu/provider connection;进程必须由 service identity、workspace、keyring 与 Feishu/provider connection;进程必须由
`HUB_SILO_ORGANIZATION_ID` fail-closed 绑定唯一 org,平台后台不开放。共享 SaaS `HUB_SILO_ORGANIZATION_ID` fail-closed 绑定唯一 org,平台后台不开放。共享 SaaS
@@ -32,6 +36,10 @@
`/compact` 只能由卡片动作以未经包装的精确 prompt 转发。 `/compact` 只能由卡片动作以未经包装的精确 prompt 转发。
`settingSources: []` 继续禁用项目/用户配置加载,不得把任意 workspace `.claude` 配置变成 `settingSources: []` 继续禁用项目/用户配置加载,不得把任意 workspace `.claude` 配置变成
运行时能力(见 ADR-0018)。 运行时能力(见 ADR-0018)。
skill/role 的管理面分组由一棵 org 内共用、可嵌套的 folder 树承载:透明组织节点,
不进入身份、解析与授权——name/roleId 仍 org 内唯一,role→skill 绑定、run 加载与
slash 命令均不引用 folder;folder 归属变更是 label 类变更,不归档会话;仅空 folder
可删(见 ADR-0028 / `Spec.System.AgentRole`)。
- 项目发现由 `ProjectDiscovery` 模块统一承载:PostgreSQL `pg_trgm` 搜索派生文档、项目编号 - 项目发现由 `ProjectDiscovery` 模块统一承载:PostgreSQL `pg_trgm` 搜索派生文档、项目编号
归一化、完整 Folder breadcrumb、MANAGE 授权过滤与分页都在该模块内;飞书卡片只是 adapter。 归一化、完整 Folder breadcrumb、MANAGE 授权过滤与分页都在该模块内;飞书卡片只是 adapter。
`Project`/`Folder` 仍是事实来源,搜索文档必须可重建且由数据库触发器同步,禁止调用方双写。 `Project`/`Folder` 仍是事实来源,搜索文档必须可重建且由数据库触发器同步,禁止调用方双写。
@@ -40,12 +48,12 @@
## 纪律 ## 纪律
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。ADR 与 `CONTEXT.md` 是语义的唯一权威来源;没写的,就是没定的。 1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。契约里 prose doc 注释是语义的唯一权威来源;契约没写的,就是没定的。
2. **凡 ADR 未写明者,不得假设。** 遇到没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。 2. **凡契约未写明者,不得假设。** 遇到标了 `OPEN` 的地方,或契约根本没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
3. **新语义决策进 ADR。** 跨部件的语义分歧点按编号顺延新增 `docs/adr/NNNN-*.md`;代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。已有 ADR 正文不改写历史——推翻旧决策就写新 ADR 标记 supersede 3. **改 `spec/` 必须保持其 `lake build` 通过。**`spec/` 目录下跑 `lake build`。新增声明必须带 `/-- … -/` doc 注释和恰当标签(`PINNED` / `OPEN` / `ADR-NNNN`)。规范见 `spec/README.md`。不准用 `sorry` 把 build 糊绿
4. **实现向 ADR 对齐;偏离必须 surface。** 没有 CI gate 替你把关 ADR↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与决策不一致时,报告它,不要默默让其中一边将就另一边。 4. **实现向契约对齐;偏离必须 surface。** 没有 CI gate 替你把关 spec↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与契约不一致时,报告它,不要默默让其中一边将就另一边。
5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。 5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。
+10 -8
View File
@@ -1,23 +1,25 @@
# CLAUDE.md —— agent 操作手册(全 repo) # CLAUDE.md —— agent 操作手册(全 repo)
本 repo 是 monorepo。先读根 `README.md` 的"宪法"4 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。 本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
## 这个 repo 是什么 ## 这个 repo 是什么
- `docs/adr/` 是系统级决策的唯一权威来源;`CONTEXT.md` 是平台语言词汇表;代码注释把关键不变量锚到 ADR 编号,可 grep - `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team` - `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020)。 必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。 - org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021)。 普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 /
`Spec.System.ProjectWorkspace`)。
## 纪律 ## 纪律
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。ADR 与 `CONTEXT.md` 是语义的唯一权威来源;没写的,就是没定的。 1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。契约里 prose doc 注释是语义的唯一权威来源;契约没写的,就是没定的。
2. **凡 ADR 未写明者,不得假设。** 遇到没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。 2. **凡契约未写明者,不得假设。** 遇到标了 `OPEN` 的地方,或契约根本没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
3. **新语义决策进 ADR。** 跨部件的语义分歧点按编号顺延新增 `docs/adr/NNNN-*.md`;代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。已有 ADR 正文不改写历史——推翻旧决策就写新 ADR 标记 supersede 3. **改 `spec/` 必须保持其 `lake build` 通过。**`spec/` 目录下跑 `lake build`。新增声明必须带 `/-- … -/` doc 注释和恰当标签(`PINNED` / `OPEN` / `ADR-NNNN`)。规范见 `spec/README.md`。不准用 `sorry` 把 build 糊绿
4. **实现向 ADR 对齐;偏离必须 surface。** 没有 CI gate 替你把关 ADR↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与决策不一致时,报告它,不要默默让其中一边将就另一边。 4. **实现向契约对齐;偏离必须 surface。** 没有 CI gate 替你把关 spec↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与契约不一致时,报告它,不要默默让其中一边将就另一边。
5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。 5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。
+23 -16
View File
@@ -2,7 +2,7 @@
教研生产的数字化解决方案。核心思路:课程像 DAW / 剪辑软件那样有一个**结构化的工程文件**;coding agent 协助编辑它;一个 rule-based checker(类编译器)校验其合法性并给出 helpful fix hint。目标是把教研从一次性的文档,沉淀成**可累积、可校验、可复用的资产**。 教研生产的数字化解决方案。核心思路:课程像 DAW / 剪辑软件那样有一个**结构化的工程文件**;coding agent 协助编辑它;一个 rule-based checker(类编译器)校验其合法性并给出 helpful fix hint。目标是把教研从一次性的文档,沉淀成**可累积、可校验、可复用的资产**。
这是一个 **monorepo**。它的组织方式本身就表达了一条原则:**`docs/adr/` 是系统级决策的唯一权威来源,代码注释把关键不变量锚到 ADR 编号,可 grep。** 这是一个 **monorepo**。它的组织方式本身就表达了一条原则:**`spec/` 是上游的语义母本,其余部件是向它对齐的实现。**
## 安装 `cph` 命令行 ## 安装 `cph` 命令行
@@ -33,40 +33,47 @@ cph completions zsh > ~/.zfunc/_cph # 或 bash/fish/powershell/elvish
``` ```
README.md ← 本文件:总览 + 宪法(下面 5 条) README.md ← 本文件:总览 + 宪法(下面 5 条)
CLAUDE.md ← 全局 agent 操作手册(管整个 repo) CLAUDE.md ← 全局 agent 操作手册(管整个 repo)
docs/adr/ ← 系统级架构决策记录(跨部件,决策的唯一权威来源) docs/adr/ ← 系统级架构决策记录(跨部件,被 spec 契约引用)
CONTEXT.md平台语言词汇表(术语与禁用说法) spec/ Lean 语义母本(自包含的 Lean 工程)。见 spec/README.md
Cargo.toml ← 仓库级 cargo workspace(实现部件共用,便于跨部件复用 crate) Cargo.toml ← 仓库级 cargo workspace(实现部件共用,便于跨部件复用 crate)
crates/ ← 实现:rule-based checker(语义由 ADR 锚定)。见 crates/README.md crates/ ← 实现:rule-based checker(向 spec 对齐)。见 crates/README.md
cph-diag / cph-model / cph-schema / cph-typst ← 可复用基础(模型/校验/typst 引擎) cph-diag / cph-model / cph-schema / cph-typst ← 可复用基础(模型/校验/typst 引擎)
cph-check / cph-cli ← checker 本体 + `cph` 命令行 cph-check / cph-cli ← checker 本体 + `cph` 命令行
render/ ← typst 渲染包 cph-render(checker 的渲染后端,ADR-0005) render/ ← typst 渲染包 cph-render(母本的渲染后端之一,ADR-0005)
examples/ ← 样例工程文件(如 TH-141),流水线的真实输入 examples/ ← 样例工程文件(如 TH-141),流水线的真实输入
hub/ ← SaaS Hub:飞书协作、org 管理、agent runtime 与生产部署 hub/ ← SaaS Hub:飞书协作、org 管理、agent runtime 与生产部署
(exporter/ …) ← 将来的其他部件,平级于 crates/ (exporter/ …) ← 将来的其他部件,平级于 spec/
``` ```
`spec/` 与实现部件**物理分离、平级共存**:谁是上游、谁向谁对齐,一眼可见。
实现部件共用一个仓库根的 cargo workspace,使基础 crate(模型、typst 引擎)能被 实现部件共用一个仓库根的 cargo workspace,使基础 crate(模型、typst 引擎)能被
未来部件(如 exporter)复用,而非各自重造。 未来部件(如 exporter)复用,而非各自重造。
## 宪法 ## 宪法
4 条是本仓库的协作约定,是一切工作的前提。 5 条是 `spec/` 这份语义母本的定位与约束,是本仓库一切工作的前提。
1. **角色 —— ADR 是决策真相** 1. **角色 —— Lean 是研发侧的上游参照**
跨部件的语义决策只记录在 `docs/adr/`,一份决策一份 ADR,编号顺延、正文不改写历史。代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。没有第二份权威文档 `spec/` 用 Lean 编写,是开发者(领域专家)与 coding agent **共用**的 spec 工具,用来沉淀产品各部件的**语义**。它**不进入产品运行时**——产品里"站在 Lean 这个位置"的那个 checker 用什么技术实现,尚未决定;但那个东西的语义,先在 `spec/` 里固定下来
2. **对齐机制 —— 人肉承载,无机器兜底** 2. **对齐机制 —— Lean 只做上游参照**
CI 只验各部件自身良构(build / test / clippy),**没有**决策↔实现的一致性 gate。实现对齐 ADR,由"开发者 review + agent 巡逻 diff"这个人肉环节承载。发现漂移,报告它,不要默默让其中一边将就另一边。 不做 extract / codegen,不派生 conformance test,CI 里**没有** spec→实现的 gate。实现对齐 spec,由"开发者 review + agent 巡逻 diff"这个人肉环节承载。
(CI 里的 `spec check` 只验 spec **自身**能否 type-check,即契约内部良构,不是 spec↔实现的对齐检查。)
3. **形态 —— 自包含** 3. **资产性 —— 由 review 纪律承载,无机器兜底**
凡 ADR 未明文规定的,开发者与 agent 双方都不该假设;遇到没覆盖的地方,**显式 surface** 出来让开发者决定 这份仓库给你的是"精确、自洽、机器验内部良构的语义共识",**不是**"实现正确性保证"。spec 与实现之间那道缝,是我们自愿用人来守的——清醒地守,它就是资产;放任实现漂移而不回头同步,它就退化成最贵的过期文档
4. **深度判据 —— 只收录分歧点** 4. **形态 —— 它是人机共识的契约**
一条语义该不该写进 ADR,取决于一句话:**"不写明,开发者与 agent 会不会各自做出不同假设?"** 会 → 进 ADR;显然的东西 / 纯 plumbing / 普通 CRUD 字段 → 不进(写进去只稀释信噪比、增加维护面) 契约必须**自包含**:凡契约未明文规定的,开发者与 agent 双方都不该假设。这比"文档"严格——type checker 会逼这份契约在结构上无洞
深度上限是**你愿意在每次实现变更时手动回头同步的量**——写得比你能维护的更深,多出来的部分会率先过期、反过来误导实现。
5. **深度判据 —— 只收录分歧点。**
一条语义该不该写进 Lean,取决于一句话:**"不写明,开发者与 agent 会不会各自做出不同假设?"** 会 → 进契约;显然的东西 / 纯 plumbing / 普通 CRUD 字段 → 不进(写进去只稀释信噪比、增加维护面)。
深度上限不是 Lean 的表达力,而是**你愿意在每次实现变更时手动回头同步的量**——写得比你能维护的更深,多出来的部分会率先过期、反过来误导实现。
## CI ## 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` Rust checker 的本地与 CI 工具链由根 `rust-toolchain.toml` 固定;`.gitea/workflows/checker-check.yml`
必须安装同一精确版本并执行 `cargo fmt --all --check`、Clippy `-D warnings` 与 workspace 必须安装同一精确版本并执行 `cargo fmt --all --check`、Clippy `-D warnings` 与 workspace
全测试。升级 Rust 时这两处必须在同一提交更新并通过完整 checker gate。 全测试。升级 Rust 时这两处必须在同一提交更新并通过完整 checker gate。
+2 -3
View File
@@ -1,8 +1,7 @@
# crates/ # crates/
These crates implement the rule-based lesson checker whose semantics are These crates implement the rule-based lesson checker that aligns to the
pinned by the ADRs in `docs/adr/`: it reads an engineering-file (one lesson, semantic master in `spec/`: it reads an engineering-file (one lesson, ADR-0005)
ADR-0005)
laid out per ADR-0008 (declarative `manifest.toml` + per-element laid out per ADR-0008 (declarative `manifest.toml` + per-element
`element.toml`), validates structure and content, and emits diagnostics. `element.toml`), validates structure and content, and emits diagnostics.
`cph-diag` (the shared diagnostic vocabulary), `cph-model` (the ADR-0008 loader), `cph-diag` (the shared diagnostic vocabulary), `cph-model` (the ADR-0008 loader),
+11 -9
View File
@@ -19,11 +19,13 @@ const DEFAULT_TARGET: &str = "student";
/// Severity of the render-coverage ("element ignored under a target") diagnostic. /// Severity of the render-coverage ("element ignored under a target") diagnostic.
/// ///
/// **PINNED to `warning` by ADR-0005:** when a /// **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
/// `(kind, target)` pair has no render rule the checker reports that the element /// `(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 /// 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 /// severity as a const makes "it is a warning, not an error" a greppable,
/// fact rather than an inline literal. /// alignable fact rather than an inline literal.
const RENDER_IGNORED_SEVERITY: Severity = Severity::Warning; const RENDER_IGNORED_SEVERITY: Severity = Severity::Warning;
/// The result of running [`check`] (or the check phases of [`build`]). /// The result of running [`check`] (or the check phases of [`build`]).
@@ -55,12 +57,12 @@ impl CheckReport {
/// Whether any collected diagnostic is `Error`-severity. /// Whether any collected diagnostic is `Error`-severity.
/// ///
/// **Legality decision (ADR-0010).** `!has_errors()` decides lesson /// **Legality decision (spec alignment).** `!has_errors()` is the
/// legality: a lesson is *legal* iff its diagnostics contain no error-level /// implementation of `Spec.Courseware.Legal` (`spec/Spec/Courseware/Check/Diagnostic.lean`):
/// diagnostic (warnings are non-blocking — see `Severity`). There is no CI /// a lesson is *legal* iff its diagnostics contain no error-level diagnostic
/// gate enforcing ADR↔implementation alignment (repo constitution); it is /// (warnings are non-blocking — see `Severity` / ADR-0010). There is no CI
/// kept greppable here so a reviewer can tie the orchestrator's gate to /// gate enforcing this alignment (repo constitution); it is kept greppable
/// the ADR. /// here so a reviewer can tie the orchestrator's gate to the Lean master.
pub fn has_errors(&self) -> bool { pub fn has_errors(&self) -> bool {
self.diagnostics self.diagnostics
.iter() .iter()
+15 -10
View File
@@ -2,7 +2,7 @@
//! //!
//! Every other crate in the workspace depends on these types to report //! Every other crate in the workspace depends on these types to report
//! problems. The vocabulary is intentionally small and stable: a [`Severity`] //! problems. The vocabulary is intentionally small and stable: a [`Severity`]
//! (two-valued, ADR-0010), a closed set of machine-stable [`DiagCode`]s, an //! (mirroring the Lean master), a closed set of machine-stable [`DiagCode`]s, an
//! optional [`SourceSpan`] pointing back at the offending source, and a //! optional [`SourceSpan`] pointing back at the offending source, and a
//! [`Diagnostic`] tying them together with a human message and a fix hint. //! [`Diagnostic`] tying them together with a human message and a fix hint.
//! //!
@@ -17,27 +17,32 @@ use serde::Serialize;
/// Severity of a diagnostic. /// Severity of a diagnostic.
/// ///
/// **Pinned by ADR-0005 / ADR-0010: exactly two values.** /// **Mirrors `Spec.Courseware.Diagnostic.Severity`** in the Lean semantic
/// master (`spec/Spec/Courseware/Check/Diagnostic.lean`), whose definition is
/// exactly:
/// ///
/// ```text /// ```text
/// warning | error /// inductive Severity where
/// | warning
/// | error
/// ``` /// ```
/// ///
/// This two-valued shape is a **contract decision**, not an accident: the /// This two-valued shape is a **contract decision**, not an accident: the Lean
/// finer levels (`info` / `hint` / `note`) are deliberately undecided, so we /// module pins `Severity` to exactly `warning | error` and states the finer
/// levels (`info` / `hint` / `note`) are deliberately undecided. We therefore
/// do **not** add an info/note level here. `error` blocks (the artifact is /// do **not** add an info/note level here. `error` blocks (the artifact is
/// invalid); `warning` does not block (the artifact still exports, but with /// invalid); `warning` does not block (the artifact still exports, but with
/// loss / an ignored element — e.g. ADR-0005's "missing render ⇒ warning"). /// loss / an ignored element — e.g. ADR-0005's "missing render ⇒ warning").
/// ///
/// There is no CI gate enforcing ADR↔implementation alignment (see the repo /// There is no CI gate enforcing this alignment (see the repo constitution);
/// constitution); it is maintained by review, which is why the decision is /// it is maintained by review, which is why this correspondence is documented
/// documented here rather than only in the ADR. /// here rather than only in the spec.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum Severity { pub enum Severity {
/// Non-blocking: the artifact still exports, but is lossy / has an ignored /// Non-blocking: the artifact still exports, but is lossy / has an ignored
/// element. ADR-0010 `warning`. /// element. Mirrors Lean `Severity.warning`.
Warning, Warning,
/// Blocking: the artifact is invalid. ADR-0010 `error`. /// Blocking: the artifact is invalid. Mirrors Lean `Severity.error`.
Error, Error,
} }
+38 -25
View File
@@ -3,7 +3,9 @@
//! This crate is the **loader**, not the full checker. It reads //! This crate is the **loader**, not the full checker. It reads
//! `<root>/manifest.toml` (project / info / ordered `[[parts]]` / declared //! `<root>/manifest.toml` (project / info / ordered `[[parts]]` / declared
//! `[targets.*]`) and each part's `<root>/<path>/element.toml`, and produces an //! `[targets.*]`) and each part's `<root>/<path>/element.toml`, and produces an
//! ordered [`Lesson`], where the order of `parts` carries teaching semantics. //! 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.
//! //!
//! Scope boundaries (deliberately staying in lane): //! Scope boundaries (deliberately staying in lane):
//! - It validates **structure** only: manifest shape, element.toml shape, and //! - It validates **structure** only: manifest shape, element.toml shape, and
@@ -21,7 +23,7 @@ use serde::{Deserialize, Serialize};
/// An ordered, in-memory lesson loaded from an engineering file. /// An ordered, in-memory lesson loaded from an engineering file.
/// ///
/// `parts` is an ordered /// Mirrors the Lean master's `Lesson = List (Element P)`: `parts` is an ordered
/// `Vec`, and that order is the lesson's order (ADR-0008 §"the lesson manifest /// `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). /// is declarative" — the `[[parts]]` array order is the single source of truth).
#[derive(Debug, Clone, PartialEq, Serialize)] #[derive(Debug, Clone, PartialEq, Serialize)]
@@ -84,8 +86,9 @@ pub struct TargetConfig {
/// with template `exports/<name>.typ` when no `[[steps]]` are given. /// with template `exports/<name>.typ` when no `[[steps]]` are given.
pub steps: Vec<Step>, pub steps: Vec<Step>,
/// The **render-coverage declaration**: which element kinds this target /// The **render-coverage declaration**: which element kinds this target
/// renders. Realizes ADR-0011's "render /// renders. Realizes `Spec.Courseware.TargetSpec.covers : KindId → Prop`
/// coverage is a declaration, not a payload": the declaration keeps *which /// (`spec/Spec/Courseware/Export/Render.lean`) and ADR-0011's "render
/// coverage is a declaration, not a payload": the contract keeps *which
/// kinds a target renders* (used by the `renderIgnored` seed diagnostic), /// kinds a target renders* (used by the `renderIgnored` seed diagnostic),
/// while the rendering "how" lives in the template/steps. /// while the rendering "how" lives in the template/steps.
/// ///
@@ -99,10 +102,13 @@ pub struct TargetConfig {
/// The artifact an export target produces (ADR-0009/0011). /// The artifact an export target produces (ADR-0009/0011).
/// ///
/// **Pinned by ADR-0011** as an ADT with fields: /// **Mirrors `Spec.Courseware.Artifact`** in the Lean semantic master
/// (`spec/Spec/Courseware/Export/Artifact.lean`), whose definition is exactly:
/// ///
/// ```text /// ```text
/// Artifact = singleFile (filepath) | fileTree (root, outputs) /// inductive Artifact where
/// | singleFile (filepath : String)
/// | fileTree (root : String) (outputs : String)
/// ``` /// ```
/// ///
/// ADR-0011 pinned the artifact as an ADT **with fields**: "what the product /// ADR-0011 pinned the artifact as an ADT **with fields**: "what the product
@@ -114,20 +120,20 @@ pub struct TargetConfig {
/// `"single-file"` → [`Artifact::SingleFile`], `"file-tree"` → /// `"single-file"` → [`Artifact::SingleFile`], `"file-tree"` →
/// [`Artifact::FileTree`]. /// [`Artifact::FileTree`].
/// ///
/// As with `cph-diag`'s `Severity`, there is no CI gate enforcing ADR↔ /// As with `cph-diag`'s `Severity`, there is no CI gate enforcing this
/// implementation alignment (see the repo constitution) — it is maintained by /// alignment (see the repo constitution) — it is maintained by review, which is
/// review, which is why the decision is documented here. /// why the correspondence is documented here.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum Artifact { pub enum Artifact {
/// One bundled document landing at `filepath` (relative to the engineering /// One bundled document landing at `filepath` (relative to the engineering
/// root). ADR-0011 `singleFile`. The default artifact shape. /// root). Mirrors Lean `Artifact.singleFile`. The default artifact shape.
SingleFile { SingleFile {
/// Where the single product is written (relative to the engineering /// Where the single product is written (relative to the engineering
/// root), e.g. `build/student.pdf`. /// root), e.g. `build/student.pdf`.
filepath: PathBuf, filepath: PathBuf,
}, },
/// A set of files under `root` matching the `outputs` glob. ADR-0011 /// A set of files under `root` matching the `outputs` glob. Mirrors Lean
/// `fileTree`. /// `Artifact.fileTree`.
FileTree { FileTree {
/// The output directory (relative to the engineering root). /// The output directory (relative to the engineering root).
root: PathBuf, root: PathBuf,
@@ -150,10 +156,14 @@ impl Artifact {
/// One typed build step (ADR-0011). /// One typed build step (ADR-0011).
/// ///
/// **Pinned by ADR-0011** as an ADT: /// **Mirrors `Spec.Courseware.Step`** in the Lean semantic master
/// (`spec/Spec/Courseware/Export/Render.lean`), whose definition is exactly:
/// ///
/// ```text /// ```text
/// Step = typstCompile (template) | shell (run) | assembleMarkdown (field) /// inductive Step where
/// | typstCompile (template : String)
/// | shell (run : String)
/// | assembleMarkdown (field : String)
/// ``` /// ```
/// ///
/// A step is a *typed* operation (extensible): `TypstCompile` compiles a /// A step is a *typed* operation (extensible): `TypstCompile` compiles a
@@ -173,20 +183,20 @@ impl Artifact {
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum Step { pub enum Step {
/// Compile a template file (relative to the engineering root) into the /// Compile a template file (relative to the engineering root) into the
/// artifact; the framework injects the manifest. ADR-0011 /// artifact; the framework injects the manifest. Mirrors Lean
/// `typstCompile`. /// `Step.typstCompile`.
TypstCompile { TypstCompile {
/// The template file to compile as main, e.g. `exports/student.typ`. /// The template file to compile as main, e.g. `exports/student.typ`.
template: PathBuf, template: PathBuf,
}, },
/// Run a shell command — the escape hatch. ADR-0011 `shell`. /// Run a shell command — the escape hatch. Mirrors Lean `Step.shell`.
Shell { Shell {
/// The command line to run. /// The command line to run.
run: String, run: String,
}, },
/// Assemble a single-file markdown deliverable by concatenating each /// Assemble a single-file markdown deliverable by concatenating each
/// element's `field` markdown content file in `[[parts]]` order. ADR-0011 /// element's `field` markdown content file in `[[parts]]` order. Mirrors
/// `assembleMarkdown` (ADR-0015). Not a typst build — the /// Lean `Step.assembleMarkdown` (ADR-0015). Not a typst build — the
/// framework owns the read/concatenate/write itself. /// framework owns the read/concatenate/write itself.
AssembleMarkdown { AssembleMarkdown {
/// The per-element markdown content field to assemble (e.g. `slides`, /// The per-element markdown content field to assemble (e.g. `slides`,
@@ -216,11 +226,12 @@ pub struct Project {
/// `[info]` table (passed through to render targets verbatim). /// `[info]` table (passed through to render targets verbatim).
/// ///
/// The *canonical* model whose /// **Mirrors `Spec.Courseware.Info`** in the Lean semantic master
/// (`spec/Spec/Courseware/Model/Info.lean`): the *canonical* model whose
/// `authors` is always a list. The authoring-surface form (string-or-array /// `authors` is always a list. The authoring-surface form (string-or-array
/// `author`) is the separate [`RawInfo`] / [`RawAuthor`], normalized into this /// `author`) is the separate [`RawInfo`] / [`RawAuthor`], normalized into this
/// at the load boundary. No /// at the load boundary — mirroring the Lean `RawInfo` / `RawAuthor` split. No
/// CI gate enforces ADR↔implementation alignment (repo constitution); it is kept greppable. /// CI gate enforces this alignment (repo constitution); it is kept greppable.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Info { pub struct Info {
/// Lesson title. /// Lesson title.
@@ -229,6 +240,7 @@ pub struct Info {
/// so this is a list, not a single name. Empty when `[info]` declares no /// 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) /// `author`. The on-disk `author` accepts either a bare string (one author)
/// or an array of strings (see [`RawAuthor`]); both load into this `Vec`. /// or an array of strings (see [`RawAuthor`]); both load into this `Vec`.
/// Mirrors Lean `Info.authors : List String`.
pub authors: Vec<String>, pub authors: Vec<String>,
} }
@@ -278,7 +290,8 @@ struct RawProject {
name: String, name: String,
} }
/// The authoring-surface `[info]`: the raw form that exists for /// The authoring-surface `[info]` (mirrors Lean `RawInfo` in
/// `spec/Spec/Courseware/Model/Info.lean`): the raw form that exists for
/// fill-in convenience, normalized into the canonical [`Info`] at the load /// fill-in convenience, normalized into the canonical [`Info`] at the load
/// boundary. Not the form the rest of the model traffics in. /// boundary. Not the form the rest of the model traffics in.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -288,7 +301,7 @@ struct RawInfo {
} }
/// On-disk `author`: either a single name (`author = "…"`) or a list /// On-disk `author`: either a single name (`author = "…"`) or a list
/// (`author = ["…", "…"]`). A fill-in convenience whose /// (`author = ["…", "…"]`). Mirrors Lean `RawAuthor`: a fill-in convenience whose
/// string-or-array union lives **only** at the load boundary — [`RawAuthor::into_vec`] /// 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. /// folds it into the canonical [`Info::authors`] `Vec`, after which it never appears.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -299,7 +312,7 @@ enum RawAuthor {
} }
impl RawAuthor { impl RawAuthor {
/// Flatten to the ordered author list: a single /// Flatten to the ordered author list (Lean `RawAuthor.normalize`): a single
/// name becomes a one-element list; a list passes through verbatim. /// name becomes a one-element list; a list passes through verbatim.
fn into_vec(self) -> Vec<String> { fn into_vec(self) -> Vec<String> {
match self { match self {
+81
View File
@@ -0,0 +1,81 @@
# ADR 0028: Agent Configuration Folder Tree
## Status
Accepted.
## Context
ADR-0017/0018 made Agent roles and skills Organization-scoped dynamic runtime
configuration, managed without process restarts. The org admin surfaces for
them (`/admin/roles` and `/admin/skills`) render every
role/skill as one large editor card in a single flat list. As an Organization
accumulates roles and skills, the management pages degrade into an endless
scroll with no grouping affordance.
The project explorer already solves the analogous problem for projects with
transparent folders (ADR-0021): org-scoped navigation nodes that are not
permission resources. Roles and skills need the same affordance, but their
semantics differ from projects in one crucial way: skill names and role IDs
are referenced by role→skill bindings, run-time skill snapshot loading, and
Feishu slash commands. Any grouping mechanism must not leak into those
resolution paths.
## Decision
Introduce an Organization-scoped folder tree shared by Agent roles and Agent
skills:
- One folder tree per Organization is shared by both roles and skills (e.g. a
"高三化学组" folder groups that team's roles and its skills together). It is
a distinct entity from the project explorer `Folder` of ADR-0021 — the two
trees are managed independently and never reference each other.
- Folders are **transparent organization nodes**, following the ADR-0021
project-folder precedent: they exist for management-surface navigation and
grouping only, are not permission resources, and hold no grants.
- Folder membership is **not part of role/skill identity or resolution**:
- skill `name` and role `roleId` remain unique per Organization regardless
of folder membership;
- role→skill bindings, run admission's frozen role snapshot, run-scoped
skill loading, and Feishu slash commands never reference folders.
- Each role/skill sits in at most one folder; membership is optional
(unfiled items remain first-class). Folders nest arbitrarily.
- Folder assignment is a label-class change in the ADR-0017 sense: it never
archives Agent sessions, because the execution surface (model, prompt,
tools, skill content) is untouched.
- A folder can be deleted only when empty — no child folders, no roles, no
skills. Relocating items out of a folder is an explicit user action, so no
orphan-placement rule is needed yet.
- Admin web renders both pages as a left folder tree plus the item list of
the selected folder ("all" and "unfiled" included). The host-console CLI is
unchanged: folder management lives in the web surface, and CLI
`upsert-role`/`install-skill` never touch folder assignment.
## Consequences
- New DB entity `OrganizationAgentConfigFolder` (org-scoped, self-nesting via
`parentId`, delete restricted while referenced) plus nullable `folderId` on
`OrganizationAgentRole` and `OrganizationAgentSkill` (SetNull on folder
delete, though the service refuses to delete non-empty folders).
- The spec pins the transparency and single-membership semantics in
`Spec.System.AgentRole` (`AgentConfigFolder`), so future implementors do
not re-derive them differently (e.g. path-style names or per-folder
uniqueness).
- Org admin APIs gain folder CRUD plus role/skill folder-assignment endpoints
that skip session archival by construction.
- Moving a role/skill between folders changes nothing about authorization,
run resolution, or audit-visible configuration lineage beyond the folder
assignment event itself.
## Open Questions / Deferred
- Drag-and-drop assignment and bulk moves are deferred; assignment is a
per-item select for now.
- Folder-level usage aggregation for roles/skills is deferred (project
folders already aggregate usage under ADR-0021; agent configuration has no
usage dimension yet).
- CLI flags for folder assignment are deferred until a console workflow asks
for them.
- Folder-scoped default-role policies (e.g. per-folder defaults) are rejected
for now: the Organization keeps exactly one active default role
(ADR-0017/0018 invariant) regardless of folder structure.
+1 -4
View File
@@ -32,7 +32,7 @@ App ID 通常以 `cli_` 开头,可以写入交付单。App Secret 必须通过
| 接收群聊中 @ 机器人的消息 | `im:message.group_at_msg:readonly` | | 接收群聊中 @ 机器人的消息 | `im:message.group_at_msg:readonly` |
| 以应用身份发送消息 | `im:message:send_as_bot` | | 以应用身份发送消息 | `im:message:send_as_bot` |
| 读取触发消息和线程上下文 | `im:message:readonly` | | 读取触发消息和线程上下文 | `im:message:readonly` |
| 获取消息中的图片/文件,并向飞书上传图片或文件(含 Agent 回答中的图片发送) | `im:resource` | | 获取与上传图片或文件 | `im:resource` |
| 添加、删除消息表情回复 | `im:message.reactions:write_only` | | 添加、删除消息表情回复 | `im:message.reactions:write_only` |
| 获取用户基本信息 | `contact:user.base:readonly` | | 获取用户基本信息 | `contact:user.base:readonly` |
| 获取用户基本资料 | `contact:user.basic_profile:readonly` | | 获取用户基本资料 | `contact:user.basic_profile:readonly` |
@@ -66,9 +66,6 @@ Educraft 机器人以应用身份调用上述 API,因此这些 scope 全部放
如果 API 调试台提示缺少更细粒度权限,请把错误提示和发生时间截图给部署人员。不要自行开通通讯录全量读取等超出本表的权限。 如果 API 调试台提示缺少更细粒度权限,请把错误提示和发生时间截图给部署人员。不要自行开通通讯录全量读取等超出本表的权限。
说明:`im:resource` 既用于下载用户发来的图片/文件,也用于 Agent 回复时把本地或远程图片上传为飞书 `image_key` 后嵌入消息卡片。缺少该权限时,带图回答会发送失败或降级为无图文本。已开通该 scope 的存量应用一般无需新增权限,但若权限尚未随最新版本发布,请创建新版本并审核发布。
## 4. 配置事件与卡片回调 ## 4. 配置事件与卡片回调
进入“事件与回调”。 进入“事件与回调”。
-3
View File
@@ -5,9 +5,6 @@ dist/
.env.* .env.*
!.env.example !.env.example
.secrets/ .secrets/
.dev-keyring.json
.dev-workspaces/
.dev-skills/
admin-web/node_modules/ admin-web/node_modules/
admin-web/build/ admin-web/build/
admin-web/.svelte-kit/ admin-web/.svelte-kit/
+26
View File
@@ -317,6 +317,7 @@ export interface AgentRoleRow {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
skillNames: readonly string[]; skillNames: readonly string[];
folderId: string | null;
} }
export interface AgentSkillRow { export interface AgentSkillRow {
@@ -329,6 +330,14 @@ export interface AgentSkillRow {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
boundRoleIds: readonly string[]; boundRoleIds: readonly string[];
folderId: string | null;
}
/** ADR-0028 transparent folder node shared by agent roles and skills. */
export interface AgentConfigFolderRow {
id: string;
name: string;
parentId: string | null;
} }
export interface SkillFileEntry { export interface SkillFileEntry {
@@ -515,4 +524,21 @@ export const api = {
patchAgentSkill: (slug: string, name: string, body: { description?: string; disabled?: boolean }) => patchAgentSkill: (slug: string, name: string, body: { description?: string; disabled?: boolean }) =>
patch(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}`, body) as Promise<{ disabled?: boolean; updated?: boolean }>, patch(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}`, body) as Promise<{ disabled?: boolean; updated?: boolean }>,
agentModels: (slug: string) => get(`${orgBase(slug)}/agent-models`) as Promise<{ models: AgentModelRow[] }>, agentModels: (slug: string) => get(`${orgBase(slug)}/agent-models`) as Promise<{ models: AgentModelRow[] }>,
agentConfigFolders: (slug: string) =>
get(`${orgBase(slug)}/agent-config-folders`) as Promise<{ folders: AgentConfigFolderRow[] }>,
createAgentConfigFolder: (slug: string, body: { name: string; parentId?: string }) =>
post(`${orgBase(slug)}/agent-config-folders`, body) as Promise<AgentConfigFolderRow>,
patchAgentConfigFolder: (slug: string, folderId: string, body: { name?: string; parentId?: string | null }) =>
patch(`${orgBase(slug)}/agent-config-folders/${encodeURIComponent(folderId)}`, body) as Promise<AgentConfigFolderRow>,
deleteAgentConfigFolder: (slug: string, folderId: string) =>
del(`${orgBase(slug)}/agent-config-folders/${encodeURIComponent(folderId)}`) as Promise<{ deleted: boolean }>,
setAgentRoleFolder: (slug: string, roleId: string, folderId: string | null) =>
patch(`${orgBase(slug)}/agent-roles/${encodeURIComponent(roleId)}/folder`, { folderId }) as Promise<{
folderId: string | null;
}>,
setAgentSkillFolder: (slug: string, name: string, folderId: string | null) =>
patch(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}/folder`, { folderId }) as Promise<{
folderId: string | null;
}>,
}; };
@@ -0,0 +1,160 @@
<script lang="ts">
import type { AgentConfigFolderRow } from '$lib/api';
import Icon from './Icon.svelte';
/**
* ADR-0028 left folder-tree nav for the agent config pages (roles/skills).
* Transparent grouping only: selection filters the item list, it never
* affects role/skill identity or run resolution.
*/
let {
folders,
selected,
counts,
totalCount,
unfiledCount,
onselect,
oncreate,
onrename,
ondelete,
}: {
folders: AgentConfigFolderRow[];
/** 'all' | 'unfiled' | folder id */
selected: string;
/** item count per folder id (roles or skills, depending on the page) */
counts: Record<string, number>;
totalCount: number;
unfiledCount: number;
onselect: (id: string) => void;
oncreate: (parentId: string | null) => void;
onrename: (folder: AgentConfigFolderRow) => void;
ondelete: (folder: AgentConfigFolderRow) => void;
} = $props();
type Row = { folder: AgentConfigFolderRow; depth: number; hasChildren: boolean };
let collapsed = $state<ReadonlySet<string>>(new Set());
function flatten(list: AgentConfigFolderRow[], collapsedSet: ReadonlySet<string>): Row[] {
const rows: Row[] = [];
const walk = (parentId: string | null, depth: number) => {
const siblings = list
.filter((f) => f.parentId === parentId)
.sort((a, b) => a.name.localeCompare(b.name));
for (const folder of siblings) {
const hasChildren = list.some((f) => f.parentId === folder.id);
rows.push({ folder, depth, hasChildren });
if (hasChildren && !collapsedSet.has(folder.id)) walk(folder.id, depth + 1);
}
};
walk(null, 0);
return rows;
}
const rows = $derived(flatten(folders, collapsed));
function toggleCollapse(folderId: string) {
const next = new Set(collapsed);
if (next.has(folderId)) next.delete(folderId);
else next.add(folderId);
collapsed = next;
}
function childFolderCount(folderId: string): number {
return folders.filter((f) => f.parentId === folderId).length;
}
const rowClass = (active: boolean) =>
`group flex w-full items-center gap-1.5 px-2 py-1.5 text-left text-sm transition hover:bg-surface-100 ${
active ? 'bg-primary-100 font-medium text-primary-900' : 'text-surface-800'
}`;
</script>
<nav class="saas-card p-2" aria-label="文件夹导航">
<div class="flex items-center justify-between px-2 py-1.5">
<span class="text-xs font-semibold uppercase tracking-wide text-surface-600">文件夹</span>
<button
type="button"
class="text-xs text-primary-700 hover:text-primary-900"
onclick={() => oncreate(null)}
>
+ 新建
</button>
</div>
<button type="button" class={rowClass(selected === 'all')} onclick={() => onselect('all')}>
<span class="w-3.5"></span>
<span class="min-w-0 flex-1 truncate">全部</span>
<span class="saas-badge-neutral">{totalCount}</span>
</button>
<button type="button" class={rowClass(selected === 'unfiled')} onclick={() => onselect('unfiled')}>
<span class="w-3.5"></span>
<span class="min-w-0 flex-1 truncate">未分类</span>
<span class="saas-badge-neutral">{unfiledCount}</span>
</button>
{#each rows as row (row.folder.id)}
{@const itemCount = counts[row.folder.id] ?? 0}
{@const childCount = childFolderCount(row.folder.id)}
<div class={rowClass(selected === row.folder.id)} style:padding-left="{0.5 + row.depth * 1}rem">
{#if row.hasChildren}
<button
type="button"
class="w-3.5 shrink-0 text-center text-xs text-surface-600"
aria-label={collapsed.has(row.folder.id) ? '展开' : '折叠'}
onclick={(e) => {
e.stopPropagation();
toggleCollapse(row.folder.id);
}}
>
{collapsed.has(row.folder.id) ? '▸' : '▾'}
</button>
{:else}
<span class="w-3.5 shrink-0"></span>
{/if}
<button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 text-left" onclick={() => onselect(row.folder.id)}>
<Icon name="folder" class="h-3.5 w-3.5 shrink-0 opacity-60" />
<span class="min-w-0 flex-1 truncate">{row.folder.name}</span>
{#if itemCount > 0}
<span class="saas-badge-neutral">{itemCount}</span>
{/if}
</button>
<span
class="flex shrink-0 items-center gap-0.5 opacity-0 transition group-hover:opacity-100 focus-within:opacity-100"
>
<button
type="button"
class="flex h-5 w-5 items-center justify-center text-surface-500 hover:text-primary-700"
title="在此新建子文件夹"
aria-label="在 {row.folder.name} 内新建子文件夹"
onclick={() => oncreate(row.folder.id)}
>
<Icon name="folder-plus" class="h-3.5 w-3.5" />
</button>
<button
type="button"
class="flex h-5 w-5 items-center justify-center text-surface-500 hover:text-primary-700"
title="重命名 / 移动"
aria-label="重命名或移动 {row.folder.name}"
onclick={() => onrename(row.folder)}
>
</button>
<button
type="button"
class="flex h-5 w-5 items-center justify-center text-surface-500 hover:text-error-700 disabled:cursor-not-allowed disabled:opacity-30"
title={itemCount > 0 || childCount > 0 ? '仅可删除空文件夹' : '删除文件夹'}
aria-label="删除 {row.folder.name}"
disabled={itemCount > 0 || childCount > 0}
onclick={() => ondelete(row.folder)}
>
×
</button>
</span>
</div>
{/each}
{#if folders.length === 0}
<p class="px-2 py-3 text-xs text-surface-600">还没有文件夹。新建一个来分组管理。</p>
{/if}
</nav>
@@ -5,6 +5,7 @@
import { fmtDate } from '$lib/format'; import { fmtDate } from '$lib/format';
import { TOOL_OPTIONS } from '$lib/constants'; import { TOOL_OPTIONS } from '$lib/constants';
import SelectField from '$lib/components/SelectField.svelte'; import SelectField from '$lib/components/SelectField.svelte';
import SearchableSelectField from '$lib/components/SearchableSelectField.svelte';
import CheckboxControl from '$lib/components/CheckboxControl.svelte'; import CheckboxControl from '$lib/components/CheckboxControl.svelte';
import Icon from '$lib/components/Icon.svelte'; import Icon from '$lib/components/Icon.svelte';
import { toastError, toastSuccess } from '$lib/toast'; import { toastError, toastSuccess } from '$lib/toast';
@@ -14,15 +15,20 @@
models, models,
skills, skills,
slug, slug,
folderItems,
onupdated, onupdated,
onskillschanged, onskillschanged,
onfolderchanged,
}: { }: {
r: AgentRoleRow; r: AgentRoleRow;
models: AgentModelRow[]; models: AgentModelRow[];
skills: AgentSkillRow[]; skills: AgentSkillRow[];
slug: string; slug: string;
/** ADR-0028 folder choices ('' = 未分类); transparent grouping only */
folderItems: { value: string; label: string }[];
onupdated: (updated: AgentRoleRow) => void; onupdated: (updated: AgentRoleRow) => void;
onskillschanged: (roleId: string, skillNames: string[]) => void; onskillschanged: (roleId: string, skillNames: string[]) => void;
onfolderchanged: (roleId: string, folderId: string | null) => void;
} = $props(); } = $props();
const initial = { const initial = {
@@ -44,6 +50,8 @@
let isDefault = $state(initial.isDefault); let isDefault = $state(initial.isDefault);
let selectedSkills = $state<string[]>([...initial.skillNames]); let selectedSkills = $state<string[]>([...initial.skillNames]);
let saving = $state(false); let saving = $state(false);
let folderValue = $state(r.folderId ?? '');
let savingFolder = $state(false);
const groupedTools = TOOL_OPTIONS.reduce( const groupedTools = TOOL_OPTIONS.reduce(
(acc, t) => { (acc, t) => {
@@ -53,10 +61,15 @@
{} as Record<string, typeof TOOL_OPTIONS>, {} as Record<string, typeof TOOL_OPTIONS>,
); );
const modelItems = $derived([ const modelItems = $derived.by(() => {
{ value: '', label: '(使用平台默认模型)' }, const fromCatalog = models.map((m) => ({ value: m.id, label: `${m.label}${m.id}` }));
...models.map((m) => ({ value: m.id, label: `${m.label}${m.id}` })), const items = [{ value: '', label: '(使用平台默认模型)' }, ...fromCatalog];
]); // Keep a previously saved model selectable even if it left the live catalog.
if (defaultModel !== '' && !items.some((item) => item.value === defaultModel)) {
items.push({ value: defaultModel, label: `${defaultModel}(当前已存,不在目录中)` });
}
return items;
});
const skillItems = $derived(skills.map((s) => ({ value: s.name, label: s.name }))); const skillItems = $derived(skills.map((s) => ({ value: s.name, label: s.name })));
@@ -105,6 +118,24 @@
function sortKeyDirty(): boolean { function sortKeyDirty(): boolean {
return Number(sortOrder) !== r.sortOrder; return Number(sortOrder) !== r.sortOrder;
} }
// ADR-0028: folder assignment is a label-class change — instant-apply, no
// session archival, independent of the configuration save button.
async function saveFolder(next: string) {
const folderId = next === '' ? null : next;
if (folderId === r.folderId) return;
savingFolder = true;
try {
await api.setAgentRoleFolder(slug, r.roleId, folderId);
onfolderchanged(r.roleId, folderId);
toastSuccess('已更新所属文件夹');
} catch (err) {
folderValue = r.folderId ?? '';
toastError(err instanceof Error ? err.message : String(err));
} finally {
savingFolder = false;
}
}
</script> </script>
<div class="saas-card-pad"> <div class="saas-card-pad">
@@ -114,6 +145,12 @@
{#if r.isDefault} {#if r.isDefault}
<span class="saas-badge-success">默认</span> <span class="saas-badge-success">默认</span>
{/if} {/if}
<div class="ml-auto flex items-center gap-2">
<span class="text-xs text-surface-600">文件夹</span>
<div class="w-44">
<SelectField items={folderItems} bind:value={folderValue} disabled={savingFolder} onchange={saveFolder} />
</div>
</div>
</div> </div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
@@ -129,7 +166,13 @@
<div class="mt-4"> <div class="mt-4">
<p class="saas-label">默认模型</p> <p class="saas-label">默认模型</p>
<SelectField items={modelItems} bind:value={defaultModel} /> <SearchableSelectField
items={modelItems}
bind:value={defaultModel}
placeholder="选择模型…"
searchPlaceholder="搜索模型名称或 ID"
emptyText="无匹配模型"
/>
</div> </div>
<div class="mt-4"> <div class="mt-4">
@@ -0,0 +1,102 @@
<script lang="ts">
import { Combobox } from 'bits-ui';
import Icon from './Icon.svelte';
import type { SelectItem } from './SelectField.svelte';
let {
items,
value = $bindable(''),
class: className = '',
disabled = false,
placeholder = '请选择…',
searchPlaceholder = '搜索…',
emptyText = '无匹配项',
onchange,
}: {
items: SelectItem[];
value?: string;
class?: string;
disabled?: boolean;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
onchange?: (value: string) => void;
} = $props();
let searchValue = $state('');
const filteredItems = $derived.by(() => {
const q = searchValue.trim().toLowerCase();
if (q === '') return items;
return items.filter(
(item) => item.label.toLowerCase().includes(q) || item.value.toLowerCase().includes(q),
);
});
</script>
<Combobox.Root
type="single"
{items}
{disabled}
{value}
allowDeselect={false}
onValueChange={(next) => {
value = next;
onchange?.(next);
}}
onOpenChangeComplete={(open) => {
if (!open) searchValue = '';
}}
>
<div class="relative {className}">
<Combobox.Input
class="saas-combobox-input"
{disabled}
{placeholder}
aria-label={searchPlaceholder}
oninput={(e) => {
searchValue = e.currentTarget.value;
}}
/>
<Combobox.Trigger
class="absolute inset-y-0 right-0 flex w-9 items-center justify-center text-surface-600 disabled:cursor-not-allowed disabled:opacity-55"
{disabled}
aria-label="展开选项"
>
<svg
class="h-4 w-4 shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 15l3.75 3.75L15.75 15" />
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 9l3.75-3.75L15.75 9" />
</svg>
</Combobox.Trigger>
</div>
<Combobox.Portal>
<Combobox.Content class="saas-select-content" sideOffset={6} collisionPadding={8}>
<Combobox.Viewport class="max-h-72 overflow-y-auto p-1">
{#each filteredItems as item (item.value)}
<Combobox.Item
class="saas-select-item"
value={item.value}
label={item.label}
disabled={item.disabled}
>
{#snippet children({ selected })}
<span class="min-w-0 flex-1 truncate">{item.label}</span>
{#if selected}
<Icon name="check" class="ml-2 h-4 w-4 shrink-0 text-primary-600" />
{/if}
{/snippet}
</Combobox.Item>
{:else}
<div class="px-2 py-2 text-sm text-surface-600">{emptyText}</div>
{/each}
</Combobox.Viewport>
</Combobox.Content>
</Combobox.Portal>
</Combobox.Root>
@@ -3,18 +3,24 @@
import { api } from '$lib/api'; import { api } from '$lib/api';
import { fmtDate } from '$lib/format'; import { fmtDate } from '$lib/format';
import Icon from '$lib/components/Icon.svelte'; import Icon from '$lib/components/Icon.svelte';
import SelectField from '$lib/components/SelectField.svelte';
import { toastError, toastSuccess } from '$lib/toast'; import { toastError, toastSuccess } from '$lib/toast';
let { let {
slug, slug,
skill, skill,
folderItems,
oninstalled, oninstalled,
ondisabled, ondisabled,
onfolderchanged,
}: { }: {
slug: string; slug: string;
skill: AgentSkillRow; skill: AgentSkillRow;
/** ADR-0028 folder choices ('' = 未分类); transparent grouping only */
folderItems: { value: string; label: string }[];
oninstalled: (result: { id: string; name: string; contentDigest: string }) => void; oninstalled: (result: { id: string; name: string; contentDigest: string }) => void;
ondisabled: (name: string) => void; ondisabled: (name: string) => void;
onfolderchanged: (name: string, folderId: string | null) => void;
} = $props(); } = $props();
type FileNode = { path: string; content: string }; type FileNode = { path: string; content: string };
@@ -28,6 +34,8 @@
let dirty = $state(false); let dirty = $state(false);
let newFilePath = $state(''); let newFilePath = $state('');
let showNewFile = $state(false); let showNewFile = $state(false);
let folderValue = $state(skill.folderId ?? '');
let savingFolder = $state(false);
const selectedFile = $derived(files.find((f) => f.path === selectedPath) ?? null); const selectedFile = $derived(files.find((f) => f.path === selectedPath) ?? null);
const hasManifest = $derived(files.some((f) => f.path === 'SKILL.md')); const hasManifest = $derived(files.some((f) => f.path === 'SKILL.md'));
@@ -152,6 +160,24 @@
dirty = true; dirty = true;
} }
// ADR-0028: folder assignment is a label-class change — instant-apply, no
// session archival, independent of the content save button.
async function saveFolder(next: string) {
const folderId = next === '' ? null : next;
if (folderId === skill.folderId) return;
savingFolder = true;
try {
await api.setAgentSkillFolder(slug, skill.name, folderId);
onfolderchanged(skill.name, folderId);
toastSuccess('已更新所属文件夹');
} catch (err) {
folderValue = skill.folderId ?? '';
toastError(err instanceof Error ? err.message : String(err));
} finally {
savingFolder = false;
}
}
function updateFrontmatter(content: string, key: string, value: string): string { function updateFrontmatter(content: string, key: string, value: string): string {
const regex = new RegExp(`^(${key}:\\s*)(.*?)(\\s*)$`, 'm'); const regex = new RegExp(`^(${key}:\\s*)(.*?)(\\s*)$`, 'm');
if (regex.test(content)) { if (regex.test(content)) {
@@ -178,6 +204,12 @@
{#if skill.disabledAt} {#if skill.disabledAt}
<span class="saas-badge-error">已禁用</span> <span class="saas-badge-error">已禁用</span>
{/if} {/if}
<div class="ml-auto flex items-center gap-2">
<span class="text-xs text-surface-600">文件夹</span>
<div class="w-44">
<SelectField items={folderItems} bind:value={folderValue} disabled={savingFolder} onchange={saveFolder} />
</div>
</div>
</div> </div>
<div class="mb-4 grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="mb-4 grid grid-cols-1 gap-4 md:grid-cols-2">
+228 -33
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { api, type AgentRoleRow, type AgentModelRow, type AgentSkillRow } from '$lib/api'; import { api, type AgentConfigFolderRow, type AgentModelRow, type AgentRoleRow, type AgentSkillRow } from '$lib/api';
import { session } from '$lib/session'; import { session } from '$lib/session';
import { resolveOrg } from '$lib/org'; import { resolveOrg } from '$lib/org';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -8,6 +8,9 @@
import ErrorBanner from '$lib/components/ErrorBanner.svelte'; import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte'; import EmptyState from '$lib/components/EmptyState.svelte';
import RoleCard from '$lib/components/RoleCard.svelte'; import RoleCard from '$lib/components/RoleCard.svelte';
import AgentConfigFolderNav from '$lib/components/AgentConfigFolderNav.svelte';
import Modal from '$lib/components/Modal.svelte';
import SelectField from '$lib/components/SelectField.svelte';
import { toastError, toastSuccess } from '$lib/toast'; import { toastError, toastSuccess } from '$lib/toast';
const org = $derived(resolveOrg($session.me, page.url.search)); const org = $derived(resolveOrg($session.me, page.url.search));
@@ -16,20 +19,32 @@
let roles = $state<AgentRoleRow[]>([]); let roles = $state<AgentRoleRow[]>([]);
let models = $state<AgentModelRow[]>([]); let models = $state<AgentModelRow[]>([]);
let skills = $state<AgentSkillRow[]>([]); let skills = $state<AgentSkillRow[]>([]);
let folders = $state<AgentConfigFolderRow[]>([]);
let loading = $state(true); let loading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
/** 'all' | 'unfiled' | folder id (ADR-0028: transparent grouping filter) */
let selectedFolder = $state<string>('all');
let newRoleId = $state(''); let newRoleId = $state('');
let newLabel = $state(''); let newLabel = $state('');
let adding = $state(false); let adding = $state(false);
let showFolderModal = $state(false);
let folderModalMode = $state<'create' | 'rename'>('create');
let folderModalId = $state<string | null>(null);
let folderName = $state('');
let folderParent = $state('');
let savingFolder = $state(false);
async function load() { async function load() {
loading = true; loading = true;
error = null; error = null;
try { try {
const [r, s] = await Promise.all([api.agentRoles(slug), api.agentSkills(slug)]); const [r, s, f] = await Promise.all([api.agentRoles(slug), api.agentSkills(slug), api.agentConfigFolders(slug)]);
roles = r.roles; roles = r.roles;
skills = s.skills; skills = s.skills;
folders = f.folders;
// Model fetch hits the provider API and may fail or be slow; load it // Model fetch hits the provider API and may fail or be slow; load it
// independently so roles remain editable even without a model list. // independently so roles remain editable even without a model list.
models = []; models = [];
@@ -43,6 +58,66 @@
} }
} }
const counts = $derived.by(() => {
const map: Record<string, number> = {};
for (const r of roles) {
if (r.folderId) map[r.folderId] = (map[r.folderId] ?? 0) + 1;
}
return map;
});
const unfiledCount = $derived(roles.filter((r) => !r.folderId).length);
const visibleRoles = $derived(
selectedFolder === 'all'
? roles
: selectedFolder === 'unfiled'
? roles.filter((r) => !r.folderId)
: roles.filter((r) => r.folderId === selectedFolder),
);
function folderPath(f: AgentConfigFolderRow): string {
const parts: string[] = [f.name];
let cur: AgentConfigFolderRow | undefined = f;
while (cur?.parentId) {
const parent = folders.find((x) => x.id === cur!.parentId);
if (!parent) break;
parts.unshift(parent.name);
cur = parent;
}
return parts.join(' / ');
}
/** Self + descendant ids of a folder — excluded as move targets in the rename modal. */
function subtreeIds(folderId: string): Set<string> {
const ids = new Set<string>([folderId]);
let grew = true;
while (grew) {
grew = false;
for (const f of folders) {
if (f.parentId && ids.has(f.parentId) && !ids.has(f.id)) {
ids.add(f.id);
grew = true;
}
}
}
return ids;
}
const folderItems = $derived([
{ value: '', label: '(未分类)' },
...folders.map((f) => ({ value: f.id, label: folderPath(f) })),
]);
const moveTargetItems = $derived.by(() => {
if (folderModalMode !== 'rename' || folderModalId === null) {
return [{ value: '', label: '(根)' }, ...folders.map((f) => ({ value: f.id, label: folderPath(f) }))];
}
const excluded = subtreeIds(folderModalId);
return [
{ value: '', label: '(根)' },
...folders.filter((f) => !excluded.has(f.id)).map((f) => ({ value: f.id, label: folderPath(f) })),
];
});
async function add() { async function add() {
const roleId = newRoleId.trim(); const roleId = newRoleId.trim();
const label = newLabel.trim(); const label = newLabel.trim();
@@ -57,7 +132,12 @@
adding = true; adding = true;
try { try {
const created = await api.upsertAgentRole(slug, roleId, { label }); const created = await api.upsertAgentRole(slug, roleId, { label });
roles = [...roles, created]; let folderId: string | null = null;
if (selectedFolder !== 'all' && selectedFolder !== 'unfiled') {
await api.setAgentRoleFolder(slug, roleId, selectedFolder);
folderId = selectedFolder;
}
roles = [...roles, { ...created, folderId }];
newRoleId = ''; newRoleId = '';
newLabel = ''; newLabel = '';
toastSuccess('角色已创建'); toastSuccess('角色已创建');
@@ -79,6 +159,68 @@
roles = roles.map((x) => (x.roleId === roleId ? { ...x, skillNames } : x)); roles = roles.map((x) => (x.roleId === roleId ? { ...x, skillNames } : x));
} }
function onRoleFolderChanged(roleId: string, folderId: string | null) {
roles = roles.map((x) => (x.roleId === roleId ? { ...x, folderId } : x));
}
function openCreateFolder(parentId: string | null) {
folderModalMode = 'create';
folderModalId = null;
folderName = '';
folderParent = parentId ?? '';
showFolderModal = true;
}
function openRenameFolder(folder: AgentConfigFolderRow) {
folderModalMode = 'rename';
folderModalId = folder.id;
folderName = folder.name;
folderParent = folder.parentId ?? '';
showFolderModal = true;
}
async function submitFolderModal() {
const name = folderName.trim();
if (name === '') {
toastError('文件夹名称不能为空');
return;
}
savingFolder = true;
try {
if (folderModalMode === 'create') {
await api.createAgentConfigFolder(slug, {
name,
...(folderParent !== '' ? { parentId: folderParent } : {}),
});
toastSuccess('文件夹已创建');
} else if (folderModalId !== null) {
await api.patchAgentConfigFolder(slug, folderModalId, {
name,
parentId: folderParent === '' ? null : folderParent,
});
toastSuccess('文件夹已更新');
}
showFolderModal = false;
await load();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
savingFolder = false;
}
}
async function deleteFolder(folder: AgentConfigFolderRow) {
if (!confirm(`删除文件夹「${folder.name}」? 仅空文件夹可删除。`)) return;
try {
await api.deleteAgentConfigFolder(slug, folder.id);
if (selectedFolder === folder.id) selectedFolder = 'all';
toastSuccess('文件夹已删除');
await load();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
$effect(() => { $effect(() => {
if (slug) load(); if (slug) load();
}); });
@@ -86,7 +228,7 @@
<PageHeader <PageHeader
title="角色" title="角色"
description="角色是组织级数据:组合默认模型、系统提示词、工具白名单与已绑定技能。角色 ID 即飞书斜杠命令(如 /draft)。" description="角色是组织级数据:组合默认模型、系统提示词、工具白名单与已绑定技能。文件夹仅作管理分组,不影响角色解析与默认角色约束。"
/> />
{#if loading} {#if loading}
@@ -94,39 +236,92 @@
{:else if error} {:else if error}
<ErrorBanner message={error} onretry={load} /> <ErrorBanner message={error} onretry={load} />
{:else} {:else}
<div class="saas-card-pad mb-6"> <div class="grid gap-4 lg:grid-cols-[15rem_1fr]">
<h2 class="saas-section-title mb-4">新建角色</h2> <div class="h-fit lg:sticky lg:top-4">
<div class="grid gap-3 sm:grid-cols-[10rem_1fr_auto]"> <AgentConfigFolderNav
<input {folders}
class="saas-input font-mono text-sm" selected={selectedFolder}
placeholder="角色 ID(如 draft" {counts}
bind:value={newRoleId} totalCount={roles.length}
onkeydown={(e) => { {unfiledCount}
if (e.key === 'Enter') add(); onselect={(id) => (selectedFolder = id)}
}} oncreate={openCreateFolder}
onrename={openRenameFolder}
ondelete={deleteFolder}
/> />
<input
class="saas-input"
placeholder="显示名(如 草稿)"
bind:value={newLabel}
onkeydown={(e) => {
if (e.key === 'Enter') add();
}}
/>
<button class="saas-btn-primary" onclick={add} disabled={adding}>新建</button>
</div> </div>
<p class="mt-2 text-xs text-surface-600">角色 ID 仅允许小写字母、数字、下划线与连字符,且以字母或数字开头。</p>
<div>
<div class="saas-card-pad mb-6">
<h2 class="saas-section-title mb-4">新建角色</h2>
<div class="grid gap-3 sm:grid-cols-[10rem_1fr_auto]">
<input
class="saas-input font-mono text-sm"
placeholder="角色 ID(如 draft"
bind:value={newRoleId}
onkeydown={(e) => {
if (e.key === 'Enter') add();
}}
/>
<input
class="saas-input"
placeholder="显示名(如 草稿)"
bind:value={newLabel}
onkeydown={(e) => {
if (e.key === 'Enter') add();
}}
/>
<button class="saas-btn-primary" onclick={add} disabled={adding}>新建</button>
</div>
<p class="mt-2 text-xs text-surface-600">角色 ID 仅允许小写字母、数字、下划线与连字符,且以字母或数字开头;当前选中文件夹时新角色会自动归入其中。</p>
</div>
{#if roles.length === 0}
<div class="saas-card">
<EmptyState title="暂无角色" description="组织必须且只能有一个启用中的默认角色;新建第一个角色将自动成为默认。" />
</div>
{:else if visibleRoles.length === 0}
<div class="saas-card">
<EmptyState title="此分类下暂无角色" description="在角色卡片上可将其移入当前文件夹。" />
</div>
{:else}
<div class="space-y-4">
{#each visibleRoles as r (r.roleId)}
<RoleCard
{r}
{models}
{skills}
{slug}
{folderItems}
onupdated={onRoleUpdated}
onskillschanged={onRoleSkillsChanged}
onfolderchanged={onRoleFolderChanged}
/>
{/each}
</div>
{/if}
</div>
</div> </div>
{#if roles.length === 0} <Modal bind:open={showFolderModal} title={folderModalMode === 'create' ? '新建文件夹' : '重命名 / 移动文件夹'}>
<div class="saas-card"> <label class="saas-label" for="agent-folder-name">名称</label>
<EmptyState title="暂无角色" description="组织必须且只能有一个启用中的默认角色;新建第一个角色将自动成为默认。" /> <input
id="agent-folder-name"
class="saas-input mb-4"
bind:value={folderName}
onkeydown={(e) => {
if (e.key === 'Enter') submitFolderModal();
}}
/>
<p class="saas-label">父文件夹</p>
<div class="mb-4">
<SelectField items={moveTargetItems} bind:value={folderParent} />
</div> </div>
{:else} <div class="flex justify-end gap-2">
<div class="space-y-4"> <button class="saas-btn-ghost" onclick={() => (showFolderModal = false)}>取消</button>
{#each roles as r (r.roleId)} <button class="saas-btn-primary" onclick={submitFolderModal} disabled={savingFolder}>
<RoleCard {r} {models} {skills} {slug} onupdated={onRoleUpdated} onskillschanged={onRoleSkillsChanged} /> {savingFolder ? '保存中…' : '保存'}
{/each} </button>
</div> </div>
{/if} </Modal>
{/if} {/if}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { api, type AgentSkillRow, type SkillFileEntry } from '$lib/api'; import { api, type AgentConfigFolderRow, type AgentSkillRow, type SkillFileEntry } from '$lib/api';
import { session } from '$lib/session'; import { session } from '$lib/session';
import { resolveOrg } from '$lib/org'; import { resolveOrg } from '$lib/org';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -8,27 +8,42 @@
import ErrorBanner from '$lib/components/ErrorBanner.svelte'; import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte'; import EmptyState from '$lib/components/EmptyState.svelte';
import SkillEditor from '$lib/components/SkillEditor.svelte'; import SkillEditor from '$lib/components/SkillEditor.svelte';
import AgentConfigFolderNav from '$lib/components/AgentConfigFolderNav.svelte';
import Modal from '$lib/components/Modal.svelte';
import SelectField from '$lib/components/SelectField.svelte';
import { toastError, toastSuccess } from '$lib/toast'; import { toastError, toastSuccess } from '$lib/toast';
const org = $derived(resolveOrg($session.me, page.url.search)); const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? ''); const slug = $derived(org?.slug ?? '');
let skills = $state<AgentSkillRow[]>([]); let skills = $state<AgentSkillRow[]>([]);
let folders = $state<AgentConfigFolderRow[]>([]);
let loading = $state(true); let loading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
/** 'all' | 'unfiled' | folder id (ADR-0028: transparent grouping filter) */
let selectedFolder = $state<string>('all');
let showNewSkill = $state(false); let showNewSkill = $state(false);
let newSkillName = $state(''); let newSkillName = $state('');
let newSkillVersion = $state('0.1.0'); let newSkillVersion = $state('0.1.0');
let newSkillDescription = $state(''); let newSkillDescription = $state('');
let creating = $state(false); let creating = $state(false);
let showFolderModal = $state(false);
let folderModalMode = $state<'create' | 'rename'>('create');
let folderModalId = $state<string | null>(null);
let folderName = $state('');
let folderParent = $state('');
let savingFolder = $state(false);
async function load() { async function load() {
loading = true; loading = true;
error = null; error = null;
try { try {
const res = await api.agentSkills(slug); const [s, f] = await Promise.all([api.agentSkills(slug), api.agentConfigFolders(slug)]);
skills = res.skills; skills = s.skills;
folders = f.folders;
} catch (err) { } catch (err) {
error = err instanceof Error ? err.message : String(err); error = err instanceof Error ? err.message : String(err);
} finally { } finally {
@@ -36,6 +51,66 @@
} }
} }
const counts = $derived.by(() => {
const map: Record<string, number> = {};
for (const s of skills) {
if (s.folderId) map[s.folderId] = (map[s.folderId] ?? 0) + 1;
}
return map;
});
const unfiledCount = $derived(skills.filter((s) => !s.folderId).length);
const visibleSkills = $derived(
selectedFolder === 'all'
? skills
: selectedFolder === 'unfiled'
? skills.filter((s) => !s.folderId)
: skills.filter((s) => s.folderId === selectedFolder),
);
function folderPath(f: AgentConfigFolderRow): string {
const parts: string[] = [f.name];
let cur: AgentConfigFolderRow | undefined = f;
while (cur?.parentId) {
const parent = folders.find((x) => x.id === cur!.parentId);
if (!parent) break;
parts.unshift(parent.name);
cur = parent;
}
return parts.join(' / ');
}
/** Self + descendant ids of a folder — excluded as move targets in the rename modal. */
function subtreeIds(folderId: string): Set<string> {
const ids = new Set<string>([folderId]);
let grew = true;
while (grew) {
grew = false;
for (const f of folders) {
if (f.parentId && ids.has(f.parentId) && !ids.has(f.id)) {
ids.add(f.id);
grew = true;
}
}
}
return ids;
}
const folderItems = $derived([
{ value: '', label: '(未分类)' },
...folders.map((f) => ({ value: f.id, label: folderPath(f) })),
]);
const moveTargetItems = $derived.by(() => {
if (folderModalMode !== 'rename' || folderModalId === null) {
return [{ value: '', label: '(根)' }, ...folders.map((f) => ({ value: f.id, label: folderPath(f) }))];
}
const excluded = subtreeIds(folderModalId);
return [
{ value: '', label: '(根)' },
...folders.filter((f) => !excluded.has(f.id)).map((f) => ({ value: f.id, label: folderPath(f) })),
];
});
async function createSkill() { async function createSkill() {
const name = newSkillName.trim(); const name = newSkillName.trim();
if (name === '') { if (name === '') {
@@ -56,6 +131,9 @@
const manifest = buildManifest(name, newSkillDescription.trim()); const manifest = buildManifest(name, newSkillDescription.trim());
const files: SkillFileEntry[] = [{ path: 'SKILL.md', content: manifest }]; const files: SkillFileEntry[] = [{ path: 'SKILL.md', content: manifest }];
const result = await api.installAgentSkill(slug, name, { version, files }); const result = await api.installAgentSkill(slug, name, { version, files });
if (selectedFolder !== 'all' && selectedFolder !== 'unfiled') {
await api.setAgentSkillFolder(slug, result.name, selectedFolder);
}
toastSuccess(`技能 ${result.name} 已创建`); toastSuccess(`技能 ${result.name} 已创建`);
newSkillName = ''; newSkillName = '';
newSkillDescription = ''; newSkillDescription = '';
@@ -81,6 +159,68 @@
load(); load();
} }
function onSkillFolderChanged(name: string, folderId: string | null) {
skills = skills.map((s) => (s.name === name ? { ...s, folderId } : s));
}
function openCreateFolder(parentId: string | null) {
folderModalMode = 'create';
folderModalId = null;
folderName = '';
folderParent = parentId ?? '';
showFolderModal = true;
}
function openRenameFolder(folder: AgentConfigFolderRow) {
folderModalMode = 'rename';
folderModalId = folder.id;
folderName = folder.name;
folderParent = folder.parentId ?? '';
showFolderModal = true;
}
async function submitFolderModal() {
const name = folderName.trim();
if (name === '') {
toastError('文件夹名称不能为空');
return;
}
savingFolder = true;
try {
if (folderModalMode === 'create') {
await api.createAgentConfigFolder(slug, {
name,
...(folderParent !== '' ? { parentId: folderParent } : {}),
});
toastSuccess('文件夹已创建');
} else if (folderModalId !== null) {
await api.patchAgentConfigFolder(slug, folderModalId, {
name,
parentId: folderParent === '' ? null : folderParent,
});
toastSuccess('文件夹已更新');
}
showFolderModal = false;
await load();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
savingFolder = false;
}
}
async function deleteFolder(folder: AgentConfigFolderRow) {
if (!confirm(`删除文件夹「${folder.name}」? 仅空文件夹可删除。`)) return;
try {
await api.deleteAgentConfigFolder(slug, folder.id);
if (selectedFolder === folder.id) selectedFolder = 'all';
toastSuccess('文件夹已删除');
await load();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
$effect(() => { $effect(() => {
if (slug) load(); if (slug) load();
}); });
@@ -88,7 +228,7 @@
<PageHeader <PageHeader
title="技能" title="技能"
description="技能是组织级 Agent 能力包:一个包含 SKILL.md manifest 的目录。技能内容按 SHA-256 content-addressed 存储,变更后绑定角色的活跃会话自动归档。" description="技能是组织级 Agent 能力包:一个包含 SKILL.md manifest 的目录。文件夹仅作管理分组,不影响技能解析与绑定。"
/> />
{#if loading} {#if loading}
@@ -96,49 +236,100 @@
{:else if error} {:else if error}
<ErrorBanner message={error} onretry={load} /> <ErrorBanner message={error} onretry={load} />
{:else} {:else}
<div class="saas-card-pad mb-6"> <div class="grid gap-4 lg:grid-cols-[15rem_1fr]">
<div class="flex items-center justify-between"> <div class="h-fit lg:sticky lg:top-4">
<h2 class="saas-section-title">新建技能</h2> <AgentConfigFolderNav
<button class="text-sm text-primary-700 hover:text-primary-900" onclick={() => (showNewSkill = !showNewSkill)}> {folders}
{showNewSkill ? '取消' : '+ 新建'} selected={selectedFolder}
</button> {counts}
totalCount={skills.length}
{unfiledCount}
onselect={(id) => (selectedFolder = id)}
oncreate={openCreateFolder}
onrename={openRenameFolder}
ondelete={deleteFolder}
/>
</div> </div>
{#if showNewSkill}
<div class="mt-4 grid gap-3 sm:grid-cols-[12rem_8rem_1fr_auto]"> <div>
<input <div class="saas-card-pad mb-6">
class="saas-input font-mono text-sm" <div class="flex items-center justify-between">
placeholder="技能名(如 typst-help" <h2 class="saas-section-title">新建技能</h2>
bind:value={newSkillName} <button class="text-sm text-primary-700 hover:text-primary-900" onclick={() => (showNewSkill = !showNewSkill)}>
/> {showNewSkill ? '取消' : '+ 新建'}
<input </button>
class="saas-input text-sm" </div>
placeholder="版本号" {#if showNewSkill}
bind:value={newSkillVersion} <div class="mt-4 grid gap-3 sm:grid-cols-[12rem_8rem_1fr_auto]">
/> <input
<input class="saas-input font-mono text-sm"
class="saas-input text-sm" placeholder="技能名(如 typst-help"
placeholder="描述" bind:value={newSkillName}
bind:value={newSkillDescription} />
/> <input
<button class="saas-btn-primary" onclick={createSkill} disabled={creating}> class="saas-input text-sm"
{creating ? '创建中…' : '创建'} placeholder="版本号"
</button> bind:value={newSkillVersion}
/>
<input
class="saas-input text-sm"
placeholder="描述"
bind:value={newSkillDescription}
/>
<button class="saas-btn-primary" onclick={createSkill} disabled={creating}>
{creating ? '创建中…' : '创建'}
</button>
</div>
<p class="mt-2 text-xs text-surface-600">
技能名称仅允许小写字母、数字和连字符,且以字母或数字开头。创建后会生成 SKILL.md 模板;当前选中文件夹时新技能会自动归入其中。
</p>
{/if}
</div> </div>
<p class="mt-2 text-xs text-surface-600">
技能名称仅允许小写字母、数字和连字符,且以字母或数字开头。创建后会生成 SKILL.md 模板。 {#if skills.length === 0}
</p> <div class="saas-card">
{/if} <EmptyState title="暂无技能" description="新建一个技能,然后在角色管理中绑定到角色。" />
</div>
{:else if visibleSkills.length === 0}
<div class="saas-card">
<EmptyState title="此分类下暂无技能" description="在技能卡片上可将其移入当前文件夹。" />
</div>
{:else}
<div class="space-y-4">
{#each visibleSkills as skill (skill.id)}
<SkillEditor
{slug}
{skill}
{folderItems}
oninstalled={onInstalled}
ondisabled={onDisabled}
onfolderchanged={onSkillFolderChanged}
/>
{/each}
</div>
{/if}
</div>
</div> </div>
{#if skills.length === 0} <Modal bind:open={showFolderModal} title={folderModalMode === 'create' ? '新建文件夹' : '重命名 / 移动文件夹'}>
<div class="saas-card"> <label class="saas-label" for="agent-folder-name">名称</label>
<EmptyState title="暂无技能" description="新建一个技能,然后在角色管理中绑定到角色。" /> <input
id="agent-folder-name"
class="saas-input mb-4"
bind:value={folderName}
onkeydown={(e) => {
if (e.key === 'Enter') submitFolderModal();
}}
/>
<p class="saas-label">父文件夹</p>
<div class="mb-4">
<SelectField items={moveTargetItems} bind:value={folderParent} />
</div> </div>
{:else} <div class="flex justify-end gap-2">
<div class="space-y-4"> <button class="saas-btn-ghost" onclick={() => (showFolderModal = false)}>取消</button>
{#each skills as skill (skill.id)} <button class="saas-btn-primary" onclick={submitFolderModal} disabled={savingFolder}>
<SkillEditor {slug} {skill} oninstalled={onInstalled} ondisabled={onDisabled} /> {savingFolder ? '保存中…' : '保存'}
{/each} </button>
</div> </div>
{/if} </Modal>
{/if} {/if}
+24 -6
View File
@@ -406,7 +406,8 @@
resize: vertical; resize: vertical;
} }
.saas-select-trigger { .saas-select-trigger,
.saas-combobox-input {
display: inline-flex; display: inline-flex;
width: 100%; width: 100%;
align-items: center; align-items: center;
@@ -425,14 +426,25 @@
text-align: left; text-align: left;
} }
.saas-combobox-input {
cursor: text;
padding-right: 2.25rem;
}
.saas-combobox-input::placeholder {
color: var(--color-surface-500);
}
.saas-select-trigger:focus-visible, .saas-select-trigger:focus-visible,
.saas-select-trigger[data-state='open'] { .saas-select-trigger[data-state='open'],
.saas-combobox-input:focus {
border-color: var(--color-primary-600); border-color: var(--color-primary-600);
box-shadow: inset 0 0 0 1px var(--color-primary-600); box-shadow: inset 0 0 0 1px var(--color-primary-600);
} }
.saas-select-trigger:disabled, .saas-select-trigger:disabled,
.saas-select-trigger[data-disabled] { .saas-select-trigger[data-disabled],
.saas-combobox-input:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.55; opacity: 0.55;
} }
@@ -443,9 +455,15 @@
.saas-select-content { .saas-select-content {
z-index: 70; z-index: 70;
max-height: min(18rem, var(--bits-select-content-available-height, 18rem)); max-height: min(
width: var(--bits-select-anchor-width); 18rem,
min-width: var(--bits-select-anchor-width); var(
--bits-combobox-content-available-height,
var(--bits-select-content-available-height, 18rem)
)
);
width: var(--bits-combobox-anchor-width, var(--bits-select-anchor-width));
min-width: var(--bits-combobox-anchor-width, var(--bits-select-anchor-width));
overflow: hidden; overflow: hidden;
border-radius: 0; border-radius: 0;
border: 1px solid var(--color-surface-400); border: 1px solid var(--color-surface-400);
-23
View File
@@ -1,23 +0,0 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
-1
View File
@@ -1 +0,0 @@
engine-strict=true
-4
View File
@@ -1,4 +0,0 @@
build
.svelte-kit
node_modules
package-lock.json
-9
View File
@@ -1,9 +0,0 @@
{
"useTabs": true,
"singleQuote": true,
"semi": true,
"trailingComma": "all",
"printWidth": 120,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}
File diff suppressed because it is too large Load Diff
-33
View File
@@ -1,33 +0,0 @@
{
"name": "database-admin",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"devDependencies": {
"@skeletonlabs/skeleton": "^4.15.2",
"@skeletonlabs/skeleton-svelte": "^4.15.2",
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/vite": "^4.3.2",
"bits-ui": "^2.18.1",
"prettier": "^3.9.5",
"prettier-plugin-svelte": "^4.1.1",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"tailwindcss": "^4.3.2",
"typescript": "^6.0.3",
"vite": "^8.0.16"
}
}
-13
View File
@@ -1,13 +0,0 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
-29
View File
@@ -1,29 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Curriculum Project Hub — 数据库管理后台" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<style>
/* Fallback before CSS bundle */
html {
font-family: 'Inter', system-ui, 'Noto Sans SC', 'PingFang SC', sans-serif;
}
</style>
<title>Database Admin</title>
%sveltekit.head%
</head>
<body
data-sveltekit-preload-data="hover"
class="relative min-h-screen overflow-x-hidden bg-gradient-to-br from-slate-50 via-white to-indigo-50 text-slate-700"
>
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
-76
View File
@@ -1,76 +0,0 @@
/**
* Thin API client for the database-admin backend. Same-origin cookie auth,
* reusing the platform session (`cph_session`) and the admin plane's /api/me.
*/
export class ApiError extends Error {
code: string;
status: number;
constructor(code: string, message: string, status: number) {
super(message);
this.name = 'ApiError';
this.code = code;
this.status = status;
}
}
async function request(method: string, url: string, body?: unknown): Promise<unknown> {
const init: RequestInit = {
method,
credentials: 'same-origin',
headers: body !== undefined ? { 'content-type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
};
const res = await fetch(url, init);
const text = await res.text();
let data: unknown = null;
if (text !== '') {
try {
data = JSON.parse(text);
} catch {
data = text;
}
}
if (!res.ok) {
const err = (data as { error?: { code?: string; message?: string } } | null)?.error;
throw new ApiError(err?.code ?? 'http_error', err?.message ?? `HTTP ${res.status}`, res.status);
}
return data;
}
const get = (u: string) => request('GET', u);
const post = (u: string, b?: unknown) => request('POST', u, b);
// --- Types ---
export interface OrgMembership {
id: string;
slug: string;
name: string;
status: string;
role: 'OWNER' | 'ADMIN' | 'MEMBER';
}
export interface MeResponse {
user: {
id: string;
feishuOpenId: string;
displayName: string;
avatarUrl: string | null;
};
organizations: OrgMembership[];
}
/** Unauthenticated bootstrap the login page needs: which org to OAuth against + dev toggle. */
export interface DatabaseConfig {
siloOrganizationSlug: string;
devLoginEnabled: boolean;
}
// --- API ---
export const api = {
me: () => get('/api/me') as Promise<MeResponse>,
logout: () => post('/auth/logout'),
databaseConfig: () => get('/database/config') as Promise<DatabaseConfig>,
};
@@ -1,6 +0,0 @@
<!-- Animated aurora background blobs, shared by both pages (soft pastels on light). -->
<div class="pointer-events-none fixed inset-0 overflow-hidden">
<div class="aurora absolute -left-32 -top-32 h-96 w-96 rounded-full bg-violet-300/50"></div>
<div class="aurora absolute right-0 top-1/4 h-96 w-96 rounded-full bg-cyan-300/40" style="animation-delay:-6s"></div>
<div class="aurora absolute bottom-0 left-1/3 h-96 w-96 rounded-full bg-indigo-300/40" style="animation-delay:-12s"></div>
</div>
-7
View File
@@ -1,7 +0,0 @@
import type { OrgMembership } from './api';
export function isOrgAdmin(org: OrgMembership | null | undefined): boolean {
if (!org) return false;
const role = String(org.role ?? '').toUpperCase();
return role === 'OWNER' || role === 'ADMIN';
}
-58
View File
@@ -1,58 +0,0 @@
import { writable } from 'svelte/store';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { api, type MeResponse } from './api';
interface SessionState {
loading: boolean;
me: MeResponse | null;
error: string | null;
}
export const session = writable<SessionState>({
loading: true,
me: null,
error: null,
});
export async function loadSession(): Promise<void> {
session.update((s) => ({ ...s, loading: true, error: null }));
try {
const me = await api.me();
session.set({ loading: false, me, error: null });
} catch (err) {
const status = (err as { status?: number }).status;
if (status === 401) {
session.set({ loading: false, me: null, error: null });
void redirectToLogin();
return;
}
session.set({
loading: false,
me: null,
error: err instanceof Error ? err.message : String(err),
});
}
}
/**
* Send the browser to the login page (`/database/admin`). Unlike admin-web we
* don't jump straight to Feishu OAuth: the login page reads /database/config
* to build the org-scoped link and to show the dev bypass when enabled.
*/
export async function redirectToLogin(): Promise<void> {
const loginPath = `${base}/admin`;
if (window.location.pathname === loginPath) return;
await goto(loginPath, { replaceState: true });
}
/** Build the org-scoped Feishu OAuth entry point (a backend route, not under base). */
export function feishuLoginHref(orgSlug: string, returnTo: string = `${base}/dashboard`): string {
return `/auth/feishu/${encodeURIComponent(orgSlug)}?returnTo=${encodeURIComponent(returnTo)}`;
}
export async function logout(): Promise<void> {
await api.logout();
session.set({ loading: false, me: null, error: null });
await redirectToLogin();
}
@@ -1,7 +0,0 @@
<script lang="ts">
import './app.css';
let { children } = $props();
</script>
{@render children()}
@@ -1,30 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { api } from '$lib/api';
onMount(async () => {
// /database entry — route to dashboard if signed in, else the login page.
try {
await api.me();
await goto(`${base}/dashboard`, { replaceState: true });
} catch {
await goto(`${base}/admin`, { replaceState: true });
}
});
</script>
<div class="flex min-h-screen items-center justify-center">
<div class="flex flex-col items-center gap-3 text-slate-400">
<svg class="h-7 w-7 animate-spin text-violet-500" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path
class="opacity-90"
fill="currentColor"
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">正在进入…</p>
</div>
</div>
@@ -1,78 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { api, type DatabaseConfig } from '$lib/api';
import { feishuLoginHref } from '$lib/session';
import Aurora from '$lib/components/Aurora.svelte';
let config = $state<DatabaseConfig | null>(null);
let error = $state<string | null>(null);
onMount(async () => {
// Already signed in → straight to the dashboard.
try {
await api.me();
await goto(`${base}/dashboard`, { replaceState: true });
return;
} catch {
// Not signed in (401) or backend unreachable — show the login card.
}
try {
config = await api.databaseConfig();
} catch (err) {
error = err instanceof Error ? err.message : String(err);
}
});
const feishuHref = $derived(config ? feishuLoginHref(config.siloOrganizationSlug) : '#');
</script>
<Aurora />
<main class="flex min-h-screen items-center justify-center p-4">
<div class="rise glass relative w-full max-w-sm rounded-3xl border border-white/80 p-8 shadow-2xl shadow-indigo-200/50">
<div class="mb-7 text-center">
<div
class="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-violet-500 to-cyan-400 text-2xl font-bold text-white glow-btn"
>
D
</div>
<h1 class="text-2xl font-bold tracking-tight grad-text">Database Admin</h1>
<p class="mt-2 text-sm text-slate-500">使用飞书登录以管理数据库</p>
</div>
{#if error}
<div class="mb-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600">
无法连接后端:{error}
</div>
{/if}
<a
href={feishuHref}
aria-disabled={config ? 'false' : 'true'}
class="rise-2 glow-btn group flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-violet-500 to-indigo-500 px-4 py-3.5 text-sm font-semibold text-white transition hover:from-violet-400 hover:to-indigo-400 aria-disabled:pointer-events-none aria-disabled:opacity-50"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2 3 7v10l9 5 9-5V7l-9-5Zm0 2.3 6.5 3.6L12 11.5 5.5 7.9 12 4.3Z" />
</svg>
使用飞书登录
</a>
{#if config?.devLoginEnabled}
<div class="relative my-6 rise-3">
<div class="absolute inset-0 flex items-center"><div class="w-full border-t border-slate-200"></div></div>
<div class="relative flex justify-center">
<span class="bg-white/70 px-3 text-[11px] font-medium uppercase tracking-[0.2em] text-slate-400">开发模式</span>
</div>
</div>
<a
href="/database/dev-login"
class="rise-3 group flex w-full items-center justify-center gap-2 rounded-xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-700 transition hover:border-amber-400 hover:bg-amber-100"
>
<span></span> 一键登录管理员
</a>
<p class="rise-3 mt-2 text-center text-xs text-slate-400">仅开发环境可见 · 跳过飞书 OAuth</p>
{/if}
</div>
</main>
-96
View File
@@ -1,96 +0,0 @@
@import 'tailwindcss';
@source './**/*.{html,js,svelte,ts}';
@source '../lib/**/*.{html,js,svelte,ts}';
/*
* Design system for the database-admin SPA. Migrated verbatim (in spirit) from
* the previous server-rendered pages in hub/src/database/routes/databaseRoutes.ts:
* a light aurora / glassmorphism look with violet→cyan gradients and soft rise-in
* entrances. Distinct from admin-web's flat industrial theme on purpose.
*/
@theme {
--font-sans: 'Inter', system-ui, -apple-system, 'Segoe UI', 'Noto Sans SC', 'PingFang SC', sans-serif;
}
@keyframes aurora {
0% {
transform: translate(0, 0) scale(1);
}
33% {
transform: translate(6%, -8%) scale(1.15);
}
66% {
transform: translate(-8%, 6%) scale(0.9);
}
100% {
transform: translate(0, 0) scale(1);
}
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
@layer base {
:root {
font-family: var(--font-sans);
}
html {
height: 100%;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
@layer components {
.aurora {
filter: blur(80px);
animation: aurora 18s ease-in-out infinite;
}
.rise {
animation: rise 0.7s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.rise-1 {
animation: rise 0.7s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.rise-2 {
animation: rise 0.7s cubic-bezier(0.16, 1, 0.3, 1) 0.1s both;
}
.rise-3 {
animation: rise 0.7s cubic-bezier(0.16, 1, 0.3, 1) 0.2s both;
}
.grad-text {
background: linear-gradient(120deg, #7c3aed, #0891b2, #4f46e5);
background-size: 200% auto;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: shimmer 6s linear infinite;
}
.glass {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.glow-btn {
box-shadow: 0 12px 32px -10px rgba(124, 58, 237, 0.45);
}
}
@@ -1,160 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { base } from '$app/paths';
import { session, loadSession, logout } from '$lib/session';
import { isOrgAdmin } from '$lib/org';
import Aurora from '$lib/components/Aurora.svelte';
onMount(() => {
loadSession();
});
// The session is scoped to the silo org, so /api/me returns exactly that org's
// membership. Admin gate: only OWNER/ADMIN may use the database console.
const me = $derived($session.me);
const org = $derived(me?.organizations[0] ?? null);
const allowed = $derived(isOrgAdmin(org));
const displayName = $derived(me?.user.displayName ?? '');
const initial = $derived(displayName.slice(0, 1) || 'U');
const navItems = [
{ label: '概览', href: `${base}/dashboard`, active: true, icon: 'M4 13h6V4H4v9Zm0 7h6v-5H4v5Zm10 0h6V11h-6v9Zm0-16v5h6V4h-6Z' },
{ label: '数据表', href: '#', active: false, icon: 'M4 5h16v4H4V5Zm0 6h16v4H4v-4Zm0 6h16v2H4v-2Z' },
{ label: '查询', href: '#', active: false, icon: 'm21 21-4.3-4.3M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z' },
{
label: '设置',
href: '#',
active: false,
icon: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm7-3 2 1-2 3-2-1a7 7 0 0 1-2 1l-1 2h-4l-1-2a7 7 0 0 1-2-1l-2 1-2-3 2-1a7 7 0 0 1 0-2l-2-1 2-3 2 1a7 7 0 0 1 2-1l1-2h4l1 2a7 7 0 0 1 2 1l2-1 2 3-2 1a7 7 0 0 1 0 2Z',
},
];
const stats = [
{ label: '数据表', value: '—', accent: 'from-violet-200/60 to-transparent' },
{ label: '记录数', value: '—', accent: 'from-cyan-200/60 to-transparent' },
{ label: '最近查询', value: '—', accent: 'from-indigo-200/60 to-transparent' },
];
</script>
<Aurora />
{#if $session.loading}
<div class="flex min-h-screen items-center justify-center">
<div class="flex flex-col items-center gap-3 text-slate-400">
<svg class="h-8 w-8 animate-spin text-violet-500" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path
class="opacity-90"
fill="currentColor"
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">加载中…</p>
</div>
</div>
{:else if $session.error}
<div class="flex min-h-screen items-center justify-center p-4">
<div class="rise glass w-full max-w-sm rounded-3xl border border-white/80 p-8 text-center shadow-2xl shadow-indigo-200/50">
<h2 class="mb-2 text-lg font-bold text-slate-900">无法连接后端</h2>
<p class="mb-5 text-sm text-slate-500">{$session.error}</p>
<button
class="rounded-xl bg-gradient-to-r from-violet-500 to-indigo-500 px-4 py-2 text-sm font-semibold text-white"
onclick={() => loadSession()}>重试</button
>
</div>
</div>
{:else if !allowed}
<div class="flex min-h-screen items-center justify-center p-4">
<div class="rise glass w-full max-w-md rounded-3xl border border-white/80 p-8 text-center shadow-2xl shadow-indigo-200/50">
<h2 class="mb-2 text-lg font-bold text-slate-900">无权访问</h2>
<p class="mb-5 text-sm text-slate-500">数据库管理台仅向组织所有者与管理员开放。</p>
<button
class="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-50"
onclick={() => logout()}>退出登录</button
>
</div>
</div>
{:else}
<div class="relative flex min-h-screen">
<!-- 左侧菜单栏 -->
<aside class="flex w-64 shrink-0 flex-col border-r border-slate-200/80 glass">
<div class="flex items-center gap-3 px-5 py-6">
<div
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-cyan-400 text-lg font-bold text-white glow-btn"
>
D
</div>
<span class="text-base font-bold grad-text">Database Admin</span>
</div>
<nav class="flex flex-1 flex-col gap-1.5 px-3 py-2">
{#each navItems as item}
<a
href={item.href}
class={item.active
? 'group flex items-center gap-3 rounded-xl bg-gradient-to-r from-violet-500 to-indigo-500 px-3 py-2.5 text-sm font-semibold text-white shadow-lg shadow-indigo-300/50'
: 'group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-slate-500 transition hover:bg-slate-100 hover:text-slate-900'}
>
<svg
class="h-4 w-4 shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"><path d={item.icon} /></svg
>
{item.label}
</a>
{/each}
</nav>
<div class="m-3 flex items-center gap-3 rounded-xl border border-slate-200 bg-white/60 px-3 py-3">
<div
class="flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br from-violet-500 to-indigo-500 text-sm font-semibold text-white"
>
{initial}
</div>
<div class="min-w-0">
<p class="text-[11px] uppercase tracking-wider text-slate-400">已登录</p>
<p class="truncate text-sm font-medium text-slate-700">{displayName}</p>
</div>
</div>
</aside>
<!-- 右侧内容 -->
<div class="flex flex-1 flex-col">
<header class="flex items-center justify-between border-b border-slate-200/80 glass px-8 py-4">
<div>
<h1 class="text-lg font-bold text-slate-900">概览</h1>
<p class="text-xs text-slate-400">欢迎回来,这里是数据库管理台</p>
</div>
<button
onclick={() => logout()}
class="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900"
>
退出登录
</button>
</header>
<main class="flex-1 p-8">
<div class="grid grid-cols-1 gap-5 sm:grid-cols-3">
{#each stats as s, i}
<div
class="rise-{i +
1} group relative overflow-hidden rounded-2xl border border-white/80 glass p-5 shadow-lg shadow-slate-200/50 transition hover:-translate-y-0.5 hover:shadow-xl hover:shadow-indigo-200/50"
>
<div class="absolute inset-0 bg-gradient-to-br {s.accent} opacity-0 transition group-hover:opacity-100"></div>
<p class="relative text-sm text-slate-500">{s.label}</p>
<p class="relative mt-2 text-3xl font-bold text-slate-900">{s.value}</p>
</div>
{/each}
</div>
<div
class="rise-3 mt-6 flex h-64 items-center justify-center rounded-2xl border border-dashed border-slate-300 glass text-sm text-slate-400"
>
内容区占位 · 后续数据端点挂在 /database/* 并加 guard
</div>
</main>
</div>
</div>
{/if}
-5
View File
@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#4F46E5"/>
<path d="M8 10.5h7.5a3.5 3.5 0 0 1 0 7H11v4H8v-11Zm3 4.5h4.5a1.5 1.5 0 0 0 0-3H11v3Z" fill="white"/>
<path d="M20.5 21.5c1.93 0 3.5-1.34 3.5-3s-1.57-3-3.5-3S17 16.84 17 18.5s1.57 3 3.5 3Z" fill="white" opacity=".9"/>
</svg>

Before

Width:  |  Height:  |  Size: 353 B

-3
View File
@@ -1,3 +0,0 @@
# allow crawling everything by default
User-agent: *
Disallow:
-22
View File
@@ -1,22 +0,0 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
precompress: false,
strict: false,
}),
// This SPA is served under /database by the Hub. admin-web owns the
// root asset paths (/_app, /favicon.svg); a base path moves this app's
// assets to /database/_app/* so the two builds never collide.
paths: { base: '/database' },
},
};
export default config;
-20
View File
@@ -1,20 +0,0 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}
-17
View File
@@ -1,17 +0,0 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
server: {
proxy: {
'/api': 'http://127.0.0.1:8788',
'/auth': 'http://127.0.0.1:8788',
// Backend owns the /database/* HTTP surface (login page, dashboard,
// data routes). Proxy it in dev so those paths hit the real server
// instead of the SPA fallback.
'/database': 'http://127.0.0.1:8788',
},
},
});
+1 -4
View File
@@ -82,11 +82,10 @@ REMOTE
rsync -az --delete \ rsync -az --delete \
--exclude node_modules --exclude dist --exclude .env \ --exclude node_modules --exclude dist --exclude .env \
--exclude admin-web/node_modules --exclude admin-web/build --exclude admin-web/.svelte-kit \ --exclude admin-web/node_modules --exclude admin-web/build --exclude admin-web/.svelte-kit \
--exclude database-admin/node_modules --exclude database-admin/build --exclude database-admin/.svelte-kit \
-e "ssh ${SSH_OPTS[*]}" \ -e "ssh ${SSH_OPTS[*]}" \
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/" "$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
echo "[fleet] npm ci + build (tsc + admin-web & database-admin SPAs)" echo "[fleet] npm ci + build (tsc + admin-web SPA)"
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" bash -s <<REMOTE ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" bash -s <<REMOTE
set -euo pipefail set -euo pipefail
flock /var/lock/cph-hub-release-publish bash -c ' flock /var/lock/cph-hub-release-publish bash -c '
@@ -98,11 +97,9 @@ flock /var/lock/cph-hub-release-publish bash -c '
cd "$HUB_DIR" cd "$HUB_DIR"
PUPPETEER_SKIP_DOWNLOAD=1 npm ci PUPPETEER_SKIP_DOWNLOAD=1 npm ci
npm ci --prefix admin-web npm ci --prefix admin-web
npm ci --prefix database-admin
npm run audit:production npm run audit:production
npm run build npm run build
test -f admin-web/build/index.html test -f admin-web/build/index.html
test -f database-admin/build/index.html
touch "$RELEASE_DIR/.complete" touch "$RELEASE_DIR/.complete"
' '
REMOTE REMOTE
+3 -4
View File
@@ -60,11 +60,10 @@ if [ "$release_ready" = false ]; then
-e "ssh ${SSH_OPTS[*]}" \ -e "ssh ${SSH_OPTS[*]}" \
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/" "$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
# 2. Install deps (hub + both SPAs), audit hub prod, build tsc + SPAs, mark complete. # 2. Install deps (hub + admin-web), audit hub prod, build tsc + SPA, mark complete.
# `npm run build` → tsc then admin:build + database:build → admin-web/build and # `npm run build` → tsc then admin:build → admin-web/build for registerStaticSpa.
# database-admin/build for registerStaticSpa / registerDatabaseSpa.
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \ ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \
"cd '$HUB_DIR' && PUPPETEER_SKIP_DOWNLOAD=1 npm ci && npm ci --prefix admin-web && npm ci --prefix database-admin && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'" "cd '$HUB_DIR' && PUPPETEER_SKIP_DOWNLOAD=1 npm ci && npm ci --prefix admin-web && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
fi fi
# 3. Ensure the service is installed (idempotent), then restart. # 3. Ensure the service is installed (idempotent), then restart.
+20 -112
View File
@@ -1,12 +1,12 @@
{ {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.36", "version": "0.0.35",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.36", "version": "0.0.35",
"dependencies": { "dependencies": {
"@alicloud/credentials": "^2.4.5", "@alicloud/credentials": "^2.4.5",
"@alicloud/docmind-api20220711": "^1.4.15", "@alicloud/docmind-api20220711": "^1.4.15",
@@ -401,7 +401,6 @@
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
@@ -424,31 +423,6 @@
"xml2js": "^0.6.2" "xml2js": "^0.6.2"
} }
}, },
"node_modules/@emnapi/core": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
"integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
@@ -1038,7 +1012,6 @@
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18.14.1" "node": ">=18.14.1"
}, },
@@ -1577,8 +1550,7 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/@standard-schema/spec": { "node_modules/@standard-schema/spec": {
"version": "1.1.0", "version": "1.1.0",
@@ -1779,7 +1751,6 @@
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"mime-types": "^3.0.0", "mime-types": "^3.0.0",
"negotiator": "^1.0.0" "negotiator": "^1.0.0"
@@ -1793,7 +1764,6 @@
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
} }
@@ -1803,7 +1773,6 @@
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"mime-db": "^1.54.0" "mime-db": "^1.54.0"
}, },
@@ -1939,7 +1908,6 @@
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"bytes": "^3.1.2", "bytes": "^3.1.2",
"content-type": "^2.0.0", "content-type": "^2.0.0",
@@ -1964,7 +1932,6 @@
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@@ -1978,7 +1945,6 @@
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.8" "node": ">= 0.8"
} }
@@ -2124,7 +2090,6 @@
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@@ -2138,7 +2103,6 @@
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
} }
@@ -2168,7 +2132,6 @@
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=6.6.0" "node": ">=6.6.0"
} }
@@ -2178,7 +2141,6 @@
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"object-assign": "^4", "object-assign": "^4",
"vary": "^1" "vary": "^1"
@@ -2196,7 +2158,6 @@
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"path-key": "^3.1.0", "path-key": "^3.1.0",
"shebang-command": "^2.0.0", "shebang-command": "^2.0.0",
@@ -2254,7 +2215,6 @@
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.8" "node": ">= 0.8"
} }
@@ -2315,8 +2275,7 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/effect": { "node_modules/effect": {
"version": "3.21.0", "version": "3.21.0",
@@ -2344,7 +2303,6 @@
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.8" "node": ">= 0.8"
} }
@@ -2447,8 +2405,7 @@
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/estree-walker": { "node_modules/estree-walker": {
"version": "3.0.3", "version": "3.0.3",
@@ -2465,7 +2422,6 @@
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
} }
@@ -2475,7 +2431,6 @@
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"eventsource-parser": "^3.0.1" "eventsource-parser": "^3.0.1"
}, },
@@ -2507,7 +2462,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"accepts": "^2.0.0", "accepts": "^2.0.0",
"body-parser": "^2.2.1", "body-parser": "^2.2.1",
@@ -2551,7 +2505,6 @@
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"ip-address": "^10.2.0" "ip-address": "^10.2.0"
}, },
@@ -2570,7 +2523,6 @@
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
} }
@@ -2580,7 +2532,6 @@
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
} }
@@ -2590,7 +2541,6 @@
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"mime-db": "^1.54.0" "mime-db": "^1.54.0"
}, },
@@ -2681,8 +2631,7 @@
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense", "license": "Unlicense"
"peer": true
}, },
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.1.3", "version": "3.1.3",
@@ -2781,7 +2730,6 @@
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"debug": "^4.4.0", "debug": "^4.4.0",
"encodeurl": "^2.0.0", "encodeurl": "^2.0.0",
@@ -2853,7 +2801,6 @@
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
} }
@@ -2863,7 +2810,6 @@
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.8" "node": ">= 0.8"
} }
@@ -3013,7 +2959,6 @@
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"depd": "~2.0.0", "depd": "~2.0.0",
"inherits": "~2.0.4", "inherits": "~2.0.4",
@@ -3080,7 +3025,6 @@
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0" "safer-buffer": ">= 2.1.2 < 3.0.0"
}, },
@@ -3096,8 +3040,7 @@
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC", "license": "ISC"
"peer": true
}, },
"node_modules/ini": { "node_modules/ini": {
"version": "1.3.8", "version": "1.3.8",
@@ -3127,15 +3070,13 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/isexe": { "node_modules/isexe": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC", "license": "ISC"
"peer": true
}, },
"node_modules/jiti": { "node_modules/jiti": {
"version": "2.7.0", "version": "2.7.0",
@@ -3152,7 +3093,6 @@
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/panva" "url": "https://github.com/sponsors/panva"
} }
@@ -3187,7 +3127,6 @@
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.18.3", "@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0" "ts-algebra": "^2.0.0"
@@ -3206,8 +3145,7 @@
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause"
"peer": true
}, },
"node_modules/kitx": { "node_modules/kitx": {
"version": "2.2.0", "version": "2.2.0",
@@ -3570,7 +3508,6 @@
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.8" "node": ">= 0.8"
} }
@@ -3580,7 +3517,6 @@
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@@ -3660,7 +3596,6 @@
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
} }
@@ -3702,7 +3637,6 @@
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -3754,7 +3688,6 @@
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"ee-first": "1.1.1" "ee-first": "1.1.1"
}, },
@@ -3767,7 +3700,6 @@
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC", "license": "ISC",
"peer": true,
"dependencies": { "dependencies": {
"wrappy": "1" "wrappy": "1"
} }
@@ -3777,7 +3709,6 @@
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.8" "node": ">= 0.8"
} }
@@ -3787,7 +3718,6 @@
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -3797,7 +3727,6 @@
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
@@ -3830,6 +3759,7 @@
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -3879,7 +3809,6 @@
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=16.20.0" "node": ">=16.20.0"
} }
@@ -3932,6 +3861,7 @@
"devOptional": true, "devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@prisma/config": "6.19.3", "@prisma/config": "6.19.3",
"@prisma/engines": "6.19.3" "@prisma/engines": "6.19.3"
@@ -3995,7 +3925,6 @@
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"forwarded": "0.2.0", "forwarded": "0.2.0",
"ipaddr.js": "1.9.1" "ipaddr.js": "1.9.1"
@@ -4009,7 +3938,6 @@
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.10" "node": ">= 0.10"
} }
@@ -4067,7 +3995,6 @@
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
"integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
}, },
@@ -4081,7 +4008,6 @@
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"bytes": "~3.1.2", "bytes": "~3.1.2",
"http-errors": "~2.0.1", "http-errors": "~2.0.1",
@@ -4199,7 +4125,6 @@
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"debug": "^4.4.0", "debug": "^4.4.0",
"depd": "^2.0.0", "depd": "^2.0.0",
@@ -4246,8 +4171,7 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/sax": { "node_modules/sax": {
"version": "1.6.0", "version": "1.6.0",
@@ -4291,7 +4215,6 @@
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"debug": "^4.4.3", "debug": "^4.4.3",
"encodeurl": "^2.0.0", "encodeurl": "^2.0.0",
@@ -4318,7 +4241,6 @@
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
} }
@@ -4328,7 +4250,6 @@
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"mime-db": "^1.54.0" "mime-db": "^1.54.0"
}, },
@@ -4345,7 +4266,6 @@
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"encodeurl": "^2.0.0", "encodeurl": "^2.0.0",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
@@ -4370,15 +4290,13 @@
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC", "license": "ISC"
"peer": true
}, },
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"shebang-regex": "^3.0.0" "shebang-regex": "^3.0.0"
}, },
@@ -4391,7 +4309,6 @@
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -4559,7 +4476,6 @@
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@stablelib/base64": "^1.0.0", "@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0" "fast-sha256": "^1.3.0"
@@ -4570,7 +4486,6 @@
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.8" "node": ">= 0.8"
} }
@@ -4658,7 +4573,6 @@
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.6" "node": ">=0.6"
} }
@@ -4667,8 +4581,7 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
@@ -4684,6 +4597,7 @@
"integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "~0.28.0" "esbuild": "~0.28.0"
}, },
@@ -4702,7 +4616,6 @@
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"content-type": "^2.0.0", "content-type": "^2.0.0",
"media-typer": "^1.1.0", "media-typer": "^1.1.0",
@@ -4721,7 +4634,6 @@
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@@ -4735,7 +4647,6 @@
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
} }
@@ -4745,7 +4656,6 @@
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"mime-db": "^1.54.0" "mime-db": "^1.54.0"
}, },
@@ -4763,6 +4673,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -4782,7 +4693,6 @@
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.8" "node": ">= 0.8"
} }
@@ -4792,7 +4702,6 @@
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.8" "node": ">= 0.8"
} }
@@ -4803,6 +4712,7 @@
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
"picomatch": "^4.0.4", "picomatch": "^4.0.4",
@@ -4970,7 +4880,6 @@
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC", "license": "ISC",
"peer": true,
"dependencies": { "dependencies": {
"isexe": "^2.0.0" "isexe": "^2.0.0"
}, },
@@ -5002,8 +4911,7 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC", "license": "ISC"
"peer": true
}, },
"node_modules/ws": { "node_modules/ws": {
"version": "8.21.0", "version": "8.21.0",
@@ -5053,6 +4961,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
@@ -5062,7 +4971,6 @@
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
"license": "ISC", "license": "ISC",
"peer": true,
"peerDependencies": { "peerDependencies": {
"zod": "^3.25.28 || ^4" "zod": "^3.25.28 || ^4"
} }
+4 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.36", "version": "0.0.35",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
@@ -30,10 +30,10 @@
"axios": "1.18.1" "axios": "1.18.1"
} }
}, },
"description": "Curriculum Project Hub — org-scoped Feishu collaboration and confined Agent runtime. Semantics pinned by docs/adr/ (ADR-0001 through ADR-0027).", "description": "Curriculum Project Hub — org-scoped Feishu collaboration and confined Agent runtime. Aligns to spec/System through ADR-0024.",
"scripts": { "scripts": {
"dev": "npm run prisma:migrate && tsx watch src/server.ts", "dev": "npm run prisma:migrate && tsx watch src/server.ts",
"build": "tsc -p tsconfig.json && npm run admin:build && npm run database:build", "build": "tsc -p tsconfig.json && npm run admin:build",
"start": "npm run prisma:migrate && node dist/server.js", "start": "npm run prisma:migrate && node dist/server.js",
"check": "tsc -p tsconfig.json --noEmit", "check": "tsc -p tsconfig.json --noEmit",
"audit:production": "npm audit --omit=dev --audit-level=high", "audit:production": "npm audit --omit=dev --audit-level=high",
@@ -48,8 +48,6 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"admin:dev": "npm run dev --prefix admin-web", "admin:dev": "npm run dev --prefix admin-web",
"admin:build": "npm run build --prefix admin-web", "admin:build": "npm run build --prefix admin-web"
"database:dev": "npm run dev --prefix database-admin",
"database:build": "npm run build --prefix database-admin"
} }
} }
@@ -1,6 +1,6 @@
-- ADR-0023 rejected the legacy `PlatformRoleAssignment` / `PlatformRole`{ADMIN,TEACHER} -- ADR-0023 rejected the legacy `PlatformRoleAssignment` / `PlatformRole`{ADMIN,TEACHER}
-- model: the platform administration control plane is a separate identity/session/ -- model: the platform administration control plane is a separate identity/session/
-- audit surface, intentionally not built -- audit surface (see `Spec.System.PlatformAdministration`), intentionally not built
-- in alpha (ADR-0025, `hub/deploy/README.md`). The legacy table has no runtime -- 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 — -- 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. -- and ADR-0023 requires it to be migrated/replaced before the platform panel ships.
@@ -0,0 +1,33 @@
-- ADR-0028 agent configuration folder tree. One org-scoped transparent folder
-- tree shared by Agent roles and skills (management-surface grouping only);
-- skill name / roleId uniqueness, role→skill bindings, run-time skill loading
-- and Feishu slash commands never reference folders. A folder deletes only
-- when empty (service-enforced); item `folderId` references are SetNull as
-- backstop.
-- CreateTable
CREATE TABLE "OrganizationAgentConfigFolder" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"parentId" TEXT,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OrganizationAgentConfigFolder_pkey" PRIMARY KEY ("id")
);
-- AlterTable
ALTER TABLE "OrganizationAgentSkill" ADD COLUMN "folderId" TEXT;
ALTER TABLE "OrganizationAgentRole" ADD COLUMN "folderId" TEXT;
-- CreateIndex
CREATE INDEX "OrganizationAgentConfigFolder_organizationId_parentId_idx" ON "OrganizationAgentConfigFolder"("organizationId", "parentId");
CREATE INDEX "OrganizationAgentSkill_organizationId_folderId_idx" ON "OrganizationAgentSkill"("organizationId", "folderId");
CREATE INDEX "OrganizationAgentRole_organizationId_folderId_idx" ON "OrganizationAgentRole"("organizationId", "folderId");
-- AddForeignKey
ALTER TABLE "OrganizationAgentConfigFolder" ADD CONSTRAINT "OrganizationAgentConfigFolder_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "OrganizationAgentConfigFolder" ADD CONSTRAINT "OrganizationAgentConfigFolder_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "OrganizationAgentConfigFolder"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "OrganizationAgentSkill" ADD CONSTRAINT "OrganizationAgentSkill_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "OrganizationAgentConfigFolder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "OrganizationAgentRole" ADD CONSTRAINT "OrganizationAgentRole_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "OrganizationAgentConfigFolder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -1,70 +0,0 @@
-- Global, unlimited-depth member group hierarchy (requirement 3.1-3.3).
-- Managed only by the platform super administrator; deliberately NOT
-- org-scoped. Stores membership + nesting only, never permission data.
-- Deletion is soft (archivedAt / revokedAt markers); a group delete
-- cascade-soft-deletes its whole subtree as an application operation.
-- The permission side (GROUP principal, FOLDER resource, grant inheritance)
-- is intentionally deferred to a later migration.
-- CreateTable
CREATE TABLE "MemberGroup" (
"id" TEXT NOT NULL,
"parentId" TEXT,
"name" TEXT NOT NULL,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"archivedAt" TIMESTAMP(3),
CONSTRAINT "MemberGroup_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MemberGroupMembership" (
"id" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
CONSTRAINT "MemberGroupMembership_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MemberGroupClosure" (
"ancestorId" TEXT NOT NULL,
"descendantId" TEXT NOT NULL,
"depth" INTEGER NOT NULL,
CONSTRAINT "MemberGroupClosure_pkey" PRIMARY KEY ("ancestorId", "descendantId")
);
-- CreateIndex
CREATE INDEX "MemberGroup_parentId_archivedAt_idx" ON "MemberGroup"("parentId", "archivedAt");
-- CreateIndex
CREATE INDEX "MemberGroupMembership_userId_revokedAt_idx" ON "MemberGroupMembership"("userId", "revokedAt");
-- CreateIndex
CREATE INDEX "MemberGroupMembership_groupId_revokedAt_idx" ON "MemberGroupMembership"("groupId", "revokedAt");
-- CreateIndex
CREATE UNIQUE INDEX "MemberGroupMembership_groupId_userId_revokedAt_key" ON "MemberGroupMembership"("groupId", "userId", "revokedAt");
-- CreateIndex
CREATE INDEX "MemberGroupClosure_descendantId_idx" ON "MemberGroupClosure"("descendantId");
-- AddForeignKey
ALTER TABLE "MemberGroup" ADD CONSTRAINT "MemberGroup_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "MemberGroup"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MemberGroupMembership" ADD CONSTRAINT "MemberGroupMembership_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "MemberGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MemberGroupMembership" ADD CONSTRAINT "MemberGroupMembership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MemberGroupClosure" ADD CONSTRAINT "MemberGroupClosure_ancestorId_fkey" FOREIGN KEY ("ancestorId") REFERENCES "MemberGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MemberGroupClosure" ADD CONSTRAINT "MemberGroupClosure_descendantId_fkey" FOREIGN KEY ("descendantId") REFERENCES "MemberGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+36 -68
View File
@@ -1,6 +1,6 @@
// Prisma schema for Curriculum Project Hub. // Prisma schema for Curriculum Project Hub.
// //
// Aligns to ADR-0001..0004, 0017. Key divergences from the // Aligns to spec/System (ADR-0001..0004, 0017). Key divergences from the
// legacy teaching-material-host-service schema, each deliberate: // legacy teaching-material-host-service schema, each deliberate:
// //
// - AgentSession is provider/model-bound. Provider runtime cursors such as // - 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 // - ProjectGroupBinding is project→chat only (ADR-0001 1:1); legacy mixed
// user/chat targets into one binding table. // user/chat targets into one binding table.
// - PermissionGrant + PermissionSettings land (ADR-0004), missing in legacy. // - PermissionGrant + PermissionSettings land (ADR-0004), missing in legacy.
// - AgentRunStatus adds WAITING_FOR_USER + TIMED_OUT (the run-state set is // - AgentRunStatus adds WAITING_FOR_USER + TIMED_OUT (spec RunState; enum
// open — add states without a schema migration war). // completeness OPEN — add states without a schema migration war).
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
@@ -47,6 +47,7 @@ model Organization {
capabilityConnections OrganizationCapabilityConnection[] capabilityConnections OrganizationCapabilityConnection[]
agentSkills OrganizationAgentSkill[] agentSkills OrganizationAgentSkill[]
agentRoles OrganizationAgentRole[] agentRoles OrganizationAgentRole[]
agentConfigFolders OrganizationAgentConfigFolder[]
projectGroupBindings ProjectGroupBinding[] projectGroupBindings ProjectGroupBinding[]
auditEntries AuditEntry[] @relation("organizationAudit") auditEntries AuditEntry[] @relation("organizationAudit")
projectSearchDocuments ProjectSearchDocument[] projectSearchDocuments ProjectSearchDocument[]
@@ -61,7 +62,7 @@ enum OrganizationStatus {
} }
/// Org-scoped membership role. Distinct from project PermissionRole and from /// Org-scoped membership role. Distinct from project PermissionRole and from
/// the platform administrator surface (ADR-0023), /// the platform administrator surface (ADR-0023 / Spec.System.PlatformAdministration),
/// which is a separate control plane not modeled in alpha (ADR-0025). /// which is a separate control plane not modeled in alpha (ADR-0025).
model OrganizationMembership { model OrganizationMembership {
id String @id @default(cuid()) id String @id @default(cuid())
@@ -95,16 +96,19 @@ model OrganizationAgentSkill {
version String version String
description String? description String?
contentDigest String contentDigest String
folderId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
disabledAt DateTime? disabledAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
folder OrganizationAgentConfigFolder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
roleBindings OrganizationAgentRoleSkill[] roleBindings OrganizationAgentRoleSkill[]
@@unique([organizationId, name]) @@unique([organizationId, name])
@@unique([organizationId, id]) @@unique([organizationId, id])
@@index([organizationId, disabledAt]) @@index([organizationId, disabledAt])
@@index([organizationId, folderId])
@@index([contentDigest]) @@index([contentDigest])
} }
@@ -121,17 +125,20 @@ model OrganizationAgentRole {
tools Json? tools Json?
sortOrder Int @default(0) sortOrder Int @default(0)
isDefault Boolean @default(false) isDefault Boolean @default(false)
folderId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
disabledAt DateTime? disabledAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
folder OrganizationAgentConfigFolder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
skillBindings OrganizationAgentRoleSkill[] skillBindings OrganizationAgentRoleSkill[]
selectedByBindings ProjectGroupBinding[] @relation("selectedAgentRole") selectedByBindings ProjectGroupBinding[] @relation("selectedAgentRole")
@@unique([organizationId, roleId]) @@unique([organizationId, roleId])
@@unique([organizationId, id]) @@unique([organizationId, id])
@@index([organizationId, disabledAt, sortOrder]) @@index([organizationId, disabledAt, sortOrder])
@@index([organizationId, folderId])
} }
/// Same-Organization join enforced by both composite foreign keys. `sortOrder` /// Same-Organization join enforced by both composite foreign keys. `sortOrder`
@@ -151,6 +158,29 @@ model OrganizationAgentRoleSkill {
@@index([organizationId, agentSkillId]) @@index([organizationId, agentSkillId])
} }
/// ADR-0028: org-scoped transparent folder tree shared by agent roles and
/// skills. Management-surface navigation/grouping only — not a permission
/// resource, and never referenced by role→skill bindings, run-time skill
/// loading, or Feishu slash commands. Skill name and roleId stay unique per
/// organization regardless of folder membership. A folder is deleted only
/// when empty (service-enforced); item references are SetNull as backstop.
model OrganizationAgentConfigFolder {
id String @id @default(cuid())
organizationId String
parentId String?
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
parent OrganizationAgentConfigFolder? @relation("agentConfigFolderTree", fields: [parentId], references: [id], onDelete: Restrict)
children OrganizationAgentConfigFolder[] @relation("agentConfigFolderTree")
skills OrganizationAgentSkill[]
roles OrganizationAgentRole[]
@@index([organizationId, parentId])
}
/// ADR-0021: org-level project onboarding policy. Ordinary Feishu users can /// ADR-0021: org-level project onboarding policy. Ordinary Feishu users can
/// create projects from unbound chats only when membersCanCreateProjects=true. /// create projects from unbound chats only when membersCanCreateProjects=true.
model OrganizationProjectSettings { model OrganizationProjectSettings {
@@ -193,7 +223,6 @@ model User {
heldLocks ProjectAgentLock[] @relation("lockHolder") heldLocks ProjectAgentLock[] @relation("lockHolder")
feishuBindings ProjectGroupBinding[] @relation("bindingCreator") feishuBindings ProjectGroupBinding[] @relation("bindingCreator")
teamMemberships TeamMembership[] teamMemberships TeamMembership[]
memberGroupMemberships MemberGroupMembership[]
externalPrincipalMemberships ExternalPrincipalMembership[] externalPrincipalMemberships ExternalPrincipalMembership[]
permissionGrants PermissionGrant[] @relation("grantCreator") permissionGrants PermissionGrant[] @relation("grantCreator")
roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator") roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator")
@@ -394,67 +423,6 @@ model TeamExternalBinding {
@@index([teamId, revokedAt]) @@index([teamId, revokedAt])
} }
// --- Member groups (global, nestable authorization principal) ------------
/// Global, unlimited-depth member group. Managed only by the platform super administrator (ADR-0023);
/// Deletion is soft: `archivedAt` is a marker.
/// Deleting a group cascade-soft-deletes its whole subtree — an application
/// operation (walk the subtree, stamp archivedAt), not a DB constraint.
model MemberGroup {
id String @id @default(cuid())
parentId String?
name String
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
archivedAt DateTime?
parent MemberGroup? @relation("groupTree", fields: [parentId], references: [id], onDelete: Restrict)
children MemberGroup[] @relation("groupTree")
memberships MemberGroupMembership[]
asAncestor MemberGroupClosure[] @relation("ancestor")
asDescendant MemberGroupClosure[] @relation("descendant")
@@index([parentId, archivedAt])
}
/// User↔group many-to-many; a user may belong to multiple groups. Soft delete
/// via `revokedAt` (same pattern as TeamMembership) allows re-adding a removed
/// member. `userId, revokedAt` index is the resolution hot path: fetch a user's direct groups.
model MemberGroupMembership {
id String @id @default(cuid())
groupId String
userId String
createdAt DateTime @default(now())
revokedAt DateTime?
group MemberGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([groupId, userId, revokedAt])
@@index([userId, revokedAt])
@@index([groupId, revokedAt])
}
/// Transitive closure of the MemberGroup tree. Every group has a depth=0
/// self row. Turns ancestor/descendant resolution into one indexed join
/// instead of a recursive CTE; maintained only on create / reparent (rare
/// super-admin ops, so the write cost is amortized against hot reads). On soft
/// delete the closure rows are retained; resolution filters by
/// MemberGroup.archivedAt. Reparent must reject a new parent inside the moved
/// subtree (cycle guard).
model MemberGroupClosure {
ancestorId String
descendantId String
depth Int
ancestor MemberGroup @relation("ancestor", fields: [ancestorId], references: [id], onDelete: Cascade)
descendant MemberGroup @relation("descendant", fields: [descendantId], references: [id], onDelete: Cascade)
@@id([ancestorId, descendantId])
@@index([descendantId])
}
/// Locally synchronized Feishu external principal membership. /// Locally synchronized Feishu external principal membership.
model ExternalDirectoryConnection { model ExternalDirectoryConnection {
id String @id @default(cuid()) id String @id @default(cuid())
+121
View File
@@ -15,6 +15,10 @@
* `commitSkillContent`, so the web path and CLI path share one ingestion * `commitSkillContent`, so the web path and CLI path share one ingestion
* pipeline and one set of safety checks (SKILL.md manifest required, 512-file * pipeline and one set of safety checks (SKILL.md manifest required, 512-file
* / 16-byte limits, symlink rejection). * / 16-byte limits, symlink rejection).
*
* Folder tree (ADR-0028): one org-scoped transparent folder tree shared by
* roles and skills for management-surface grouping. Folder endpoints never
* touch session state — assignment is a label-class change (ADR-0017).
*/ */
import type { PrismaClient } from "@prisma/client"; import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
@@ -243,4 +247,121 @@ export async function registerAgentConfigRoutes(
return handleRouteError(reply, err); return handleRouteError(reply, err);
} }
}); });
// --- ADR-0028 shared agent-config folder tree (transparent grouping) ---
app.get("/api/org/:orgSlug/agent-config-folders", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const folders = await agentConfig.listFolders({ organizationId: auth.organization.id });
return { folders };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/agent-config-folders", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { name?: unknown; parentId?: unknown };
if (typeof body.name !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "name is required" },
});
}
const folder = await agentConfig.createFolder({
organizationId: auth.organization.id,
name: body.name,
...(typeof body.parentId === "string" ? { parentId: body.parentId } : {}),
});
return reply.status(201).send(folder);
} catch (err) {
return handleRouteError(reply, err);
}
});
app.patch("/api/org/:orgSlug/agent-config-folders/:folderId", async (request, reply) => {
try {
const { orgSlug, folderId } = request.params as { orgSlug: string; folderId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { name?: unknown; parentId?: unknown };
const folder = await agentConfig.updateFolder({
organizationId: auth.organization.id,
folderId,
...(typeof body.name === "string" ? { name: body.name } : {}),
...(body.parentId === null || typeof body.parentId === "string"
? { parentId: body.parentId as string | null }
: {}),
});
return folder;
} catch (err) {
return handleRouteError(reply, err);
}
});
app.delete("/api/org/:orgSlug/agent-config-folders/:folderId", async (request, reply) => {
try {
const { orgSlug, folderId } = request.params as { orgSlug: string; folderId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
await agentConfig.deleteFolder({
organizationId: auth.organization.id,
folderId,
});
return { deleted: true };
} catch (err) {
return handleRouteError(reply, err);
}
});
// Folder assignment is a label-class change (ADR-0017): these endpoints
// never archive Agent sessions (ADR-0028).
app.patch("/api/org/:orgSlug/agent-roles/:roleId/folder", async (request, reply) => {
try {
const { orgSlug, roleId } = request.params as { orgSlug: string; roleId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { folderId?: unknown };
if (body.folderId !== null && typeof body.folderId !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "folderId must be a string or null" },
});
}
await agentConfig.setRoleFolder({
organizationId: auth.organization.id,
roleId,
folderId: body.folderId as string | null,
});
return { folderId: body.folderId as string | null };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.patch("/api/org/:orgSlug/agent-skills/:name/folder", async (request, reply) => {
try {
const { orgSlug, name } = request.params as { orgSlug: string; name: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { folderId?: unknown };
if (body.folderId !== null && typeof body.folderId !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "folderId must be a string or null" },
});
}
await agentConfig.setSkillFolder({
organizationId: auth.organization.id,
name,
folderId: body.folderId as string | null,
});
return { folderId: body.folderId as string | null };
} catch (err) {
return handleRouteError(reply, err);
}
});
} }
+231
View File
@@ -18,6 +18,7 @@ export interface AgentRoleRow {
readonly createdAt: string; readonly createdAt: string;
readonly updatedAt: string; readonly updatedAt: string;
readonly skillNames: readonly string[]; readonly skillNames: readonly string[];
readonly folderId: string | null;
} }
export interface AgentSkillRow { export interface AgentSkillRow {
@@ -30,6 +31,17 @@ export interface AgentSkillRow {
readonly createdAt: string; readonly createdAt: string;
readonly updatedAt: string; readonly updatedAt: string;
readonly boundRoleIds: readonly string[]; readonly boundRoleIds: readonly string[];
readonly folderId: string | null;
}
/**
* ADR-0028 transparent folder node of the org's shared agent-config folder
* tree. Grouping only: never part of skill/role identity or run resolution.
*/
export interface AgentConfigFolderRow {
readonly id: string;
readonly name: string;
readonly parentId: string | null;
} }
/** /**
@@ -80,9 +92,213 @@ export class OrganizationAgentConfiguration {
createdAt: skill.createdAt.toISOString(), createdAt: skill.createdAt.toISOString(),
updatedAt: skill.updatedAt.toISOString(), updatedAt: skill.updatedAt.toISOString(),
boundRoleIds: skill.roleBindings.map((binding) => binding.role.roleId), boundRoleIds: skill.roleBindings.map((binding) => binding.role.roleId),
folderId: skill.folderId,
})); }));
} }
async listFolders(input: { readonly organizationId: string }): Promise<readonly AgentConfigFolderRow[]> {
await this.requireActiveOrganization(input.organizationId);
const folders = await this.prisma.organizationAgentConfigFolder.findMany({
where: { organizationId: input.organizationId },
orderBy: [{ name: "asc" }, { id: "asc" }],
select: { id: true, name: true, parentId: true },
});
return folders;
}
async createFolder(input: {
readonly organizationId: string;
readonly name: string;
readonly parentId?: string | undefined;
}): Promise<AgentConfigFolderRow> {
await this.requireActiveOrganization(input.organizationId);
const name = nonEmpty(input.name, "folder name");
return this.prisma.$transaction(async (tx) => {
if (input.parentId !== undefined) {
await requireFolder(tx, input.organizationId, input.parentId);
}
const folder = await tx.organizationAgentConfigFolder.create({
data: {
organizationId: input.organizationId,
name,
...(input.parentId !== undefined ? { parentId: input.parentId } : {}),
},
select: { id: true, name: true, parentId: true },
});
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
action: "agent_config_folder.created",
metadata: { folderId: folder.id, name: folder.name, parentId: folder.parentId },
},
});
return folder;
});
}
/**
* Rename and/or move a folder inside the same Organization tree. Moving is
* rejected when the target parent is the folder itself or one of its
* descendants (would create a cycle).
*/
async updateFolder(input: {
readonly organizationId: string;
readonly folderId: string;
readonly name?: string | undefined;
readonly parentId?: string | null | undefined;
}): Promise<AgentConfigFolderRow> {
await this.requireActiveOrganization(input.organizationId);
return this.prisma.$transaction(async (tx) => {
const folder = await requireFolder(tx, input.organizationId, input.folderId);
if (input.parentId !== undefined && input.parentId !== null) {
if (input.parentId === folder.id) {
throw new Error("folder cannot be its own parent");
}
await requireFolder(tx, input.organizationId, input.parentId);
const descendant = await tx.$queryRaw<Array<{ found: boolean }>>(Prisma.sql`
WITH RECURSIVE descendants AS (
SELECT "id" FROM "OrganizationAgentConfigFolder" WHERE "parentId" = ${folder.id}
UNION ALL
SELECT child."id" FROM "OrganizationAgentConfigFolder" child
JOIN descendants parent ON child."parentId" = parent."id"
)
SELECT EXISTS(SELECT 1 FROM descendants WHERE "id" = ${input.parentId}) AS found
`);
if (descendant[0]?.found === true) throw new Error("folder cannot be moved below its descendant");
}
const name = input.name !== undefined ? nonEmpty(input.name, "folder name") : undefined;
const updated = await tx.organizationAgentConfigFolder.update({
where: { id: folder.id },
data: {
...(name !== undefined ? { name } : {}),
...(input.parentId !== undefined ? { parentId: input.parentId } : {}),
},
select: { id: true, name: true, parentId: true },
});
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
action: "agent_config_folder.updated",
metadata: {
folderId: folder.id,
...(name !== undefined ? { name } : {}),
...(input.parentId !== undefined ? { parentId: input.parentId } : {}),
},
},
});
return updated;
});
}
/**
* Delete a folder. Refused while the folder still has child folders, roles
* or skills (ADR-0028: items are relocated explicitly, so no orphan-placement
* rule is needed).
*/
async deleteFolder(input: {
readonly organizationId: string;
readonly folderId: string;
}): Promise<void> {
await this.requireActiveOrganization(input.organizationId);
await this.prisma.$transaction(async (tx) => {
const folder = await requireFolder(tx, input.organizationId, input.folderId);
const childFolders = await tx.organizationAgentConfigFolder.count({
where: { parentId: folder.id },
});
if (childFolders > 0) {
throw new Error(`cannot delete folder: still has ${childFolders} child folder(s)`);
}
const skills = await tx.organizationAgentSkill.count({
where: { organizationId: input.organizationId, folderId: folder.id },
});
const roles = await tx.organizationAgentRole.count({
where: { organizationId: input.organizationId, folderId: folder.id },
});
if (skills > 0 || roles > 0) {
throw new Error(`cannot delete folder: still has ${roles} role(s) and ${skills} skill(s)`);
}
await tx.organizationAgentConfigFolder.delete({ where: { id: folder.id } });
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
action: "agent_config_folder.deleted",
metadata: { folderId: folder.id, name: folder.name },
},
});
});
}
/**
* Assign a skill to a folder (or unfile it with `folderId: null`). This is
* a label-class change in the ADR-0017 sense — the execution surface is
* untouched, so no session archival (ADR-0028).
*/
async setSkillFolder(input: {
readonly organizationId: string;
readonly name: string;
readonly folderId: string | null;
}): Promise<void> {
await this.requireActiveOrganization(input.organizationId);
await this.prisma.$transaction(async (tx) => {
const skill = await tx.organizationAgentSkill.findUnique({
where: { organizationId_name: { organizationId: input.organizationId, name: input.name } },
select: { id: true, disabledAt: true },
});
if (skill === null || skill.disabledAt !== null) {
throw new Error(`active skill not found in organization: ${input.name}`);
}
if (input.folderId !== null) {
await requireFolder(tx, input.organizationId, input.folderId);
}
await tx.organizationAgentSkill.update({
where: { id: skill.id },
data: { folderId: input.folderId },
});
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
action: "agent_skill.folder_set",
metadata: { name: input.name, folderId: input.folderId },
},
});
});
}
/**
* Assign a role to a folder (or unfile it with `folderId: null`). Same
* label-class semantics as `setSkillFolder`: no session archival (ADR-0028).
*/
async setRoleFolder(input: {
readonly organizationId: string;
readonly roleId: string;
readonly folderId: string | null;
}): Promise<void> {
await this.requireActiveOrganization(input.organizationId);
await this.prisma.$transaction(async (tx) => {
const role = await tx.organizationAgentRole.findUnique({
where: { organizationId_roleId: { organizationId: input.organizationId, roleId: input.roleId } },
select: { id: true, disabledAt: true },
});
if (role === null || role.disabledAt !== null) {
throw new Error(`active role not found in organization: ${input.roleId}`);
}
if (input.folderId !== null) {
await requireFolder(tx, input.organizationId, input.folderId);
}
await tx.organizationAgentRole.update({
where: { id: role.id },
data: { folderId: input.folderId },
});
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
action: "agent_role.folder_set",
metadata: { roleId: input.roleId, folderId: input.folderId },
},
});
});
}
async installSkill(input: { async installSkill(input: {
readonly organizationId: string; readonly organizationId: string;
readonly sourceDir: string; readonly sourceDir: string;
@@ -472,6 +688,19 @@ export class OrganizationAgentConfiguration {
} }
} }
async function requireFolder(
tx: Prisma.TransactionClient,
organizationId: string,
folderId: string,
): Promise<{ readonly id: string; readonly name: string; readonly parentId: string | null }> {
const folder = await tx.organizationAgentConfigFolder.findFirst({
where: { id: folderId, organizationId },
select: { id: true, name: true, parentId: true },
});
if (folder === null) throw new Error(`folder not found in organization: ${folderId}`);
return folder;
}
async function archiveRoleSessions( async function archiveRoleSessions(
tx: Prisma.TransactionClient, tx: Prisma.TransactionClient,
organizationId: string, organizationId: string,
@@ -530,6 +759,7 @@ function toRoleRow(role: {
readonly disabledAt: Date | null; readonly disabledAt: Date | null;
readonly createdAt: Date; readonly createdAt: Date;
readonly updatedAt: Date; readonly updatedAt: Date;
readonly folderId: string | null;
readonly skillBindings: ReadonlyArray<{ readonly skillBindings: ReadonlyArray<{
readonly skill: { readonly name: string; readonly disabledAt: Date | null }; readonly skill: { readonly name: string; readonly disabledAt: Date | null };
}>; }>;
@@ -549,5 +779,6 @@ function toRoleRow(role: {
skillNames: role.skillBindings skillNames: role.skillBindings
.filter((binding) => binding.skill.disabledAt === null) .filter((binding) => binding.skill.disabledAt === null)
.map((binding) => binding.skill.name), .map((binding) => binding.skill.name),
folderId: role.folderId,
}; };
} }
+3 -3
View File
@@ -24,10 +24,10 @@
* denied by default and re-opened only for the workspace plus named system * 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 * runtimes, and `failIfUnavailable` hard-fails if the sandbox can't start. The
* subprocess gets a minimal environment and SDK credential protection removes * subprocess gets a minimal environment and SDK credential protection removes
* provider secrets from Bash. This upholds the workspace-bounded file-op * provider secrets from Bash. This upholds `AgentFileOp.Authorized`
* invariant (ADR-0018) without re-implementing the * (ADR-0018 / `Spec.System.AgentSurface`) without re-implementing the
* `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox * `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox
* is the mechanism, the ADR pins the invariant. * is the mechanism, the contract 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 { 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"; import type { PrismaClient } from "@prisma/client";
+2 -2
View File
@@ -1,6 +1,6 @@
/** /**
* ADR-0022 capacity dimensions. * ADR-0022 capacity dimensions (spec `Spec.System.Capacity.CapacityDimension`).
* The 23 pinned dimensions; exact numeric ceilings are open and calibrated by * 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 * capacity testing. This module is the single source of the dimension set shared
* by the platform-ceiling config and the org capacity-policy service. * by the platform-ceiling config and the org capacity-policy service.
*/ */
-107
View File
@@ -1,107 +0,0 @@
# src/database/
`/database/*` HTTP 面。代码写在这个目录里,`hub.ts` 通过 `plugin.ts` 挂载它,
所以服务器启动时能正确识别这些路由。
**前后端分离**:页面已迁到独立的 SvelteKit 静态 SPA `hub/database-admin/`
(与 `hub/admin-web/` 同一套框架)。本目录的后端只保留三件事:鉴权透传、一个
免鉴权配置端点、以及把 SPA 构建产物托管出去。页面全部由 SPA 客户端渲染。
后端路由:
- `GET /database/config` —— 免鉴权。返回 `{ siloOrganizationSlug, devLoginEnabled }`
给 SPA 登录页拼飞书链接、决定是否显示 dev 按钮用。不含任何敏感数据。
- `GET /database/dev-login` —— 仅开发。见下。
- `GET /database``GET /database/*` —— SPA shell / 客户端路由 fallback
`static.ts``registerDatabaseSpa`);资产在 `/database/_app/*`
SPA 页面(`database-admin``paths.base='/database'`):
- `/database/admin` —— 飞书登录页。按钮指向 `/auth/feishu/<orgSlug>`slug 来自
`/database/config`),回调由 `src/admin/routes/authRoutes.ts` 处理并种 session cookie。
- `/database/dashboard` —— 后台壳。未登录跳登录页;**登录但非 OWNER/ADMIN 显示无权提示**。
> **注册顺序要点**concrete 路由(`/database/config`、`/database/dev-login`)必须在
> `registerDatabaseSpa` 的 `/database/*` fallback 之前注册(已在 `plugin.ts` 保证),
> 否则通配会 shadow 它们。
## 开发模式:用环境变量开启一键登录
本地开发没有真实飞书 app 时,可以用环境变量开启一键登录,跳过飞书 OAuth,
直接以现有 OWNER/ADMIN 身份登入后台。**仅限开发,不是生产登录路径。**
### 怎么开
`hub/.env` 里设:
```sh
HUB_DEV_LOGIN_BYPASS="true"
```
改完重启服务(`npm run dev`,或本地手动 `npx tsx src/server.ts`)。启动日志会
打印一行 `DEV login bypass enabled: /database/dev-login ...` 作为确认。
开启后:
- `/database/config` 返回 `devLoginEnabled: true`SPA 登录页据此显示
「⚡ 一键登录管理员」按钮
- 后端注册 `/database/dev-login` 端点:按钮就是打它,它签发一个和飞书 OAuth
回调完全一样的 session,然后跳到 `/database/dashboard`
### 怎么关
把值设成 `false`(或 `0` / `no` / `off`),或删掉这一行。关闭后按钮消失、
`/database/dev-login` 返回 404 —— 按钮和端点同进同退。
### 双重门禁(重要)
真正的开关是两个条件的**与**(判断在 `plugin.ts`):
```
allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
```
即:**只要 `NODE_ENV=production`,无论 `HUB_DEV_LOGIN_BYPASS` 设成什么,一键登录
都强制关闭。** 生产始终只能走真实飞书 OAuth。
> 提醒:`HUB_DEV_LOGIN_BYPASS` 是敏感开关,别把开着它的 `.env` 带到任何联网 /
> 共享环境。整个旁路逻辑自包含在本目录(`plugin.ts` + `routes/databaseRoutes.ts`),
> `src/admin` 的登录路由未受影响。
## 文件
| 文件 | 职责 |
|------|------|
| `plugin.ts` | 模块对外入口,`hub.ts``registerDatabasePlugin()`;先挂 concrete 路由再挂 SPA |
| `routes/databaseRoutes.ts` | 后端 JSON / redirect 路由(`/database/config``/database/dev-login`),**数据端点加在这里** |
| `static.ts` | `registerDatabaseSpa`:托管 `database-admin/build` 的 SPA + `/database/*` fallback |
新增一类**数据**端点时:要么直接往 `databaseRoutes.ts``app.get("/database/...")`
要么新建 `routes/xxxRoutes.ts` 并在 `databaseRoutes.ts``registerXxxRoutes(app, {...})`
注册一次。**页面**则加在 `database-admin/src/routes/` 下(SvelteKit 路由)。
## SPA 构建与托管
- 前端在 `hub/database-admin/``npm run build`(或 hub 根的 `npm run database:build`
产出到 `database-admin/build/`。hub 的 `npm run build` 会把两个 SPA 一起带出来。
- `static.ts` 默认从 `../../database-admin/build` 读产物;可用 `CPH_DATABASE_UI_DIR`
覆盖。产物缺失时降级:只 warn,不挂 SPA,`/database/config``/database/dev-login` 仍可用。
- 本地开发:hub 根 `npm run database:dev` 起 Vite,它把 `/api``/auth``/database`
代理到 `127.0.0.1:8788`
## 约定(与 admin 面一致)
1. 路由用**绝对路径** `"/database/..."`,不用 Fastify prefix —— 每条路由 grep 得到。
2. **guard 前置、fail closed**:凡碰数据的端点第一行先跑
`requireSession` / `requireOrgRole` / `requireProjectPermission`
(都在 `../admin/auth/guards.js`)。
3. **租户隔离**ADR-0020):每个 Prisma 查询都 scope 到 `auth.organization.id`
不得跨 org。禁止无鉴权的数据路由。
4. 数据库通过传入的 `config.prisma` 访问(全进程单例,见 `../db.ts`);
不要在这里 `new PrismaClient()`
## 为什么代码在 `src/` 下
`tsconfig.json` 固定 `rootDir: "src"``include: ["src/**/*.ts"]`。只有
`src/` 下的 `.ts` 会被 `tsc` 编译、被 `tsx watch``npm run dev`)加载。放在
`src/` 之外的目录不会被构建,外部识别不到。
-50
View File
@@ -1,50 +0,0 @@
/**
* Registers the `/database/*` HTTP surface onto the Fastify app.
*
* Single public entry point (mirrors src/admin/plugin.ts). hub.ts calls
* registerDatabasePlugin() so the routes are recognized at startup.
*
* Register AFTER registerAdminPlugin: it relies on the @fastify/cookie parser
* and the /auth/feishu/* login routes that the admin plugin adds.
*
* The dev login bypass is self-contained in THIS module: the flag is read here
* (not threaded from hub.ts) and only ever enables the `/database/dev-login`
* route below. Double-gated: NODE_ENV must not be production AND
* HUB_DEV_LOGIN_BYPASS must be truthy. Production always requires real Feishu
* OAuth.
*/
import type { FastifyInstance } from "fastify";
import type { PrismaClient } from "@prisma/client";
import { registerDatabaseRoutes } from "./routes/databaseRoutes.js";
import { registerDatabaseSpa } from "./static.js";
export interface DatabasePluginConfig {
readonly prisma: PrismaClient;
readonly sessionSecret: string;
readonly siloOrganizationSlug: string;
}
const FALSY = new Set(["", "0", "false", "no", "off"]);
export async function registerDatabasePlugin(
app: FastifyInstance,
config: DatabasePluginConfig,
): Promise<void> {
const allowDevLoginBypass =
process.env["NODE_ENV"] !== "production" &&
!FALSY.has((process.env["HUB_DEV_LOGIN_BYPASS"] ?? "").trim().toLowerCase());
if (allowDevLoginBypass) {
app.log.warn("DEV login bypass enabled: /database/dev-login mints a session without Feishu OAuth");
}
await registerDatabaseRoutes(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
siloOrganizationSlug: config.siloOrganizationSlug,
allowDevLoginBypass,
});
// SPA shell + fallback, registered after the concrete /database/* routes above
// so the /database/* wildcard does not shadow them.
await registerDatabaseSpa(app);
}
-106
View File
@@ -1,106 +0,0 @@
/**
* `/database/*` route aggregator.
*
* Owns the `/database` HTTP surface (single place new sub-routes get wired in).
* Handlers use ABSOLUTE paths (no Fastify prefix) so every route greps as the
* literal string it serves.
*
* The login page and dashboard are now served by the `database-admin` SvelteKit
* SPA (see ../static.ts / registerDatabaseSpa). This file keeps only the
* concrete JSON/redirect routes the SPA depends on:
*
* /database/config — unauthenticated bootstrap: silo org slug + dev toggle
* /database/dev-login — DEV ONLY bypass, registered only when the flag is on
*
* The dev bypass (/database/dev-login) is self-contained here and gated by
* allowDevLoginBypass (computed in ./plugin.ts from HUB_DEV_LOGIN_BYPASS +
* NODE_ENV). Production requires real Feishu OAuth.
*
* NOTE: concrete routes here MUST be registered before the SPA fallback
* (registerDatabaseSpa serves /database and /database/*), or the wildcard would
* shadow them.
*/
import type { FastifyInstance } from "fastify";
import type { PrismaClient } from "@prisma/client";
import { SESSION_COOKIE_NAME, signSession } from "../../admin/auth/session.js";
export interface DatabaseRouteConfig {
readonly prisma: PrismaClient;
/** HMAC secret for the signed session cookie — reused from the admin plane. */
readonly sessionSecret: string;
/** Silo Organization slug — the SPA builds the org-scoped Feishu login link from it. */
readonly siloOrganizationSlug: string;
/** DEV ONLY. Enables the one-click button and the /database/dev-login route. */
readonly allowDevLoginBypass: boolean;
}
export async function registerDatabaseRoutes(
app: FastifyInstance,
config: DatabaseRouteConfig,
): Promise<void> {
// Unauthenticated bootstrap for the static SPA login page. Exposes only what
// the page needs to build the Feishu login link and toggle the dev button —
// no secrets, no user data.
app.get("/database/config", async () => {
return {
siloOrganizationSlug: config.siloOrganizationSlug,
devLoginEnabled: config.allowDevLoginBypass,
};
});
// DEV ONLY bypass — self-contained here, registered only when the flag is on
// (see ./plugin.ts). Mints a session for an existing OWNER/ADMIN, reusing the
// scoped SessionIdentity shape the Feishu OAuth callback produces so the
// session guard behaves identically. Never registered in production.
if (config.allowDevLoginBypass) {
app.get("/database/dev-login", async (_request, reply) => {
const membership = await config.prisma.organizationMembership.findFirst({
where: {
revokedAt: null,
role: { in: ["OWNER", "ADMIN"] },
organization: { status: "ACTIVE" },
},
select: { userId: true, organizationId: true },
orderBy: { createdAt: "asc" },
});
if (membership === null) {
return reply.status(404).send({ error: { code: "no_admin", message: "no active OWNER/ADMIN to impersonate" } });
}
const identity = await config.prisma.feishuUserIdentity.findFirst({
where: {
userId: membership.userId,
connection: { organizationId: membership.organizationId, status: "ACTIVE" },
},
select: { id: true, connectionId: true, connection: { select: { organizationId: true } } },
});
if (identity === null) {
return reply.status(404).send({ error: { code: "no_identity", message: "admin has no active scoped Feishu identity" } });
}
const token = signSession(
{
userId: membership.userId,
feishuIdentityId: identity.id,
feishuConnectionId: identity.connectionId,
feishuOrganizationId: identity.connection.organizationId,
},
config.sessionSecret,
);
// Local dev is http://127.0.0.1, so secure:false. This route only ever
// runs outside production (double-gated in ./plugin.ts).
reply.setCookie(SESSION_COOKIE_NAME, token, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: false,
maxAge: 7 * 24 * 60 * 60,
});
reply.log.warn({ userId: membership.userId, orgId: membership.organizationId }, "DEV database login bypass used");
return reply.redirect("/database/dashboard");
});
}
// Add more /database/* JSON routes here. Guard data routes with requireSession
// / requireOrgRole (../../admin/auth/guards.js) and scope every query to the
// caller's org (ADR-0020). Access the DB via config.prisma. Register concrete
// routes before registerDatabaseSpa's /database/* fallback (done in ./plugin.ts).
}
-98
View File
@@ -1,98 +0,0 @@
/**
* Serves the database-admin SPA (built by SvelteKit via `database-admin/build/`)
* and the SPA index fallback for client-side routes under `/database/*`.
*
* The SvelteKit project lives in `hub/database-admin/` and is built with
* `paths.base = '/database'`, so its assets are emitted under `/database/_app/*`
* (not the root `/_app/*` that admin-web owns — that keeps the two SPAs from
* colliding). On disk the files still live at `build/_app/*`; this handler maps
* the `/database`-prefixed URLs back to those files.
*
* Run `npm run build` in database-admin/ to produce the static output. In
* development, `npm run dev` there proxies `/api`, `/auth`, and `/database` to
* the Hub. Override the UI directory with `CPH_DATABASE_UI_DIR` if needed.
*
* Mirrors src/admin/static.ts.
*/
import { readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, extname, join, resolve as resolvePath } from "node:path";
import type { FastifyInstance } from "fastify";
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".png": "image/png",
".jpg": "image/jpeg",
".woff": "font/woff",
".woff2": "font/woff2",
".json": "application/json; charset=utf-8",
".txt": "text/plain; charset=utf-8",
};
function resolveUiDir(): string {
const override = process.env["CPH_DATABASE_UI_DIR"];
if (override && override.trim() !== "") return resolvePath(override);
const here = dirname(fileURLToPath(import.meta.url));
return resolvePath(join(here, "..", "..", "database-admin", "build"));
}
export async function registerDatabaseSpa(app: FastifyInstance): Promise<void> {
const uiDir = resolveUiDir();
if (!existsSync(join(uiDir, "index.html"))) {
app.log.warn(
{ uiDir },
"database-admin/build not found; /database SPA shell disabled. Run `npm run build` in database-admin/ to enable. /database/config and /database/dev-login remain functional.",
);
return;
}
const indexHtml = await readFile(join(uiDir, "index.html"), "utf8");
// SvelteKit build assets. base='/database' emits them at /database/_app/*,
// but on disk they're under build/_app/*.
app.get("/database/_app/*", async (request, reply) => {
const rel = (request.params as { "*": string })["*"];
const safe = rel.split("/").filter((p) => p !== ".." && p !== "").join("/");
try {
const buf = await readFile(join(uiDir, "_app", safe));
const mime = MIME[extname(safe)] ?? "application/octet-stream";
return reply.type(mime).send(buf);
} catch {
return reply.status(404).send({ error: { code: "not_found", message: "asset not found" } });
}
});
// Top-level static files emitted under the base path (favicon.svg, robots.txt).
app.get("/database/favicon.svg", async (_request, reply) => {
try {
const buf = await readFile(join(uiDir, "favicon.svg"));
return reply.type("image/svg+xml").send(buf);
} catch {
return reply.status(404).send();
}
});
app.get("/database/robots.txt", async (_request, reply) => {
try {
const buf = await readFile(join(uiDir, "robots.txt"));
return reply.type("text/plain; charset=utf-8").send(buf);
} catch {
return reply.status(404).send();
}
});
// SPA client-side route fallback. Concrete /database/* routes (/database/config,
// /database/dev-login, and the asset routes above) are more specific, so
// Fastify's router matches them before this wildcard. Everything else under
// /database serves index.html so SvelteKit's client router can resolve the view.
app.get("/database", async (_request, reply) => {
return reply.type("text/html; charset=utf-8").send(indexHtml);
});
app.get("/database/*", async (_request, reply) => {
return reply.type("text/html; charset=utf-8").send(indexHtml);
});
}
-1
View File
@@ -250,7 +250,6 @@ async function initializeSilo(
data: { data: {
organizationId: input.organization.id, organizationId: input.organization.id,
name: "Inbox", name: "Inbox",
kind: "SYSTEM_INBOX",
sortKey: "000000", sortKey: "000000",
}, },
}); });
+4 -13
View File
@@ -2,8 +2,8 @@
* Silo-wide HTTP request rate limit (ADR-0022 `requestRate`). * Silo-wide HTTP request rate limit (ADR-0022 `requestRate`).
* *
* Counts dynamic traffic only: APIs, auth, and other application handlers. * Counts dynamic traffic only: APIs, auth, and other application handlers.
* Static SPA assets and the admin HTML shells are exempt so a single page load * Static SPA assets and the org-admin HTML shell are exempt so a single page
* (dozens of `/_app/*` chunks + favicon) does not exhaust the minute budget. * load (dozens of `/_app/*` chunks + favicon) does not exhaust the minute budget.
*/ */
/** Paths that must not consume the silo HTTP request-rate budget. */ /** Paths that must not consume the silo HTTP request-rate budget. */
@@ -12,21 +12,12 @@ export function isSiloHttpRateLimitExempt(url: string): boolean {
if (path === "/api/healthz") return true; if (path === "/api/healthz") return true;
// admin-web SvelteKit build output at the root, and top-level static files // SvelteKit build output and top-level static files (see admin/static.ts).
// (see admin/static.ts).
if (path === "/_app" || path.startsWith("/_app/")) return true; if (path === "/_app" || path.startsWith("/_app/")) return true;
if (path === "/favicon.ico" || path === "/favicon.svg" || path === "/robots.txt") return true; if (path === "/favicon.ico" || path === "/favicon.svg" || path === "/robots.txt") return true;
// database-admin SvelteKit build output, served under /database (base path; // SPA index shell for client-side routes (not an API).
// see database/static.ts).
if (path === "/database/_app" || path.startsWith("/database/_app/")) return true;
if (path === "/database/favicon.svg" || path === "/database/robots.txt") return true;
// SPA index shells for client-side routes (not APIs). The /database/* shell is
// blanket-exempt like /admin/* since client routes are unknowable up front;
// this also covers the once-per-load /database/config bootstrap.
if (path === "/admin" || path.startsWith("/admin/")) return true; if (path === "/admin" || path.startsWith("/admin/")) return true;
if (path === "/database" || path.startsWith("/database/")) return true;
return false; return false;
} }
+9 -47
View File
@@ -12,7 +12,6 @@
*/ */
import type { ToolUseTraceStep } from "./trace-store.js"; import type { ToolUseTraceStep } from "./trace-store.js";
import type { CardContentSegment } from "../outboundImages.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@@ -59,7 +58,6 @@ function toolIcon(toolName: string): string {
export function buildAgentCard(params: { export function buildAgentCard(params: {
phase: CardPhase; phase: CardPhase;
text: string; text: string;
contentSegments?: readonly CardContentSegment[] | undefined;
reasoningText: string | undefined; reasoningText: string | undefined;
toolUseSteps: ToolUseTraceStep[]; toolUseSteps: ToolUseTraceStep[];
toolUseElapsedMs: number | undefined; toolUseElapsedMs: number | undefined;
@@ -67,19 +65,19 @@ export function buildAgentCard(params: {
interrupted: boolean | undefined; interrupted: boolean | undefined;
runId: string | undefined; runId: string | undefined;
}): Record<string, unknown> { }): Record<string, unknown> {
const { phase, text, contentSegments, reasoningText, toolUseSteps, toolUseElapsedMs, isError, interrupted } = params; const { phase, text, reasoningText, toolUseSteps, toolUseElapsedMs, isError, interrupted } = params;
const elements: unknown[] = []; const elements: unknown[] = [];
// Tool-use panel (always present if there are steps) // Tool-use panel (always present if there are steps)
if (toolUseSteps.length > 0) { if (toolUseSteps.length > 0) {
elements.push(buildToolUsePanel(toolUseSteps, toolUseElapsedMs, phase !== "complete")); elements.push(buildToolUsePanel(toolUseSteps, toolUseElapsedMs, phase !== "complete"));
} else if (phase === "thinking" || (phase === "streaming" && text === "" && (contentSegments === undefined || contentSegments.length === 0))) { } else if (phase === "thinking" || (phase === "streaming" && text === "")) {
elements.push(buildPendingToolUsePanel()); elements.push(buildPendingToolUsePanel());
} }
// Reasoning panel // Reasoning panel
if (reasoningText !== undefined && reasoningText !== "") { if (reasoningText !== undefined && reasoningText !== "") {
if (phase === "streaming" && text === "" && (contentSegments === undefined || contentSegments.length === 0)) { if (phase === "streaming" && text === "") {
// Still thinking: show reasoning inline // Still thinking: show reasoning inline
elements.push({ elements.push({
tag: "markdown", tag: "markdown",
@@ -92,11 +90,12 @@ export function buildAgentCard(params: {
} }
} }
// Main answer: either materialized segments (markdown + Feishu-hosted images) // Main text content
// or a single markdown block. if (text !== "") {
const answerElements = buildAnswerElements(text, contentSegments); elements.push({
if (answerElements.length > 0) { tag: "markdown",
elements.push(...answerElements); content: truncateText(text, MAX_TEXT_LENGTH),
});
} else if (phase === "thinking" && toolUseSteps.length === 0 && (reasoningText === undefined || reasoningText === "")) { } else if (phase === "thinking" && toolUseSteps.length === 0 && (reasoningText === undefined || reasoningText === "")) {
elements.push({ elements.push({
tag: "markdown", tag: "markdown",
@@ -402,43 +401,6 @@ function escapeMarkdown(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/([`*_{}[\]<>])/g, "\\$1"); return value.replace(/\\/g, "\\\\").replace(/([`*_{}[\]<>])/g, "\\$1");
} }
function buildAnswerElements(
text: string,
contentSegments: readonly CardContentSegment[] | undefined,
): unknown[] {
if (contentSegments !== undefined && contentSegments.length > 0) {
const elements: unknown[] = [];
let remaining = MAX_TEXT_LENGTH;
for (const segment of contentSegments) {
if (segment.type === "image") {
elements.push({
tag: "img",
img_key: segment.imgKey,
alt: { tag: "plain_text", content: segment.alt },
mode: "fit_horizontal",
preview: true,
});
continue;
}
if (segment.content === "" || remaining <= 0) continue;
const slice = segment.content.length <= remaining
? segment.content
: truncateText(segment.content, remaining);
remaining -= slice.length;
elements.push({
tag: "markdown",
content: slice,
});
}
return elements;
}
if (text === "") return [];
return [{
tag: "markdown",
content: truncateText(text, MAX_TEXT_LENGTH),
}];
}
function truncateText(value: string, maxLength: number): string { function truncateText(value: string, maxLength: number): string {
return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`; return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`;
} }
+38 -139
View File
@@ -18,13 +18,10 @@
* 4. onToolEnd(name, id, input, result?, error?) — complete a tool step * 4. onToolEnd(name, id, input, result?, error?) — complete a tool step
* 5. finish(finalText) — flush + transition to complete card * 5. finish(finalText) — flush + transition to complete card
* 6. fail(errorText) — flush + transition to error card * 6. fail(errorText) — flush + transition to error card
*
* On finish, markdown image references (`![](url|path)`) are downloaded /
* read, uploaded to Feishu as message images, and embedded as native card
* `img` elements so external URLs never hit Feishu content-security checks.
*/ */
import type { FeishuRuntime, SendMessageOptions } from "../client.js"; import type { FeishuRuntime, SendMessageOptions } from "../client.js";
import { sendCard, patchCard, sendText, sendLongText } from "../client.js"; import { sendCard, patchCard, sendText } from "../client.js";
import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "../textStream.js"; import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "../textStream.js";
import { import {
startToolUseTraceRun, startToolUseTraceRun,
@@ -34,12 +31,6 @@ import {
getToolUseTraceSteps, getToolUseTraceSteps,
} from "./trace-store.js"; } from "./trace-store.js";
import { buildAgentCard, type CardPhase } from "./builder.js"; import { buildAgentCard, type CardPhase } from "./builder.js";
import {
type CardContentSegment,
maskMarkdownImagesForStreaming,
materializeAnswerSegments,
sendImageMessage,
} from "../outboundImages.js";
export interface StreamingCardSink { export interface StreamingCardSink {
readonly create: (card: Record<string, unknown>) => Promise<string | null>; readonly create: (card: Record<string, unknown>) => Promise<string | null>;
@@ -53,11 +44,6 @@ export interface StreamingCardOptions {
readonly sendOptions?: SendMessageOptions | undefined; readonly sendOptions?: SendMessageOptions | undefined;
readonly patchIntervalMs: number | undefined; readonly patchIntervalMs: number | undefined;
readonly maxMessageLength: number | undefined; readonly maxMessageLength: number | undefined;
/** Project workspace root; required to resolve local image paths. */
readonly workspaceRoot?: string | undefined;
/** Project workspace directory; required to resolve local image paths. */
readonly workspaceDir?: string | undefined;
readonly maxImageBytes?: number | undefined;
} }
const DEFAULT_PATCH_INTERVAL_MS = 400; const DEFAULT_PATCH_INTERVAL_MS = 400;
@@ -79,9 +65,6 @@ export class StreamingAgentCard {
private readonly sendOptions: SendMessageOptions | undefined; private readonly sendOptions: SendMessageOptions | undefined;
private readonly patchIntervalMs: number; private readonly patchIntervalMs: number;
private readonly maxMessageLength: number; private readonly maxMessageLength: number;
private readonly workspaceRoot: string | undefined;
private readonly workspaceDir: string | undefined;
private readonly maxImageBytes: number | undefined;
constructor(options: StreamingCardOptions) { constructor(options: StreamingCardOptions) {
this.runId = options.runId; this.runId = options.runId;
@@ -90,9 +73,6 @@ export class StreamingAgentCard {
this.sendOptions = options.sendOptions; this.sendOptions = options.sendOptions;
this.patchIntervalMs = options.patchIntervalMs ?? DEFAULT_PATCH_INTERVAL_MS; this.patchIntervalMs = options.patchIntervalMs ?? DEFAULT_PATCH_INTERVAL_MS;
this.maxMessageLength = options.maxMessageLength ?? DEFAULT_MAX_MESSAGE_LENGTH; this.maxMessageLength = options.maxMessageLength ?? DEFAULT_MAX_MESSAGE_LENGTH;
this.workspaceRoot = options.workspaceRoot;
this.workspaceDir = options.workspaceDir;
this.maxImageBytes = options.maxImageBytes;
startToolUseTraceRun(this.runId); startToolUseTraceRun(this.runId);
} }
@@ -126,43 +106,23 @@ export class StreamingAgentCard {
recordToolUseEnd({ runId: this.runId, ...params }); recordToolUseEnd({ runId: this.runId, ...params });
this.scheduleFlush(); this.scheduleFlush();
} }
async finish(fallbackText: string, options: { readonly interrupted?: boolean; readonly footerText?: string | undefined } = {}): Promise<void> {
async finish(
fallbackText: string,
options: { readonly interrupted?: boolean; readonly footerText?: string | undefined } = {},
): Promise<void> {
await this.flushChain; await this.flushChain;
this.interrupted = options.interrupted === true; this.interrupted = options.interrupted === true;
const footerText = options.footerText ?? ""; const footerText = options.footerText ?? "";
const fallbackWithFooter = appendFooter(fallbackText, footerText); const fallbackWithFooter = appendFooter(fallbackText, footerText);
try { try {
let answerText =
this.text.length > 0 ? appendFooter(this.text, footerText) : fallbackWithFooter;
this.text = answerText;
const { segments, unresolved } = await materializeAnswerSegments(answerText, {
rt: this.rt,
workspaceRoot: this.workspaceRoot,
workspaceDir: this.workspaceDir,
maxImageBytes: this.maxImageBytes,
});
if (unresolved.length > 0) {
this.rt.logger.warn(
{ runId: this.runId, unresolvedCount: unresolved.length, unresolved: unresolved.slice(0, 5) },
"some answer images could not be uploaded to Feishu",
);
}
let updated = true; let updated = true;
if (answerText.length > 0 || segments.length > 0) { if (this.text.length > 0) {
updated = await this.flushCard("complete", answerText, false, segments); this.text = appendFooter(this.text, footerText);
updated = await this.flushCard("complete", this.text);
} else if (this.currentMessageId === null && fallbackWithFooter.length > 0) {
// No streaming text was sent. If we never created a card, send one now.
this.text = fallbackWithFooter;
updated = await this.flushCard("complete", this.text);
} else if (this.currentMessageId !== null) { } else if (this.currentMessageId !== null) {
updated = await this.flushCard("complete", "", false, []); // Patch the existing card with the final text.
} updated = await this.flushCard("complete", fallbackWithFooter);
if (!updated) {
// Card path failed (e.g. residual content policy). Deliver text + standalone images.
updated = await this.deliverPlainFallback(segments, answerText);
} }
if (!updated && this.interrupted) { if (!updated && this.interrupted) {
await sendText(this.rt, this.chatId, "\u5DF2\u4E2D\u65AD\u5F53\u524D\u8FD0\u884C\u3002", this.sendOptions); await sendText(this.rt, this.chatId, "\u5DF2\u4E2D\u65AD\u5F53\u524D\u8FD0\u884C\u3002", this.sendOptions);
@@ -206,30 +166,15 @@ export class StreamingAgentCard {
return this.flushCard(this.currentPhase(), this.text); return this.flushCard(this.currentPhase(), this.text);
} }
private async flushCard( private async flushCard(phase: CardPhase, text: string, isError = false): Promise<boolean> {
phase: CardPhase, const chunks = splitAtBoundary(text, this.maxMessageLength);
text: string, const firstChunk = chunks[0];
isError = false, if (firstChunk === undefined) return true;
contentSegments?: readonly CardContentSegment[],
): Promise<boolean> {
// During live streaming, strip image URLs so Feishu never fetches remote
// ranks mid-run. Materialized segments are only used on the complete pass.
const displayText =
phase === "complete" && contentSegments !== undefined
? text
: maskMarkdownImagesForStreaming(text);
const chunks = splitAtBoundary(displayText, this.maxMessageLength);
const firstChunk = chunks[0] ?? "";
// When we have segments (complete+images), keep first-card complete content
// on segments only; overflow text (rare) falls back to plain chunked cards.
const toolUseSteps = getToolUseTraceSteps(this.runId); const toolUseSteps = getToolUseTraceSteps(this.runId);
const card = buildAgentCard({ const card = buildAgentCard({
phase, phase,
text: contentSegments !== undefined && contentSegments.length > 0 ? "" : firstChunk, text: firstChunk,
contentSegments: contentSegments !== undefined && contentSegments.length > 0
? contentSegments
: undefined,
reasoningText: this.reasoningText || undefined, reasoningText: this.reasoningText || undefined,
toolUseSteps, toolUseSteps,
toolUseElapsedMs: this.toolUseElapsedMs, toolUseElapsedMs: this.toolUseElapsedMs,
@@ -241,30 +186,7 @@ export class StreamingAgentCard {
if (this.currentMessageId === null) { if (this.currentMessageId === null) {
this.currentMessageId = await sendCard(this.rt, this.chatId, card, this.sendOptions); this.currentMessageId = await sendCard(this.rt, this.chatId, card, this.sendOptions);
let updated = this.currentMessageId !== null; let updated = this.currentMessageId !== null;
// Send overflow chunks as new messages (rare for agent output). Segments // Send overflow chunks as new messages (rare for agent output)
// already include the whole answer; only plain text overflows.
if (contentSegments === undefined || contentSegments.length === 0) {
for (const chunk of chunks.slice(1)) {
const overflowCard = buildAgentCard({
phase,
text: chunk,
reasoningText: undefined,
toolUseSteps: [],
toolUseElapsedMs: undefined,
isError,
interrupted: this.interrupted,
runId: undefined,
});
const overflowMessageId = await sendCard(this.rt, this.chatId, overflowCard, this.sendOptions);
updated = updated && overflowMessageId !== null;
this.currentMessageId = overflowMessageId;
}
}
return updated;
}
let updated = await patchCard(this.rt, this.currentMessageId, card);
if (contentSegments === undefined || contentSegments.length === 0) {
for (const chunk of chunks.slice(1)) { for (const chunk of chunks.slice(1)) {
const overflowCard = buildAgentCard({ const overflowCard = buildAgentCard({
phase, phase,
@@ -280,50 +202,27 @@ export class StreamingAgentCard {
updated = updated && overflowMessageId !== null; updated = updated && overflowMessageId !== null;
this.currentMessageId = overflowMessageId; this.currentMessageId = overflowMessageId;
} }
} return updated;
return updated; } else {
} let updated = await patchCard(this.rt, this.currentMessageId, card);
// For overflow, create new messages
private async deliverPlainFallback( for (const chunk of chunks.slice(1)) {
segments: readonly CardContentSegment[], const overflowCard = buildAgentCard({
answerText: string, phase,
): Promise<boolean> { text: chunk,
const textParts: string[] = []; reasoningText: undefined,
const imageKeys: string[] = []; toolUseSteps: [],
if (segments.length > 0) { toolUseElapsedMs: undefined,
for (const segment of segments) { isError,
if (segment.type === "markdown") { interrupted: this.interrupted,
if (segment.content.trim() !== "") textParts.push(segment.content); runId: undefined,
} else { });
imageKeys.push(segment.imgKey); const overflowMessageId = await sendCard(this.rt, this.chatId, overflowCard, this.sendOptions);
} updated = updated && overflowMessageId !== null;
this.currentMessageId = overflowMessageId;
} }
} else if (answerText.trim() !== "") { return updated;
textParts.push(maskMarkdownImagesForStreaming(answerText));
} }
let any = false;
if (textParts.length > 0) {
const messageId = await sendLongText(
this.rt,
this.chatId,
textParts.join("\n\n"),
this.sendOptions,
);
any = messageId !== null;
}
for (const imageKey of imageKeys) {
try {
const messageId = await sendImageMessage(this.rt, this.chatId, imageKey, this.sendOptions);
any = any || messageId !== null;
} catch (error) {
this.rt.logger.warn(
{ runId: this.runId, err: error instanceof Error ? error.message : String(error) },
"standalone image fallback failed",
);
}
}
return any;
} }
private currentPhase(): CardPhase { private currentPhase(): CardPhase {
@@ -336,5 +235,5 @@ export class StreamingAgentCard {
function appendFooter(text: string, footerText: string): string { function appendFooter(text: string, footerText: string): string {
if (footerText === "") return text; if (footerText === "") return text;
if (text === "") return footerText; if (text === "") return footerText;
return `${text}\n\n${footerText}`; return `${text.trimEnd()}\n\n${footerText}`;
} }
+2 -3
View File
@@ -316,8 +316,7 @@ export async function sendCard(
{ msgType: "interactive", content: JSON.stringify(card) }, { msgType: "interactive", content: JSON.stringify(card) },
options, options,
); );
} catch (e) { } catch {
rt.logger.warn({ chatId, err: errorText(e) }, "sendCard failed");
return null; return null;
} }
} }
@@ -335,7 +334,7 @@ export async function patchCard(rt: FeishuRuntime, messageId: string, card: Reco
}); });
return true; return true;
} catch (e) { } catch (e) {
rt.logger.warn({ messageId, err: errorText(e) }, "patchCard failed"); rt.logger.warn({ messageId, err: e instanceof Error ? e.message : String(e) }, "patchCard failed");
return false; return false;
} }
} }
-389
View File
@@ -1,389 +0,0 @@
/**
* Resolve markdown image references in agent answers into Feishu-hosted
* image_keys so cards can embed them without remote URLs (which trip Feishu
* content-security controls).
*/
import { isIP } from "node:net";
import type { FeishuRuntime } from "./client.js";
import { withRetry } from "./client.js";
import {
WorkspaceFileBoundaryError,
readWorkspaceFileNoFollow,
} from "../security/workspaceFiles.js";
export const FEISHU_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
export const DEFAULT_MAX_OUTBOUND_IMAGES = 10;
const IMAGE_MARKDOWN_RE = /!\[([^\]\n]*)\]\(([^)\n]+)\)/g;
const FENCED_CODE_RE = /```[\s\S]*?```/g;
export type CardContentSegment =
| { readonly type: "markdown"; readonly content: string }
| { readonly type: "image"; readonly imgKey: string; readonly alt: string };
export interface MarkdownImageRef {
readonly fullMatch: string;
readonly alt: string;
readonly src: string;
readonly index: number;
readonly length: number;
}
export interface OutboundImageContext {
readonly rt: FeishuRuntime;
readonly workspaceRoot?: string | undefined;
readonly workspaceDir?: string | undefined;
readonly maxImageBytes?: number | undefined;
readonly maxImages?: number | undefined;
readonly fetchImpl?: typeof fetch | undefined;
}
type ImageCreateResponse = {
image_key?: string;
data?: { image_key?: string };
} | null;
type MessageCreateResponse = {
message_id?: string;
data?: { message_id?: string };
} | null;
/** Streaming-safe view: drop markdown image URLs so partial cards do not hit Feishu URL checks. */
export function maskMarkdownImagesForStreaming(text: string): string {
return rewriteMarkdownImagesOutsideCode(text, (ref) => {
const alt = ref.alt.trim();
return alt === "" ? "\u3010\u56fe\u7247\u3011" : alt;
});
}
export function findMarkdownImagesOutsideCode(text: string): MarkdownImageRef[] {
const blocked = blockedRanges(text);
const refs: MarkdownImageRef[] = [];
IMAGE_MARKDOWN_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = IMAGE_MARKDOWN_RE.exec(text)) !== null) {
const index = match.index;
if (blocked.some((range) => index >= range.start && index < range.end)) continue;
const fullMatch = match[0];
const alt = match[1] ?? "";
const rawSrc = (match[2] ?? "").trim();
const src = stripUrlTitle(rawSrc);
if (src === "") continue;
refs.push({ fullMatch, alt, src, index, length: fullMatch.length });
}
return refs;
}
/**
* Upload reachable markdown images and split the answer into card segments
* (markdown + Feishu img elements). Unresolved images become visible alt text.
*/
export async function materializeAnswerSegments(
text: string,
ctx: OutboundImageContext,
): Promise<{ segments: CardContentSegment[]; unresolved: string[] }> {
const refs = findMarkdownImagesOutsideCode(text);
if (refs.length === 0) {
return {
segments: text === "" ? [] : [{ type: "markdown", content: text }],
unresolved: [],
};
}
const maxImages = ctx.maxImages ?? DEFAULT_MAX_OUTBOUND_IMAGES;
const maxBytes = ctx.maxImageBytes ?? FEISHU_MAX_IMAGE_BYTES;
const keyBySrc = new Map<string, string>();
const unresolved: string[] = [];
const uniqueSrcs: string[] = [];
for (const ref of refs) {
if (!uniqueSrcs.includes(ref.src)) uniqueSrcs.push(ref.src);
}
for (const src of uniqueSrcs.slice(0, maxImages)) {
try {
const bytes = await resolveOutboundImageBytes(src, ctx, maxBytes);
if (bytes === null) {
unresolved.push(src);
continue;
}
const imageKey = await uploadMessageImage(ctx.rt, bytes);
keyBySrc.set(src, imageKey);
} catch (error) {
ctx.rt.logger.warn(
{ src, err: error instanceof Error ? error.message : String(error) },
"outbound image materialize failed",
);
unresolved.push(src);
}
}
for (const src of uniqueSrcs.slice(maxImages)) {
unresolved.push(src);
}
const segments: CardContentSegment[] = [];
let cursor = 0;
for (const ref of refs) {
if (ref.index > cursor) {
pushMarkdown(segments, text.slice(cursor, ref.index));
}
const imageKey = keyBySrc.get(ref.src);
if (imageKey !== undefined) {
segments.push({
type: "image",
imgKey: imageKey,
alt: ref.alt.trim() === "" ? "\u56fe\u7247" : ref.alt.trim(),
});
} else {
const alt = ref.alt.trim();
pushMarkdown(segments, alt === "" ? "\u3010\u56fe\u7247\u3011" : alt);
}
cursor = ref.index + ref.length;
}
if (cursor < text.length) {
pushMarkdown(segments, text.slice(cursor));
}
return { segments, unresolved };
}
export async function uploadMessageImage(rt: FeishuRuntime, image: Buffer): Promise<string> {
if (image.byteLength === 0) {
throw new Error("image is empty");
}
if (image.byteLength > FEISHU_MAX_IMAGE_BYTES) {
throw new Error(`image exceeds Feishu limit of ${FEISHU_MAX_IMAGE_BYTES} bytes`);
}
const client = rt.client as unknown as {
im: { v1: { image: { create: (p: unknown) => Promise<ImageCreateResponse> } } };
};
const res = await withRetry(async () =>
client.im.v1.image.create({
data: { image_type: "message", image },
}),
);
const imageKey = res?.image_key ?? res?.data?.image_key;
if (imageKey === undefined || imageKey === "") {
throw new Error("Feishu image upload response is missing image_key");
}
return imageKey;
}
/** Send a standalone image message (fallback if card embed is unavailable). */
export async function sendImageMessage(
rt: FeishuRuntime,
chatId: string,
imageKey: string,
options?: { readonly replyToMessageId?: string | undefined },
): Promise<string | null> {
const client = rt.client as unknown as {
im: {
v1: {
message: {
create: (p: unknown) => Promise<MessageCreateResponse>;
reply: (p: unknown) => Promise<MessageCreateResponse>;
};
};
};
};
const replyTo = options?.replyToMessageId;
if (replyTo !== undefined && replyTo !== "") {
const res = await client.im.v1.message.reply({
path: { message_id: replyTo },
data: { msg_type: "image", content: JSON.stringify({ image_key: imageKey }) },
});
return res?.data?.message_id ?? res?.message_id ?? null;
}
const res = await client.im.v1.message.create({
params: { receive_id_type: "chat_id" },
data: {
receive_id: chatId,
msg_type: "image",
content: JSON.stringify({ image_key: imageKey }),
},
});
return res?.data?.message_id ?? res?.message_id ?? null;
}
export async function resolveOutboundImageBytes(
src: string,
ctx: OutboundImageContext,
maxBytes: number,
): Promise<Buffer | null> {
if (isRemoteUrl(src)) {
return fetchRemoteImage(src, ctx.fetchImpl ?? fetch, maxBytes);
}
const root = ctx.workspaceRoot?.trim();
const dir = ctx.workspaceDir?.trim();
if (root === undefined || root === "" || dir === undefined || dir === "") {
return null;
}
try {
const file = await readWorkspaceFileNoFollow(root, dir, src, maxBytes);
return file.data;
} catch (error) {
if (error instanceof WorkspaceFileBoundaryError && error.reason === "not_found") {
return null;
}
throw error;
}
}
function rewriteMarkdownImagesOutsideCode(
text: string,
replace: (ref: MarkdownImageRef) => string,
): string {
const refs = findMarkdownImagesOutsideCode(text);
if (refs.length === 0) return text;
let out = "";
let cursor = 0;
for (const ref of refs) {
out += text.slice(cursor, ref.index);
out += replace(ref);
cursor = ref.index + ref.length;
}
out += text.slice(cursor);
return out;
}
function pushMarkdown(segments: CardContentSegment[], content: string): void {
if (content === "") return;
const last = segments[segments.length - 1];
if (last !== undefined && last.type === "markdown") {
segments[segments.length - 1] = { type: "markdown", content: last.content + content };
return;
}
segments.push({ type: "markdown", content });
}
function blockedRanges(text: string): Array<{ start: number; end: number }> {
const ranges: Array<{ start: number; end: number }> = [];
FENCED_CODE_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = FENCED_CODE_RE.exec(text)) !== null) {
ranges.push({ start: match.index, end: match.index + match[0].length });
}
return ranges;
}
function stripUrlTitle(raw: string): string {
const trimmed = raw.trim();
// Markdown optional title: url "title" or url 'title'
const titled = /^(\S+)\s+(".*"|'.*')$/.exec(trimmed);
return (titled?.[1] ?? trimmed).trim();
}
function isRemoteUrl(src: string): boolean {
try {
const url = new URL(src);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}
async function fetchRemoteImage(
src: string,
fetchImpl: typeof fetch,
maxBytes: number,
): Promise<Buffer | null> {
let url: URL;
try {
url = new URL(src);
} catch {
return null;
}
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
if (!isPublicHttpHost(url.hostname)) return null;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15_000);
try {
const response = await fetchImpl(url, {
method: "GET",
redirect: "manual",
signal: controller.signal,
headers: { accept: "image/*,*/*;q=0.8" },
});
// One safe redirect hop to another public http(s) host.
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get("location");
if (location === null || location === "") return null;
let redirected: URL;
try {
redirected = new URL(location, url);
} catch {
return null;
}
if (redirected.protocol !== "http:" && redirected.protocol !== "https:") return null;
if (!isPublicHttpHost(redirected.hostname)) return null;
const second = await fetchImpl(redirected, {
method: "GET",
redirect: "manual",
signal: controller.signal,
headers: { accept: "image/*,*/*;q=0.8" },
});
return readImageBody(second, maxBytes);
}
return readImageBody(response, maxBytes);
} finally {
clearTimeout(timer);
}
}
async function readImageBody(response: Response, maxBytes: number): Promise<Buffer | null> {
if (!response.ok) return null;
const contentType = (response.headers.get("content-type") ?? "").toLowerCase();
if (
contentType !== "" &&
!contentType.startsWith("image/") &&
!contentType.includes("octet-stream") &&
(contentType.startsWith("text/") || contentType.includes("json") || contentType.includes("html"))
) {
return null;
}
const contentLength = Number(response.headers.get("content-length") ?? "NaN");
if (Number.isFinite(contentLength) && contentLength > maxBytes) return null;
const buf = Buffer.from(await response.arrayBuffer());
if (buf.byteLength === 0 || buf.byteLength > maxBytes) return null;
return buf;
}
function isPublicHttpHost(hostname: string): boolean {
const host = hostname.trim().toLowerCase().replace(/\.$/, "");
if (host === "" || host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) {
return false;
}
if (host === "0.0.0.0" || host === "::" || host === "[::]" || host === "::1" || host === "[::1]") {
return false;
}
const unbracketed = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
const ipVersion = isIP(unbracketed);
if (ipVersion === 4) return !isPrivateIPv4(unbracketed);
if (ipVersion === 6) return !isPrivateIPv6(unbracketed);
return true;
}
function isPrivateIPv4(ip: string): boolean {
const parts = ip.split(".").map((part) => Number(part));
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return true;
}
const a = parts[0]!;
const b = parts[1]!;
if (a === 10 || a === 127 || a === 0) return true;
if (a === 169 && b === 254) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
if (a >= 224) return true; // multicast / reserved
return false;
}
function isPrivateIPv6(ip: string): boolean {
const normalized = ip.toLowerCase();
if (normalized === "::1") return true;
if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true; // unique local
if (normalized.startsWith("fe80:")) return true; // link-local
const mapped = /^:ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(normalized);
if (mapped?.[1] !== undefined) return isPrivateIPv4(mapped[1]);
return false;
}
-3
View File
@@ -496,9 +496,6 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
sendOptions, sendOptions,
patchIntervalMs: undefined, patchIntervalMs: undefined,
maxMessageLength: undefined, maxMessageLength: undefined,
workspaceRoot: projectWorkspaceRoot,
workspaceDir: project.workspaceDir,
maxImageBytes: deps.resourceLimits?.maxBytesPerFile,
}); });
const fileDeliveryMcpServer = createFileDeliveryMcpServer({ const fileDeliveryMcpServer = createFileDeliveryMcpServer({
rt, rt,
-9
View File
@@ -1,6 +1,5 @@
import Fastify from "fastify"; import Fastify from "fastify";
import { registerAdminPlugin } from "./admin/plugin.js"; import { registerAdminPlugin } from "./admin/plugin.js";
import { registerDatabasePlugin } from "./database/plugin.js";
import { prisma } from "./db.js"; import { prisma } from "./db.js";
import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js"; import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
import { archiveFeishuBindingForLifecycleEvent } from "./feishu/bindingLifecycle.js"; import { archiveFeishuBindingForLifecycleEvent } from "./feishu/bindingLifecycle.js";
@@ -144,14 +143,6 @@ export async function startHub(): Promise<void> {
secretEnvelope, secretEnvelope,
}); });
// `/database/*` surface. Registered after the admin plugin so the
// @fastify/cookie parser and /auth/* login routes are already available.
await registerDatabasePlugin(app, {
prisma,
sessionSecret,
siloOrganizationSlug: siloOrganization.slug,
});
const feishuListenerEnabled = booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true); const feishuListenerEnabled = booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true);
if (feishuListenerEnabled) { if (feishuListenerEnabled) {
const feishuConfig = { const feishuConfig = {
+3 -3
View File
@@ -1,9 +1,9 @@
/** /**
* Org capacity policy service (ADR-0022). * Org capacity policy service (ADR-0022 / spec `Spec.System.Capacity`).
* *
* Stores per-Organization lower `organizationLimit` overrides per * Stores per-Organization lower `organizationLimit` overrides per
* `CapacityDimension`. Enforces the layered-limit invariant: a set limit must be ≤ the * `CapacityDimension`. Enforces `LayeredLimit.Valid`: a set limit must be ≤ the
* platform ceiling for that dimension. The effective limit (min of the two) * platform ceiling for that dimension. `LayeredLimit.effective` (min of the two)
* is the value capacity admission should use; dimensions with no org override * is the value capacity admission should use; dimensions with no org override
* fall back to the platform ceiling. * fall back to the platform ceiling.
*/ */
+1 -1
View File
@@ -1,7 +1,7 @@
/** /**
* Organization membership management for org admin (ADR-0021). * Organization membership management for org admin (ADR-0021).
* *
* Role rules (product pin, not yet recorded in an ADR): * Role rules (product pin, not yet in Lean):
* 1. Actor must be OWNER or ADMIN (enforced at HTTP layer). * 1. Actor must be OWNER or ADMIN (enforced at HTTP layer).
* 2. Only OWNER can grant/revoke OWNER or modify another OWNER. * 2. Only OWNER can grant/revoke OWNER or modify another OWNER.
* 3. Cannot revoke or demote the last remaining OWNER. * 3. Cannot revoke or demote the last remaining OWNER.
-20
View File
@@ -558,26 +558,6 @@ async function ensureInboxFolder(prisma: Prisma.TransactionClient, organizationI
select: { id: true }, select: { id: true },
}); });
if (existing !== null) return existing; 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({ return prisma.folder.create({
data: { organizationId, name: "Inbox", kind: "SYSTEM_INBOX", sortKey: "000000" }, data: { organizationId, name: "Inbox", kind: "SYSTEM_INBOX", sortKey: "000000" },
select: { id: true }, select: { id: true },
@@ -118,6 +118,105 @@ describe("Organization Agent configuration management", () => {
})).rejects.toThrow("must have exactly one active default Agent role"); })).rejects.toThrow("must have exactly one active default Agent role");
}); });
it("groups roles and skills in the shared folder tree (ADR-0028)", async () => {
const teaching = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "教学" });
const lessonPrep = await configuration.createFolder({
organizationId: DEFAULT_ORG_ID,
name: "备课",
parentId: teaching.id,
});
expect(lessonPrep.parentId).toBe(teaching.id);
const listed = await configuration.listFolders({ organizationId: DEFAULT_ORG_ID });
expect(listed.map((folder) => folder.id).sort()).toEqual([teaching.id, lessonPrep.id].sort());
const typst = await makeSkill(root, "typst");
await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: typst, version: "1" });
await configuration.setSkillFolder({ organizationId: DEFAULT_ORG_ID, name: "typst", folderId: lessonPrep.id });
await configuration.setRoleFolder({ organizationId: DEFAULT_ORG_ID, roleId: "draft", folderId: teaching.id });
const skills = await configuration.listSkills({ organizationId: DEFAULT_ORG_ID });
expect(skills.find((skill) => skill.name === "typst")?.folderId).toBe(lessonPrep.id);
const roles = await configuration.listRoles({ organizationId: DEFAULT_ORG_ID });
expect(roles.find((role) => role.roleId === "draft")?.folderId).toBe(teaching.id);
const renamed = await configuration.updateFolder({
organizationId: DEFAULT_ORG_ID,
folderId: teaching.id,
name: "教研",
});
expect(renamed.name).toBe("教研");
});
it("moves folders within the tree and rejects moves below a descendant", async () => {
const a = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "a" });
const b = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "b", parentId: a.id });
const c = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "c" });
const moved = await configuration.updateFolder({ organizationId: DEFAULT_ORG_ID, folderId: c.id, parentId: b.id });
expect(moved.parentId).toBe(b.id);
await expect(configuration.updateFolder({ organizationId: DEFAULT_ORG_ID, folderId: a.id, parentId: c.id }))
.rejects.toThrow("folder cannot be moved below its descendant");
await expect(configuration.updateFolder({ organizationId: DEFAULT_ORG_ID, folderId: a.id, parentId: a.id }))
.rejects.toThrow("folder cannot be its own parent");
});
it("deletes only empty folders", async () => {
const folder = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "非空" });
const typst = await makeSkill(root, "typst");
await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: typst, version: "1" });
await configuration.setSkillFolder({ organizationId: DEFAULT_ORG_ID, name: "typst", folderId: folder.id });
await expect(configuration.deleteFolder({ organizationId: DEFAULT_ORG_ID, folderId: folder.id }))
.rejects.toThrow("still has");
const parent = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "父" });
await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "子", parentId: parent.id });
await expect(configuration.deleteFolder({ organizationId: DEFAULT_ORG_ID, folderId: parent.id }))
.rejects.toThrow("child folder");
await configuration.setSkillFolder({ organizationId: DEFAULT_ORG_ID, name: "typst", folderId: null });
await configuration.deleteFolder({ organizationId: DEFAULT_ORG_ID, folderId: folder.id });
const remaining = await configuration.listFolders({ organizationId: DEFAULT_ORG_ID });
expect(remaining.map((f) => f.id)).not.toContain(folder.id);
const skill = (await configuration.listSkills({ organizationId: DEFAULT_ORG_ID })).find((s) => s.name === "typst");
expect(skill?.folderId).toBeNull();
});
it("rejects cross-Organization folder assignment", async () => {
await seedTestOrganization("org_other", "other");
const otherFolder = await configuration.createFolder({ organizationId: "org_other", name: "外部" });
const typst = await makeSkill(root, "typst");
await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: typst, version: "1" });
await expect(configuration.setSkillFolder({
organizationId: DEFAULT_ORG_ID,
name: "typst",
folderId: otherFolder.id,
})).rejects.toThrow("folder not found in organization");
await expect(configuration.setRoleFolder({
organizationId: DEFAULT_ORG_ID,
roleId: "draft",
folderId: otherFolder.id,
})).rejects.toThrow("folder not found in organization");
});
it("treats folder assignment as a label-class change (no session archival)", async () => {
await prisma.project.create({
data: { id: "project-a", organizationId: DEFAULT_ORG_ID, name: "A", workspaceDir: "/tmp/a" },
});
await prisma.agentSession.create({
data: {
id: "session-folder-assignment",
projectId: "project-a",
provider: "openrouter",
roleId: "draft",
model: "anthropic/claude-sonnet-5",
metadata: {},
},
});
const folder = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "分组" });
await configuration.setRoleFolder({ organizationId: DEFAULT_ORG_ID, roleId: "draft", folderId: folder.id });
await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: "session-folder-assignment" } }))
.resolves.toMatchObject({ archivedAt: null });
});
async function makeSkill(parent: string, name: string): Promise<string> { async function makeSkill(parent: string, name: string): Promise<string> {
const source = join(parent, "sources", name); const source = join(parent, "sources", name);
await mkdir(source, { recursive: true }); await mkdir(source, { recursive: true });
@@ -101,43 +101,6 @@ 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 () => { it("blocks ordinary Feishu project creation when the org setting is off", async () => {
await seedUser("u-member", "ou_member", "MEMBER"); await seedUser("u-member", "ou_member", "MEMBER");
await setMembersCanCreateProjects(prisma, { await setMembersCanCreateProjects(prisma, {
@@ -62,11 +62,6 @@ describe("Alpha Silo bootstrap", () => {
{ roleId: "draft", label: "草稿", isDefault: true }, { roleId: "draft", label: "草稿", isDefault: true },
{ roleId: "review", label: "审校", isDefault: false }, { 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({ const persisted = JSON.stringify({
feishu: await prisma.feishuApplicationCredentialVersion.findMany(), feishu: await prisma.feishuApplicationCredentialVersion.findMany(),
@@ -1,163 +0,0 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { FeishuRuntime } from "../../src/feishu/client.js";
import {
findMarkdownImagesOutsideCode,
maskMarkdownImagesForStreaming,
materializeAnswerSegments,
uploadMessageImage,
} from "../../src/feishu/outboundImages.js";
import { buildAgentCard } from "../../src/feishu/card/builder.js";
const temps: string[] = [];
afterEach(async () => {
await Promise.all(temps.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
describe("outbound markdown image parsing", () => {
it("finds image refs outside fenced code blocks", () => {
const text = [
"See diagram:",
"![plot](https://cdn.example.com/a.png)",
"",
"```md",
"![not-this](https://cdn.example.com/b.png)",
"```",
"![local](assets/x.png \"title\")",
].join("\n");
const refs = findMarkdownImagesOutsideCode(text);
expect(refs.map((ref) => ref.src)).toEqual([
"https://cdn.example.com/a.png",
"assets/x.png",
]);
});
it("masks image urls for streaming cards", () => {
expect(maskMarkdownImagesForStreaming("before ![alt text](https://x/y.png) after")).toBe(
"before alt text after",
);
expect(maskMarkdownImagesForStreaming("![](https://x/y.png)")).toBe("【图片】");
});
});
describe("materializeAnswerSegments", () => {
it("uploads remote and workspace images and builds card segments", async () => {
const workspaceRoot = await mkdtemp(join(tmpdir(), "cph-img-root-"));
temps.push(workspaceRoot);
const workspaceDir = join(workspaceRoot, "project");
await mkdir(join(workspaceDir, "assets"), { recursive: true });
await writeFile(
join(workspaceDir, "assets", "local.png"),
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
);
const imageCreate = vi
.fn()
.mockResolvedValueOnce({ image_key: "img_remote_1" })
.mockResolvedValueOnce({ image_key: "img_local_1" });
const rt = mockRuntime({ imageCreate });
const fetchImpl = vi.fn(async () =>
new Response(Buffer.from("remote-bytes"), {
status: 200,
headers: { "content-type": "image/png" },
}),
);
const text = "Intro\n\n![remote](https://cdn.example.com/r.png)\n\nAnd local ![local](assets/local.png)\nDone.";
const { segments, unresolved } = await materializeAnswerSegments(text, {
rt,
workspaceRoot,
workspaceDir,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
expect(unresolved).toEqual([]);
expect(imageCreate).toHaveBeenCalledTimes(2);
expect(segments).toEqual([
{ type: "markdown", content: "Intro\n\n" },
{ type: "image", imgKey: "img_remote_1", alt: "remote" },
{ type: "markdown", content: "\n\nAnd local " },
{ type: "image", imgKey: "img_local_1", alt: "local" },
{ type: "markdown", content: "\nDone." },
]);
const card = buildAgentCard({
phase: "complete",
text: "",
contentSegments: segments,
reasoningText: undefined,
toolUseSteps: [],
toolUseElapsedMs: undefined,
isError: false,
interrupted: false,
runId: undefined,
});
expect(card.elements).toEqual(expect.arrayContaining([
expect.objectContaining({ tag: "img", img_key: "img_remote_1" }),
expect.objectContaining({ tag: "img", img_key: "img_local_1" }),
expect.objectContaining({ tag: "markdown", content: "Intro\n\n" }),
]));
});
it("rejects private remote hosts", async () => {
const imageCreate = vi.fn();
const rt = mockRuntime({ imageCreate });
const fetchImpl = vi.fn();
const { segments, unresolved } = await materializeAnswerSegments(
"![x](http://127.0.0.1/secret.png)",
{ rt, fetchImpl: fetchImpl as unknown as typeof fetch },
);
expect(fetchImpl).not.toHaveBeenCalled();
expect(imageCreate).not.toHaveBeenCalled();
expect(unresolved).toEqual(["http://127.0.0.1/secret.png"]);
expect(segments).toEqual([{ type: "markdown", content: "x" }]);
});
});
describe("uploadMessageImage", () => {
it("returns image_key from Feishu upload", async () => {
const imageCreate = vi.fn(async () => ({ data: { image_key: "img_nested" } }));
const rt = mockRuntime({ imageCreate });
await expect(uploadMessageImage(rt, Buffer.from("png"))).resolves.toBe("img_nested");
expect(imageCreate).toHaveBeenCalledWith({
data: { image_type: "message", image: Buffer.from("png") },
});
});
});
function mockRuntime(options: {
readonly imageCreate?: (payload: unknown) => Promise<unknown>;
}): FeishuRuntime {
return {
client: {
im: {
v1: {
image: {
create: options.imageCreate ?? vi.fn(),
},
message: {
create: vi.fn(),
reply: vi.fn(),
patch: vi.fn(),
},
},
},
} as unknown as FeishuRuntime["client"],
logger: {
warn: vi.fn(),
error: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
child: vi.fn(),
fatal: vi.fn(),
trace: vi.fn(),
silent: vi.fn(),
level: "info",
} as unknown as FeishuRuntime["logger"],
};
}
+1
View File
@@ -0,0 +1 @@
/.lake
+55
View File
@@ -0,0 +1,55 @@
# 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
@@ -0,0 +1,5 @@
-- 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
@@ -0,0 +1,21 @@
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
@@ -0,0 +1,9 @@
import Spec.Courseware.Check.Diagnostic
import Spec.Courseware.Check.Pipeline
/-!
# Courseware.Check —— checker 语义
诊断分类与合法 lesson(`Diagnostic`)、检查管线的阶段与序(`Pipeline`)。决策出处
ADR-0010。
-/
+107
View File
@@ -0,0 +1,107 @@
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
@@ -0,0 +1,54 @@
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
@@ -0,0 +1,8 @@
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
@@ -0,0 +1,23 @@
/-!
# 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
@@ -0,0 +1,93 @@
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
@@ -0,0 +1,13 @@
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
@@ -0,0 +1,24 @@
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
@@ -0,0 +1,58 @@
/-!
# 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
@@ -0,0 +1,16 @@
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
@@ -0,0 +1,34 @@
/-!
# 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
@@ -0,0 +1,44 @@
/-!
# 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
@@ -0,0 +1,9 @@
import Spec.Courseware.Open.QuestionBank
import Spec.Courseware.Open.Course
/-!
# Courseware.Open —— OPEN 骨架(核心关系)
题库与 element 的关系(`QuestionBank`)、课程编排规则(`Course`)。两者均为已 surface
但未决策的 OPEN 分歧点,待专门 ADR 落定。
-/
+10
View File
@@ -0,0 +1,10 @@
/-!
# Course (, OPEN)
ADR-0005:****;course / ****, lesson
****"编排":( )?lesson
? lesson ()? `OPEN`
`Course := List Lesson`(
"扁平有序、无层级") surface: ADR
-/
@@ -0,0 +1,10 @@
/-!
# QuestionBank (, OPEN)
( DAW sample library):,
,lesson ** element **,
"纯引用可能不够"element / lesson / ?
`OPEN` `QuestionRef`
, surface ADR
-/
+70
View File
@@ -0,0 +1,70 @@
/-!
# Prelude System
(runsessionprincipalchatplatform 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 配置 folder 标识(`OPEN` 表示;ADR-0028 透明组织节点,skill/role 共用一棵树;
project explorer `FolderId` ,) -/
AgentConfigFolderId : 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
@@ -0,0 +1,57 @@
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` (PDFMD)(ADR-0027);
org-scoped ADR-0024 model-provider; Run ,
workspace, UsageFactcapability credential Agent
- `Permission` readeditmanage ;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
-/

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