Compare commits

...

269 Commits

Author SHA1 Message Date
ecc92c9a87 docs(adr): cph 的 nested-manifest / batch-export 改号 0036/0037
上游 0029/0030 与本地 filelib 侧同号 ADR 相撞,cph 两份改到本地空闲号段,
代码锚点引用一并跟随。
2026-08-06 00:51:33 +08:00
e878b46701 Merge remote-tracking branch 'educraft/main' into merge/educraft-cph
# Conflicts:
#	.gitignore
#	hub/.env.example
#	hub/deploy/deploy_fleet_release.sh
#	hub/deploy/deploy_platform.sh
#	hub/test/integration/helpers.ts
2026-08-06 00:49:02 +08:00
hongjr03 a426490ae3 Merge pull request 'feat(cli): cph init / cph add 子命令' (#101) from feat/cph-init-add into main
Reviewed-on: EduCraft/curriculum-project-hub#101
2026-08-05 21:36:50 +08:00
hongjr03 5866b3d6cc feat(cli): cph init / cph add 子命令(工程脚手架)
init 生成可 check 的工程根(manifest.toml + .cph-version + 默认 exports/student.typ + 空 kind 目录);
add 按 kind 建 part 目录/element.toml/必填内容文件并追加 [[parts]]。均纯本地,不涉及 hub 语义;
Engine 改惰性构造。cph-schema 新增 required_content_field_names() 作为 add 建文件合同。
2026-08-05 21:22:26 +08:00
hongjr03 7a0b4b0c4f Merge pull request 'feat(cph): implement nested outline manifest and batch/combined export' (#96) from feat/adr-0029-0030-nested-manifest-export into main
Reviewed-on: EduCraft/curriculum-project-hub#96
2026-08-05 18:34:13 +08:00
hongjr03 3d46525f40 fix(cph): correct nested outline hierarchy 2026-08-05 18:34:05 +08:00
hongjr03 dd8fcfd366 chore(cph): track TH-141 outline PDF 2026-08-05 18:34:05 +08:00
hongjr03 fe8a17c6ad feat(cph): add outline export command 2026-08-05 18:34:05 +08:00
hongjr03 9927d38c18 feat(cph): implement nested outline manifest and batch/combined export
ADR-0029 — nested outline manifest, supersedes ADR-0008's flat [[parts]]:
- cph-model: recursive loader over manifest.toml containers / element.toml
  leaves; Lesson.parts (pure elements, DFS order) + Lesson.outline (elements
  interleaved with section headings at their DFS-open position); rejects
  ambiguous/incomplete folders and root-vs-container table misplacement
- cph-diag: new DiagCode::ManifestMalformed for carrier-document structure
  errors (discharges an existing TODO)
- cph-typst: augmented manifest now serializes the outline (element/section
  entries) instead of a flat parts array
- render/lib.typ: render-lesson renders section headings at their depth
- examples/TH-141 migrated to 5 nested section containers + 3 root segments,
  byte-identical element order; smoke-verified via cph check/build + pdftotext

ADR-0030 — batch & combined export, extends ADR-0009/0011:
- cph build with no --target batches every declared target (repeatable
  --target for an explicit subset); any target failure => non-zero exit,
  per-target ledger, independent per-target execution
- cph-model: bundle.toml loader (directory + [info]/[targets.*]/ordered
  lessons with per-lesson target overrides)
- cph-typst: augmented bundle manifest (path-prefixed member outlines),
  Engine::{compile_check_bundle,build_bundle_pdf}
- render/lib.typ: render-bundle assembles member lessons under per-lesson
  headings, depth-shifts their own section headings, resets example/lemma
  counters at each lesson boundary by default
- cph-cli: `cph bundle <path> --target <name>` subcommand, same batching
  contract as `cph build`
- new bundle fixtures/tests (cph-model unit + cph-typst through-template PDF
  compile), smoke-verified via a real 2-lesson merged PDF

Verification: cargo fmt/clippy/test clean across the workspace (68 tests);
real cph check/build/bundle runs against TH-141 and a bundle fixture, PDF
content inspected via pdftotext.
2026-08-05 18:34:05 +08:00
hongjr03 e0bd6120ec docs(adr): nested outline manifest and batch/combined export design
ADR-0029 supersedes ADR-0008's flat [[parts]] with a per-folder
manifest.toml outline tree (root = implicit top container, single
section container kind, DFS pre-order = lesson order).

ADR-0030 extends ADR-0009/0011: cph build batches all targets (any
failure => non-zero), cph bundle assembles ordered lessons into one
SingleFile/FileTree artifact (self-contained lessons, export-time
combination only).
2026-08-05 18:34:05 +08:00
hongjr03 66fc9516d9 Merge pull request 'fix(hub): clear npm audit high gate (deploy-admin CI)' (#98) from fix/hub-audit-production-vulns into main
Reviewed-on: EduCraft/curriculum-project-hub#98
2026-08-05 15:44:12 +08:00
hongjr03 9c5021f8ed fix(hub): bump transitive deps to clear npm audit high gate
npm audit --omit=dev --audit-level=high (audit:production, enforced in the
deploy-admin fleet workflow) was failing on high-severity advisories in
fast-uri (3.1.4) and ip-address (10.2.0), plus moderate hono and
@hono/node-server. All fixes resolve within existing ^ ranges, so a plain
npm audit fix regenerated the lock only (package.json untouched):
fast-uri 3.1.4 -> 3.1.5, ip-address 10.2.0 -> 10.4.0, hono 4.12.28 -> 4.13.0.
audit:production now reports 0 vulnerabilities.
2026-08-05 15:43:50 +08:00
hongjr03 6290b39c63 Merge pull request 'fix(admin-web): sync package-lock so fleet deploy npm ci succeeds' (#97) from fix/admin-web-lockfile-emnapi-sync into main
Reviewed-on: EduCraft/curriculum-project-hub#97
2026-08-05 15:26:27 +08:00
hongjr03 79734b5435 fix(admin-web): sync package-lock so fleet deploy npm ci succeeds
The wasm32-wasi optional native bindings (tailwindcss oxide, rolldown)
declare @emnapi/* ranges that now resolve to 1.11.3 / 1.2.3 on the
registry, but the committed lock still pinned the older .1/.2 patch
versions. npm ci (strict sync) therefore failed with EUSAGE, breaking
the deploy-admin fleet workflow. Regenerated the lock with
npm install --package-lock-only.
2026-08-05 15:25:09 +08:00
hongjr03 5754005a97 Merge pull request 'fix(admin-web): 创建项目后跳转修正为使用 projectId' (#43) from fix/admin-projects-redirect-undefined into main
Reviewed-on: EduCraft/curriculum-project-hub#43
2026-08-04 16:55:04 +08:00
dc271ff9f3 Merge branch 'maoyuanyang-main'
# Conflicts:
#	hub/filelib-web/src/lib/GrantsPanel.svelte
#	hub/filelib-web/src/lib/OverviewPanel.svelte
#	hub/filelib-web/src/lib/types.ts
#	hub/src/database/filelib/grantService.ts
2026-08-03 15:35:44 +08:00
ChickenPige0n 97313aba0f fix(admin-web): redirect to created project using projectId not id 2026-08-01 22:54:34 +08:00
hongjr03 cfe733554a Merge pull request 'feat(admin-web): skill zip import and folder-grouped role picker' (#41) from feat/upload-skill-zip into main
Reviewed-on: EduCraft/curriculum-project-hub#41
2026-07-31 20:25:09 +08:00
ChickenPige0n ff990b4caf feat(admin-web): skill zip import and folder-grouped role picker
- parse skill package zips client-side, mirroring backend ingestion limits (ADR-0018)
- add zip upload flow on skills page and zip replace in SkillEditor
- group role skill bindings by the shared management folder tree (ADR-0028)
- add fflate dependency
2026-07-31 15:41:42 +08:00
ymy 3cfbe55070 Merge branch 'chore/sidebar-240' 2026-07-31 15:06:09 +08:00
ymy 53ef364af2 chore(filelib-web): 老师端左栏宽度对齐管理员后台(240px) 2026-07-31 15:06:08 +08:00
ymy 94dba4e672 Merge branch 'chore/sidebar-wider' 2026-07-31 15:03:00 +08:00
ymy 06aa6f0dfb chore(filelib-web): 左栏加宽168->200px,标题与菜单间加横线 2026-07-31 15:02:59 +08:00
ymy 4fc72541be Merge branch 'chore/app-sidebar-layout' 2026-07-31 14:57:01 +08:00
ymy 672f05c66a chore(filelib-web): 左栏加「教研数据库」标题,用户身份+退出移至栏底,顶栏去掉身份区 2026-07-31 14:57:01 +08:00
ymy e173aa18e0 Merge branch 'chore/grid-icon-size' 2026-07-31 14:51:56 +08:00
ymy e9cecbf071 chore(filelib-web): 放大网格图标(文件夹54->72/项目50->66/文件46->60),列宽118->132 2026-07-31 14:51:55 +08:00
ymy 55f29523a0 Merge branch 'fix/restore-no-suffix' 2026-07-31 14:28:35 +08:00
ymy d9cde19bdf fix(filelib): 恢复保留原名,撞名报清晰错误而非自动加后缀(ADR-0035,supersede ADR-0033)
恢复不再改名;同名兄弟占位时抛 409 name_conflict_on_restore + 人话提示,
操作者自行重命名现有节点或彻底删除旧节点后再恢复。
2026-07-31 14:28:34 +08:00
ymy a463ab48e6 Merge branch 'fix/grid-card-contextmenu' 2026-07-31 14:16:21 +08:00
ymy d39ebed62e fix(filelib-web): 卡片右键补 stopPropagation,不再被背景菜单覆盖(导致看不到删除等节点操作) 2026-07-31 14:16:20 +08:00
ymy 270275c367 Merge branch 'chore/detail-delete-button' 2026-07-31 14:11:27 +08:00
ymy 09338f355a chore(filelib-web): 详情弹窗加删除按钮(MANAGE 专属,进回收站可恢复),删除入口不再只在右键菜单 2026-07-31 14:11:26 +08:00
ymy 8bc5dbf9e2 Merge branch 'feat/purge-follows-manage' 2026-07-31 14:01:47 +08:00
ymy beaa92de2e feat(filelib): 彻底删除改与条目可见性同权(ADR-0034,supersede ADR-0031 仅管理员条款)
能删进回收站(MANAGE)的人就能清空;无关者 404(D8)。二次确认与
node.purge 审计不变;BinView 彻底删除按钮对全部可见条目开放。
2026-07-31 14:01:46 +08:00
ymy b1fd2e8f7b Merge branch 'fix/bin-restore-dedup' 2026-07-31 13:52:24 +08:00
ymy 9e38d1e011 fix(filelib): 恢复撞名不再死锁,自动改名「(已恢复)」(ADR-0033)
- restore 前查活跃兄弟:撞名则恢复为「原名(已恢复[/ N])」(截断计入
  128 长度预算),同事务落审计并记 renamedFrom;API 返回最终名
- BinView toast 提示改名;测试补撞名/二次恢复用例
2026-07-31 13:52:23 +08:00
ymy f99c8ea4d8 Merge branch 'chore/remove-recent-module' 2026-07-31 13:39:19 +08:00
ymy 83a6b012b7 feat(filelib): 移除最近打开模块(ADR-0032,supersede ADR-0031 对应半部)
- FileLibRecentVisit 删表(手写迁移;表当日新建无生产数据)
- recentService/recentRoutes/RecentView 删除;GridLibraryView 埋点与
  navTarget 跳转一并移除;types 清 RecentEntry
- 左栏保留 文件库/回收站(ADR-0031 回收站半部不受影响)
- breadcrumb 的 role 字段保留(独立可用的增量字段)
2026-07-31 13:39:18 +08:00
ymy 475b397ca8 Merge branch 'feat/filelib-nav-rail' 2026-07-31 13:27:04 +08:00
ymy d072e9ec1e feat(filelib): 老师端左栏导航:回收站 + 最近打开(ADR-0031)
- 回收站:listBin(祖先全活跃的已删顶点;管理员/直连 MANAGE 可见)、
  restore(与 D15 对称只清本节点,落审计)、purge(仅管理员,pathIds 枚举
  子树按深度降序分批硬删,绕过 self-FK RESTRICT)
- 最近打开:FileLibRecentVisit 表(filePath='' 兜底 PG 唯一索引),客户端
  成功打开后上报(VIEW 门禁,upsert 刷新),列表 20 条,D8/D15 可见性过滤
- 前端:/app 左栏(文件库/最近打开/回收站);RecentView/BinView;
  GridLibraryView 埋点 + navTarget 跳转(breadcrumb 建栈,role 已捎带)
- 测试:filelib-nav 集成 4 例;全套 79 例绿
2026-07-31 13:27:04 +08:00
ymy 04fa383286 Merge branch 'feat/filelib-grid-browser' 2026-07-31 12:02:24 +08:00
ymy 0fd21e51f7 feat(filelib-web): 老师端文件库改网盘式网格浏览(下钻导航+右键菜单)
- 新 GridLibraryView:双击下钻文件夹/项目,面包屑+返回跳级,项目内文件同网格,
  双击进 FileEditor 预览;工具条按上下文出新建文件夹/项目/文件
- 右键菜单(ContextMenu):打开/新建子文件夹/重命名/授权管理/详情/删除,
  按节点 role 动态显隐;文件菜单:打开预览/下载/删除;空白处右键出新建+刷新
- GridCard 大图标卡片(琥珀文件夹/立方体项目/文档文件);授权与详情复用
  GrantsPanel/OverviewPanel(Modal 加 maxW prop);Icon 补 5 个图标
- 管理后台 /database 保留树状 LibraryView 不动;后端零改动
2026-07-31 12:02:23 +08:00
ymy ab5e03823c chore: 误提交 .omo 会话文件,移出跟踪 2026-07-30 22:32:07 +08:00
ymy 02b46e2dcf Merge branch 'feat/filelib-always-independent' 2026-07-30 22:32:07 +08:00
ymy 834f4c380c chore: 误提交 .omo 会话文件,移出跟踪并加入 .gitignore 2026-07-30 22:31:08 +08:00
ymy 4c68c7db0b Merge branch 'feat/filelib-always-independent' 2026-07-30 22:27:46 +08:00
ymy 39a2be6347 feat(filelib): 项目级授权恒生效,移除独立权限开关(ADR-0030)
- permission.ts: effectiveRole 删除 D11 冻结分支,输入不再含开关字段
- treeService: 停止读 FileLibProjectSettings;建项目不再写默认行
- grantService/routes: 删 setIndependentPermission 与 PUT 路由;节点详情 DTO 去掉 independentPermission
- filelib-web: 概览 tab 移除开关;NodeDetail 类型同步
- 测试: 单测/集成改为断言恒生效语义;ADR-0030 废除契约 D11/P5
- FileLibProjectSettings 表保留(存量行忽略,不再读写),审计词表保留历史读取
2026-07-30 22:27:45 +08:00
hongjr03 6ea8e33148 Merge pull request 'fix(hub): enable SDK auto-compact for resumed sessions' (#40) from fix/sdk-auto-compact into main
Reviewed-on: EduCraft/curriculum-project-hub#40
2026-07-30 17:13:15 +08:00
hongjr03 295f07d111 fix(hub): enable SDK auto-compact for resumed sessions
Sessions resume across runs (ADR-0017). Without auto-compact the SDK jsonl
grows unboundedly — a 7-day / 26-run session hit 31 MB / 1995 lines, making
every API call resend the full history and inflating a trivial "change a
title" task to 22 minutes. Enable autoCompactEnabled in the SDK settings so
the SDK compacts automatically when the context window fills.
2026-07-30 17:12:31 +08:00
hongjr03 e1da845bfc Merge pull request 'feat(hub): org-scoped agent role/skill folder tree (ADR-0028)' (#37) from feat/agent-config-folder-tree into main
Reviewed-on: EduCraft/curriculum-project-hub#37
2026-07-30 16:18:51 +08:00
hongjr03 ace724c609 fix(hub): always send interrupt notice when card finalize fails
If StreamingAgentCard.finish cannot patch the live card, plain-text fallback
can still succeed with partial answer text. Interrupt is terminal — always
emit the explicit 已中断 notice when the card path failed so teachers see the
abort. Harden the integration assertion with waitFor.
2026-07-30 15:02:08 +08:00
hongjr03 ee928b5832 fix(hub): restore project create payload and stabilize integration DB seed
Explorer POST /projects was dropping projectId/folderId/workspaceDir after a
narrowed response shape, breaking admin-explorer. Make seedTestOrganization
idempotent under shared-DB isolation, force single-worker vitest, and align the
OAuth no-membership redirect expectation with authRoutes.
2026-07-30 14:44:11 +08:00
hongjr03 0f4377f16c fix(hub): assert DB wipe and single-worker integration tests
Fail fast if TRUNCATE left Organization rows, drop the extra deleteMany
before seed create, and force vitest maxWorkers=1 so forks cannot race
the shared Postgres.
2026-07-30 14:24:05 +08:00
hongjr03 751d0c4100 fix(hub): harden test org seed and run hub-check only on push
seedTestOrganization now wipes+creates instead of fragile upsert.
hub-check drops pull_request triggers so push/PR pairs no longer double
migrate against the runner; branch push status remains the gate.
2026-07-30 14:05:22 +08:00
hongjr03 0ebfeb927a fix(ci): cancel concurrent hub-check runs on the same ref 2026-07-30 13:37:08 +08:00
hongjr03 4eafbf20a9 fix(hub): reset integration DB with TRUNCATE CASCADE
deleteMany could not reliably clear nested agent-config folders and left
ghost Organization rows that broke the next upsert. Truncate every public
table except _prisma_migrations before seeding the default org.
2026-07-30 13:28:42 +08:00
hongjr03 49e1e2f19e fix(hub): cascade agent-config folder parent deletes
parentId RESTRICT prevented Organization.deleteMany from clearing nested
folder trees during test resetDb, leaving half-wiped rows and breaking
subsequent upserts. Service still refuses non-empty folder deletes;
DB cascade only unblocks org teardown / full wipe.
2026-07-30 13:20:24 +08:00
hongjr03 9c1f9de9c1 fix(hub): give integration tests skill-store root and portable DB URL
Admin routes always construct OrganizationAgentConfiguration via
readSkillStoreRoot(); CI and local runs without HUB_SKILL_STORE_ROOT
failed open. Seed a tmp root when unset. Also make preflight CLI tests
honor DATABASE_URL and set the skill-store env in hub-check.
2026-07-30 13:12:08 +08:00
hongjr03 ac53d42a0a fix(hub): honor DATABASE_URL in integration test helpers
CI hub-check reaches Postgres as the service hostname `postgres`, but
helpers hard-coded 127.0.0.1:5432, so migrate ran against the service
while vitest connected to the wrong place. Prefer env when set.
2026-07-30 13:00:58 +08:00
hongjr03 e0e25ca4c5 fix(hub): expect always-on todo_write in pbank tool mapping 2026-07-30 12:51:48 +08:00
hongjr03 6ddc0b5bd1 fix(ci): skip live bwrap sandbox proof without unprivileged userns
Act/docker runners commonly block non-privileged user namespaces, so
setpriv+CapEff=0 cannot run bwrap. Gate the proof on `unshare --user`
and keep unit + remaining integration tests as the default CI net.
2026-07-30 12:44:41 +08:00
hongjr03 b91938071d fix(ci): enable unprivileged userns for hub-check bwrap 2026-07-30 12:38:11 +08:00
hongjr03 6f736abe50 fix(hub): assert sandbox skills by deny-list, not exact set
Claude SDK may report an extra host/doctor skill id even with
disableBundledSkills. Keep the ADR-0018 guarantee: managed outline
loads and workspace-local untrusted skills do not.
2026-07-30 12:31:33 +08:00
hongjr03 fdd83999df fix(ci): clear caps as root when switching sandbox uid 2026-07-30 12:24:12 +08:00
hongjr03 d4cd1ad74f fix(ci): drop caps after runuser without setgroups 2026-07-30 12:17:49 +08:00
hongjr03 315e4bd018 fix(ci): keep node/npx on PATH for unprivileged sandbox proof 2026-07-30 12:09:20 +08:00
hongjr03 3a46ebc54d fix(ci): run hub-check sandbox proof as unprivileged user
agent-sandbox-linux requires uid>0, CapEff=0, and NoNewPrivs=1. Act runners
often execute as root with residual caps; create cphci and drop privileges
via setpriv before vitest.
2026-07-30 12:09:02 +08:00
hongjr03 213f00eb07 fix(ci): install Rust in hub-check for shipping cph
hub-check builds cph via cargo install; the runner image has no rustup.
Mirror checker-check's dtolnay/rust-toolchain + cargo cache.
2026-07-30 12:02:49 +08:00
hongjr03 127a8c3418 fix(ci): install admin-web deps in hub-check before build
hub build runs admin:build; fleet deploy already npm ci --prefix admin-web
but hub-check only installed hub/. Without that, vite fails on @sveltejs/kit.
2026-07-30 11:58:02 +08:00
hongjr03 61512454cc fix(ci): use service DNS for hub-check Postgres (no host port)
Concurrent hub-check jobs raced on host-published 5432/15432. Drop the
host port mapping and talk to the service container as postgres:5432.
2026-07-30 11:47:44 +08:00
hongjr03 03142c75ea fix(ci): reach hub-check Postgres by service DNS
Concurrent hub-check jobs on the shared runner fought over published
host ports (5432 then 15432). Drop host port mapping and connect to the
service container as postgres:5432 on the job network.
2026-07-30 11:47:21 +08:00
hongjr03 46d6722254 fix(ci): restore hub-check postgres wait deadline 2026-07-30 11:46:18 +08:00
hongjr03 3a4b9e052a fix(ci): bind hub-check Postgres on host 15432
Runner host 5432 is already allocated (leftover containers), causing
hub-check service Postgres to fail start. Map service DB to 15432 and
point wait/integration DATABASE_URL at that port.
2026-07-30 11:45:36 +08:00
hongjr03 faafece1a4 ci: re-run hub-check after postgres flake 2026-07-30 11:42:18 +08:00
hongjr03 d7bbffb9c6 fix(hub): rebase agent config folders onto main and sync lockfile
Rebased feat/agent-config-folder-tree onto current main, keeping cursor
invalidation (not session archive) and requireFolder helpers. Regenerated
package-lock so npm ci finds @emnapi/*; tighten session-cursor test assert
for missing claudeSessionId key.
2026-07-30 11:37:34 +08:00
ChickenPige0n 6c8d2da897 feat(hub): add SearchableSelectField component and update RoleCard to use it 2026-07-30 11:36:28 +08:00
ChickenPige0n c9adf83e5c 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-30 11:36:27 +08:00
hongjr03 db7b7ec094 Merge pull request 'fix(hub): download Feishu resources via bot-owned lark-cli' (#39) from fix/feishu-bot-cli-download into main 2026-07-30 11:28:09 +08:00
hongjr03 88386fb943 fix(hub): download Feishu resources via bot-owned lark-cli
Agent tool downloads and trigger attachment staging both used the SDK
messageResource path, which fails closed for multi-MB teacher files and
did not share the bot-identity transport contract. Route every download
through Hub-owned createFeishuBotCli (secret via stdin, disposable HOME,
HUB_FEISHU_CLI_BIN), keep workspace containment on write, and inject the
adapter in trigger tests.
2026-07-30 11:27:53 +08:00
hongjr03 97c7054529 Merge pull request 'fix(hub): raise Docmind OSS upload timeout past httpx 3s default' (#38) from fix/docmind-oss-upload-timeout into main 2026-07-30 11:17:25 +08:00
hongjr03 97a99cd381 fix(hub): raise Docmind OSS upload timeout past httpx 3s default
SubmitDocParserJobAdvance uploads PDFs to Aliyun OSS via tea/httpx, which
defaults readTimeout to 3000ms when RuntimeOptions is empty. Multi-MB
teacher PDFs on para silo failed with ReadTimeout(3000) before the job
could start. Set connectTimeout=15s and readTimeout=5m.
2026-07-30 11:16:56 +08:00
87bbf0bd57 feat(filelib-web): 刷新页面时保持树展开、选中节点、tab 与文件夹位置 2026-07-27 21:27:09 +08:00
8eda04f1d9 fix(filelib-web): 文件内容无变化时不提交 commit 2026-07-27 21:21:15 +08:00
1dab83f9db feat(filelib): 项目新增修改历史 tab 并隐藏 version hash id 2026-07-27 21:19:59 +08:00
8c26c42e0c feat(filelib-web): 编辑器增加 Typst 语法高亮支持 2026-07-27 21:02:02 +08:00
fe38d0b8a9 feat(filelib-web): 文件编辑改为模态框并集成 CodeMirror 语法高亮 2026-07-27 20:53:16 +08:00
e194670d74 feat(filelib-web): 文件面板支持资源管理器式文件夹导航与图标视图切换 2026-07-27 20:48:23 +08:00
3b544d99f4 feat(filelib): 导出改为下载 cph 编译的真实 PDF 2026-07-27 20:25:29 +08:00
ymy c72f8c7050 Merge branch 'chore/grants-panel-wider' 2026-07-27 17:00:42 +08:00
ymy 947f969967 chore(filelib-web): 详情容器加宽到 1400px,宽屏下授权表格全列无遮挡;仅文件预览挤压时出横向滚动条 2026-07-27 17:00:41 +08:00
ymy 60856d7cc1 Merge branch 'chore/grants-table-overflow' 2026-07-27 16:54:07 +08:00
ymy 2e08c0a734 chore(filelib-web): 授权表格外套横向滚动容器,修删除列贴边与文件预览打开时表格挤压 2026-07-27 16:54:07 +08:00
ymy 2225c6d43b Merge branch 'chore/grants-table-width' 2026-07-27 16:47:05 +08:00
ymy fc908eaf3b chore(filelib-web): 拉宽授权表格:容器 880→1120px,单元格留白/禁换行,长 id 截断+悬停全文 2026-07-27 16:47:05 +08:00
ymy 2395671693 Merge branch 'chore/grant-id-columns' 2026-07-27 16:40:26 +08:00
ymy cc4d9d907c chore(filelib-web): 授权成员单元格只留头像+名称,userId 与飞书 ID 拆为独立两列
GrantDto 增加 principalOpenId(USER 主体的 feishuOpenId),GROUP 行两列显示 —;
搜索过滤同步覆盖 openId。
2026-07-27 16:40:25 +08:00
ymy ef02428bb6 Merge branch 'chore/grant-avatar' 2026-07-27 16:22:03 +08:00
ymy ad1a464f22 chore(filelib-web): 授权成员单元格改用首字母圆形头像(复用 Avatar 组件) 2026-07-27 16:22:01 +08:00
ymy 2dfe72cd5e Merge branch 'chore/grant-modal-copy' 2026-07-27 16:16:17 +08:00
ymy 12a1246a7a chore(filelib-web): 移除添加授权弹窗底部的 8.1 规则提示语 2026-07-27 16:16:16 +08:00
ce5fbfb9a6 fix(filelib): 上传上限抬到 50MiB,并把它与 body limit 的串联写清
7.8MB 文件上传报 413:那是 Fastify 在 body 解析阶段拒的,不是
HUB_FILELIB_MAX_FILE_BYTES。上传把内容放在 JSON body 里、二进制过 base64
体积涨 4/3,所以有效上限是 min(该值, HUB_HTTP_BODY_LIMIT_BYTES × 3/4)。
原先 body limit 是 1MiB,10MiB 的文件上限根本不可达。

.env.example:body limit 1MiB → 70MiB,新增 HUB_FILELIB_MAX_FILE_BYTES=50MiB。
注意 body limit 同时是 ADR-0022 requestBodySize 维度的平台 ceiling,抬高它
对所有端点生效。

resolveMaxFileBytes 拆成 parseMaxFileBytes(纯解析)+ resolveMaxFileBytes(读
env):原先带默认参数,显式传 undefined 会回落到读 env,"没传值"与"读环境变量"
分不开,vitest 加载 .env 后测试会读到真实配置。
2026-07-27 16:14:50 +08:00
ymy 405312b36b Merge branch 'chore/role-label-zh' 2026-07-27 15:57:42 +08:00
ymy be17f74fc2 chore(filelib-web): 文件库权限名称汉化(VIEW/EDIT/MANAGE → 只读/可编辑/可管理)
统一走新增共享常量 labels.ts ROLE_LABEL(与 OverviewPanel 既有文案一致);
覆盖授权表格与弹窗下拉、详情头 tag、树节点角标;API 传参仍用英文枚举。
2026-07-27 15:57:41 +08:00
4849a765da fix(filelib): 授权表与侧栏展示 displayName 而非裸 userId
GrantDto 加 principalName:USER → User.displayName,GROUP → MemberGroup.name,
取不到行(用户/组已删)时回落为 principalId,与 /database/api/me 同一回落语义。
解析走批量 helper(两条 IN 查询,非 N+1),listGrants/putGrants/forceAdjustGrants
三个出口共用,保证 GET 与 PUT 响应同形状。组不按 archivedAt 过滤 —— 已归档组的
历史授权仍需显示名字,否则管理员无法辨认后收回。

principalName 是纯展示字段;写路径仍只认 principalId,不得据此做授权判断。

前端:
- GrantsPanel 主体列由裸 id 改为展示名,id 移入 title 供排查;收回确认框同步。
- LibraryView 侧栏身份区改用 $me.displayName(/me 早已返回,此前未消费)。
- types.ts 去掉重复声明的 Grant 与无引用的 GroupSearchResult。

集成测试断言三种情形(displayName / 组名 / 已删主体回落)。
2026-07-27 15:56:07 +08:00
82241afb56 feat(filelib)!: VersionStore 改为真 git,一项目一仓库
内存 store 换成 gitVersionStore:init 建目录并 git init,VersionId 是
commit hash,某文件的版本取 `git log -1 -- <path>`(D16 文件级版本不因
别的文件提交而失效)。删除也是一个 commit,旧版本仍可读。决策见 ADR-0030。

git 用 execFile 调系统二进制,不引依赖。每次调用钉死 --git-dir/--work-tree
并禁 hooks、隔离全局 gitconfig:项目仓库是老师上传的数据,而 storage root
默认就在本 repo 内,不钉死会让命令落到外层仓库上。

同时:
- 单文件上限改为 HUB_FILELIB_MAX_FILE_BYTES(缺省 10MiB),前端从
  /database/config 读,不再两处硬编码
- commit 身份 name=displayName、email=<userId>@filelib.paradigm-edu.net;
  message 缺省为「【用户名】修改了【路径】」,调用方显式传则优先
- 上传改走弹窗,路径与 commit 信息可手填(原先 prompt 只能填路径)

BREAKING CHANGE: VersionId 由计数器(v1/v2)变为 commit hash;
CommitRequest.author 由字符串变为 { userId, displayName? }。
旧 .version-store.json 不迁移,此前建的项目报 repo_not_found。
2026-07-27 15:55:01 +08:00
ymy fccae5dacb Merge branch 'feat/grants-table' 2026-07-27 15:16:30 +08:00
ymy 0dd2ae347e feat(filelib-web): 授权面板表格化:搜索、添加弹窗、权限下拉与成员跳转
- GrantsPanel 重写为表格:顶部左侧授权成员搜索框(名称/id/类型过滤),
  右侧「添加授权」弹窗(类型 + 主体搜索选择 + 权限);行内权限下拉
  直接改级(复用 PUT upsert),操作列删除;成员单元格跳转用户管理
  (?q= 过滤)或 Group 管理(?select= 选中)。
- grantService: GrantDto 增加 principalName,list/put/force 三处统一
  批量回填(用户 displayName / 组 name),前端不再只显示裸 id。
- 用户管理页加过滤框并从 ?q= 初始化;GroupAdmin 支持 ?select= 直达。
- 测试:resetDb 补 MemberGroup 三表清理(全局表不被 org/user 级联清到,
  此前跨用例污染导致级联软删用例断言失败);cph_hub_test 补 migrate。
- 顺带合并 types.ts 里重复的 Grant 声明(interface 合并残留)。
2026-07-27 15:13:57 +08:00
a306c58db2 fix(filelib-web): 退出登录后管理后台真正跳回登录页
管理后台外壳把「未登录跳 /database/admin」写在 onMount 里,只判断一次。
但 logout() 只清前端 store(它与老师端 /app 共用,那边 me=null 是终态、
不跳转),退出后这层壳重新渲染进 me===null 分支,onMount 不会再跑,
于是永远停在「跳转到登录页…」。

把这道权限门移到 $effect,对任何一次「变成未登录」都生效。条件里的
$authChecked 是必要的 —— 否则首屏 session 请求未回时 me 仍是初始 null,
会把已登录用户直接弹去登录页。

顺带修好「无权访问管理后台」分支里同一个死胡同的退出按钮。
/auth/logout 的 204 本身没有问题。
2026-07-27 14:24:11 +08:00
ymy a4c07d1a5d Merge branch 'fix/filelib-tree-loading' 2026-07-27 14:09:48 +08:00
ymy 91afd3c1b1 fix(filelib-web): 文件库树加载失败时显示错误而非永久加载中 2026-07-27 14:09:35 +08:00
hongjr03 3367d3340b fix(hub): do not crash Hub on missing DocMind input files
Merge missing-file DocMind stream crash fix
2026-07-27 12:25:15 +08:00
hongjr03 34d5d5e88e fix(hub): do not crash Hub on missing DocMind input files
createReadStream emits async ENOENT without a listener, which became an
unhandled 'error' event and exited the silo process. Teachers then saw the
startup "process restart" notice. Wait for stream open and convert missing
files into DocmindClientError instead.
2026-07-27 12:25:04 +08:00
50ddf32cc2 fix(auth): POST /auth/logout 接受任意 Content-Type
该端点不读 body,但调用方(curl -d、Postman、部分 HTTP 客户端)常给空 POST
自动带上 Content-Type。Fastify 默认只有 JSON parser,遇到别的媒体类型在解析
阶段就以 415 FST_ERR_CTP_INVALID_MEDIA_TYPE 拒掉,进不到 handler。

修法是给它一个丢弃 body 的 catch-all parser,**封装在自己的 register 作用域
内**。不能加到外层实例上:admin plugin 没有 fastify-plugin 封装,那样会让全站
每个 POST/PUT/PATCH 都接受 form-urlencoded。而 form-urlencoded 是跨站 HTML
form 唯一能发出的媒体类型(application/json 会触发 CORS preflight),"只认
JSON"本身是一层 CSRF 纵深防御 —— 当前 sameSite=lax 还挡着,但不该为这个端点
全局放掉。

两处细节:
- "*" 只兜没有专属 parser 的媒体类型。内建 JSON parser 优先级更高,空 body 会
  被它判成 FST_ERR_CTP_EMPTY_JSON_BODY(400),故在本作用域内一并覆盖。
- 用 parseAs:"string" 让 Fastify 读完流(否则连接不释放),而非手写
  payload.resume()。

前端未改 —— 原本不带 Content-Type 的发法一直是 204,是正确的。

测试 5 个 case,最后一个是护栏:断言作用域外的 POST 路由发 form-encoded 仍为
415,防止以后有人把 parser 提到外层。
2026-07-26 22:39:46 +08:00
e6e23294a2 Merge branch 'feat/member-group-hierarchy'
MemberGroup 全局嵌套层级(ADR-0028)与 /database 前后端分离(ADR-0029)。
2026-07-26 20:43:59 +08:00
8a13e455fb chore: 删除 .omo/ 与文件库-接口契约.md
.omo/ 下 12 个 run-continuation/ses_*.json 是 agent 会话续跑状态,
机器生成,本不该进版本库;文件库-开工计划.md 一并删除。

《文件库-接口契约.md》(C/D 编号)同时删除。两份文档的内容都可从 git
历史取回。

代码注释里的 C/D 编号(契约 8.1、C2、C4、D11–D19 等)因此不再有在库
文档可查,分布在 filelib 的 model / grantService / treeService /
guards、prisma schema 与迁移、以及 ADR-0028。README 原先按路径引用
这两份文档,现改为说明出处与取回方式。
2026-07-26 20:32:38 +08:00
6990082247 build(deploy): 部署与限流配置切换到 filelib-web,并加共存回归测试
三处引用旧工程名/旧资源路径的地方一并更新,它们必须同时改 —— 少改一处
就是静默故障,而不是构建期报错:

1. 部署脚本(deploy_platform.sh / deploy_fleet_release.sh):npm ci 的
   prefix、rsync 排除项、构建产物存在性检查从 database-admin 换成
   filelib-web。最后一项是真门禁:static.ts 缺产物时只 warn 不注册路由,
   漏改会让 /app 与 /database 静默 404 —— 恰是 database-admin 长期处于
   禁用状态的原因。

2. silo 限流豁免:资源路径随 appDir 改名而变(/database/_app/* 已不存在,
   现为 /_filelib/*);/app/* 此前不在豁免列表,它现在也是 SPA 外壳,
   客户端路由无法预先枚举。
   注:/database/* 是整体豁免,filelib 的 JSON API 也绕过限流预算。这是
   迁移前就有的行为,原样保留,但覆盖面因多了 /app/* 而变宽。

3. 回归测试:把 registerStaticSpa 与 registerDatabaseSpa 挂到同一个
   Fastify 实例,断言 ready() 不因重复路由抛错 —— appDir 若用回默认的
   _app,这里会红(ADR-0029 的承重约束)。另断言 /app 与
   /database/dashboard/users 返回同一份字节(SPA 回退不读请求)、body 含
   /_filelib/。构建产物缺失时不 skip 而是直接失败:那说明该先跑
   filelib-web 的 build,不是测试不适用。
2026-07-26 20:23:15 +08:00
cdeb29ccf2 fix(database): 补回文件库的「授权」tab 与 /me 的显示名
迁移时整个授权 tab 连同四个端点一起漏掉了 —— 后端一直可用,前端零调用:
  GET/PUT/DELETE /nodes/:id/grants
  PUT /projects/:id/independent-permission
权限编辑是这个后台的核心用途,而它此前在界面上完全不可达。

tab 组装也修正为与旧 libraryBrowser 一致:概览恒有、文件仅 PROJECT、
授权仅 MANAGE。注意文件夹也有授权 tab —— 它虽是透明组织节点,授权仍
挂在节点上(ADR-0021);此前文件夹一个 tab 都没有。

GrantsPanel 的语义按契约 8.1:创建者授权不给收回入口;MANAGE 仅创建者
可授,前端不拦,后端 fail closed 的报错原样呈现;GROUP 主体走
/groups/search 下拉选,不手敲 id。

/database/api/me 加 displayName 与 avatarUrl:侧栏此前显示原始 userId。
旧页面是服务端渲染,handler 里查 Prisma 就有名字;页面不再服务端渲染后
(ADR-0029),模板闭包过的数据也是被迁移的契约的一部分,不是旧实现的
无关细节。

概览面板同时补回丢失的「类型」「更新时间」两行、导出 target 下拉、
节点标题旁的角色 tag,以及整块缺失的独立权限开关。
2026-07-26 20:19:47 +08:00
eeb8f56742 fix(filelib-web): 补齐 Group 管理面板,与旧后端面板逐条对齐
迁移时误把分支上一个早先存在的简易 GroupAdmin(281 行)当成迁移产物,
它与旧 renderGroupsPanel(747 行)从来不是同一个东西,于是后端 8 个
group 端点前端只调了 5 个。

补上的功能(端点一直可用,只是没有入口):
  PATCH /groups/:id          重命名 / 改描述
  GET  /groups?includeArchived=1  列出已归档组
  POST /groups/:id/restore   恢复(连带恢复已归档祖先链,子树仍归档)
  GET  /users/search         成员选择器,不再手敲 userId
影响最实际的是恢复:软删的组此前在界面上无法恢复。

补上的交互:折叠树、组名过滤(命中项保留整条祖先链,过滤态强制展开)、
右键菜单(归档组只给「恢复」)、面包屑、统计条、树底部计数、成员表的
头像/openId/加入时间三列。

types.ts 之前也是截断的:MemberGroupNode 少 archivedAt,
MemberGroupMember 少 feishuOpenId/avatarUrl/joinedAt —— 类型里没有,
UI 自然渲染不出来。

一处实现偏离:折叠状态用数组而非 Set。Svelte 5 的 $state 深层代理不
跟踪 Set 变更,用 Set 会点了没反应。

groups tab 外框补 padding:20px/overflow:hidden,对齐旧 #tab-groups,
否则面板贴着侧边栏。
2026-07-26 20:19:30 +08:00
325b4fc137 fix(filelib-web): 补回迁移丢失的共享组件样式层与图标集
第一版迁移只把 uiTheme.ts 的 @theme 颜色令牌搬了过来,155 行里约 90
行的组件类(.btn/.panel/.input/.select/table.list/.tag/.switch/
.link-danger/.quiet 等)被丢掉,于是每个组件各自内联重述按钮、输入框、
面板的样式 —— 正是旧代码的重复问题被原样复刻,后台观感明显退化。

现在 app.css 是设计系统的唯一去处:@theme 管令牌,@layer components
管组件类。组件只带布局工具类,不重述组件样式。

图标集同样是丢的:旧面板有 13 个内联 SVG,新版一个不剩,只有纯文字的
「+」「删除」—— 这是"简陋"最直接的来源。提成 Icon.svelte 共享。
Group 节点沿用两人剪影而非文件夹图标:MemberGroup 与文件库的
FOLDER/PROJECT 是两套无关层级,图标不应混淆(ADR-0028/0021)。

顺手修 FilesPanel 的 uploadInput:bind:this 的目标要用 $state,
否则 Svelte 5 下不保证更新。
2026-07-26 20:19:10 +08:00
a7f90f387d chore(database-admin): 删除该前端工程,已被 filelib-web 取代
12628c9 引入它意在替换后端渲染的 /database 页面,但从未接通:具体
路由 /database/dashboard 比 SPA 通配 /database/* 更具体,服务端
handler 永远胜出,SPA 的 dashboard 不可达。工程头注释声称 SPA 已
接管 dashboard、且 /database/config 存在,两者当时都不成立。

hub 的 build 脚本也从未构建它,于是 static.ts 里的 existsSync 守卫
每次部署都失败,这个外壳实际长期处于禁用状态 —— 它没服务过一个请求。

与 filelib-web 合并而非并存的理由:两者共用文件库浏览器、会话层、
toast 宿主与设计令牌,拆开就要把这些全复制一遍(ADR-0029)。

内容可从 git 历史取回。
2026-07-26 20:18:15 +08:00
d159e372d2 refactor(database)!: 后端不再渲染任何 HTML,只出 JSON
删掉约 1770 行服务端模板拼接:renderDashboard / renderLoginPage
(databaseRoutes)、adminPanels、libraryBrowser、uiTheme,以及
libraryPage —— 后者迁移前已是无人引用的死代码。

新增两个端点承接原先在 page handler 里 inline 算的东西:
  GET /database/config      免鉴权 bootstrap(org slug + dev 开关);
                            注册位置刻意早于 silo org 的提前返回,
                            org 未就绪时登录页仍要能渲染。
  GET /database/api/stats   概览统计,要求 silo org OWNER/ADMIN ——
                            它聚合的是 org 级计数与审计流,不是
                            单节点权限视图。

静态托管收敛到 static.ts:一份 filelib-web 构建产物挂 /app 与
/database 两个前缀,资源路由只注册一次。并发症是路由顺序成了硬约束
—— 具体页面路由必须先于 SPA 通配注册,否则重演 /database/dashboard
盖住 SPA 的老 bug(ADR-0029)。

/database/library 改为 302 到 /database/dashboard/library。

BREAKING: 部署需先构建 filelib-web,否则 static.ts 的 existsSync
守卫会让 /app 与 /database 全部 404。
2026-07-26 20:17:59 +08:00
3d0f4e5c2d feat(filelib-web): 把 /database 各页从后端 HTML 拼接迁到 SvelteKit 路由
登录页、后台外壳与六个 tab 全部成为客户端路由:
  /database/admin              登录(迁自 renderLoginPage)
  /database/dashboard          概览(迁自 renderDashboard)
  .../library .../users .../groups .../search .../settings

六个 tab 是真 URL,不再是 location.hash + display:none —— 刷新不丢
位置,链接可分享。

BrowserShell 拆成 LibraryView,加 showUserFooter:老师端 /app 显示
身份/登出页脚,后台的文件库 tab 不显示(外层已有身份区)。

bootstrap 走 /database/config 而非 /database/api/login-info:后者由
teacherApp 在 silo org 查找成功后才注册,前者无条件注册,登录页在
org 未就绪时也必须能拿到配置。

后端拥有的链接(OAuth、DEV 一键登录)标 data-sveltekit-reload,
否则被客户端路由拦下。
2026-07-26 20:17:43 +08:00
de9f846fd0 build(filelib-web): 从 Svelte+Vite 改为 SvelteKit(adapter-static)
纯 SPA:adapter-static + fallback index.html,不做 SSR/预渲染。
index.html / main.ts / App.svelte 由 app.html + src/routes/ 取代。

两项配置是承重的(ADR-0029),不是风格选择:
  appDir: '_filelib'   默认 _app 会与 admin-web 在根上注册的 /_app/*
                       撞成 Fastify 重复路由,启动即抛错。
  paths.relative: false 同一份 index.html 会在 /app 和
                       /database/dashboard/users 等不同深度送出,
                       相对资源路径会解析到错的 base。

dev 代理表列出后端拥有的全部路径:JSON API、免鉴权 bootstrap
(/database/config)、OAuth、以及 DEV 一键登录端点 —— 后者不代理会被
SPA 回退吃掉。
2026-07-26 20:17:27 +08:00
683e97ca53 docs(adr): 0029 web 界面一律静态 SPA,hub 只出 JSON
记录本次迁移的语义决策:没有 HTTP handler 渲染 HTML;/app 与
/database 是同一个前端工程 filelib-web,构建一次挂两个前缀;
客户端导航用真 URL 路由而非 hash 片段。

两条承重配置约束一并写明:appDir 必须改名(默认 _app 与 admin-web
在根上的 /_app/* 撞重复路由,Fastify 启动即失败),以及
paths.relative=false(同一份 index.html 在不同 URL 深度被送出)。
2026-07-26 20:17:09 +08:00
11a7ec8004 feat(database): 后台成员组(MemberGroup)管理与嵌套解析 2026-07-26 18:06:18 +08:00
2f2ece1a3a fix(database): expose submitCreate on window.__lib in library browser
新建根目录/子节点弹窗的「创建」按钮 onclick 调 window.__lib.submitCreate,
但该函数虽已定义却漏挂到 __lib 导出表,导致点击报 "submitCreate is not
a function"。补挂即可。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 16:39:38 +08:00
hongjr03 bdd722c463 fix(hub): include Edit in default single-agent tools
Merge pull request #34 from fix/hub-include-edit-tool
2026-07-25 14:19:04 +08:00
hongjr03 2699ff3679 fix(hub): include Edit in default single-agent tools
Removing the claude_code preset dropped Edit. Unrestricted roles need it for
in-place file edits; write_file also grants Edit for restricted roles.
2026-07-25 14:18:57 +08:00
hongjr03 a3af63c1c6 fix(hub): stop unrestricted roles from loading multi-agent tools
Merge pull request #33 from fix/hub-no-background-agent-tools
2026-07-25 13:45:01 +08:00
hongjr03 755704e2ae fix(hub): stop unrestricted roles from loading multi-agent tools
The claude_code preset exposed Agent/SendMessage/Task. Background agents
abort with reason "background", which the SDK maps to Bash
toolDenialKind "cancelled" ("user doesn't want this action") and freezes
command execution mid-run.
2026-07-25 13:40:27 +08:00
hongjr03 dcda4cb7a3 Merge pull request 'fix(hub): unblock production deployment audit' (#32) from fix/deploy-router-audit into main 2026-07-24 16:46:17 +08:00
hongjr03 9e26585e1f fix(hub): unblock production deployment audit 2026-07-24 16:43:11 +08:00
hongjr03 8bfbecf60f Merge pull request 'fix(hub): clarify BYOK rotation state' (#31) from fix/provider-rotation-ux into main 2026-07-24 16:34:20 +08:00
hongjr03 4087f09994 fix(hub): clarify BYOK rotation state 2026-07-24 16:32:19 +08:00
hongjr03 3708162fa9 Merge pull request 'fix(hub): lock provider id during BYOK rotate + scroll/focus feedback' (#29) 2026-07-24 14:46:53 +08:00
hongjr03 4de290a73c fix(hub): lock provider id during BYOK rotate + scroll/focus feedback
Clicking 轮换 on a BYOK provider row prefilled the 供应方 ID below but gave
no signal — no scroll, no focus, no toast — so it looked dead ("点不进去").
The 供应方 ID input stayed editable too: changing it silently created a new
connection (version 1) instead of rotating the one clicked.

- startRotate now locks the id (readonly), scrolls the form into view,
  focuses 接口地址, and toasts "已填入供应方 X…".
- Heading switches between 轮换凭据 · <id> and 新建 / 轮换 BYOK 凭据;
  an inline hint explains how to create a different provider (清空).
- save() distinguishes create vs rotate via the returned activeVersion
  (version 1 → 已创建 BYOK 连接, else 凭据已轮换).
- Add toastInfo to $lib/toast for the info-level affordance.

Backend unchanged: list/rotate/reject-member/reject-platform-overwrite tests
already pass; this is a UI feedback + id-lock fix.
2026-07-24 14:39:26 +08:00
58c81d4379 Merge remote-tracking branch 'origin/main' into feat/member-group-hierarchy 2026-07-24 00:00:50 +08:00
bai d16bd4899d Merge pull request 'feat(database): init database folder frontend and permission' (#1) from maoyuanyang/curriculum-project-hub:main into main
Reviewed-on: bai/curriculum-project-hub#1
2026-07-23 23:44:17 +08:00
ymy 4021e58d5d feat(database): init database folder frontend and permission 2026-07-23 23:41:11 +08:00
hongjr03 744c707a39 Merge pull request 'fix(hub): render Feishu checklist from TaskCreate/TaskUpdate' (#28) from fix/task-checklist-card-panel into main 2026-07-23 23:21:50 +08:00
hongjr03 74f5c4a02e fix(hub): render Feishu checklist from TaskCreate/TaskUpdate
Headless Claud agents expose TaskCreate/TaskUpdate rather than TodoWrite.
Fold those tool events into the live card checklist, pass real tool names
and inputs through tool-result, and hide Task* noise once the panel is up.
2026-07-23 23:21:34 +08:00
hongjr03 326224b778 Merge pull request 'fix(hub): shipible checklist via cph_hub todo_write MCP tool' (#27) from fix/hub-mcp-todo-write into main 2026-07-23 23:08:59 +08:00
hongjr03 e3b463d390 fix(hub): ship checklist via cph_hub todo_write MCP tool
Native Claude TodoWrite is not registered in headless agent mode even with
--tools default. Add mcp__cph_hub__todo_write (always enabled), mirror the
TodoWrite schema, instruct multi-step runs to use it, and keep the Feishu
progress panel parsing both native and hub tool names.
2026-07-23 23:08:42 +08:00
hongjr03 ceaf64c4f9 Merge pull request 'fix(hub): expose TodoWrite via SDK default toolset' (#26) from fix/todowrite-default-toolset into main 2026-07-23 22:56:28 +08:00
hongjr03 bb426dfaf5 fix(hub): expose TodoWrite via SDK default toolset
Unrestricted roles were still passed an explicit --tools name list. The
native Claude binary only reliably registers bundled tools like TodoWrite
on --tools default. Treat role tools JSON null as unrestricted, use the
claude_code preset (→ default) in that case, and keep TodoWrite on allowedTools.
2026-07-23 22:56:26 +08:00
hongjr03 b4fe734e80 Merge pull request 'feat(hub): live TodoWrite checklist on Feishu agent cards' (#25) from feat/agent-todo-progress-card into main 2026-07-23 22:42:54 +08:00
hongjr03 2f79b7743f feat(hub): live TodoWrite checklist on Feishu agent cards
Always expose Claude Agent SDK TodoWrite (todoFeatureEnabled) so multi-step
runs can plan in the open. Parse TodoWrite payloads into a progress panel on
the streaming Feishu card (completed/in_progress/pending) and filter raw
TodoWrite noise out of the tool-use list.
2026-07-23 22:42:02 +08:00
192cd43245 feat(hub): add global nestable member group hierarchy
Global, unlimited-depth member groups managed by the platform super
admin (requirement 3.1-3.3). Stores membership + nesting only, never
permission data; exposes user -> ancestor-closed group set.

- MemberGroup: soft delete via archivedAt; parentId FK RESTRICT.
  Deleting a group cascade-soft-deletes its whole subtree as an
  application operation, not a DB cascade.
- MemberGroupMembership: user<->group many-to-many, revokedAt soft
  delete, user/group indexed for resolution hot path.
- MemberGroupClosure: transitive closure (depth-0 self rows) for
  one-join ancestor/descendant resolution; maintained on
  create/reparent with a cycle guard.

Permission side (GROUP principal, FOLDER resource, grant inheritance)
is deferred. This principal is deliberately not org-scoped and will
need ADR-0028 to supersede the ADR-0020 cross-org invariant before the
GROUP principal ships.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 22:31:26 +08:00
12628c9233 feat(hub): migrate database admin pages to SPA (database-admin)
- scaffold hub/database-admin as SvelteKit 2 + Svelte 5 static SPA
  with aurora/glass visual style (paths.base='/database')
- add lib/{api,session,org}.ts + Aurora.svelte component
- add routes: root redirect, /admin login page, /dashboard (OWNER/ADMIN only)
- backend: replace server-rendered HTML routes with /database/config JSON endpoint
- add hub/src/database/static.ts to serve SPA under /database/*
- wire registerDatabaseSpa into plugin.ts
- exempt /database/* from silo rate-limit (same treatment as /admin/*)
- add database:dev + database:build npm scripts; update deploy scripts
- update hub/src/database/README.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:51:53 +08:00
hongjr03 4a03bc1f4a Merge pull request 'fix(hub): extract PBank zips in-process without host unzip' (#24) from fix/pbank-pure-node-unzip into main 2026-07-23 21:05:26 +08:00
hongjr03 8a81c60ea5 fix(hub): extract PBank zips in-process without host unzip
pbank materialize previously shelled out to `unzip` and soft-failed when
the binary was missing, so agents only saw titles. Read zip entries with
Node zlib (store/deflate) and write under workspace .pbank-sources.
2026-07-23 21:05:15 +08:00
hongjr03 00f7a8db39 Merge pull request 'feat(hub): built-in PBank 题库 capability + role tools (v0.0.42)' (#23) from feat/hub-pbank-capability into main 2026-07-23 20:13:19 +08:00
hongjr03 54837717fd feat(hub): built-in PBank 题库 capability + role tools (v0.0.42)
Register pbank as an ADR-0027 external capability with org-scoped
username/password envelopes, readiness via /login, and in-process
cph_hub MCP tools (search/get/get_many) that materialize sources under
the run workspace. Extend the capability secret payload for docmind vs
pbank kinds, admin capabilities UI, role tool umbrella `pbank`, and the
pbank-problem-report skill. Credentials never reach the Agent process.
2026-07-23 20:13:00 +08:00
hongjr03 2285ae871e Merge pull request 'fix(hub): enable tenant Typst package resolution' (#22) from fix/tenant-typst-package-resolution into main
Reviewed-on: EduCraft/curriculum-project-hub#22
2026-07-22 17:50:55 +08:00
hongjr03 36660f72d6 fix(hub): enable tenant Typst package resolution 2026-07-22 17:47:56 +08:00
hongjr03 6462e42823 fix(hub): stamp CheckMark/CrossMark when agent run finishes (v0.0.41) (#21) 2026-07-21 14:36:21 +08:00
hongjr03 6f7497bce8 fix(hub): stamp CheckMark/CrossMark when agent run finishes (v0.0.41)
After removing the Typing reaction, add CheckMark on success or CrossMark
on failure so teachers can see completion on the source message without
opening the card.
2026-07-21 06:36:09 +00:00
hongjr03 e634418a02 feat(hub): concurrent multi-PDF convert_pdf_to_md + readable skills (v0.0.40) (#20) 2026-07-21 13:56:10 +08:00
hongjr03 db49a0d23d feat(hub): concurrent multi-PDF convert_pdf_to_md + readable skills (v0.0.40)
Teachers convert many PDFs in one tool call with bounded Docmind concurrency.
Each item keeps its own output_dir/document.md and UsageFact; failures are
per-file. Mirror role skills to .cph/runtime-skills and CPH_RUNTIME_SKILLS_DIR
so agents can Read SKILL.md instead of dead .claude/sandbox stubs.
2026-07-21 05:55:55 +00:00
hongjr03 54b9fee22c fix(hub): agent 会话记忆在配置变更/发版后丢失 + Feishu thread 400 (#19)
Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-20 22:45:42 +08:00
hongjr03 5f668d71a2 fix(hub): forward host HTTP_PROXY into agent sandbox (v0.0.39) (#18) 2026-07-20 21:46:09 +08:00
hongjr03 3fbc4b81c2 fix(hub): forward host HTTP(S)_PROXY into agent sandbox (v0.0.39)
Host egress requires the local forward proxy; sandbox env previously
omitted PROXY vars so Bash/curl timed out on public image URLs. Pass
HTTP(S)/ALL/NO_PROXY (+ lowercase) and NODE_USE_ENV_PROXY from the
trusted service environment into the agent subprocess.
2026-07-20 13:46:07 +00:00
5df1900ca8 docs(hub): document HUB_DEV_LOGIN_BYPASS dev login in database README
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 21:09:14 +08:00
df66691d24 feat(hub): add /database admin surface with Feishu login
Adds a self-contained `/database/*` HTTP surface under hub/src/database:
- /database/admin: Feishu-only login page (Tailwind, light theme)
- /database/dashboard: session-gated sidebar + content shell
- /database/dev-login: DEV ONLY session bypass, double-gated by
  NODE_ENV != production AND HUB_DEV_LOGIN_BYPASS; never active in prod

hub.ts mounts the plugin after the admin plugin so the cookie parser and
/auth/feishu/* routes are available. The dev bypass logic is fully contained
in the database module; admin auth routes are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 21:02:04 +08:00
hongjr03 e016564cc5 feat(hub): raise agent limits + teacher failure notices (v0.0.38) (#17) 2026-07-20 20:07:47 +08:00
hongjr03 6cefb2a938 feat(hub): raise agent turns/time limits and notify teachers on failure
Defaults and silo env go to 150 turns / 1800s wall clock. Run completion
appends a clear Feishu notice for max-turns, timeout, and other failures
(partial answer kept). Startup process-restart kills notify the bound chat.
Release v0.0.38.
2026-07-20 12:07:45 +00:00
hongjr03 34c4908237 chore(hub): default HUB_MAX_FILES_PER_MESSAGE to 20 (#16) 2026-07-20 19:50:47 +08:00
hongjr03 46687dd5f6 chore(hub): default HUB_MAX_FILES_PER_MESSAGE to 20
Match production silos (raised for multi-image Feishu posts).
2026-07-20 11:50:45 +00:00
hongjr03 e9b578153e fix(hub): strip card markdown images + prefer inline ![] over send_file (v0.0.37) (#15)
fix card inline images v0.0.37
2026-07-20 19:39:08 +08:00
hongjr03 93f3f2424c fix(hub): strip card markdown images + prefer inline ![] over send_file
Feishu interactive markdown rejects ![](http...) without image_key
(error 230099 empty/missing imagekey). Always mask residual image md in
card builders; skip img tags with empty keys; skip inline-code examples;
fetch remote images with a browser UA and without env HTTP_PROXY.
Steer the agent: use ![alt](workspace-path) for 图文, send_file only for
downloadable attachments.

Release v0.0.37.
2026-07-20 11:38:55 +00:00
hongjr03 73cb0e5b47 feat(hub): embed agent images via Feishu upload + release v0.0.36 (#14)
feat(hub): embed agent images via Feishu upload + release v0.0.36

Merge pull request #14
2026-07-20 18:42:38 +08:00
hongjr03 e21096c642 feat(hub): embed agent images via Feishu upload + release v0.0.36
Materialize markdown image refs on agent finish: fetch/read bytes, upload
im.v1.image, and render native card img elements so remote image URLs no
longer trip Feishu content-security. Stream masks image URLs mid-run;
card failure falls back to plain text plus standalone image messages.

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

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

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

Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-19 01:19:59 +08:00
hongjr03 ce18740870 feat(hub): expose pdf_to_md_bundle as MCP tool to agent + skill (ADR-0027) (#9)
Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-18 21:43:00 +08:00
hongjr03 ef96f8d33d feat(hub): capability connection admin API + UI + release v0.0.32 (#8)
Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-18 17:57:04 +08:00
hongjr03 5e10419fc8 fix(hub): docmind client stream upload + correct API response parsing (#7)
Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-18 17:26:11 +08:00
hongjr03 64b3d1fc64 feat(hub): switch capability provider to Aliyun Doc Mind (ADR-0027) (#6)
Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-18 16:42:42 +08:00
hongjr03 b673dd1fe9 feat(hub): external capability registry for PDF/ASR transforms (ADR-0027) (#5)
Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-18 15:55:02 +08:00
hongjr03 aaa098bb8b feat(hub): usage fact ledger for run-scoped cost attribution (ADR-0026) (#4)
Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-18 14:46:41 +08:00
hongjr03 97f7972cc5 chore(hub): remove markdown_to_pdf tool (#3)
Drop markdown_to_pdf MCP surface, implementation, tests, and md-to-pdf dependency.

Roles that still list markdown_to_pdf must be cleaned before startup.

Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-18 13:57:27 +08:00
hongjr03 cc42e6a7c6 chore: release v0.0.31 2026-07-18 13:38:07 +08:00
hongjr03 4e01c18cac feat(hub): add markdown_to_pdf tool and default web tools
Teachers need ad-hoc Markdown → PDF. Ship an MCP tool powered by
md-to-pdf (Marked + headless Chrome) so remote images/CSS work, with
workspace-scoped basedir, front-matter stripped so untrusted markdown
cannot override dest/basedir/launch options, and MathJax for $/$ math.

Also include WebFetch and WebSearch in the unrestricted role tool
surface by default. Deploy skips Puppeteer's browser download and
expects a host Chrome/Chromium (PUPPETEER_EXECUTABLE_PATH / CHROME_PATH).
2026-07-18 13:38:07 +08:00
ChickenPige0n a12984d174 feat(admin-web): add folder and project creation functionality in FolderNode and FolderTree components 2026-07-18 12:32:45 +08:00
hongjr03 b93acd8e8c chore: release v0.0.30 2026-07-16 01:26:28 +08:00
hongjr03 35251986af feat(hub): derive admin model picker from org provider connection via OpenRouter API
The admin role model picker was hardcoded to the env-default model registry
(createDefaultModelRegistry), which only ever returned a single Sonnet model.
Roles could not select any other model regardless of what the org's provider
connection supported.

Replace the env-only model list with a ProviderModelCatalog that:
- Resolves the org's ACTIVE provider connection credential (BYOK or
  platform-managed, encrypted via ADR-0024 envelope)
- Calls OpenRouter GET /v1/models?supported_parameters=tools to list
  tool-capable models available to that org
- Caches results in-memory with a 5-minute TTL per organization
- Falls back to the env-default registry when no ACTIVE provider exists

The runtime modelRegistry no longer validates role.defaultModel against the
env model list — the admin already validated by selection from the provider
catalog. The env list remains as the fallback for roles with null defaultModel.

The admin roles page loads models independently (non-blocking) so roles
remain editable even if the provider API is slow or unreachable.
2026-07-16 01:24:00 +08:00
hongjr03 3ee6da7ceb chore: release v0.0.29
Web-based skill management: create, read, edit, disable skills from the
org admin surface. File tree editor with SKILL.md manifest support.
2026-07-16 01:12:51 +08:00
hongjr03 79f72ecca8 feat(admin): web-based skill management with file editor
Add full skill lifecycle to the org-admin web surface: create, read,
edit, disable. Skills are directories (SKILL.md manifest + supporting
files), content-addressed by SHA-256 in an immutable store.

Backend:
- skillStore: extract commitSkillContent (shared populate→inspect→
  dedup→atomic rename); add importSkillFromFiles (in-memory file list
  ingestion) and readSkillFiles (read stored version back as UTF-8)
- configuration: add installSkillFromFiles, readSkillFiles, disableSkill
  (soft-delete + archive bound role sessions), updateSkillDescription
  (label-only, no archival); refactor installSkill to share
  commitInstalledSkill
- agentConfigRoutes: wire skillStoreRoot; add GET
  /agent-skills/:name/files, PUT /agent-skills/:name (create/replace),
  PATCH /agent-skills/:name (description/disable)
- orgRoutes: pass readSkillStoreRoot() to agent config routes

Frontend:
- api.ts: agentSkillFiles, installAgentSkill, patchAgentSkill methods
- SkillEditor.svelte: file tree + text editor + version/description form
- skills/+page.svelte: skill list, create form (generates SKILL.md
  template), per-skill editor
- layout: add 技能 nav item

ADR-0018: update Decision to reflect web surface joining host-console
CLI in the shared content-addressed ingestion pipeline.

Spec (AgentRole.lean): unchanged — storage mechanism is OPEN, web
installation is one implementation of it.
2026-07-16 01:11:49 +08:00
hongjr03 ae5f78f036 chore: release v0.0.28
Exempt SPA static assets and admin HTML shell from silo HTTP rate limit so
page loads no longer exhaust HUB_HTTP_REQUESTS_PER_MINUTE.
2026-07-15 22:33:07 +08:00
hongjr03 0782e155f6 chore: release v0.0.27
Agent role/skill admin restore, OAuth login URL fixes, project create 404 fix.
2026-07-15 21:35:26 +08:00
ChickenPige0n 0726dc13c8 feat(admin): restore org Agent role/skill management and fix 404 after project create
- explorer POST /projects now returns {id,name} matching the SPA contract
  (previously returned ProjectOnboardingResult.projectId, so res.id was
  undefined and the redirect to /projects/undefined 404'd)
- add OrganizationAgentConfiguration.listRoles/listSkills + AgentRoleRow/
  AgentSkillRow exports; upsertRole now returns the full row
- new agentConfigRoutes: GET/PUT /agent-roles, PUT /agent-roles/:id/skills,
  GET /agent-skills, GET /agent-models (env-default picker)
- restore admin-web roles page + RoleCard rewired to ADR-0017/0018 backend
  (label, defaultModel, tools whitelist, skill binding, systemPrompt,
  sortOrder, default toggle); add 角色 nav item + roles icon
- skill installation stays out-of-band (CLI/seed) per spec; the surface only
  lists installed skills and binds them to roles
2026-07-15 18:19:47 +08:00
ChickenPige0n fb66614e38 fix(admin-web): break /admin/login returnTo redirect loop in dev
vite dev only proxied /api and /auth, so /admin/login hit the SPA root
layout, re-ran loadSession, got 401, and redirected back to /admin/login
with an ever-nesting returnTo. Proxy /admin/login to the backend (which
owns it before the SPA fallback) and guard redirectToLogin against
re-entering /admin/login.

Replace the only emoji-as-icon (the back-arrow on project detail) with
the Icon component (new arrow-left glyph).

Add scripts/dev-bootstrap.ts for seeding a local Silo (stub probes) so
npm run dev can start on a fresh dev DB.
2026-07-15 18:19:47 +08:00
hongjr03 11de9e81db fix(hub): redirect Feishu OAuth default login to org admin SPA
Default returnTo=/admin previously landed on the static complete page
meant for chat onboarding; send users to /admin/org/:slug instead.
2026-07-15 14:29:42 +08:00
hongjr03 46ce942aec fix(admin-web): use org-scoped Feishu OAuth login URL
Unscoped GET /auth/feishu is disabled by default. Derive org slug from
/admin/org/:slug, ?org=, or the Alpha Silo hostname and redirect to
/auth/feishu/:orgSlug so the login button works on tenant domains.
2026-07-15 00:52:58 +08:00
hongjr03 8990277916 chore: release v0.0.26
Org admin SPA, capacity policy admin, fleet deploy CI for educraft/educraft-dev.
2026-07-15 00:29:31 +08:00
hongjr03 b217c16c1b Merge branch 'admin-panel': org admin SPA and fleet deploy CI
Bring in the org-admin Svelte SPA, capacity policy surface, production SPA
serving, project MANAGE fixes, and Gitea deploy of educraft/educraft-dev fleets.
2026-07-15 00:29:30 +08:00
hongjr03 78f94fcc8c fix(ci): sync npm lockfiles so fleet deploy npm ci succeeds
Hub and admin-web package-lock.json were missing @emnapi/* entries that
npm 11 on the Alpha host requires, so deploy_fleet_release failed at
npm ci. Regenerate both lockfiles and retry incomplete release trees.
2026-07-15 00:18:22 +08:00
hongjr03 7269480abb ci: deploy Hub admin SPA fleets via Gitea Actions
Add deploy_fleet_release.sh and a workflow that rolls immutable Hub
releases (including admin-web) to educraft-dev on push/PR and educraft
on main/tags, selecting silos by HUB_PUBLIC_BASE_URL middle domain.
2026-07-15 00:14:22 +08:00
hongjr03 1d2f4657ba fix(org): match listMyProjects grants by principal pair
Filtering permission grants with separate principalType/principalId IN
lists matched cross-product rows (e.g. TEAM + user id). Use OR of exact
(type, id) pairs so members only see projects their principals hold.
2026-07-14 23:55:20 +08:00
hongjr03 4ad0259193 fix(admin): let project MANAGE holders list teams for grants
GET /teams was org-admin only while team-access mutations require project
MANAGE, so members with MANAGE saw an empty grant picker. Open the
read-only team list to any org member and load it in the project page
whenever the actor can manage the project.
2026-07-14 23:55:16 +08:00
hongjr03 4e2699d0a5 fix(admin): serve built SPA and include it in release builds
registerStaticSpa was never mounted, so production only exposed org-admin
APIs. Wire it after auth/API routes, fold admin:build into npm run build,
and install admin-web deps during deploy so admin-web/build ships with the
release for same-origin /admin/*.
2026-07-14 23:54:47 +08:00
ChickenPige0n 153d74d033 feat: 更新容量策略页面的维度标签和逻辑分组 2026-07-14 22:54:37 +08:00
ChickenPige0n 080efa70c5 feat(admin): gate project surfaces behind permission grants for members
The org admin SPA was org-admin only: every project route used
requireOrgRole, so a plain MEMBER could not reach the projects they held
a project grant on, and an org OWNER/ADMIN could mutate any project
without holding the project's `manage` grant. That contradicts ADR-0004
(spec `Permission.lean`): org role is not a project authorization root,
and the only out-of-role override is platform-admin force-release
(`RequiresAdmin`), not org admin.

Add `requireProjectPermission` (guards.ts): resolve any org member, bind
the project to their org, then check the PermissionGrant authorizer.
`allowOrgAdminOversight=true` lets OWNER/ADMIN through for *read*
oversight only; mutations pinned to `collaborator.manage`
(grant/revoke team-access) pass `allowOrgAdminOversight=false`, so an org
admin still needs the project MANAGE grant to mutate access. The project
detail GET now also returns `actorIsOrgAdmin` and `actorCanManageProject`
so the SPA can render mutation controls only for entitled actors.

Add a member-facing project surface:
- `GET /api/org/:orgSlug/my-projects` + `listMyProjects` resolve the
  actor's principals and return the projects with a READ+ grant.
- The SPA routes members (non-admin) to the projects page instead of the
  admin overview, renders a member project shell on project routes, shows
  a `我的项目` list for members and the full folder explorer for admins.
- The project detail page gates rename/archive/bind/sessions behind org
  admin and the grant/revoke UI behind `actorCanManageProject`.
- The denied panel now points members at their authorized projects.

Update admin-members-teams integration test: seed the owner with a MANAGE
grant on the test project so the org-owner flow still passes the new
project-level gate on team-access grant/revoke.
2026-07-14 21:25:18 +08:00
ChickenPige0n ab9dfad53a feat: org capacity policy admin surface
ADR-0022 / Spec.System.Capacity pins layered capacity limits: a platform
ceiling per dimension is unbreakable, and each organization may only set a
lower `organizationLimit`. The effective limit is the minimum of the two
(`LayeredLimit.effective`); dimensions with no org override fall back to
the platform ceiling. No dimension may be unlimited (a ceiling must exist
before an org limit can be set, `LayeredLimit.Valid`).

Add the backend: the pinned 23 CapacityDimension set + labels
(src/capacity/dimensions.ts), platform ceilings sourced from existing
runtime env vars plus `HUB_CEILING_<DIMENSION>` (src/capacity/ceilings.ts),
the OrganizationCapacityPolicy prisma model + migration, the
getCapacityPolicy/setCapacityPolicy service enforcing LayeredLimit.Valid,
and org-admin GET/PUT /api/org/:orgSlug/capacity-policy routes wired into
the org route tree.

Add the admin-web surface: CapacityDimension/CapacityPolicyView api client
types, capacityPolicy/setCapacityPolicy methods, a `容量` nav entry, and a
capacity page that lists every dimension with its platform ceiling (or
`未配置` when unset), an org-limit input (disabled until a ceiling exists),
and a live effective-value column. Saving sends the partial limits map;
the service rejects values above the ceiling or for unconfigured dimensions.
2026-07-14 21:23:41 +08:00
ChickenPige0n adce8fb6f5 chore: drop legacy PlatformRoleAssignment model
ADR-0023 / Spec.System.PlatformAdministration pins the platform
administrator as a separate identity/session/audit control plane,
intentionally not modeled in alpha (ADR-0025). The legacy
PlatformRoleAssignment / PlatformRole{ADMIN,TEACHER} table had no runtime
reader (no guard, route, or service queried it for an authorization
decision) and ADR-0023 requires it to be replaced before the platform
panel ships.

Drop the model, the PlatformRole enum, the User.platformRoles relation,
and the migration. Stop seeding platformRoles in externalSync principal
ingestion and the integration test helper. Update the doc comments on
OrganizationMembership and PermissionRole to point at the platform-admin
control plane instead of the dropped model.

The 20260709180000_organization_tenant_root backfill only referenced
PlatformRoleAssignment in a one-time INSERT...SELECT; no persistent
object references it, so dropping the table is safe after that migration.
2026-07-14 21:20:50 +08:00
ChickenPige0n b574ef871c refactor(org): keep archived-team grants as dead rows
Archiving a team no longer cascade-revokes its active TEAM->PROJECT grants
and memberships. The archived flag alone makes the team principal
unresolvable (permissions/principals.ts refuses archived teams), so the
dead grant/membership rows confer no access. listProjectTeamAccess now
filters archived teams out of the project view instead of relying on a
revokedAt cascade, and the org-admin teams page confirm copy is updated.
archiveTeam drops the revokedGrants count from its return shape.

ADR-0019 / Spec.System.Organization: principal resolution, not grant
mutation, is the access boundary for archived teams.
2026-07-14 21:17:20 +08:00
ChickenPige0n cbe569d7e6 style(admin-web): apply Prettier formatting
Run `prettier --write .` across all source files. Changes are purely
formatting: trailing commas, line wrapping at 120 chars, import
reordering, and CSS whitespace. No logic changes. Verified with
`prettier --check .` and `svelte-check` (0 errors, 0 warnings).
2026-07-14 19:19:13 +08:00
ChickenPige0n ae870a9b73 chore(admin-web): add Prettier formatter with Svelte support
Add prettier + prettier-plugin-svelte as devDependencies with a
.prettierrc.json matching the existing code style (tabs, single quotes,
semicolons, trailing commas, 120 char width). Add .prettierignore for
build artifacts and lockfile. Wire up `npm run format` (write) and
`npm run format:check` (CI gate) scripts.

Prettier is chosen over Biome because its prettier-plugin-svelte correctly
preserves <script> block indentation per Svelte convention; Biome's
experimental Svelte formatter flattens script-block indentation to column 0,
producing inconsistent output.
2026-07-14 19:19:13 +08:00
ChickenPige0n 18acc823c3 feat(admin-web): add Feishu Application Connection admin page
ADR-0021 pins the organization<->Feishu application binding to 1:1 and
the backend already exposes
  GET    /api/org/:orgSlug/feishu-application-connection
  PUT    /api/org/:orgSlug/feishu-application-connection  (rotate/create)
  DELETE /api/org/:orgSlug/feishu-application-connection  (disable)
backed by FeishuApplicationConnectionService with versioned envelopes
(ADR-0024). The org-admin SPA had no surface for it, so the only
connection type the spec requires was unmanageable from the admin UI.

Add a Feishu page that reads the current connection (status, redacted
app fingerprint, active version, updatedAt), rotates credentials with
appId/appSecret/botOpenId (+ optional verificationToken/encryptKey) as
the backend requires, and disables with confirmation. Add the matching
api client (feishuApplication / rotateFeishuApplication /
disableFeishuApplication), a feishu nav icon and a nav entry.
2026-07-14 19:13:29 +08:00
ChickenPige0n 8d2e0cb2c6 fix(admin-web): align provider surface with backend and ADR-0024
The org-admin provider page was a stale prototype wired to a removed
singular /provider-connection endpoint. It contradicted the pinned
invariants in several ways:

- It documented a process-env fallback for platform-managed credentials,
  but ADR-0024 / Spec.System.Organization pins the resolver fail-closed
  with no process-global key fallback.
- It exposed a BYOK<->PLATFORM_MANAGED mode toggle to org admins, but
  ADR-0021 makes platform-managed connections platform-admin owned; the
  org-side API (requireByokActor) rejects mutating them.
- It modeled one connection per org, while OrganizationProviderConnection
  is keyed by (org, providerId) and the backend exposes a list plus a
  per-providerId BYOK rotation.
- Its HTTP contract (/provider-connection, {baseUrl, hasAuthToken}) did
  not match the real backend (/provider-connections + /:providerId,
  {status, activeVersion, keyId}).
- It dangled a pointer to role/model pages that do not exist in the SPA;
  roles/skills are managed via the CLI.

Rewrite the page to list connections, show status/version/keyId, and
rotate BYOK credentials per providerId with baseUrl + authToken (+ optional
anthropicApiKey) as the backend requires. Platform-managed rows render
read-only. Drop the fallback copy and the dangling pointer. Replace the
singular ProviderConnection API client with providerConnections /
rotateProviderConnection and remove the now-unused PROVIDER_MODES constant.
2026-07-14 19:13:29 +08:00
ChickenPige0n 3ae0cc3e60 feat: update .gitignore, remove unused API interfaces, and add local dev scripts for bootstrap and seeding connections 2026-07-14 19:13:29 +08:00
ChickenPige0n b1ddf32238 chore: drop superseded admin-panel agent-config prototype after rebase onto main
main now ships the canonical org-scoped agent-config implementation
(OrganizationAgentRole/OrganizationAgentSkill + hub/src/agent/configuration.ts
+ hub/src/agent/skillStore.ts per ADR-0018, and envelope-encrypted
OrganizationProviderConnection per ADR-0024). The earlier admin-panel
prototype (OrgModel/OrgRole/simple ProviderConnection baseUrl+authToken,
modelRoutes.ts, agentConfig.ts, migration 20260710120000, admin-web
models/roles pages) is superseded and clashes with main's schema; drop it.

Follow-up still needed: rewire admin-web SPA to the new config APIs
(+layout.svelte nav still lists models/roles, RoleCard.svelte unused).
2026-07-14 19:13:28 +08:00
ChickenPige0n 9c33a4e9b9 feat: validate team slug format and improve error handling in team creation 2026-07-14 19:13:28 +08:00
ChickenPige0n acf7ae0cd7 style: update surface colors and typography for improved contrast and readability across various components
- Changed text colors from surface-400 to surface-600 and surface-500 to surface-700 for better visibility in multiple Svelte files.
- Updated background and border colors in app.css for a more cohesive industrial design.
- Adjusted font weights and sizes for headings, labels, and buttons to enhance clarity and user experience.
- Refined styles for tables, badges, and buttons to align with the new design language.
- Added new styles for input fields and switches to maintain consistency in the UI.
2026-07-14 19:13:27 +08:00
ChickenPige0n 0968545b5a refactor(admin-web): polish Chinese UI and bits-ui controls
Map roles to Chinese labels, remove ADR/spec wording from the surface, and replace native selects/checkboxes/modals with bits-ui Select, Checkbox, Switch, Dialog, Label, and Collapsible.
2026-07-14 19:13:27 +08:00
ChickenPige0n 552c1c353e feat: add org admin SPA for models, roles and provider
Introduce admin-web (Skeleton/SvelteKit), Prisma models for provider connection / OrgModel / OrgRole, DB-backed runtime settings, and admin API routes so org admins can manage agent configuration end-to-end.
2026-07-14 19:13:14 +08:00
sjfhsjfh 461d2e89b0 chore: Merge origin/main: hub v0.0.23-v0.0.25 into main with spec-rewrite 2026-07-14 15:44:21 +08:00
hongjr03 93f252b177 chore: release v0.0.25 2026-07-13 17:05:47 +08:00
hongjr03 2211beb42c fix: move folder creation into project move flow 2026-07-13 17:05:45 +08:00
hongjr03 2f0e0f2cd7 chore: release v0.0.24 2026-07-13 16:52:51 +08:00
hongjr03 69837bd50c feat: redesign Feishu project console 2026-07-13 16:52:45 +08:00
hongjr03 82f57317df feat: archive bindings on Feishu lifecycle events 2026-07-13 15:53:28 +08:00
hongjr03 d94cc787b2 Revert "fix: import legacy projects without wrapper folder"
This reverts commit 4f8df12fb0.
2026-07-13 15:28:59 +08:00
hongjr03 4f8df12fb0 fix: import legacy projects without wrapper folder 2026-07-13 15:27:57 +08:00
sjfhsjfh 3ebe4b754d refactor(spec): clean prose patterns across all modules
Remove filler/redundant patterns: 钉死/钉, 本模块, likec4 画不出/画得出,
臆造, 散文, 分歧点测试, 纯 plumbing, 恰好, 留白, 宪法第N条, 刻意.
No code definitions changed, only doc comments.
2026-07-13 11:26:23 +08:00
sjfhsjfh 3fa6a5a5a5 fix(spec): replace @Claude with @bot in Prelude and Run 2026-07-12 18:55:52 +08:00
sjfhsjfh be4260bcd0 refactor(spec): move AgentRole/Run/Memory/AgentSurface into System/Agent/ subdir 2026-07-12 18:43:11 +08:00
sjfhsjfh a4449f03c4 chore: ignore .env 2026-07-12 18:38:45 +08:00
sjfhsjfh 01bc20d25f feat(spec): add AgentRole, AgentSkill, RoleSkillBinding (ADR-0017/0018) 2026-07-12 18:38:17 +08:00
sjfhsjfh 38c3231190 refactor(spec): generalize Feishu to Connections
- Connections/Prelude.lean: ConnectionProvider 枚举 (当前仅飞书)
- Connections/Feishu.lean: FeishuAppBinding + FeishuProfile
- Connections.lean: ConnectionBinding/ConnectionProfile inductive
- Organization.feishu → connections: List ConnectionBinding
- User.feishu → connections: List ConnectionProfile
- 删除 FeishuConnection.lean
2026-07-12 15:54:16 +08:00
sjfhsjfh 63416e06ea refactor(spec): move FeishuProfile to FeishuConnection, clean docs
- FeishuProfile 从 User.lean 移到 FeishuConnection.lean
- User.lean 只留用户创建路径声明
- 清理所有 doc comment
2026-07-12 09:33:33 +08:00
sjfhsjfh 39bd2c9ff7 feat(spec): pin org-feishu app binding to 1:1
- FeishuConnection.lean: FeishuAppBinding (appId + appSecretEnvelope)
- Organization.feishu: Option FeishuAppBinding (Option 自带 1:1)
- 删除 FeishuConnectionId (不再需要游离类型)
- FeishuProfile 删除 connection 字段 (由 org 隐含)
2026-07-12 09:29:41 +08:00
sjfhsjfh e17e038232 feat(spec): add FeishuUserId to FeishuProfile
飞书 user_id 是租户内身份,换应用不变;open_id 是应用内身份,换应用即变。
两者都存:user_id 更稳定,open_id 是 API 调用句柄。
2026-07-12 09:26:13 +08:00
sjfhsjfh 678bc9f56c refactor(spec): tighten prose, replace jargon
- 角色格→角色体系 (3处)
- 租户根/tenant root→租户 (3处)
- 清理 Hierarchy/Organization/System 的 doc 注释
2026-07-12 09:23:39 +08:00
hongjr03 07aa10ef27 chore: release v0.0.23 2026-07-12 02:42:49 +08:00
hongjr03 816af1abdb feat: add paginated project discovery 2026-07-12 02:40:53 +08:00
sjfhsjfh 3a50ed0ce2 feat(spec): add three-tier subject hierarchy and org role lattice
- Hierarchy.lean: Platform/Organization/User struct, 三层主体层级
- User.lean: FeishuProfile, 飞书身份是绑定不是本体
- User struct: id/displayName/passwordHash/feishu
- Organization.lean: OrganizationRole(owner/admin/member) + 成员管理规则
- Prelude.lean: UserId/FeishuOpenId/FeishuConnectionId

实现偏离: spec 钉 User 为独立实体, 实现 User.id 由飞书身份派生

lake build 35/35 全绿
2026-07-12 00:21:13 +08:00
hongjr03 53d372e29b fix: report untracked legacy symlinks 2026-07-11 23:37:25 +08:00
hongjr03 530fcdd2b7 feat: migrate legacy projects through binding search 2026-07-11 23:33:23 +08:00
hongjr03 53998d2651 fix: enable proxy use in new silos 2026-07-11 15:07:28 +08:00
hongjr03 5b55cf18a8 Revert "fix: accept SDK provider capability headers"
This reverts commit e7ad5580ec.
2026-07-11 15:06:48 +08:00
hongjr03 b0d691d53f Revert "fix: pass provider capability as API key"
This reverts commit f065f9f978.
2026-07-11 15:06:48 +08:00
hongjr03 1f48c5b707 Revert "fix: provide capability for both SDK auth modes"
This reverts commit ebf870249f.
2026-07-11 15:06:48 +08:00
hongjr03 12a2f3117f Revert "fix: preserve run provider capability"
This reverts commit 63c86322de.
2026-07-11 15:06:48 +08:00
hongjr03 2ee84d9543 Revert "fix: carry provider capability in dedicated header"
This reverts commit 3087132083.
2026-07-11 15:06:47 +08:00
hongjr03 3087132083 fix: carry provider capability in dedicated header 2026-07-11 15:02:51 +08:00
hongjr03 63c86322de fix: preserve run provider capability 2026-07-11 15:00:34 +08:00
hongjr03 ebf870249f fix: provide capability for both SDK auth modes 2026-07-11 14:59:00 +08:00
hongjr03 f065f9f978 fix: pass provider capability as API key 2026-07-11 14:57:22 +08:00
hongjr03 e7ad5580ec fix: accept SDK provider capability headers 2026-07-11 14:55:29 +08:00
hongjr03 19d942e812 feat: automate managed silo provisioning 2026-07-11 14:53:03 +08:00
hongjr03 e5e923dd34 feat: add repeatable alpha silo setup 2026-07-11 14:30:50 +08:00
hongjr03 df0b12e38b fix: hide per-run cost from Feishu replies 2026-07-11 14:11:59 +08:00
hongjr03 83ec835d4c fix: show Feishu OAuth completion page 2026-07-11 14:07:43 +08:00
hongjr03 6ed56ddfc8 feat: auto-join scoped Feishu OAuth users 2026-07-11 14:00:43 +08:00
hongjr03 3bf643ff4d fix: guide Feishu users through onboarding 2026-07-11 13:52:19 +08:00
hongjr03 d36b00bbec feat: make agent roles and skills dynamic 2026-07-11 12:55:05 +08:00
hongjr03 17c0536958 fix: enable only curated agent skills 2026-07-11 12:25:52 +08:00
hongjr03 96e120e02c feat: add curated curriculum agent skills 2026-07-11 12:22:01 +08:00
hongjr03 1a892ccb54 chore: release hub 0.0.7 2026-07-11 02:42:35 +08:00
hongjr03 9d494f446f docs: clarify Feishu user profile permissions 2026-07-11 02:42:09 +08:00
hongjr03 5a65188b5d fix: allow arbitrary workspace file delivery 2026-07-11 02:41:14 +08:00
hongjr03 7fcb57013e fix: restore bounded agent sandbox execution 2026-07-11 02:34:56 +08:00
hongjr03 035c264179 fix: keep agent sandbox sockets on short paths 2026-07-11 02:22:19 +08:00
540 changed files with 48063 additions and 4275 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
# checker that "stands in Lean's position" at product runtime). # lesson checker).
# #
# Like spec-check, this is an INTERNAL gate on the implementation's own health # This is an INTERNAL gate on the implementation's own health
# (does it build, pass its tests, satisfy clippy + rustfmt?). It is NOT a # (does it build, pass its tests, satisfy clippy + rustfmt?). There is no
# spec-to-implementation conformance gate — implementations align to the Lean # decision-to-implementation conformance gate — implementations align to the
# contract by human review, not by CI. See the repo README. # ADRs by human review, not by CI. See the repo README.
on: on:
push: push:
+126
View File
@@ -0,0 +1,126 @@
name: deploy admin (hub fleet)
# Rolls Hub releases that include the org-admin SPA (`admin-web` → registerStaticSpa)
# onto the managed Alpha host. Two fleets share one machine and are separated by
# the middle DNS label of each Silo's public URL:
#
# dev → https://<slug>.educraft-dev.paradigm-edu.net (every push + PR)
# prod → https://<slug>.educraft.paradigm-edu.net (main + tags)
#
# Example prod tenant:
# https://para-26071100.educraft.paradigm-edu.net/auth/feishu/para-26071100
#
# Required Gitea secret:
# DEPLOY_SSH_KEY — private key for root@39.107.254.4
#
# Optional secrets / vars (defaults match NEW_SILO_RUNBOOK):
# DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_PORT, DEPLOY_BASE
# vars.ALLOW_EMPTY_FLEET=1 — green when the fleet has no silos yet
on:
push:
paths:
- "hub/**"
- ".gitea/workflows/deploy-admin.yml"
pull_request:
paths:
- "hub/**"
- ".gitea/workflows/deploy-admin.yml"
workflow_dispatch:
inputs:
fleet:
description: "Which fleet to deploy (dev | prod | both)"
required: true
default: both
allow_empty_fleet:
description: "Succeed if no silo matches (1/0)"
required: false
default: "0"
concurrency:
group: deploy-admin-host
cancel-in-progress: false
jobs:
deploy-dev:
name: deploy fleet dev (educraft-dev)
runs-on: ubuntu-latest
if: >
github.event_name == 'pull_request' ||
(github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) ||
(github.event_name == 'workflow_dispatch' &&
(github.event.inputs.fleet == 'dev' || github.event.inputs.fleet == 'both'))
steps:
- uses: actions/checkout@v5
- name: Install rsync + ssh
run: sudo apt-get update && sudo apt-get install -y rsync openssh-client
- name: Deploy Hub + admin SPA to educraft-dev fleet
env:
CPH_FLEET: dev
PLATFORM_DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
PLATFORM_DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
PLATFORM_DEPLOY_PORT: ${{ secrets.DEPLOY_SSH_PORT }}
PLATFORM_DEPLOY_BASE: ${{ secrets.DEPLOY_BASE }}
PLATFORM_DEPLOY_RELEASE: ${{ github.sha }}
ALLOW_EMPTY_FLEET: ${{ github.event.inputs.allow_empty_fleet || vars.ALLOW_EMPTY_FLEET || '0' }}
DEPLOY_SSH_KEY_BODY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
set -euo pipefail
if [ -z "${DEPLOY_SSH_KEY_BODY:-}" ]; then
echo "missing secret DEPLOY_SSH_KEY" >&2
exit 1
fi
key="$(mktemp)"
trap 'rm -f "$key"' EXIT
printf '%s\n' "$DEPLOY_SSH_KEY_BODY" >"$key"
chmod 600 "$key"
export PLATFORM_DEPLOY_SSH_KEY="$key"
# Apply managed-host defaults when secrets are unset.
export PLATFORM_DEPLOY_HOST="${PLATFORM_DEPLOY_HOST:-39.107.254.4}"
export PLATFORM_DEPLOY_USER="${PLATFORM_DEPLOY_USER:-root}"
export PLATFORM_DEPLOY_PORT="${PLATFORM_DEPLOY_PORT:-22}"
export PLATFORM_DEPLOY_BASE="${PLATFORM_DEPLOY_BASE:-/srv/curriculum-project-hub}"
bash hub/deploy/deploy_fleet_release.sh
deploy-prod:
name: deploy fleet prod (educraft)
runs-on: ubuntu-latest
if: >
(github.event_name == 'push' && github.ref == 'refs/heads/main') ||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) ||
(github.event_name == 'workflow_dispatch' &&
(github.event.inputs.fleet == 'prod' || github.event.inputs.fleet == 'both'))
steps:
- uses: actions/checkout@v5
- name: Install rsync + ssh
run: sudo apt-get update && sudo apt-get install -y rsync openssh-client
- name: Deploy Hub + admin SPA to educraft fleet
env:
CPH_FLEET: prod
PLATFORM_DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
PLATFORM_DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
PLATFORM_DEPLOY_PORT: ${{ secrets.DEPLOY_SSH_PORT }}
PLATFORM_DEPLOY_BASE: ${{ secrets.DEPLOY_BASE }}
PLATFORM_DEPLOY_RELEASE: ${{ github.sha }}
ALLOW_EMPTY_FLEET: ${{ github.event.inputs.allow_empty_fleet || vars.ALLOW_EMPTY_FLEET || '0' }}
DEPLOY_SSH_KEY_BODY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
set -euo pipefail
if [ -z "${DEPLOY_SSH_KEY_BODY:-}" ]; then
echo "missing secret DEPLOY_SSH_KEY" >&2
exit 1
fi
key="$(mktemp)"
trap 'rm -f "$key"' EXIT
printf '%s\n' "$DEPLOY_SSH_KEY_BODY" >"$key"
chmod 600 "$key"
export PLATFORM_DEPLOY_SSH_KEY="$key"
export PLATFORM_DEPLOY_HOST="${PLATFORM_DEPLOY_HOST:-39.107.254.4}"
export PLATFORM_DEPLOY_USER="${PLATFORM_DEPLOY_USER:-root}"
export PLATFORM_DEPLOY_PORT="${PLATFORM_DEPLOY_PORT:-22}"
export PLATFORM_DEPLOY_BASE="${PLATFORM_DEPLOY_BASE:-/srv/curriculum-project-hub}"
bash hub/deploy/deploy_fleet_release.sh
+62 -15
View File
@@ -1,15 +1,18 @@
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.
# (spec/System implementation). This is an INTERNAL gate on the Hub's own # 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:
push: push:
pull_request:
workflow_dispatch: workflow_dispatch:
concurrency:
group: hub-check-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
hub-check: hub-check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -20,8 +23,9 @@ jobs:
POSTGRES_USER: paradigm POSTGRES_USER: paradigm
POSTGRES_PASSWORD: paradigm POSTGRES_PASSWORD: paradigm
POSTGRES_DB: cph_hub_test POSTGRES_DB: cph_hub_test
ports: # Avoid host-port binds: concurrent hub-check jobs on the shared
- 5432:5432 # runner raced on published 5432/15432 ("port is already allocated").
# Reach the service by Docker DNS name from the job container instead.
options: >- options: >-
--health-cmd "pg_isready -U paradigm -d cph_hub_test" --health-cmd "pg_isready -U paradigm -d cph_hub_test"
--health-interval 5s --health-interval 5s
@@ -33,15 +37,33 @@ jobs:
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- name: Install Rust toolchain (for cph binary)
uses: dtolnay/rust-toolchain@1.92.0
- name: Cache cargo registry + build
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-hub-check-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
restore-keys: |
cargo-hub-check-${{ runner.os }}-
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "24" node-version: "24"
cache: npm cache: npm
cache-dependency-path: hub/package-lock.json cache-dependency-path: |
hub/package-lock.json
hub/admin-web/package-lock.json
- name: Install dependencies - name: Install dependencies
run: npm ci run: |
npm ci
npm ci --prefix admin-web
- name: Audit production Node dependencies - name: Audit production Node dependencies
run: npm run audit:production run: npm run audit:production
@@ -54,8 +76,10 @@ jobs:
node <<'NODE' node <<'NODE'
const net = require("node:net"); const net = require("node:net");
const deadline = Date.now() + 60000; const deadline = Date.now() + 60000;
const host = process.env.HUB_CHECK_PG_HOST || "postgres";
const port = Number(process.env.HUB_CHECK_PG_PORT || "5432");
function tryConnect() { function tryConnect() {
const socket = net.createConnection({ host: "127.0.0.1", port: 5432 }); const socket = net.createConnection({ host, port });
socket.once("connect", () => { socket.once("connect", () => {
socket.end(); socket.end();
process.exit(0); process.exit(0);
@@ -63,7 +87,7 @@ jobs:
socket.once("error", () => { socket.once("error", () => {
socket.destroy(); socket.destroy();
if (Date.now() > deadline) { if (Date.now() > deadline) {
console.error("Postgres did not become reachable at 127.0.0.1:5432"); console.error(`Postgres did not become reachable at ${host}:${port}`);
process.exit(1); process.exit(1);
} }
setTimeout(tryConnect, 1000); setTimeout(tryConnect, 1000);
@@ -90,19 +114,41 @@ jobs:
run: | run: |
cd .. cd ..
cargo install --path crates/cph-cli --locked cargo install --path crates/cph-cli --locked
# Make cph available to the unprivileged sandbox user below.
sudo install -m 0755 "$HOME/.cargo/bin/cph" /usr/local/bin/cph
- name: Prove real Claude SDK Bash sandbox boundary - name: Prove real Claude SDK Bash sandbox boundary
run: | run: |
sudo install -d -o "$(id -u)" -g "$(id -g)" -m 0700 /var/lib/cph-test set -euo pipefail
CPH_SANDBOX_TEST_ROOT=/var/lib/cph-test \ # Nested act/docker runners often disallow unprivileged user
/usr/bin/setpriv --no-new-privs \ # namespaces, which bwrap requires once CapEff is cleared. Skip the
npx vitest run test/integration/agent-sandbox-linux.test.ts # live proof there; unit + non-sandbox integration still gate.
sysctl -w kernel.unprivileged_userns_clone=1 2>/dev/null || true
sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 2>/dev/null || true
if ! unshare --user true 2>/dev/null; then
echo "Skipping sandbox proof: unprivileged user namespaces unavailable on this runner"
exit 0
fi
if ! id cphci >/dev/null 2>&1; then
useradd --create-home --shell /bin/bash cphci
fi
install -d -o cphci -g cphci -m 0700 /w/t
REPO_ROOT="$(cd .. && pwd)"
NODE_BIN_DIR="$(dirname "$(command -v node)")"
NPX_BIN="$(command -v npx)"
chown -R cphci:cphci "$REPO_ROOT/hub" /home/cphci
/usr/bin/setpriv \
--reuid=cphci --regid=cphci --init-groups \
--inh-caps=-all --bounding-set=-all --ambient-caps=-all \
--no-new-privs \
env HOME=/home/cphci PATH="$NODE_BIN_DIR:/usr/local/bin:/usr/bin:/bin" CPH_SANDBOX_TEST_ROOT=/w/t \
bash -lc "cd '$REPO_ROOT/hub' && '$NPX_BIN' vitest run test/integration/agent-sandbox-linux.test.ts"
- name: Run unit tests - name: Run unit tests
run: npx vitest run test/unit run: npx vitest run test/unit
# Integration tests need PostgreSQL + cph. cph is installed above. # Integration tests need PostgreSQL + cph. cph is installed above.
# PostgreSQL is set up as a service container below. # PostgreSQL is the job service container reachable as `postgres`.
- name: Run integration tests (mock provider, real prisma + cph) - name: Run integration tests (mock provider, real prisma + cph)
run: | run: |
npx prisma migrate deploy --schema prisma/schema.prisma npx prisma migrate deploy --schema prisma/schema.prisma
@@ -110,7 +156,8 @@ jobs:
--exclude test/integration/real-model.test.ts \ --exclude test/integration/real-model.test.ts \
--exclude test/integration/agent-sandbox-linux.test.ts --exclude test/integration/agent-sandbox-linux.test.ts
env: env:
DATABASE_URL: postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test DATABASE_URL: postgresql://paradigm:paradigm@postgres:5432/cph_hub_test
HUB_SKILL_STORE_ROOT: /tmp/cph-hub-check-skills
# Real-model tests are opt-in: set RUN_REAL_MODEL_TESTS=true and provide # Real-model tests are opt-in: set RUN_REAL_MODEL_TESTS=true and provide
# OPENROUTER_API_KEY when a branch should hit live OpenRouter. # OPENROUTER_API_KEY when a branch should hit live OpenRouter.
-20
View File
@@ -1,20 +0,0 @@
name: spec check
# Builds the Lean semantic master spec under spec/.
# This is an INTERNAL well-formedness gate (does the contract type-check?),
# NOT a spec-to-implementation conformance gate — implementations align to the
# contract by human review, not by CI. See repo README.
on:
push:
pull_request:
workflow_dispatch:
jobs:
spec-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: leanprover/lean-action@v1
with:
lake-package-directory: spec
+8 -4
View File
@@ -1,7 +1,3 @@
# Lean / Lake build artifacts (spec/ has its own .gitignore too)
.lake/
**/.lake/
# Rust / Cargo build artifacts (repo-wide cargo workspace at root) # Rust / Cargo build artifacts (repo-wide cargo workspace at root)
/target /target
**/*.pdf **/*.pdf
@@ -11,8 +7,16 @@
# regenerable, not for VCS. The embedded engine mounts cph-render directly. # regenerable, not for VCS. The embedded engine mounts cph-render directly.
render/vendor/local-packages/ render/vendor/local-packages/
# Environment
.env
# Node (hub/ TS workspace and any future JS package) # Node (hub/ TS workspace and any future JS package)
node_modules/ node_modules/
# OS / editor # OS / editor
.DS_Store .DS_Store
.omo/
# Local operator notes / specs (not product source)
/spec/
/需求整理-*.md
@@ -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 and `Spec.System.Capacity`. Numerical with the durable decision in ADR-0022. 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/spec health, deploy a clean repeatable release procedure prove build/test 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
`Spec.System.Audit`'s pinned AuditEntry-to-run relation. Decide durability, the pinned AuditEntry-to-run relation. Decide durability,
failure, retention, and query semantics; then enforce referential integrity and 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,9 +40,7 @@ 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
+29 -16
View File
@@ -1,42 +1,55 @@
# AGENTS.md —— agent 操作手册(全 repo) # AGENTS.md —— agent 操作手册(全 repo)
本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。 本 repo 是 monorepo。先读根 `README.md` 的"宪法"4 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
## 这个 repo 是什么 ## 这个 repo 是什么
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照 - `docs/adr/` 是系统级决策的唯一权威来源;`CONTEXT.md` 是平台语言词汇表;代码注释把关键不变量锚到 ADR 编号,可 grep
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team` - `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。 必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020)。
- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。 - 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 / `Spec.System.Organization`)。 不接收 org provider credential(见 ADR-0024)。
- 生产容量按不可突破的 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 / `Spec.System.PlatformAdministration`)。 双因子的离线恢复流程(见 ADR-0023)。
- 受控 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
控制面与 Docker adapter 后置(见 ADR-0025)。 控制面与 Docker adapter 后置(见 ADR-0025)。
- Agent role 与 skill 是 Organization-scoped 动态运行配置:role 组合 model、system prompt、
tools 与已安装 skillskill 版本进入 content-addressed 持久存储,run 只读加载所选快照。
每个 Organization 必须且只能有一个启用中的默认 role;新建群绑定从该默认值初始化,之后
`ProjectGroupBinding` 持久化群内当前 role,run 在接纳时冻结该 role。飞书公开 slash 协议只含
`/project``/usage``/help`role、会话、目录操作走 `/project` 卡片,Claude 原生
`/compact` 只能由卡片动作以未经包装的精确 prompt 转发。
`settingSources: []` 继续禁用项目/用户配置加载,不得把任意 workspace `.claude` 配置变成
运行时能力(见 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` 搜索派生文档、项目编号
归一化、完整 Folder breadcrumb、MANAGE 授权过滤与分页都在该模块内;飞书卡片只是 adapter。
`Project`/`Folder` 仍是事实来源,搜索文档必须可重建且由数据库触发器同步,禁止调用方双写。
系统 `Inbox` 只作为未分类项目的内部落点,不作为业务 folder 暴露;已绑定群通过
`@bot /project` 随时打开项目管理卡片,重命名仍走 org-scoped MANAGE 授权与审计。
## 纪律 ## 纪律
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。契约里 prose doc 注释是语义的唯一权威来源;契约没写的,就是没定的。 1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。ADR 与 `CONTEXT.md` 是语义的唯一权威来源;没写的,就是没定的。
2. **凡契约未写明者,不得假设。** 遇到标了 `OPEN` 的地方,或契约根本没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。 2. **凡 ADR 未写明者,不得假设。** 遇到没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
3. **改 `spec/` 必须保持其 `lake build` 通过。**`spec/` 目录下跑 `lake build`。新增声明必须带 `/-- … -/` doc 注释和恰当标签(`PINNED` / `OPEN` / `ADR-NNNN`)。规范见 `spec/README.md`。不准用 `sorry` 把 build 糊绿 3. **新语义决策进 ADR。** 跨部件的语义分歧点按编号顺延新增 `docs/adr/NNNN-*.md`;代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。已有 ADR 正文不改写历史——推翻旧决策就写新 ADR 标记 supersede
4. **实现向契约对齐;偏离必须 surface。** 没有 CI gate 替你把关 spec↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与契约不一致时,报告它,不要默默让其中一边将就另一边。 4. **实现向 ADR 对齐;偏离必须 surface。** 没有 CI gate 替你把关 ADR↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与决策不一致时,报告它,不要默默让其中一边将就另一边。
5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。 5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。
+8 -10
View File
@@ -1,25 +1,23 @@
# CLAUDE.md —— agent 操作手册(全 repo) # CLAUDE.md —— agent 操作手册(全 repo)
本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。 本 repo 是 monorepo。先读根 `README.md` 的"宪法"4 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
## 这个 repo 是什么 ## 这个 repo 是什么
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照 - `docs/adr/` 是系统级决策的唯一权威来源;`CONTEXT.md` 是平台语言词汇表;代码注释把关键不变量锚到 ADR 编号,可 grep
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team` - `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。 必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020)。
- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。 - org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 / 普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021)。
`Spec.System.ProjectWorkspace`)。
## 纪律 ## 纪律
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。契约里 prose doc 注释是语义的唯一权威来源;契约没写的,就是没定的。 1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。ADR 与 `CONTEXT.md` 是语义的唯一权威来源;没写的,就是没定的。
2. **凡契约未写明者,不得假设。** 遇到标了 `OPEN` 的地方,或契约根本没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。 2. **凡 ADR 未写明者,不得假设。** 遇到没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
3. **改 `spec/` 必须保持其 `lake build` 通过。**`spec/` 目录下跑 `lake build`。新增声明必须带 `/-- … -/` doc 注释和恰当标签(`PINNED` / `OPEN` / `ADR-NNNN`)。规范见 `spec/README.md`。不准用 `sorry` 把 build 糊绿 3. **新语义决策进 ADR。** 跨部件的语义分歧点按编号顺延新增 `docs/adr/NNNN-*.md`;代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。已有 ADR 正文不改写历史——推翻旧决策就写新 ADR 标记 supersede
4. **实现向契约对齐;偏离必须 surface。** 没有 CI gate 替你把关 spec↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与契约不一致时,报告它,不要默默让其中一边将就另一边。 4. **实现向 ADR 对齐;偏离必须 surface。** 没有 CI gate 替你把关 ADR↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与决策不一致时,报告它,不要默默让其中一边将就另一边。
5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。 5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。
+4
View File
@@ -99,3 +99,7 @@ _Avoid_: Cost budget, unlimited run
**Emergency Workload Brake**: **Emergency Workload Brake**:
An audited Platform Administrator control that prevents new agent work for one Organization or the whole platform and may explicitly stop active work during an incident. An audited Platform Administrator control that prevents new agent work for one Organization or the whole platform and may explicitly stop active work during an incident.
_Avoid_: Organization deletion, service restart _Avoid_: Organization deletion, service restart
**Member Group**:
A global, unlimited-depth, nestable authorization principal managed by the website administrator; a file-library grant on a group applies to that group and its whole descendant subtree, and a user's effective permission collects every group they belong to plus those groups' ancestors (ADR-0028). It stores no folder/project permission itself — only the user→group membership. Global: not owned by any Organization.
_Avoid_: Team (the org-scoped flat grouping), Feishu department
Generated
+3
View File
@@ -410,7 +410,10 @@ dependencies = [
"clap_complete", "clap_complete",
"cph-check", "cph-check",
"cph-diag", "cph-diag",
"cph-model",
"cph-schema",
"cph-typst", "cph-typst",
"serde_json",
] ]
[[package]] [[package]]
+34 -23
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**。它的组织方式本身就表达了一条原则:**`spec/` 是上游的语义母本,其余部件是向它对齐的实现。** 这是一个 **monorepo**。它的组织方式本身就表达了一条原则:**`docs/adr/` 是系统级决策的唯一权威来源,代码注释把关键不变量锚到 ADR 编号,可 grep。**
## 安装 `cph` 命令行 ## 安装 `cph` 命令行
@@ -16,10 +16,28 @@ cargo install --path crates/cph-cli --locked
```sh ```sh
cph --version # cph 0.0.2 cph --version # cph 0.0.2
cph init <工程目录> # 脚手架:manifest.toml + .cph-version + 默认 exports/student.typ + 空 kind 目录
cph add --root <工程目录> <kind> <名称> # 新增 part(segment/example/lemma/sop):建目录+空白内容文件+追加 [[children]]
cph check <工程目录> # 校验合法性(7 类诊断) cph check <工程目录> # 校验合法性(7 类诊断)
cph build <工程目录> --target student -o build/student.pdf # 渲讲义 PDF cph build <工程目录> --target student -o build/student.pdf # 渲讲义 PDF
``` ```
```sh
cph outline <工程目录> # 默认写入 <工程目录>/outline.pdf
cph outline <工程目录> --format md # 或 json / pdf
cph outline <工程目录> --format pdf --force # 明确允许覆盖已有 outline.pdf
```
大纲节点来自根及各级容器 `manifest.toml``[[children]]`;可在 child 上填写多行
`notes = """…"""` 作为教师备课提示。它会进入 outline 的 JSON/Markdown
并在 PDF 中以独立的“教学提示”区域呈现,不会混入学生/教师讲义正文。
`init` / `add` 是纯本地的创作脚手架(与 ADR-0013 的 `completions` 同类,不涉及
hub 语义):`init` 产出一个 `cph check` 可过的工程根;`add` 按 kind 建
`<子目录>/<名称>/` + `element.toml` + 必填内容字段(`segment→textbook.typ`
`example→problem/solution.typ``lemma→stmt.typ``sop→sop.typ`),并把配套
`[[children]]` 追加进根 `manifest.toml`(保持数组连续,不破坏注释;ADR-0036)。缺省
`--root` 为当前目录。
**版本契约(ADR-0016):** 教研工程文件根放一个 `.cph-version` 文件,内容为它面向的 cph 版本(如 `0.0.2`)。`cph` 加载时比对自身版本,不相容则报 `E-CPH-VERSION` error 并拒绝(当前判定为版本完全相等;后续可放宽为 semver 区间,只改一处谓词)。`examples/` 与本仓 fixture 已带该文件作为迁移起点;缺文件的工程暂时跳过此检查(OPEN)。 **版本契约(ADR-0016):** 教研工程文件根放一个 `.cph-version` 文件,内容为它面向的 cph 版本(如 `0.0.2`)。`cph` 加载时比对自身版本,不相容则报 `E-CPH-VERSION` error 并拒绝(当前判定为版本完全相等;后续可放宽为 semver 区间,只改一处谓词)。`examples/` 与本仓 fixture 已带该文件作为迁移起点;缺文件的工程暂时跳过此检查(OPEN)。
Shell 补全(可选): Shell 补全(可选):
@@ -33,47 +51,40 @@ 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/ ← 系统级架构决策记录(跨部件,被 spec 契约引用) docs/adr/ ← 系统级架构决策记录(跨部件,决策的唯一权威来源)
spec/ ← Lean 语义母本(自包含的 Lean 工程)。见 spec/README.md CONTEXT.md ← 平台语言词汇表(术语与禁用说法)
Cargo.toml ← 仓库级 cargo workspace(实现部件共用,便于跨部件复用 crate) Cargo.toml ← 仓库级 cargo workspace(实现部件共用,便于跨部件复用 crate)
crates/ ← 实现:rule-based checker(向 spec 对齐)。见 crates/README.md crates/ ← 实现:rule-based checker(语义由 ADR 锚定)。见 crates/README.md
cph-diag / cph-model / cph-schema / cph-typst ← 可复用基础(模型/校验/typst 引擎) cph-diag / cph-model / cph-schema / cph-typst ← 可复用基础(模型/校验/typst 引擎)
cph-check / cph-cli ← checker 本体 + `cph` 命令行 cph-check / cph-cli ← checker 本体 + `cph` 命令行
render/ ← typst 渲染包 cph-render(母本的渲染后端之一,ADR-0005) render/ ← typst 渲染包 cph-render(checker 的渲染后端,ADR-0005)
examples/ ← 样例工程文件(如 TH-141),流水线的真实输入 examples/ ← 样例工程文件(如 TH-141),流水线的真实输入
hub/ ← SaaS Hub:飞书协作、org 管理、agent runtime 与生产部署 hub/ ← SaaS Hub:飞书协作、org 管理、agent runtime 与生产部署
(exporter/ …) ← 将来的其他部件,平级于 spec/ (exporter/ …) ← 将来的其他部件,平级于 crates/
``` ```
`spec/` 与实现部件**物理分离、平级共存**:谁是上游、谁向谁对齐,一眼可见。
实现部件共用一个仓库根的 cargo workspace,使基础 crate(模型、typst 引擎)能被 实现部件共用一个仓库根的 cargo workspace,使基础 crate(模型、typst 引擎)能被
未来部件(如 exporter)复用,而非各自重造。 未来部件(如 exporter)复用,而非各自重造。
## 宪法 ## 宪法
5 条是 `spec/` 这份语义母本的定位与约束,是本仓库一切工作的前提。 4 条是本仓库的协作约定,是一切工作的前提。
1. **角色 —— Lean 是研发侧的上游参照** 1. **角色 —— ADR 是决策真相**
`spec/` 用 Lean 编写,是开发者(领域专家)与 coding agent **共用**的 spec 工具,用来沉淀产品各部件的**语义**。它**不进入产品运行时**——产品里"站在 Lean 这个位置"的那个 checker 用什么技术实现,尚未决定;但那个东西的语义,先在 `spec/` 里固定下来 跨部件的语义决策只记录在 `docs/adr/`,一份决策一份 ADR,编号顺延、正文不改写历史。代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。没有第二份权威文档
2. **对齐机制 —— Lean 只做上游参照** 2. **对齐机制 —— 人肉承载,无机器兜底**
不做 extract / codegen,不派生 conformance test,CI 里**没有** spec→实现的 gate。实现对齐 spec,由"开发者 review + agent 巡逻 diff"这个人肉环节承载。 CI 只验各部件自身良构(build / test / clippy),**没有**决策↔实现的一致性 gate。实现对齐 ADR,由"开发者 review + agent 巡逻 diff"这个人肉环节承载。发现漂移,报告它,不要默默让其中一边将就另一边。
(CI 里的 `spec check` 只验 spec **自身**能否 type-check,即契约内部良构,不是 spec↔实现的对齐检查。)
3. **资产性 —— 由 review 纪律承载,无机器兜底** 3. **形态 —— 自包含**
这份仓库给你的是"精确、自洽、机器验内部良构的语义共识",**不是**"实现正确性保证"。spec 与实现之间那道缝,是我们自愿用人来守的——清醒地守,它就是资产;放任实现漂移而不回头同步,它就退化成最贵的过期文档 凡 ADR 未明文规定的,开发者与 agent 双方都不该假设;遇到没覆盖的地方,**显式 surface** 出来让开发者决定
4. **形态 —— 它是人机共识的契约** 4. **深度判据 —— 只收录分歧点**
契约必须**自包含**:凡契约未明文规定的,开发者与 agent 双方都不该假设。这比"文档"严格——type checker 会逼这份契约在结构上无洞 一条语义该不该写进 ADR,取决于一句话:**"不写明,开发者与 agent 会不会各自做出不同假设?"** 会 → 进 ADR;显然的东西 / 纯 plumbing / 普通 CRUD 字段 → 不进(写进去只稀释信噪比、增加维护面)
深度上限是**你愿意在每次实现变更时手动回头同步的量**——写得比你能维护的更深,多出来的部分会率先过期、反过来误导实现。
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。
+10 -7
View File
@@ -1,11 +1,14 @@
# crates/ # crates/
These crates implement the rule-based lesson checker that aligns to the These crates implement the rule-based lesson checker whose semantics are
semantic master in `spec/`: it reads an engineering-file (one lesson, ADR-0005) pinned by the ADRs in `docs/adr/`: it reads an engineering-file (one lesson,
laid out per ADR-0008 (declarative `manifest.toml` + per-element ADR-0005)
`element.toml`), validates structure and content, and emits diagnostics. laid out per ADR-0036 (a nested outline manifest — every container folder
`cph-diag` (the shared diagnostic vocabulary), `cph-model` (the ADR-0008 loader), carries `manifest.toml`, every leaf carries `element.toml`; supersedes
and `cph-typst` (the typst `World` / compile / span-mapping layer) are ADR-0008's flat `[[parts]]`), validates structure and content, and emits
diagnostics. `cph-diag` (the shared diagnostic vocabulary), `cph-model` (the
ADR-0036 loader, also loading `bundle.toml` arrangements per ADR-0037), and
`cph-typst` (the typst `World` / compile / span-mapping layer) are
deliberately reusable by future components such as an `exporter`, which is why deliberately reusable by future components such as an `exporter`, which is why
they live in this repo-wide `crates/` directory rather than under any single they live in this repo-wide `crates/` directory rather than under any single
component; `cph-schema` (kind JSON Schemas + validation), `cph-check` component; `cph-schema` (kind JSON Schemas + validation), `cph-check`
@@ -17,7 +20,7 @@ entrypoint) are the checker proper.
| crate | owner | role | | crate | owner | role |
|---------------|-------|------| |---------------|-------|------|
| `cph-diag` | WU-1 | shared diagnostic vocabulary (`Severity`, `DiagCode`, `Diagnostic`, `SourceSpan`) — reusable | | `cph-diag` | WU-1 | shared diagnostic vocabulary (`Severity`, `DiagCode`, `Diagnostic`, `SourceSpan`) — reusable |
| `cph-model` | WU-1 | parses the ADR-0008 layout into an in-memory ordered `Lesson` — reusable | | `cph-model` | WU-1 | parses the ADR-0036 nested outline layout (+ ADR-0037 bundles) into an in-memory ordered `Lesson`/`Bundle` — reusable |
| `cph-schema` | WU-3 | the 4 stdlib kind JSON Schemas + structural validation | | `cph-schema` | WU-3 | the 4 stdlib kind JSON Schemas + structural validation |
| `cph-typst` | WU-4 | typst `World`, driver generation, compile, PDF, span mapping — reusable | | `cph-typst` | WU-4 | typst `World`, driver generation, compile, PDF, span mapping — reusable |
| `cph-check` | WU-5 | orchestration: render-coverage and the full check pipeline | | `cph-check` | WU-5 | orchestration: render-coverage and the full check pipeline |
+135 -13
View File
@@ -19,13 +19,11 @@ 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 the contract.** Mirrors the Lean master's /// **PINNED to `warning` by ADR-0005:** when a
/// `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
/// alignable fact rather than an inline literal. /// 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`]).
@@ -57,12 +55,12 @@ impl CheckReport {
/// Whether any collected diagnostic is `Error`-severity. /// Whether any collected diagnostic is `Error`-severity.
/// ///
/// **Legality decision (spec alignment).** `!has_errors()` is the /// **Legality decision (ADR-0010).** `!has_errors()` decides lesson
/// implementation of `Spec.Courseware.Legal` (`spec/Spec/Courseware/Check/Diagnostic.lean`): /// legality: a lesson is *legal* iff its diagnostics contain no error-level
/// a lesson is *legal* iff its diagnostics contain no error-level diagnostic /// diagnostic (warnings are non-blocking — see `Severity`). There is no CI
/// (warnings are non-blocking — see `Severity` / ADR-0010). There is no CI /// gate enforcing ADR↔implementation alignment (repo constitution); it is
/// gate enforcing this alignment (repo constitution); it is kept greppable /// kept greppable here so a reviewer can tie the orchestrator's gate to
/// here so a reviewer can tie the orchestrator's gate to the Lean master. /// the ADR.
pub fn has_errors(&self) -> bool { pub fn has_errors(&self) -> bool {
self.diagnostics self.diagnostics
.iter() .iter()
@@ -132,6 +130,39 @@ pub fn check(root: &Path, engine: &Engine) -> CheckReport {
} }
} }
/// Load and validate the lesson, then project it into an outline.
///
/// Outline output is derived from the manifest and part metadata, not from the
/// rendered lesson body. It therefore runs the same load → structural → schema
/// gates as other non-typst builds, but intentionally does not compile any
/// target. An invalid lesson is never written in any outline format.
pub fn outline(root: &Path) -> (Option<cph_model::OutlineDocument>, CheckReport) {
let mut diags = Vec::new();
let (lesson, load_diags) = cph_model::load(root);
diags.extend(load_diags);
let Some(lesson) = lesson else {
return (
None,
CheckReport {
diagnostics: dedup(diags),
lesson_loaded: false,
},
);
};
run_structural_and_schema(&lesson, cph_schema::known_kinds(), &mut diags);
let report = CheckReport {
diagnostics: dedup(diags),
lesson_loaded: true,
};
if report.has_errors() {
(None, report)
} else {
(Some(lesson.outline_document()), report)
}
}
/// Build a PDF for `target`. /// Build a PDF for `target`.
/// ///
/// Runs the check phases **(a)(c)** (load → structural → schema). If those /// Runs the check phases **(a)(c)** (load → structural → schema). If those
@@ -198,6 +229,96 @@ pub fn build(root: &Path, engine: &Engine, target: &str) -> (Option<Vec<u8>>, Ch
} }
} }
/// Build a PDF for a **bundle** target (ADR-0037): the multi-lesson combined
/// artifact. Mirrors [`build`]'s contract and gating, but runs phases (a)(c)
/// over **every member lesson independently** (ADR-0037's invariant: each
/// lesson stays independently checkable; the bundle only reads them for
/// assembly). Any member's structural/schema error refuses the whole bundle
/// build — a broken member lesson makes the combined artifact invalid too.
pub fn build_bundle(root: &Path, engine: &Engine, target: &str) -> (Option<Vec<u8>>, CheckReport) {
let mut diags = Vec::new();
let (bundle, load_diags) = cph_model::load_bundle(root);
diags.extend(load_diags);
let Some(bundle) = bundle else {
return (
None,
CheckReport {
diagnostics: dedup(diags),
lesson_loaded: false,
},
);
};
let known = cph_schema::known_kinds();
for member in &bundle.lessons {
run_structural_and_schema(&member.lesson, known, &mut diags);
}
if diags.iter().any(|d| d.severity == Severity::Error) {
return (
None,
CheckReport {
diagnostics: dedup(diags),
lesson_loaded: true,
},
);
}
match engine.build_bundle_pdf(&bundle, target) {
Ok(bytes) => (
Some(bytes),
CheckReport {
diagnostics: dedup(diags),
lesson_loaded: true,
},
),
Err(compile_diags) => {
diags.extend(compile_diags);
(
None,
CheckReport {
diagnostics: dedup(diags),
lesson_loaded: true,
},
)
}
}
}
/// The bundle's declared export-target names, in declared order — or
/// `[DEFAULT_TARGET]` if it declares none (ADR-0037 batch default, mirroring
/// [`declared_target_names`]).
pub fn declared_bundle_target_names(root: &Path) -> Vec<String> {
let (bundle, _) = cph_model::load_bundle(root);
match bundle {
Some(b) if !b.targets.is_empty() => {
b.target_names().into_iter().map(String::from).collect()
}
_ => vec![DEFAULT_TARGET.to_string()],
}
}
/// The lesson's declared export-target names, in declared order — or
/// `[DEFAULT_TARGET]` if it declares none (ADR-0037 batch default: `cph build`
/// with no `--target` builds every declared target).
///
/// Loads the lesson read-only, ignoring diagnostics: an unloadable lesson (or
/// one with a malformed root manifest) still yields `[DEFAULT_TARGET]` here so
/// the caller's subsequent per-target build attempt is what surfaces the real
/// load error — this helper only resolves *which names to attempt*, never
/// gates on lesson validity.
pub fn declared_target_names(root: &Path) -> Vec<String> {
let (lesson, _) = cph_model::load(root);
match lesson {
Some(l) if !l.targets.is_empty() => {
l.target_names().into_iter().map(String::from).collect()
}
_ => vec![DEFAULT_TARGET.to_string()],
}
}
/// One shell step's execution outcome (for [`run_shell_target`]). /// One shell step's execution outcome (for [`run_shell_target`]).
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct ShellStepOutcome { pub struct ShellStepOutcome {
@@ -443,8 +564,9 @@ pub struct MarkdownAssembleReport {
} }
/// Execute the `AssembleMarkdown` steps of a target (ADR-0015): concatenate each /// Execute the `AssembleMarkdown` steps of a target (ADR-0015): concatenate each
/// element's `<field>.md` markdown content file in `[[parts]]` order into the /// element's `<field>.md` markdown content file in `parts` order (ADR-0036's
/// target's single-file artifact. This is the **third typed step**: unlike /// depth-first element sequence) into the target's single-file artifact.
/// This is the **third typed step**: unlike
/// [`build`] (typst template → PDF) the framework owns the read/concatenate/write /// [`build`] (typst template → PDF) the framework owns the read/concatenate/write
/// itself (not a typst compile, not an external tool like [`run_shell_target`]). /// itself (not a typst compile, not an external tool like [`run_shell_target`]).
/// ///
+28 -11
View File
@@ -45,6 +45,23 @@ fn good_fixture_has_no_errors() {
assert!(!report.has_errors()); assert!(!report.has_errors());
} }
#[test]
fn outline_projects_parts_in_manifest_order() {
let (outline, report) = cph_check::outline(&mini_fixture());
assert_eq!(
report.error_count(),
0,
"outline should validate the fixture"
);
let outline = outline.expect("valid lesson should produce an outline");
assert_eq!(outline.title, "迷你示例课时");
assert_eq!(outline.children.len(), 3);
assert_eq!(outline.children[0].title, "开场对照导言");
assert_eq!(outline.children[1].kind, "section");
assert_eq!(outline.children[1].children[0].title, "量纲分析估计");
assert_eq!(outline.children[2].title, "自由落体");
}
#[test] #[test]
fn unknown_kind_is_an_error() { fn unknown_kind_is_an_error() {
// Build a throwaway lesson whose part declares kind "frob". // Build a throwaway lesson whose part declares kind "frob".
@@ -59,7 +76,7 @@ name = "broken"
[info] [info]
title = "broken" title = "broken"
[[parts]] [[children]]
kind = "frob" kind = "frob"
path = "elements/widget" path = "elements/widget"
"#, "#,
@@ -123,7 +140,7 @@ name = "broken"
[info] [info]
title = "broken" title = "broken"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/does-not-exist" path = "segments/does-not-exist"
"#, "#,
@@ -150,7 +167,7 @@ name = "cov"
[info] [info]
title = "cov" title = "cov"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/intro" path = "segments/intro"
@@ -276,7 +293,7 @@ name = "sh"
[info] [info]
title = "sh" title = "sh"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/intro" path = "segments/intro"
@@ -350,7 +367,7 @@ name = "sh"
[info] [info]
title = "sh" title = "sh"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/missing" path = "segments/missing"
@@ -390,7 +407,7 @@ fn write_markdown_assemble_target_lesson(tmp: &Path, slides: &[(&str, &str)]) {
parts.push('\n'); parts.push('\n');
} }
parts.push_str(&format!( parts.push_str(&format!(
"[[parts]]\nkind = \"segment\"\npath = \"segments/{name}\"\n" "[[children]]\nkind = \"segment\"\npath = \"segments/{name}\"\n"
)); ));
} }
std::fs::write( std::fs::write(
@@ -468,8 +485,8 @@ fn run_markdown_assemble_target_skips_parts_without_the_field() {
// Only the first segment has a slides.md; the second is skipped (optional). // Only the first segment has a slides.md; the second is skipped (optional).
let tmp = tempdir(); let tmp = tempdir();
let mut parts = String::new(); let mut parts = String::new();
parts.push_str("[[parts]]\nkind = \"segment\"\npath = \"segments/a\"\n\n"); parts.push_str("[[children]]\nkind = \"segment\"\npath = \"segments/a\"\n\n");
parts.push_str("[[parts]]\nkind = \"segment\"\npath = \"segments/b\"\n"); parts.push_str("[[children]]\nkind = \"segment\"\npath = \"segments/b\"\n");
std::fs::write( std::fs::write(
tmp.join("manifest.toml"), tmp.join("manifest.toml"),
format!( format!(
@@ -527,7 +544,7 @@ name = "md"
[info] [info]
title = "md" title = "md"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/a" path = "segments/a"
@@ -584,7 +601,7 @@ name = "md"
[info] [info]
title = "md" title = "md"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/a" path = "segments/a"
@@ -630,7 +647,7 @@ name = "md"
[info] [info]
title = "md" title = "md"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/missing" path = "segments/missing"
+3
View File
@@ -11,6 +11,9 @@ path = "src/main.rs"
[dependencies] [dependencies]
cph-check = { path = "../cph-check" } cph-check = { path = "../cph-check" }
cph-diag = { workspace = true } cph-diag = { workspace = true }
cph-model = { workspace = true }
cph-schema = { path = "../cph-schema" }
cph-typst = { path = "../cph-typst" } cph-typst = { path = "../cph-typst" }
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
clap_complete = "4" clap_complete = "4"
serde_json = "1"
+739 -20
View File
@@ -7,11 +7,14 @@
//! exits 1 when there is any `Error`-severity diagnostic (warnings alone exit //! exits 1 when there is any `Error`-severity diagnostic (warnings alone exit
//! 0); `build` exits 1 when the PDF could not be produced. //! 0); `build` exits 1 when the PDF could not be produced.
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::ExitCode; use std::process::ExitCode;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use cph_check::CheckReport; use cph_check::CheckReport;
use cph_model::OutlineDocument;
use cph_typst::Engine; use cph_typst::Engine;
/// The `cph` checker for curriculum engineering files. /// The `cph` checker for curriculum engineering files.
@@ -35,17 +38,56 @@ enum Command {
/// Path to the engineering-file root (the folder with `manifest.toml`). /// Path to the engineering-file root (the folder with `manifest.toml`).
path: PathBuf, path: PathBuf,
}, },
/// Build a PDF for a render target. Exits 1 if the build fails. /// Build one or more render targets. Exits 1 if any target fails.
///
/// With no `--target`, batches every target the lesson declares
/// (ADR-0037): each target builds independently — one failing does not
/// stop the rest — and the exit code is non-zero if any target failed.
/// Repeat `--target` to build an explicit ordered subset instead.
Build { Build {
/// Path to the engineering-file root (the folder with `manifest.toml`). /// Path to the engineering-file root (the folder with `manifest.toml`).
path: PathBuf, path: PathBuf,
/// Render target to export. /// Render target(s) to export. Repeatable. Defaults to every target
#[arg(long, default_value = "student")] /// the lesson declares (or `student` if it declares none).
target: String, #[arg(long = "target")]
/// Output PDF path. Defaults to `<PATH>/build/<target>.pdf`. targets: Vec<String>,
/// Output path for a *single*-target build. Defaults to
/// `<PATH>/build/<target>.pdf`. Rejected when building more than one
/// target (ambiguous: which target would it name?).
#[arg(short = 'o', long, value_name = "OUT")] #[arg(short = 'o', long, value_name = "OUT")]
out: Option<PathBuf>, out: Option<PathBuf>,
}, },
/// Build one or more bundle targets (ADR-0037): combine an ordered
/// arrangement of self-contained lessons (`bundle.toml`) into one
/// artifact. Same batching/exit-code contract as `build`.
Bundle {
/// Path to the bundle root (the folder with `bundle.toml`).
path: PathBuf,
/// Bundle target(s) to export. Repeatable. Defaults to every target
/// the bundle declares (or `student` if it declares none).
#[arg(long = "target")]
targets: Vec<String>,
/// Output path for a *single*-target build. Defaults to
/// `<PATH>/build/<target>.pdf`. Rejected when building more than one
/// target.
#[arg(short = 'o', long, value_name = "OUT")]
out: Option<PathBuf>,
},
/// Export the teacher-facing outline as Markdown, PDF, or JSON.
Outline {
/// Path to the engineering-file root. Defaults to the current directory.
#[arg(default_value = ".")]
path: PathBuf,
/// Output format. Defaults to PDF.
#[arg(long, value_enum, default_value_t = OutlineFormat::Pdf)]
format: OutlineFormat,
/// Output path. Defaults to `<PATH>/outline.<format>`.
#[arg(short = 'o', long, value_name = "OUT")]
out: Option<PathBuf>,
/// Allow replacing an existing output file.
#[arg(long)]
force: bool,
},
/// Print a shell-completion script to stdout (clap_complete; ADR-0013 opt-in /// Print a shell-completion script to stdout (clap_complete; ADR-0013 opt-in
/// sibling: a local convenience, no lesson involved). Pipe to your shell's /// sibling: a local convenience, no lesson involved). Pipe to your shell's
/// completion file, e.g. `cph completions zsh > ~/.zfunc/_cph`. /// completion file, e.g. `cph completions zsh > ~/.zfunc/_cph`.
@@ -53,6 +95,49 @@ enum Command {
/// Which shell to generate completions for. /// Which shell to generate completions for.
shell: CompletionTarget, shell: CompletionTarget,
}, },
/// Scaffold a new, check-clean engineering-file root under `path`. Owns the
/// `manifest.toml` (with a generated `[project].id`), a pinning
/// `.cph-version` (ADR-0016), the stock `exports/student.typ` render
/// template, and the empty per-kind part folders. A local authoring
/// convenience — no hub semantics involved.
Init {
/// Directory to create the engineering file in. Created recursively if
/// missing; refused if it already holds a `manifest.toml`.
path: PathBuf,
/// Project name / lesson title. Defaults to the directory's base name.
#[arg(long, value_name = "NAME")]
name: Option<String>,
},
/// Add a new part to an engineering file: create its folder, its
/// element.toml, and the blank required content files, then append a
/// [[children]] entry to the root manifest.toml. A local authoring
/// convenience, not a hub write.
Add {
/// Engineering-file root (the folder with `manifest.toml`).
#[arg(long, default_value = ".", value_name = "DIR")]
root: PathBuf,
/// The element kind.
kind: String,
/// Display name of the new part (also its folder name).
name: String,
},
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
enum OutlineFormat {
Md,
Pdf,
Json,
}
impl OutlineFormat {
fn extension(self) -> &'static str {
match self {
Self::Md => "md",
Self::Pdf => "pdf",
Self::Json => "json",
}
}
} }
#[derive(Debug, Clone, Copy, clap::ValueEnum)] #[derive(Debug, Clone, Copy, clap::ValueEnum)]
@@ -67,15 +152,34 @@ enum CompletionTarget {
fn main() -> ExitCode { fn main() -> ExitCode {
let cli = Cli::parse(); let cli = Cli::parse();
let engine = match &cli.render_dir { match cli.command {
Command::Check { path } => run_check(&path, &engine_from(&cli.render_dir)),
Command::Build { path, targets, out } => {
run_build_command(&path, &engine_from(&cli.render_dir), targets, out)
}
Command::Bundle { path, targets, out } => {
run_bundle_command(&path, &engine_from(&cli.render_dir), targets, out)
}
Command::Outline {
path,
format,
out,
force,
} => run_outline(&path, &engine_from(&cli.render_dir), format, out, force),
Command::Completions { shell } => run_completions(shell),
Command::Init { path, name } => run_init(&path, name.as_deref()),
Command::Add { root, kind, name } => run_add(&root, &kind, &name),
}
}
/// Build the typst [`Engine`], honoring a `--render-dir` override. Constructed
/// lazily — only the render-touching commands (`check`, `build`, `bundle`,
/// `outline`) need it; the authoring ones (`init`, `add`, `completions`) skip
/// the render-package extraction cost entirely.
fn engine_from(render_dir: &Option<std::path::PathBuf>) -> Engine {
match render_dir {
Some(dir) => Engine::with_render_dir(dir.clone()), Some(dir) => Engine::with_render_dir(dir.clone()),
None => Engine::new(), None => Engine::new(),
};
match cli.command {
Command::Check { path } => run_check(&path, &engine),
Command::Build { path, target, out } => run_build(&path, &engine, &target, out),
Command::Completions { shell } => run_completions(shell),
} }
} }
@@ -98,6 +202,358 @@ fn run_completions(shell: CompletionTarget) -> ExitCode {
ExitCode::SUCCESS ExitCode::SUCCESS
} }
// ===========================================================================
// Authoring subcommands (`init`, `add`): local engineering-file scaffolding.
// They never touch the hub and are deliberately local — they only create
// dirs/files and append to the local `manifest.toml`. `check` stays
// authoritative: whatever these write, `cph check` must accept.
/// The stock `exports/student.typ` written by `init` — the framework's default
/// render template (ADR-0011, outline-shape ADR-0036), the same file the
/// examples ship. Kept verbatim so a freshly scaffolded engineering file
/// renders out of the box; it imports `@local/cph-render:0.1.0`, which the
/// engine resolves from the embedded package. Presentation (heading numbering,
/// styling) is editable here per engineering file, not in the manifest.
const DEFAULT_STUDENT_TEMPLATE: &str = r##"// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011, outline shape ADR-0036).
//
// This is a *real, editable* file that lives in an engineering file at
// `exports/student.typ`. The framework compiles it AS THE MAIN FILE with the
// manifest injected:
// typst compile --root <eng-root> --input manifest=<path-rel-to-root> exports/student.typ <out>
//
// It is intentionally self-contained (no shared helper import) so it can be
// copied verbatim into a new engineering file's `exports/`. Presentation —
// heading numbering — lives HERE (editable per engineering file), not in the
// manifest and not hardcoded in the cph-render package.
//
// WHY THE INCLUDE LOOP IS HERE AND NOT IN cph-render: typst resolves a dynamic
// `include` path relative to the file it lexically appears in, and a package has
// its own virtual root — an include inside cph-render would resolve against the
// PACKAGE, not the engineering root. A `/<part.path>/<field>.typ` written HERE
// (this template lives under `--root`) resolves against `--root`. So the
// template loads content and hands cph-render an already-assembled `outline`
// array (elements interleaved with section headings, ADR-0036).
//
// OPEN CONTRACT POINT — optional-content presence. typst has no "does this file
// exist" primitive (a missing `include` is a hard compile error). So the
// template CANNOT probe disk the way the old Rust driver did for lemma `proof`.
// It relies on the manifest declaring which optional content fields are present,
// via a per-element `fields` array listing the content fields that exist on disk
// (the engine knows this — it walks the part dir). Required fields are loaded
// unconditionally; optional fields load only if listed in `fields`. If an element
// omits `fields`, optional content is skipped (conservative). The exact shape of
// this declaration is for the manifest/Rust contract to pin.
#import "@local/cph-render:0.1.0": render-lesson, part-fields, default-heading-numbering
// This template IS the student build, so the target is fixed.
#let target = "student"
// Read the injected manifest (a path string relative to typst --root).
#let manifest = toml(sys.inputs.manifest)
#let info = manifest.at("info", default: (:))
#let raw-outline = manifest.at("outline", default: ())
// Assemble each outline entry:
// - an "element" entry: include its content fields (computed absolute paths,
// resolved against --root) and read scalar fields from <path>/element.toml.
// `part-fields` (from cph-render) is the single source of truth for
// kind->fields.
// - a "section" entry (ADR-0036): pass its title/depth straight through — no
// content to load, it is a heading.
#let outline = raw-outline.map(raw => {
if raw.at("type", default: "element") == "section" {
(
entry-type: "section",
kind: raw.at("kind", default: none),
title: raw.at("title", default: ""),
depth: raw.at("depth", default: 1),
)
} else {
let kind = raw.at("kind", default: none)
let path = raw.at("path", default: none)
let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ()))
// Which optional content fields are present on disk (manifest-declared).
let present = raw.at("fields", default: ())
let entry = (entry-type: "element", kind: kind)
// Required content fields: <path>/<field>.typ (absolute, root-relative).
for field in spec.content {
entry.insert(field, include "/" + path + "/" + field + ".typ")
}
// Optional content fields: only when the manifest says the file exists.
for field in spec.optional-content {
if field in present {
entry.insert(field, include "/" + path + "/" + field + ".typ")
}
}
// Scalar fields come from <path>/element.toml.
if spec.scalars.len() > 0 {
let element = toml("/" + path + "/element.toml")
for field in spec.scalars {
let v = element.at(field, default: none)
if v != none and v != "" { entry.insert(field, v) }
}
}
entry
}
})
// Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes
// from cph-render; override here per engineering file if desired.
#render-lesson(
info: info,
target: target,
outline: outline,
heading-numbering: default-heading-numbering,
)
"##;
/// The on-disk folder a part of `kind` lives under (a convention shared by the
/// examples and the hub, not derivable from the kind schema, so it's pinned
/// here alongside the initializer). `None` for unknown kinds — the same set
/// `cph_schema::known_kinds()` reports.
fn kind_dir(kind: &str) -> Option<&'static str> {
match kind {
"segment" => Some("segments"),
"example" => Some("examples"),
"lemma" => Some("lemmas"),
"sop" => Some("sops"),
_ => None,
}
}
/// Encode `v` as lowercase base-36 for use in a generated project id.
fn encode_id(mut v: u64) -> String {
const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
if v == 0 {
return "0".into();
}
let mut s = String::new();
while v > 0 {
s.push(ALPHABET[(v % 36) as usize] as char);
v /= 36;
}
s
}
/// Generate a `local-…` project id for a new engineering file (mirrors the
/// examples' `local-<a>-<b>` shape). Not security entropy — enough to be unique
/// per init, derived from time + pid + a per-process counter.
fn new_project_id() -> String {
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let counter = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mix = now.rotate_left(17)
^ (std::process::id() as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15)
^ counter.wrapping_mul(0xBF58_476D_1CE4_E5B9);
format!("local-{}-{}", encode_id(mix), encode_id(counter))
}
/// Scaffold a new engineering-file root under `path`. Refuses to clobber an
/// existing `manifest.toml`, so a repeat run is safe.
fn run_init(path: &std::path::Path, name: Option<&str>) -> ExitCode {
if path.join("manifest.toml").exists() {
eprintln!(
"error: '{}' already contains manifest.toml; refusing to init over it",
path.display()
);
return ExitCode::FAILURE;
}
if let Err(e) = std::fs::create_dir_all(path) {
eprintln!("error: cannot create '{}': {e}", path.display());
return ExitCode::FAILURE;
}
let display_name = match name {
Some(n) => n.to_string(),
None => path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "untitled".into()),
};
let manifest = format!(
r#"[project]
id = "{id}"
name = "{name}"
[info]
title = "{name}"
# Export target (ADR-0009/0011): a typed build. The stock template at
# exports/student.typ imports @local/cph-render:0.1.0 (embedded in cph).
[targets.student]
artifact = {{ type = "single-file", filepath = "build/student.pdf" }}
[[targets.student.steps]]
type = "typst-compile"
template = "exports/student.typ"
"#,
id = new_project_id(),
name = display_name,
);
let files: &[(&str, &str)] = &[
("manifest.toml", &manifest),
(".cph-version", &format!("{}\n", env!("CARGO_PKG_VERSION"))),
("exports/student.typ", DEFAULT_STUDENT_TEMPLATE),
];
for (rel, content) in files {
let full = path.join(rel);
if let Some(parent) = full.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
eprintln!("error: cannot create '{}': {e}", parent.display());
return ExitCode::FAILURE;
}
}
if let Err(e) = std::fs::write(&full, content) {
eprintln!("error: cannot write '{}': {e}", full.display());
return ExitCode::FAILURE;
}
}
for dir in ["segments", "lemmas", "examples", "sops"] {
if let Err(e) = std::fs::create_dir_all(path.join(dir)) {
eprintln!("error: cannot create '{}': {e}", path.join(dir).display());
return ExitCode::FAILURE;
}
}
println!("initialized engineering file at {}", path.display());
println!(
" next: cph check {} | cph build {} --target student",
path.display(),
path.display()
);
ExitCode::SUCCESS
}
/// Add a new part to the engineering file at `root`: create its folder with
/// `element.toml` + blank required content files, then append its `[[children]]`
/// entry to the root `manifest.toml` (ADR-0036 root children). Rejects unknown
/// kinds, unsafe names, and anything that would double-register an existing part.
fn run_add(root: &std::path::Path, kind: &str, name: &str) -> ExitCode {
let dir = match kind_dir(kind) {
Some(d) => d,
None => {
eprintln!(
"error: unknown kind '{kind}'; expected one of: {}",
cph_schema::known_kinds().join(", ")
);
return ExitCode::FAILURE;
}
};
let trimmed = name.trim();
if trimmed.is_empty()
|| trimmed.contains('/')
|| trimmed.contains('\\')
|| trimmed.contains('"')
{
eprintln!("error: invalid part name {name:?}; use a plain folder name (no / \\ or quotes)");
return ExitCode::FAILURE;
}
let rel = format!("{dir}/{trimmed}");
let part_dir = root.join(&rel);
let manifest_path = root.join("manifest.toml");
let manifest_src = match std::fs::read_to_string(&manifest_path) {
Ok(s) => s,
Err(e) => {
eprintln!(
"error: cannot read '{}': {e} (run `cph init` here first?)",
manifest_path.display()
);
return ExitCode::FAILURE;
}
};
if part_dir.exists() {
eprintln!("error: '{}' already exists", part_dir.display());
return ExitCode::FAILURE;
}
if manifest_has_child(&manifest_src, &rel) {
eprintln!("error: manifest.toml already declares a part at '{rel}'");
return ExitCode::FAILURE;
}
if let Err(e) = std::fs::create_dir_all(&part_dir) {
eprintln!("error: cannot create '{}': {e}", part_dir.display());
return ExitCode::FAILURE;
}
let element_toml = format!("kind = \"{kind}\"\n");
if let Err(e) = std::fs::write(part_dir.join("element.toml"), element_toml) {
eprintln!("error: cannot write element.toml for '{rel}': {e}");
return ExitCode::FAILURE;
}
let required = cph_schema::schema_for(kind)
.map(|s| s.required_content_field_names())
.unwrap_or_default();
for field in &required {
let f = part_dir.join(format!("{field}.typ"));
if let Err(e) = std::fs::write(&f, "") {
eprintln!("error: cannot write '{}': {e}", f.display());
return ExitCode::FAILURE;
}
}
let updated = insert_child(&manifest_src, kind, &rel);
if let Err(e) = std::fs::write(&manifest_path, updated) {
eprintln!("error: cannot update '{}': {e}", manifest_path.display());
return ExitCode::FAILURE;
}
println!("added {kind} '{trimmed}' → {rel} (folder + [[children]] entry)");
if required.is_empty() {
println!(" note: kind '{kind}' declares no required content fields");
} else {
println!(" content files created: {}", required.join(", "));
}
ExitCode::SUCCESS
}
/// Whether `manifest` already declares a child whose `path` line equals `rel`.
fn manifest_has_child(manifest: &str, rel: &str) -> bool {
let needle = format!("path = \"{rel}\"");
manifest.lines().any(|l| l.trim() == needle)
}
/// Insert a new `[[children]]` block into `manifest`, keeping the array of
/// tables contiguous (a TOML requirement: all elements of `[[children]]` must be
/// adjacent). The block goes immediately before the first section header that is
/// neither `[project]`/`[info]` nor an existing `[[children]]` entry (i.e. before
/// `[targets.*]`), or at end-of-file if none — either way it lands at the tail
/// of the root-children run, after `[info]` and any existing children (ADR-0036).
/// Comment blocks are preserved.
fn insert_child(manifest: &str, kind: &str, rel: &str) -> String {
let block = format!("[[children]]\nkind = \"{kind}\"\npath = \"{rel}\"\n");
let lines: Vec<&str> = manifest.lines().collect();
let insert_at = lines
.iter()
.position(|l| {
let t = l.trim_start();
t.starts_with('[')
&& !t.starts_with("[[children]]")
&& t != "[project]"
&& t != "[info]"
})
.unwrap_or(lines.len());
let mut out = String::new();
for (i, line) in lines.iter().enumerate() {
if i == insert_at {
out.push_str(&block);
}
out.push_str(line);
out.push('\n');
}
if insert_at == lines.len() {
out.push_str(&block);
}
out
}
/// Print every diagnostic in `report` to stderr, followed by a summary line. /// Print every diagnostic in `report` to stderr, followed by a summary line.
fn print_diagnostics(report: &CheckReport) { fn print_diagnostics(report: &CheckReport) {
for d in &report.diagnostics { for d in &report.diagnostics {
@@ -132,25 +588,205 @@ fn run_check(path: &std::path::Path, engine: &Engine) -> ExitCode {
} }
} }
fn run_build( fn run_outline(
path: &std::path::Path,
engine: &Engine,
format: OutlineFormat,
out: Option<PathBuf>,
force: bool,
) -> ExitCode {
let out_path = out.unwrap_or_else(|| path.join(format!("outline.{}", format.extension())));
if out_path.exists() && !force {
eprintln!(
"warning: output '{}' already exists; pass --force to overwrite",
out_path.display()
);
return ExitCode::FAILURE;
}
let (outline, report) = cph_check::outline(path);
print_diagnostics(&report);
let Some(outline) = outline else {
eprintln!("outline failed: fix the lesson before exporting");
return ExitCode::FAILURE;
};
let bytes = match format {
OutlineFormat::Md => render_outline_markdown(&outline).into_bytes(),
OutlineFormat::Json => match serde_json::to_vec_pretty(&outline) {
Ok(mut bytes) => {
bytes.push(b'\n');
bytes
}
Err(e) => {
eprintln!("outline failed: cannot serialize JSON: {e}");
return ExitCode::FAILURE;
}
},
OutlineFormat::Pdf => match engine.build_outline_pdf(&outline) {
Ok(bytes) => bytes,
Err(diags) => {
for diagnostic in &diags {
eprintln!("{diagnostic}");
}
eprintln!("outline failed: PDF compilation failed");
return ExitCode::FAILURE;
}
},
};
if force && out_path.exists() {
eprintln!(
"warning: overwriting existing output '{}'",
out_path.display()
);
}
if let Err(e) = write_outline_output(&out_path, &bytes, force) {
eprintln!("outline failed: {e}");
return ExitCode::FAILURE;
}
println!("wrote {} ({} bytes)", out_path.display(), bytes.len());
ExitCode::SUCCESS
}
fn render_outline_markdown(outline: &OutlineDocument) -> String {
let mut body = format!("# {}\n\n", outline.title.trim());
if !outline.authors.is_empty() {
body.push_str("作者:");
body.push_str(&outline.authors.join(""));
body.push_str("\n\n");
}
for child in &outline.children {
append_outline_markdown(&mut body, child, 2);
}
body
}
fn append_outline_markdown(body: &mut String, node: &cph_model::OutlineNode, level: usize) {
let level = level.min(6);
body.push_str(&"#".repeat(level));
body.push(' ');
body.push_str(&node.title);
if !node.kind.is_empty() {
body.push_str(" `[");
body.push_str(&node.kind);
body.push_str("]`");
}
body.push_str("\n\n");
if let Some(notes) = node.notes.as_deref() {
body.push_str("> 教学提示:\n");
for line in notes.lines() {
body.push_str("> ");
body.push_str(line);
body.push('\n');
}
body.push('\n');
}
for child in &node.children {
append_outline_markdown(body, child, level + 1);
}
}
fn write_outline_output(path: &std::path::Path, bytes: &[u8], force: bool) -> Result<(), String> {
if let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
std::fs::create_dir_all(parent)
.map_err(|e| format!("cannot create output directory '{}': {e}", parent.display()))?;
}
if force {
std::fs::write(path, bytes).map_err(|e| format!("cannot write '{}': {e}", path.display()))
} else {
let mut file = match OpenOptions::new().write(true).create_new(true).open(path) {
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
return Err(format!(
"output '{}' already exists; pass --force to overwrite",
path.display()
));
}
Err(e) => return Err(format!("cannot create '{}': {e}", path.display())),
};
file.write_all(bytes)
.map_err(|e| format!("cannot write '{}': {e}", path.display()))
}
}
/// Dispatch `cph build` (ADR-0037): with an explicit `--target` (repeatable),
/// build exactly that ordered set; with none, batch every target the lesson
/// declares. Each target builds **independently** — one failing does not stop
/// the rest — and prints a per-target ledger when building more than one.
/// Exits non-zero iff **any** target failed to produce its artifact (a build
/// failure is a real defect, distinct from the non-blocking `renderIgnored`
/// warning class — ADR-0037).
fn run_build_command(
path: &std::path::Path,
engine: &Engine,
targets: Vec<String>,
out: Option<PathBuf>,
) -> ExitCode {
let target_list = if targets.is_empty() {
cph_check::declared_target_names(path)
} else {
targets
};
if target_list.len() > 1 && out.is_some() {
eprintln!(
"error: -o/--out only applies to a single-target build; pass exactly one --target with -o"
);
return ExitCode::FAILURE;
}
let mut results: Vec<(String, bool)> = Vec::with_capacity(target_list.len());
for target in &target_list {
if target_list.len() > 1 {
eprintln!("=== target '{target}' ===");
}
let ok = run_build_one(path, engine, target, out.clone());
results.push((target.clone(), ok));
}
if target_list.len() > 1 {
eprintln!("--- build summary ---");
for (target, ok) in &results {
eprintln!("{target}: {}", if *ok { "ok" } else { "failed" });
}
}
if results.iter().any(|(_, ok)| !ok) {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}
/// Build one target, returning whether it succeeded. Routes to the shell,
/// markdown-assemble, or typst-compile path per the target's step shape.
fn run_build_one(
path: &std::path::Path, path: &std::path::Path,
engine: &Engine, engine: &Engine,
target: &str, target: &str,
out: Option<PathBuf>, out: Option<PathBuf>,
) -> ExitCode { ) -> bool {
// A target whose steps are shell commands (a tool-generated asset bundle, // A target whose steps are shell commands (a tool-generated asset bundle,
// ADR-0009 category (b) — e.g. KenKen interactives via `kendoku`) is run by // ADR-0009 category (b) — e.g. KenKen interactives via `kendoku`) is run by
// executing those commands, not by compiling a typst template. Detect that // executing those commands, not by compiling a typst template. Detect that
// shape up front and route accordingly. // shape up front and route accordingly.
if cph_check::target_is_shell(path, target) { if cph_check::target_is_shell(path, target) {
return run_shell_build(path, engine, target); return run_shell_build(path, engine, target) == ExitCode::SUCCESS;
} }
// A target whose steps assemble markdown (ADR-0015: slides outline / 逐字稿 // A target whose steps assemble markdown (ADR-0015: slides outline / 逐字稿
// transcript surfaces) is built by concatenating per-element `<field>.md` // transcript surfaces) is built by concatenating per-element `<field>.md`
// files in parts order, not by compiling a typst template. // files in parts order, not by compiling a typst template.
if cph_check::target_is_markdown_assemble(path, target) { if cph_check::target_is_markdown_assemble(path, target) {
return run_markdown_assemble_build(path, engine, target); return run_markdown_assemble_build(path, engine, target) == ExitCode::SUCCESS;
} }
let out_path = out.unwrap_or_else(|| path.join("build").join(format!("{target}.pdf"))); let out_path = out.unwrap_or_else(|| path.join("build").join(format!("{target}.pdf")));
@@ -166,19 +802,102 @@ fn run_build(
"error: cannot create output directory '{}': {e}", "error: cannot create output directory '{}': {e}",
parent.display() parent.display()
); );
return ExitCode::FAILURE; return false;
} }
} }
if let Err(e) = std::fs::write(&out_path, &bytes) { if let Err(e) = std::fs::write(&out_path, &bytes) {
eprintln!("error: cannot write '{}': {e}", out_path.display()); eprintln!("error: cannot write '{}': {e}", out_path.display());
return ExitCode::FAILURE; return false;
} }
println!("wrote {} ({} bytes)", out_path.display(), bytes.len()); println!("wrote {} ({} bytes)", out_path.display(), bytes.len());
ExitCode::SUCCESS true
} }
None => { None => {
eprintln!("build failed: {} errors", report.error_count()); eprintln!("build failed: {} errors", report.error_count());
ExitCode::FAILURE false
}
}
}
/// Dispatch `cph bundle` (ADR-0037) — same batching/exit-code contract as
/// [`run_build_command`], over a bundle's own declared targets. MVP bundle
/// targets are `typst-compile` only (no shell/markdown-assemble routing —
/// ADR-0037 did not extend those step kinds to bundles).
fn run_bundle_command(
path: &std::path::Path,
engine: &Engine,
targets: Vec<String>,
out: Option<PathBuf>,
) -> ExitCode {
let target_list = if targets.is_empty() {
cph_check::declared_bundle_target_names(path)
} else {
targets
};
if target_list.len() > 1 && out.is_some() {
eprintln!(
"error: -o/--out only applies to a single-target build; pass exactly one --target with -o"
);
return ExitCode::FAILURE;
}
let mut results: Vec<(String, bool)> = Vec::with_capacity(target_list.len());
for target in &target_list {
if target_list.len() > 1 {
eprintln!("=== target '{target}' ===");
}
let ok = run_bundle_one(path, engine, target, out.clone());
results.push((target.clone(), ok));
}
if target_list.len() > 1 {
eprintln!("--- build summary ---");
for (target, ok) in &results {
eprintln!("{target}: {}", if *ok { "ok" } else { "failed" });
}
}
if results.iter().any(|(_, ok)| !ok) {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}
/// Build one bundle target, returning whether it succeeded.
fn run_bundle_one(
path: &std::path::Path,
engine: &Engine,
target: &str,
out: Option<PathBuf>,
) -> bool {
let out_path = out.unwrap_or_else(|| path.join("build").join(format!("{target}.pdf")));
let (pdf, report) = cph_check::build_bundle(path, engine, target);
print_diagnostics(&report);
match pdf {
Some(bytes) => {
if let Some(parent) = out_path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
eprintln!(
"error: cannot create output directory '{}': {e}",
parent.display()
);
return false;
}
}
if let Err(e) = std::fs::write(&out_path, &bytes) {
eprintln!("error: cannot write '{}': {e}", out_path.display());
return false;
}
println!("wrote {} ({} bytes)", out_path.display(), bytes.len());
true
}
None => {
eprintln!("build failed: {} errors", report.error_count());
false
} }
} }
} }
+22 -16
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`]
//! (mirroring the Lean master), a closed set of machine-stable [`DiagCode`]s, an //! (two-valued, ADR-0010), a closed set of machine-stable [`DiagCode`]s, an
//! optional [`SourceSpan`] pointing back at the offending source, and a //! 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,32 +17,27 @@ use serde::Serialize;
/// Severity of a diagnostic. /// Severity of a diagnostic.
/// ///
/// **Mirrors `Spec.Courseware.Diagnostic.Severity`** in the Lean semantic /// **Pinned by ADR-0005 / ADR-0010: exactly two values.**
/// master (`spec/Spec/Courseware/Check/Diagnostic.lean`), whose definition is
/// exactly:
/// ///
/// ```text /// ```text
/// inductive Severity where /// warning | error
/// | warning
/// | error
/// ``` /// ```
/// ///
/// This two-valued shape is a **contract decision**, not an accident: the Lean /// This two-valued shape is a **contract decision**, not an accident: the
/// module pins `Severity` to exactly `warning | error` and states the finer /// finer levels (`info` / `hint` / `note`) are deliberately undecided, so we
/// 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 this alignment (see the repo constitution); /// There is no CI gate enforcing ADR↔implementation alignment (see the repo
/// it is maintained by review, which is why this correspondence is documented /// constitution); it is maintained by review, which is why the decision is
/// here rather than only in the spec. /// documented here rather than only in the ADR.
#[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. Mirrors Lean `Severity.warning`. /// element. ADR-0010 `warning`.
Warning, Warning,
/// Blocking: the artifact is invalid. Mirrors Lean `Severity.error`. /// Blocking: the artifact is invalid. ADR-0010 `error`.
Error, Error,
} }
@@ -70,7 +65,8 @@ pub struct SourceSpan {
/// Do not invent codes outside this enum without a deliberate decision. /// Do not invent codes outside this enum without a deliberate decision.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum DiagCode { pub enum DiagCode {
/// A `[[parts]]` entry references a folder/path that does not exist. /// A `[[parts]]`/outline entry references a folder/path that does not
/// exist.
PartPathMissing, PartPathMissing,
/// An element declares a `kind` that is not a known kind. /// An element declares a `kind` that is not a known kind.
UnknownKind, UnknownKind,
@@ -91,6 +87,15 @@ pub enum DiagCode {
/// The engineering file's `.cph-version` is not compatible with the running /// The engineering file's `.cph-version` is not compatible with the running
/// CLI's version (ADR-0016). Decided at load time; `error` severity. /// CLI's version (ADR-0016). Decided at load time; `error` severity.
CphVersionMismatch, CphVersionMismatch,
/// A `manifest.toml`/`bundle.toml` is structurally broken: invalid TOML, a
/// required table missing (root `[project]`/`[info]`), a folder that is
/// neither a container (`manifest.toml`) nor a leaf (`element.toml`) — or
/// is ambiguously both (ADR-0036) — or a bundle `lessons` entry malformed
/// (ADR-0037). Distinct from `SchemaViolation` (instance data vs. its
/// kind's schema): this code is for the *carrier document's own*
/// structure. Added to discharge the manifest-level errors that used to
/// overload `SchemaViolation` before this code existed.
ManifestMalformed,
} }
impl DiagCode { impl DiagCode {
@@ -107,6 +112,7 @@ impl DiagCode {
DiagCode::TypstCompile => "E-TYPST-COMPILE", DiagCode::TypstCompile => "E-TYPST-COMPILE",
DiagCode::RenderIgnored => "W-RENDER-IGNORED", DiagCode::RenderIgnored => "W-RENDER-IGNORED",
DiagCode::CphVersionMismatch => "E-CPH-VERSION", DiagCode::CphVersionMismatch => "E-CPH-VERSION",
DiagCode::ManifestMalformed => "E-MANIFEST",
} }
} }
} }
File diff suppressed because it is too large Load Diff
+83
View File
@@ -0,0 +1,83 @@
//! Integration tests for `cph_model::load_bundle` (ADR-0037): an ordered
//! arrangement of self-contained lessons, loaded from `bundle.toml`.
use std::path::PathBuf;
use cph_diag::DiagCode;
use cph_model::load_bundle;
fn fixture(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(name)
}
#[test]
fn valid_bundle_loads_lessons_in_order_with_overrides() {
let (bundle, diags) = load_bundle(&fixture("bundle-valid"));
let bundle = bundle.expect("valid bundle fixture must produce a Bundle");
assert!(
diags.is_empty(),
"valid bundle fixture must have no diagnostics, got: {diags:?}"
);
assert_eq!(bundle.info.title, "测试合集");
assert_eq!(
bundle.info.authors,
vec!["张老师".to_string(), "李老师".to_string()]
);
assert_eq!(bundle.lessons.len(), 2);
// lesson-a: no explicit `target` in bundle.toml -> falls back to the
// lesson's own first declared target ("student").
assert_eq!(bundle.lessons[0].path, PathBuf::from("lesson-a"));
assert_eq!(bundle.lessons[0].target, "student");
assert_eq!(bundle.lessons[0].lesson.info.title, "课时A");
// lesson-b: explicit `target = "teacher"` in bundle.toml, overriding the
// lesson's own single declared target (also "teacher" here, but the point
// is the bundle entry's `target` wins regardless).
assert_eq!(bundle.lessons[1].path, PathBuf::from("lesson-b"));
assert_eq!(bundle.lessons[1].target, "teacher");
assert_eq!(bundle.lessons[1].lesson.info.title, "课时B");
// The bundle's own targets are collected exactly like a lesson's.
assert_eq!(bundle.target_names(), vec!["merged"]);
}
#[test]
fn missing_lesson_folder_yields_part_path_missing_and_is_skipped() {
let (bundle, diags) = load_bundle(&fixture("bundle-missing-lesson"));
let bundle = bundle.expect("must still produce a best-effort Bundle");
assert!(
bundle.lessons.is_empty(),
"the missing lesson is skipped, not placeholder'd"
);
let missing: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagCode::PartPathMissing)
.collect();
assert_eq!(
missing.len(),
1,
"exactly one PartPathMissing expected, got: {diags:?}"
);
}
#[test]
fn malformed_bundle_toml_is_a_hard_failure() {
let (bundle, diags) = load_bundle(&fixture("bundle-malformed"));
assert!(bundle.is_none(), "malformed bundle.toml is a hard failure");
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, DiagCode::ManifestMalformed);
}
#[test]
fn missing_bundle_toml_is_a_hard_failure() {
let (bundle, diags) = load_bundle(&fixture("does-not-exist-at-all"));
assert!(bundle.is_none());
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, DiagCode::ManifestMalformed);
}
@@ -0,0 +1,12 @@
[project]
id = "fixture-both"
name = "both"
[info]
title = "文件夹既是容器又是叶子"
[[children]]
kind = "segment"
path = "segments/broken"
[targets.student]
@@ -0,0 +1 @@
[[children]]
@@ -0,0 +1,2 @@
[info
this is broken
@@ -0,0 +1,5 @@
[info]
title = "缺失课时的合集"
[[lessons]]
path = "does-not-exist"
@@ -0,0 +1,16 @@
[info]
title = "测试合集"
author = ["张老师", "李老师"]
[[lessons]]
path = "lesson-a"
[[lessons]]
path = "lesson-b"
target = "teacher"
[targets.merged]
artifact = { type = "single-file", filepath = "build/merged.pdf" }
[[targets.merged.steps]]
type = "typst-compile"
template = "exports/merged.typ"
@@ -0,0 +1,12 @@
[project]
id = "lesson-a"
name = "lesson-a"
[info]
title = "课时A"
[[children]]
kind = "segment"
path = "segments/a"
[targets.student]
@@ -0,0 +1 @@
A.
@@ -0,0 +1,12 @@
[project]
id = "lesson-b"
name = "lesson-b"
[info]
title = "课时B"
[[children]]
kind = "segment"
path = "segments/b"
[targets.teacher]
@@ -0,0 +1 @@
B.
@@ -0,0 +1,12 @@
[project]
id = "fixture-container-root-tables"
name = "container-root-tables"
[info]
title = "容器错误声明了根级表"
[[children]]
kind = "section"
path = "section"
[targets.student]
@@ -0,0 +1,5 @@
[project]
id = "should-not-be-here"
name = "should-not-be-here"
children = []
@@ -5,7 +5,7 @@ name = "kind-mismatch"
[info] [info]
title = "kind 不一致测试" title = "kind 不一致测试"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/intro" path = "segments/intro"
+2 -2
View File
@@ -5,11 +5,11 @@ name = "missing-part"
[info] [info]
title = "缺部件测试" title = "缺部件测试"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/intro" path = "segments/intro"
[[parts]] [[children]]
kind = "lemma" kind = "lemma"
path = "lemmas/does-not-exist" path = "lemmas/does-not-exist"
@@ -0,0 +1,12 @@
[project]
id = "fixture-neither"
name = "neither"
[info]
title = "文件夹既不是容器也不是叶子"
[[children]]
kind = "segment"
path = "segments/broken"
[targets.student]
+21
View File
@@ -0,0 +1,21 @@
[project]
id = "fixture-nested"
name = "nested"
[info]
title = "嵌套结构测试"
[[children]]
kind = "segment"
path = "segments/开场白"
[[children]]
kind = "section"
path = "导言簇"
notes = "这里先建立直观图像,再进入分组推导。"
[[children]]
kind = "section"
path = "收束簇"
[targets.student]
@@ -0,0 +1 @@
开场白。
@@ -0,0 +1,14 @@
[group]
title = "导言簇"
[[children]]
kind = "segment"
path = "segments/子段一"
[[children]]
kind = "section"
path = "嵌套子节"
[[children]]
kind = "segment"
path = "segments/子段二"
@@ -0,0 +1 @@
子段一。
@@ -0,0 +1 @@
子段二。
@@ -0,0 +1 @@
子引理陈述。
@@ -0,0 +1,3 @@
[[children]]
kind = "lemma"
path = "lemmas/子引理"
@@ -0,0 +1,6 @@
[group]
title = "收束簇"
[[children]]
kind = "segment"
path = "segments/总结"
@@ -0,0 +1 @@
总结
@@ -0,0 +1,15 @@
[project]
id = "fixture-root-group"
name = "root-group"
[info]
title = "根级 manifest 错误声明了 group"
[group]
title = "不该在根级"
[[children]]
kind = "segment"
path = "segments/a"
[targets.student]
@@ -0,0 +1 @@
a.
+3 -2
View File
@@ -6,11 +6,12 @@ name = "valid-2-part"
title = "测试课:两个部件" title = "测试课:两个部件"
author = "范式教育教研组" author = "范式教育教研组"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/intro" path = "segments/intro"
notes = "这一节补充一个直观例题"
[[parts]] [[children]]
kind = "lemma" kind = "lemma"
path = "lemmas/young" path = "lemmas/young"
+209 -6
View File
@@ -1,11 +1,12 @@
//! Integration tests for `cph_model::load`, driven by static fixtures under //! Integration tests for `cph_model::load`, driven by static fixtures under
//! `tests/fixtures/`. The fixtures double as documentation of the ADR-0008 //! `tests/fixtures/`. The fixtures double as documentation of the ADR-0036
//! on-disk format. //! on-disk format (a nested outline manifest; supersedes ADR-0008's flat
//! `[[parts]]`).
use std::path::PathBuf; use std::path::PathBuf;
use cph_diag::DiagCode; use cph_diag::DiagCode;
use cph_model::load; use cph_model::{load, OutlineEntry};
/// Absolute path to a fixture engineering-file root. /// Absolute path to a fixture engineering-file root.
fn fixture(name: &str) -> PathBuf { fn fixture(name: &str) -> PathBuf {
@@ -34,11 +35,38 @@ fn valid_two_part_lesson_loads_in_order_with_no_errors() {
assert_eq!(lesson.parts.len(), 2); assert_eq!(lesson.parts.len(), 2);
assert_eq!(lesson.parts[0].kind, "segment"); assert_eq!(lesson.parts[0].kind, "segment");
assert_eq!(lesson.parts[0].path, PathBuf::from("segments/intro")); assert_eq!(lesson.parts[0].path, PathBuf::from("segments/intro"));
assert_eq!(
lesson.parts[0].notes.as_deref(),
Some("这一节补充一个直观例题")
);
let outline = lesson.outline_document();
assert_eq!(outline.children[0].title, "intro");
assert_eq!(
outline.children[0].notes.as_deref(),
Some("这一节补充一个直观例题")
);
assert!(outline.children[0].children.is_empty());
assert_eq!(lesson.parts[0].descriptor.kind, "segment"); assert_eq!(lesson.parts[0].descriptor.kind, "segment");
assert_eq!(lesson.parts[1].kind, "lemma"); assert_eq!(lesson.parts[1].kind, "lemma");
assert_eq!(lesson.parts[1].path, PathBuf::from("lemmas/young")); assert_eq!(lesson.parts[1].path, PathBuf::from("lemmas/young"));
assert_eq!(lesson.parts[1].descriptor.kind, "lemma"); assert_eq!(lesson.parts[1].descriptor.kind, "lemma");
// The outline is a flat sequence of elements-by-index when there are no
// containers.
assert_eq!(
lesson.outline,
vec![
OutlineEntry::Element {
part_index: 0,
depth: 0,
},
OutlineEntry::Element {
part_index: 1,
depth: 0,
},
]
);
// `source` scalar survives on the lemma descriptor; `kind` is removed. // `source` scalar survives on the lemma descriptor; `kind` is removed.
let scalars = &lesson.parts[1].descriptor.scalars; let scalars = &lesson.parts[1].descriptor.scalars;
assert_eq!( assert_eq!(
@@ -74,6 +102,90 @@ fn valid_two_part_lesson_loads_in_order_with_no_errors() {
); );
} }
#[test]
fn nested_sections_flatten_depth_first_with_correct_depths() {
let (lesson, diags) = load(&fixture("nested"));
let lesson = lesson.expect("nested fixture must produce a Lesson");
assert!(
diags.is_empty(),
"nested fixture must have no diagnostics, got: {diags:?}"
);
// DFS pre-order element sequence (ADR-0036): containers contribute no
// element of their own.
let paths: Vec<_> = lesson.parts.iter().map(|p| p.path.clone()).collect();
assert_eq!(
paths,
vec![
PathBuf::from("segments/开场白"),
PathBuf::from("导言簇/segments/子段一"),
PathBuf::from("导言簇/嵌套子节/lemmas/子引理"),
PathBuf::from("导言簇/segments/子段二"),
PathBuf::from("收束簇/segments/总结"),
],
"root-relative paths must accumulate through every nesting level"
);
// The outline interleaves section headings at their DFS-open position,
// with depth 1 for a section directly under the root and depth 2 for one
// nested inside another section.
assert_eq!(
lesson.outline,
vec![
OutlineEntry::Element {
part_index: 0,
depth: 0,
}, // segments/开场白
OutlineEntry::Section {
kind: "section".to_string(),
title: "导言簇".to_string(),
depth: 1,
notes: Some("这里先建立直观图像,再进入分组推导。".to_string()),
path: PathBuf::from("导言簇"),
},
OutlineEntry::Element {
part_index: 1,
depth: 1,
}, // 导言簇/segments/子段一
OutlineEntry::Section {
kind: "section".to_string(),
title: "嵌套子节".to_string(),
depth: 2,
notes: None,
path: PathBuf::from("导言簇/嵌套子节"),
},
OutlineEntry::Element {
part_index: 2,
depth: 2,
}, // 导言簇/嵌套子节/lemmas/子引理
OutlineEntry::Element {
part_index: 3,
depth: 1,
}, // 导言簇/segments/子段二
OutlineEntry::Section {
kind: "section".to_string(),
title: "收束簇".to_string(),
depth: 1,
notes: None,
path: PathBuf::from("收束簇"),
},
OutlineEntry::Element {
part_index: 4,
depth: 1,
}, // 收束簇/segments/总结
]
);
let document = lesson.outline_document();
assert_eq!(document.children.len(), 3);
assert_eq!(document.children[1].title, "导言簇");
assert_eq!(document.children[1].children.len(), 3);
assert_eq!(document.children[2].title, "收束簇");
assert_eq!(document.children[2].children[0].title, "总结");
// The outer section declares [group].title = "导言簇"; the inner section
// has no [group] at all, so its title falls back to the folder basename.
}
#[test] #[test]
fn missing_part_folder_yields_part_path_missing() { fn missing_part_folder_yields_part_path_missing() {
let (lesson, diags) = load(&fixture("missing-part")); let (lesson, diags) = load(&fixture("missing-part"));
@@ -126,7 +238,7 @@ fn malformed_manifest_is_a_hard_failure() {
"malformed manifest must be a hard failure (None)" "malformed manifest must be a hard failure (None)"
); );
assert_eq!(diags.len(), 1, "one hard-failure diagnostic expected"); assert_eq!(diags.len(), 1, "one hard-failure diagnostic expected");
assert_eq!(diags[0].code, DiagCode::SchemaViolation); assert_eq!(diags[0].code, DiagCode::ManifestMalformed);
assert_eq!(diags[0].severity, cph_diag::Severity::Error); assert_eq!(diags[0].severity, cph_diag::Severity::Error);
} }
@@ -136,7 +248,98 @@ fn missing_manifest_is_a_hard_failure() {
let (lesson, diags) = load(&fixture("does-not-exist-at-all")); let (lesson, diags) = load(&fixture("does-not-exist-at-all"));
assert!(lesson.is_none()); assert!(lesson.is_none());
assert_eq!(diags.len(), 1); assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, DiagCode::SchemaViolation); assert_eq!(diags[0].code, DiagCode::ManifestMalformed);
}
#[test]
fn folder_with_both_manifest_and_element_is_manifest_malformed() {
let (lesson, diags) = load(&fixture("both-manifest-and-element"));
let lesson = lesson.expect("must still produce a best-effort Lesson");
assert_eq!(
lesson.parts.len(),
1,
"the ambiguous child is a placeholder"
);
let malformed: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagCode::ManifestMalformed)
.collect();
assert_eq!(
malformed.len(),
1,
"exactly one ManifestMalformed expected, got: {diags:?}"
);
assert!(
malformed[0]
.message
.contains("both manifest.toml and element.toml"),
"message should explain the ambiguity, got: {}",
malformed[0].message
);
}
#[test]
fn folder_with_neither_manifest_nor_element_is_manifest_malformed() {
let (lesson, diags) = load(&fixture("neither-manifest-nor-element"));
let lesson = lesson.expect("must still produce a best-effort Lesson");
assert_eq!(
lesson.parts.len(),
1,
"the incomplete child is a placeholder"
);
let malformed: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagCode::ManifestMalformed)
.collect();
assert_eq!(
malformed.len(),
1,
"exactly one ManifestMalformed expected, got: {diags:?}"
);
assert!(
malformed[0]
.message
.contains("neither manifest.toml nor element.toml"),
"message should explain the gap, got: {}",
malformed[0].message
);
}
#[test]
fn container_declaring_root_only_tables_is_manifest_malformed() {
let (lesson, diags) = load(&fixture("container-root-tables"));
assert!(
lesson.is_some(),
"a container misplacing root tables is non-fatal"
);
let malformed: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagCode::ManifestMalformed && d.message.contains("root-only"))
.collect();
assert_eq!(
malformed.len(),
1,
"exactly one root-only-table diagnostic expected, got: {diags:?}"
);
}
#[test]
fn root_manifest_declaring_group_is_manifest_malformed() {
let (lesson, diags) = load(&fixture("root-group-declared"));
assert!(lesson.is_some(), "the root declaring [group] is non-fatal");
let malformed: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagCode::ManifestMalformed && d.message.contains("[group]"))
.collect();
assert_eq!(
malformed.len(),
1,
"exactly one root-[group] diagnostic expected, got: {diags:?}"
);
} }
#[test] #[test]
@@ -290,7 +493,7 @@ fn tmp_lesson_with_version(version: Option<&str>) -> tempfile::TempDir {
let p = tmp.path(); let p = tmp.path();
std::fs::write( std::fs::write(
p.join("manifest.toml"), p.join("manifest.toml"),
"[project]\nid = \"v\"\nname = \"v\"\n[info]\ntitle = \"v\"\n[[parts]]\nkind = \"segment\"\npath = \"segments/a\"\n", "[project]\nid = \"v\"\nname = \"v\"\n[info]\ntitle = \"v\"\n[[children]]\nkind = \"segment\"\npath = \"segments/a\"\n",
) )
.unwrap(); .unwrap();
let seg = p.join("segments").join("a"); let seg = p.join("segments").join("a");
+13
View File
@@ -155,6 +155,19 @@ impl KindSchema {
.collect() .collect()
} }
/// The names of the **required** content fields (those in the schema's
/// `required` list), in schema order. The `cph-cli add` authoring surface
/// uses this to scaffold the sibling `<field>.typ` files a new part must
/// have (ADR-0008): an optional content field (e.g. a lemma's `proof`) is
/// not created, so a freshly added part stays schema-legal.
pub fn required_content_field_names(&self) -> Vec<&str> {
self.content_fields
.iter()
.filter(|f| f.required)
.map(|f| f.name.as_str())
.collect()
}
/// The names of the scalar fields (those living in `element.toml`), in /// The names of the scalar fields (those living in `element.toml`), in
/// schema order. /// schema order.
pub fn scalar_field_names(&self) -> Vec<&str> { pub fn scalar_field_names(&self) -> Vec<&str> {
+22
View File
@@ -74,3 +74,25 @@ fn example_source_non_string_is_schema_violation() {
assert!(diags[0].message.contains("source")); assert!(diags[0].message.contains("source"));
assert!(diags[0].message.contains("string")); assert!(diags[0].message.contains("string"));
} }
/// `required_content_field_names` is the `cph-cli add` authoring contract: the
/// set of sibling `.typ` files a new part of a kind must have to be
/// schema-legal (ADR-0008). It must be the schema `required` content fields —
/// not the optional ones (e.g. a lemma's `proof`) and not the scalar fields.
#[test]
fn required_content_field_names_match_schema_required() {
let case = |kind: &str, expected: Vec<&str>| {
let mut got: Vec<&str> = cph_schema::schema_for(kind)
.unwrap()
.required_content_field_names();
got.sort_unstable();
let mut want = expected;
want.sort_unstable();
assert_eq!(got, want, "required content fields for '{kind}'");
};
case("segment", vec!["textbook"]);
case("lemma", vec!["stmt"]);
case("example", vec!["problem", "solution"]);
case("sop", vec!["sop"]);
}
+28 -13
View File
@@ -33,11 +33,10 @@ static RENDER_DIR: Dir<'_> = include_dir!("$CPH_STAGED_RENDER_DIR");
/// extracting the embedded copy to a per-user cache dir if needed. /// extracting the embedded copy to a per-user cache dir if needed.
/// ///
/// Resolution order: /// Resolution order:
/// 1. `CPH_RENDER_DIR` env var — an explicit override (dev convenience: point /// 1. `CPH_RENDER_DIR` — an explicit override (dev convenience: point at the
/// at the live repo `render/`). /// live repo `render/`).
/// 2. The extracted embedded copy under the user cache dir /// 2. The extracted embedded copy under the user cache dir
/// (`<cache>/cph/render-<version>/`). Extracted once per crate version; /// (`<cache>/cph/render-<version>/`).
/// subsequent runs reuse it.
/// ///
/// On any failure to locate a cache dir or extract, falls back to a temp-dir /// On any failure to locate a cache dir or extract, falls back to a temp-dir
/// location so the engine still works (just re-extracting per process). /// location so the engine still works (just re-extracting per process).
@@ -48,31 +47,47 @@ pub fn resolve_render_dir() -> PathBuf {
ensure_extracted().unwrap_or_else(|_| { ensure_extracted().unwrap_or_else(|_| {
// Last-resort: extract under the OS temp dir. Still correct, just not // Last-resort: extract under the OS temp dir. Still correct, just not
// cached across processes. // cached across processes.
let fallback = let fallback = std::env::temp_dir().join(format!(
std::env::temp_dir().join(format!("cph-render-{}", env!("CARGO_PKG_VERSION"))); "cph-render-{}-{}",
env!("CARGO_PKG_VERSION"),
RENDER_CACHE_REVISION
));
let _ = extract_to(&fallback); let _ = extract_to(&fallback);
fallback fallback
}) })
} }
/// The version-keyed cache location and a guarantee the embedded tree is present /// Bump when the embedded render package changes without a cph crate-version
/// there. Returns the directory the World should use. /// bump. Otherwise a user's old per-version cache can miss newly added package
/// functions (such as `render-outline`).
const RENDER_CACHE_REVISION: &str = "outline-v2";
/// The version/revision-keyed cache location and a guarantee the embedded tree
/// is present there. Returns the directory the World should use.
fn ensure_extracted() -> std::io::Result<PathBuf> { fn ensure_extracted() -> std::io::Result<PathBuf> {
let base = dirs::cache_dir() let base = dirs::cache_dir()
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no user cache dir"))?; .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no user cache dir"))?;
let dest = base let dest = base
.join("cph") .join("cph")
.join(format!("render-{}", env!("CARGO_PKG_VERSION"))); .join(format!("render-{}", env!("CARGO_PKG_VERSION")));
// A sentinel marks a complete extraction; if present, reuse as-is. (Keyed by
// version, so a new `cph` version re-extracts into a fresh dir.)
let sentinel = dest.join(".extracted"); let sentinel = dest.join(".extracted");
if sentinel.is_file() { let expected = format!("{}:{}", env!("CARGO_PKG_VERSION"), RENDER_CACHE_REVISION);
if std::fs::read_to_string(&sentinel)
.map(|contents| contents.trim_end() == expected)
.unwrap_or(false)
{
return Ok(dest); return Ok(dest);
} }
// The crate version can stay stable while the embedded render package
// evolves. Remove the old tree before extracting so deleted files do not
// survive a revision refresh.
if dest.exists() {
std::fs::remove_dir_all(&dest)?;
}
extract_to(&dest)?; extract_to(&dest)?;
std::fs::write(&sentinel, env!("CARGO_PKG_VERSION"))?; std::fs::write(&sentinel, expected)?;
Ok(dest) Ok(dest)
} }
+99 -14
View File
@@ -39,15 +39,15 @@ mod embedded;
mod manifest; mod manifest;
mod world; mod world;
use cph_diag::{DiagCode, Diagnostic};
use std::path::PathBuf; use std::path::PathBuf;
use cph_diag::{DiagCode, Diagnostic}; use cph_model::{Artifact, Bundle, Lesson, OutlineDocument, Step, TargetConfig};
use cph_model::{Artifact, Lesson, Step, TargetConfig};
use typst_kit::fonts::{self, FontStore}; use typst_kit::fonts::{self, FontStore};
use typst_layout::PagedDocument; use typst_layout::PagedDocument;
use typst_pdf::PdfOptions; use typst_pdf::PdfOptions;
pub use manifest::build_augmented_manifest; pub use manifest::{build_augmented_bundle_manifest, build_augmented_manifest};
pub use world::{render_package_spec, LessonWorld, MANIFEST_VPATH}; pub use world::{render_package_spec, LessonWorld, MANIFEST_VPATH};
/// The compile/PDF engine: holds the shared font store and the on-disk location /// The compile/PDF engine: holds the shared font store and the on-disk location
@@ -139,10 +139,38 @@ impl Engine {
typst_pdf::pdf(&doc, &PdfOptions::default()).map_err(|errors| map_all(&world, &errors)) typst_pdf::pdf(&doc, &PdfOptions::default()).map_err(|errors| map_all(&world, &errors))
} }
/// Build a PDF for an outline document without creating files in the lesson.
///
/// The outline entrypoint and its TOML data are served by an in-memory
/// [`LessonWorld`]. This keeps outline generation independent of any
/// declared lesson export target while reusing the embedded fonts and PDF
/// backend.
pub fn build_outline_pdf(&self, outline: &OutlineDocument) -> Result<Vec<u8>, Vec<Diagnostic>> {
const SOURCE: &str = r#"#import "@local/cph-render:0.1.0": render-outline
#let outline = toml(sys.inputs.outline)
#render-outline(outline)
"#;
let outline_src = toml::to_string(outline).expect("outline serializes to TOML");
let world = LessonWorld::new_outline(
PathBuf::from("."),
self.render_dir.clone(),
SOURCE.to_owned(),
outline_src,
self.fonts.clone(),
);
let warned = typst::compile::<PagedDocument>(&world);
let doc = match warned.output {
Ok(doc) => doc,
Err(errors) => return Err(map_all(&world, &errors)),
};
typst_pdf::pdf(&doc, &PdfOptions::default()).map_err(|errors| map_all(&world, &errors))
}
/// Build the [`LessonWorld`] for `(lesson, target)`, or `Err(blocking)` when /// Build the [`LessonWorld`] for `(lesson, target)`, or `Err(blocking)` when
/// the request cannot be honored (see [`target_precheck`]). /// the request cannot be honored (see [`target_precheck`]).
fn world_for(&self, lesson: &Lesson, target: &str) -> Result<LessonWorld, Vec<Diagnostic>> { fn world_for(&self, lesson: &Lesson, target: &str) -> Result<LessonWorld, Vec<Diagnostic>> {
let template = target_precheck(lesson, target)?; let template = target_precheck(target, &lesson.targets)?;
let manifest_src = build_augmented_manifest(lesson); let manifest_src = build_augmented_manifest(lesson);
Ok(LessonWorld::new( Ok(LessonWorld::new(
lesson.root.clone(), lesson.root.clone(),
@@ -152,6 +180,60 @@ impl Engine {
self.fonts.clone(), self.fonts.clone(),
)) ))
} }
/// Compile-check `bundle` for `target` (ADR-0037) — same contract as
/// [`Engine::compile_check`], but over a [`Bundle`]'s own declared targets
/// and the augmented **bundle** manifest (each member lesson's outline,
/// path-prefixed to resolve against the bundle root).
pub fn compile_check_bundle(&self, bundle: &Bundle, target: &str) -> Vec<Diagnostic> {
let world = match self.world_for_bundle(bundle, target) {
Ok(world) => world,
Err(blocking) => return blocking,
};
let warned = typst::compile::<PagedDocument>(&world);
let mut out = Vec::new();
if let Err(errors) = &warned.output {
out.extend(map_all(&world, errors));
}
out.extend(map_all(&world, &warned.warnings));
out
}
/// Build a PDF for `bundle` / `target` (ADR-0037) — same contract as
/// [`Engine::build_pdf`], over a [`Bundle`]'s own declared targets.
pub fn build_bundle_pdf(
&self,
bundle: &Bundle,
target: &str,
) -> Result<Vec<u8>, Vec<Diagnostic>> {
let world = self.world_for_bundle(bundle, target)?;
let warned = typst::compile::<PagedDocument>(&world);
let doc = match warned.output {
Ok(doc) => doc,
Err(errors) => return Err(map_all(&world, &errors)),
};
typst_pdf::pdf(&doc, &PdfOptions::default()).map_err(|errors| map_all(&world, &errors))
}
/// Build the [`LessonWorld`] for `(bundle, target)`: same shape as
/// [`Engine::world_for`], main file resolved under the **bundle** root and
/// the injected manifest built by [`build_augmented_bundle_manifest`].
fn world_for_bundle(
&self,
bundle: &Bundle,
target: &str,
) -> Result<LessonWorld, Vec<Diagnostic>> {
let template = target_precheck(target, &bundle.targets)?;
let manifest_src = build_augmented_bundle_manifest(bundle);
Ok(LessonWorld::new(
bundle.root.clone(),
self.render_dir.clone(),
&template,
manifest_src,
self.fonts.clone(),
))
}
} }
impl Default for Engine { impl Default for Engine {
@@ -160,13 +242,16 @@ impl Default for Engine {
} }
} }
/// Validate a `(lesson, target)` request and resolve the template path to /// Validate a `(targets, target)` request and resolve the template path to
/// compile. Returns `Ok(template_path)` (relative to the lesson root) when the /// compile. Shared by [`Engine::world_for`] (a lesson's `targets`) and
/// request is buildable, or `Err(blocking_diagnostics)` when it is not: /// [`Engine::world_for_bundle`] (a bundle's own `targets` — ADR-0037 gives a
/// bundle target the exact same build/artifact/step shape). Returns
/// `Ok(template_path)` (relative to the lesson/bundle root) when the request is
/// buildable, or `Err(blocking_diagnostics)` when it is not:
/// ///
/// - **Unknown target** (the `--target` name isn't in `lesson.targets`, and the /// - **Unknown target** (the `--target` name isn't in `targets`, and `targets`
/// lesson declares at least one target): a `SchemaViolation` error — a target /// is non-empty): a `SchemaViolation` error — a target must be declared in
/// must be declared in the manifest to be built (ADR-0009). /// the manifest to be built (ADR-0009).
/// - **No declared targets at all**: not an error — callers (e.g. `cph-check`) /// - **No declared targets at all**: not an error — callers (e.g. `cph-check`)
/// may compile-check a defaulted `"student"` target the lesson never declared. /// may compile-check a defaulted `"student"` target the lesson never declared.
/// The stock template path `exports/<target>.typ` is used (the framework /// The stock template path `exports/<target>.typ` is used (the framework
@@ -177,10 +262,10 @@ impl Default for Engine {
/// [`Step::Shell`], returns a clear "not yet implemented" `SchemaViolation` /// [`Step::Shell`], returns a clear "not yet implemented" `SchemaViolation`
/// rather than wrong output. The template is taken from the **first** /// rather than wrong output. The template is taken from the **first**
/// `TypstCompile` step (MVP: one step per target). /// `TypstCompile` step (MVP: one step per target).
fn target_precheck(lesson: &Lesson, target: &str) -> Result<PathBuf, Vec<Diagnostic>> { fn target_precheck(target: &str, targets: &[TargetConfig]) -> Result<PathBuf, Vec<Diagnostic>> {
let Some(tc) = lesson.targets.iter().find(|t| t.name == target) else { let Some(tc) = targets.iter().find(|t| t.name == target) else {
if lesson.targets.is_empty() { if targets.is_empty() {
// Lesson declares no targets; the orchestrator compiles a defaulted // Declares no targets; the orchestrator compiles a defaulted
// target. Use the stock template path (matches cph-model's default). // target. Use the stock template path (matches cph-model's default).
return Ok(PathBuf::from(format!("exports/{target}.typ"))); return Ok(PathBuf::from(format!("exports/{target}.typ")));
} }
+156 -34
View File
@@ -1,18 +1,28 @@
//! Augmented-manifest construction (ADR-0011). //! Augmented-manifest construction (ADR-0011, outline shape per ADR-0036).
//! //!
//! The template (`exports/<target>.typ`) reads the manifest via //! The template (`exports/<target>.typ`) reads the manifest via
//! `toml(sys.inputs.manifest)`, then for each part `include`s its content fields //! `toml(sys.inputs.manifest)`, then for each **element** outline entry
//! by a **computed** path and reads scalar fields from `<path>/element.toml`. //! `include`s its content fields by a **computed** path and reads scalar
//! For *optional* content fields the template must know whether the file exists //! fields from `<path>/element.toml`. For *optional* content fields the
//! on disk — typst has no file-exists primitive and a missing `include` is a //! template must know whether the file exists on disk — typst has no
//! hard error (see the OPEN contract point in `render/templates/student.typ`). //! file-exists primitive and a missing `include` is a hard error (see the OPEN
//! contract point in `render/templates/student.typ`).
//! //!
//! The ENGINE has filesystem access, so it closes that gap: it builds an //! The ENGINE has filesystem access, so it closes that gap: it builds an
//! **augmented manifest** = the lesson's `[info]` + ordered `[[parts]]`, with a //! **augmented manifest** = the lesson's `[info]` + the ordered `[[outline]]`
//! per-part **`fields` array** listing the content fields whose `<field>.typ` //! (ADR-0036's depth-first rendering order — elements interleaved with section
//! actually exists under the lesson root. The augmented manifest is served as an //! headings at their DFS-open position). Each `[[outline]]` entry carries a
//! in-memory virtual file in the [`crate::world::LessonWorld`] (it is **never** //! `type` discriminator (`"element"` | `"section"`):
//! written to the user's tree), and injected via `sys.inputs.manifest`. //!
//! - `type = "element"`: `kind`, `path`, and a per-part **`fields` array**
//! listing the content fields whose `<field>.typ` actually exists under the
//! lesson root (same contract as before ADR-0036).
//! - `type = "section"`: `kind`, `title`, `depth`, `path` — a section heading;
//! the template renders it without touching any content file.
//!
//! The augmented manifest is served as an in-memory virtual file in the
//! [`crate::world::LessonWorld`] (it is **never** written to the user's tree),
//! and injected via `sys.inputs.manifest`.
//! //!
//! ## `fields` is computed from `cph-schema` //! ## `fields` is computed from `cph-schema`
//! //!
@@ -20,23 +30,104 @@
//! ([`cph_schema::KindSchema::content_field_names`]) — the same knowledge the //! ([`cph_schema::KindSchema::content_field_names`]) — the same knowledge the
//! render package exposes as `part-fields`. We reuse it here rather than //! render package exposes as `part-fields`. We reuse it here rather than
//! re-deriving a kind→fields map, so the engine and the template agree on what a //! re-deriving a kind→fields map, so the engine and the template agree on what a
//! kind's content fields are. For each part, a content field is listed in //! kind's content fields are. For each element, a content field is listed in
//! `fields` iff `<root>/<part.path>/<field>.typ` is a real file. //! `fields` iff `<root>/<part.path>/<field>.typ` is a real file.
use cph_model::Lesson; use std::path::Path;
use cph_model::{Bundle, BundleLesson, Lesson, OutlineEntry};
/// Build the augmented-manifest TOML source for `lesson`. /// Build the augmented-manifest TOML source for `lesson`.
/// ///
/// The result is a self-contained TOML document the template's /// The result is a self-contained TOML document the template's
/// `toml(sys.inputs.manifest)` reads. It carries `[info]` (title + optional /// `toml(sys.inputs.manifest)` reads. It carries `[info]` (title + optional
/// author) and the ordered `[[parts]]`, each with `kind`, `path`, and a /// author) and the ordered `[[outline]]` (ADR-0036's depth-first rendering
/// `fields = [...]` array of the content fields present on disk (per /// order), each entry typed `"element"` or `"section"` per the module docs. It
/// [`present_fields`]). It does **not** reproduce `[project]` or `[targets.*]` /// does **not** reproduce `[project]` or `[targets.*]` — the template only
/// — the template only consumes `info` and `parts`. /// consumes `info` and `outline`.
pub fn build_augmented_manifest(lesson: &Lesson) -> String { pub fn build_augmented_manifest(lesson: &Lesson) -> String {
let mut doc = toml::Table::new(); let mut doc = toml::Table::new();
// [info] doc.insert("info".to_string(), toml::Value::Table(info_table(lesson)));
// [[outline]] — ADR-0036's depth-first rendering order: elements
// interleaved with section headings at their DFS-open position.
let outline: Vec<toml::Value> = lesson
.outline
.iter()
.map(|entry| toml::Value::Table(outline_entry_table(lesson, entry, None)))
.collect();
doc.insert("outline".to_string(), toml::Value::Array(outline));
toml::to_string(&doc).expect("augmented manifest serializes")
}
/// Build the augmented **bundle** manifest TOML source for `bundle` (ADR-0037).
///
/// The bundle template (`exports/<target>.typ` under the `bundle.toml` root)
/// reads it via `toml(sys.inputs.manifest)`. It carries `[info]` (the bundle's
/// own title/author) and the ordered `[[lessons]]`, each a
/// `(info, target, outline)` table — the same shape a single-lesson template
/// would assemble, except every outline entry's `path` is **prefixed with that
/// lesson's own bundle-root-relative directory** (`BundleLesson::path`), since
/// the bundle template's computed include paths resolve against the *bundle*
/// root, not each lesson's own root (ADR-0037: combination reads
/// already-authored lessons at export time; each lesson's `path` bookkeeping
/// stays correct because the prefix is applied only here, in the manifest the
/// template consumes — never inside a lesson's own authored content).
pub fn build_augmented_bundle_manifest(bundle: &Bundle) -> String {
let mut doc = toml::Table::new();
let mut info = toml::Table::new();
info.insert(
"title".to_string(),
toml::Value::String(bundle.info.title.clone()),
);
if !bundle.info.authors.is_empty() {
let authors = bundle
.info
.authors
.iter()
.cloned()
.map(toml::Value::String)
.collect();
info.insert("author".to_string(), toml::Value::Array(authors));
}
doc.insert("info".to_string(), toml::Value::Table(info));
let lessons: Vec<toml::Value> = bundle
.lessons
.iter()
.map(|bl| toml::Value::Table(bundle_lesson_table(bl)))
.collect();
doc.insert("lessons".to_string(), toml::Value::Array(lessons));
toml::to_string(&doc).expect("augmented bundle manifest serializes")
}
/// Build one `[[lessons]]` entry's table: that member lesson's own `info`,
/// its selected `target`, and its outline with every entry's `path` prefixed
/// by the lesson's bundle-relative directory.
fn bundle_lesson_table(bl: &BundleLesson) -> toml::Table {
let mut t = toml::Table::new();
t.insert(
"info".to_string(),
toml::Value::Table(info_table(&bl.lesson)),
);
t.insert("target".to_string(), toml::Value::String(bl.target.clone()));
let outline: Vec<toml::Value> = bl
.lesson
.outline
.iter()
.map(|entry| toml::Value::Table(outline_entry_table(&bl.lesson, entry, Some(&bl.path))))
.collect();
t.insert("outline".to_string(), toml::Value::Array(outline));
t
}
/// Build the `[info]` table shared by a single-lesson manifest and a bundle
/// member's `info` entry.
fn info_table(lesson: &Lesson) -> toml::Table {
let mut info = toml::Table::new(); let mut info = toml::Table::new();
info.insert( info.insert(
"title".to_string(), "title".to_string(),
@@ -52,30 +143,61 @@ pub fn build_augmented_manifest(lesson: &Lesson) -> String {
.collect(); .collect();
info.insert("author".to_string(), toml::Value::Array(authors)); info.insert("author".to_string(), toml::Value::Array(authors));
} }
doc.insert("info".to_string(), toml::Value::Table(info)); info
}
// [[parts]] — preserve declared order; attach the on-disk `fields` array. /// Build one `[[outline]]` entry's table for either variant of
let parts: Vec<toml::Value> = lesson /// [`OutlineEntry`]. `bundle_prefix`, when set (ADR-0037's bundle case), is
.parts /// joined onto the emitted `path` so the bundle template's computed include
.iter() /// resolves against the bundle root rather than the lesson's own root.
.map(|part| { fn outline_entry_table(
let mut entry = toml::Table::new(); lesson: &Lesson,
entry.insert("kind".to_string(), toml::Value::String(part.kind.clone())); entry: &OutlineEntry,
entry.insert( bundle_prefix: Option<&Path>,
) -> toml::Table {
let mut e = toml::Table::new();
match entry {
OutlineEntry::Element { part_index, .. } => {
let part = &lesson.parts[*part_index];
e.insert("type".to_string(), toml::Value::String("element".into()));
e.insert("kind".to_string(), toml::Value::String(part.kind.clone()));
e.insert(
"path".to_string(), "path".to_string(),
toml::Value::String(path_to_forward_slash(&part.path)), toml::Value::String(prefixed_forward_slash(bundle_prefix, &part.path)),
); );
let fields = present_fields(lesson, part) let fields = present_fields(lesson, part)
.into_iter() .into_iter()
.map(toml::Value::String) .map(toml::Value::String)
.collect(); .collect();
entry.insert("fields".to_string(), toml::Value::Array(fields)); e.insert("fields".to_string(), toml::Value::Array(fields));
toml::Value::Table(entry) }
}) OutlineEntry::Section {
.collect(); kind,
doc.insert("parts".to_string(), toml::Value::Array(parts)); title,
depth,
path,
notes: _,
} => {
e.insert("type".to_string(), toml::Value::String("section".into()));
e.insert("kind".to_string(), toml::Value::String(kind.clone()));
e.insert("title".to_string(), toml::Value::String(title.clone()));
e.insert("depth".to_string(), toml::Value::Integer(i64::from(*depth)));
e.insert(
"path".to_string(),
toml::Value::String(prefixed_forward_slash(bundle_prefix, path)),
);
}
}
e
}
toml::to_string(&doc).expect("augmented manifest serializes") /// [`path_to_forward_slash`], with `prefix` (a bundle member's own
/// bundle-relative directory) joined in front when present.
fn prefixed_forward_slash(prefix: Option<&Path>, path: &Path) -> String {
match prefix {
Some(p) => path_to_forward_slash(&p.join(path)),
None => path_to_forward_slash(path),
}
} }
/// The content fields of `part`'s kind whose `<root>/<part.path>/<field>.typ` /// The content fields of `part`'s kind whose `<root>/<part.path>/<field>.typ`
+68 -26
View File
@@ -48,13 +48,14 @@ use typst::{Library, LibraryExt, World};
use typst_kit::fonts::FontStore; use typst_kit::fonts::FontStore;
/// Root-relative vpath the augmented manifest is served at (in-memory only). /// Root-relative vpath the augmented manifest is served at (in-memory only).
///
/// A **leading slash** is essential: the template lives under `exports/`, and
/// `toml(sys.inputs.manifest)` resolves a relative path against the template's
/// own directory — a bare name would miss. A root-relative absolute path anchors
/// at `--root` (the lesson root) regardless of where the template sits.
pub const MANIFEST_VPATH: &str = "/.cph/manifest.toml"; pub const MANIFEST_VPATH: &str = "/.cph/manifest.toml";
/// Root-relative vpath of the virtual outline entrypoint.
pub const OUTLINE_VPATH: &str = "/exports/outline.typ";
/// Root-relative vpath of the virtual outline data file.
pub const OUTLINE_DATA_VPATH: &str = "/.cph/outline.toml";
/// The package spec the template imports and the World mounts from `render_dir`. /// The package spec the template imports and the World mounts from `render_dir`.
pub fn render_package_spec() -> PackageSpec { pub fn render_package_spec() -> PackageSpec {
PackageSpec { PackageSpec {
@@ -76,13 +77,11 @@ pub struct LessonWorld {
render_dir: PathBuf, render_dir: PathBuf,
/// The render package spec (`@local/cph-render:0.1.0`). /// The render package spec (`@local/cph-render:0.1.0`).
render_spec: PackageSpec, render_spec: PackageSpec,
/// FileId of the template entrypoint (a real file under `root`). /// FileId of the entrypoint.
main: FileId, main: FileId,
/// FileId of the in-memory augmented manifest. /// In-memory project files (manifest, or the outline entrypoint/data).
manifest_id: FileId, virtual_sources: HashMap<FileId, Source>,
/// The augmented-manifest source (in-memory; never on disk). /// Standard library inputs exposed to the Typst source.
manifest_source: Source,
/// Standard library, with `sys.inputs.manifest` set.
library: LazyHash<Library>, library: LazyHash<Library>,
/// Shared font store (book + lazily-loaded fonts). /// Shared font store (book + lazily-loaded fonts).
fonts: Arc<FontStore>, fonts: Arc<FontStore>,
@@ -95,8 +94,8 @@ impl LessonWorld {
/// whose injected manifest is `manifest_src` (served virtually at /// whose injected manifest is `manifest_src` (served virtually at
/// [`MANIFEST_VPATH`], with `sys.inputs.manifest` pointing there). /// [`MANIFEST_VPATH`], with `sys.inputs.manifest` pointing there).
/// ///
/// `template` is the lesson-root-relative template path (e.g. /// `template` is the lesson-root-relative path taken from the target's
/// `exports/student.typ`), taken from the target's `Step::TypstCompile`. /// `Step::TypstCompile`.
pub fn new( pub fn new(
root: PathBuf, root: PathBuf,
render_dir: PathBuf, render_dir: PathBuf,
@@ -107,16 +106,55 @@ impl LessonWorld {
let main_vpath = VirtualPath::new(format!("/{}", path_to_forward_slash(template))) let main_vpath = VirtualPath::new(format!("/{}", path_to_forward_slash(template)))
.expect("template vpath is a valid virtual path"); .expect("template vpath is a valid virtual path");
let main = FileId::new(RootedPath::new(VirtualRoot::Project, main_vpath)); let main = FileId::new(RootedPath::new(VirtualRoot::Project, main_vpath));
let manifest_id = project_file_id(MANIFEST_VPATH);
let mut virtual_sources = HashMap::new();
virtual_sources.insert(manifest_id, Source::new(manifest_id, manifest_src));
Self::with_virtual_files(
root,
render_dir,
main,
virtual_sources,
&[("manifest", MANIFEST_VPATH)],
fonts,
)
}
let manifest_vpath = /// Build a world for a fully virtual outline document and its TOML data.
VirtualPath::new(MANIFEST_VPATH).expect("manifest vpath is a valid virtual path"); /// The caller never has to create temporary files in the engineering file.
let manifest_id = FileId::new(RootedPath::new(VirtualRoot::Project, manifest_vpath)); pub fn new_outline(
let manifest_source = Source::new(manifest_id, manifest_src); root: PathBuf,
render_dir: PathBuf,
source: String,
outline_src: String,
fonts: Arc<FontStore>,
) -> Self {
let main = project_file_id(OUTLINE_VPATH);
let outline_id = project_file_id(OUTLINE_DATA_VPATH);
let mut virtual_sources = HashMap::new();
virtual_sources.insert(main, Source::new(main, source));
virtual_sources.insert(outline_id, Source::new(outline_id, outline_src));
Self::with_virtual_files(
root,
render_dir,
main,
virtual_sources,
&[("outline", OUTLINE_DATA_VPATH)],
fonts,
)
}
// Inject `sys.inputs.manifest = "/.cph/manifest.toml"` so the template's fn with_virtual_files(
// `toml(sys.inputs.manifest)` reads the augmented manifest. root: PathBuf,
render_dir: PathBuf,
main: FileId,
virtual_sources: HashMap<FileId, Source>,
input_files: &[(&str, &str)],
fonts: Arc<FontStore>,
) -> Self {
let mut inputs = Dict::new(); let mut inputs = Dict::new();
inputs.insert("manifest".into(), Value::Str(MANIFEST_VPATH.into())); for (name, path) in input_files {
inputs.insert((*name).into(), Value::Str((*path).into()));
}
let library = Library::builder().with_inputs(inputs).build(); let library = Library::builder().with_inputs(inputs).build();
Self { Self {
@@ -124,8 +162,7 @@ impl LessonWorld {
render_dir, render_dir,
render_spec: render_package_spec(), render_spec: render_package_spec(),
main, main,
manifest_id, virtual_sources,
manifest_source,
library: LazyHash::new(library), library: LazyHash::new(library),
fonts, fonts,
sources: Mutex::new(HashMap::new()), sources: Mutex::new(HashMap::new()),
@@ -185,8 +222,8 @@ impl World for LessonWorld {
} }
fn source(&self, id: FileId) -> FileResult<Source> { fn source(&self, id: FileId) -> FileResult<Source> {
if id == self.manifest_id { if let Some(source) = self.virtual_sources.get(&id) {
return Ok(self.manifest_source.clone()); return Ok(source.clone());
} }
// Cache hit? // Cache hit?
if let Some(src) = self.sources.lock().expect("sources mutex").get(&id) { if let Some(src) = self.sources.lock().expect("sources mutex").get(&id) {
@@ -203,8 +240,8 @@ impl World for LessonWorld {
} }
fn file(&self, id: FileId) -> FileResult<Bytes> { fn file(&self, id: FileId) -> FileResult<Bytes> {
if id == self.manifest_id { if let Some(source) = self.virtual_sources.get(&id) {
return Ok(Bytes::from_string(self.manifest_source.text().to_string())); return Ok(Bytes::from_string(source.text().to_string()));
} }
let bytes = self.read_bytes(id)?; let bytes = self.read_bytes(id)?;
Ok(Bytes::new(bytes)) Ok(Bytes::new(bytes))
@@ -220,6 +257,11 @@ impl World for LessonWorld {
} }
} }
fn project_file_id(path: &str) -> FileId {
let vpath = VirtualPath::new(path).expect("virtual project path is valid");
FileId::new(RootedPath::new(VirtualRoot::Project, vpath))
}
/// Render a relative `Path` as a forward-slash string, dropping any leading /// Render a relative `Path` as a forward-slash string, dropping any leading
/// `./` or `/` and ignoring `..`. UTF-8 segments kept verbatim. /// `./` or `/` and ignoring `..`. UTF-8 segments kept verbatim.
fn path_to_forward_slash(path: &Path) -> String { fn path_to_forward_slash(path: &Path) -> String {
+122
View File
@@ -0,0 +1,122 @@
//! Integration tests for the bundle build path (ADR-0037): compiling a bundle
//! target's template (`exports/<target>.typ` under a `bundle.toml` root) as
//! main, injecting the augmented **bundle** manifest, against the real
//! `render/` package.
use std::path::PathBuf;
use cph_diag::Severity;
use cph_typst::{build_augmented_bundle_manifest, Engine};
fn fixture_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bundle")
}
fn real_render_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("render")
}
fn load_bundle() -> cph_model::Bundle {
let (bundle, diags) = cph_model::load_bundle(&fixture_root());
let bundle = bundle.expect("bundle fixture loads into a Bundle");
let errors: Vec<_> = diags
.iter()
.filter(|d| d.severity == Severity::Error)
.collect();
assert!(errors.is_empty(), "fixture has loader errors: {errors:?}");
bundle
}
/// PURE UNIT TEST (no fonts, no render package): the augmented bundle manifest
/// carries the bundle's own `[info]` and an ordered `[[lessons]]`, each with
/// that member's own `info`/`target` and a `path`-prefixed outline (ADR-0037).
#[test]
fn augmented_bundle_manifest_prefixes_member_paths() {
let bundle = load_bundle();
let src = build_augmented_bundle_manifest(&bundle);
assert!(
src.contains("测试合集"),
"bundle info.title present:\n{src}"
);
let doc: toml::Value = toml::from_str(&src).expect("augmented bundle manifest is valid TOML");
let lessons = doc
.get("lessons")
.and_then(|l| l.as_array())
.expect("lessons array present");
assert_eq!(lessons.len(), 2, "two member lessons:\n{src}");
// lesson-a: target defaults to its own first declared target ("student"),
// outline paths are prefixed with "lesson-a/".
let a_target = lessons[0].get("target").unwrap().as_str().unwrap();
assert_eq!(a_target, "student");
let a_outline = lessons[0].get("outline").unwrap().as_array().unwrap();
// segment + section heading + lemma = 3 outline entries.
assert_eq!(a_outline.len(), 3);
let a_seg_path = a_outline[0].get("path").unwrap().as_str().unwrap();
assert_eq!(a_seg_path, "lesson-a/segments/a");
let a_section_path = a_outline[1].get("path").unwrap().as_str().unwrap();
assert_eq!(a_section_path, "lesson-a/小节");
let a_lemma_path = a_outline[2].get("path").unwrap().as_str().unwrap();
assert_eq!(a_lemma_path, "lesson-a/小节/lemmas/引理甲");
// lesson-b: bundle.toml overrides `target = "teacher"`.
let b_target = lessons[1].get("target").unwrap().as_str().unwrap();
assert_eq!(b_target, "teacher");
let b_outline = lessons[1].get("outline").unwrap().as_array().unwrap();
assert_eq!(b_outline.len(), 1);
let b_seg_path = b_outline[0].get("path").unwrap().as_str().unwrap();
assert_eq!(b_seg_path, "lesson-b/segments/b");
}
/// THROUGH-TEMPLATE compile-check against the REAL render package: compiling
/// the bundle's `merged` target as main with the injected augmented bundle
/// manifest is clean.
#[test]
fn compile_check_clean_through_bundle_template() {
let bundle = load_bundle();
let engine = Engine::with_render_dir(real_render_dir());
let diags = engine.compile_check_bundle(&bundle, "merged");
let errors: Vec<_> = diags
.iter()
.filter(|d| d.severity == Severity::Error)
.collect();
assert!(errors.is_empty(), "unexpected compile errors: {errors:#?}");
}
/// THROUGH-TEMPLATE PDF export, fully offline: a non-trivial combined PDF is
/// produced from the two member lessons through the real bundle template.
#[test]
fn build_bundle_pdf_through_template_offline() {
let bundle = load_bundle();
let engine = Engine::with_render_dir(real_render_dir());
let pdf = engine
.build_bundle_pdf(&bundle, "merged")
.unwrap_or_else(|d| panic!("bundle PDF build failed: {d:#?}"));
assert!(pdf.starts_with(b"%PDF"), "output is a PDF");
assert!(
pdf.len() > 1024,
"bundle PDF is non-trivial (got {} bytes)",
pdf.len()
);
}
/// An undeclared bundle target name is a blocking `SchemaViolation`, exactly
/// like a lesson's own unknown-target path.
#[test]
fn unknown_bundle_target_is_blocking() {
let bundle = load_bundle();
let engine = Engine::with_render_dir(real_render_dir());
let diags = engine.compile_check_bundle(&bundle, "nonexistent");
assert_eq!(diags.len(), 1, "one blocking diagnostic: {diags:#?}");
assert_eq!(diags[0].severity, Severity::Error);
assert!(
diags[0].message.contains("not declared"),
"expected an undeclared-target error: {diags:#?}"
);
}
+73 -37
View File
@@ -39,10 +39,12 @@ fn load_mini() -> cph_model::Lesson {
} }
/// PURE UNIT TEST (no fonts, no render package): the augmented manifest carries /// PURE UNIT TEST (no fonts, no render package): the augmented manifest carries
/// `[info]`, the ordered `[[parts]]`, and a per-part `fields` array listing the /// `[info]` and the ordered `[[outline]]` (ADR-0036) — elements (with a
/// content fields present on disk. /// per-element `fields` array of the content fields present on disk)
/// interleaved with the section heading the mini fixture nests its two lemmas
/// under.
#[test] #[test]
fn augmented_manifest_has_per_part_fields() { fn augmented_manifest_has_outline_with_section_and_fields() {
let lesson = load_mini(); let lesson = load_mini();
let src = build_augmented_manifest(&lesson); let src = build_augmented_manifest(&lesson);
@@ -50,66 +52,98 @@ fn augmented_manifest_has_per_part_fields() {
assert!(src.contains("迷你示例课时"), "info.title present:\n{src}"); assert!(src.contains("迷你示例课时"), "info.title present:\n{src}");
assert!(src.contains("测试作者"), "info.author present:\n{src}"); assert!(src.contains("测试作者"), "info.author present:\n{src}");
// Parse it back to inspect the per-part fields precisely. // Parse it back to inspect the outline entries precisely.
let doc: toml::Value = toml::from_str(&src).expect("augmented manifest is valid TOML"); let doc: toml::Value = toml::from_str(&src).expect("augmented manifest is valid TOML");
let parts = doc let outline = doc
.get("parts") .get("outline")
.and_then(|p| p.as_array()) .and_then(|p| p.as_array())
.expect("parts array present"); .expect("outline array present");
assert_eq!(parts.len(), 4, "four parts in declared order:\n{src}"); // segment, section, lemma, lemma, example — 5 entries (ADR-0036: the
// section contributes a heading entry, not an element).
assert_eq!(outline.len(), 5, "five outline entries:\n{src}");
// `fields` is a presence SET (the template tests membership), so order is let entry_type = |idx: usize| {
// not load-bearing; sort for a stable assertion. outline[idx]
let fields_of = |idx: usize| -> Vec<String> { .get("type")
let mut v: Vec<String> = parts[idx]
.get("fields")
.and_then(|f| f.as_array())
.expect("part has a fields array")
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
v.sort();
v
};
let path_of = |idx: usize| {
parts[idx]
.get("path")
.unwrap() .unwrap()
.as_str() .as_str()
.unwrap() .unwrap()
.to_string() .to_string()
}; };
let kind_of = |idx: usize| { let kind_of = |idx: usize| {
parts[idx] outline[idx]
.get("kind") .get("kind")
.unwrap() .unwrap()
.as_str() .as_str()
.unwrap() .unwrap()
.to_string() .to_string()
}; };
let path_of = |idx: usize| {
outline[idx]
.get("path")
.unwrap()
.as_str()
.unwrap()
.to_string()
};
// `fields` is a presence SET (the template tests membership), so order is
// not load-bearing; sort for a stable assertion.
let fields_of = |idx: usize| -> Vec<String> {
let mut v: Vec<String> = outline[idx]
.get("fields")
.and_then(|f| f.as_array())
.expect("element entry has a fields array")
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
v.sort();
v
};
// Order preserved: segment, lemma (w/ proof), lemma (no proof), example. // Order preserved: segment, section (引理组), lemma (w/ proof), lemma (no
// proof), example.
assert_eq!(entry_type(0), "element");
assert_eq!(kind_of(0), "segment"); assert_eq!(kind_of(0), "segment");
assert_eq!(kind_of(1), "lemma");
assert_eq!(kind_of(2), "lemma");
assert_eq!(kind_of(3), "example");
// Paths kept as forward-slash UTF-8.
assert_eq!(path_of(0), "segments/开场对照导言"); assert_eq!(path_of(0), "segments/开场对照导言");
assert_eq!(path_of(2), "lemmas/无证明引理");
// segment: only `textbook` exists.
assert_eq!(fields_of(0), vec!["textbook"]); assert_eq!(fields_of(0), vec!["textbook"]);
assert_eq!(entry_type(1), "section");
assert_eq!(outline[1].get("title").unwrap().as_str().unwrap(), "引理组");
assert_eq!(outline[1].get("depth").unwrap().as_integer().unwrap(), 1);
assert_eq!(path_of(1), "引理组");
assert_eq!(entry_type(2), "element");
assert_eq!(kind_of(2), "lemma");
assert_eq!(path_of(2), "引理组/lemmas/量纲分析估计");
// lemma WITH proof.typ: both stmt + proof present (sorted). // lemma WITH proof.typ: both stmt + proof present (sorted).
assert_eq!(fields_of(1), vec!["proof", "stmt"]); assert_eq!(fields_of(2), vec!["proof", "stmt"]);
assert_eq!(entry_type(3), "element");
assert_eq!(kind_of(3), "lemma");
assert_eq!(path_of(3), "引理组/lemmas/无证明引理");
// lemma WITHOUT proof.typ: only stmt present (the OPTIONAL-content path). // lemma WITHOUT proof.typ: only stmt present (the OPTIONAL-content path).
assert_eq!( assert_eq!(
fields_of(2), fields_of(3),
vec!["stmt"], vec!["stmt"],
"proof must be omitted when absent" "proof must be omitted when absent"
); );
assert_eq!(entry_type(4), "element");
assert_eq!(kind_of(4), "example");
// example: problem + solution present (source is a scalar, not a content field). // example: problem + solution present (source is a scalar, not a content field).
assert_eq!(fields_of(3), vec!["problem", "solution"]); assert_eq!(fields_of(4), vec!["problem", "solution"]);
}
#[test]
fn outline_pdf_renders_without_lesson_files() {
let lesson = load_mini();
let outline = lesson.outline_document();
let engine = Engine::with_render_dir(real_render_dir());
let pdf = engine
.build_outline_pdf(&outline)
.expect("outline PDF should compile");
assert!(pdf.starts_with(b"%PDF"), "output should be a PDF");
assert!(pdf.len() > 1_000, "outline PDF should be non-trivial");
} }
/// THROUGH-TEMPLATE compile-check against the REAL render package: compiling the /// THROUGH-TEMPLATE compile-check against the REAL render package: compiling the
@@ -202,6 +236,7 @@ fn file_tree_artifact_is_deferred() {
authors: vec![], authors: vec![],
}, },
parts: vec![], parts: vec![],
outline: vec![],
targets: vec![TargetConfig { targets: vec![TargetConfig {
name: "web".into(), name: "web".into(),
artifact: Artifact::FileTree { artifact: Artifact::FileTree {
@@ -241,6 +276,7 @@ fn shell_only_target_is_deferred() {
authors: vec![], authors: vec![],
}, },
parts: vec![], parts: vec![],
outline: vec![],
targets: vec![TargetConfig { targets: vec![TargetConfig {
name: "packaged".into(), name: "packaged".into(),
artifact: Artifact::SingleFile { artifact: Artifact::SingleFile {
+16
View File
@@ -0,0 +1,16 @@
[info]
title = "测试合集"
author = "测试作者"
[[lessons]]
path = "lesson-a"
[[lessons]]
path = "lesson-b"
target = "teacher"
[targets.merged]
artifact = { type = "single-file", filepath = "build/merged.pdf" }
[[targets.merged.steps]]
type = "typst-compile"
template = "exports/merged.typ"
@@ -0,0 +1,76 @@
// DEFAULT BUNDLE TEMPLATE (ADR-0037, outline shape ADR-0036).
//
// Lives in a bundle at `<bundle-root>/exports/<target>.typ`, e.g.
// `exports/merged.typ`. Compiled AS MAIN with the augmented BUNDLE manifest
// injected:
// typst compile --root <bundle-root> --input manifest=<path-rel-to-root> exports/merged.typ <out>
//
// Structurally identical to the single-lesson `student.typ`/`teacher.typ`
// templates (see their notes on why the include loop lives in the template,
// not in cph-render), except it reads `manifest.lessons` (an ordered array of
// per-lesson `(info, target, outline)` tables — see
// `cph_typst::build_augmented_bundle_manifest`) instead of a single
// `manifest.outline`, and calls `render-bundle` instead of `render-lesson`.
//
// Every outline entry's `path` in a bundle manifest is ALREADY prefixed with
// that lesson's own bundle-root-relative directory (done by the Rust engine),
// so the same `include "/" + path + "/" + field + ".typ"` computation used by
// a single-lesson template resolves correctly here too — no special-casing
// needed in this loop.
#import "@local/cph-render:0.1.0": render-bundle, part-fields, default-heading-numbering
#let manifest = toml(sys.inputs.manifest)
#let info = manifest.at("info", default: (:))
#let raw-lessons = manifest.at("lessons", default: ())
// Assemble one outline entry exactly as a single-lesson template would.
#let assemble-entry(raw) = {
if raw.at("type", default: "element") == "section" {
(
entry-type: "section",
kind: raw.at("kind", default: none),
title: raw.at("title", default: ""),
depth: raw.at("depth", default: 1),
)
} else {
let kind = raw.at("kind", default: none)
let path = raw.at("path", default: none)
let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ()))
let present = raw.at("fields", default: ())
let entry = (entry-type: "element", kind: kind)
for field in spec.content {
entry.insert(field, include "/" + path + "/" + field + ".typ")
}
for field in spec.optional-content {
if field in present {
entry.insert(field, include "/" + path + "/" + field + ".typ")
}
}
if spec.scalars.len() > 0 {
let element = toml("/" + path + "/element.toml")
for field in spec.scalars {
let v = element.at(field, default: none)
if v != none and v != "" { entry.insert(field, v) }
}
}
entry
}
}
#let lessons = raw-lessons.map(raw => (
info: raw.at("info", default: (:)),
target: raw.at("target", default: "student"),
outline: raw.at("outline", default: ()).map(assemble-entry),
))
// Presentation: shared per-level heading numbering across the whole bundle,
// and the ADR-0037 recommended default of resetting auto-counters at each
// lesson boundary (override `reset-counters: false` for continuous numbering).
#render-bundle(
info: info,
lessons: lessons,
heading-numbering: default-heading-numbering,
reset-counters: true,
)
@@ -0,0 +1,17 @@
[project]
id = "lesson-a"
name = "lesson-a"
[info]
title = "课时A"
author = "作者A"
[[children]]
kind = "segment"
path = "segments/a"
[[children]]
kind = "section"
path = "小节"
[targets.student]
@@ -0,0 +1 @@
= 课时A导言
@@ -0,0 +1 @@
引理甲陈述。
@@ -0,0 +1,6 @@
[group]
title = "小节"
[[children]]
kind = "lemma"
path = "lemmas/引理甲"
@@ -0,0 +1,12 @@
[project]
id = "lesson-b"
name = "lesson-b"
[info]
title = "课时B"
[[children]]
kind = "segment"
path = "segments/b"
[targets.teacher]
@@ -0,0 +1 @@
= 课时B导言
+46 -32
View File
@@ -1,4 +1,4 @@
// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011). // DEFAULT STUDENT TEMPLATE (framework default; ADR-0011, outline shape ADR-0036).
// //
// This is a *real, editable* file that lives in an engineering file at // This is a *real, editable* file that lives in an engineering file at
// `exports/student.typ`. The framework compiles it AS THE MAIN FILE with the // `exports/student.typ`. The framework compiles it AS THE MAIN FILE with the
@@ -15,15 +15,16 @@
// its own virtual root — an include inside cph-render would resolve against the // its own virtual root — an include inside cph-render would resolve against the
// PACKAGE, not the engineering root. A `/<part.path>/<field>.typ` written HERE // PACKAGE, not the engineering root. A `/<part.path>/<field>.typ` written HERE
// (this template lives under `--root`) resolves against `--root`. So the // (this template lives under `--root`) resolves against `--root`. So the
// template loads content and hands cph-render an already-assembled `parts` array. // template loads content and hands cph-render an already-assembled `outline`
// array (elements interleaved with section headings, ADR-0036).
// //
// OPEN CONTRACT POINT — optional-content presence. typst has no "does this file // OPEN CONTRACT POINT — optional-content presence. typst has no "does this file
// exist" primitive (a missing `include` is a hard compile error). So the // exist" primitive (a missing `include` is a hard compile error). So the
// template CANNOT probe disk the way the old Rust driver did for lemma `proof`. // template CANNOT probe disk the way the old Rust driver did for lemma `proof`.
// It relies on the manifest declaring which optional content fields are present, // It relies on the manifest declaring which optional content fields are present,
// via a per-part `fields` array listing the content fields that exist on disk // via a per-element `fields` array listing the content fields that exist on disk
// (the engine knows this — it walks the part dir). Required fields are loaded // (the engine knows this — it walks the part dir). Required fields are loaded
// unconditionally; optional fields load only if listed in `fields`. If a part // unconditionally; optional fields load only if listed in `fields`. If an element
// omits `fields`, optional content is skipped (conservative). The exact shape of // omits `fields`, optional content is skipped (conservative). The exact shape of
// this declaration is for the manifest/Rust contract to pin. // this declaration is for the manifest/Rust contract to pin.
@@ -35,38 +36,51 @@
// Read the injected manifest (a path string relative to typst --root). // Read the injected manifest (a path string relative to typst --root).
#let manifest = toml(sys.inputs.manifest) #let manifest = toml(sys.inputs.manifest)
#let info = manifest.at("info", default: (:)) #let info = manifest.at("info", default: (:))
#let raw-parts = manifest.at("parts", default: ()) #let raw-outline = manifest.at("outline", default: ())
// Assemble each part: include its content fields (computed absolute paths, // Assemble each outline entry:
// resolved against --root) and read scalar fields from <path>/element.toml. // - an "element" entry: include its content fields (computed absolute paths,
// `part-fields` (from cph-render) is the single source of truth for kind->fields. // resolved against --root) and read scalar fields from <path>/element.toml.
#let parts = raw-parts.map(raw => { // `part-fields` (from cph-render) is the single source of truth for
let kind = raw.at("kind", default: none) // kind->fields.
let path = raw.at("path", default: none) // - a "section" entry (ADR-0036): pass its title/depth straight through — no
let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) // content to load, it is a heading.
// Which optional content fields are present on disk (manifest-declared). #let outline = raw-outline.map(raw => {
let present = raw.at("fields", default: ()) if raw.at("type", default: "element") == "section" {
let part = (kind: kind) (
entry-type: "section",
kind: raw.at("kind", default: none),
title: raw.at("title", default: ""),
depth: raw.at("depth", default: 1),
)
} else {
let kind = raw.at("kind", default: none)
let path = raw.at("path", default: none)
let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ()))
// Which optional content fields are present on disk (manifest-declared).
let present = raw.at("fields", default: ())
let entry = (entry-type: "element", kind: kind)
// Required content fields: <path>/<field>.typ (absolute, root-relative). // Required content fields: <path>/<field>.typ (absolute, root-relative).
for field in spec.content { for field in spec.content {
part.insert(field, include "/" + path + "/" + field + ".typ") entry.insert(field, include "/" + path + "/" + field + ".typ")
}
// Optional content fields: only when the manifest says the file exists.
for field in spec.optional-content {
if field in present {
part.insert(field, include "/" + path + "/" + field + ".typ")
} }
} // Optional content fields: only when the manifest says the file exists.
// Scalar fields come from <path>/element.toml. for field in spec.optional-content {
if spec.scalars.len() > 0 { if field in present {
let element = toml("/" + path + "/element.toml") entry.insert(field, include "/" + path + "/" + field + ".typ")
for field in spec.scalars { }
let v = element.at(field, default: none)
if v != none and v != "" { part.insert(field, v) }
} }
// Scalar fields come from <path>/element.toml.
if spec.scalars.len() > 0 {
let element = toml("/" + path + "/element.toml")
for field in spec.scalars {
let v = element.at(field, default: none)
if v != none and v != "" { entry.insert(field, v) }
}
}
entry
} }
part
}) })
// Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes // Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes
@@ -74,6 +88,6 @@
#render-lesson( #render-lesson(
info: info, info: info,
target: target, target: target,
parts: parts, outline: outline,
heading-numbering: default-heading-numbering, heading-numbering: default-heading-numbering,
) )
+38 -29
View File
@@ -1,4 +1,4 @@
// DEFAULT TEACHER TEMPLATE (framework default; ADR-0011). // DEFAULT TEACHER TEMPLATE (framework default; ADR-0011, outline shape ADR-0036).
// //
// Lives in an engineering file at `exports/teacher.typ`. Compiled AS MAIN with // Lives in an engineering file at `exports/teacher.typ`. Compiled AS MAIN with
// the manifest injected: // the manifest injected:
@@ -18,38 +18,47 @@
// Read the injected manifest (a path string relative to typst --root). // Read the injected manifest (a path string relative to typst --root).
#let manifest = toml(sys.inputs.manifest) #let manifest = toml(sys.inputs.manifest)
#let info = manifest.at("info", default: (:)) #let info = manifest.at("info", default: (:))
#let raw-parts = manifest.at("parts", default: ()) #let raw-outline = manifest.at("outline", default: ())
// Assemble each part: include its content fields (computed absolute paths, // Assemble each outline entry: an "element" entry includes its content fields
// resolved against --root) and read scalar fields from <path>/element.toml. // and reads scalars from element.toml; a "section" entry (ADR-0036) passes
// `part-fields` (from cph-render) is the single source of truth for kind->fields. // title/depth straight through as a heading, no content to load.
#let parts = raw-parts.map(raw => { #let outline = raw-outline.map(raw => {
let kind = raw.at("kind", default: none) if raw.at("type", default: "element") == "section" {
let path = raw.at("path", default: none) (
let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) entry-type: "section",
// Which optional content fields are present on disk (manifest-declared). kind: raw.at("kind", default: none),
let present = raw.at("fields", default: ()) title: raw.at("title", default: ""),
let part = (kind: kind) depth: raw.at("depth", default: 1),
)
} else {
let kind = raw.at("kind", default: none)
let path = raw.at("path", default: none)
let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ()))
// Which optional content fields are present on disk (manifest-declared).
let present = raw.at("fields", default: ())
let entry = (entry-type: "element", kind: kind)
// Required content fields: <path>/<field>.typ (absolute, root-relative). // Required content fields: <path>/<field>.typ (absolute, root-relative).
for field in spec.content { for field in spec.content {
part.insert(field, include "/" + path + "/" + field + ".typ") entry.insert(field, include "/" + path + "/" + field + ".typ")
}
// Optional content fields: only when the manifest says the file exists.
for field in spec.optional-content {
if field in present {
part.insert(field, include "/" + path + "/" + field + ".typ")
} }
} // Optional content fields: only when the manifest says the file exists.
// Scalar fields come from <path>/element.toml. for field in spec.optional-content {
if spec.scalars.len() > 0 { if field in present {
let element = toml("/" + path + "/element.toml") entry.insert(field, include "/" + path + "/" + field + ".typ")
for field in spec.scalars { }
let v = element.at(field, default: none)
if v != none and v != "" { part.insert(field, v) }
} }
// Scalar fields come from <path>/element.toml.
if spec.scalars.len() > 0 {
let element = toml("/" + path + "/element.toml")
for field in spec.scalars {
let v = element.at(field, default: none)
if v != none and v != "" { entry.insert(field, v) }
}
}
entry
} }
part
}) })
// Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes // Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes
@@ -57,6 +66,6 @@
#render-lesson( #render-lesson(
info: info, info: info,
target: target, target: target,
parts: parts, outline: outline,
heading-numbering: default-heading-numbering, heading-numbering: default-heading-numbering,
) )
+5 -9
View File
@@ -6,19 +6,15 @@ name = "迷你课时"
title = "迷你示例课时" title = "迷你示例课时"
author = "测试作者" author = "测试作者"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/开场对照导言" path = "segments/开场对照导言"
[[parts]] [[children]]
kind = "lemma" kind = "section"
path = "lemmas/量纲分析估计" path = "引理组"
[[parts]] [[children]]
kind = "lemma"
path = "lemmas/无证明引理"
[[parts]]
kind = "example" kind = "example"
path = "examples/自由落体" path = "examples/自由落体"
@@ -0,0 +1,10 @@
[group]
title = "引理组"
[[children]]
kind = "lemma"
path = "lemmas/量纲分析估计"
[[children]]
kind = "lemma"
path = "lemmas/无证明引理"
@@ -34,8 +34,36 @@ provider runtime cursor needed to continue a conversation. For Claude Code SDK,
that cursor is the `result.session_id`; store it in `AgentSession.metadata` as that cursor is the `result.session_id`; store it in `AgentSession.metadata` as
`claudeSessionId` and pass it back to the next `query()` call as `claudeSessionId` and pass it back to the next `query()` call as
`options.resume`. Role is part of the session binding because role prompts and `options.resume`. Role is part of the session binding because role prompts and
tool surfaces can differ even when the underlying model is the same; `/draft` tool surfaces can differ even when the underlying model is the same. A Feishu
and `/review` must not resume the same Claude runtime cursor by accident. project group's active binding selects one Organization role for ordinary
messages. Switching that selection routes future messages to the selected
role's own session; it never mutates or merges provider cursors across roles.
Work freezes the selected role when accepted so queued requests cannot drift
after a later switch.
Role definitions are Organization-scoped runtime data. A role bundle selects
its default model, system prompt, tool allowlist and installed Agent skill
versions. PostgreSQL is authoritative for role composition and skill metadata;
skill bytes live in a content-addressed persistent store selected only by the
recorded SHA-256 digest. Updating a role or binding skills takes effect without
a Hub release or process restart. A change to the role's execution surface
(model, prompt, tools, selected skill content) archives its active sessions so
the next run cannot resume a provider context created under stale instructions;
label and ordering-only changes preserve conversational continuity.
Exactly one active role per Organization is the default used when a project
group is first bound. The default is configuration data, not a hard-coded role
name. Roles are selected through the Hub project console; role ids are not
public slash commands. A project participant may change the group's shared
selection only when both `agent.trigger` for the project and `role.trigger` for
the target role authorize that actor. The selection affects every participant's
future messages, while already accepted work keeps its frozen role.
Hub slash commands are a closed control-plane protocol. Unknown commands fail
explicitly and are never downgraded to Agent text. Claude SDK session commands
are invoked only through typed Hub actions. In particular, compaction resumes
the selected role session and sends the exact `/compact` prompt without
prepending Feishu context.
Environment variables: Environment variables:
``` ```
@@ -93,22 +93,26 @@ The boundary is enforced by the Claude Code SDK's built-in sandbox
its Bash subprocesses run sandboxed; if the sandbox can't start, `query()` its Bash subprocesses run sandboxed; if the sandbox can't start, `query()`
emits an error and exits rather than running unsandboxed. emits an error and exits rather than running unsandboxed.
- `sandbox.allowUnsandboxedCommands: false` — a tool cannot opt out with the - `sandbox.allowUnsandboxedCommands: false` — a tool cannot opt out with the
SDK's `dangerouslyDisableSandbox` input. SDK's `dangerouslyDisableSandbox` input. Claude Code 2.1.202 does not enforce
that option reliably, so a host-side `PreToolUse` hook also denies every
Bash request whose input explicitly sets `dangerouslyDisableSandbox: true`
before a process can start.
- `sandbox.filesystem.denyRead: ["/"]` with `allowRead` for the canonical - `sandbox.filesystem.denyRead: ["/"]` with `allowRead` for the canonical
current workspace and a small named system-runtime set — normal reads stay current workspace and a small named system-runtime set — normal reads stay
in the run's workspace while `/bin`, shared libraries, CA certificates, in the run's workspace while `/bin`, shared libraries, CA certificates,
fonts and the configured `cph` executable remain available as the external fonts and the configured `cph` executable remain available as the external
tool exception described above. tool exception described above.
- `sandbox.filesystem.allowWrite: [workspaceDir]` confines every write to the - `sandbox.filesystem.allowWrite: [workspaceDir]` confines persistent host
canonical ADR-0007 workspace. Config/cache/home stay beneath effects to the canonical ADR-0007 workspace. Config/cache/home stay beneath
`.cph/agent-runtime/`. `TMPDIR`, `TMP`, and `TEMP` use the absolute `.cph/agent-runtime/`; `TMPDIR`, `TMP`, `TEMP`, and `CLAUDE_CODE_TMPDIR` all
workspace-local `.cph/t/` path so tools remain anchored after `cd`; point at the workspace-local `.cph/t`. The workspace allocator uses stable
`CLAUDE_CODE_TMPDIR` uses the short relative `.cph/t` prefix, resolved from compact Organization/Project path segments, deployment requires a short
the canonical workspace cwd, because the SDK's socat bridge otherwise falls workspace root, and the canonical temp prefix fails fast above 56 bytes so
back to a host temp directory when its Unix-socket prefix is too long. the SDK can append randomized `socat` bridge socket names without exceeding
Root is denied for writes and only the canonical workspace is re-opened, so Linux `sockaddr_un.sun_path`. Bubblewrap shadows non-allowlisted host trees
`/tmp`, `/var/tmp`, sibling projects and every other host path are rejected. with disposable tmpfs mounts: a shell write there may succeed inside that
There is no writable scratch exception outside the project directory. private namespace, but it cannot mutate the corresponding host path. The
Linux proof checks host state after the sandbox exits.
- The SDK subprocess environment replaces rather than spreads `process.env`. - The SDK subprocess environment replaces rather than spreads `process.env`.
Only provider protocol variables and non-secret runtime variables cross the Only provider protocol variables and non-secret runtime variables cross the
boundary; database, Feishu and Hub session credentials never enter it. boundary; database, Feishu and Hub session credentials never enter it.
@@ -118,6 +122,24 @@ The boundary is enforced by the Claude Code SDK's built-in sandbox
- `settingSources: []` and strict MCP configuration prevent an untrusted - `settingSources: []` and strict MCP configuration prevent an untrusted
workspace or service-user config from widening tools, hooks, MCP servers, or workspace or service-user config from widening tools, hooks, MCP servers, or
sandbox paths. sandbox paths.
- Agent skills are Organization-scoped runtime configuration, not Hub release
assets. Skill content is imported into a content-addressed persistent store
through a shared ingestion pipeline (`importSkillDirectory` for host-console
CLI, `importSkillFromFiles` for org-admin web surface) that enforces the same
safety checks: `SKILL.md` manifest required, 512-file / 16-byte limits,
symlink rejection, SHA-256 content addressing. The web surface
(`OrganizationAgentConfiguration.installSkillFromFiles`) writes to the same
store as the host-console CLI (`installSkill`); both flow through
`commitSkillContent` for deduplication and atomic rename into
`versions/<digest>/`. Org-admin authentication gates the web surface; the
content-addressed store remains platform-controlled. A role selects enabled
Organization skills alongside its model, system prompt and tool allowlist.
Each run copies only those selected immutable versions into a run-scoped
plugin outside the project workspace; the sandbox exposes that snapshot
read-only and deletes it after the run. SDK-bundled skills and filesystem
setting sources remain disabled, so project `.claude` content cannot register
skills or widen tools. Requested skill versions are recorded on
`run.created`; SDK initialization remains authoritative loading evidence.
- Network: open (see Open Questions). - Network: open (see Open Questions).
`bypassPermissions` is kept (headless server — no interactive prompts); the `bypassPermissions` is kept (headless server — no interactive prompts); the
+146
View File
@@ -0,0 +1,146 @@
# ADR 0026: Usage Fact Ledger
## Status
Accepted.
## Context
ADR-0022 pins the cost-attribution contract for the platform: token,
provider-reported cost, run count, and duration are attributed by Organization,
Project, Run, model, and Provider Connection; "missing provider cost remains
unknown rather than zero." ADR-0021 scopes usage accounting as operational
reporting, not payment collection (commercial billing stays deferred).
The implementation today stores this as **one scalar per `AgentRun`**:
`inputTokens`, `outputTokens`, `costUsd?`, `costSource?`, written once from the
Claude Agent SDK's `result.total_cost_usd` when the run finishes. This is
adequate for a single provider-reported model loop, but it cannot represent:
- **External capability consumption** inside a run. PDF→Markdown bundle
conversion, audio/video transcription, OCR and similar media transforms are
not the main agent loop. They run as side effects of a run, may use a
different provider/model, may bill in non-token units (pages, seconds,
invocations), and may report cost through a different channel than the
OpenRouter gateway. Today there is no row to write that cost to — it would
either disappear or silently corrupt the run's single scalar.
- **Multiple model calls within one run** (e.g. a sub-model invoked by a
tool, a gateway-side reroute). The scalar collapses them into one number.
- **Pricebook derivation** after the fact. With only a final USD figure and no
`(provider, model, occurredAt, tokens)` fact, an operator cannot re-derive
cost from a price table when the provider did not report it.
Treating each external call as a nested `AgentRun` was considered and
rejected: `AgentRun` carries lock ownership (ADR-0002), session/provider/role
binding (ADR-0017), admission/capacity semantics (ADR-0022), and the
user-visible task boundary. External calls hold none of those. Making them
`AgentRun`s would pollute run counts, admission, lock semantics, and session
continuity, and would still not solve non-token metering.
## Decision
Introduce **`UsageFact`** as the single source of truth for billable
consumption inside an `AgentRun`. An `AgentRun` owns zero or more
append-only `UsageFact` rows; each row records one billable consumption event:
- `kind``model_completion` (the main agent loop) | `external_capability`
| `tool_proxy`. The kind set is `OPEN`; new kinds must be surfaced, not
silently folded into an existing one.
- `provider` — e.g. `openrouter`, `mineru`, `openai_whisper`.
- `model?`, `inputTokens?`, `outputTokens?` — token metering, optional
because non-token capabilities have none.
- `quantity?` + `unit?` — non-token metering (pages, audio_seconds,
invocations), coexisting with tokens rather than replacing them.
- `costUsd?` + `costSource``provider_reported` | `pricebook_derived` |
`unknown`. `costUsd = null` means **unknown, not zero** (ADR-0022). When the
provider reported a cost, `costSource = provider_reported` and that value
wins. When only tokens are known, a later pricebook pass may derive
`costUsd` with `costSource = pricebook_derived`. When neither is possible,
`costSource = unknown` and `costUsd` stays null.
- `occurredAt` — when the consumption happened; the pricebook derivation
depends on this, not on `AgentRun.finishedAt`, because an external
capability may complete before the run finishes.
- `capabilityId?` — for `external_capability` facts, the registered capability
id (e.g. `pdf_to_md_bundle`, `audio_video_to_text`).
- `correlationId?` — external request id for reconciliation / idempotency;
not part of the aggregation key.
### Invariants
1. **Append-only.** A `UsageFact` row is never updated or deleted. A cost
correction is a new row; the old row stays. `onDelete: Cascade` exists only
so a hard run delete (itself not a normal path) cleans up its facts.
2. **Belongs to exactly one Run; never holds a lock.** A fact is a side-effect
ledger of one run, not a sub-run. External capability calls obey the same
boundary: their identity is `capabilityId + correlationId`, not `RunId`.
3. **Missing cost ≠ zero.** Aggregation MUST NOT sum `null` `costUsd` as 0.
A run whose facts all have `costUsd = null` is "cost unknown" — reported as
`runsWithoutCost`, exactly as the pre-migration `costUsd = null` runs are
today. A run with at least one `costUsd`-bearing fact contributes its sum.
### Rollup cache
`AgentRun.costUsd / inputTokens / outputTokens / costSource` columns are kept
as a **derived rollup cache**, not dropped:
- Existing non-`/usage` readers (slash `/usage`, session detail, integration
tests asserting `run.costUsd`) continue to work without code changes for
historical runs.
- On run finish, the writer writes the `UsageFact` row first and then mirrors
it onto `AgentRun` as two separate statements, not one transaction. The
fact is the truth, so it is written first; the cache is derived, so it is
written second. A crash between the two leaves the cache stale, but the
usage service re-reads `UsageFact` directly, so staleness is recoverable
(and the reverse order would lose the truth, which is not). Keeping the
fact insert and the run update in separate statements also avoids holding
the `AgentRun` row lock across the FK ShareLock taken by the insert, which
deadlocks against concurrent workspace teardown under the cascade path
`Organization → Project → AgentRun → UsageFact`.
- The org/project usage service and the slash `/usage` command read from
`UsageFact` directly — they are the canonical aggregation path.
### Migration
A new `UsageFact` table is added. For every existing `AgentRun` with a
non-null `costUsd` or non-null `inputTokens`/`outputTokens`, the migration
inserts **one synthetic `model_completion` fact** carrying the run's
provider, model, tokens, cost, and `costSource`. Its `correlationId` is the
run id, so the row is identifiable as a backfill artefact. This keeps
historical reporting correct under the new aggregation path. Pre-migration
runs with no recorded cost remain `runsWithoutCost` by design, matching
ADR-0022's "missing cost ≠ zero" rule and the existing migration
`20260709143000_agent_run_cost_tracking`'s "no backfill" stance for the
truly unrecorded.
## Consequences
- The billing model now supports external capabilities (PDF→MD, ASR, OCR, …)
without per-capability schema changes: a new capability is one new
`capabilityId` value and one or more `external_capability` facts.
- `AgentRun.costUsd` is no longer the truth; it is a convenience cache.
Readers that need the truth (cost breakdown, per-capability attribution)
must read `UsageFact`. The cache MUST be kept consistent on the write path.
- The capability registry, pricebook, and any commercial settlement remain
`OPEN` and out of pilot scope (ADR-0021). This ADR only pins the ledger
shape and invariants.
- `usage.ts` and `/usage` now perform a join against `UsageFact` rather than a
single-table scan of `AgentRun`; the index on `(runId, occurredAt)` and
`(provider, model, occurredAt)` keeps the existing org/project/report
queries bounded.
- Cost corrections (e.g. a provider rebills a run) produce a new fact row; the
rollup cache must be recomputed. The initial release writes facts once at
run finish and does not support post-hoc correction flows — that remains
`OPEN`.
## Deferred
- **Capability registry** as a first-class spec entity (`Spec.System.Capability`)
with org-scoped enable/disable, input/output contracts, metering schemas,
and org-exclusive credentials (ADR-0024 alignment). This ADR only reserves
the `capabilityId` field and the `external_capability` fact kind.
- **Pricebook** — a versioned price table keyed by `(provider, model, unit)`
with time validity. Required to actually produce `pricebook_derived` costs;
until then, facts without provider-reported cost stay `unknown`.
- **Post-hoc cost correction flow** — append-only today; correction UI and
rollup recompute are `OPEN`.
- **Commercial billing, invoicing, settlement** — still deferred per ADR-0021.
@@ -0,0 +1,152 @@
# ADR 0027: External Capability Registry
## Status
Accepted.
## Context
ADR-0026 introduced the `UsageFact` ledger with a `kind = external_capability`
fact and a `capabilityId` field, but deferred the capability registry itself.
Two concrete needs now force the issue:
- **PDF→Markdown bundle** conversion (and, imminently, audio/video→text)
must run as a side effect of an `AgentRun`, bill in non-token units
(pages, seconds), use a different provider than the model loop, and report
cost through a different channel. It is not a sub-run (ADR-0026 rejected
that) and not a model-provider call (it does not speak the Anthropic/
OpenRouter protocol).
- The Agent already has a Bash tool. Without a first-class capability seam,
the path of least resistance is for the agent to shell out to ad-hoc
scripts that embed API keys, write to arbitrary paths, and report nothing
to the ledger. That is exactly the unattributed, uncontained external
consumption ADR-0022/0026 exist to prevent.
The model-provider connection (`OrganizationProviderConnection`, ADR-0024)
is the wrong seam for these services:
- Its payload schema (`baseUrl` + `authToken` + `anthropicApiKey`) and
readiness probe (`/v1/models?supported_parameters=tools`) are specific to
OpenRouter/Anthropic. MinerU, Whisper, and future OCR/ASR services have
different auth shapes (an API token, optionally a project id) and no
`/v1/models` endpoint.
- Its uniqueness key is `(organizationId, providerId)` where `providerId`
is an OpenRouter-style model-routing id. A capability provider id
(`mineru`) names a *service*, not a model.
- Coupling capability credentials into the model-provider table would force
every capability's auth shape through `ProviderSecretPayloadV1` and every
readiness probe through `probeOpenRouterCredential`.
The Feishu Application Connection (`OrganizationFeishuApplicationConnection`)
is the right structural precedent: it reuses the ADR-0024 envelope (KEK →
DEK → AES-256-GCM, AAD-bound to purpose/org/connection/version) but has its
own connection table, its own payload schema, its own readiness probe, and
its own per-org uniqueness. Capability connections follow the same pattern.
## Decision
### External Capability
An **External Capability** is a platform-registered, org-enabled document or
media transform service invoked as a side effect of an `AgentRun`. It is
identified by a stable `capabilityId` (e.g. `pdf_to_md_bundle`,
`audio_video_to_text`). A capability:
- Has an **input kind** (PDF, image, audio, video, …) and an **output
contract** (markdown bundle with extracted images, transcript text, …).
- Bills in **non-token units** (pages, audio-seconds) recorded on a
`UsageFact` with `kind = external_capability`, or in tokens when the
backing service reports them.
- Writes its output **into the invoking run's workspace** (ADR-0018
`AgentSurface` — no escapes).
- Is invoked through a **capability adapter** in Hub, never by the Agent
shelling out with embedded credentials.
### Capability Connection
Credentials for a capability live in an **`OrganizationCapabilityConnection`**,
structurally identical to the Feishu Application Connection:
- Belongs to exactly one Organization.
- Unique by `(organizationId, capabilityId)`.
- `DRAFT` / `ACTIVE` / `DISABLED`; resolution accepts only `ACTIVE` with a
valid active secret version.
- Secret material is an immutable, AAD-bound, KEK-wrapped envelope version
(`CapabilityCredentialVersion`), reusing the ADR-0024 encryption
machinery with `purpose = "capability"`.
- Its payload schema is capability-specific (`CapabilitySecretPayloadV1`:
`baseUrl`, `apiToken`, optional `projectId`). New capability types extend
the payload, not the connection table.
- A capability-specific **readiness probe** validates the credential before
activation (e.g. MinerU: a trivial authenticated GET). The probe is
injectable, matching the Feishu/provider pattern, so tests never hit the
network.
### Capability Adapter
The adapter is the seam between the Agent and the external service. It:
- Resolves the org's active capability connection (fail-closed, no
process-global fallback — ADR-0024).
- Accepts a workspace-relative input path and an output directory.
- Calls the backing service (MinerU, Whisper, …) via an injectable
`Client` interface so the real HTTP client is swappable and mockable.
- Writes the produced markdown + image assets into the run's workspace.
- Writes one `UsageFact` (or more, if the service reports per-stage
consumption) with `kind = external_capability`, `capabilityId`,
`provider` (the service id), `quantity + unit` (pages / seconds), and
`costUsd + costSource = provider_reported` when the service reports cost.
### Registry
The platform maintains a **registry** of known capabilities: their id,
input kind, output contract, metering unit, and adapter. This is
code-level registration (like `ToolRegistry`), not a database table — a
capability is available to an Organization only when (a) the platform
knows the adapter and (b) the Organization has an `ACTIVE` connection for
it. Both gates are required.
### What is NOT in this ADR
- The capability invocation is **not** a first-class persisted record
(`CapabilityInvocation` table) in this ADR. The `UsageFact` row with
`capabilityId` + `correlationId` is the durable trace. If we later need
a richer invocation log (retries, partial output, multi-stage status),
that is a follow-up; for now the fact is enough.
- **Pricebook** remains deferred (ADR-0026). Capability facts use
`provider_reported` when the service returns cost; otherwise `unknown`.
- **Org-scoped enable/disable policy** beyond connection status is
deferred. An org with an `ACTIVE` connection has the capability; one
without does not. A finer "enabled but no credential" toggle is not
needed yet.
- **Agent-facing tool exposure** (how the Agent discovers and calls the
capability — MCP tool, Bash wrapper, or built-in) is an implementation
detail of the adapter wiring, not a contract concern. The contract pins
that the Agent never receives the capability credential.
## Consequences
- Adding a new external capability (e.g. `image_ocr`) is: register an
adapter, add a `capabilityId` constant, optionally extend the secret
payload — no schema change to `UsageFact` or `AgentRun`.
- The model-provider connection table stays focused on model routing;
capability credentials do not pollute its payload or readiness probe.
- Three connection types now share the ADR-0024 envelope: model-provider,
Feishu application, and capability. Each has its own table, payload
schema, and probe, but the same encryption, rotation, and resolver
boundary.
- The Agent's Bash tool remains available, but the intended path for
document/media transforms is the capability adapter. Whether to narrow
Bash for capability-shaped tasks is an operational policy decision,
not a contract one.
- Tests prove: workspace containment of capability output, fail-closed
credential resolution, `UsageFact` attribution with non-token metering,
and that the Agent process never receives the capability credential.
## Deferred
- `CapabilityInvocation` as a first-class durable record (status, retries,
partial output) — currently the `UsageFact` row is the only trace.
- Pricebook derivation for capability costs (ADR-0026 deferred).
- Org-scoped capability enable/disable policy finer than connection status.
- Agent-facing tool discovery (MCP vs built-in) for capabilities.
+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.
@@ -0,0 +1,153 @@
# ADR 0028: Member Group Management And Resolution
## Status
Accepted.
## Context
ADR-0020 fixed `Organization` as the tenant root and ADR-0019 pinned the
principal-set permission model. The file library (《文件库-接口契约.md》) computes
effective permission over two principal kinds — `USER` and `GROUP` — and consumes
the group side through a single read-only port, `GroupResolver`
(`resolveMemberGroupIds(userId) → groupIds[]`, contract C2/G2).
The contract's v0.1 proposal framed the Group system as a *separate HTTP service*
owned by another team, consumed read-only. In practice the schema now carries the
group tables directly in the hub database (`MemberGroup`, `MemberGroupMembership`,
`MemberGroupClosure` — a global, unlimited-depth, closure-backed hierarchy), and
the product requirement is to build **group management in the backend admin**, not
to integrate a foreign service. Until this ADR, nothing read or wrote those tables:
the live `GroupResolver` was a transitional implementation reading flat hub `Team`
membership, and the admin "Group 管理" panel actually managed `Team`.
This ADR settles the semantics needed to make the `MemberGroup` tables the real,
in-hub group system.
## Decision
### Group system is in-hub, not a foreign service
`MemberGroup` is the platform's global member-group principal. It lives in the hub
database and is managed through the `/database` backend. The contract's "separate
service" framing was an unfrozen v0.1 proposal; the implementation aligns to the
tables that were actually built. The `GroupResolver` port stays — an external
`HUB_GROUP_SERVICE_URL` HTTP implementation remains a supported override — but the
default implementation reads the in-hub `MemberGroup` closure.
### Authority: website administrator only
Group create/delete and member add/remove are restricted to the **website
administrator**, defined (consistently with the rest of the file library, D19/C4
adaptation) as an `OWNER`/`ADMIN` of the silo Organization (`isWebsiteAdmin` in
`filelib/guards.ts`). ADR-0023's `PlatformIdentity` is the future "true" platform
control plane; the file library uniformly uses org OWNER/ADMIN today and this
feature stays consistent with that. Reading groups for the authorization selector
(`/groups/search`) is **not** admin-gated — picking a group to grant is a Manage
holder's ability, not an administrator's.
### Resolution semantics (the crux)
`resolveMemberGroupIds(user)` returns the user's **active direct groups the
active ancestors of those groups**, deduplicated (the closure's depth-0 self row
makes each direct group its own ancestor). This is the single query the permission
engine relies on; equivalently: a grant placed on group G applies to members of G
and of every descendant of G (requirement 3.2 — permission flows down the tree, so
resolution collects up the tree). It is computed **live, never cached** (contract
D4/G4): a membership change is visible on the very next protected request.
MemberGroup is global (no `organizationId`), so resolution is not org-scoped.
### Soft delete via `archivedAt`, cascading the subtree
Delete is soft: `MemberGroup.archivedAt` is a tag. Deleting a group
cascade-soft-deletes its **whole subtree** (walk `MemberGroupClosure` where
`ancestorId = G`, stamp `archivedAt` on each active descendant) — an application
operation, not a DB constraint. Closure and membership rows are **retained**;
resolution and listing filter by `archivedAt`, so an archived group and everything
under it stop contributing to permission at once.
### Closure maintenance
The closure is maintained on **create**: insert `(G, G, 0)`, then for a parent `P`
insert `(a.ancestorId, G, a.depth + 1)` for every `a` in
`closure where descendantId = P`. v1 does **not** support reparenting a group
(moving it under a new parent). The schema reserves reparent (closure rebuild plus
the cycle guard "reject a new parent inside the moved subtree"); it is a follow-on.
### Rename and description edits are in scope; reparent stays out
A group's `name` and `description` are mutable by the website administrator
(`PATCH /database/api/groups/:id`, audited as `group.update`). This is deliberately
separated from reparent: renaming touches **no** closure row and cannot create a
cycle, so it carries none of the invariant risk that keeps reparent out of v1. The
endpoint therefore **rejects** a `parentId` field outright rather than ignoring it,
so a future reparent cannot arrive silently through this route. Passing an empty
`description` clears it; omitting a field leaves it unchanged.
### Restore is deliberately asymmetric with delete
Archived groups stay visible to the administrator (`GET
/database/api/groups?includeArchived=1` returns them carrying `archivedAt`; the
console tags and greys them) and can be restored (`POST
/database/api/groups/:id/restore`, audited as `group.restore`).
Restore is **not** the mirror image of delete. Delete cascades down the whole
subtree; restore un-archives **the group plus every archived ancestor of it, and
nothing below it**:
- Restoring the ancestor chain is **mandatory**, not a convenience. An active group
whose parent is archived has no path in the tree, and the `depth` derivation
(closure row count) presumes "an active group's ancestors are active" — the
invariant that cascade-delete establishes. Restoring a node alone would break it.
- The subtree is deliberately **left archived**. A group's descendants may have been
archived for reasons of their own, and one click should not silently re-grant
permission across a whole historical branch. Descendants remain visible in their
archived state and are each restored explicitly.
Restore takes effect immediately, like every other membership change (D4/G4): the
group resumes contributing permission on the next resolution.
An archived group is **readable but not writable**. Its membership rows are never
revoked by archiving, so `listMembers` succeeds on an archived group — the console
must be able to show *who was in it* before deciding whether to restore it. Every
mutation, by contrast, still requires an active group (`requireActiveGroup` → 404):
rename, child creation, and member add/remove all reject. The group is inert for
permission purposes and frozen for editing, but not hidden and not forgotten.
### Member picker reads global users, admin-only
`GET /database/api/users/search` backs the "add member" picker: it matches `User`
by display name or Feishu open id and is gated to the website administrator, the
same authority that may add members. It widens no existing capability — adding a
member already accepts **any** global user (`resolveUser` does not require an org
membership), so the endpoint only replaces blind id entry with search. It is
deliberately **not** opened to the non-admin authorization-selector audience that
`/groups/search` serves: choosing a group to grant is a Manage-holder action,
whereas enumerating people is not. `excludeGroupId` filters out the target group's
active members so the picker cannot surface a candidate that must 409.
### Audit is written in-hub
The contract (C3 §6.3) originally deferred group actions to the foreign Group
service's own audit. With the group system in-hub, group mutations are audited
through the existing file-library sink (`filelib/audit.ts`, same-transaction
`AuditEntry`) under the silo Organization — `MemberGroup` has no `organizationId`,
so the audit row is attributed to the silo org. New actions: `group.create`,
`group.update`, `group.delete`, `group.restore`, `group.member_add`,
`group.member_remove`; new audit object type `group`.
## Consequences
- The default `GroupResolver` becomes the in-hub `MemberGroup` closure reader.
`createTeamGroupResolver` is retained but deprecated (no longer wired); existing
flat-Team group grants no longer resolve for the file library.
- Group grants take effect in real time through the existing `effectiveRole`
reducer (P6) with no change to the permission algebra — only the set of group ids
fed to it changes.
- v1 omits reparent; the closure invariants above must hold whenever reparent is
added later (rebuild descendants' ancestor rows, reject cycles).
- Group management is an admin-only surface; the authorization selector is not.
- Numeric limits (max depth, max members) and a hard-delete/restore path remain
follow-on operational decisions; they must not weaken the archived-filter,
admin-authority, or live-resolution invariants fixed here.
@@ -0,0 +1,132 @@
# ADR 0029: Web Surfaces Are Static SPAs; the Hub Serves JSON Only
## Status
Accepted.
## Context
The Hub exposes three browser surfaces: the org-admin console (`/admin`), the
teacher-facing file library (`/app`), and the database admin back office
(`/database`). They arrived at different times and diverged in how HTML reached
the browser.
`/admin` and `/app` were already separated: the backend serves a prebuilt static
`index.html` and never inspects the request; all data flows through JSON
endpoints. `/database` was not. Roughly 1770 lines across four modules
(`renderDashboard`/`renderLoginPage` in `routes/databaseRoutes.ts`,
`routes/adminPanels.ts`, `routes/libraryBrowser.ts`, `routes/libraryPage.ts`)
assembled HTML template strings server-side, reading the session cookie and
querying Prisma inside the page handler, with layout expressed as inline
`style="…"` attributes and behavior as `<script>` text.
A prior migration (`12628c9`) introduced a fourth frontend project,
`hub/database-admin/`, intended to replace those pages. It was never wired up:
the concrete route `/database/dashboard` is more specific than the SPA wildcard
`/database/*`, so the server-rendered handler always won and the SPA's dashboard
was unreachable. That project's file header claimed the SPA served the dashboard
and that `/database/config` existed; neither was true. The `npm run build` script
also never built it, so the `existsSync` guard in `database/static.ts` failed on
every deploy and the shell was permanently disabled.
Duplicated visual rules were the practical cost: card padding and type sizes were
restated in each render module, and only the CSS variables in `routes/uiTheme.ts`
were genuinely shared.
## Decision
**No Hub HTTP handler renders HTML.** Every browser surface is a prebuilt static
SPA. Page handlers send a byte-identical `index.html` that does not depend on the
request; all per-user and per-request data is fetched by the client from JSON
endpoints under `/api/*` or `/database/api/*`.
**`/app` and `/database` are one frontend project, `hub/filelib-web`, built once
and mounted at two prefixes.** They share the file library browser, the session
layer, the toast host, and the design tokens; splitting them would duplicate all
of it. `hub/database-admin` is deleted — superseded before it ever served a
request.
Two configuration constraints follow from co-hosting two SvelteKit SPAs on one
Fastify instance, and are load-bearing:
- `filelib-web` sets `appDir: '_filelib'`. The SvelteKit default `_app` collides
with the root `/_app/*` asset route that `admin-web` owns
(`src/admin/static.ts`); Fastify rejects duplicate routes at startup, so the
collision is a boot failure, not a silent misroute.
- `filelib-web` sets `paths.relative: false`. The same `index.html` is served at
different URL depths (`/app`, `/database/dashboard/users`), so relative asset
paths would resolve against the wrong base.
**Client-side navigation uses real URL routes, not hash fragments or hidden
sections.** The six back-office tabs are `/database/dashboard`,
`/database/dashboard/library`, `/users`, `/groups`, `/search`, `/settings`.
Refresh preserves position and links are shareable — the previous
`location.hash` + `display:none` scheme lost both.
Concrete routes must be registered before the SPA wildcards. This is an ordering
obligation on `database/plugin.ts`, not an incidental detail: the earlier
`/database/dashboard` shadowing bug is exactly what happens when a concrete page
route outranks the fallback.
## Consequences
- Authorization is enforced only by the JSON endpoints. A client-side guard (the
`isWebsiteAdmin` check in the dashboard layout) is a navigation convenience and
carries no security weight; every endpoint keeps its own `fail closed` guard.
- `/database/api/stats` is a new endpoint carrying what `loadDashboardStats` used
to compute inline. It requires silo org `OWNER`/`ADMIN` because it aggregates
org-wide counts and the audit stream rather than a per-node permission view.
- `/database/api/me` grew `displayName` and `avatarUrl`. Anything the old page
handler read from Prisma to render chrome has to become part of a JSON payload
or it is simply unavailable: the sidebar identity strip showed a raw `userId`
until these were added. When migrating a server-rendered surface, the data the
template closed over is part of the contract being ported, not an incidental
detail of the old implementation.
- Editing a page no longer requires a Hub restart in development; `vite dev`
serves the frontend and proxies data requests to the Hub. In production the
`index.html` is cached in memory at startup, so a frontend rebuild does require
a restart.
- Deploy scripts and the silo rate-limit exemption list name `filelib-web` and
`/_filelib/*`. Adding a fourth surface means picking another `appDir` and
extending that list.
- The design system is one file, `filelib-web/src/app.css`: an `@theme` block for
tokens plus an `@layer components` block for the shared component classes
(`.btn`, `.panel`, `.input`, `.select`, `.list`, `.tag`, `.quiet`, …).
`routes/uiTheme.ts` is deleted; both halves live there now.
The first cut of this migration kept only the tokens and restated button,
input, and panel styling inline in every component. That reproduced the
duplication the old code had — the admin panels visibly regressed — so the
component layer was ported too. Components carry layout utilities; they do not
restate component styling. The one admitted exception is a data-derived value
(tree indent computed from `depth`), which cannot be a static class.
The icon set (`lib/Icon.svelte`, 13 paths) is likewise shared rather than
restated. It came from `adminPanels.ts`; Group nodes deliberately use a
two-person silhouette, not a folder glyph, because `MemberGroup` and the file
library's `FOLDER`/`PROJECT` are unrelated hierarchies (ADR-0028, ADR-0021).
- **A migrated surface is only done when its endpoint coverage matches.** Two
panels were rebuilt from a superficially similar component that predated the
migration rather than from the server module they replaced, and the mismatch
was invisible in the rendered page:
- Group management called 5 of 8 endpoints. Rename (`PATCH`),
`?includeArchived=1`, `/restore`, and `/users/search` had no entry point, so
a soft-deleted group could not be restored through the UI at all even though
the backend fully supported it.
- The library browser dropped the `授权` tab entirely — `GET/PUT/DELETE
.../grants` and `PUT .../independent-permission` had no caller. Permission
editing is the point of the back office, and it was unreachable.
Diffing the route table against the frontend's `api()` call sites catches this;
reading the new page does not.
## Deferred
- `/admin` (admin-web) stays a separate project. It has its own design language
(`saas-*` classes, `surface-*`/`primary-*` scales) and a different audience;
merging it is not motivated by shared code.
- The `search` and `settings` tabs remain placeholders, as they were server-side.
- Serving `/admin` and `/database` from a single SPA, which would remove the
`appDir` collision constraint entirely.
@@ -0,0 +1,157 @@
# ADR 0030: The File Library VersionStore Is a Real Git Repository per Project
## Status
Accepted.
## Context
The file library (`hub/src/database/filelib/`, an independent subsystem that does
not reuse the Hub's own `Folder`/`Project` tree from ADR-0021) stores each project
as a versioned file tree behind the `VersionStore` port (contract C1). Until now
the only implementation was `createInMemoryVersionStore`: a `Map` of per-file
version chains, with `VersionId` as a per-repository monotonic counter
(`v1`, `v2`, …), a hand-written line differ, and an optional JSON snapshot of the
entire storage root written to `<storageRoot>/.version-store.json` so that a
process restart did not lose the demo data.
Two things about the surrounding design were already settled in code and are
confirmed here rather than changed:
- **A `FOLDER` node has no on-disk existence.** `FileLibNode.storageDir` is
`NULL` for folders. The tree is `parentId` plus the `pathIds` materialized path;
nothing in the filesystem mirrors it.
- **Projects are flat under one root, keyed by id.** `storageDir` is
`<storageRoot>/<nodeId>` where `nodeId` is a `randomUUID()`. Names never enter
the path, which is why `renameNode` touches no disk state and does not rewrite
descendant paths.
What was never true is the part the names implied. `HUB_FILELIB_STORAGE_ROOT` was
documented as "the project git repository root" and `fileService` was documented
as observing a "git first, then audit" ordering, but no code in the repository
ever invoked git. `versionStore.init(storageDir)` inserted a `Map` entry; the
directory was never created. Every project's entire content and history lived in
one process-global JSON file. The header comment and `README.md` both marked this
as a placeholder awaiting an npm package from the versioning team.
That package has not arrived, and the in-memory store's properties are not
acceptable for real teacher data: a corrupt or lost `.version-store.json` loses
every project at once, the whole storage root is rewritten on every commit, and
`VersionId` values are meaningless outside the process that minted them.
## Decision
**Each file library project is a real Git repository at
`<storageRoot>/<nodeId>`.** `VersionStore.init` creates the directory and runs
`git init` there. This is the production implementation;
`createInMemoryVersionStore` is retained for tests only.
**`VersionId` is a Git commit hash.** The full 40-hex object name, as printed by
`git rev-parse`. It is no longer a per-repository counter.
**File-level versioning (D16) maps onto commit history as follows.** A write
touches exactly one path and produces exactly one commit. The version of a file is
the hash of the most recent commit that modified that path — `git log -1 --
<path>`. Consequently:
- Two files in one project have independent versions, because a commit that
touches `a.md` does not appear in `git log -- b.md`. This preserves the D16
property that advancing one file does not invalidate another file's
`baseVersion`, even though commits are repository-global objects.
- `baseVersion` checking (S1/S2) compares the caller's id against the current
per-file version. `baseVersion: null` means create, and conflicts if the path
already exists at `HEAD`.
- Reading version `V` of a path means `git show V:<path>`, which is the content as
of that commit, not the content the commit introduced to some other file.
**Deletion is a commit, not a tombstone record.** `remove` runs `git rm` and
commits, so the path is absent from `HEAD` and `list` stops reporting it, while
`git show <olderVersion>:<path>` still resolves. The in-memory store expressed
this as a `deleted: true` chain entry; the observable API semantics are the same.
**Git is invoked as a subprocess, not through a library.** `node:child_process`
`execFile` with an argument array, no new npm dependency. Every invocation is
hardened, and the hardening is load-bearing rather than incidental:
- `-c core.hooksPath=` and `-c commit.gpgsign=false`, plus
`GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_SYSTEM=/dev/null`. A project
repository is *data*, uploaded by teachers. Without this, a committed
`.git/hooks/` entry or a developer's global `gitconfig` would execute or alter
server-side behavior.
- `GIT_LITERAL_PATHSPECS=1` and `--` before every path, so a filename is never
reinterpreted as an option or as pathspec magic (`:(glob)`).
- `GIT_TERMINAL_PROMPT=0`, so a repository never blocks a request waiting on
credentials.
- Author identity is passed per-commit via `GIT_AUTHOR_*`/`GIT_COMMITTER_*`
environment variables, never written into the repository's config. The git
author name is the acting user's `displayName` (falling back to `userId` when
absent), and the email is `<userId>@filelib.paradigm-edu.net`. The email
deliberately keys on `userId` rather than the display name, because nicknames
change and identity attribution must not drift with them. Characters that would
break git's ident line (`<`, `>`, newlines) are stripped from the name.
- `--git-dir=<projectDir>/.git` and `--work-tree=<projectDir>` are pinned on
every invocation, and `GIT_DIR`/`GIT_WORK_TREE`/`GIT_INDEX_FILE`/
`GIT_OBJECT_DIRECTORY` are removed from the child environment. Git otherwise
searches *upward* for a `.git`, and the storage root is frequently nested inside
another repository — the local development default `hub/.filelib-repos` sits
inside this very repo. Without pinning, operations on a project directory that
has no repository of its own silently retarget the enclosing repository.
Existence is therefore tested on the filesystem (`<projectDir>/.git`), not with
`git rev-parse --git-dir`, which merely echoes a pinned value back.
**Writes to one repository remain serialized in-process**, as under S4, because
concurrent git invocations contend on `index.lock`. This is a single-process
guarantee only; see Consequences.
## Consequences
- `.version-store.json` is not read or migrated by the new store. Existing
development data under `HUB_FILELIB_STORAGE_ROOT` does not appear in the git
store; those projects report `repo_not_found` until recreated. No production
data exists to migrate, since the in-memory store was never production-viable.
- `VersionId` changes shape in API responses (`GET .../files/*`, history, and the
409 `currentVersion` detail). Clients must keep treating it as an opaque
string; `filelib-web` already does.
- `VersionInfo.author` now comes back as the git author name, which is the acting
user's display name at commit time (or the `userId` when no display name is
known). Commits written without an author carry a fixed `filelib` identity
rather than `undefined`. Display names are point-in-time: renaming a user does
not rewrite existing commits, and the stable identifier stays in the email.
- `VersionStore.commit`/`remove` take a structured `CommitAuthor`
(`{ userId, displayName? }`) rather than a bare author string, so the port can
express both the stable key and the display label. Deletion carries the same
identity as any other commit.
- Serialization is per-process. Two Hub processes sharing a storage root can race
on the same repository and surface a git lock error rather than a clean
conflict. The alpha Silo deployment (ADR-0025) is one process per organization,
so this is not currently reachable; a multi-process deployment needs either a
database advisory lock keyed by project id or a single writer.
- `git` must be present on the host. Absence is a startup-visible failure of
project creation (`provision_failed`), not a silent degradation.
- Repository content is now attacker-influenced data on disk. The path validation
in `fileService.validateFilePath` (rejecting `..`, `.git`, absolute paths,
control characters) moves from hygiene to a security boundary, and
`versionStore` re-checks it rather than trusting callers.
## Alternatives considered
- **`isomorphic-git` or `simple-git`.** Both add a dependency to carry work that
three `execFile` calls do. `isomorphic-git` additionally reimplements the object
layer, so its bugs would be ours to diagnose.
- **One commit per repository state, with the repository head as the version.**
Simpler mapping, but it breaks D16: any write would invalidate every other
file's `baseVersion`, turning independent edits into false conflicts.
- **Keeping the counter as `VersionId` alongside git.** Requires a durable
counter-to-hash mapping outside git, which is the state the decision removes.
- **Bare repositories with a git index-only write path.** Avoids a working tree,
but every read and write becomes plumbing (`hash-object`, `update-index`,
`commit-tree`), for no benefit at this scale.
## Deferred
- Cross-process write serialization (advisory lock keyed by project id).
- Garbage collection and pack maintenance policy for long-lived repositories.
- Whether export builds (`exportService`) should read a git tree directly instead
of going through the `listFiles`/`readFile` port.
- Recovering `provisionStatus=FAILED` projects by re-running `init`; the status
machine records the failure but nothing retries it yet.
@@ -0,0 +1,43 @@
# ADR 0030: Project Grants Are Always Live; The Independent-Permission Toggle Is Removed
## Status
Accepted. Supersedes the file-library contract rule **D11 / P5** (《文件库-接口契约.md》,
since deleted; recoverable from git history) which introduced the per-project
"独立权限" (independent permission) switch.
## Context
D11 gave each PROJECT a toggle (`FileLibProjectSettings.independentPermissionsEnabled`,
default off). While off, project-level non-creator grants were **frozen** — present in
`FileLibGrant` but excluded from `effectiveRole`; ancestor-chain grants and the creator's
auto-grant were unaffected. The intent was to support two workflows: "project follows the
folder's ACL" (off) vs "project has its own ACL" (on).
In practice the toggle surprised operators twice: grants appeared to "not work" until
someone found and flipped a per-project switch buried in the 概览 tab, and the frozen state
was indistinguishable from missing grants in the UI. The product decision is that
project-level grants should simply always be live.
## Decision
- **Project-level grants always participate in `effectiveRole`.** The freeze branch in
`hub/src/database/filelib/permission.ts` is deleted; `EffectiveRoleInput` no longer
carries `independentPermissionsEnabled`.
- **The toggle surface is removed end-to-end**: `PUT /database/api/projects/:id/independent-permission`,
`grantService.setIndependentPermission`, the `independentPermission` field in the node
detail DTO, and the 概览 tab switch in `filelib-web`.
- **`FileLibProjectSettings` becomes vestigial.** The table stays (existing rows are
ignored, no data migration); new projects no longer get a default row. It may be dropped
in a future migration once nothing references it.
- Audit action vocabulary `independent_enable` / `independent_disable` is retained for
reading historical audit entries; no new entries are produced.
Behavior change for existing deployments: projects whose toggle was off now have their
project-level grants effective immediately — this is the intended effect of the decision.
## Consequences
- Permission semantics shrink to the single P6 rule: `effective = max(grants on self
ancestors for user resolved groups)`, no exceptions by node kind.
- One less state dimension in tests and in the admin UI.
@@ -0,0 +1,58 @@
# ADR 0031: File Library Recycle Bin And Recent-Visit Tracking
## Status
Accepted.
## Context
The teacher app (`/app`) gains a left navigation rail with three entries: 文件库 /
最近打开 / 回收站. Two of them need semantics that no prior decision covers:
- **回收站 (recycle bin)**: D15 defined soft delete (mark `deletedAt` on the node only;
a node is invisible when any ancestor is deleted) but never defined listing, restore,
or permanent deletion.
- **最近打开 (recent visits)**: nothing tracks opens.
## Decision
### Recycle bin
- **List**: shows nodes with `deletedAt != null` whose **ancestors are all active**
(the topmost deleted node per branch; descendants of a deleted node are represented
by it and not listed separately).
- **Visibility/auth**: a bin entry is visible to (a) the website administrator, or
(b) any actor holding an active MANAGE grant **on the deleted node itself**
(grants stay live through soft delete, so this is a plain grant query — no chain
walk, no inheritance; the bin is a management surface, not a browsing surface).
- **Restore** clears `deletedAt` on that node only (D15 symmetry: delete marks one
node, restore unmarks one node). The subtree becomes visible again immediately.
Same auth as the list entry. Audited (`folder_restore` / `project_restore`).
- **Permanent delete (彻底删除)** is **website-administrator only**: hard-deletes the
node **and its whole subtree** (descendants enumerated via the `pathIds` materialized
path, deleted deepest-first because the self-FK is `ON DELETE RESTRICT`), in one
transaction, with one audit entry (`node_purge`, detail carries removed count).
Grants/settings/export-jobs cascade. There is no recovery; the UI must confirm
explicitly.
### Recent visits
- **Model**: `FileLibRecentVisit(organizationId, userId, nodeId, filePath, openedAt)`,
unique on `(organizationId, userId, nodeId, filePath)` with `filePath` defaulting to
`""` (Postgres unique indexes treat NULLs as distinct). `filePath = ""` means the
visit is the node itself (drill into folder/project); non-empty means a file preview
inside that project.
- **Recording is client-driven**: the teacher app POSTs after a successful open
(folder drill, project open, file preview). The endpoint requires VIEW on the node
(D8: no VIEW → 404, leaking nothing). Upsert semantics: re-opening refreshes
`openedAt`. No audit entries — this is a per-user read model, not a权限-sensitive
mutation.
- **List**: the actor's own most recent 20, `openedAt` desc. Entries whose node is
deleted **or has any deleted ancestor** are filtered out (D8/D15 visibility holds
on every surface). Names are read live from `FileLibNode` (no denormalization).
## Consequences
- No change to existing permission algebra; both features are additive surfaces.
- The bin deliberately does not offer per-owner bins or inherited-MANAGE visibility —
if real usage demands it, that is a new decision.
@@ -0,0 +1,30 @@
# ADR 0032: Remove The Recent-Visit Module
## Status
Accepted. **Supersedes the "Recent visits" half of ADR-0031** (the recycle-bin half
is unaffected and remains in force).
## Context
ADR-0031 (same day) introduced 最近打开: a `FileLibRecentVisit` table, client-driven
visit recording, and a rail entry in the teacher app. After seeing it live, the product
call is that the module is not wanted — it adds a tracking surface, a table, and rail
noise without a compelling teacher workflow behind it.
## Decision
The recent-visit module is removed end-to-end:
- `FileLibRecentVisit` is dropped (hand-written migration
`20260731090000_drop_filelib_recent_visit`; the table was created the same day and
held no production data).
- `recentService` / `recentRoutes` (`/database/api/recent`) and the `RecentView`
component are deleted; the rail in `/app` keeps only 文件库 / 回收站.
- `GridLibraryView` visit recording and the `navTarget` navigation entry go with it.
- The `role` field added to breadcrumb entries for ADR-0031 is **kept** — it is a
cheap, additive field on an existing API and independent of the removed module.
If recent-visit tracking comes back as a requirement, it is a new decision (and
should then define why client-driven tracking is worth its surface) rather than a
revival of this one.
@@ -0,0 +1,26 @@
# ADR 0033: Restore De-Duplicates The Node Name On Sibling Conflict
## Status
Accepted.
## Context
ADR-0031 defined restore as "clear `deletedAt` on that node only". It did not cover
the case where a same-name sibling was created **after** the deletion: D14's partial
unique index (active siblings, case-insensitive) then rejects the restore with a 409
`conflict`, leaving the entry permanently stuck in the bin — unrecoverable for
non-admin users (who cannot purge) and cryptic for admins.
## Decision
Restore never fails on a name conflict. Before clearing `deletedAt`, the service
checks active siblings; if the node's name is taken, it restores as
`原名(已恢复)`, then `原名(已恢复 2)`, …, first free key wins (suffix is included
in the `NODE_NAME_MAX_LENGTH` budget by truncating the base). The rename is part of
the same transaction and is recorded in the restore audit entry as
`{ name, renamedFrom }`. The API returns the final name so the UI can tell the user.
Rationale: the bin's purpose is recovery; a restore that can deadlock on naming is a
trap, not a safeguard. Users who care about the name can rename afterwards (they have
MANAGE by definition of bin visibility).
+28
View File
@@ -0,0 +1,28 @@
# ADR 0034: Permanent Delete Follows MANAGE, Not Website Administrator
## Status
Accepted. **Supersedes one clause of ADR-0031**: "Permanent delete (彻底删除) is
website-administrator only".
## Context
ADR-0031 gated 彻底删除 to the website administrator as a high-risk-operation
precaution. The product call is that this is inconsistent with the rest of the
permission model: soft delete already requires only MANAGE on the node, and a
MANAGE holder who can delete a node into the bin should also be able to purge it —
the authority that grants deletion grants destruction. Admin-only purge strands
non-admin managers with bins they cannot empty.
## Decision
Permanent delete uses **the same visibility rule as the bin entry itself**: website
administrator, or an actor with an active MANAGE grant on the deleted node (direct
grant, USER or resolved GROUP). Anyone else gets 404 (D8). The double confirmation
in the UI and the `node.purge` audit entry are unchanged.
## Consequences
- Purge auth = restore auth = bin-entry visibility: one rule, three surfaces.
- The operation remains irreversible and audited; no new capability is granted to
anyone who could not already delete the node (soft) and see it in the bin.

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