forked from bai/curriculum-project-hub
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2c8fa8eaf | |||
| afaf5bee09 | |||
| 0fe6cabc68 | |||
| e60aa43731 | |||
| 09cadd0148 | |||
| ce767e9cab | |||
| a4d52e1850 | |||
| 313e890b6c | |||
| b4dd8f09e1 | |||
| a08c33205b | |||
| 3bca137b48 | |||
| 18aac1ff16 | |||
|
4c697904e6
|
|||
|
3c1eb846a9
|
|||
|
ebcb3a7589
|
|||
|
57f7773987
|
|||
|
4a9eb59aa9
|
@@ -11,5 +11,8 @@
|
||||
# regenerable, not for VCS. The embedded engine mounts cph-render directly.
|
||||
render/vendor/local-packages/
|
||||
|
||||
# Node (hub/ TS workspace and any future JS package)
|
||||
node_modules/
|
||||
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
|
||||
Generated
+16
-6
@@ -294,6 +294,15 @@ dependencies = [
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_complete"
|
||||
version = "4.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772"
|
||||
dependencies = [
|
||||
"clap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.1"
|
||||
@@ -386,7 +395,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cph-check"
|
||||
version = "0.0.1"
|
||||
version = "0.0.2"
|
||||
dependencies = [
|
||||
"cph-diag",
|
||||
"cph-model",
|
||||
@@ -396,9 +405,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cph-cli"
|
||||
version = "0.0.1"
|
||||
version = "0.0.2"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
"cph-check",
|
||||
"cph-diag",
|
||||
"cph-typst",
|
||||
@@ -406,14 +416,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cph-diag"
|
||||
version = "0.0.1"
|
||||
version = "0.0.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cph-model"
|
||||
version = "0.0.1"
|
||||
version = "0.0.2"
|
||||
dependencies = [
|
||||
"cph-diag",
|
||||
"serde",
|
||||
@@ -422,7 +432,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cph-schema"
|
||||
version = "0.0.1"
|
||||
version = "0.0.2"
|
||||
dependencies = [
|
||||
"cph-diag",
|
||||
"cph-model",
|
||||
@@ -432,7 +442,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cph-typst"
|
||||
version = "0.0.1"
|
||||
version = "0.0.2"
|
||||
dependencies = [
|
||||
"cph-diag",
|
||||
"cph-model",
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ members = [
|
||||
# Shared metadata for all workspace crates. Individual crates inherit these via
|
||||
# `version.workspace = true` / `edition.workspace = true`.
|
||||
[workspace.package]
|
||||
version = "0.0.1"
|
||||
version = "0.0.2"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 安装 `cph` 命令行
|
||||
|
||||
当前版本 **0.0.1**。从源码安装(本仓库根目录):
|
||||
当前版本 **0.0.2**。从源码安装(本仓库根目录):
|
||||
|
||||
```sh
|
||||
cargo install --path crates/cph-cli --locked
|
||||
@@ -15,11 +15,19 @@ cargo install --path crates/cph-cli --locked
|
||||
`render` 包已嵌入二进制(build.rs 编译期拷入 + `include_dir!`),所以装好后**无需任何环境变量、不依赖源码树**即可用。渲染包按版本解压到 per-user cache(`~/.cache/cph/render-<version>/` 或 macOS `~/Library/Caches/cph/...`);开发时可设 `CPH_RENDER_DIR` 指向 live `render/` 目录覆盖。
|
||||
|
||||
```sh
|
||||
cph --version # cph 0.0.1
|
||||
cph check <工程目录> # 校验合法性(6 类诊断)
|
||||
cph --version # cph 0.0.2
|
||||
cph check <工程目录> # 校验合法性(7 类诊断)
|
||||
cph build <工程目录> --target student -o build/student.pdf # 渲讲义 PDF
|
||||
```
|
||||
|
||||
**版本契约(ADR-0016):** 教研工程文件根放一个 `.cph-version` 文件,内容为它面向的 cph 版本(如 `0.0.2`)。`cph` 加载时比对自身版本,不相容则报 `E-CPH-VERSION` error 并拒绝(当前判定为版本完全相等;后续可放宽为 semver 区间,只改一处谓词)。`examples/` 与本仓 fixture 已带该文件作为迁移起点;缺文件的工程暂时跳过此检查(OPEN)。
|
||||
|
||||
Shell 补全(可选):
|
||||
|
||||
```sh
|
||||
cph completions zsh > ~/.zfunc/_cph # 或 bash/fish/powershell/elvish
|
||||
```
|
||||
|
||||
## 仓库布局
|
||||
|
||||
```
|
||||
|
||||
@@ -13,3 +13,4 @@ cph-check = { path = "../cph-check" }
|
||||
cph-diag = { workspace = true }
|
||||
cph-typst = { path = "../cph-typst" }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
clap_complete = "4"
|
||||
|
||||
@@ -46,6 +46,22 @@ enum Command {
|
||||
#[arg(short = 'o', long, value_name = "OUT")]
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
/// 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
|
||||
/// completion file, e.g. `cph completions zsh > ~/.zfunc/_cph`.
|
||||
Completions {
|
||||
/// Which shell to generate completions for.
|
||||
shell: Shell,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
|
||||
enum Shell {
|
||||
Bash,
|
||||
Zsh,
|
||||
Fish,
|
||||
PowerShell,
|
||||
Elvish,
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
@@ -59,9 +75,29 @@ fn main() -> ExitCode {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit a shell-completion script for `shell` to stdout. The script is built
|
||||
/// from the same `Cli` clap definition above, so it tracks subcommands/flags as
|
||||
/// they evolve.
|
||||
fn run_completions(shell: Shell) -> ExitCode {
|
||||
use clap::CommandFactory;
|
||||
use clap_complete::Shell as CompShell;
|
||||
|
||||
let sh = match shell {
|
||||
Shell::Bash => CompShell::Bash,
|
||||
Shell::Zsh => CompShell::Zsh,
|
||||
Shell::Fish => CompShell::Fish,
|
||||
Shell::PowerShell => CompShell::PowerShell,
|
||||
Shell::Elvish => CompShell::Elvish,
|
||||
};
|
||||
let mut cmd = Cli::command();
|
||||
clap_complete::generate(sh, &mut cmd, "cph", &mut std::io::stdout());
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
/// Print every diagnostic in `report` to stderr, followed by a summary line.
|
||||
fn print_diagnostics(report: &CheckReport) {
|
||||
for d in &report.diagnostics {
|
||||
|
||||
@@ -88,6 +88,9 @@ pub enum DiagCode {
|
||||
TypstCompile,
|
||||
/// An element is ignored under a render target (ADR-0005: warning).
|
||||
RenderIgnored,
|
||||
/// The engineering file's `.cph-version` is not compatible with the running
|
||||
/// CLI's version (ADR-0016). Decided at load time; `error` severity.
|
||||
CphVersionMismatch,
|
||||
}
|
||||
|
||||
impl DiagCode {
|
||||
@@ -103,6 +106,7 @@ impl DiagCode {
|
||||
DiagCode::SchemaViolation => "E-SCHEMA",
|
||||
DiagCode::TypstCompile => "E-TYPST-COMPILE",
|
||||
DiagCode::RenderIgnored => "W-RENDER-IGNORED",
|
||||
DiagCode::CphVersionMismatch => "E-CPH-VERSION",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,6 +391,14 @@ pub fn load(root: &Path) -> (Option<Lesson>, Vec<Diagnostic>) {
|
||||
}
|
||||
};
|
||||
|
||||
// `.cph-version` compatibility (ADR-0016). Decided at load time, before
|
||||
// any structural/schema/compile work. An incompatible version is an
|
||||
// error diagnostic (`CphVersionMismatch`); it does not halt loading, so
|
||||
// any other defects surface alongside it — but an error diagnostic alone
|
||||
// already makes the lesson illegal (ADR-0010), so `check`/`build` refuse.
|
||||
// A missing `.cph-version` is skipped for now (ADR-0016 OPEN).
|
||||
check_cph_version(root, &mut diags);
|
||||
|
||||
// [project] and [info] are required to build a Lesson at all.
|
||||
let project = match raw.project {
|
||||
Some(p) => Project {
|
||||
@@ -506,6 +514,58 @@ pub fn load(root: &Path) -> (Option<Lesson>, Vec<Diagnostic>) {
|
||||
(Some(lesson), diags)
|
||||
}
|
||||
|
||||
/// The cph version the running CLI was built with (ADR-0016). Pulled from the
|
||||
/// crate's `CARGO_PKG_VERSION` at compile time.
|
||||
pub const CPH_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// Read the engineering file's `.cph-version` and, if present, push a
|
||||
/// `CphVersionMismatch` error when it is not compatible with [`CPH_VERSION`]
|
||||
/// (ADR-0016). A missing file is skipped for now (migration period; OPEN in
|
||||
/// ADR-0016).
|
||||
fn check_cph_version(root: &Path, diags: &mut Vec<Diagnostic>) {
|
||||
let path = root.join(".cph-version");
|
||||
let Ok(src) = std::fs::read_to_string(&path) else {
|
||||
return; // missing `.cph-version` — skipped (ADR-0016 OPEN).
|
||||
};
|
||||
let file_version = src.trim();
|
||||
if file_version.is_empty() {
|
||||
diags.push(
|
||||
Diagnostic::error(
|
||||
DiagCode::CphVersionMismatch,
|
||||
format!(".cph-version is empty; expected cph version {CPH_VERSION}"),
|
||||
)
|
||||
.with_hint(format!(
|
||||
"write the cph version this file targets into {} (currently {CPH_VERSION})",
|
||||
path.display()
|
||||
)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if !versions_compatible(file_version, CPH_VERSION) {
|
||||
diags.push(
|
||||
Diagnostic::error(
|
||||
DiagCode::CphVersionMismatch,
|
||||
format!(
|
||||
".cph-version declares {file_version}, but this cph is {CPH_VERSION} (incompatible)"
|
||||
),
|
||||
)
|
||||
.with_hint(format!(
|
||||
"align them: set .cph-version to {CPH_VERSION}, or install cph {file_version}"
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an engineering file's declared cph version is compatible with the
|
||||
/// running CLI's version (ADR-0016). **The rule is exact equality** — the
|
||||
/// strictest choice, deliberately, while the format is young (0.0.x). This is
|
||||
/// the single seam for future relaxation to a semver range: relaxing the
|
||||
/// format does not change the diagnostic class, the spec, or the CLI — only
|
||||
/// this predicate.
|
||||
fn versions_compatible(file_version: &str, cli_version: &str) -> bool {
|
||||
file_version == cli_version
|
||||
}
|
||||
|
||||
/// Load one element's `element.toml` into an [`ElementDescriptor`], collecting
|
||||
/// diagnostics. On a missing/malformed `element.toml` the descriptor falls back
|
||||
/// to the part's declared kind with empty scalars so the lesson stays buildable.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
0.0.2
|
||||
@@ -272,3 +272,90 @@ fn malformed_target_config_is_non_fatal_with_schema_violations() {
|
||||
"a diagnostic should name the bad step type, got: {diags:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// --- `.cph-version` compatibility gate (ADR-0016) ------------------------------
|
||||
|
||||
/// The cph version the running CLI was built with — what `.cph-version` must
|
||||
/// match exactly (ADR-0016's MVP rule).
|
||||
const CPH_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// Write a one-part lesson into a temp dir, optionally with a `.cph-version`
|
||||
/// file. Returns the temp dir so the caller runs `load`.
|
||||
fn tmp_lesson_with_version(version: Option<&str>) -> PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
p.push(format!("cph-version-test-{nanos}"));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
std::fs::write(
|
||||
p.join("manifest.toml"),
|
||||
"[project]\nid = \"v\"\nname = \"v\"\n[info]\ntitle = \"v\"\n[[parts]]\nkind = \"segment\"\npath = \"segments/a\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let seg = p.join("segments").join("a");
|
||||
std::fs::create_dir_all(&seg).unwrap();
|
||||
std::fs::write(seg.join("element.toml"), "kind = \"segment\"\n").unwrap();
|
||||
std::fs::write(seg.join("textbook.typ"), "t.\n").unwrap();
|
||||
if let Some(v) = version {
|
||||
std::fs::write(p.join(".cph-version"), v).unwrap();
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cph_version_matching_is_not_a_diagnostic() {
|
||||
let tmp = tmp_lesson_with_version(Some(CPH_VERSION));
|
||||
let (_lesson, diags) = load(&tmp);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
assert!(
|
||||
diags.iter().all(|d| d.code != DiagCode::CphVersionMismatch),
|
||||
"a matching .cph-version must not warn, got {diags:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cph_version_mismatch_is_an_error_diagnostic() {
|
||||
let tmp = tmp_lesson_with_version(Some("99.99.99"));
|
||||
let (lesson, diags) = load(&tmp);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
// The lesson still loads (so other defects could surface), but the
|
||||
// mismatch is an error diagnostic — which alone makes it illegal.
|
||||
assert!(lesson.is_some(), "a mismatch should not halt loading");
|
||||
let mm: Vec<_> = diags
|
||||
.iter()
|
||||
.filter(|d| d.code == DiagCode::CphVersionMismatch)
|
||||
.collect();
|
||||
assert_eq!(mm.len(), 1, "expected one CphVersionMismatch, got {diags:?}");
|
||||
assert!(
|
||||
mm[0].message.contains("99.99.99") && mm[0].message.contains(CPH_VERSION),
|
||||
"diagnostic should name both versions, got: {}",
|
||||
mm[0].message
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cph_version_missing_is_skipped() {
|
||||
// No `.cph-version` file at all → skipped (ADR-0016 migration period; OPEN).
|
||||
let tmp = tmp_lesson_with_version(None);
|
||||
let (_lesson, diags) = load(&tmp);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
assert!(
|
||||
diags.iter().all(|d| d.code != DiagCode::CphVersionMismatch),
|
||||
"a missing .cph-version must be skipped, got {diags:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cph_version_empty_is_an_error() {
|
||||
let tmp = tmp_lesson_with_version(Some(" \n"));
|
||||
let (_lesson, diags) = load(&tmp);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
assert!(
|
||||
diags
|
||||
.iter()
|
||||
.any(|d| d.code == DiagCode::CphVersionMismatch && d.message.contains("empty")),
|
||||
"an empty .cph-version should be an error, got {diags:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
0.0.2
|
||||
@@ -0,0 +1,105 @@
|
||||
# ADR 0016: The `.cph-version` Contract File
|
||||
|
||||
## Status
|
||||
|
||||
Accepted — and implemented. The cph CLI refuses to process an engineering
|
||||
file whose `.cph-version` is not compatible with the running CLI, surfacing
|
||||
this as a 7th diagnostic class `cphVersionMismatch`.
|
||||
|
||||
Builds on **ADR-0005** (the engineering file is the asset; its structure is
|
||||
declared), **ADR-0008** (the engineering file is a directory tree rooted at
|
||||
`manifest.toml`), and **ADR-0010/0012** (the diagnostic taxonomy and the
|
||||
"legal lesson = no error diagnostics" rule).
|
||||
|
||||
## Context
|
||||
|
||||
As the cph tool and the authored curriculum engineering files evolve
|
||||
independently, a file authored against one version of the format may not be
|
||||
processable by a CLI built against another. Without a version contract, a
|
||||
mismatch surfaces as confusing downstream failures (a malformed-manifest
|
||||
diagnostic, a compile error, or a silently-wrong build) rather than as the
|
||||
real cause: "this file expects a different cph than the one you're running."
|
||||
|
||||
The user's framing: the curriculum engineering-file workspace should carry a
|
||||
`.cph-version` file naming the cph version it targets; the CLI should
|
||||
**fail early** when that version is not compatible with itself. The
|
||||
compatibility rule can be refined over time, but for now it is the strictest
|
||||
possible: **exact version equality**.
|
||||
|
||||
## Decision
|
||||
|
||||
### A `.cph-version` file at the engineering-file root
|
||||
|
||||
An engineering file's root (alongside `manifest.toml`) carries a
|
||||
`.cph-version` file whose entire content is the version string the file
|
||||
targets (e.g. `0.0.2`, no trailing newline required, whitespace trimmed).
|
||||
The CLI reads its own version from `CARGO_PKG_VERSION`.
|
||||
|
||||
### Compatibility is decided at `load` time and is a diagnostic
|
||||
|
||||
The version check runs in the `load` phase (`cph-model::load`), before any
|
||||
structural/schema/compile work. An incompatible version produces a
|
||||
`cphVersionMismatch` **error** diagnostic — not a CLI-level early exit
|
||||
without a diagnostic. This keeps the failure inside the checker's normal
|
||||
diagnostic vocabulary: `cph check` reports it and exits 1 (which *is* an
|
||||
early refusal — load-phase errors halt the pipeline), and `cph build`
|
||||
refuses to run. Because it is an error diagnostic, an incompatible-version
|
||||
file is, by the contract, **not a legal lesson** (ADR-0010's "legal = no
|
||||
error diagnostics").
|
||||
|
||||
The diagnostic carries a hint pointing the user at both versions and at the
|
||||
file (`expected <cli-version>, found <file-version> in .cph-version`).
|
||||
|
||||
### The 7th diagnostic class `cphVersionMismatch`
|
||||
|
||||
The taxonomy grows from six to seven classes. `cphVersionMismatch` is
|
||||
**structural** (the loader decides it from a file read — no oracle needed),
|
||||
like `partPathMissing`/`unknownKind`; its severity is `error`. This is a
|
||||
real divergence point (the taxonomy is spec-pinned), so it lands in Lean
|
||||
(`Spec.Courseware.Check.Diagnostic.DiagKind`) and in `DiagCode`.
|
||||
|
||||
### The compatibility rule is exact equality, for now — and is replaceable
|
||||
|
||||
The current rule is: **the file's `.cph-version` must exactly equal the
|
||||
CLI's version.** This is deliberately the strictest choice — it fails
|
||||
loudly on any drift, which is the right default while the format is young
|
||||
(0.0.x). The rule is isolated in one function
|
||||
(`cph_model::versions_compatible`), so it can be relaxed later to a semver
|
||||
range (e.g. "same major" or "compatible minor") **without touching the
|
||||
diagnostic class, the spec, or the CLI** — only that one predicate changes.
|
||||
The taxonomy and the "it's a `cphVersionMismatch` error" contract are
|
||||
stable across that evolution.
|
||||
|
||||
### A missing `.cph-version` is not (yet) an error
|
||||
|
||||
If the file has no `.cph-version`, the check is skipped (no diagnostic) for
|
||||
now — existing files and the `mini`/`TH-141` fixtures predate the contract.
|
||||
Whether a missing file should itself be a `cphVersionMismatch` (or a softer
|
||||
warning) once the ecosystem migrates is left open. The KenKen lesson and
|
||||
this repo's examples carry the file as the migration's starting point.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A curriculum file now declares the cph version it was authored against;
|
||||
the CLI refuses incompatible versions with a clear diagnostic rather than
|
||||
a downstream mystery.
|
||||
- The diagnostic taxonomy is seven classes (was six). The Lean master, the
|
||||
`DiagCode` enum, and the severity mapping all gain `cphVersionMismatch`.
|
||||
- The compatibility predicate is the single place future semver relaxation
|
||||
lands — a deliberate seam.
|
||||
- `cph check`/`build` against the repo's own examples requires those
|
||||
examples' `.cph-version` to match the workspace version; the release
|
||||
commit updates them together.
|
||||
|
||||
## Open Questions / Deferred
|
||||
|
||||
- **Missing-file policy.** Whether an absent `.cph-version` should become an
|
||||
error (forcing migration) or a warning — deferred until the contract is
|
||||
broadly adopted.
|
||||
- **Semver relaxation.** The exact equality rule is a placeholder; the real
|
||||
compatibility intervals (per ADR-0014-style "what's a breaking change")
|
||||
are settled when there is a real breaking change to justify loosening.
|
||||
- **Manifest vs file.** The version lives in a sibling file, not a
|
||||
`[project] cph-version = …` manifest key. This mirrors how a toolchain
|
||||
version is often a separate file (`.tool-versions`, `rust-toolchain`);
|
||||
whether it should migrate into the manifest is open.
|
||||
@@ -0,0 +1,47 @@
|
||||
# ADR 0017: AgentSession Is Provider-Bound
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0002 says a long-lived `AgentSession` can be reused by many runs, but never
|
||||
addressed the provider/model dimension. The agent layer is provider-agnostic
|
||||
(ADR-0001..0003 never required Claude specifically; the `@Claude` trigger name
|
||||
is a product brand, not a provider commitment). In practice the Hub routes
|
||||
model calls through an OpenAI-compatible client (e.g. OpenRouter), so a run may
|
||||
target different models — and the motivation for multi-model is **role-based
|
||||
routing**: drafting, reviewing, and triage suit different models.
|
||||
|
||||
That raises the question ADR-0002 left open: when a teacher switches model
|
||||
mid-project, does the `AgentSession` carry live context across the switch, or
|
||||
is it bound to a single provider/model?
|
||||
|
||||
## Decision
|
||||
|
||||
An `AgentSession` is **provider/model-bound**. A model or provider switch
|
||||
starts a new `AgentSession`. The `AgentSession` does not carry live context
|
||||
across a switch.
|
||||
|
||||
Cross-session continuity is not the session's job — it is carried by ADR-0003's
|
||||
project memory and anchors, which the Hub reads on demand when seeding a new
|
||||
run. This keeps B consistent with ADR-0003 ("the Hub avoids becoming a full
|
||||
chat history store"): session continuity and history continuity are the same
|
||||
mechanism, both via memory/anchors, never via a live cross-provider session.
|
||||
|
||||
Per-run model selection (the run carries its model) is the switching point.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Switching model mid-project = new session; the new run seeds from project
|
||||
memory/anchors (ADR-0003), not the prior session's live context.
|
||||
- The agent layer is provider-agnostic: the agent loop is ours; model calls go
|
||||
through an OpenAI-compatible client. No hard dependency on a single-vendor
|
||||
agent SDK.
|
||||
- Role-based model routing (different run kinds default to different models) is
|
||||
a product/admin configuration concern, not a spec invariant.
|
||||
- "AgentSession reused by many runs" (ADR-0002) holds *within* one
|
||||
provider/model; it does not span a switch.
|
||||
- The `@Claude` trigger name remains the product mention brand regardless of the
|
||||
backing model.
|
||||
@@ -0,0 +1 @@
|
||||
0.0.2
|
||||
@@ -0,0 +1,23 @@
|
||||
# Hub runtime configuration. Copy to .env and fill in.
|
||||
|
||||
# PostgreSQL connection string. Used by Prisma and the Hub server.
|
||||
DATABASE_URL="postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm"
|
||||
|
||||
# OpenRouter (or any OpenAI-compatible) API key + base URL.
|
||||
# The Hub's agent layer is provider-agnostic (ADR-0017); this points the
|
||||
# OpenAI-compatible client at OpenRouter by default.
|
||||
OPENROUTER_API_KEY=""
|
||||
# Optional: override the base URL (e.g. direct vendor API, local gateway).
|
||||
# OPENROUTER_BASE_URL="https://openrouter.ai/api/v1"
|
||||
|
||||
# Hub server port. Defaults to 8788.
|
||||
PORT=8788
|
||||
|
||||
# Feishu (lark) bot credentials. The bot receives @mentions in project groups.
|
||||
FEISHU_APP_ID=""
|
||||
FEISHU_APP_SECRET=""
|
||||
FEISHU_BOT_OPEN_ID=""
|
||||
|
||||
# Path to the `cph` binary (the Courseware-side checker, ADR-0016).
|
||||
# Defaults to `cph` on PATH if unset.
|
||||
# CPH_BIN="/usr/local/bin/cph"
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=Curriculum Project Hub (Feishu agent + cph)
|
||||
After=network-online.target postgresql.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=__SERVICE_USER__
|
||||
WorkingDirectory=__HUB_DIR__
|
||||
EnvironmentFile=__ENV_FILE__
|
||||
# Run prisma migrations before each start, then launch the Hub.
|
||||
ExecStartPre=__NODE_BIN__ __HUB_DIR__/node_modules/prisma/build/index.js migrate deploy --schema __HUB_DIR__/prisma/schema.prisma
|
||||
ExecStart=__NODE_BIN__ __HUB_DIR__/dist/server.js
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
# Graceful shutdown: lark ws close + in-flight runs.
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
# Deploy the Hub to a host: rsync the repo, install deps, build, restart service.
|
||||
#
|
||||
# Configure these env vars (e.g. in CI secrets):
|
||||
# PLATFORM_DEPLOY_HOST required
|
||||
# PLATFORM_DEPLOY_SSH_KEY required (private key for the deploy user)
|
||||
# PLATFORM_DEPLOY_USER optional, defaults to deploy
|
||||
# PLATFORM_DEPLOY_PORT optional, defaults to 22
|
||||
# PLATFORM_DEPLOY_BASE optional, defaults to /srv/curriculum-project-hub
|
||||
#
|
||||
# The target host must have Node.js, npm, rsync, PostgreSQL, systemd, and a
|
||||
# writable $PLATFORM_DEPLOY_BASE. Use a dedicated `deploy` user that has
|
||||
# passwordless sudo for:
|
||||
# systemctl restart cph-hub.service
|
||||
# systemctl is-active --quiet cph-hub.service
|
||||
set -euo pipefail
|
||||
|
||||
HOST="${PLATFORM_DEPLOY_HOST:?PLATFORM_DEPLOY_HOST required}"
|
||||
SSH_KEY="${PLATFORM_DEPLOY_SSH_KEY:?PLATFORM_DEPLOY_SSH_KEY required}"
|
||||
DEPLOY_USER="${PLATFORM_DEPLOY_USER:-deploy}"
|
||||
PORT="${PLATFORM_DEPLOY_PORT:-22}"
|
||||
BASE="${PLATFORM_DEPLOY_BASE:-/srv/curriculum-project-hub}"
|
||||
HUB_DIR="$BASE/hub"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
SSH_OPTS=(-i "$SSH_KEY" -p "$PORT" -o StrictHostKeyChecking=accept-new)
|
||||
|
||||
# 1. Rsync the hub/ directory (exclude node_modules/dist, they rebuild on host).
|
||||
rsync -az --delete \
|
||||
--exclude node_modules --exclude dist --exclude .env \
|
||||
-e "ssh ${SSH_OPTS[*]}" \
|
||||
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
|
||||
|
||||
# 2. Install deps + build on the host.
|
||||
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "cd '$HUB_DIR' && npm ci && npm run build"
|
||||
|
||||
# 3. Ensure the service is installed (idempotent), then restart.
|
||||
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "
|
||||
BASE='$BASE' HUB_DIR='$HUB_DIR' bash '$HUB_DIR/deploy/install_service.sh' || true
|
||||
sudo systemctl restart cph-hub.service
|
||||
sleep 2
|
||||
sudo systemctl is-active --quiet cph-hub.service || { echo 'service failed to start'; exit 1; }
|
||||
curl -sf http://127.0.0.1:8788/api/healthz || { echo 'healthz failed'; exit 1; }
|
||||
"
|
||||
|
||||
echo "Deployed cph-hub to $HOST ($HUB_DIR)"
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install the cph-hub systemd service on a host. Idempotent.
|
||||
#
|
||||
# Prerequisites already on the host: Node.js, npm, PostgreSQL, systemd.
|
||||
# The Hub repo is expected at HUB_DIR (default /srv/curriculum-project-hub/hub)
|
||||
# with `npm ci` + `npm run build` already run by deploy_platform.sh.
|
||||
#
|
||||
# Usage:
|
||||
# sudo BASE=/srv/curriculum-project-hub bash deploy/install_service.sh
|
||||
set -euo pipefail
|
||||
|
||||
BASE="${BASE:-/srv/curriculum-project-hub}"
|
||||
HUB_DIR="${HUB_DIR:-$BASE/hub}"
|
||||
SERVICE_USER="${SERVICE_USER:-root}"
|
||||
HOST="${HOST:-127.0.0.1}"
|
||||
PORT="${PORT:-8788}"
|
||||
ENV_FILE="${ENV_FILE:-$BASE/.secrets/platform.env}"
|
||||
NODE_BIN="${NODE_BIN:-$(command -v node || true)}"
|
||||
|
||||
if [ -z "$NODE_BIN" ]; then
|
||||
echo "node must be on PATH, or set NODE_BIN." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -d "$HUB_DIR" ]; then
|
||||
echo "HUB_DIR not found: $HUB_DIR (set HUB_DIR or clone the repo there)." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$HUB_DIR/dist/server.js" ]; then
|
||||
echo "build missing: run 'npm ci && npm run build' in $HUB_DIR first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Seed the env file if absent. Secrets stay on the server, never in the repo.
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
install -d -m 0700 "$(dirname "$ENV_FILE")"
|
||||
cat >"$ENV_FILE" <<EOF
|
||||
NODE_ENV=production
|
||||
DATABASE_URL=postgresql://paradigm:change-me@127.0.0.1:5432/paradigm
|
||||
OPENROUTER_API_KEY=
|
||||
FEISHU_APP_ID=
|
||||
FEISHU_APP_SECRET=
|
||||
FEISHU_BOT_OPEN_ID=
|
||||
PORT=$PORT
|
||||
HOST=$HOST
|
||||
EOF
|
||||
echo "Seeded $ENV_FILE — edit it to fill in OPENROUTER_API_KEY and Feishu creds, then re-run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Render the unit with concrete paths.
|
||||
UNIT=/etc/systemd/system/cph-hub.service
|
||||
sed \
|
||||
-e "s|__SERVICE_USER__|$SERVICE_USER|g" \
|
||||
-e "s|__HUB_DIR__|$HUB_DIR|g" \
|
||||
-e "s|__ENV_FILE__|$ENV_FILE|g" \
|
||||
-e "s|__NODE_BIN__|$NODE_BIN|g" \
|
||||
"$HUB_DIR/deploy/cph-hub.service" >"$UNIT"
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable cph-hub.service
|
||||
echo "Installed cph-hub.service. Start with: systemctl start cph-hub"
|
||||
echo "Check health: curl http://127.0.0.1:$PORT/api/healthz"
|
||||
Generated
+3558
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@paradigm/hub",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai-compatible": "^3.0.5",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@larksuiteoapi/node-sdk": "^1.66.1",
|
||||
"@prisma/client": "^6.19.3",
|
||||
"ai": "^7.0.16",
|
||||
"dotenv": "^17.4.2",
|
||||
"fastify": "^5.8.5",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prisma": "^6.19.3",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"description": "Curriculum Project Hub — Feishu-group collaboration + provider-agnostic agent runtime. Aligns to spec/System (ADR-0001..0004, 0017).",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/server.js",
|
||||
"check": "tsc -p tsconfig.json --noEmit",
|
||||
"prisma:generate": "prisma generate --schema prisma/schema.prisma",
|
||||
"prisma:validate": "DATABASE_URL=${DATABASE_URL:-postgresql://stub:stub@127.0.0.1:5432/stub} prisma validate --schema prisma/schema.prisma",
|
||||
"prisma:migrate": "DATABASE_URL=${DATABASE_URL:-postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm} prisma migrate deploy --schema prisma/schema.prisma",
|
||||
"deploy": "bash deploy/deploy_platform.sh",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PlatformRole" AS ENUM ('ADMIN', 'TEACHER');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AgentRunStatus" AS ENUM ('ACTIVE', 'WAITING_FOR_USER', 'COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AgentEntrypoint" AS ENUM ('FEISHU', 'WEB', 'CLI');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PermissionRole" AS ENUM ('READ', 'EDIT', 'MANAGE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PermissionResourceType" AS ENUM ('PROJECT', 'ARTIFACT', 'PROJECT_GROUP');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"feishuOpenId" TEXT NOT NULL,
|
||||
"displayName" TEXT NOT NULL,
|
||||
"avatarUrl" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PlatformRoleAssignment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"role" "PlatformRole" NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "PlatformRoleAssignment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Project" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"workspaceDir" TEXT NOT NULL,
|
||||
"createdByUserId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"archivedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProjectGroupBinding" (
|
||||
"id" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"chatId" TEXT NOT NULL,
|
||||
"createdByUserId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ProjectGroupBinding_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AgentSession" (
|
||||
"id" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"model" TEXT NOT NULL,
|
||||
"title" TEXT,
|
||||
"metadata" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"archivedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "AgentSession_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AgentRun" (
|
||||
"id" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"sessionId" TEXT,
|
||||
"requestedByUserId" TEXT,
|
||||
"entrypoint" "AgentEntrypoint" NOT NULL,
|
||||
"status" "AgentRunStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||
"prompt" TEXT NOT NULL,
|
||||
"model" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"summary" TEXT,
|
||||
"inputTokens" INTEGER,
|
||||
"outputTokens" INTEGER,
|
||||
"metadata" JSONB NOT NULL,
|
||||
"error" TEXT,
|
||||
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"finishedAt" TIMESTAMP(3),
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AgentRun_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProjectAgentLock" (
|
||||
"projectId" TEXT NOT NULL,
|
||||
"runId" TEXT NOT NULL,
|
||||
"holderUserId" TEXT,
|
||||
"acquiredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "ProjectAgentLock_pkey" PRIMARY KEY ("projectId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PermissionGrant" (
|
||||
"id" TEXT NOT NULL,
|
||||
"resourceType" "PermissionResourceType" NOT NULL,
|
||||
"resourceId" TEXT NOT NULL,
|
||||
"principal" TEXT NOT NULL,
|
||||
"role" "PermissionRole" NOT NULL,
|
||||
"createdByUserId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "PermissionGrant_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PermissionSettings" (
|
||||
"id" TEXT NOT NULL,
|
||||
"resourceType" "PermissionResourceType" NOT NULL,
|
||||
"resourceId" TEXT NOT NULL,
|
||||
"externalShare" TEXT NOT NULL,
|
||||
"comment" TEXT NOT NULL,
|
||||
"copyDownload" TEXT NOT NULL,
|
||||
"collaboratorMgmt" TEXT NOT NULL,
|
||||
"agentTrigger" TEXT NOT NULL,
|
||||
"agentCancel" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "PermissionSettings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AuditEntry" (
|
||||
"id" TEXT NOT NULL,
|
||||
"runId" TEXT,
|
||||
"actorUserId" TEXT,
|
||||
"action" TEXT NOT NULL,
|
||||
"metadata" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AuditEntry_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_feishuOpenId_key" ON "User"("feishuOpenId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PlatformRoleAssignment_userId_revokedAt_idx" ON "PlatformRoleAssignment"("userId", "revokedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PlatformRoleAssignment_role_revokedAt_idx" ON "PlatformRoleAssignment"("role", "revokedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Project_archivedAt_idx" ON "Project"("archivedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ProjectGroupBinding_projectId_key" ON "ProjectGroupBinding"("projectId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ProjectGroupBinding_chatId_key" ON "ProjectGroupBinding"("chatId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ProjectGroupBinding_chatId_idx" ON "ProjectGroupBinding"("chatId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AgentSession_projectId_archivedAt_idx" ON "AgentSession"("projectId", "archivedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AgentSession_provider_model_idx" ON "AgentSession"("provider", "model");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AgentSession_updatedAt_idx" ON "AgentSession"("updatedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AgentRun_projectId_status_idx" ON "AgentRun"("projectId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AgentRun_sessionId_idx" ON "AgentRun"("sessionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AgentRun_requestedByUserId_idx" ON "AgentRun"("requestedByUserId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AgentRun_updatedAt_idx" ON "AgentRun"("updatedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ProjectAgentLock_runId_key" ON "ProjectAgentLock"("runId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ProjectAgentLock_expiresAt_idx" ON "ProjectAgentLock"("expiresAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PermissionGrant_resourceType_resourceId_revokedAt_idx" ON "PermissionGrant"("resourceType", "resourceId", "revokedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PermissionGrant_principal_revokedAt_idx" ON "PermissionGrant"("principal", "revokedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PermissionGrant_resourceType_resourceId_principal_role_revo_key" ON "PermissionGrant"("resourceType", "resourceId", "principal", "role", "revokedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PermissionSettings_resourceType_resourceId_idx" ON "PermissionSettings"("resourceType", "resourceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PermissionSettings_resourceType_resourceId_key" ON "PermissionSettings"("resourceType", "resourceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditEntry_runId_idx" ON "AuditEntry"("runId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditEntry_actorUserId_idx" ON "AuditEntry"("actorUserId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditEntry_createdAt_idx" ON "AuditEntry"("createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PlatformRoleAssignment" ADD CONSTRAINT "PlatformRoleAssignment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Project" ADD CONSTRAINT "Project_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProjectGroupBinding" ADD CONSTRAINT "ProjectGroupBinding_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProjectGroupBinding" ADD CONSTRAINT "ProjectGroupBinding_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AgentSession" ADD CONSTRAINT "AgentSession_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AgentRun" ADD CONSTRAINT "AgentRun_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AgentRun" ADD CONSTRAINT "AgentRun_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "AgentSession"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AgentRun" ADD CONSTRAINT "AgentRun_requestedByUserId_fkey" FOREIGN KEY ("requestedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProjectAgentLock" ADD CONSTRAINT "ProjectAgentLock_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProjectAgentLock" ADD CONSTRAINT "ProjectAgentLock_runId_fkey" FOREIGN KEY ("runId") REFERENCES "AgentRun"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProjectAgentLock" ADD CONSTRAINT "ProjectAgentLock_holderUserId_fkey" FOREIGN KEY ("holderUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PermissionGrant" ADD CONSTRAINT "PermissionGrant_resourceId_fkey" FOREIGN KEY ("resourceId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PermissionGrant" ADD CONSTRAINT "PermissionGrant_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PermissionSettings" ADD CONSTRAINT "PermissionSettings_resourceId_fkey" FOREIGN KEY ("resourceId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AuditEntry" ADD CONSTRAINT "AuditEntry_actorUserId_fkey" FOREIGN KEY ("actorUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "RoleTriggerGrant" (
|
||||
"id" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"roleId" TEXT NOT NULL,
|
||||
"principal" TEXT NOT NULL,
|
||||
"createdByUserId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "RoleTriggerGrant_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RoleTriggerGrant_projectId_roleId_revokedAt_idx" ON "RoleTriggerGrant"("projectId", "roleId", "revokedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RoleTriggerGrant_principal_revokedAt_idx" ON "RoleTriggerGrant"("principal", "revokedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "RoleTriggerGrant_projectId_roleId_principal_revokedAt_key" ON "RoleTriggerGrant"("projectId", "roleId", "principal", "revokedAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RoleTriggerGrant" ADD CONSTRAINT "RoleTriggerGrant_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RoleTriggerGrant" ADD CONSTRAINT "RoleTriggerGrant_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,311 @@
|
||||
// Prisma schema for Curriculum Project Hub.
|
||||
//
|
||||
// Aligns to spec/System (ADR-0001..0004, 0017). Key divergences from the
|
||||
// legacy teaching-material-host-service schema, each deliberate:
|
||||
//
|
||||
// - No AgentSession.claudeSessionId. ADR-0017: session is provider/model-
|
||||
// bound, not Claude-bound; `provider`+`model` replace it. Switching model
|
||||
// = new session, so the unique constraint is on the session id alone.
|
||||
// - PermissionRole enum = read/edit/manage (ADR-0004 capability lattice),
|
||||
// distinct from platform UserRole (admin/teacher) — legacy conflated them.
|
||||
// - ProjectGroupBinding is project→chat only (ADR-0001 1:1); legacy mixed
|
||||
// user/chat targets into one binding table.
|
||||
// - PermissionGrant + PermissionSettings land (ADR-0004), missing in legacy.
|
||||
// - AgentRunStatus adds WAITING_FOR_USER + TIMED_OUT (spec RunState; enum
|
||||
// completeness OPEN — add states without a schema migration war).
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// --- Platform identity ---------------------------------------------------
|
||||
|
||||
/// A Feishu user known to the Hub. principal sub-typology is OPEN (ADR-0004).
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
feishuOpenId String @unique
|
||||
displayName String
|
||||
avatarUrl String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
platformRoles PlatformRoleAssignment[]
|
||||
createdProjects Project[] @relation("projectCreator")
|
||||
requestedRuns AgentRun[] @relation("runRequester")
|
||||
heldLocks ProjectAgentLock[] @relation("lockHolder")
|
||||
feishuBindings ProjectGroupBinding[] @relation("bindingCreator")
|
||||
permissionGrants PermissionGrant[] @relation("grantCreator")
|
||||
roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator")
|
||||
auditEntries AuditEntry[] @relation("auditActor")
|
||||
}
|
||||
|
||||
/// Platform-level role (admin/teacher). Distinct from ADR-0004 PermissionRole.
|
||||
/// `admin` is the only override path for force-release (spec RequiresAdmin).
|
||||
model PlatformRoleAssignment {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
role PlatformRole
|
||||
createdAt DateTime @default(now())
|
||||
revokedAt DateTime?
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, revokedAt])
|
||||
@@index([role, revokedAt])
|
||||
}
|
||||
|
||||
enum PlatformRole {
|
||||
ADMIN
|
||||
TEACHER
|
||||
}
|
||||
|
||||
// --- Project & Feishu binding (ADR-0001) ---------------------------------
|
||||
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
workspaceDir String
|
||||
createdByUserId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
archivedAt DateTime?
|
||||
|
||||
createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||||
groupBinding ProjectGroupBinding?
|
||||
agentSessions AgentSession[]
|
||||
agentRuns AgentRun[]
|
||||
agentLock ProjectAgentLock?
|
||||
permissionGrants PermissionGrant[] @relation("projectGrants")
|
||||
permissionSettings PermissionSettings[] @relation("projectSettings")
|
||||
roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants")
|
||||
|
||||
@@index([archivedAt])
|
||||
}
|
||||
|
||||
/// ADR-0001: one project ↔ one Feishu chat (1:1). `chatId` is unique ⇒
|
||||
/// GroupBinding.WellFormed's injectivity half (no two projects bind one chat).
|
||||
/// The "at most one binding per project" half is enforced by the 1:1 relation.
|
||||
/// Group dissolution lifecycle is OPEN (ADR-0001 Consequences) — not modeled
|
||||
/// here; archival is a future policy, not a current column.
|
||||
model ProjectGroupBinding {
|
||||
id String @id @default(cuid())
|
||||
projectId String @unique
|
||||
chatId String @unique
|
||||
createdByUserId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
createdBy User? @relation("bindingCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([chatId])
|
||||
}
|
||||
|
||||
// --- AgentRun, session, lock (ADR-0002, 0017) -----------------------------
|
||||
|
||||
/// ADR-0017: session is provider/model-bound. No claudeSessionId; `provider`
|
||||
/// + `model` capture the binding. Same provider+model ⇒ reuse across runs
|
||||
/// (ADR-0002); a switch ⇒ new session.
|
||||
model AgentSession {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
provider String
|
||||
model String
|
||||
title String?
|
||||
metadata Json
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
archivedAt DateTime?
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
runs AgentRun[]
|
||||
|
||||
@@index([projectId, archivedAt])
|
||||
@@index([provider, model])
|
||||
@@index([updatedAt])
|
||||
}
|
||||
|
||||
/// spec RunState: active/waitingForUser/completed/failed/timedOut/canceled.
|
||||
/// Enum completeness OPEN (Run.lean:12) — adding a state is a value add, not a
|
||||
/// spec breach. DB mirrors the current enum.
|
||||
enum AgentRunStatus {
|
||||
ACTIVE
|
||||
WAITING_FOR_USER
|
||||
COMPLETED
|
||||
FAILED
|
||||
TIMED_OUT
|
||||
CANCELED
|
||||
}
|
||||
|
||||
enum AgentEntrypoint {
|
||||
FEISHU
|
||||
WEB
|
||||
CLI
|
||||
}
|
||||
|
||||
model AgentRun {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
sessionId String?
|
||||
requestedByUserId String?
|
||||
entrypoint AgentEntrypoint
|
||||
status AgentRunStatus @default(ACTIVE)
|
||||
prompt String
|
||||
model String
|
||||
provider String
|
||||
summary String?
|
||||
inputTokens Int?
|
||||
outputTokens Int?
|
||||
metadata Json
|
||||
error String?
|
||||
startedAt DateTime @default(now())
|
||||
finishedAt DateTime?
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
session AgentSession? @relation(fields: [sessionId], references: [id], onDelete: SetNull)
|
||||
requestedBy User? @relation("runRequester", fields: [requestedByUserId], references: [id], onDelete: SetNull)
|
||||
projectLock ProjectAgentLock?
|
||||
|
||||
@@index([projectId, status])
|
||||
@@index([sessionId])
|
||||
@@index([requestedByUserId])
|
||||
@@index([updatedAt])
|
||||
}
|
||||
|
||||
/// ADR-0002: lock owner = run_id (not session/user/chat). `projectId @id` ⇒
|
||||
/// at most one lock per project (LockTable exclusivity). `runId @unique` ⇒
|
||||
/// a run holds at most one lock. WellFormed (holder is non-terminal) is an
|
||||
/// app-level invariant checked on read/write, not a DB constraint.
|
||||
model ProjectAgentLock {
|
||||
projectId String @id
|
||||
runId String @unique
|
||||
holderUserId String?
|
||||
acquiredAt DateTime @default(now())
|
||||
expiresAt DateTime?
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
run AgentRun @relation(fields: [runId], references: [id], onDelete: Cascade)
|
||||
holder User? @relation("lockHolder", fields: [holderUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
// --- Permission grants & settings (ADR-0004) ----------------------------
|
||||
|
||||
/// ADR-0004 PermissionRole: read ⊂ edit ⊂ manage (capability lattice).
|
||||
/// Distinct from PlatformRole. Force-release is admin-only, outside this
|
||||
/// lattice (spec RequiresAdmin).
|
||||
enum PermissionRole {
|
||||
READ
|
||||
EDIT
|
||||
MANAGE
|
||||
}
|
||||
|
||||
/// ADR-0004 resource_type: project | artifact | project_group. The resource
|
||||
/// id's meaning is determined by its type (artifact id semantics align to the
|
||||
/// Courseware half; OPEN here).
|
||||
enum PermissionResourceType {
|
||||
PROJECT
|
||||
ARTIFACT
|
||||
PROJECT_GROUP
|
||||
}
|
||||
|
||||
/// ADR-0004 PermissionGrant: resource × principal × role.
|
||||
/// `principal` is an opaque string (principal sub-typology OPEN, ADR-0004).
|
||||
/// The compound unique covers "one active grant per (resource, principal, role)"
|
||||
/// — revoked rows keep `revokedAt` set, so a re-grant after revocation is a new
|
||||
/// row, not a conflict.
|
||||
model PermissionGrant {
|
||||
id String @id @default(cuid())
|
||||
resourceType PermissionResourceType
|
||||
resourceId String
|
||||
principal String
|
||||
role PermissionRole
|
||||
createdByUserId String?
|
||||
createdAt DateTime @default(now())
|
||||
revokedAt DateTime?
|
||||
|
||||
project Project? @relation("projectGrants", fields: [resourceId], references: [id], onDelete: Cascade)
|
||||
createdBy User? @relation("grantCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([resourceType, resourceId, principal, role, revokedAt])
|
||||
@@index([resourceType, resourceId, revokedAt])
|
||||
@@index([principal, revokedAt])
|
||||
}
|
||||
|
||||
/// ADR-0004 PermissionSettings: six policy knobs, values OPEN. Stored as one
|
||||
/// opaque string column each; the enum/value-domain is decided by admin policy,
|
||||
/// not by this schema. `resourceType`+`resourceId` identify the resource.
|
||||
/// Related to Project only when resourceType=PROJECT (null otherwise).
|
||||
model PermissionSettings {
|
||||
id String @id @default(cuid())
|
||||
resourceType PermissionResourceType
|
||||
resourceId String
|
||||
externalShare String
|
||||
comment String
|
||||
copyDownload String
|
||||
collaboratorMgmt String
|
||||
agentTrigger String
|
||||
agentCancel String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
project Project? @relation("projectSettings", fields: [resourceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([resourceType, resourceId])
|
||||
@@index([resourceType, resourceId])
|
||||
}
|
||||
|
||||
|
||||
/// Per-role agent trigger grant. Orthogonal to ADR-0004's PermissionGrant:
|
||||
/// ADR-0004 decides "can trigger an agent at all" (triggerAgent capability,
|
||||
/// edit+ role); this table decides "can trigger *which* agent role" (e.g.
|
||||
/// /review vs /draft). Two gates in series — both must pass.
|
||||
///
|
||||
/// `roleId` is an opaque string matching RoleEntry.id (ADR-0017: roles are
|
||||
/// data, not a code enum). `principal` mirrors ADR-0004's opaque principal
|
||||
/// (sub-typology OPEN). A project-scoped row grants the role on that project;
|
||||
/// the compound unique covers "one active grant per (project, principal, role)".
|
||||
/// Revocation via `revokedAt`, same pattern as PermissionGrant.
|
||||
model RoleTriggerGrant {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
roleId String
|
||||
principal String
|
||||
createdByUserId String?
|
||||
createdAt DateTime @default(now())
|
||||
revokedAt DateTime?
|
||||
|
||||
project Project @relation("projectRoleGrants", fields: [projectId], references: [id], onDelete: Cascade)
|
||||
createdBy User? @relation("roleGrantCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([projectId, roleId, principal, revokedAt])
|
||||
@@index([projectId, roleId, revokedAt])
|
||||
@@index([principal, revokedAt])
|
||||
}
|
||||
|
||||
// --- Audit (ADR Audit, content OPEN) -------------------------------------
|
||||
|
||||
/// spec AuditEntry: minimal skeleton — one entry relates to a run. Event type,
|
||||
/// actor, timestamp, details are OPEN. This table mirrors that: `runId` is the
|
||||
/// PINNED relation; the rest is open-shaped columns.
|
||||
model AuditEntry {
|
||||
id String @id @default(cuid())
|
||||
runId String?
|
||||
actorUserId String?
|
||||
action String
|
||||
metadata Json
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
actor User? @relation("auditActor", fields: [actorUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([runId])
|
||||
@@index([actorUserId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* `cph` subprocess tools - the Hub<->Courseware coupling point.
|
||||
*
|
||||
* The Hub agent does not parse engineering-file models in-process; it calls the
|
||||
* stable `cph` CLI (ADR-0016 version contract). `cph check` validates a
|
||||
* project; `cph build` renders a target artifact. Version compatibility is the
|
||||
* CLI's responsibility - it refuses incompatible `.cph-version` files with a
|
||||
* `cphVersionMismatch` error diagnostic (ADR-0016); the Hub merely runs the
|
||||
* subprocess and returns its stdout/stderr/exit to the model.
|
||||
*
|
||||
* The `cph` binary path is configurable (env `CPH_BIN`, default `cph` on
|
||||
* PATH). All subprocesses run with `cwd = ctx.workspaceDir` so paths in
|
||||
* diagnostics are relative to the project root.
|
||||
*/
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
import type { ToolContext, ToolDefinition } from "./tools.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const CPH_BIN = process.env["CPH_BIN"] ?? "cph";
|
||||
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||
|
||||
interface ExecResult {
|
||||
readonly exitCode: number;
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
}
|
||||
|
||||
async function runCph(args: readonly string[], workspaceDir: string): Promise<ExecResult> {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(CPH_BIN, args, {
|
||||
cwd: workspaceDir,
|
||||
timeout: DEFAULT_TIMEOUT_MS,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
return { exitCode: 0, stdout, stderr };
|
||||
} catch (e) {
|
||||
// execFile rejects on non-zero exit; the error carries stdout/stderr/code.
|
||||
const err = e as NodeJS.ErrnoException & { stdout?: string; stderr?: string; code?: number | string };
|
||||
if (err.code === "ENOENT") {
|
||||
return {
|
||||
exitCode: 127,
|
||||
stdout: "",
|
||||
stderr: `cph binary not found at "${CPH_BIN}" (set CPH_BIN env or install cph on PATH)`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
exitCode: typeof err.code === "number" ? err.code : 1,
|
||||
stdout: err.stdout ?? "",
|
||||
stderr: err.stderr ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// `cph check <dir>` - validate a curriculum engineering file. Returns the
|
||||
/// checker's diagnostics (stdout) for the model to act on. Non-zero exit means
|
||||
/// the file has error diagnostics (ADR-0010); the output is still returned,
|
||||
/// not thrown - the model needs to see the diagnostics to fix them.
|
||||
export function cphCheckTool(ctx: ToolContext): ToolDefinition {
|
||||
return tool({
|
||||
description:
|
||||
"Run `cph check` on the project's curriculum engineering file. Returns diagnostics (7 classes: structure/schema/compile/version...). Non-zero exit means error diagnostics present - read the output to fix them.",
|
||||
inputSchema: z.object({
|
||||
path: z.string().describe("Path to the engineering file dir; defaults to the workspace root.").optional(),
|
||||
}),
|
||||
execute: async (args): Promise<string> => {
|
||||
const target = args.path ?? ".";
|
||||
const res = await runCph(["check", target], ctx.workspaceDir);
|
||||
return JSON.stringify({
|
||||
exitCode: res.exitCode,
|
||||
stdout: res.stdout,
|
||||
stderr: res.stderr,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/// `cph build <dir> --target <T> -o <out>` - render a target artifact. The
|
||||
/// target (student/teacher/...) determines the build (ADR-0009). Output path
|
||||
/// is relative to the workspace unless absolute.
|
||||
export function cphBuildTool(ctx: ToolContext): ToolDefinition {
|
||||
return tool({
|
||||
description:
|
||||
"Run `cph build` to render a curriculum artifact (e.g. student/teacher PDF). Target selects the build; output names the result file.",
|
||||
inputSchema: z.object({
|
||||
target: z.string().describe("Build target, e.g. 'student', 'teacher'."),
|
||||
path: z.string().describe("Path to the engineering file dir; defaults to the workspace root.").optional(),
|
||||
output: z.string().describe("Output file path (relative to workspace or absolute).").optional(),
|
||||
}),
|
||||
execute: async (args): Promise<string> => {
|
||||
const target = args.path ?? ".";
|
||||
const out = args.output ?? `build/${args.target}.pdf`;
|
||||
const res = await runCph(["build", target, "--target", args.target, "-o", out], ctx.workspaceDir);
|
||||
return JSON.stringify({
|
||||
exitCode: res.exitCode,
|
||||
stdout: res.stdout,
|
||||
stderr: res.stderr,
|
||||
output: out,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Per-run model selection & role-based routing.
|
||||
*
|
||||
* ADR-0017 consequence: role-based model routing (different run kinds default
|
||||
* to different models) is a **product/admin configuration** concern, not a spec
|
||||
* invariant. This registry is the seam for that config; the concrete policy
|
||||
* (which models are admin-enabled, which role maps to which default) is `OPEN`
|
||||
* here — decided by admin settings + ADR, not hard-coded.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A named role preset — the full per-run agent bundle. Roles are **data**, not
|
||||
* a code enum: admin/teachers define them (ADR-0017: role-based routing is
|
||||
* product config, not a spec invariant). `roleId` is an opaque string and
|
||||
* doubles as the slash-command name (`/draft ...`) — the registry holds the
|
||||
* role set, so new roles are added by configuration, not by editing code.
|
||||
*
|
||||
* A role bundles everything that distinguishes one agent persona from another:
|
||||
* model, system prompt, and the tool surface (files / cph / feishu / skills /
|
||||
* mcps). Per-run tool whitelisting is enforced by {@link ToolRegistry.subset};
|
||||
* the model never sees tools outside its role's whitelist, even if it tries to
|
||||
* call them — security does not rely on the system prompt.
|
||||
*/
|
||||
export interface RoleEntry {
|
||||
readonly id: string;
|
||||
/** Human label for the teacher-side switcher UX. */
|
||||
readonly label: string;
|
||||
/** Default model id for runs under this role, if a routing rule is set. */
|
||||
readonly defaultModel: string | undefined;
|
||||
/**
|
||||
* System prompt seeding the agent's persona/instructions. Prepended to the
|
||||
* run's messages only at session start (resume reads it from the transcript,
|
||||
* see runner.ts). `undefined` ⇒ no system prompt (bare run).
|
||||
*/
|
||||
readonly systemPrompt: string | undefined;
|
||||
/**
|
||||
* Tool names this role may use (whitelist). `undefined` ⇒ the full registered
|
||||
* set (back-compat / unrestricted roles). An empty array ⇒ no tools at all.
|
||||
* Names not present in the registry are silently dropped at run setup.
|
||||
*/
|
||||
readonly tools: readonly string[] | undefined;
|
||||
}
|
||||
|
||||
/** A model the admin has enabled for use by the Hub. */
|
||||
export interface ModelEntry {
|
||||
readonly id: string;
|
||||
/** Human label for the teacher-side switcher UX. */
|
||||
readonly label: string;
|
||||
/** True when the model is known to support tool use reliably. */
|
||||
readonly toolCapable: boolean;
|
||||
}
|
||||
|
||||
export interface ModelRegistry {
|
||||
/** All models admin-enabled for this Hub instance. */
|
||||
listModels(): readonly ModelEntry[];
|
||||
/** All role presets currently configured (admin/teacher-defined). */
|
||||
listRoles(): readonly RoleEntry[];
|
||||
/** The role preset with the given id, if any. */
|
||||
role(id: string): RoleEntry | undefined;
|
||||
/** Resolve a model id: explicit request → role default → first enabled. */
|
||||
resolve(requestedModel: string | undefined, roleId: string): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple in-memory registry. Real wiring reads from admin settings / DB; this
|
||||
* is the skeleton seam. Both the model list and the role set are pluggable data
|
||||
* — adding a role is a config change, not a code change.
|
||||
*/
|
||||
export class InMemoryModelRegistry implements ModelRegistry {
|
||||
private readonly models: readonly ModelEntry[];
|
||||
private readonly roles: Map<string, RoleEntry>;
|
||||
|
||||
constructor(models: readonly ModelEntry[], roles: readonly RoleEntry[] = []) {
|
||||
this.models = models;
|
||||
this.roles = new Map(roles.map((r) => [r.id, r]));
|
||||
}
|
||||
|
||||
listModels(): readonly ModelEntry[] {
|
||||
return this.models;
|
||||
}
|
||||
|
||||
listRoles(): readonly RoleEntry[] {
|
||||
return [...this.roles.values()];
|
||||
}
|
||||
|
||||
role(id: string): RoleEntry | undefined {
|
||||
return this.roles.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a model id for a run. Priority: teacher's explicit request → the
|
||||
* role preset's default → the first admin-enabled model. `roleId` is a string
|
||||
* so unknown roles degrade gracefully to the fallback rather than throwing.
|
||||
*/
|
||||
resolve(requestedModel: string | undefined, roleId: string): string {
|
||||
if (requestedModel !== undefined && this.models.some((m) => m.id === requestedModel)) {
|
||||
return requestedModel;
|
||||
}
|
||||
const role = this.roles.get(roleId);
|
||||
if (role?.defaultModel !== undefined) return role.defaultModel;
|
||||
const first = this.models[0];
|
||||
if (first === undefined) throw new Error("no models enabled for this Hub");
|
||||
return first.id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* OpenRouter model factory.
|
||||
*
|
||||
* ADR-0017: the agent layer keeps role/model resolution separate from provider
|
||||
* construction. A run carries a resolved model id, and switching model still
|
||||
* means a new provider-bound session.
|
||||
*/
|
||||
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
||||
import type { LanguageModel } from "ai";
|
||||
|
||||
export function createModelFactory(): (modelId: string) => LanguageModel {
|
||||
const provider = createOpenAICompatible({
|
||||
name: "openrouter",
|
||||
baseURL: process.env.OPENROUTER_BASE_URL ?? "https://openrouter.ai/api/v1",
|
||||
headers: { Authorization: `Bearer ${process.env.OPENROUTER_API_KEY ?? ""}` },
|
||||
});
|
||||
return (modelId: string) => provider(modelId);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Provider-bound agent runner.
|
||||
*
|
||||
* The AI SDK owns tool-call dispatch and message folding. The Hub owns the
|
||||
* per-run tool context, role/model selection, transcript persistence, and the
|
||||
* ADR-0017 session boundary: a run is bound to one model. Switching model is a
|
||||
* new run + new session; cross-run continuity is carried by project
|
||||
* memory/anchors (ADR-0003), not by this runner.
|
||||
*/
|
||||
import { generateText, stepCountIs, type LanguageModel, type ModelMessage } from "ai";
|
||||
import type { ToolContext, ToolRegistry } from "./tools.js";
|
||||
import { readTranscript, appendTranscript } from "./transcript.js";
|
||||
|
||||
export type ModelFactory = (modelId: string) => LanguageModel;
|
||||
|
||||
/** What the runner needs about the project to seed and bound a run. */
|
||||
export interface ProjectContext {
|
||||
readonly projectId: string;
|
||||
/** ADR-0001 binding - the chat this project is bound to. */
|
||||
readonly boundChatId: string;
|
||||
/** Absolute path to the curriculum engineering-file workspace. */
|
||||
readonly workspaceDir: string;
|
||||
}
|
||||
|
||||
export interface RunRequest {
|
||||
readonly prompt: string;
|
||||
/** Resolved model id (already chosen by the caller per ADR-0017). */
|
||||
readonly model: string;
|
||||
readonly project: ProjectContext;
|
||||
readonly systemPrompt: string | undefined;
|
||||
/** Iteration cap before the run is returned as `length`. Default 25. */
|
||||
readonly maxIterations?: number;
|
||||
/** Per-role tool whitelist (ADR-0017). Undefined means all registered tools. */
|
||||
readonly toolWhitelist?: readonly string[] | undefined;
|
||||
/** Path to the session transcript JSONL. If set, prior messages are loaded
|
||||
* from it before the run, and new messages are appended after. */
|
||||
readonly transcriptPath?: string;
|
||||
}
|
||||
|
||||
export type RunStatus = "completed" | "length" | "failed";
|
||||
|
||||
export interface RunResult {
|
||||
readonly status: RunStatus;
|
||||
/** Full transcript of the run, for audit (ADR Audit, content OPEN). */
|
||||
readonly messages: readonly ModelMessage[];
|
||||
readonly usage: { inputTokens: number; outputTokens: number };
|
||||
readonly error?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_ITERATIONS = 25;
|
||||
|
||||
export async function runAgent(
|
||||
modelFactory: ModelFactory,
|
||||
tools: ToolRegistry,
|
||||
req: RunRequest,
|
||||
): Promise<RunResult> {
|
||||
const ctx: ToolContext = {
|
||||
runId: cryptoRandomId(),
|
||||
projectId: req.project.projectId,
|
||||
boundChatId: req.project.boundChatId,
|
||||
workspaceDir: req.project.workspaceDir,
|
||||
};
|
||||
const aiTools = tools.build(ctx, req.toolWhitelist);
|
||||
|
||||
// Load prior session messages from transcript (continuity across @bot calls
|
||||
// in the same session). ADR-0003: session messages != group chat history.
|
||||
let messages: ModelMessage[] = req.transcriptPath !== undefined ? await readTranscript(req.transcriptPath) : [];
|
||||
const loadedCount = messages.length;
|
||||
if (req.systemPrompt !== undefined && messages.length === 0) {
|
||||
// System prompt only at the very start of a session; on resume it's
|
||||
// already in the transcript.
|
||||
messages = [{ role: "system", content: req.systemPrompt }];
|
||||
}
|
||||
messages = [...messages, { role: "user", content: req.prompt }];
|
||||
|
||||
const cap = req.maxIterations ?? DEFAULT_MAX_ITERATIONS;
|
||||
|
||||
try {
|
||||
const result = await generateText({
|
||||
model: modelFactory(req.model),
|
||||
messages,
|
||||
allowSystemInMessages: true,
|
||||
tools: aiTools,
|
||||
stopWhen: stepCountIs(cap),
|
||||
});
|
||||
const allMessages: ModelMessage[] = [...messages, ...result.responseMessages];
|
||||
await appendTranscriptIfSet(req, allMessages, loadedCount);
|
||||
|
||||
const stoppedByStepCap = result.finishReason === "tool-calls" && result.steps.length >= cap;
|
||||
const status: RunStatus =
|
||||
result.finishReason === "stop"
|
||||
? "completed"
|
||||
: result.finishReason === "length" || stoppedByStepCap
|
||||
? "length"
|
||||
: "failed";
|
||||
const error =
|
||||
status === "failed"
|
||||
? `finishReason=${result.finishReason}`
|
||||
: stoppedByStepCap
|
||||
? `exceeded maxIterations=${cap}`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
status,
|
||||
messages: allMessages,
|
||||
usage: {
|
||||
inputTokens: result.usage.inputTokens ?? 0,
|
||||
outputTokens: result.usage.outputTokens ?? 0,
|
||||
},
|
||||
...(error !== undefined ? { error } : {}),
|
||||
};
|
||||
} catch (e) {
|
||||
await appendTranscriptIfSet(req, messages, loadedCount);
|
||||
return {
|
||||
status: "failed",
|
||||
messages,
|
||||
usage: { inputTokens: 0, outputTokens: 0 },
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Append only the messages produced this run (skip the `loadedCount` prior
|
||||
/// ones already in the file). Errors are swallowed - a transcript write
|
||||
/// failure must not lose the run result.
|
||||
async function appendTranscriptIfSet(req: RunRequest, messages: readonly ModelMessage[], loadedCount: number): Promise<void> {
|
||||
if (req.transcriptPath === undefined) return;
|
||||
const newMessages = messages.slice(loadedCount);
|
||||
if (newMessages.length === 0) return;
|
||||
try {
|
||||
await appendTranscript(req.transcriptPath, newMessages);
|
||||
} catch {
|
||||
// Swallow - transcript is best-effort persistence; the run result is
|
||||
// returned regardless. ADR-0002 lock ensures no concurrent writer.
|
||||
}
|
||||
}
|
||||
|
||||
function cryptoRandomId(): string {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Tool registry - the agent's narrow, project-scoped tool surface.
|
||||
*
|
||||
* Unlike a general "Claude Code", the Hub agent operates on a curriculum
|
||||
* engineering-file tree (ADR-0007) and reads Feishu context on demand
|
||||
* (ADR-0003). Its tools are therefore bounded:
|
||||
*
|
||||
* - file-tree read/write against the project workspace dir,
|
||||
* - `cph check` / `cph build` subprocess calls (the Courseware-side checker,
|
||||
* coupled only via the stable CLI contract ADR-0016),
|
||||
* - Feishu context reads, which must honor ADR-0003's authorization invariant.
|
||||
*
|
||||
* The AI SDK owns tool-call dispatch; this registry owns Hub-specific tool
|
||||
* construction and per-role whitelisting (ADR-0017).
|
||||
*/
|
||||
import { tool, type Tool } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
/** Per-run execution context closed over by every tool execute function. */
|
||||
export interface ToolContext {
|
||||
readonly runId: string;
|
||||
readonly projectId: string;
|
||||
/** ADR-0001: the chat this project is bound to. Feishu reads must stay in it. */
|
||||
readonly boundChatId: string;
|
||||
/** Absolute path to the project's curriculum engineering-file workspace. */
|
||||
readonly workspaceDir: string;
|
||||
}
|
||||
|
||||
export type ToolDefinition = Tool;
|
||||
export type ToolFactory = (ctx: ToolContext) => ToolDefinition;
|
||||
|
||||
export class ToolRegistry {
|
||||
private readonly factories = new Map<string, ToolFactory>();
|
||||
|
||||
register(name: string, factory: ToolFactory): void {
|
||||
if (this.factories.has(name)) {
|
||||
throw new Error(`duplicate tool: ${name}`);
|
||||
}
|
||||
this.factories.set(name, factory);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the AI SDK `tools` record. If `names` is given, include only those
|
||||
* tools (per-role whitelist, ADR-0017). Names not registered are silently
|
||||
* dropped because role config may name tools a Hub instance does not provide.
|
||||
*/
|
||||
build(ctx: ToolContext, names: readonly string[] | undefined): Record<string, ToolDefinition> {
|
||||
const built: Record<string, ToolDefinition> = {};
|
||||
const selected = names ?? [...this.factories.keys()];
|
||||
for (const name of selected) {
|
||||
const factory = this.factories.get(name);
|
||||
if (factory !== undefined) {
|
||||
built[name] = factory(ctx);
|
||||
}
|
||||
}
|
||||
return built;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a Feishu context read targets a chat other than the run's project's
|
||||
* bound chat. This is the runtime enforcement of ADR-0003's
|
||||
* `McpReadRequest.Authorized`: "Claude cannot pass arbitrary chat ids".
|
||||
*/
|
||||
export class UnauthorizedChatRead extends Error {
|
||||
constructor(
|
||||
readonly requestedChatId: string,
|
||||
readonly boundChatId: string,
|
||||
) {
|
||||
super(
|
||||
`refused Feishu context read: requested chat ${requestedChatId} is not the run's project's bound chat ${boundChatId} (ADR-0003)`,
|
||||
);
|
||||
this.name = "UnauthorizedChatRead";
|
||||
}
|
||||
}
|
||||
|
||||
/** Schema for the Feishu context read tool's arguments. */
|
||||
export interface FeishuContextArgs {
|
||||
readonly chat_id: string;
|
||||
readonly anchor: "trigger_message" | "status_card" | "reply" | "thread";
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Feishu context-read tool factory. The execute closure enforces
|
||||
* ADR-0003's invariant before any API call: the requested `chat_id` must equal
|
||||
* `ctx.boundChatId`.
|
||||
*
|
||||
* The actual Feishu API call (read message / reply chain / thread / recent
|
||||
* group messages) is injected as `read`, so the invariant check is separable
|
||||
* from the transport. In tests `read` can be a stub; in production it wraps
|
||||
* `@larksuiteoapi/node-sdk`.
|
||||
*/
|
||||
export function feishuContextTool(
|
||||
read: (args: FeishuContextArgs, ctx: ToolContext, rt: unknown) => Promise<string>,
|
||||
rt: unknown,
|
||||
): ToolFactory {
|
||||
return (ctx) =>
|
||||
tool({
|
||||
description:
|
||||
"Read on-demand Feishu context (a trigger message, status card, reply, or thread) for the current project's bound chat. The chat id must match the project binding.",
|
||||
inputSchema: z.object({
|
||||
chat_id: z.string().describe("The Feishu chat id to read from."),
|
||||
anchor: z.enum(["trigger_message", "status_card", "reply", "thread"]).describe("Which kind of anchor to read."),
|
||||
id: z.string().describe("The anchor id (message id or run id)."),
|
||||
}),
|
||||
execute: async (args): Promise<string> => {
|
||||
if (args.chat_id !== ctx.boundChatId) {
|
||||
throw new UnauthorizedChatRead(args.chat_id, ctx.boundChatId);
|
||||
}
|
||||
return read(args, ctx, rt);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Session transcript — append-only JSONL file in the workspace.
|
||||
*
|
||||
* Each line is one AI SDK {@link ModelMessage}, JSON-encoded. The transcript lives at
|
||||
* `workspaceDir/.cph/sessions/<sessionId>.jsonl`, following the same "workspace
|
||||
* is a directory tree" principle as the engineering file itself (ADR-0007).
|
||||
*
|
||||
* This mirrors how Claude Code stores its transcript (a `.jsonl` file per
|
||||
* session). The agent can read its own history via `read_file` — no special
|
||||
* "load history" code path. ADR-0002's lock guarantees one run per project at a
|
||||
* time, so the transcript is never concurrently written.
|
||||
*
|
||||
* ADR-0003: this stores **agent session messages** (prompt + response + tool
|
||||
* calls produced by the Hub), NOT Feishu group chat history. The two are
|
||||
* distinct; storing session messages does not conflict with "the Hub avoids
|
||||
* becoming a full chat history store."
|
||||
*/
|
||||
import { readFile, mkdir, appendFile } from "node:fs/promises";
|
||||
import { join, dirname } from "node:path";
|
||||
import type { ModelMessage } from "ai";
|
||||
|
||||
/// Subdirectory within a workspace where session transcripts live.
|
||||
export const TRANSCRIPT_DIR = ".cph/sessions";
|
||||
|
||||
/** Path for a session's transcript file within a workspace. */
|
||||
export function transcriptPath(workspaceDir: string, sessionId: string): string {
|
||||
return join(workspaceDir, TRANSCRIPT_DIR, `${sessionId}.jsonl`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read prior messages from a transcript file. Returns an empty array if the
|
||||
* file doesn't exist yet (first run in this session). Corrupt lines are
|
||||
* skipped — a partially-written last line (crash mid-append) won't break the
|
||||
* next run.
|
||||
*/
|
||||
export async function readTranscript(path: string): Promise<ModelMessage[]> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await readFile(path, "utf8");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const messages: ModelMessage[] = [];
|
||||
for (const line of content.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === "") continue;
|
||||
try {
|
||||
messages.push(JSON.parse(trimmed) as ModelMessage);
|
||||
} catch {
|
||||
// Skip corrupt line — likely a partial write from a crash.
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append messages to a transcript file. Creates the parent directory if needed.
|
||||
* Each message is written as one JSON line.
|
||||
*/
|
||||
export async function appendTranscript(path: string, messages: readonly ModelMessage[]): Promise<void> {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const lines = messages.map((m) => JSON.stringify(m)).join("\n") + "\n";
|
||||
await appendFile(path, lines, "utf8");
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Workspace file tools - bounded read/write/list against the project's
|
||||
* curriculum engineering-file tree (ADR-0007 directory tree).
|
||||
*
|
||||
* All paths are **confined to `ctx.workspaceDir`**: the handler resolves the
|
||||
* requested relative path against the workspace root and rejects any path that
|
||||
* escapes it (via `..` or absolute paths). This is the agent's only file
|
||||
* surface; the Hub agent is narrower than a general coding agent.
|
||||
*/
|
||||
import { readFile, writeFile, readdir, mkdir } from "node:fs/promises";
|
||||
import { join, resolve, relative, isAbsolute } from "node:path";
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
import type { ToolContext, ToolDefinition } from "./tools.js";
|
||||
|
||||
/** Thrown when a requested path escapes the project workspace root. */
|
||||
export class PathEscape extends Error {
|
||||
constructor(readonly requested: string, readonly workspaceDir: string) {
|
||||
super(`path escapes workspace: ${requested} (root ${workspaceDir})`);
|
||||
this.name = "PathEscape";
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a tool-supplied path against the workspace root, rejecting escapes. */
|
||||
function confine(requestedPath: string, workspaceDir: string): string {
|
||||
if (isAbsolute(requestedPath)) {
|
||||
// Allow absolute paths only if they're already inside the workspace.
|
||||
const rel = relative(workspaceDir, requestedPath);
|
||||
if (rel.startsWith("..") || rel === "") {
|
||||
throw new PathEscape(requestedPath, workspaceDir);
|
||||
}
|
||||
return requestedPath;
|
||||
}
|
||||
const resolved = resolve(workspaceDir, requestedPath);
|
||||
const rel = relative(workspaceDir, resolved);
|
||||
if (rel.startsWith("..")) {
|
||||
throw new PathEscape(requestedPath, workspaceDir);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function readFileTool(ctx: ToolContext): ToolDefinition {
|
||||
return tool({
|
||||
description: "Read a file from the project's curriculum engineering-file workspace. Path is relative to the workspace root.",
|
||||
inputSchema: z.object({
|
||||
path: z.string().describe("Relative path within the workspace."),
|
||||
}),
|
||||
execute: async (args): Promise<string> => {
|
||||
try {
|
||||
const full = confine(args.path, ctx.workspaceDir);
|
||||
return await readFile(full, "utf8");
|
||||
} catch (e) {
|
||||
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function writeFileTool(ctx: ToolContext): ToolDefinition {
|
||||
return tool({
|
||||
description:
|
||||
"Write a file in the project's curriculum engineering-file workspace. Path is relative to the workspace root. Creates parent directories.",
|
||||
inputSchema: z.object({
|
||||
path: z.string().describe("Relative path within the workspace."),
|
||||
content: z.string().describe("The full file content to write."),
|
||||
}),
|
||||
execute: async (args): Promise<string> => {
|
||||
try {
|
||||
const full = confine(args.path, ctx.workspaceDir);
|
||||
await mkdir(join(full, ".."), { recursive: true });
|
||||
await writeFile(full, args.content, "utf8");
|
||||
return JSON.stringify({ ok: true, path: args.path, bytes: args.content.length });
|
||||
} catch (e) {
|
||||
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
readonly name: string;
|
||||
readonly kind: "file" | "dir";
|
||||
}
|
||||
|
||||
export function listFilesTool(ctx: ToolContext): ToolDefinition {
|
||||
return tool({
|
||||
description: "List files and directories at a path within the workspace. Defaults to the workspace root.",
|
||||
inputSchema: z.object({
|
||||
path: z.string().describe("Relative path within the workspace; defaults to root.").optional(),
|
||||
}),
|
||||
execute: async (args): Promise<string> => {
|
||||
const sub = args.path ?? ".";
|
||||
try {
|
||||
const full = confine(sub, ctx.workspaceDir);
|
||||
const entries = await readdir(full, { withFileTypes: true });
|
||||
const result: Entry[] = entries.map((e) => ({
|
||||
name: e.name,
|
||||
kind: e.isDirectory() ? "dir" : "file",
|
||||
}));
|
||||
return JSON.stringify(result);
|
||||
} catch (e) {
|
||||
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Prisma client singleton.
|
||||
*
|
||||
* Prisma's client is a connection-pooled datasource; constructing it per-query
|
||||
* exhausts connections. This module exports one shared instance per process.
|
||||
* Hot-reload (tsx watch) can re-import this module — the global guard prevents
|
||||
* a second PrismaClient from being created.
|
||||
*/
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { __hubPrisma?: PrismaClient };
|
||||
|
||||
export const prisma: PrismaClient =
|
||||
globalForPrisma.__hubPrisma ??
|
||||
new PrismaClient({
|
||||
log: process.env["HUB_PRISMA_LOG"] !== undefined ? ["query", "error", "warn"] : ["error", "warn"],
|
||||
});
|
||||
|
||||
if (process.env["NODE_ENV"] !== "production") {
|
||||
globalForPrisma.__hubPrisma = prisma;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Feishu lark client + long-connection event dispatcher.
|
||||
*
|
||||
* Uses lark's WebSocket long-connection mode (no public callback endpoint). The
|
||||
* `EventDispatcher` registers `im.message.receive_v1`; the SDK delivers the raw
|
||||
* event to our handler. This version of the SDK does not auto-normalize on the
|
||||
* ws path — `normalize()` is a separate helper — so we work with the raw event
|
||||
* shape directly (it carries chat_id, message_type, content, mentions).
|
||||
*
|
||||
* ADR-0001: the bot operates inside a project's bound group. It does not own
|
||||
* the lock (ADR-0002) and is not the session owner.
|
||||
*/
|
||||
import * as lark from "@larksuiteoapi/node-sdk";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
|
||||
export interface FeishuConfig {
|
||||
readonly appId: string;
|
||||
readonly appSecret: string;
|
||||
readonly botOpenId: string;
|
||||
}
|
||||
|
||||
export interface FeishuRuntime {
|
||||
readonly client: lark.Client;
|
||||
readonly logger: FastifyBaseLogger;
|
||||
}
|
||||
|
||||
/** Raw `im.message.receive_v1` event payload (subset we use). */
|
||||
export interface MessageReceiveEvent {
|
||||
readonly message: {
|
||||
readonly message_id: string;
|
||||
readonly chat_id: string;
|
||||
readonly chat_type: string;
|
||||
readonly message_type: string;
|
||||
readonly content: string;
|
||||
readonly mentions?: ReadonlyArray<{
|
||||
readonly key: string;
|
||||
readonly id: { readonly open_id?: string };
|
||||
readonly name: string;
|
||||
}>;
|
||||
};
|
||||
readonly sender: {
|
||||
readonly sender_id: { readonly open_id?: string };
|
||||
readonly sender_type: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Send a text message to a chat. */
|
||||
export async function sendText(rt: FeishuRuntime, chatId: string, text: string): Promise<void> {
|
||||
// SDK exposes im.v1 dynamically at runtime; the generated types are weak
|
||||
// there, so cast through the known shape.
|
||||
const client = rt.client as unknown as {
|
||||
im: { v1: { message: { create: (p: unknown) => Promise<unknown> } } };
|
||||
};
|
||||
await client.im.v1.message.create({
|
||||
params: { receive_id_type: "chat_id" },
|
||||
data: {
|
||||
receive_id: chatId,
|
||||
msg_type: "text",
|
||||
content: JSON.stringify({ text }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Send an interactive card to a chat. `card` is the lark card schema object. */
|
||||
export async function sendCard(
|
||||
rt: FeishuRuntime,
|
||||
chatId: string,
|
||||
card: unknown,
|
||||
): Promise<string | null> {
|
||||
const client = rt.client as unknown as {
|
||||
im: { v1: { message: { create: (p: unknown) => Promise<{ data?: { message_id?: string } }> } } };
|
||||
};
|
||||
const res = await client.im.v1.message.create({
|
||||
params: { receive_id_type: "chat_id" },
|
||||
data: {
|
||||
receive_id: chatId,
|
||||
msg_type: "interactive",
|
||||
content: JSON.stringify(card),
|
||||
},
|
||||
});
|
||||
return res.data?.message_id ?? null;
|
||||
}
|
||||
|
||||
/** Build a run-status card: status text + model selector + cancel button. */
|
||||
export function buildRunStatusCard(opts: {
|
||||
readonly status: string;
|
||||
readonly model: string;
|
||||
readonly models: readonly { readonly id: string; readonly label: string }[];
|
||||
readonly runId: string;
|
||||
}): unknown {
|
||||
return {
|
||||
config: { wide_screen_mode: true },
|
||||
header: {
|
||||
title: { tag: "plain_text", content: `@Claude · ${opts.status}` },
|
||||
template: opts.status === "处理完成" ? "green" : opts.status === "处理出错" ? "red" : "blue",
|
||||
},
|
||||
elements: [
|
||||
{ tag: "div", text: { tag: "lark_md", content: `**model:** ${opts.model}` } },
|
||||
{
|
||||
tag: "action",
|
||||
actions: [
|
||||
{
|
||||
tag: "select",
|
||||
placeholder: { tag: "plain_text", content: "切换 model" },
|
||||
options: opts.models.map((m) => ({ text: { tag: "plain_text", content: m.label }, value: m.id })),
|
||||
value: opts.model,
|
||||
},
|
||||
{
|
||||
tag: "button",
|
||||
text: { tag: "plain_text", content: "取消" },
|
||||
type: "danger",
|
||||
value: JSON.stringify({ action: "cancel", runId: opts.runId }),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the lark WebSocket client and route `im.message.receive_v1` events to
|
||||
* `onMessage`. The ws connection lives in the background; this resolves the
|
||||
* runtime handle.
|
||||
*/
|
||||
/** Build a lark Client (HTTP API surface). Used before the ws starts, so tools
|
||||
* that need to call Feishu APIs can hold it from server boot. */
|
||||
export function createLarkClient(config: FeishuConfig): lark.Client {
|
||||
return new lark.Client({
|
||||
appId: config.appId,
|
||||
appSecret: config.appSecret,
|
||||
appType: lark.AppType.SelfBuild,
|
||||
});
|
||||
}
|
||||
|
||||
export interface FeishuListener {
|
||||
readonly runtime: FeishuRuntime;
|
||||
readonly stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Start the ws listener using an already-built client. Returns the runtime. */
|
||||
export function startFeishuListenerWithClient(
|
||||
config: FeishuConfig,
|
||||
client: lark.Client,
|
||||
logger: FastifyBaseLogger,
|
||||
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
|
||||
): FeishuRuntime {
|
||||
const wsClient = new lark.WSClient({
|
||||
appId: config.appId,
|
||||
appSecret: config.appSecret,
|
||||
domain: lark.Domain.Feishu,
|
||||
loggerLevel: lark.LoggerLevel.info,
|
||||
});
|
||||
const rt: FeishuRuntime = { client, logger };
|
||||
void wsClient.start({
|
||||
eventDispatcher: new lark.EventDispatcher({}).register({
|
||||
"im.message.receive_v1": async (data) => {
|
||||
try {
|
||||
await onMessage(data as MessageReceiveEvent, rt);
|
||||
} catch (e) {
|
||||
logger.error({ err: e }, "feishu message handler threw");
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
return rt;
|
||||
}
|
||||
export function startFeishuListener(
|
||||
config: FeishuConfig,
|
||||
logger: FastifyBaseLogger,
|
||||
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
|
||||
): FeishuRuntime {
|
||||
return startFeishuListenerWithClient(config, createLarkClient(config), logger, onMessage);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Feishu context reader — the real implementation behind `feishuContextTool`.
|
||||
*
|
||||
* ADR-0003: Hub stores only anchors, reads Feishu context on demand. The tool
|
||||
* (`feishu_read_context`) is already chat-authorized by `tools.ts` (chat must
|
||||
* match the project binding). Here we actually call lark's `im.v1.message`:
|
||||
*
|
||||
* - `trigger_message` / `reply`: `message.get` by message_id.
|
||||
* - `status_card`: the run's status card message — same `message.get` by id.
|
||||
* - `thread`: lark's thread replies. The SDK exposes `message.list` with a
|
||||
* `parent_message_id` filter; we map "thread" to that.
|
||||
*
|
||||
* The lark SDK's `im.v1.message` methods are dynamic at runtime (weak types);
|
||||
* we cast through a known request/response shape and return a compact JSON for
|
||||
* the model. All calls use the chat-authorized `client` from the runtime — no
|
||||
* separate credential path, so the ADR-0003 invariant holds end-to-end.
|
||||
*/
|
||||
import type { FeishuRuntime } from "./client.js";
|
||||
import type { FeishuContextArgs } from "../agent/tools.js";
|
||||
import type { ToolContext } from "../agent/tools.js";
|
||||
|
||||
/** Pragmatic shape of the lark message object we consume. */
|
||||
interface LarkMessage {
|
||||
message_id?: string;
|
||||
msg_type?: string;
|
||||
content?: string;
|
||||
create_time?: string;
|
||||
sender?: { id?: string; sender_type?: string };
|
||||
chat_id?: string;
|
||||
parent_message_id?: string;
|
||||
root_id?: string;
|
||||
}
|
||||
|
||||
interface ImV1Message {
|
||||
get: (p: { path: { message_id: string } }) => Promise<{ data?: { items?: LarkMessage[]; message?: LarkMessage } }>;
|
||||
list: (p: {
|
||||
params: { container_id_type: string; container_id: string; page_size?: number };
|
||||
}) => Promise<{ data?: { items?: LarkMessage[] } }>;
|
||||
}
|
||||
|
||||
function im(rt: FeishuRuntime): ImV1Message {
|
||||
return (rt.client as unknown as { im: { v1: { message: ImV1Message } } }).im.v1.message;
|
||||
}
|
||||
|
||||
/** Read on-demand Feishu context for the current run. Entry point for the tool. */
|
||||
export async function readFeishuContext(
|
||||
args: FeishuContextArgs,
|
||||
_ctx: ToolContext,
|
||||
rt: unknown,
|
||||
): Promise<string> {
|
||||
const runtime = rt as FeishuRuntime;
|
||||
const api = im(runtime);
|
||||
try {
|
||||
switch (args.anchor) {
|
||||
case "trigger_message":
|
||||
case "status_card":
|
||||
case "reply": {
|
||||
// All three are single-message reads by id.
|
||||
const res = await api.get({ path: { message_id: args.id } });
|
||||
const msg = res.data?.message ?? res.data?.items?.[0];
|
||||
if (msg === undefined) return JSON.stringify({ error: "message not found", id: args.id });
|
||||
return JSON.stringify(compact(msg));
|
||||
}
|
||||
case "thread": {
|
||||
// Thread = replies to a parent message. `container_id` is the parent's
|
||||
// message_id; container_id_type=message_id scopes the list to that thread.
|
||||
const res = await api.list({
|
||||
params: {
|
||||
container_id_type: "message_id",
|
||||
container_id: args.id,
|
||||
page_size: 50,
|
||||
},
|
||||
});
|
||||
const items = res.data?.items ?? [];
|
||||
return JSON.stringify(items.map(compact));
|
||||
}
|
||||
default:
|
||||
return JSON.stringify({ error: `unknown anchor type: ${args.anchor as string}` });
|
||||
}
|
||||
} catch (e) {
|
||||
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
/** Project a lark message down to the fields the model actually needs. */
|
||||
function compact(m: LarkMessage): Record<string, unknown> {
|
||||
return {
|
||||
message_id: m.message_id,
|
||||
msg_type: m.msg_type,
|
||||
content: m.content,
|
||||
create_time: m.create_time,
|
||||
sender_id: m.sender?.id,
|
||||
parent_message_id: m.parent_message_id,
|
||||
root_id: m.root_id,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* Feishu @bot trigger → AgentRun lifecycle.
|
||||
*
|
||||
* The core path (ADR-0001..0003, 0017):
|
||||
* 1. A message mentioning the bot arrives in a project group.
|
||||
* 2. Resolve the chat → project binding (ADR-0001). Unknown chat ⇒ ignored.
|
||||
* 3. Create an AgentRun + AgentSession (provider-bound, ADR-0017).
|
||||
* 4. Acquire the project lock for the run (ADR-0002). If locked, reply busy.
|
||||
* 5. Run the agent loop; the agent reads Feishu context on demand through the
|
||||
* ADR-0003-authorized tool (chat must match binding).
|
||||
* 6. On finish/fail/timeout: release the lock, post a status card.
|
||||
*
|
||||
* The agent model id comes from the ModelRegistry (ADR-0017 role-based
|
||||
* routing, role-as-data). The trigger does not pick a model directly.
|
||||
*/
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { sendText, sendCard, buildRunStatusCard, type FeishuRuntime, type MessageReceiveEvent } from "./client.js";
|
||||
import type { ToolRegistry } from "../agent/tools.js";
|
||||
import type { ModelRegistry } from "../agent/models.js";
|
||||
import { runAgent, type ModelFactory, type ProjectContext } from "../agent/runner.js";
|
||||
import { acquireLock, currentLockRunId, releaseLock } from "../lock.js";
|
||||
import { canTriggerAgent, canTriggerRole } from "../permission.js";
|
||||
import { transcriptPath } from "../agent/transcript.js";
|
||||
|
||||
interface TriggerDeps {
|
||||
readonly prisma: PrismaClient;
|
||||
readonly modelFactory: ModelFactory;
|
||||
readonly tools: ToolRegistry;
|
||||
readonly models: ModelRegistry;
|
||||
readonly logger: FastifyBaseLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the message handler. Returns a function suitable for the lark
|
||||
* `im.message.receive_v1` event. The handler is async and long-running (the
|
||||
* agent run is the long leg); the lock guarantees one run per project at a time.
|
||||
*/
|
||||
export function makeTriggerHandler(deps: TriggerDeps) {
|
||||
return async (event: MessageReceiveEvent, rt: FeishuRuntime): Promise<void> => {
|
||||
const msg = event.message;
|
||||
const sender = event.sender;
|
||||
void sender; // sender→user mapping is OPEN (principal sub-typology)
|
||||
|
||||
// Only react to text messages in group chats.
|
||||
if (msg.message_type !== "text") return;
|
||||
const chatId = msg.chat_id;
|
||||
if (chatId === "") return;
|
||||
|
||||
// ADR-0001: resolve chat → project. Unknown chat ⇒ not a project group.
|
||||
const binding = await deps.prisma.projectGroupBinding.findUnique({
|
||||
where: { chatId },
|
||||
select: { projectId: true },
|
||||
});
|
||||
if (binding === null) {
|
||||
deps.logger.debug({ chatId }, "feishu trigger: chat not bound to any project");
|
||||
return;
|
||||
}
|
||||
const projectId = binding.projectId;
|
||||
// ADR-0004: triggerAgent requires edit on the project. principal=sender
|
||||
// open_id (sub-typology OPEN — pragmatic choice, see permission.ts).
|
||||
const senderOpenId = event.sender.sender_id.open_id ?? "";
|
||||
if (senderOpenId !== "") {
|
||||
const perm = await canTriggerAgent(deps.prisma, projectId, senderOpenId);
|
||||
if (!perm.allowed) {
|
||||
deps.logger.info({ projectId, senderOpenId, reason: perm.reason }, "feishu trigger: permission denied");
|
||||
await sendText(rt, chatId, "无权限触发。");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const prompt = extractPrompt(msg);
|
||||
if (prompt === null) return;
|
||||
|
||||
// Slash commands: session management, not agent runs. These bypass the
|
||||
// lock — they don't create a run, so they can't conflict with one.
|
||||
if (prompt.startsWith("/")) {
|
||||
const cmd = prompt.split(/\s+/)[0];
|
||||
switch (cmd) {
|
||||
case "/new": {
|
||||
await deps.prisma.agentSession.updateMany({
|
||||
where: { projectId, archivedAt: null },
|
||||
data: { archivedAt: new Date() },
|
||||
});
|
||||
await sendText(rt, chatId, "已开新会话,下次 @bot 将从头开始。");
|
||||
return;
|
||||
}
|
||||
case "/resume": {
|
||||
const latest = await deps.prisma.agentSession.findFirst({
|
||||
where: { projectId, archivedAt: { not: null } },
|
||||
orderBy: { archivedAt: "desc" },
|
||||
select: { id: true },
|
||||
});
|
||||
if (latest === null) {
|
||||
await sendText(rt, chatId, "没有可恢复的会话。");
|
||||
return;
|
||||
}
|
||||
await deps.prisma.agentSession.update({
|
||||
where: { id: latest.id },
|
||||
data: { archivedAt: null },
|
||||
});
|
||||
await sendText(rt, chatId, "已恢复上一个会话。");
|
||||
return;
|
||||
}
|
||||
case "/reset": {
|
||||
await deps.prisma.agentSession.updateMany({
|
||||
where: { projectId, archivedAt: null },
|
||||
data: { archivedAt: new Date() },
|
||||
});
|
||||
await sendText(rt, chatId, "已重置,下次 @bot 将从头开始。");
|
||||
return;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ADR-0002: if locked by another run, reply busy.
|
||||
const existing = await currentLockRunId(deps.prisma, projectId);
|
||||
if (existing !== null) {
|
||||
await sendText(rt, chatId, "项目正在处理中,请稍候。");
|
||||
return;
|
||||
}
|
||||
|
||||
const project = await deps.prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: { workspaceDir: true },
|
||||
});
|
||||
if (project === null) {
|
||||
deps.logger.error({ projectId }, "feishu trigger: project missing");
|
||||
return;
|
||||
}
|
||||
|
||||
// ADR-0017: role-as-data. Parse a leading `/<role>` command; unknown
|
||||
// slashes are left as literal text (extractRole returns null). Falls back
|
||||
// to "draft" when no role is named — the registry degrades gracefully.
|
||||
const { roleId: parsedRole, prompt: cleanPrompt } = extractRole(prompt, deps.models);
|
||||
const roleId = parsedRole ?? "draft";
|
||||
// Per-role trigger gate (ADR-0017). Orthogonal to ADR-0004's
|
||||
// canTriggerAgent above: that checked "can trigger an agent at all";
|
||||
// this checks "can trigger *this* role". Unconfigured role ⇒ open.
|
||||
if (senderOpenId !== "") {
|
||||
const rolePerm = await canTriggerRole(deps.prisma, projectId, roleId, senderOpenId);
|
||||
if (!rolePerm.allowed) {
|
||||
deps.logger.info({ projectId, roleId, senderOpenId, reason: rolePerm.reason }, "feishu trigger: role permission denied");
|
||||
await sendText(rt, chatId, `无权限使用角色 ${roleId}。`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const role = deps.models.role(roleId);
|
||||
const model = deps.models.resolve(undefined, roleId);
|
||||
const providerId = "openrouter";
|
||||
// Per-role bundle (ADR-0017): systemPrompt seeds persona; tools whitelist
|
||||
// is enforced inside runAgent when it builds the SDK tools record. `tools:
|
||||
// undefined` means the full registered set (unrestricted role).
|
||||
const systemPrompt = role?.systemPrompt;
|
||||
|
||||
// ADR-0017: provider+model-bound session. Reuse if one exists for this
|
||||
// (project, provider, model) triple; otherwise create.
|
||||
const existingSession = await deps.prisma.agentSession.findFirst({
|
||||
where: { projectId, provider: providerId, model, archivedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
const session =
|
||||
existingSession !== null
|
||||
? await deps.prisma.agentSession.update({
|
||||
where: { id: existingSession.id },
|
||||
data: { provider: providerId, model, updatedAt: new Date() },
|
||||
select: { id: true },
|
||||
})
|
||||
: await deps.prisma.agentSession.create({
|
||||
data: {
|
||||
projectId,
|
||||
provider: providerId,
|
||||
model,
|
||||
title: cleanPrompt.slice(0, 40) || null,
|
||||
metadata: {},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const run = await deps.prisma.agentRun.create({
|
||||
data: {
|
||||
projectId,
|
||||
sessionId: session.id,
|
||||
requestedByUserId: null, // sender→user mapping is OPEN (principal sub-typology)
|
||||
entrypoint: "FEISHU",
|
||||
status: "ACTIVE",
|
||||
prompt: cleanPrompt,
|
||||
model,
|
||||
provider: providerId,
|
||||
metadata: { roleId },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// ADR-0002: acquire the project lock for this run.
|
||||
try {
|
||||
await acquireLock(deps.prisma, projectId, run.id, null);
|
||||
} catch {
|
||||
// Race: another run grabbed the lock between our check and create.
|
||||
await deps.prisma.agentRun.update({
|
||||
where: { id: run.id },
|
||||
data: { status: "FAILED", error: "lock race", finishedAt: new Date() },
|
||||
});
|
||||
await sendText(rt, chatId, "项目正在处理中,请稍候。");
|
||||
return;
|
||||
}
|
||||
|
||||
await sendText(rt, chatId, `已开始处理(role: ${roleId}, model: ${model})。`);
|
||||
|
||||
const projectCtx: ProjectContext = {
|
||||
projectId,
|
||||
boundChatId: chatId, // ADR-0003: Feishu reads stay in this chat
|
||||
workspaceDir: project.workspaceDir,
|
||||
};
|
||||
|
||||
// Run the agent — fire-and-forget from the ws handler's perspective.
|
||||
// Per-run tools + systemPrompt come from the role bundle (ADR-0017).
|
||||
runAgent(deps.modelFactory, deps.tools, {
|
||||
prompt: cleanPrompt,
|
||||
model,
|
||||
project: projectCtx,
|
||||
systemPrompt,
|
||||
toolWhitelist: role?.tools,
|
||||
transcriptPath: transcriptPath(project.workspaceDir, session.id),
|
||||
})
|
||||
.then(async (result) => {
|
||||
await deps.prisma.agentRun.update({
|
||||
where: { id: run.id },
|
||||
data: {
|
||||
status:
|
||||
result.status === "completed"
|
||||
? "COMPLETED"
|
||||
: result.status === "length"
|
||||
? "TIMED_OUT"
|
||||
: "FAILED",
|
||||
inputTokens: result.usage.inputTokens,
|
||||
outputTokens: result.usage.outputTokens,
|
||||
error: result.error ?? null,
|
||||
finishedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await sendCard(rt, chatId, buildRunStatusCard({
|
||||
status: statusReply(result.status),
|
||||
model,
|
||||
models: deps.models.listModels(),
|
||||
runId: run.id,
|
||||
}));
|
||||
})
|
||||
.catch(async (e) => {
|
||||
// The run may have been removed (admin force-delete, or test reset)
|
||||
// between creation and this callback. A P2025 from update is not
|
||||
// actionable here — log and still post the status card.
|
||||
try {
|
||||
await deps.prisma.agentRun.update({
|
||||
where: { id: run.id },
|
||||
data: { status: "FAILED", error: String(e), finishedAt: new Date() },
|
||||
});
|
||||
} catch (updateErr) {
|
||||
deps.logger.warn({ runId: run.id, err: String(updateErr) }, "trigger: could not mark run FAILED (already gone?)");
|
||||
}
|
||||
await sendCard(rt, chatId, buildRunStatusCard({
|
||||
status: "处理出错",
|
||||
model,
|
||||
models: deps.models.listModels(),
|
||||
runId: run.id,
|
||||
}));
|
||||
})
|
||||
.finally(async () => {
|
||||
await releaseLock(deps.prisma, run.id);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function statusReply(status: "completed" | "length" | "failed"): string {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "处理完成。";
|
||||
case "length":
|
||||
return "处理达到步数上限,已停止。";
|
||||
case "failed":
|
||||
return "处理出错。";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the prompt text from a text message, stripping @mentions.
|
||||
* Returns null if the message is empty after stripping or doesn't mention the bot.
|
||||
* Lark text content is JSON: `{"text":"@_user_1 do something"}`.
|
||||
*/
|
||||
export function extractPrompt(msg: MessageReceiveEvent["message"]): string | null {
|
||||
// Check for bot mention — mentions[].id.open_id should include the bot.
|
||||
// The dispatcher already routes only message events; we further require
|
||||
// that the bot is among the mentions (if no mentions, it's not @bot).
|
||||
const mentions = msg.mentions;
|
||||
if (mentions === undefined || mentions.length === 0) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(msg.content) as { text?: string };
|
||||
const text = parsed.text;
|
||||
if (typeof text !== "string") return null;
|
||||
// Strip @mentions (@_user_1 etc.) and trim; remainder is the prompt.
|
||||
const stripped = text.replace(/@_\w+\s*/g, "").trim();
|
||||
return stripped === "" ? null : stripped;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Parse a leading `/<role>` command from a prompt (e.g. `/draft 帮我写教案`).
|
||||
* Returns the role id and the remaining prompt with the command stripped.
|
||||
* Control commands already handled upstream (`/new`, `/resume`, `/reset`) are
|
||||
* ignored here — they never reach this function.
|
||||
*
|
||||
* Unknown `/foo` that isn't a registered role: returns `null` role and the
|
||||
* original prompt unchanged (the slash is treated as literal text). Role
|
||||
* resolution still happens via the registry's fallback.
|
||||
*/
|
||||
export function extractRole(
|
||||
prompt: string,
|
||||
registry: { role(id: string): unknown },
|
||||
): { roleId: string | null; prompt: string } {
|
||||
const m = /^\/(\S+)\s*/.exec(prompt);
|
||||
if (m === null || m[1] === undefined) return { roleId: null, prompt };
|
||||
const roleId = m[1];
|
||||
if (registry.role(roleId) === undefined) {
|
||||
// Unknown slash command — leave the prompt as-is (no silent role switch).
|
||||
return { roleId: null, prompt };
|
||||
}
|
||||
return { roleId, prompt: prompt.slice(m[0].length) };
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* ProjectAgentLock — ADR-0002 invariant enforcement at the DB layer.
|
||||
*
|
||||
* `projectId @id` gives at-most-one lock per project; `runId @unique` gives a
|
||||
* run holds at most one lock. Those are DB-level. The spec's `WellFormed`
|
||||
* invariant ("holder is a non-terminal run") is app-level: it must hold on
|
||||
* every read of the lock table. `assertLockHolderActive` enforces it on the
|
||||
* read path — if a lock exists but its run is terminal, that's a bug (the run
|
||||
* should have released it), surfaced as an error rather than silently trusted.
|
||||
*/
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { AgentRunStatus } from "@prisma/client";
|
||||
|
||||
/// spec RunState.Terminal: completed/failed/timedOut/canceled.
|
||||
/// active/waitingForUser are NOT terminal — the lock must still be held.
|
||||
const TERMINAL_STATUSES: ReadonlySet<AgentRunStatus> = new Set([
|
||||
"COMPLETED",
|
||||
"FAILED",
|
||||
"TIMED_OUT",
|
||||
"CANCELED",
|
||||
]);
|
||||
|
||||
export function isTerminal(status: AgentRunStatus): boolean {
|
||||
return TERMINAL_STATUSES.has(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire the project lock for a run. Throws if the project is already locked
|
||||
* by another run (ADR-0002 exclusivity). The unique runId constraint means a
|
||||
* run that already holds a lock elsewhere will also fail — that's correct, a
|
||||
* run scopes to one project.
|
||||
*/
|
||||
export async function acquireLock(
|
||||
prisma: PrismaClient,
|
||||
projectId: string,
|
||||
runId: string,
|
||||
holderUserId: string | null,
|
||||
): Promise<void> {
|
||||
await prisma.projectAgentLock.create({
|
||||
data: { projectId, runId, holderUserId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the lock owned by a run. Idempotent — a no-op if no lock exists.
|
||||
* Called on run completion/failure/timeout/cancellation (ADR-0002).
|
||||
*/
|
||||
export async function releaseLock(prisma: PrismaClient, runId: string): Promise<void> {
|
||||
await prisma.projectAgentLock.deleteMany({ where: { runId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* The spec's `LockTable.WellFormed` on a single read: if `projectId` has a
|
||||
* lock owned by `runId`, that run must be non-terminal. Throws if a terminal
|
||||
* run still holds a lock — that's a release-path bug, not a recoverable state.
|
||||
*
|
||||
* Returns the lock's runId if a healthy lock exists, or null if unlocked.
|
||||
*/
|
||||
export async function currentLockRunId(
|
||||
prisma: PrismaClient,
|
||||
projectId: string,
|
||||
): Promise<string | null> {
|
||||
const lock = await prisma.projectAgentLock.findUnique({
|
||||
where: { projectId },
|
||||
select: { runId: true },
|
||||
});
|
||||
if (lock === null) return null;
|
||||
|
||||
const run = await prisma.agentRun.findUnique({
|
||||
where: { id: lock.runId },
|
||||
select: { status: true },
|
||||
});
|
||||
if (run !== null && isTerminal(run.status)) {
|
||||
throw new Error(
|
||||
`lock invariant violated: project ${projectId} locked by terminal run ${lock.runId} (status ${run.status}) — release path bug (ADR-0002 WellFormed)`,
|
||||
);
|
||||
}
|
||||
return lock.runId;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Permission gate — ADR-0004 in code, project-scope.
|
||||
*
|
||||
* `triggerAgent` requires the `edit` capability (ADR-0004). `edit` is granted
|
||||
* by a `PermissionGrant` with role `edit` or `manage` (manage ⊇ edit, spec
|
||||
* `Role.can_mono`). This module checks that for the run's requester against
|
||||
* the project resource.
|
||||
*
|
||||
* Scope decision (project-wise): the resource is `project`, not
|
||||
* `project_group` — the run and lock scope to the project (ADR-0002), so the
|
||||
* capability is checked against the project. per-artifact permission is `OPEN`
|
||||
* pending a Courseware-side artifact id (ADR-0004 + ADR-0007 mapping).
|
||||
*
|
||||
* principal→user mapping is `OPEN` (ADR-0004 principal sub-typology). Here we
|
||||
* use the sender's Feishu open_id as the principal string literal — a pragmatic
|
||||
* choice for the first cut, not a spec decision. When a real principal model
|
||||
* lands, this is the single seam to change.
|
||||
*
|
||||
* settings.agentTrigger composition is `OPEN` (ADR-0004 separates role-derived
|
||||
* capabilities from settings policy knobs but doesn't pin the composition
|
||||
* rule). This gate implements the role half only; settings composition is
|
||||
* deferred and clearly marked.
|
||||
*/
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { PermissionRole } from "@prisma/client";
|
||||
|
||||
/// The roles that grant `edit` capability (edit itself + manage, by monotonicity).
|
||||
const EDIT_OR_HIGHER: ReadonlySet<PermissionRole> = new Set(["EDIT", "MANAGE"]);
|
||||
|
||||
export interface PermissionResult {
|
||||
readonly allowed: boolean;
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether `principal` may trigger an agent run on `projectId`.
|
||||
*
|
||||
* Returns `{allowed, reason}`. On allow, the caller proceeds; on deny, the
|
||||
* caller replies with the reason. Never throws — a DB error is a deny with the
|
||||
* error as reason, so the run is not started on a broken state.
|
||||
*/
|
||||
export async function canTriggerAgent(
|
||||
prisma: PrismaClient,
|
||||
projectId: string,
|
||||
principal: string,
|
||||
): Promise<PermissionResult> {
|
||||
try {
|
||||
// Look for an active (non-revoked) grant on the project resource for this
|
||||
// principal, with a role that grants edit or higher.
|
||||
const grant = await prisma.permissionGrant.findFirst({
|
||||
where: {
|
||||
resourceType: "PROJECT",
|
||||
resourceId: projectId,
|
||||
principal,
|
||||
role: { in: ["EDIT", "MANAGE"] },
|
||||
revokedAt: null,
|
||||
},
|
||||
select: { id: true, role: true },
|
||||
});
|
||||
if (grant !== null) {
|
||||
return { allowed: true, reason: `granted by role ${grant.role}` };
|
||||
}
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `no active edit+ grant for ${principal} on project ${projectId}`,
|
||||
};
|
||||
} catch (e) {
|
||||
return { allowed: false, reason: `permission check failed: ${e instanceof Error ? e.message : String(e)}` };
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Per-role trigger gate (ADR-0017: roles are product config). Orthogonal to
|
||||
* {@link canTriggerAgent}: that decides "can trigger an agent at all"
|
||||
* (ADR-0004 triggerAgent capability); this decides "can trigger *which* role".
|
||||
* Two gates in series — both must pass.
|
||||
*
|
||||
* Semantics: if **no** `RoleTriggerGrant` row exists for `(projectId, roleId)`
|
||||
* (regardless of principal), the role is unconfigured on this project →
|
||||
* **allow** (back-compat: a project that hasn't configured per-role grants
|
||||
* doesn't get locked down). Once any grant exists for that role on the
|
||||
* project, only principals with an active grant may trigger it. "Grants exist
|
||||
* but this principal has none" → deny (admin explicitly restricted the role).
|
||||
*
|
||||
* `roleId` matches `RoleEntry.id` (the slash-command name). `principal` is the
|
||||
* same opaque string ADR-0004 uses (sender open_id here; principal
|
||||
* sub-typology OPEN).
|
||||
*/
|
||||
export async function canTriggerRole(
|
||||
prisma: PrismaClient,
|
||||
projectId: string,
|
||||
roleId: string,
|
||||
principal: string,
|
||||
): Promise<PermissionResult> {
|
||||
try {
|
||||
// Any grant record (active OR revoked) for this (project, role)? If none,
|
||||
// the role is unconfigured → allow (back-compat: no per-role restriction).
|
||||
// A revoked grant still counts as "configured" — revoking one person must
|
||||
// not silently reopen the role to everyone.
|
||||
const anyGrant = await prisma.roleTriggerGrant.findFirst({
|
||||
where: { projectId, roleId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (anyGrant === null) {
|
||||
return { allowed: true, reason: `role ${roleId} unconfigured on project (open)` };
|
||||
}
|
||||
// Grants exist — this principal must hold one.
|
||||
const grant = await prisma.roleTriggerGrant.findFirst({
|
||||
where: { projectId, roleId, principal, revokedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (grant !== null) {
|
||||
return { allowed: true, reason: `granted role ${roleId}` };
|
||||
}
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `no active ${roleId} grant for ${principal} on project ${projectId}`,
|
||||
};
|
||||
} catch (e) {
|
||||
return { allowed: false, reason: `role permission check failed: ${e instanceof Error ? e.message : String(e)}` };
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a role grants edit capability (edit or manage). Exported for tests.
|
||||
export function roleGrantsEdit(role: PermissionRole): boolean {
|
||||
return EDIT_OR_HIGHER.has(role);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Hub entry — Fastify server + Feishu WebSocket listener.
|
||||
*
|
||||
* Wires the full trigger path: lark ws receives `im.message.receive_v1` →
|
||||
* trigger handler resolves chat->project (ADR-0001), creates AgentRun + acquires
|
||||
* the project lock (ADR-0002), runs the AI SDK-backed agent runner
|
||||
* (ADR-0017), and releases the lock on finish. Feishu context reads inside the
|
||||
* loop are ADR-0003-authorized (chat must match binding).
|
||||
*
|
||||
* Env (see .env.example):
|
||||
* DATABASE_URL, OPENROUTER_API_KEY, FEISHU_APP_ID/SECRET/BOT_OPEN_ID, PORT
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import Fastify from "fastify";
|
||||
import { prisma } from "./db.js";
|
||||
import { createModelFactory } from "./agent/provider.js";
|
||||
import { InMemoryModelRegistry } from "./agent/models.js";
|
||||
import { ToolRegistry, feishuContextTool } from "./agent/tools.js";
|
||||
import { readFileTool, writeFileTool, listFilesTool } from "./agent/workspace.js";
|
||||
import { cphCheckTool, cphBuildTool } from "./agent/cph.js";
|
||||
import { createLarkClient, startFeishuListenerWithClient, type FeishuRuntime } from "./feishu/client.js";
|
||||
import { readFeishuContext } from "./feishu/read.js";
|
||||
import { makeTriggerHandler } from "./feishu/trigger.js";
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const v = process.env[name];
|
||||
if (v === undefined || v === "") {
|
||||
console.error(`[hub] missing required env: ${name}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const databaseUrl = requireEnv("DATABASE_URL");
|
||||
void databaseUrl; // prisma reads DATABASE_URL from env directly
|
||||
requireEnv("OPENROUTER_API_KEY");
|
||||
const feishuAppId = requireEnv("FEISHU_APP_ID");
|
||||
const feishuAppSecret = requireEnv("FEISHU_APP_SECRET");
|
||||
const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID");
|
||||
const port = Number(process.env["PORT"] ?? "8788");
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
app.get("/api/healthz", async () => ({ ok: true, ts: Date.now() }));
|
||||
|
||||
// --- Agent seam wiring ---
|
||||
const modelFactory = createModelFactory();
|
||||
const models = new InMemoryModelRegistry(
|
||||
[
|
||||
{ id: "z-ai/glm-4.6", label: "GLM-4.6", toolCapable: true },
|
||||
{ id: "anthropic/claude-3.5-sonnet", label: "Claude 3.5 Sonnet", toolCapable: true },
|
||||
],
|
||||
[
|
||||
{
|
||||
id: "draft",
|
||||
label: "草稿",
|
||||
defaultModel: "z-ai/glm-4.6",
|
||||
systemPrompt: undefined,
|
||||
// Drafting needs full workspace + cph access.
|
||||
tools: ["read_file", "write_file", "list_files", "cph_check", "cph_build", "feishu_read_context"],
|
||||
},
|
||||
{
|
||||
id: "review",
|
||||
label: "审校",
|
||||
defaultModel: "anthropic/claude-3.5-sonnet",
|
||||
systemPrompt: undefined,
|
||||
// Review is read-only on artifacts; no write_file, no build.
|
||||
tools: ["read_file", "list_files", "cph_check", "feishu_read_context"],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
// Build the lark client first — tools that call Feishu APIs hold it from boot.
|
||||
const feishuConfig = { appId: feishuAppId, appSecret: feishuAppSecret, botOpenId: feishuBotOpenId };
|
||||
const larkClient = createLarkClient(feishuConfig);
|
||||
const feishuRt: FeishuRuntime = { client: larkClient, logger: app.log };
|
||||
|
||||
const tools = new ToolRegistry();
|
||||
// ADR-0003: the handler enforces chat==boundChat before any API call;
|
||||
// real read wraps @larksuiteoapi/node-sdk's im.v1.message get/list.
|
||||
tools.register("feishu_read_context", feishuContextTool(readFeishuContext, feishuRt));
|
||||
// Workspace file tools (ADR-0007 directory tree, path-confined).
|
||||
tools.register("read_file", readFileTool);
|
||||
tools.register("write_file", writeFileTool);
|
||||
tools.register("list_files", listFilesTool);
|
||||
// cph CLI tools (ADR-0016 version contract enforced by cph itself).
|
||||
tools.register("cph_check", cphCheckTool);
|
||||
tools.register("cph_build", cphBuildTool);
|
||||
|
||||
// --- Feishu listener (reuses the same lark client) ---
|
||||
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: app.log });
|
||||
startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger);
|
||||
|
||||
app.listen({ port, host: "0.0.0.0" }, (err, address) => {
|
||||
if (err !== null) {
|
||||
app.log.error({ err }, "listen failed");
|
||||
process.exit(1);
|
||||
}
|
||||
app.log.info({ address }, "hub listening");
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("[hub] fatal:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { cp, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ToolRegistry, type ToolContext } from "../../src/agent/tools.js";
|
||||
import { cphCheckTool, cphBuildTool } from "../../src/agent/cph.js";
|
||||
|
||||
const EXAMPLES_DIR = join(process.cwd(), "..", "examples", "TH-141");
|
||||
|
||||
describe("cph subprocess tools (integration, real cph binary)", () => {
|
||||
let ws: string;
|
||||
let tools: ToolRegistry;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Use a writable copy of the real TH-141 example as the workspace.
|
||||
ws = await mkdtemp(join(tmpdir(), "hub-cph-example-"));
|
||||
await cp(EXAMPLES_DIR, ws, { recursive: true });
|
||||
tools = new ToolRegistry();
|
||||
tools.register("cph_check", cphCheckTool);
|
||||
tools.register("cph_build", cphBuildTool);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(ws, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("cph_check runs on the TH-141 example and returns diagnostics", async () => {
|
||||
const ctx = { runId: "r", projectId: "p", boundChatId: "c", workspaceDir: ws };
|
||||
const out = await executeTool(tools, "cph_check", {}, ctx);
|
||||
const result = JSON.parse(out) as { exitCode: number; stdout: string; stderr: string };
|
||||
|
||||
// cph check exits 0 on a legal lesson (no error diagnostics, ADR-0010).
|
||||
// If the example has warnings, exit is still 0.
|
||||
expect(result.exitCode).toBe(0);
|
||||
}, 30000);
|
||||
|
||||
it("cph_build renders the student target PDF", async () => {
|
||||
const ctx = { runId: "r", projectId: "p", boundChatId: "c", workspaceDir: ws };
|
||||
const out = await executeTool(tools, "cph_build", { target: "student", output: "build/test-student.pdf" }, ctx);
|
||||
const result = JSON.parse(out) as { exitCode: number; stdout: string; stderr: string; output: string };
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
}, 30000);
|
||||
|
||||
it("cph_check on a temp dir with no engineering file returns non-zero", async () => {
|
||||
const empty = await mkdtemp(join(tmpdir(), "hub-cph-empty-"));
|
||||
try {
|
||||
const ctx = { runId: "r", projectId: "p", boundChatId: "c", workspaceDir: empty };
|
||||
const out = await executeTool(tools, "cph_check", {}, ctx);
|
||||
const result = JSON.parse(out) as { exitCode: number };
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
} finally {
|
||||
await rm(empty, { recursive: true, force: true });
|
||||
}
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
async function executeTool(tools: ToolRegistry, name: string, args: unknown, ctx: ToolContext): Promise<string> {
|
||||
const def = tools.build(ctx)[name];
|
||||
if (def?.execute === undefined) {
|
||||
throw new Error(`missing executable tool: ${name}`);
|
||||
}
|
||||
return def.execute(args, { toolCallId: "call", messages: [], context: undefined }) as Promise<string>;
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Test helpers for integration tests.
|
||||
*
|
||||
* Each test gets a clean DB (tables truncated before the test), a mock
|
||||
* FeishuRuntime (sendText/sendCard are no-ops that record calls), and a mock
|
||||
* AI SDK model factory (doGenerate() returns canned responses - no network).
|
||||
*/
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type {
|
||||
LanguageModelV4,
|
||||
LanguageModelV4CallOptions,
|
||||
LanguageModelV4Content,
|
||||
LanguageModelV4GenerateResult,
|
||||
LanguageModelV4StreamResult,
|
||||
} from "@ai-sdk/provider";
|
||||
import type { FeishuRuntime } from "../../src/feishu/client.js";
|
||||
import type { ModelFactory } from "../../src/agent/runner.js";
|
||||
|
||||
export const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
|
||||
|
||||
export const prisma = new PrismaClient({
|
||||
datasources: { db: { url: TEST_DATABASE_URL } },
|
||||
});
|
||||
|
||||
/** Truncate all tables before each test for isolation. */
|
||||
export async function resetDb(): Promise<void> {
|
||||
const tables = [
|
||||
"AuditEntry",
|
||||
"PermissionSettings",
|
||||
"PermissionGrant",
|
||||
"RoleTriggerGrant",
|
||||
"ProjectAgentLock",
|
||||
"AgentRun",
|
||||
"AgentSession",
|
||||
"ProjectGroupBinding",
|
||||
"PlatformRoleAssignment",
|
||||
"User",
|
||||
"Project",
|
||||
];
|
||||
// Truncate with CASCADE to wipe dependent rows in one shot.
|
||||
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tables.map((t) => `"${t}"`).join(", ")} RESTART IDENTITY CASCADE`);
|
||||
}
|
||||
|
||||
/** A logger that discards everything (tests don't need fastify's pino). */
|
||||
export const silentLogger: FastifyBaseLogger = {
|
||||
info() {}, warn() {}, error() {}, debug() {}, fatal() {},
|
||||
child() { return this; },
|
||||
level: "silent",
|
||||
} as unknown as FastifyBaseLogger;
|
||||
|
||||
/** Records sendText/sendCard calls so tests can assert on them. */
|
||||
export interface MockFeishuRuntime extends FeishuRuntime {
|
||||
readonly sentTexts: string[];
|
||||
readonly sentCards: unknown[];
|
||||
}
|
||||
|
||||
export function mockFeishuRuntime(): MockFeishuRuntime {
|
||||
const sentTexts: string[] = [];
|
||||
const sentCards: unknown[] = [];
|
||||
// Mock the client — sendText/sendCard are in client.ts, not on the runtime
|
||||
// object directly. We patch by providing a runtime whose client is a stub;
|
||||
// the actual send functions cast through shape, so a minimal stub works.
|
||||
const rt: MockFeishuRuntime = {
|
||||
client: {
|
||||
im: {
|
||||
v1: {
|
||||
message: {
|
||||
create: async (p: unknown) => {
|
||||
const payload = p as { data?: { msg_type?: string; content?: string } };
|
||||
if (payload.data?.msg_type === "interactive") {
|
||||
sentCards.push(payload.data.content ? JSON.parse(payload.data.content) : null);
|
||||
} else {
|
||||
try {
|
||||
const c = JSON.parse(payload.data?.content ?? "{}") as { text?: string };
|
||||
sentTexts.push(c.text ?? payload.data?.content ?? "");
|
||||
} catch {
|
||||
sentTexts.push(payload.data?.content ?? "");
|
||||
}
|
||||
}
|
||||
return { data: { message_id: "mock-msg-id" } };
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as FeishuRuntime["client"],
|
||||
logger: silentLogger,
|
||||
sentTexts,
|
||||
sentCards,
|
||||
};
|
||||
return rt;
|
||||
}
|
||||
|
||||
export interface MockToolCall {
|
||||
readonly toolCallId: string;
|
||||
readonly toolName: string;
|
||||
readonly input: unknown;
|
||||
}
|
||||
|
||||
export interface MockModelResponse {
|
||||
readonly text?: string;
|
||||
readonly toolCalls?: readonly MockToolCall[];
|
||||
readonly finishReason?: "stop" | "length" | "content-filter" | "tool-calls" | "error" | "other";
|
||||
readonly inputTokens?: number;
|
||||
readonly outputTokens?: number;
|
||||
}
|
||||
|
||||
export class MockLanguageModel implements LanguageModelV4 {
|
||||
readonly specificationVersion = "v4";
|
||||
readonly provider = "mock";
|
||||
readonly supportedUrls: Record<string, RegExp[]> = {};
|
||||
readonly calls: LanguageModelV4CallOptions[] = [];
|
||||
|
||||
constructor(
|
||||
readonly modelId: string,
|
||||
private readonly responses: readonly MockModelResponse[] = [{ text: "mock response" }],
|
||||
) {}
|
||||
|
||||
async doGenerate(options: LanguageModelV4CallOptions): Promise<LanguageModelV4GenerateResult> {
|
||||
this.calls.push(options);
|
||||
const configured = this.responses[this.calls.length - 1];
|
||||
const last = this.responses[this.responses.length - 1];
|
||||
const response =
|
||||
configured ??
|
||||
((last?.toolCalls?.length ?? 0) > 0
|
||||
? { text: "mock response" }
|
||||
: last ?? { text: "mock response" });
|
||||
const content: LanguageModelV4Content[] = [];
|
||||
if (response.text !== undefined) {
|
||||
content.push({ type: "text", text: response.text });
|
||||
}
|
||||
for (const call of response.toolCalls ?? []) {
|
||||
content.push({
|
||||
type: "tool-call",
|
||||
toolCallId: call.toolCallId,
|
||||
toolName: call.toolName,
|
||||
input: JSON.stringify(call.input),
|
||||
});
|
||||
}
|
||||
const finishReason = response.finishReason ?? ((response.toolCalls?.length ?? 0) > 0 ? "tool-calls" : "stop");
|
||||
return {
|
||||
content,
|
||||
finishReason: { unified: finishReason, raw: finishReason },
|
||||
usage: {
|
||||
inputTokens: { total: response.inputTokens ?? 10, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },
|
||||
outputTokens: { total: response.outputTokens ?? 5, text: undefined, reasoning: undefined },
|
||||
},
|
||||
response: { id: `mock-${this.calls.length}`, timestamp: new Date(), modelId: this.modelId },
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
async doStream(_options: LanguageModelV4CallOptions): Promise<LanguageModelV4StreamResult> {
|
||||
throw new Error("MockLanguageModel does not implement streaming");
|
||||
}
|
||||
}
|
||||
|
||||
export function createMockModelFactory(
|
||||
responses: readonly MockModelResponse[] = [{ text: "mock response" }],
|
||||
): { readonly modelFactory: ModelFactory; readonly models: ReadonlyMap<string, MockLanguageModel> } {
|
||||
const models = new Map<string, MockLanguageModel>();
|
||||
return {
|
||||
models,
|
||||
modelFactory: (modelId) => {
|
||||
const model = new MockLanguageModel(modelId, responses);
|
||||
models.set(modelId, model);
|
||||
return model;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a project + binding + user + grant for a test. */
|
||||
export async function seedProject(
|
||||
projectId: string,
|
||||
chatId: string,
|
||||
options: { principal?: string; role?: "READ" | "EDIT" | "MANAGE" } = {},
|
||||
): Promise<void> {
|
||||
const principal = options.principal ?? "ou_test_user";
|
||||
const role = options.role ?? "EDIT";
|
||||
await prisma.project.create({
|
||||
data: {
|
||||
id: projectId,
|
||||
name: `Test ${projectId}`,
|
||||
workspaceDir: `/tmp/test-${projectId}`,
|
||||
},
|
||||
});
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: "u_" + projectId,
|
||||
feishuOpenId: principal,
|
||||
displayName: "Test User",
|
||||
platformRoles: { create: { role: "TEACHER" } },
|
||||
permissionGrants: {
|
||||
create: {
|
||||
resourceType: "PROJECT",
|
||||
resourceId: projectId,
|
||||
principal,
|
||||
role,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await prisma.projectGroupBinding.create({
|
||||
data: { projectId, chatId, createdByUserId: "u_" + projectId },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, beforeEach, afterAll } from "vitest";
|
||||
import { prisma, resetDb } from "./helpers.js";
|
||||
import { acquireLock, releaseLock, currentLockRunId, isTerminal } from "../../src/lock.js";
|
||||
|
||||
describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => {
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
});
|
||||
|
||||
it("acquireLock + currentLockRunId round-trip", async () => {
|
||||
const project = await prisma.project.create({
|
||||
data: { id: "p-lock-1", name: "Test", workspaceDir: "/tmp/x" },
|
||||
});
|
||||
const run = await prisma.agentRun.create({
|
||||
data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} },
|
||||
});
|
||||
|
||||
await acquireLock(prisma, project.id, run.id, null);
|
||||
const holder = await currentLockRunId(prisma, project.id);
|
||||
expect(holder).toBe(run.id);
|
||||
|
||||
await releaseLock(prisma, run.id);
|
||||
const after = await currentLockRunId(prisma, project.id);
|
||||
expect(after).toBeNull();
|
||||
});
|
||||
|
||||
it("acquireLock fails when project already locked (exclusivity)", async () => {
|
||||
const project = await prisma.project.create({
|
||||
data: { id: "p-lock-2", name: "Test", workspaceDir: "/tmp/x" },
|
||||
});
|
||||
const run1 = await prisma.agentRun.create({
|
||||
data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} },
|
||||
});
|
||||
const run2 = await prisma.agentRun.create({
|
||||
data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "y", model: "m", provider: "mock", metadata: {} },
|
||||
});
|
||||
|
||||
await acquireLock(prisma, project.id, run1.id, null);
|
||||
await expect(acquireLock(prisma, project.id, run2.id, null)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("currentLockRunId throws when a terminal run holds the lock (WellFormed)", async () => {
|
||||
const project = await prisma.project.create({
|
||||
data: { id: "p-lock-3", name: "Test", workspaceDir: "/tmp/x" },
|
||||
});
|
||||
// Create a run that is already COMPLETED (terminal), then manually insert a lock.
|
||||
const run = await prisma.agentRun.create({
|
||||
data: { projectId: project.id, entrypoint: "FEISHU", status: "COMPLETED", prompt: "x", model: "m", provider: "mock", metadata: {}, finishedAt: new Date() },
|
||||
});
|
||||
await prisma.projectAgentLock.create({
|
||||
data: { projectId: project.id, runId: run.id },
|
||||
});
|
||||
|
||||
// WellFormed invariant: reading a lock held by a terminal run must throw.
|
||||
await expect(currentLockRunId(prisma, project.id)).rejects.toThrow(/lock invariant violated/);
|
||||
});
|
||||
|
||||
it("releaseLock is idempotent", async () => {
|
||||
const project = await prisma.project.create({
|
||||
data: { id: "p-lock-4", name: "Test", workspaceDir: "/tmp/x" },
|
||||
});
|
||||
const run = await prisma.agentRun.create({
|
||||
data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} },
|
||||
});
|
||||
|
||||
await acquireLock(prisma, project.id, run.id, null);
|
||||
await releaseLock(prisma, run.id);
|
||||
// Second release is a no-op.
|
||||
await releaseLock(prisma, run.id);
|
||||
expect(await currentLockRunId(prisma, project.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("isTerminal matches spec RunState.Terminal", () => {
|
||||
expect(isTerminal("ACTIVE")).toBe(false);
|
||||
expect(isTerminal("WAITING_FOR_USER")).toBe(false);
|
||||
expect(isTerminal("COMPLETED")).toBe(true);
|
||||
expect(isTerminal("FAILED")).toBe(true);
|
||||
expect(isTerminal("TIMED_OUT")).toBe(true);
|
||||
expect(isTerminal("CANCELED")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, beforeEach, afterAll } from "vitest";
|
||||
import { prisma, resetDb } from "./helpers.js";
|
||||
import { canTriggerRole } from "../../src/permission.js";
|
||||
|
||||
describe("canTriggerRole (integration, per-role gate)", () => {
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it("allows when role is unconfigured on the project (back-compat: open)", async () => {
|
||||
const project = await prisma.project.create({
|
||||
data: { id: "p-role-1", name: "T", workspaceDir: "/tmp/x" },
|
||||
});
|
||||
const r = await canTriggerRole(prisma, project.id, "draft", "ou_a");
|
||||
expect(r.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("allows when principal holds an active grant for the role", async () => {
|
||||
const project = await prisma.project.create({
|
||||
data: { id: "p-role-2", name: "T", workspaceDir: "/tmp/x" },
|
||||
});
|
||||
await prisma.roleTriggerGrant.create({
|
||||
data: { projectId: project.id, roleId: "review", principal: "ou_a" },
|
||||
});
|
||||
const r = await canTriggerRole(prisma, project.id, "review", "ou_a");
|
||||
expect(r.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("denies when grants exist for the role but principal has none", async () => {
|
||||
const project = await prisma.project.create({
|
||||
data: { id: "p-role-3", name: "T", workspaceDir: "/tmp/x" },
|
||||
});
|
||||
// Someone else has the role; ou_b does not.
|
||||
await prisma.roleTriggerGrant.create({
|
||||
data: { projectId: project.id, roleId: "review", principal: "ou_a" },
|
||||
});
|
||||
const r = await canTriggerRole(prisma, project.id, "review", "ou_b");
|
||||
expect(r.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("denies when the principal's grant was revoked", async () => {
|
||||
const project = await prisma.project.create({
|
||||
data: { id: "p-role-4", name: "T", workspaceDir: "/tmp/x" },
|
||||
});
|
||||
await prisma.roleTriggerGrant.create({
|
||||
data: { projectId: project.id, roleId: "review", principal: "ou_a", revokedAt: new Date() },
|
||||
});
|
||||
const r = await canTriggerRole(prisma, project.id, "review", "ou_a");
|
||||
expect(r.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("scopes grants per-project (grant on p1 does not allow on p2)", async () => {
|
||||
const p1 = await prisma.project.create({ data: { id: "p-role-5a", name: "T", workspaceDir: "/tmp/x" } });
|
||||
const p2 = await prisma.project.create({ data: { id: "p-role-5b", name: "T", workspaceDir: "/tmp/x" } });
|
||||
await prisma.roleTriggerGrant.create({
|
||||
data: { projectId: p1.id, roleId: "review", principal: "ou_a" },
|
||||
});
|
||||
// p2 has the role configured for someone else; ou_a has no grant there.
|
||||
await prisma.roleTriggerGrant.create({
|
||||
data: { projectId: p2.id, roleId: "review", principal: "ou_other" },
|
||||
});
|
||||
const onP1 = await canTriggerRole(prisma, p1.id, "review", "ou_a");
|
||||
expect(onP1.allowed).toBe(true);
|
||||
const onP2 = await canTriggerRole(prisma, p2.id, "review", "ou_a");
|
||||
expect(onP2.allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import { describe, it, expect, beforeEach, afterAll, vi } from "vitest";
|
||||
import { prisma, resetDb, mockFeishuRuntime, createMockModelFactory, seedProject, silentLogger } from "./helpers.js";
|
||||
import { InMemoryModelRegistry } from "../../src/agent/models.js";
|
||||
import { ToolRegistry } from "../../src/agent/tools.js";
|
||||
import { makeTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
|
||||
import type { MessageReceiveEvent } from "../../src/feishu/client.js";
|
||||
import type { ModelFactory } from "../../src/agent/runner.js";
|
||||
|
||||
const bot = { key: "@_user_1", id: { open_id: "ou_bot" }, name: "Bot" };
|
||||
|
||||
function makeEvent(chatId: string, text: string, senderOpenId = "ou_test_user"): MessageReceiveEvent {
|
||||
return {
|
||||
message: {
|
||||
message_id: "m_" + Math.random().toString(36).slice(2),
|
||||
chat_id: chatId,
|
||||
chat_type: "group",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text }),
|
||||
mentions: [bot],
|
||||
},
|
||||
sender: { sender_id: { open_id: senderOpenId }, sender_type: "user" },
|
||||
};
|
||||
}
|
||||
|
||||
describe("trigger full lifecycle (integration)", () => {
|
||||
let modelFactory: ModelFactory;
|
||||
let tools: ToolRegistry;
|
||||
let models: InMemoryModelRegistry;
|
||||
let rt: ReturnType<typeof mockFeishuRuntime>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
modelFactory = createMockModelFactory().modelFactory;
|
||||
tools = new ToolRegistry();
|
||||
models = new InMemoryModelRegistry(
|
||||
[{ id: "mock-model", label: "Mock", toolCapable: true }],
|
||||
[
|
||||
{ id: "draft", label: "草稿", defaultModel: "mock-model", systemPrompt: undefined, tools: undefined },
|
||||
{ id: "review", label: "审校", defaultModel: "mock-model", systemPrompt: undefined, tools: ["read_file"] },
|
||||
],
|
||||
);
|
||||
rt = mockFeishuRuntime();
|
||||
});
|
||||
|
||||
it("creates a run, acquires + releases the lock, sends status card", async () => {
|
||||
await seedProject("proj-1", "chat-1");
|
||||
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
|
||||
|
||||
await trigger(makeEvent("chat-1", "@_user_1 写教案"), rt);
|
||||
|
||||
// Wait for the async run to complete (fire-and-forget in trigger).
|
||||
await vi.waitFor(async () => {
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]?.status).toBe("COMPLETED");
|
||||
});
|
||||
|
||||
// Lock released (no lock row remains).
|
||||
const locks = await prisma.projectAgentLock.findMany();
|
||||
expect(locks).toHaveLength(0);
|
||||
|
||||
// A status card was sent.
|
||||
expect(rt.sentCards.length).toBeGreaterThanOrEqual(1);
|
||||
expect(rt.sentTexts).toContain("已开始处理(role: draft, model: mock-model)。");
|
||||
});
|
||||
|
||||
it("rejects a sender without edit grant (ADR-0004)", async () => {
|
||||
await seedProject("proj-2", "chat-2", { role: "READ" });
|
||||
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
|
||||
|
||||
await trigger(makeEvent("chat-2", "@_user_1 写教案"), rt);
|
||||
|
||||
expect(rt.sentTexts).toContain("无权限触发。");
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("replies busy when project is already locked (ADR-0002)", async () => {
|
||||
await seedProject("proj-3", "chat-3");
|
||||
// Manually create a lock by inserting a run + lock.
|
||||
const existingRun = await prisma.agentRun.create({
|
||||
data: { projectId: "proj-3", entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} },
|
||||
});
|
||||
await prisma.projectAgentLock.create({
|
||||
data: { projectId: "proj-3", runId: existingRun.id },
|
||||
});
|
||||
|
||||
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
|
||||
await trigger(makeEvent("chat-3", "@_user_1 写教案"), rt);
|
||||
|
||||
expect(rt.sentTexts).toContain("项目正在处理中,请稍候。");
|
||||
// No new run created.
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ignores messages from unbound chats (ADR-0001)", async () => {
|
||||
await seedProject("proj-4", "chat-4");
|
||||
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
|
||||
|
||||
await trigger(makeEvent("chat-UNKNOWN", "@_user_1 写教案"), rt);
|
||||
|
||||
expect(rt.sentTexts).toHaveLength(0);
|
||||
expect(rt.sentCards).toHaveLength(0);
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores messages without @bot mention", async () => {
|
||||
await seedProject("proj-5", "chat-5");
|
||||
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
|
||||
|
||||
const event: MessageReceiveEvent = {
|
||||
message: {
|
||||
message_id: "m_nobot",
|
||||
chat_id: "chat-5",
|
||||
chat_type: "group",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "hello no bot" }),
|
||||
mentions: undefined,
|
||||
},
|
||||
sender: { sender_id: { open_id: "ou_test_user" }, sender_type: "user" },
|
||||
};
|
||||
await trigger(event, rt);
|
||||
|
||||
expect(rt.sentTexts).toHaveLength(0);
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("/new archives current session (no run created)", async () => {
|
||||
await seedProject("proj-6", "chat-6");
|
||||
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
|
||||
|
||||
// First @bot creates a session + run.
|
||||
await trigger(makeEvent("chat-6", "@_user_1 写教案"), rt);
|
||||
await vi.waitFor(async () => {
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]?.status).toBe("COMPLETED");
|
||||
});
|
||||
|
||||
// /new archives the session.
|
||||
await trigger(makeEvent("chat-6", "@_user_1 /new"), rt);
|
||||
expect(rt.sentTexts).toContain("已开新会话,下次 @bot 将从头开始。");
|
||||
|
||||
const sessions = await prisma.agentSession.findMany();
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0]?.archivedAt).not.toBeNull();
|
||||
// No new run created for /new.
|
||||
expect(await prisma.agentRun.findMany()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("/resume un-archives the most recent session", async () => {
|
||||
await seedProject("proj-7", "chat-7");
|
||||
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
|
||||
|
||||
// Create + archive a session via /new.
|
||||
await trigger(makeEvent("chat-7", "@_user_1 写教案"), rt);
|
||||
await vi.waitFor(async () => {
|
||||
expect(await prisma.agentRun.findMany()).toHaveLength(1);
|
||||
});
|
||||
await trigger(makeEvent("chat-7", "@_user_1 /new"), rt);
|
||||
|
||||
// /resume un-archives.
|
||||
await trigger(makeEvent("chat-7", "@_user_1 /resume"), rt);
|
||||
expect(rt.sentTexts).toContain("已恢复上一个会话。");
|
||||
|
||||
const sessions = await prisma.agentSession.findMany();
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0]?.archivedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("/reset archives current session", async () => {
|
||||
await seedProject("proj-8", "chat-8");
|
||||
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
|
||||
|
||||
await trigger(makeEvent("chat-8", "@_user_1 写教案"), rt);
|
||||
await vi.waitFor(async () => {
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]?.status).toBe("COMPLETED");
|
||||
});
|
||||
|
||||
await trigger(makeEvent("chat-8", "@_user_1 /reset"), rt);
|
||||
expect(rt.sentTexts).toContain("已重置,下次 @bot 将从头开始。");
|
||||
|
||||
const sessions = await prisma.agentSession.findMany();
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0]?.archivedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it("unknown slash command falls through to agent", async () => {
|
||||
await seedProject("proj-9", "chat-9");
|
||||
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
|
||||
|
||||
await trigger(makeEvent("chat-9", "@_user_1 /unknown"), rt);
|
||||
// Should create a run (falls through as a normal prompt).
|
||||
await vi.waitFor(async () => {
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]?.prompt).toBe("/unknown");
|
||||
});
|
||||
});
|
||||
|
||||
it("denies /review when sender has no role grant (per-role gate)", async () => {
|
||||
await seedProject("proj-10", "chat-10");
|
||||
// Someone else holds review; ou_test_user does not.
|
||||
await prisma.roleTriggerGrant.create({
|
||||
data: { projectId: "proj-10", roleId: "review", principal: "ou_other" },
|
||||
});
|
||||
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
|
||||
|
||||
await trigger(makeEvent("chat-10", "@_user_1 /review 看看这节"), rt);
|
||||
|
||||
expect(rt.sentTexts).toContain("无权限使用角色 review。");
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("allows /review when sender holds the role grant", async () => {
|
||||
await seedProject("proj-11", "chat-11");
|
||||
await prisma.roleTriggerGrant.create({
|
||||
data: { projectId: "proj-11", roleId: "review", principal: "ou_test_user" },
|
||||
});
|
||||
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
|
||||
|
||||
await trigger(makeEvent("chat-11", "@_user_1 /review 看看这节"), rt);
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]?.status).toBe("COMPLETED");
|
||||
expect(runs[0]?.metadata).toMatchObject({ roleId: "review" });
|
||||
});
|
||||
});
|
||||
|
||||
it("extractRole: /draft sets roleId=draft, strips command from prompt", async () => {
|
||||
await seedProject("proj-12", "chat-12");
|
||||
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
|
||||
|
||||
await trigger(makeEvent("chat-12", "@_user_1 /draft 写第三单元"), rt);
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]?.prompt).toBe("写第三单元");
|
||||
expect(runs[0]?.metadata).toMatchObject({ roleId: "draft" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { roleGrantsEdit } from "../../src/permission.js";
|
||||
|
||||
describe("roleGrantsEdit (ADR-0004 monotonicity)", () => {
|
||||
it("READ does not grant edit", () => {
|
||||
expect(roleGrantsEdit("READ")).toBe(false);
|
||||
});
|
||||
|
||||
it("EDIT grants edit", () => {
|
||||
expect(roleGrantsEdit("EDIT")).toBe(true);
|
||||
});
|
||||
|
||||
it("MANAGE grants edit (manage ⊇ edit, spec can_mono)", () => {
|
||||
expect(roleGrantsEdit("MANAGE")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { extractPrompt } from "../../src/feishu/trigger.js";
|
||||
import type { MessageReceiveEvent } from "../../src/feishu/client.js";
|
||||
|
||||
const bot = { key: "@_user_1", id: { open_id: "ou_bot" }, name: "Bot" };
|
||||
|
||||
function msg(text: string, mentions: typeof bot[] | undefined = [bot]): MessageReceiveEvent["message"] {
|
||||
return {
|
||||
message_id: "m",
|
||||
chat_id: "c",
|
||||
chat_type: "group",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text }),
|
||||
mentions,
|
||||
};
|
||||
}
|
||||
|
||||
describe("extractPrompt (@bot mention parsing)", () => {
|
||||
it("extracts the prompt after @bot", () => {
|
||||
expect(extractPrompt(msg("@_user_1 帮我写教案"))).toBe("帮我写教案");
|
||||
});
|
||||
|
||||
it("strips multiple mentions", () => {
|
||||
const other = { key: "@_user_2", id: { open_id: "ou_other" }, name: "Other" };
|
||||
expect(extractPrompt(msg("@_user_1 @_user_2 检查例题2", [bot, other]))).toBe("检查例题2");
|
||||
});
|
||||
|
||||
it("returns null when only @bot with no prompt", () => {
|
||||
expect(extractPrompt(msg("@_user_1"))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when @bot followed by whitespace only", () => {
|
||||
expect(extractPrompt(msg("@_user_1 "))).toBeNull();
|
||||
});
|
||||
it("returns null when no mentions", () => {
|
||||
const m: MessageReceiveEvent["message"] = {
|
||||
message_id: "m", chat_id: "c", chat_type: "group",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "hello" }),
|
||||
mentions: undefined,
|
||||
};
|
||||
expect(extractPrompt(m)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-text message_type (content still parseable)", () => {
|
||||
const m = msg("{}", [bot]);
|
||||
// message_type check is the caller's job; extractPrompt only parses content.
|
||||
// But with empty text it returns null.
|
||||
expect(extractPrompt({ ...m, content: JSON.stringify({ text: "" }) })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when content is not valid JSON", () => {
|
||||
const m: MessageReceiveEvent["message"] = {
|
||||
message_id: "m",
|
||||
chat_id: "c",
|
||||
chat_type: "group",
|
||||
message_type: "text",
|
||||
content: "not json",
|
||||
mentions: [bot],
|
||||
};
|
||||
expect(extractPrompt(m)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when JSON has no text field", () => {
|
||||
const m: MessageReceiveEvent["message"] = {
|
||||
message_id: "m",
|
||||
chat_id: "c",
|
||||
chat_type: "group",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ foo: "bar" }),
|
||||
mentions: [bot],
|
||||
};
|
||||
expect(extractPrompt(m)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { runAgent } from "../../src/agent/runner.js";
|
||||
import { ToolRegistry } from "../../src/agent/tools.js";
|
||||
import { readFileTool } from "../../src/agent/workspace.js";
|
||||
import { createMockModelFactory } from "../integration/helpers.js";
|
||||
|
||||
describe("runAgent with AI SDK tool loop", () => {
|
||||
it("executes SDK tools and returns the final assistant response", async () => {
|
||||
const ws = await mkdtemp(join(tmpdir(), "hub-runner-"));
|
||||
try {
|
||||
await writeFile(join(ws, "lesson.txt"), "hello lesson", "utf8");
|
||||
const tools = new ToolRegistry();
|
||||
tools.register("read_file", readFileTool);
|
||||
const { modelFactory, models } = createMockModelFactory([
|
||||
{
|
||||
toolCalls: [{ toolCallId: "call-1", toolName: "read_file", input: { path: "lesson.txt" } }],
|
||||
},
|
||||
{ text: "done", finishReason: "stop" },
|
||||
]);
|
||||
|
||||
const result = await runAgent(modelFactory, tools, {
|
||||
prompt: "read lesson.txt",
|
||||
model: "mock-model",
|
||||
project: { projectId: "p", boundChatId: "c", workspaceDir: ws },
|
||||
systemPrompt: "system",
|
||||
maxIterations: 5,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("completed");
|
||||
expect(result.messages.at(-1)).toMatchObject({ role: "assistant" });
|
||||
expect(models.get("mock-model")?.calls).toHaveLength(2);
|
||||
} finally {
|
||||
await rm(ws, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtemp, rm, readFile, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { readTranscript, appendTranscript, transcriptPath, TRANSCRIPT_DIR } from "../../src/agent/transcript.js";
|
||||
import type { ModelMessage } from "ai";
|
||||
|
||||
const sample: ModelMessage = { role: "user", content: "hello" };
|
||||
const reply: ModelMessage = { role: "assistant", content: "hi" };
|
||||
|
||||
describe("transcript JSONL", () => {
|
||||
let dir: string;
|
||||
let path: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "hub-transcript-"));
|
||||
path = join(dir, "session.jsonl");
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("readTranscript returns empty array for nonexistent file", async () => {
|
||||
expect(await readTranscript(path)).toEqual([]);
|
||||
});
|
||||
|
||||
it("appendTranscript creates parent dirs and writes JSONL", async () => {
|
||||
await appendTranscript(path, [sample]);
|
||||
const content = await readFile(path, "utf8");
|
||||
expect(content.trim()).toBe(JSON.stringify(sample));
|
||||
});
|
||||
|
||||
it("readTranscript reads back appended messages", async () => {
|
||||
await appendTranscript(path, [sample, reply]);
|
||||
const messages = await readTranscript(path);
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toEqual(sample);
|
||||
expect(messages[1]).toEqual(reply);
|
||||
});
|
||||
|
||||
it("multiple appends accumulate", async () => {
|
||||
await appendTranscript(path, [sample]);
|
||||
await appendTranscript(path, [reply]);
|
||||
const messages = await readTranscript(path);
|
||||
expect(messages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("skips corrupt lines (partial write recovery)", async () => {
|
||||
await appendTranscript(path, [sample]);
|
||||
// Append a corrupt line (simulating a crash mid-write).
|
||||
await writeFile(path, '{"broken json\n', { flag: "a" });
|
||||
const messages = await readTranscript(path);
|
||||
expect(messages).toHaveLength(1); // only the valid line
|
||||
});
|
||||
|
||||
it("transcriptPath builds the expected path", () => {
|
||||
const p = transcriptPath("/ws", "sess-1");
|
||||
expect(p).toBe(join("/ws", TRANSCRIPT_DIR, "sess-1.jsonl"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { mkdtemp, writeFile, mkdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ToolRegistry, type ToolContext } from "../../src/agent/tools.js";
|
||||
import { readFileTool, writeFileTool, listFilesTool, PathEscape } from "../../src/agent/workspace.js";
|
||||
|
||||
describe("workspace path confinement", () => {
|
||||
let ws: string;
|
||||
let tools: ToolRegistry;
|
||||
|
||||
beforeEach(async () => {
|
||||
ws = await mkdtemp(join(tmpdir(), "hub-unit-"));
|
||||
tools = new ToolRegistry();
|
||||
tools.register("read_file", readFileTool);
|
||||
tools.register("write_file", writeFileTool);
|
||||
tools.register("list_files", listFilesTool);
|
||||
await mkdir(join(ws, "sub"));
|
||||
await writeFile(join(ws, "a.txt"), "hello");
|
||||
await writeFile(join(ws, "sub", "b.txt"), "world");
|
||||
});
|
||||
|
||||
const ctx = () => ({ runId: "r", projectId: "p", boundChatId: "c", workspaceDir: ws });
|
||||
|
||||
it("reads a file inside the workspace", async () => {
|
||||
expect(await executeTool(tools, "read_file", { path: "a.txt" }, ctx())).toBe("hello");
|
||||
});
|
||||
|
||||
it("reads nested files", async () => {
|
||||
expect(await executeTool(tools, "read_file", { path: "sub/b.txt" }, ctx())).toBe("world");
|
||||
});
|
||||
|
||||
it("rejects .. escapes as error JSON, not a throw", async () => {
|
||||
const out = await executeTool(tools, "read_file", { path: "../../etc/passwd" }, ctx());
|
||||
expect((JSON.parse(out) as { error: string }).error).toContain("escapes workspace");
|
||||
});
|
||||
|
||||
it("rejects absolute paths outside the workspace", async () => {
|
||||
const out = await executeTool(tools, "read_file", { path: "/etc/passwd" }, ctx());
|
||||
expect((JSON.parse(out) as { error: string }).error).toContain("escapes workspace");
|
||||
});
|
||||
|
||||
it("writes a file and reads it back", async () => {
|
||||
await executeTool(tools, "write_file", { path: "sub/c.txt", content: "new" }, ctx());
|
||||
expect(await executeTool(tools, "read_file", { path: "sub/c.txt" }, ctx())).toBe("new");
|
||||
});
|
||||
|
||||
it("creates parent dirs on write", async () => {
|
||||
await executeTool(tools, "write_file", { path: "deep/nested/d.txt", content: "x" }, ctx());
|
||||
expect(await executeTool(tools, "read_file", { path: "deep/nested/d.txt" }, ctx())).toBe("x");
|
||||
});
|
||||
|
||||
it("lists the workspace root", async () => {
|
||||
const out = await executeTool(tools, "list_files", {}, ctx());
|
||||
const entries = JSON.parse(out) as Array<{ name: string; kind: string }>;
|
||||
expect(entries).toContainEqual({ name: "a.txt", kind: "file" });
|
||||
expect(entries).toContainEqual({ name: "sub", kind: "dir" });
|
||||
});
|
||||
|
||||
it("lists a subdirectory", async () => {
|
||||
const out = await executeTool(tools, "list_files", { path: "sub" }, ctx());
|
||||
const entries = JSON.parse(out) as Array<{ name: string; kind: string }>;
|
||||
expect(entries).toContainEqual({ name: "b.txt", kind: "file" });
|
||||
});
|
||||
|
||||
it("PathEscape is an Error subclass", () => {
|
||||
expect(new PathEscape("../x", "/ws")).toBeInstanceOf(Error);
|
||||
expect(new PathEscape("../x", "/ws").name).toBe("PathEscape");
|
||||
});
|
||||
|
||||
it("read of nonexistent file returns error JSON", async () => {
|
||||
const out = await executeTool(tools, "read_file", { path: "nope.txt" }, ctx());
|
||||
expect((JSON.parse(out) as { error: string }).error).toBeTruthy();
|
||||
});
|
||||
|
||||
it("write with .. escape returns error JSON", async () => {
|
||||
const out = await executeTool(tools, "write_file", { path: "../escape.txt", content: "x" }, ctx());
|
||||
expect((JSON.parse(out) as { error: string }).error).toContain("escapes workspace");
|
||||
});
|
||||
});
|
||||
|
||||
async function executeTool(tools: ToolRegistry, name: string, args: unknown, ctx: ToolContext): Promise<string> {
|
||||
const def = tools.build(ctx)[name];
|
||||
if (def?.execute === undefined) {
|
||||
throw new Error(`missing executable tool: ${name}`);
|
||||
}
|
||||
return def.execute(args, { toolCallId: "call", messages: [], context: undefined }) as Promise<string>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noImplicitOverride": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": false,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["test/**/*.test.ts"],
|
||||
// Integration tests share one DB; run files sequentially to avoid
|
||||
// concurrent truncate/insert races. Unit tests are fast either way.
|
||||
pool: "forks",
|
||||
fileParallelism: false,
|
||||
env: { NODE_ENV: "test" },
|
||||
},
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import Spec.Courseware.Open
|
||||
/-!
|
||||
# Courseware —— 产品层契约(课程工程文件)
|
||||
|
||||
护城河:课程"工程文件"的语义母本与合法性规则。决策出处 ADR-0005..0015。按四组组织:
|
||||
护城河:课程"工程文件"的语义母本与合法性规则。决策出处 ADR-0005..0016。按四组组织:
|
||||
|
||||
- **`Model`** —— 工程文件的内容模型:基元 `Primitives`、富内容 `RichContent`、原子
|
||||
单位 `Element`、单节课 `Lesson`(element 的有序序列)。
|
||||
|
||||
@@ -6,12 +6,12 @@ import Spec.Courseware.Export.Render
|
||||
|
||||
产品里"站在 Lean 位置"的 rule-based checker,语义在此沉淀(ADR-0010,经 ADR-0012
|
||||
修订)。它对 lesson 提诊断,每条有**分类**(`DiagKind`)与**严重级别**(`Severity`)。
|
||||
本模块:钉级别类型(二分);钉 6 类诊断各自的含义与级别,并把"**合法 lesson = 无
|
||||
本模块:钉级别类型(二分);钉 7 类诊断各自的含义与级别,并把"**合法 lesson = 无
|
||||
error 级诊断**"建成判定(ADR-0005 deferred 的"完整合法判定"的回填);对**模型外设施**
|
||||
型诊断(typst 编过否、数据合 schema 否)用**抽象谓词 + `Oracle` 实现边界**表示——契约
|
||||
说"存在这条诊断、什么意思、什么级别",真值由实现提供,不在 Lean 内计算(不内嵌 typst
|
||||
编译器)。引用解析(`@ref`、相对 import)不另设诊断:它们都是 typst 编译期失败,归
|
||||
`typstCompile`(ADR-0012)。
|
||||
`typstCompile`(ADR-0012)。版本契约诊断 `cphVersionMismatch`(ADR-0016)是第 7 类。
|
||||
-/
|
||||
|
||||
namespace Spec.Courseware
|
||||
@@ -22,10 +22,10 @@ inductive Severity where
|
||||
| warning
|
||||
| error
|
||||
|
||||
/-- 诊断**分类**(`PINNED` 6 类, ADR-0010,经 ADR-0012 修订为 6 类)。按"谁来判定"分
|
||||
三层:**结构型**(模型自身可判):`partPathMissing`/`unknownKind`;**schema/外部设施型**
|
||||
(靠实现 oracle):`missingContentFile`/`schemaViolation`/`typstCompile`;**语义型**:
|
||||
`renderIgnored`。 -/
|
||||
/-- 诊断**分类**(`PINNED` 7 类, ADR-0010,经 ADR-0012 修订为 6 类,经 ADR-0016 增至 7
|
||||
类)。按"谁来判定"分三层:**结构型**(模型自身可判):`partPathMissing`/`unknownKind`/
|
||||
`cphVersionMismatch`;**schema/外部设施型**(靠实现 oracle):
|
||||
`missingContentFile`/`schemaViolation`/`typstCompile`;**语义型**:`renderIgnored`。 -/
|
||||
inductive DiagKind where
|
||||
/-- manifest 的 part 指向不存在的文件夹(或经 `..` 逃出根)。结构型。 -/
|
||||
| partPathMissing
|
||||
@@ -41,17 +41,24 @@ inductive DiagKind where
|
||||
| typstCompile
|
||||
/-- 某被用到的 kind 在某声明的 target 下无渲染规则,该 element 被忽略。语义型。 -/
|
||||
| renderIgnored
|
||||
/-- 工程文件的 `.cph-version` 与 CLI(cph)版本不相容(ADR-0016)。**结构型**(加载期
|
||||
判)。工程文件根的 `.cph-version` 声明它所面向的 cph 版本;CLI 加载时按**兼容性判
|
||||
定**比对自身版本(实现侧 `CARGO_PKG_VERSION`),不相容即产此类。**当前判定为版本
|
||||
完全相等才相容**(MVP;后续可放宽为 semver 区间,判定逻辑可逐步修改而不动本分类)。
|
||||
`error` 级——版本不相容的工程文件不应被该 CLI 处理。 -/
|
||||
| cphVersionMismatch
|
||||
|
||||
/-- 每类诊断的**严重级别**(`PINNED`, ADR-0010)。五类 `error`(阻断);**唯
|
||||
/-- 每类诊断的**严重级别**(`PINNED`, ADR-0010)。六类 `error`(阻断);**唯
|
||||
`renderIgnored` 为 `warning`**——ADR-0005 种子规则"缺渲染 ⇒ warning,不阻断导出"。
|
||||
钉成全函数使"哪类阻断"成为可引用、可对齐的事实(实现侧 `DiagCode` 级别据此对齐)。 -/
|
||||
def DiagKind.severity : DiagKind → Severity
|
||||
| .partPathMissing => .error
|
||||
| .unknownKind => .error
|
||||
| .partPathMissing => .error
|
||||
| .unknownKind => .error
|
||||
| .missingContentFile => .error
|
||||
| .schemaViolation => .error
|
||||
| .typstCompile => .error
|
||||
| .renderIgnored => .warning
|
||||
| .schemaViolation => .error
|
||||
| .typstCompile => .error
|
||||
| .renderIgnored => .warning
|
||||
| .cphVersionMismatch => .error
|
||||
|
||||
/-- 缺渲染诊断的级别 = **warning**(`PINNED`, ADR-0005/0010,**非 error**)。具名常量,
|
||||
使"它是 warning"可被实现侧 grep 对齐(实现里同名常量 `RENDER_IGNORED_SEVERITY` 引用
|
||||
|
||||
@@ -14,7 +14,9 @@ namespace Spec.Courseware
|
||||
|
||||
/-- 检查管线的**阶段**(`PINNED` 5 阶段, ADR-0010)。
|
||||
|
||||
- `load` —— 解析 manifest + 各 element.toml。硬失败则**停**整条管线。
|
||||
- `load` —— 解析 manifest + 各 element.toml。**含 `.cph-version` 兼容性判定**
|
||||
(ADR-0016:工程根 `.cph-version` 与 CLI 版本不相容 ⇒ `cphVersionMismatch` error)。
|
||||
硬失败(无法解析 lesson)则**停**整条管线。
|
||||
- `structural` —— part 路径存在、无 `..`、kind 已知且一致。此处判缺的 part 后续跳过。
|
||||
- `schema` —— 每个"存在且 kind 已知"的 part 按其 kind schema 校验。
|
||||
- `compile` —— 模型外设施阶段(typst 编译)。**门控:仅当前序零 error 才跑**。
|
||||
|
||||
+10
-3
@@ -1,7 +1,7 @@
|
||||
/-!
|
||||
# Prelude —— System 层共享标识符
|
||||
|
||||
平台层反复引用一组标识符(项目、run、session、principal)。其内部表示从未被决策
|
||||
平台层反复引用一组标识符(项目、run、session、principal、chat)。其内部表示从未被决策
|
||||
(UUID / 复合键、principal 子类型学),也非分歧点,故收口成 opaque 载体
|
||||
`Identifiers`,System 各模块在其上参数化——契约谈得了"锁 owner 是哪个 run"这类
|
||||
**关系**,却不对标识符表示作承诺。
|
||||
@@ -13,12 +13,19 @@ namespace Spec.System
|
||||
structure Identifiers where
|
||||
/-- 课程项目标识(`OPEN` 表示;聚合根,likec4 `Project`)。 -/
|
||||
ProjectId : Type
|
||||
/-- 一次 Claude 任务的标识(`OPEN` 表示;锁的 owner、审计主体,`AgentRun`)。 -/
|
||||
/-- 一次 agent 任务的标识(`OPEN` 表示;锁的 owner、审计主体,`AgentRun`。provider 无关,
|
||||
ADR-0017;`@Claude` 仅为触发品牌,不承诺 provider)。 -/
|
||||
RunId : Type
|
||||
/-- 长生命周期 Claude 会话标识(`OPEN` 表示;跨多 run 复用,ADR-0002)。 -/
|
||||
/-- 长生命周期 agent 会话标识(`OPEN` 表示;**provider/model 绑定, ADR-0017**——一次
|
||||
session 不跨 provider/model;切 model 即新 session,跨 session 连续性由 ADR-0003 项目
|
||||
记忆/锚点重建,不由 session 自带。同 provider/model 内可跨多 run 复用, ADR-0002)。 -/
|
||||
SessionId : Type
|
||||
/-- 权限主体标识(`OPEN` 表示及其子类型学;ADR-0004 的 user/chat/department/…
|
||||
子类型学未定且非本层分歧点,纯 plumbing,故只留 opaque 键)。 -/
|
||||
Principal : Type
|
||||
/-- 飞书项目群 chat 标识(`OPEN` 表示;ADR-0001 协作空间、ADR-0003 锚点引用、
|
||||
ADR-0004 `feishu_chat` principal 三处共用同一实体。独立成载体而非 `Principal` 子
|
||||
类型——principal 子类型学 OPEN 见上,本层不预设"chat 是 principal 的哪种子型")。 -/
|
||||
ChatId : Type
|
||||
|
||||
end Spec.System
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import Spec.System.ProjectGroup
|
||||
import Spec.System.Run
|
||||
import Spec.System.Lock
|
||||
import Spec.System.Memory
|
||||
import Spec.System.Permission
|
||||
import Spec.System.PermissionGrant
|
||||
import Spec.System.Audit
|
||||
|
||||
/-!
|
||||
# System —— Hub 平台层契约
|
||||
|
||||
协作与执行的平台:项目、飞书群、AgentRun、锁、权限、审计。likec4
|
||||
协作与执行的平台:项目、飞书群、AgentRun、锁、权限、审计、按需上下文。likec4
|
||||
(`docs/architecture/likec4/`)已画出这一层的**结构**;本层只补 likec4 画不出的
|
||||
**语义分歧点**:
|
||||
|
||||
- `ProjectGroup` —— project↔飞书群 1:1 长生命周期绑定(ADR-0001);群是协作空间,不持锁。
|
||||
- `Run` —— AgentRun 状态与终止判定(状态集合完整性 OPEN)。
|
||||
- `Lock` —— 锁 owner=run(ADR-0002),及"持锁者必为非终止 run"的核心不变式。
|
||||
- `Memory` —— 按需上下文:锚点类别(ADR-0003)+ MCP 工具按 run/project 上下文授权的不变式。
|
||||
- `Permission` —— read⊂edit⊂manage 角色格、能力推导、单调性;force-release 在格外。
|
||||
- `PermissionGrant` —— grant(resource×principal×role)与 settings(六 policy 旋钮)结构
|
||||
(ADR-0004);role-capability 与 settings-policy 的组合规则 OPEN。
|
||||
- `Audit` —— 有意从简(内容多为 plumbing,OPEN)。
|
||||
|
||||
标识符见 `Spec.Prelude`。决策出处:ADR-0001..0004。
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import Spec.Prelude
|
||||
|
||||
/-!
|
||||
# Memory —— 按需上下文:锚点与项目记忆(ADR-0003)
|
||||
|
||||
ADR-0003:Hub **只存锚点与项目记忆**,不存全量飞书消息历史;Claude 需要更多上下文时经
|
||||
飞书 API 按需读取。本模块刻画所存锚点的**类别**(ADR 列定,枚举完整性 OPEN——ADR 是
|
||||
"例如"式列举,新增类别不违反契约),并钉死一条 likec4 画不出的安全不变式:**MCP 工具
|
||||
按 run/project 上下文授权,Claude 不得传任意 chat id**(ADR-0003 Consequences 末条)。
|
||||
|
||||
"chat id 与 project 绑定"这一锚点类别由 `ProjectGroup.GroupBinding`(ADR-0001)权威承载,
|
||||
本模块不重复声明,只覆盖其余飞书侧指针(触发消息、状态卡片、回复、线程)。
|
||||
-/
|
||||
|
||||
namespace Spec.System
|
||||
|
||||
variable (I : Identifiers)
|
||||
variable (MessageId CardId : Type)
|
||||
|
||||
/-- 上下文锚点(`PINNED` 类别, ADR-0003 列定;**枚举完整性 `OPEN`**——ADR 是"例如"式
|
||||
列举,实现若需新类别须 surface,不得默认本枚举已穷尽)。承载 Hub 保留的飞书侧最小指针,
|
||||
而非消息正文。 -/
|
||||
inductive Anchor where
|
||||
/-- 触发某次 run 的消息(`PINNED` 类别, ADR-0003 "trigger message id")。 -/
|
||||
| triggerMessage : MessageId → Anchor
|
||||
/-- 某 run 的状态卡片(`PINNED` 类别, ADR-0003 "run id and status card id")。 -/
|
||||
| statusCard : I.RunId → CardId → Anchor
|
||||
/-- 回复锚点(`PINNED` 类别, ADR-0003 "reply anchors")。 -/
|
||||
| reply : MessageId → Anchor
|
||||
/-- 线程锚点(`PINNED` 类别, ADR-0003 "thread anchors")。 -/
|
||||
| thread : MessageId → Anchor
|
||||
|
||||
/-- MCP 读上下文请求:由某 run 发起、指向某 chat(`PINNED` 关系, ADR-0003 "Claude calls
|
||||
MCP tools to read … through Feishu APIs")。 -/
|
||||
structure McpReadRequest where
|
||||
/-- 发起请求的 run(授权上下文主体, ADR-0003)。 -/
|
||||
run : I.RunId
|
||||
/-- 请求读取的 chat(是否允许越界由下方 `Authorized` 钉死:不允许)。 -/
|
||||
chat : I.ChatId
|
||||
|
||||
/-- 请求获授权:其 chat 必须等于该 run 所属 project 的绑定群(`PINNED` 安全不变式,
|
||||
ADR-0003 Consequences "MCP tools must authorize by run/project context; Claude cannot
|
||||
pass arbitrary chat ids")。`runProject`/`boundChat` 由平台提供(表示 `OPEN`);本谓词只
|
||||
钉死"chat 必须匹配 run 的 project 绑定",杜绝 Claude 传任意 chat id 越权读取。 -/
|
||||
def McpReadRequest.Authorized
|
||||
(req : McpReadRequest I)
|
||||
(runProject : I.RunId → Option I.ProjectId)
|
||||
(boundChat : I.ProjectId → Option I.ChatId) : Prop :=
|
||||
∃ p, runProject req.run = some p ∧ boundChat p = some req.chat
|
||||
|
||||
end Spec.System
|
||||
@@ -0,0 +1,69 @@
|
||||
import Spec.Prelude
|
||||
import Spec.System.Permission
|
||||
|
||||
/-!
|
||||
# PermissionGrant —— 授权与设置(ADR-0004)
|
||||
|
||||
ADR-0004 的"飞书云文档式"权限:**grant**(`resource × principal × role`)与 **settings**
|
||||
(各 policy 旋钮)分离;role 决定"谁能"(能力,见 `Permission`),settings 决定"此资源
|
||||
是否开某类操作"(策略)。本模块把 grant/settings 的结构钉死——`Permission` 已落 role 能
|
||||
力格,本模块补"授权如何挂到资源/主体上"。
|
||||
|
||||
principal 子类型学(user/chat/department/…)与各 policy 值域均为 `OPEN`(ADR 未定,非本
|
||||
层分歧点)。**role-capability 与 settings-policy 如何组合成最终授权决策**亦 `OPEN`——
|
||||
ADR-0004 把二者列为分离的闸,但未明文规定组合规则(AND?settings 能否超出 role?),实现
|
||||
须 surface,不得默认。
|
||||
-/
|
||||
|
||||
namespace Spec.System
|
||||
|
||||
variable (I : Identifiers)
|
||||
variable (ArtifactId Policy : Type)
|
||||
|
||||
/-- 受权限调控的资源(`PINNED` 三类, ADR-0004 `resource_type: project | artifact |
|
||||
project_group`);每类携带其 id,故 resource_id 由 resource_type 决定(结构无洞)。 -/
|
||||
inductive Resource where
|
||||
/-- 课程项目(`PINNED` 类别, ADR-0004)。 -/
|
||||
| project : I.ProjectId → Resource
|
||||
/-- 教研产物(`PINNED` 类别, ADR-0004;id 表示 `OPEN`,语义向 Courseware 半边对齐)。 -/
|
||||
| artifact : ArtifactId → Resource
|
||||
/-- 飞书项目群(`PINNED` 类别, ADR-0004 `project_group`;chat 标识见
|
||||
`Identifiers.ChatId`,绑定见 `ProjectGroup`)。 -/
|
||||
| projectGroup : I.ChatId → Resource
|
||||
|
||||
/-- 授权项(`PINNED` 结构, ADR-0004 `PermissionGrant`):把一个 role 授予一个 principal 对
|
||||
某个 resource。role 取自 `Permission.Role`(read/edit/manage 累积格)。principal 表示
|
||||
`OPEN`(子类型学未定,见 `Identifiers.Principal`)。 -/
|
||||
structure PermissionGrant where
|
||||
/-- 被授权的资源(`PINNED`, ADR-0004 `resource_id`)。 -/
|
||||
resource : Resource I ArtifactId
|
||||
/-- 被授权的主体(`PINNED` 字段/`OPEN` 表示, ADR-0004 `principal_id`;user/chat/
|
||||
department/user_group/app 子类型学未定)。 -/
|
||||
principal : I.Principal
|
||||
/-- 授予的角色(`PINNED`, ADR-0004 `role`;read⊂edit⊂manage 见 `Permission.Role`)。 -/
|
||||
role : Role
|
||||
|
||||
/-- 资源策略设置(`PINNED` 结构 + 六旋钮, ADR-0004 `PermissionSettings`):与 grant 分离,
|
||||
控制"此资源是否开某类操作"。六旋钮由 ADR 逐字列名;各旋钮值域 `OPEN`(ADR 未定,非本层
|
||||
分歧点)。共享同一 opaque `Policy` 类型:契约只钉死"旋钮存在且相互独立",不钉死"各旋钮
|
||||
值域互异"——值域是实现/后续 ADR 的事。 -/
|
||||
structure PermissionSettings where
|
||||
/-- 设置所属资源(`PINNED`, ADR-0004)。 -/
|
||||
resource : Resource I ArtifactId
|
||||
/-- 对外分享策略(`PINNED` 旋钮名, ADR-0004 `external_share_policy`;值域 `OPEN`)。 -/
|
||||
externalShare : Policy
|
||||
/-- 评论策略(`PINNED` 旋钮名, ADR-0004 `comment_policy`;值域 `OPEN`)。 -/
|
||||
comment : Policy
|
||||
/-- 复制/下载策略(`PINNED` 旋钮名, ADR-0004 `copy_download_policy`;值域 `OPEN`)。 -/
|
||||
copyDownload : Policy
|
||||
/-- 协作者管理策略(`PINNED` 旋钮名, ADR-0004 `collaborator_management_policy`;
|
||||
值域 `OPEN`)。 -/
|
||||
collaboratorMgmt : Policy
|
||||
/-- agent 触发策略(`PINNED` 旋钮名, ADR-0004 `agent_trigger_policy`;值域 `OPEN`。与
|
||||
`agentCancel` 分立——"谁能触发"≠"谁能取消",ADR-0004 故意拆开)。 -/
|
||||
agentTrigger : Policy
|
||||
/-- agent 取消策略(`PINNED` 旋钮名, ADR-0004 `agent_cancel_policy`;值域 `OPEN`。与
|
||||
`agentTrigger` 分立,见上)。 -/
|
||||
agentCancel : Policy
|
||||
|
||||
end Spec.System
|
||||
@@ -0,0 +1,40 @@
|
||||
import Spec.Prelude
|
||||
|
||||
/-!
|
||||
# ProjectGroup —— 飞书项目群作为协作空间(ADR-0001)
|
||||
|
||||
ADR-0001 的核心:一个 project 对应一个**长生命周期**飞书项目群;群是协作空间,**不是锁
|
||||
owner**(锁归 `AgentRun`,见 `Lock` / ADR-0002),不是临时处理 session。群可在无 Claude
|
||||
处理时保持开启;教师离群/静音与项目权限、与 Claude 生命周期相互独立。本模块钉死
|
||||
project↔group 的一对一绑定——likec4 画得出"project has group",画不出"恰好一个、且群
|
||||
不持锁"。
|
||||
|
||||
**群失效态与绑定快照(`OPEN`, ADR-0001 Consequences):** 群解散/归档/不可达时,
|
||||
`GroupBinding` 快照应变为 `none`(解绑、允许 rebind)还是保持 `some` 但指向失效 chat
|
||||
(悬挂、待管理员干预)未决策。ADR-0001 已点名 "rebinding or group archival needs
|
||||
explicit product rules";本模块只刻画健康态 1:1 不变式,不臆造失效态语义。实现遇到
|
||||
群解散必须 surface,不得默认任一方。
|
||||
-/
|
||||
|
||||
namespace Spec.System
|
||||
|
||||
variable (I : Identifiers)
|
||||
|
||||
/-- 飞书项目群(`PINNED` 长生命周期协作空间, ADR-0001)。承载 project 与飞书 chat 的绑定;
|
||||
**不是锁 owner**(锁归 `AgentRun`,见 `Lock`);不是临时 session。 -/
|
||||
structure ProjectGroup where
|
||||
/-- 群对应的飞书 chat(`PINNED` 关系, ADR-0001 "one project has one Feishu project
|
||||
group";chat 标识见 `Identifiers.ChatId`)。 -/
|
||||
chat : I.ChatId
|
||||
|
||||
/-- 项目↔群绑定表(`PINNED` 每项目至多一个群, ADR-0001)。`ProjectId → Option ChatId` 的
|
||||
结构本身即"一个 project 至多绑一个群"(由 `Option` 自带);良构补另一半——单射。 -/
|
||||
def GroupBinding := I.ProjectId → Option I.ChatId
|
||||
|
||||
/-- 绑定良构:**单射**——不同 project 不绑同一 chat(`PINNED` 1:1 的另一半, ADR-0001)。
|
||||
"每 project 至多一个群"由 `Option` 结构自带;这条钉死"每群至多属于一个 project"。群
|
||||
rebinding/archival 是生命周期事件(ADR-0001 Consequences),不在本快照不变式内。 -/
|
||||
def GroupBinding.WellFormed (b : GroupBinding I) : Prop :=
|
||||
∀ p₁ p₂ c, b p₁ = some c → b p₂ = some c → p₁ = p₂
|
||||
|
||||
end Spec.System
|
||||
Reference in New Issue
Block a user