Compare commits

..

11 Commits

85 changed files with 4691 additions and 1504 deletions
-3
View File
@@ -11,9 +11,6 @@
# regenerable, not for VCS. The embedded engine mounts cph-render directly. # regenerable, not for VCS. The embedded engine mounts cph-render directly.
render/vendor/local-packages/ render/vendor/local-packages/
# Environment
.env
# Node (hub/ TS workspace and any future JS package) # Node (hub/ TS workspace and any future JS package)
node_modules/ node_modules/
+9
View File
@@ -30,8 +30,17 @@
控制面与 Docker adapter 后置(见 ADR-0025)。 控制面与 Docker adapter 后置(见 ADR-0025)。
- Agent role 与 skill 是 Organization-scoped 动态运行配置:role 组合 model、system prompt、 - Agent role 与 skill 是 Organization-scoped 动态运行配置:role 组合 model、system prompt、
tools 与已安装 skillskill 版本进入 content-addressed 持久存储,run 只读加载所选快照。 tools 与已安装 skillskill 版本进入 content-addressed 持久存储,run 只读加载所选快照。
每个 Organization 必须且只能有一个启用中的默认 role;新建群绑定从该默认值初始化,之后
`ProjectGroupBinding` 持久化群内当前 role,run 在接纳时冻结该 role。飞书公开 slash 协议只含
`/project``/usage``/help`role、会话、目录操作走 `/project` 卡片,Claude 原生
`/compact` 只能由卡片动作以未经包装的精确 prompt 转发。
`settingSources: []` 继续禁用项目/用户配置加载,不得把任意 workspace `.claude` 配置变成 `settingSources: []` 继续禁用项目/用户配置加载,不得把任意 workspace `.claude` 配置变成
运行时能力(见 ADR-0018)。 运行时能力(见 ADR-0018)。
- 项目发现由 `ProjectDiscovery` 模块统一承载:PostgreSQL `pg_trgm` 搜索派生文档、项目编号
归一化、完整 Folder breadcrumb、MANAGE 授权过滤与分页都在该模块内;飞书卡片只是 adapter。
`Project`/`Folder` 仍是事实来源,搜索文档必须可重建且由数据库触发器同步,禁止调用方双写。
系统 `Inbox` 只作为未分类项目的内部落点,不作为业务 folder 暴露;已绑定群通过
`@bot /project` 随时打开项目管理卡片,重命名仍走 org-scoped MANAGE 授权与审计。
## 纪律 ## 纪律
@@ -34,8 +34,12 @@ provider runtime cursor needed to continue a conversation. For Claude Code SDK,
that cursor is the `result.session_id`; store it in `AgentSession.metadata` as that cursor is the `result.session_id`; store it in `AgentSession.metadata` as
`claudeSessionId` and pass it back to the next `query()` call as `claudeSessionId` and pass it back to the next `query()` call as
`options.resume`. Role is part of the session binding because role prompts and `options.resume`. Role is part of the session binding because role prompts and
tool surfaces can differ even when the underlying model is the same; `/draft` tool surfaces can differ even when the underlying model is the same. A Feishu
and `/review` must not resume the same Claude runtime cursor by accident. project group's active binding selects one Organization role for ordinary
messages. Switching that selection routes future messages to the selected
role's own session; it never mutates or merges provider cursors across roles.
Work freezes the selected role when accepted so queued requests cannot drift
after a later switch.
Role definitions are Organization-scoped runtime data. A role bundle selects Role definitions are Organization-scoped runtime data. A role bundle selects
its default model, system prompt, tool allowlist and installed Agent skill its default model, system prompt, tool allowlist and installed Agent skill
@@ -47,6 +51,20 @@ a Hub release or process restart. A change to the role's execution surface
the next run cannot resume a provider context created under stale instructions; the next run cannot resume a provider context created under stale instructions;
label and ordering-only changes preserve conversational continuity. label and ordering-only changes preserve conversational continuity.
Exactly one active role per Organization is the default used when a project
group is first bound. The default is configuration data, not a hard-coded role
name. Roles are selected through the Hub project console; role ids are not
public slash commands. A project participant may change the group's shared
selection only when both `agent.trigger` for the project and `role.trigger` for
the target role authorize that actor. The selection affects every participant's
future messages, while already accepted work keeps its frozen role.
Hub slash commands are a closed control-plane protocol. Unknown commands fail
explicitly and are never downgraded to Agent text. Claude SDK session commands
are invoked only through typed Hub actions. In particular, compaction resumes
the selected role session and sends the exact `/compact` prompt without
prepending Feishu context.
Environment variables: Environment variables:
``` ```
ANTHROPIC_BASE_URL=https://openrouter.ai/api ANTHROPIC_BASE_URL=https://openrouter.ai/api
+5 -3
View File
@@ -72,8 +72,10 @@ Educraft 机器人以应用身份调用上述 API,因此这些 scope 全部放
1. 在“事件配置”中将订阅方式设为“使用长连接接收事件”。 1. 在“事件配置”中将订阅方式设为“使用长连接接收事件”。
2. 添加事件“接收消息” `im.message.receive_v1` 2. 添加事件“接收消息” `im.message.receive_v1`
3. 在“回调配置”中同样选择长连接 3. 添加事件“解散群” `im.chat.disbanded_v1`,用于立即归档该群的项目绑定
4. 添加回调“卡片回传交互” `card.action.trigger`,用于审批、运行中断和项目创建/绑定按钮 4. 添加事件“机器人被移出群” `im.chat.member.bot.deleted_v1`,用于立即归档该群的项目绑定
5. 在“回调配置”中同样选择长连接。
6. 添加回调“卡片回传交互” `card.action.trigger`,用于审批、运行中断和项目创建/绑定按钮。
![长连接与消息事件配置](assets/feishu-setup/03-events.png) ![长连接与消息事件配置](assets/feishu-setup/03-events.png)
@@ -174,7 +176,7 @@ App IDcli_...
App Secret:(通过安全渠道单独发送) App Secret:(通过安全渠道单独发送)
应用已发布:是 / 否 应用已发布:是 / 否
机器人能力已启用:是 / 否 机器人能力已启用:是 / 否
消息事件和卡片回调已配置:是 / 否 消息事件(含解散群、机器人被移出群)和卡片回调已配置:是 / 否
OAuth 重定向 URL 已配置:是 / 否 OAuth 重定向 URL 已配置:是 / 否
【首位 OWNER】 【首位 OWNER】
@@ -0,0 +1,59 @@
#!/usr/bin/env node
import { readdir, readFile, realpath } from "node:fs/promises";
import { dirname, relative, resolve, sep } from "node:path";
const rootArgument = process.argv[2];
if (!rootArgument) throw new Error("usage: build_legacy_project_manifest.mjs <legacy-workspaces-root>");
const root = await realpath(rootArgument);
const projectFiles = await findProjectFiles(root);
const seenIds = new Set();
const manifest = [];
for (const projectFile of projectFiles) {
const metadata = JSON.parse(await readFile(projectFile, "utf8"));
if (typeof metadata.id !== "string" || metadata.id.trim() === "") {
throw new Error(`project metadata has no id: ${projectFile}`);
}
if (seenIds.has(metadata.id)) throw new Error(`duplicate project id: ${metadata.id}`);
seenIds.add(metadata.id);
if (typeof metadata.name !== "string" || metadata.name.trim() === "") {
throw new Error(`project metadata has no name: ${projectFile}`);
}
const projectRoot = dirname(projectFile);
const sourceRelativePath = relative(root, projectRoot).split(sep).join("/");
const physicalFolderPath = dirname(sourceRelativePath) === "."
? []
: dirname(sourceRelativePath).split("/");
if (metadata.folderPath !== undefined && (
!Array.isArray(metadata.folderPath)
|| metadata.folderPath.some((part) => typeof part !== "string")
|| JSON.stringify(metadata.folderPath) !== JSON.stringify(physicalFolderPath)
)) {
process.stderr.write(`[legacy-manifest] stale metadata folderPath; using physical path: ${projectFile}\n`);
}
manifest.push({
legacyId: metadata.id,
name: metadata.name,
folderPath: physicalFolderPath,
sourceRelativePath,
});
}
manifest.sort((left, right) => left.sourceRelativePath.localeCompare(right.sourceRelativePath, "zh-CN"));
process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`);
async function findProjectFiles(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const projectMetadata = entries.find((entry) => entry.isFile() && entry.name === "project.json");
if (projectMetadata !== undefined) return [resolve(directory, projectMetadata.name)];
const found = [];
for (const entry of entries) {
if (entry.name === ".trash") continue;
const path = resolve(directory, entry.name);
if (entry.isSymbolicLink()) {
process.stderr.write(`[legacy-manifest] skip untracked symbolic link: ${path}\n`);
continue;
}
if (entry.isDirectory()) found.push(...await findProjectFiles(path));
}
return found;
}
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.20", "version": "0.0.25",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.20", "version": "0.0.25",
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.202", "@anthropic-ai/claude-agent-sdk": "^0.3.202",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.20", "version": "0.0.25",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
@@ -0,0 +1,191 @@
-- Derived project discovery projection. Project/Folder remain authoritative;
-- triggers prevent index drift through ordinary database mutations.
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE TYPE "FolderKind" AS ENUM ('REGULAR', 'SYSTEM_INBOX');
ALTER TABLE "Folder" ADD COLUMN "kind" "FolderKind" NOT NULL DEFAULT 'REGULAR';
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM "Folder"
WHERE "parentId" IS NULL AND "name" = 'Inbox' AND "archivedAt" IS NULL
GROUP BY "organizationId" HAVING count(*) > 1
) THEN
RAISE EXCEPTION 'cannot identify system Inbox: organization has multiple active root Inbox folders';
END IF;
END $$;
UPDATE "Folder" f SET "kind" = 'SYSTEM_INBOX'
WHERE f."parentId" IS NULL AND f."name" = 'Inbox' AND f."archivedAt" IS NULL;
INSERT INTO "Folder" ("id", "organizationId", "parentId", "name", "kind", "sortKey", "createdAt", "updatedAt")
SELECT o."id" || ':system-inbox', o."id", NULL, 'Inbox', 'SYSTEM_INBOX', '000000', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM "Organization" o
WHERE NOT EXISTS (
SELECT 1 FROM "Folder" f
WHERE f."organizationId" = o."id" AND f."kind" = 'SYSTEM_INBOX' AND f."archivedAt" IS NULL
);
CREATE UNIQUE INDEX "Folder_one_active_system_inbox_per_org"
ON "Folder"("organizationId") WHERE "kind" = 'SYSTEM_INBOX' AND "archivedAt" IS NULL;
CREATE OR REPLACE FUNCTION cph_protect_system_inbox() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'UPDATE' AND OLD."kind" = 'SYSTEM_INBOX' AND (
NEW."id" IS DISTINCT FROM OLD."id" OR
NEW."organizationId" IS DISTINCT FROM OLD."organizationId" OR
NEW."kind" IS DISTINCT FROM OLD."kind" OR
NEW."name" IS DISTINCT FROM OLD."name" OR
NEW."parentId" IS DISTINCT FROM OLD."parentId" OR
NEW."archivedAt" IS DISTINCT FROM OLD."archivedAt"
) THEN
RAISE EXCEPTION 'system Inbox identity cannot be changed';
END IF;
IF TG_OP IN ('INSERT', 'UPDATE') AND NEW."kind" = 'SYSTEM_INBOX' AND (
NEW."name" <> 'Inbox' OR NEW."parentId" IS NOT NULL OR NEW."archivedAt" IS NOT NULL
) THEN
RAISE EXCEPTION 'system Inbox must be an active root folder named Inbox';
END IF;
IF TG_OP = 'DELETE' AND OLD."kind" = 'SYSTEM_INBOX' AND EXISTS (
SELECT 1 FROM "Organization" WHERE "id" = OLD."organizationId"
) THEN
RAISE EXCEPTION 'system Inbox cannot be deleted while its organization exists';
END IF;
RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
END;
$$;
CREATE TRIGGER cph_protect_system_inbox
BEFORE INSERT OR UPDATE OR DELETE ON "Folder"
FOR EACH ROW EXECUTE FUNCTION cph_protect_system_inbox();
ALTER TABLE "Project" ADD COLUMN "code" TEXT;
CREATE TABLE "ProjectSearchDocument" (
"projectId" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"code" TEXT,
"normalizedCode" TEXT NOT NULL,
"normalizedName" TEXT NOT NULL,
"breadcrumb" TEXT NOT NULL,
"normalizedBreadcrumb" TEXT NOT NULL,
"normalizedSearchText" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ProjectSearchDocument_pkey" PRIMARY KEY ("projectId")
);
CREATE INDEX "ProjectSearchDocument_organizationId_idx" ON "ProjectSearchDocument"("organizationId");
CREATE INDEX "ProjectSearchDocument_normalizedName_trgm_idx"
ON "ProjectSearchDocument" USING GIN ("normalizedName" gin_trgm_ops);
CREATE INDEX "ProjectSearchDocument_normalizedCode_trgm_idx"
ON "ProjectSearchDocument" USING GIN ("normalizedCode" gin_trgm_ops);
CREATE INDEX "ProjectSearchDocument_normalizedBreadcrumb_trgm_idx"
ON "ProjectSearchDocument" USING GIN ("normalizedBreadcrumb" gin_trgm_ops);
CREATE INDEX "ProjectSearchDocument_normalizedSearchText_trgm_idx"
ON "ProjectSearchDocument" USING GIN ("normalizedSearchText" gin_trgm_ops);
ALTER TABLE "ProjectSearchDocument" ADD CONSTRAINT "ProjectSearchDocument_projectId_fkey"
FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ProjectSearchDocument" ADD CONSTRAINT "ProjectSearchDocument_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE OR REPLACE FUNCTION cph_project_search_normalize(value TEXT) RETURNS TEXT
LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS $$
SELECT lower(regexp_replace(normalize(value, NFKC), '[[:space:]_.:/\\-]+', '', 'g'))
$$;
CREATE OR REPLACE FUNCTION cph_folder_breadcrumb(folder_id TEXT) RETURNS TEXT
LANGUAGE sql STABLE PARALLEL SAFE AS $$
WITH RECURSIVE ancestors AS (
SELECT f."id", f."parentId", f."name", f."kind", 0 AS depth
FROM "Folder" f
WHERE f."id" = folder_id
UNION ALL
SELECT parent."id", parent."parentId", parent."name", parent."kind", child.depth + 1
FROM "Folder" parent
JOIN ancestors child ON parent."id" = child."parentId"
)
SELECT CASE
WHEN count(*) = 1 AND bool_and("kind" = 'SYSTEM_INBOX') THEN '未分类'
ELSE coalesce(string_agg("name", ' / ' ORDER BY depth DESC), '')
END
FROM ancestors
$$;
CREATE OR REPLACE FUNCTION cph_project_search_code(explicit_code TEXT, project_name TEXT) RETURNS TEXT
LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
SELECT CASE
WHEN explicit_code IS NOT NULL AND btrim(explicit_code) <> ''
THEN cph_project_search_normalize(explicit_code)
ELSE coalesce(substring(cph_project_search_normalize(project_name) FROM '^[a-z]+[0-9]+'), '')
END
$$;
CREATE OR REPLACE FUNCTION cph_refresh_project_search_document(target_project_id TEXT) RETURNS void
LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO "ProjectSearchDocument" (
"projectId", "organizationId", "name", "code", "normalizedCode", "normalizedName",
"breadcrumb", "normalizedBreadcrumb", "normalizedSearchText", "updatedAt"
)
SELECT p."id", p."organizationId", p."name", p."code",
cph_project_search_code(p."code", p."name"),
cph_project_search_normalize(p."name"),
cph_folder_breadcrumb(p."folderId"),
cph_project_search_normalize(cph_folder_breadcrumb(p."folderId")),
cph_project_search_normalize(cph_folder_breadcrumb(p."folderId") || ' ' || p."name"),
CURRENT_TIMESTAMP
FROM "Project" p WHERE p."id" = target_project_id
ON CONFLICT ("projectId") DO UPDATE SET
"organizationId" = EXCLUDED."organizationId",
"name" = EXCLUDED."name",
"code" = EXCLUDED."code",
"normalizedCode" = EXCLUDED."normalizedCode",
"normalizedName" = EXCLUDED."normalizedName",
"breadcrumb" = EXCLUDED."breadcrumb",
"normalizedBreadcrumb" = EXCLUDED."normalizedBreadcrumb",
"normalizedSearchText" = EXCLUDED."normalizedSearchText",
"updatedAt" = CURRENT_TIMESTAMP;
END;
$$;
CREATE OR REPLACE FUNCTION cph_project_search_project_trigger() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
PERFORM cph_refresh_project_search_document(NEW."id");
RETURN NEW;
END;
$$;
CREATE TRIGGER cph_project_search_project_changed
AFTER INSERT OR UPDATE OF "name", "code", "folderId", "organizationId", "archivedAt" ON "Project"
FOR EACH ROW EXECUTE FUNCTION cph_project_search_project_trigger();
CREATE OR REPLACE FUNCTION cph_project_search_folder_trigger() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE project_id TEXT;
BEGIN
FOR project_id IN
WITH RECURSIVE descendants AS (
SELECT NEW."id"
UNION ALL
SELECT child."id" FROM "Folder" child
JOIN descendants parent ON child."parentId" = parent."id"
)
SELECT p."id" FROM "Project" p
WHERE p."folderId" IN (SELECT "id" FROM descendants)
LOOP
PERFORM cph_refresh_project_search_document(project_id);
END LOOP;
RETURN NEW;
END;
$$;
CREATE TRIGGER cph_project_search_folder_changed
AFTER UPDATE OF "name", "kind", "parentId", "archivedAt" ON "Folder"
FOR EACH ROW EXECUTE FUNCTION cph_project_search_folder_trigger();
SELECT cph_refresh_project_search_document("id") FROM "Project";
@@ -0,0 +1,65 @@
-- ADR-0017: roles are selected through the project-group control plane, not
-- through slash-command names. Existing alpha Organizations keep draft as
-- their configured default during migration; runtime code no longer hard-codes it.
ALTER TABLE "OrganizationAgentRole"
ADD COLUMN "isDefault" BOOLEAN NOT NULL DEFAULT false;
-- An Organization without any role was valid in the previous schema (the
-- runtime failed closed later). Preserve the old alpha baseline so every
-- existing binding can acquire a selected role during this migration.
INSERT INTO "OrganizationAgentRole" (
"id", "organizationId", "roleId", "label", "sortOrder", "isDefault", "updatedAt"
)
SELECT organization."id" || ':agent-role:draft', organization."id", 'draft', '草稿', 10, true, CURRENT_TIMESTAMP
FROM "Organization" organization
WHERE NOT EXISTS (
SELECT 1 FROM "OrganizationAgentRole" role
WHERE role."organizationId" = organization."id" AND role."disabledAt" IS NULL
);
WITH ranked AS (
SELECT "id", row_number() OVER (
PARTITION BY "organizationId"
ORDER BY CASE WHEN "roleId" = 'draft' THEN 0 ELSE 1 END, "sortOrder", "roleId", "id"
) AS position
FROM "OrganizationAgentRole"
WHERE "disabledAt" IS NULL
)
UPDATE "OrganizationAgentRole" role
SET "isDefault" = (ranked.position = 1)
FROM ranked
WHERE role."id" = ranked."id";
CREATE UNIQUE INDEX "OrganizationAgentRole_one_active_default_per_org"
ON "OrganizationAgentRole"("organizationId")
WHERE "isDefault" = true AND "disabledAt" IS NULL;
ALTER TABLE "ProjectGroupBinding"
ADD COLUMN "selectedAgentRoleId" TEXT;
UPDATE "ProjectGroupBinding" binding
SET "selectedAgentRoleId" = role."id"
FROM "Project" project
JOIN "OrganizationAgentRole" role
ON role."organizationId" = project."organizationId"
AND role."isDefault" = true
AND role."disabledAt" IS NULL
WHERE binding."projectId" = project."id";
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM "ProjectGroupBinding" WHERE "selectedAgentRoleId" IS NULL) THEN
RAISE EXCEPTION 'cannot migrate project-group bindings without an active default Agent role';
END IF;
END $$;
ALTER TABLE "ProjectGroupBinding"
ALTER COLUMN "selectedAgentRoleId" SET NOT NULL;
CREATE INDEX "ProjectGroupBinding_selectedAgentRoleId_idx"
ON "ProjectGroupBinding"("selectedAgentRoleId");
ALTER TABLE "ProjectGroupBinding"
ADD CONSTRAINT "ProjectGroupBinding_selectedAgentRoleId_fkey"
FOREIGN KEY ("selectedAgentRoleId") REFERENCES "OrganizationAgentRole"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,41 @@
-- ADR-0017 / ADR-0020: enforce the selected role's Organization at the
-- database boundary. A role primary key alone cannot prove tenant scope.
ALTER TABLE "ProjectGroupBinding"
ADD COLUMN "organizationId" TEXT;
UPDATE "ProjectGroupBinding" binding
SET "organizationId" = project."organizationId"
FROM "Project" project
WHERE project."id" = binding."projectId";
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM "ProjectGroupBinding" WHERE "organizationId" IS NULL) THEN
RAISE EXCEPTION 'cannot tenant-scope project-group binding without a project Organization';
END IF;
END $$;
ALTER TABLE "ProjectGroupBinding"
ALTER COLUMN "organizationId" SET NOT NULL;
CREATE UNIQUE INDEX "Project_organizationId_id_key"
ON "Project"("organizationId", "id");
ALTER TABLE "ProjectGroupBinding"
DROP CONSTRAINT "ProjectGroupBinding_projectId_fkey",
DROP CONSTRAINT "ProjectGroupBinding_selectedAgentRoleId_fkey";
ALTER TABLE "ProjectGroupBinding"
ADD CONSTRAINT "ProjectGroupBinding_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id")
ON DELETE CASCADE ON UPDATE CASCADE,
ADD CONSTRAINT "ProjectGroupBinding_organizationId_projectId_fkey"
FOREIGN KEY ("organizationId", "projectId") REFERENCES "Project"("organizationId", "id")
ON DELETE CASCADE ON UPDATE CASCADE,
ADD CONSTRAINT "ProjectGroupBinding_organizationId_selectedAgentRoleId_fkey"
FOREIGN KEY ("organizationId", "selectedAgentRoleId")
REFERENCES "OrganizationAgentRole"("organizationId", "id")
ON DELETE RESTRICT ON UPDATE CASCADE;
CREATE INDEX "ProjectGroupBinding_organizationId_idx"
ON "ProjectGroupBinding"("organizationId");
@@ -0,0 +1,31 @@
-- ADR-0017: once an Organization starts configuring roles, every committed
-- state must contain exactly one active default. The deferred trigger permits
-- an atomic default switch while rejecting zero-default transitions.
CREATE FUNCTION cph_enforce_agent_role_default() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE
target_organization_id TEXT := COALESCE(NEW."organizationId", OLD."organizationId");
active_default_count INTEGER;
BEGIN
IF NOT EXISTS (SELECT 1 FROM "Organization" WHERE "id" = target_organization_id) THEN
RETURN NULL;
END IF;
SELECT count(*) INTO active_default_count
FROM "OrganizationAgentRole"
WHERE "organizationId" = target_organization_id
AND "isDefault" = true
AND "disabledAt" IS NULL;
IF active_default_count <> 1 THEN
RAISE EXCEPTION 'Organization % must have exactly one active default Agent role; found %',
target_organization_id, active_default_count;
END IF;
RETURN NULL;
END;
$$;
CREATE CONSTRAINT TRIGGER "OrganizationAgentRole_exactly_one_active_default"
AFTER INSERT OR UPDATE OR DELETE ON "OrganizationAgentRole"
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION cph_enforce_agent_role_default();
@@ -0,0 +1,30 @@
-- Organization-owned role configuration cannot be re-parented. Besides being
-- a tenant boundary, immutability ensures the deferred default-role invariant
-- checks the same Organization before and after an update.
CREATE OR REPLACE FUNCTION cph_enforce_agent_role_default() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE
target_organization_id TEXT := COALESCE(NEW."organizationId", OLD."organizationId");
active_default_count INTEGER;
BEGIN
IF TG_OP = 'UPDATE' AND NEW."organizationId" <> OLD."organizationId" THEN
RAISE EXCEPTION 'OrganizationAgentRole.organizationId is immutable';
END IF;
IF NOT EXISTS (SELECT 1 FROM "Organization" WHERE "id" = target_organization_id) THEN
RETURN NULL;
END IF;
SELECT count(*) INTO active_default_count
FROM "OrganizationAgentRole"
WHERE "organizationId" = target_organization_id
AND "isDefault" = true
AND "disabledAt" IS NULL;
IF active_default_count <> 1 THEN
RAISE EXCEPTION 'Organization % must have exactly one active default Agent role; found %',
target_organization_id, active_default_count;
END IF;
RETURN NULL;
END;
$$;
@@ -0,0 +1,25 @@
-- ADR-0017's default-role function is total over Organizations. Enforce the
-- other side of the invariant when an Organization itself is created.
CREATE FUNCTION cph_enforce_organization_default_role() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE
active_default_count INTEGER;
BEGIN
SELECT count(*) INTO active_default_count
FROM "OrganizationAgentRole"
WHERE "organizationId" = NEW."id"
AND "isDefault" = true
AND "disabledAt" IS NULL;
IF active_default_count <> 1 THEN
RAISE EXCEPTION 'Organization % must have exactly one active default Agent role; found %',
NEW."id", active_default_count;
END IF;
RETURN NULL;
END;
$$;
CREATE CONSTRAINT TRIGGER "Organization_requires_active_default_role"
AFTER INSERT ON "Organization"
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION cph_enforce_organization_default_role();
+41 -1
View File
@@ -45,7 +45,9 @@ model Organization {
feishuApplicationConnection OrganizationFeishuApplicationConnection? feishuApplicationConnection OrganizationFeishuApplicationConnection?
agentSkills OrganizationAgentSkill[] agentSkills OrganizationAgentSkill[]
agentRoles OrganizationAgentRole[] agentRoles OrganizationAgentRole[]
projectGroupBindings ProjectGroupBinding[]
auditEntries AuditEntry[] @relation("organizationAudit") auditEntries AuditEntry[] @relation("organizationAudit")
projectSearchDocuments ProjectSearchDocument[]
@@index([status]) @@index([status])
} }
@@ -115,12 +117,14 @@ model OrganizationAgentRole {
systemPrompt String? systemPrompt String?
tools Json? tools Json?
sortOrder Int @default(0) sortOrder Int @default(0)
isDefault Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
disabledAt DateTime? disabledAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
skillBindings OrganizationAgentRoleSkill[] skillBindings OrganizationAgentRoleSkill[]
selectedByBindings ProjectGroupBinding[] @relation("selectedAgentRole")
@@unique([organizationId, roleId]) @@unique([organizationId, roleId])
@@unique([organizationId, id]) @@unique([organizationId, id])
@@ -437,11 +441,17 @@ model ExternalPrincipalMembership {
/// ADR-0021: transparent project explorer folder. Folders are org-scoped /// ADR-0021: transparent project explorer folder. Folders are org-scoped
/// navigation/aggregation nodes, not permission resources; project grants stay /// navigation/aggregation nodes, not permission resources; project grants stay
/// attached to PROJECT resources. /// attached to PROJECT resources.
enum FolderKind {
REGULAR
SYSTEM_INBOX
}
model Folder { model Folder {
id String @id @default(cuid()) id String @id @default(cuid())
organizationId String organizationId String
parentId String? parentId String?
name String name String
kind FolderKind @default(REGULAR)
sortKey String @default("") sortKey String @default("")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -460,6 +470,7 @@ model Project {
id String @id @default(cuid()) id String @id @default(cuid())
organizationId String organizationId String
folderId String? folderId String?
code String?
name String name String
workspaceDir String workspaceDir String
createdByUserId String? createdByUserId String?
@@ -477,29 +488,58 @@ model Project {
roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants") roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants")
auditEntries AuditEntry[] @relation("projectAudit") auditEntries AuditEntry[] @relation("projectAudit")
fileChanges AgentFileChange[] @relation("projectFileChanges") fileChanges AgentFileChange[] @relation("projectFileChanges")
searchDocument ProjectSearchDocument?
@@unique([organizationId, id])
@@index([organizationId, archivedAt]) @@index([organizationId, archivedAt])
@@index([folderId, archivedAt]) @@index([folderId, archivedAt])
@@index([archivedAt]) @@index([archivedAt])
} }
/// Derived, rebuildable search projection for project discovery. PostgreSQL
/// triggers keep it synchronized with Project and Folder mutations; Project
/// remains the source of truth and authorization remains outside this table.
model ProjectSearchDocument {
projectId String @id
organizationId String
name String
code String?
normalizedCode String
normalizedName String
breadcrumb String
normalizedBreadcrumb String
normalizedSearchText String
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@index([organizationId])
}
/// ADR-0001 + ADR-0021: active bindings are one project ↔ one Feishu chat /// ADR-0001 + ADR-0021: active bindings are one project ↔ one Feishu chat
/// (1:1). Historical archived bindings are retained for audit; partial unique /// (1:1). Historical archived bindings are retained for audit; partial unique
/// indexes in migrations enforce one active binding per project and per chat. /// indexes in migrations enforce one active binding per project and per chat.
model ProjectGroupBinding { model ProjectGroupBinding {
id String @id @default(cuid()) id String @id @default(cuid())
organizationId String
projectId String projectId String
chatId String chatId String
createdByUserId String? createdByUserId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
archivedAt DateTime? archivedAt DateTime?
selectedAgentRoleId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
project Project @relation(fields: [organizationId, projectId], references: [organizationId, id], onDelete: Cascade)
createdBy User? @relation("bindingCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) createdBy User? @relation("bindingCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
selectedRole OrganizationAgentRole @relation("selectedAgentRole", fields: [organizationId, selectedAgentRoleId], references: [organizationId, id], onDelete: Restrict)
@@index([organizationId])
@@index([projectId, archivedAt]) @@index([projectId, archivedAt])
@@index([chatId, archivedAt]) @@index([chatId, archivedAt])
@@index([selectedAgentRoleId])
} }
// --- AgentRun, session, lock (ADR-0002, 0017) ----------------------------- // --- AgentRun, session, lock (ADR-0002, 0017) -----------------------------
+41 -4
View File
@@ -89,6 +89,7 @@ export class OrganizationAgentConfiguration {
readonly systemPrompt?: string | null | undefined; readonly systemPrompt?: string | null | undefined;
readonly tools?: readonly string[] | null | undefined; readonly tools?: readonly string[] | null | undefined;
readonly sortOrder?: number | undefined; readonly sortOrder?: number | undefined;
readonly isDefault?: boolean | undefined;
}): Promise<{ readonly id: string; readonly roleId: string }> { }): Promise<{ readonly id: string; readonly roleId: string }> {
await this.requireActiveOrganization(input.organizationId); await this.requireActiveOrganization(input.organizationId);
if (!ROLE_ID_PATTERN.test(input.roleId)) throw new Error(`invalid role id: ${input.roleId}`); if (!ROLE_ID_PATTERN.test(input.roleId)) throw new Error(`invalid role id: ${input.roleId}`);
@@ -107,8 +108,22 @@ export class OrganizationAgentConfiguration {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
const previous = await tx.organizationAgentRole.findUnique({ const previous = await tx.organizationAgentRole.findUnique({
where: { organizationId_roleId: { organizationId: input.organizationId, roleId: input.roleId } }, where: { organizationId_roleId: { organizationId: input.organizationId, roleId: input.roleId } },
select: { defaultModel: true, systemPrompt: true, tools: true }, select: { defaultModel: true, systemPrompt: true, tools: true, isDefault: true },
}); });
const currentDefault = await tx.organizationAgentRole.findFirst({
where: { organizationId: input.organizationId, isDefault: true, disabledAt: null },
select: { id: true },
});
const effectiveIsDefault = input.isDefault ?? previous?.isDefault ?? (currentDefault === null);
if (input.isDefault === false && previous?.isDefault === true) {
throw new Error("cannot unset the active default role without selecting a replacement");
}
if (effectiveIsDefault) {
await tx.organizationAgentRole.updateMany({
where: { organizationId: input.organizationId, isDefault: true },
data: { isDefault: false },
});
}
const role = await tx.organizationAgentRole.upsert({ const role = await tx.organizationAgentRole.upsert({
where: { where: {
organizationId_roleId: { organizationId_roleId: {
@@ -124,6 +139,7 @@ export class OrganizationAgentConfiguration {
systemPrompt: normalizeOptionalText(input.systemPrompt), systemPrompt: normalizeOptionalText(input.systemPrompt),
tools: createTools, tools: createTools,
sortOrder, sortOrder,
isDefault: effectiveIsDefault,
}, },
update: { update: {
label, label,
@@ -131,6 +147,7 @@ export class OrganizationAgentConfiguration {
...(input.systemPrompt !== undefined ? { systemPrompt: normalizeOptionalText(input.systemPrompt) } : {}), ...(input.systemPrompt !== undefined ? { systemPrompt: normalizeOptionalText(input.systemPrompt) } : {}),
...(updateTools !== undefined ? { tools: updateTools } : {}), ...(updateTools !== undefined ? { tools: updateTools } : {}),
sortOrder, sortOrder,
isDefault: effectiveIsDefault,
disabledAt: null, disabledAt: null,
}, },
select: { id: true, roleId: true }, select: { id: true, roleId: true },
@@ -140,6 +157,12 @@ export class OrganizationAgentConfiguration {
(input.systemPrompt !== undefined && normalizeOptionalText(input.systemPrompt) !== previous.systemPrompt) || (input.systemPrompt !== undefined && normalizeOptionalText(input.systemPrompt) !== previous.systemPrompt) ||
(input.tools !== undefined && JSON.stringify(input.tools) !== JSON.stringify(previous.tools)) (input.tools !== undefined && JSON.stringify(input.tools) !== JSON.stringify(previous.tools))
); );
const activeDefaultCount = await tx.organizationAgentRole.count({
where: { organizationId: input.organizationId, isDefault: true, disabledAt: null },
});
if (activeDefaultCount !== 1) {
throw new Error(`organization ${input.organizationId} must have exactly one active default role`);
}
if (executionSurfaceChanged) await archiveRoleSessions(tx, input.organizationId, [input.roleId]); if (executionSurfaceChanged) await archiveRoleSessions(tx, input.organizationId, [input.roleId]);
await tx.auditEntry.create({ await tx.auditEntry.create({
data: { data: {
@@ -154,6 +177,8 @@ export class OrganizationAgentConfiguration {
: normalizeOptionalText(input.systemPrompt) !== null, : normalizeOptionalText(input.systemPrompt) !== null,
tools: input.tools === undefined ? "unchanged" : input.tools === null ? "all" : [...input.tools], tools: input.tools === undefined ? "unchanged" : input.tools === null ? "all" : [...input.tools],
sortOrder, sortOrder,
isDefault: effectiveIsDefault,
defaultExplicit: input.isDefault !== undefined,
}, },
}, },
}); });
@@ -237,14 +262,26 @@ async function archiveRoleSessions(
roleIds: readonly string[], roleIds: readonly string[],
): Promise<void> { ): Promise<void> {
if (roleIds.length === 0) return; if (roleIds.length === 0) return;
await tx.agentSession.updateMany({ const sessions = await tx.agentSession.findMany({
where: { where: {
roleId: { in: [...new Set(roleIds)] }, roleId: { in: [...new Set(roleIds)] },
archivedAt: null,
project: { organizationId }, project: { organizationId },
}, },
data: { archivedAt: new Date() }, select: { id: true, archivedAt: true, metadata: true },
}); });
const archivedAt = new Date();
for (const session of sessions) {
const metadata = typeof session.metadata === "object" && session.metadata !== null && !Array.isArray(session.metadata)
? session.metadata as Prisma.JsonObject
: {};
await tx.agentSession.update({
where: { id: session.id },
data: {
...(session.archivedAt === null ? { archivedAt } : {}),
metadata: { ...metadata, userResumable: false },
},
});
}
} }
function nonEmpty(value: string, label: string): string { function nonEmpty(value: string, label: string): string {
+2 -2
View File
@@ -12,8 +12,8 @@
* A named role preset — the full per-run agent bundle. Roles are **data**, not * 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 * 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 * product config, not a spec invariant). `roleId` is an opaque string and
* doubles as the slash-command name (`/draft ...`) — the registry holds the * is selected through the project console — the registry holds the role set,
* role set, so new roles are added by configuration, not by editing code. * so new roles are added by configuration, not by editing code.
* *
* A role bundles everything that distinguishes one agent persona from another: * A role bundles everything that distinguishes one agent persona from another:
* model, system prompt, and the tool surface (files / cph / feishu / skills / * model, system prompt, and the tool surface (files / cph / feishu / skills /
+3
View File
@@ -293,6 +293,9 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
case "result": { case "result": {
const result = message as SDKResultMessage; const result = message as SDKResultMessage;
sdkSessionId = result.session_id; sdkSessionId = result.session_id;
if (result.subtype === "success" && fullText === "" && typeof result.result === "string") {
fullText = result.result;
}
costUsd = Number.isFinite(result.total_cost_usd) ? result.total_cost_usd : undefined; costUsd = Number.isFinite(result.total_cost_usd) ? result.total_cost_usd : undefined;
if (result.subtype !== "success") { if (result.subtype !== "success") {
error = `result_${result.subtype}`; error = `result_${result.subtype}`;
+9 -1
View File
@@ -47,6 +47,7 @@ async function main(argv: readonly string[]): Promise<void> {
: {}), : {}),
...(tools !== undefined ? { tools } : {}), ...(tools !== undefined ? { tools } : {}),
...(sortOrderRaw !== undefined ? { sortOrder: integer(sortOrderRaw, "sort-order") } : {}), ...(sortOrderRaw !== undefined ? { sortOrder: integer(sortOrderRaw, "sort-order") } : {}),
...(options.has("default") ? { isDefault: boolean(options.get("default")!, "default") } : {}),
}); });
console.log(JSON.stringify(role)); console.log(JSON.stringify(role));
return; return;
@@ -79,6 +80,7 @@ async function main(argv: readonly string[]): Promise<void> {
systemPromptConfigured: role.systemPrompt !== null, systemPromptConfigured: role.systemPrompt !== null,
tools: role.tools, tools: role.tools,
disabled: role.disabledAt !== null, disabled: role.disabledAt !== null,
default: role.isDefault,
skills: role.skillBindings.map((binding) => ({ skills: role.skillBindings.map((binding) => ({
name: binding.skill.name, name: binding.skill.name,
version: binding.skill.version, version: binding.skill.version,
@@ -138,10 +140,16 @@ function integer(raw: string, name: string): number {
return value; return value;
} }
function boolean(raw: string, name: string): boolean {
if (raw === "true") return true;
if (raw === "false") return false;
throw new Error(`--${name} must be true or false`);
}
function printHelp(): void { function printHelp(): void {
console.log(`Usage: console.log(`Usage:
agent-config install-skill --organization ORG --source DIR --version VERSION agent-config install-skill --organization ORG --source DIR --version VERSION
agent-config upsert-role --organization ORG --role ID --label LABEL [--model MODEL] [--system-prompt-file FILE] [--tools-json JSON] [--sort-order N] agent-config upsert-role --organization ORG --role ID --label LABEL [--model MODEL] [--system-prompt-file FILE] [--tools-json JSON] [--sort-order N] [--default true|false]
agent-config set-role-skills --organization ORG --role ID --skills name,name agent-config set-role-skills --organization ORG --role ID --skills name,name
agent-config list --organization ORG agent-config list --organization ORG
agent-config verify-store --organization ORG`); agent-config verify-store --organization ORG`);
+1
View File
@@ -233,6 +233,7 @@ async function initializeSilo(
roleId: "draft", roleId: "draft",
label: "草稿", label: "草稿",
sortOrder: 10, sortOrder: 10,
isDefault: true,
}, },
{ {
organizationId: input.organization.id, organizationId: input.organization.id,
@@ -0,0 +1,48 @@
import { readFile } from "node:fs/promises";
import { prisma } from "../db.js";
import { importLegacyProjects, type LegacyProjectManifestEntry } from "./legacyProjectImport.js";
import { readSiloOrganizationId } from "./silo.js";
async function main(argv: readonly string[]): Promise<void> {
const options = parseOptions(argv);
const organizationId = readSiloOrganizationId();
const manifest = JSON.parse(await readFile(required(options, "manifest"), "utf8")) as unknown;
if (!Array.isArray(manifest)) throw new Error("legacy import manifest must be a JSON array");
const state = await importLegacyProjects({
prisma,
organizationId,
actorFeishuOpenId: required(options, "actor-open-id"),
workspaceRoot: required(options, "workspace-root"),
sourceRoot: required(options, "source-root"),
stateFile: required(options, "state-file"),
projects: manifest as LegacyProjectManifestEntry[],
onProgress: (message) => console.error(`[legacy-import] ${message}`),
});
console.log(JSON.stringify({ imported: Object.keys(state.projects).length }));
}
function parseOptions(args: readonly string[]): Map<string, string> {
const options = new Map<string, string>();
for (let index = 0; index < args.length; index += 2) {
const flag = args[index];
const value = args[index + 1];
if (flag === undefined || !flag.startsWith("--") || value === undefined) {
throw new Error(`expected --name value, got: ${args.slice(index).join(" ")}`);
}
options.set(flag.slice(2), value);
}
return options;
}
function required(options: ReadonlyMap<string, string>, name: string): string {
const value = options.get(name)?.trim();
if (!value) throw new Error(`--${name} is required`);
return value;
}
main(process.argv.slice(2))
.catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
})
.finally(async () => prisma.$disconnect());
+369
View File
@@ -0,0 +1,369 @@
import { createHash } from "node:crypto";
import { cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import type { PrismaClient } from "@prisma/client";
import { createFolder, createProjectFromOrgAdmin } from "../projectOnboarding.js";
export interface LegacyProjectManifestEntry {
readonly legacyId: string;
readonly name: string;
readonly folderPath: readonly string[];
readonly sourceRelativePath: string;
}
export interface LegacyProjectImportState {
readonly version: 1;
readonly projects: Readonly<Record<string, {
readonly status: "PENDING" | "COMPLETED";
readonly projectId: string;
readonly workspaceDir: string;
readonly importedAt: string;
readonly sourceRelativePath: string;
}>>;
}
export async function importLegacyProjects(input: {
readonly prisma: PrismaClient;
readonly organizationId: string;
readonly actorFeishuOpenId: string;
readonly workspaceRoot: string;
readonly sourceRoot: string;
readonly stateFile: string;
readonly projects: readonly LegacyProjectManifestEntry[];
readonly onProgress?: (message: string) => void;
}): Promise<LegacyProjectImportState> {
const sourceRoot = await realpath(input.sourceRoot);
const state = await readState(input.stateFile);
const projects = { ...state.projects };
const seen = new Set<string>();
for (const entry of input.projects) {
validateEntry(entry);
if (seen.has(entry.legacyId)) throw new Error(`duplicate legacy project id: ${entry.legacyId}`);
seen.add(entry.legacyId);
const sourceDir = await confinedSourceDir(sourceRoot, entry.sourceRelativePath);
const projectId = importedProjectId(input.organizationId, entry.legacyId);
const existing = await input.prisma.project.findUnique({ where: { id: projectId } });
const recorded = projects[entry.legacyId];
if (recorded !== undefined && (recorded.projectId !== projectId || recorded.sourceRelativePath !== entry.sourceRelativePath)) {
throw new Error(`legacy import state identity conflict: ${entry.legacyId}`);
}
if (recorded?.status === "COMPLETED" && existing === null) {
throw new Error(`legacy import state references missing project: ${entry.legacyId} -> ${recorded.projectId}`);
}
if (existing !== null) {
if (existing.organizationId !== input.organizationId || (recorded !== undefined && recorded.projectId !== existing.id)) {
throw new Error(`legacy import identity conflict: ${entry.legacyId} -> ${existing.id}`);
}
if (await hasCompletionMarker(existing.workspaceDir, entry)) {
await ensureImportAudit(input.prisma, existing.id, entry);
projects[entry.legacyId] = {
status: "COMPLETED",
projectId: existing.id,
workspaceDir: existing.workspaceDir,
importedAt: recorded?.importedAt || new Date().toISOString(),
sourceRelativePath: entry.sourceRelativePath,
};
await writeState(input.stateFile, { version: 1, projects });
input.onProgress?.(`skip ${entry.legacyId}: recovered completed import`);
continue;
}
if (recorded?.status !== "PENDING") {
throw new Error(`refusing to remove legacy project without a matching pending record: ${entry.legacyId}`);
}
input.onProgress?.(`recover ${entry.legacyId}: remove incomplete target`);
await removeIncompleteTarget(input.prisma, existing.id, existing.workspaceDir, input.workspaceRoot);
}
projects[entry.legacyId] = {
status: "PENDING",
projectId,
workspaceDir: "",
importedAt: "",
sourceRelativePath: entry.sourceRelativePath,
};
await writeState(input.stateFile, { version: 1, projects });
const folderId = await ensureFolderPath(input.prisma, input.organizationId, ["旧教学资产", ...entry.folderPath]);
input.onProgress?.(`import ${entry.legacyId}: ${entry.name}`);
const created = await createProjectFromOrgAdmin(input.prisma, {
organizationId: input.organizationId,
actorFeishuOpenId: input.actorFeishuOpenId,
name: entry.name,
workspaceRoot: input.workspaceRoot,
folderId,
projectId,
});
try {
await copyLegacyProject(sourceDir, created.workspaceDir, entry);
} catch (error) {
try {
await removeIncompleteTarget(input.prisma, created.projectId, created.workspaceDir, input.workspaceRoot);
delete projects[entry.legacyId];
await writeState(input.stateFile, { version: 1, projects });
} catch (cleanupError) {
throw new AggregateError(
[error, cleanupError],
`legacy project import and target cleanup failed: ${entry.legacyId}`,
);
}
throw new Error(`legacy project import failed: ${entry.legacyId}: ${errorMessage(error)}`, { cause: error });
}
await ensureImportAudit(input.prisma, created.projectId, entry);
projects[entry.legacyId] = {
status: "COMPLETED",
projectId: created.projectId,
workspaceDir: created.workspaceDir,
importedAt: new Date().toISOString(),
sourceRelativePath: entry.sourceRelativePath,
};
await writeState(input.stateFile, { version: 1, projects });
}
return { version: 1, projects };
}
async function ensureFolderPath(
prisma: PrismaClient,
organizationId: string,
parts: readonly string[],
): Promise<string> {
let parentId: string | undefined;
for (const name of parts) {
const existing = await prisma.folder.findFirst({
where: { organizationId, parentId: parentId ?? null, name, archivedAt: null },
select: { id: true },
});
if (existing !== null) {
parentId = existing.id;
continue;
}
const created = await createFolder(prisma, {
organizationId,
name,
...(parentId !== undefined ? { parentId } : {}),
});
parentId = created.id;
}
if (parentId === undefined) throw new Error("legacy import folder path is empty");
return parentId;
}
async function copyLegacyProject(
sourceDir: string,
workspaceDir: string,
entry: LegacyProjectManifestEntry,
): Promise<void> {
const sourceWorkspace = join(sourceDir, "workspace");
await assertNoSymlinks(sourceWorkspace);
const names = await readdir(sourceWorkspace);
for (const name of names) {
if (name === ".claude" || name === ".cph") continue;
await cp(join(sourceWorkspace, name), join(workspaceDir, name), {
recursive: true,
force: false,
errorOnExist: true,
preserveTimestamps: true,
filter: (source) => {
const parts = relative(sourceWorkspace, source).split(sep);
return !parts.includes(".claude") && !parts.includes(".cph");
},
});
}
const legacyDir = join(workspaceDir, ".legacy-source");
await mkdir(legacyDir, { mode: 0o750 });
await cp(join(sourceDir, "project.json"), join(legacyDir, "project.json"), {
force: false,
errorOnExist: true,
preserveTimestamps: true,
});
const rawDir = join(sourceDir, "_raw");
await assertNoSymlinks(rawDir).catch((error: unknown) => {
if (isMissing(error)) return;
throw error;
});
await cp(rawDir, join(legacyDir, "raw"), {
recursive: true,
force: false,
errorOnExist: true,
preserveTimestamps: true,
}).catch((error: unknown) => {
if (isMissing(error)) return;
throw error;
});
await writeFile(join(legacyDir, "migration.json"), `${JSON.stringify({
source: "teaching-material-host-service",
legacyProjectId: entry.legacyId,
legacyPath: entry.sourceRelativePath,
migratedAt: new Date().toISOString(),
}, null, 2)}\n`, { mode: 0o640 });
}
function importedProjectId(organizationId: string, legacyId: string): string {
const digest = createHash("sha256")
.update("teaching-material-host-service\0")
.update(organizationId)
.update("\0")
.update(legacyId)
.digest("hex")
.slice(0, 32);
return `legacy_${digest}`;
}
async function hasCompletionMarker(
workspaceDir: string,
entry: LegacyProjectManifestEntry,
): Promise<boolean> {
try {
const marker = JSON.parse(await readFile(join(workspaceDir, ".legacy-source", "migration.json"), "utf8")) as unknown;
return typeof marker === "object" && marker !== null
&& "legacyProjectId" in marker && marker.legacyProjectId === entry.legacyId
&& "legacyPath" in marker && marker.legacyPath === entry.sourceRelativePath;
} catch (error) {
if (isMissing(error)) return false;
if (error instanceof SyntaxError) {
throw new Error(`invalid legacy completion marker: ${workspaceDir}`, { cause: error });
}
throw error;
}
}
async function ensureImportAudit(
prisma: PrismaClient,
projectId: string,
entry: LegacyProjectManifestEntry,
): Promise<void> {
const existing = await prisma.auditEntry.findFirst({
where: { projectId, action: "legacy_project.imported" },
select: { id: true },
});
if (existing !== null) return;
await prisma.auditEntry.create({
data: {
projectId,
action: "legacy_project.imported",
metadata: {
source: "teaching-material-host-service",
legacyProjectId: entry.legacyId,
legacyPath: entry.sourceRelativePath,
},
},
});
}
async function removeIncompleteTarget(
prisma: PrismaClient,
projectId: string,
workspaceDir: string,
workspaceRoot: string,
): Promise<void> {
await assertConfinedExistingPath(workspaceRoot, workspaceDir);
const failures: unknown[] = [];
try {
await prisma.project.delete({ where: { id: projectId } });
} catch (error) {
failures.push(error);
}
try {
await rm(workspaceDir, { recursive: true, force: true });
} catch (error) {
failures.push(error);
}
if (failures.length > 0) throw new AggregateError(failures, `failed to remove incomplete legacy target: ${projectId}`);
}
async function assertConfinedExistingPath(root: string, path: string): Promise<void> {
const trustedRoot = await realpath(root);
const candidate = await realpath(path);
const rel = relative(trustedRoot, candidate);
if (rel === "" || rel === ".." || rel.startsWith("../") || isAbsolute(rel)) {
throw new Error(`refusing to remove path outside workspace root: ${path}`);
}
}
async function assertNoSymlinks(path: string): Promise<void> {
const metadata = await lstat(path);
if (metadata.isSymbolicLink()) throw new Error(`legacy import rejects symbolic link: ${path}`);
if (!metadata.isDirectory()) return;
for (const entry of await readdir(path)) await assertNoSymlinks(join(path, entry));
}
async function confinedSourceDir(sourceRoot: string, relativePath: string): Promise<string> {
const candidate = await realpath(resolve(sourceRoot, relativePath));
const rel = relative(sourceRoot, candidate);
if (rel === "" || rel === ".." || rel.startsWith("../") || isAbsolute(rel)) {
throw new Error(`legacy source path escapes source root: ${relativePath}`);
}
return candidate;
}
function validateEntry(entry: LegacyProjectManifestEntry): void {
if (typeof entry !== "object" || entry === null) throw new Error("invalid legacy project entry");
if (typeof entry.legacyId !== "string") throw new Error("legacy project id must be a string");
if (!/^[A-Za-z0-9_-]+$/.test(entry.legacyId)) throw new Error(`invalid legacy project id: ${entry.legacyId}`);
if (typeof entry.name !== "string") throw new Error(`legacy project name must be a string: ${entry.legacyId}`);
if (entry.name.trim() === "") throw new Error(`legacy project name is empty: ${entry.legacyId}`);
if (typeof entry.sourceRelativePath !== "string") {
throw new Error(`legacy source path must be a string: ${entry.legacyId}`);
}
if (entry.sourceRelativePath === "" || resolve("/", entry.sourceRelativePath) === "/") {
throw new Error(`invalid legacy source path: ${entry.legacyId}`);
}
if (!Array.isArray(entry.folderPath)) throw new Error(`legacy folder path must be an array: ${entry.legacyId}`);
for (const part of entry.folderPath) {
if (typeof part !== "string" || part.trim() === "" || part === "." || part === ".." || part.includes("/") || part.includes("\\")) {
throw new Error(`invalid legacy folder part for ${entry.legacyId}: ${part}`);
}
}
}
async function readState(path: string): Promise<LegacyProjectImportState> {
try {
const parsed = JSON.parse(await readFile(path, "utf8")) as LegacyProjectImportState;
if (parsed.version !== 1 || typeof parsed.projects !== "object" || parsed.projects === null) {
throw new Error(`invalid legacy import state: ${path}`);
}
for (const [legacyId, record] of Object.entries(parsed.projects)) validateStateRecord(path, legacyId, record);
return parsed;
} catch (error) {
if (isMissing(error)) return { version: 1, projects: {} };
throw error;
}
}
function validateStateRecord(path: string, legacyId: string, record: unknown): void {
if (typeof record !== "object" || record === null || Array.isArray(record)) {
throw new Error(`invalid legacy import state record: ${path}#${legacyId}`);
}
const values = record as Record<string, unknown>;
const expectedKeys = ["importedAt", "projectId", "sourceRelativePath", "status", "workspaceDir"];
if (Object.keys(values).sort().join("\0") !== expectedKeys.join("\0")) {
throw new Error(`invalid legacy import state fields: ${path}#${legacyId}`);
}
if (values.status !== "PENDING" && values.status !== "COMPLETED") {
throw new Error(`invalid legacy import state status: ${path}#${legacyId}`);
}
for (const field of ["projectId", "workspaceDir", "importedAt", "sourceRelativePath"] as const) {
if (typeof values[field] !== "string") throw new Error(`invalid legacy import state ${field}: ${path}#${legacyId}`);
}
if (values.projectId === "" || values.sourceRelativePath === "") {
throw new Error(`invalid legacy import state identity: ${path}#${legacyId}`);
}
if (values.status === "PENDING" && (values.workspaceDir !== "" || values.importedAt !== "")) {
throw new Error(`invalid pending legacy import state: ${path}#${legacyId}`);
}
if (values.status === "COMPLETED" && (values.workspaceDir === "" || values.importedAt === "")) {
throw new Error(`invalid completed legacy import state: ${path}#${legacyId}`);
}
}
async function writeState(path: string, state: LegacyProjectImportState): Promise<void> {
await mkdir(dirname(path), { recursive: true, mode: 0o750 });
const temporary = `${path}.tmp`;
await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
await rename(temporary, path);
}
function isMissing(error: unknown): boolean {
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+101
View File
@@ -0,0 +1,101 @@
import type { PrismaClient } from "@prisma/client";
import {
requireActiveFeishuApplicationConnectionInTransaction,
scopedFeishuPrincipalId,
} from "./identityNamespace.js";
import { lockActiveOrganization } from "../org/status.js";
export type FeishuBindingLifecycleReason = "chat_dissolved" | "bot_removed";
/**
* Archive a project's active Feishu chat binding after Feishu has notified this
* bot that the chat dissolved or that the bot was removed. The event can be
* redelivered; only the first delivery changes state and writes an audit row.
*/
export async function archiveFeishuBindingForLifecycleEvent(
prisma: PrismaClient,
input: {
readonly chatId: string;
readonly eventId: string;
readonly reason: FeishuBindingLifecycleReason;
},
): Promise<{ readonly archived: boolean; readonly projectId?: string | undefined }> {
const chatId = requireNonEmpty(input.chatId, "Feishu chat id");
const eventId = requireNonEmpty(input.eventId, "Feishu event id");
return prisma.$transaction(async (tx) => {
// Feishu WS delivery is at-least-once. Persist the receipt in the same
// transaction as the archive so a failed archive remains eligible for a
// retry, while an old redelivery cannot archive a later re-binding.
const receipt = await tx.feishuEventReceipt.createMany({
data: {
eventId,
eventType: lifecycleEventType(input.reason),
},
skipDuplicates: true,
});
if (receipt.count === 0) return { archived: false };
const binding = await tx.projectGroupBinding.findFirst({
where: { chatId, archivedAt: null },
select: { id: true, projectId: true, project: { select: { organizationId: true } } },
});
if (binding === null) return { archived: false };
await lockActiveOrganization(tx, binding.project.organizationId);
const connection = await tx.organizationFeishuApplicationConnection.findUnique({
where: { organizationId: binding.project.organizationId },
select: { id: true },
});
if (connection === null) {
throw new Error(`Feishu application connection not found for organization ${binding.project.organizationId}`);
}
const activeConnection = await requireActiveFeishuApplicationConnectionInTransaction(tx, connection.id);
if (activeConnection.organizationId !== binding.project.organizationId) {
throw new Error("Feishu application connection Organization scope mismatch");
}
const now = new Date();
const archived = await tx.projectGroupBinding.updateMany({
where: { id: binding.id, archivedAt: null },
data: { archivedAt: now },
});
if (archived.count === 0) return { archived: false };
await tx.permissionGrant.updateMany({
where: {
resourceType: "PROJECT",
resourceId: binding.projectId,
principalType: "FEISHU_CHAT",
// Bindings created before ADR-0024 used the raw chat id. Retire only
// that exact legacy principal alongside the current scoped principal.
principalId: {
in: [scopedFeishuPrincipalId("CHAT", connection.id, chatId), chatId],
},
revokedAt: null,
},
data: { revokedAt: now },
});
await tx.auditEntry.create({
data: {
organizationId: binding.project.organizationId,
projectId: binding.projectId,
action: "project.chat_binding_archived",
metadata: {
chatId,
reason: input.reason,
eventId,
},
},
});
return { archived: true, projectId: binding.projectId };
});
}
function lifecycleEventType(reason: FeishuBindingLifecycleReason): string {
return reason === "chat_dissolved" ? "im.chat.disbanded_v1" : "im.chat.member.bot.deleted_v1";
}
function requireNonEmpty(value: string, label: string): string {
const trimmed = value.trim();
if (trimmed === "") throw new Error(`${label} is required`);
return trimmed;
}
+47
View File
@@ -100,11 +100,19 @@ export interface CardActionEvent {
readonly value?: unknown; readonly value?: unknown;
readonly tag?: string; readonly tag?: string;
readonly option?: string; readonly option?: string;
readonly form_value?: Readonly<Record<string, unknown>>;
}; };
readonly context?: { readonly open_message_id?: string; readonly open_chat_id?: string }; readonly context?: { readonly open_message_id?: string; readonly open_chat_id?: string };
readonly token?: string; readonly token?: string;
} }
/** A binding-ending Feishu event delivered to this application's bot. */
export interface FeishuBindingLifecycleEvent {
readonly eventId: string;
readonly chatId: string;
readonly reason: "chat_dissolved" | "bot_removed";
}
interface ContactBasicUser { interface ContactBasicUser {
readonly name?: string | undefined; readonly name?: string | undefined;
readonly i18n_name?: { readonly i18n_name?: {
@@ -921,6 +929,7 @@ export async function startFeishuListenerWithClient(
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>, onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>, onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>,
onTerminalError?: (error: Error) => void, onTerminalError?: (error: Error) => void,
onBindingLifecycle?: (event: FeishuBindingLifecycleEvent) => Promise<void>,
): Promise<FeishuRuntime> { ): Promise<FeishuRuntime> {
let state: "STARTING" | "READY" | "FAILED" = "STARTING"; let state: "STARTING" | "READY" | "FAILED" = "STARTING";
let resolveReady!: () => void; let resolveReady!: () => void;
@@ -962,6 +971,14 @@ export async function startFeishuListenerWithClient(
.catch((e) => { logger.error({ err: e }, "feishu card action handler threw"); }); .catch((e) => { logger.error({ err: e }, "feishu card action handler threw"); });
}; };
} }
if (onBindingLifecycle !== undefined) {
handlers["im.chat.disbanded_v1"] = async (data) => {
await dispatchBindingLifecycleEvent(data, "chat_dissolved", onBindingLifecycle, logger);
};
handlers["im.chat.member.bot.deleted_v1"] = async (data) => {
await dispatchBindingLifecycleEvent(data, "bot_removed", onBindingLifecycle, logger);
};
}
await wsClient.start({ eventDispatcher: new lark.EventDispatcher({}).register(handlers) }); await wsClient.start({ eventDispatcher: new lark.EventDispatcher({}).register(handlers) });
let timeout: ReturnType<typeof setTimeout> | undefined; let timeout: ReturnType<typeof setTimeout> | undefined;
const startupTimeout = new Promise<never>((_resolve, reject) => { const startupTimeout = new Promise<never>((_resolve, reject) => {
@@ -976,6 +993,36 @@ export async function startFeishuListenerWithClient(
return rt; return rt;
} }
async function dispatchBindingLifecycleEvent(
data: unknown,
reason: FeishuBindingLifecycleEvent["reason"],
onBindingLifecycle: (event: FeishuBindingLifecycleEvent) => Promise<void>,
logger: FastifyBaseLogger,
): Promise<void> {
const event = bindingLifecycleEventFrom(data, reason);
if (event === null) {
logger.warn({ reason }, "feishu binding lifecycle event missing chat id");
return;
}
await onBindingLifecycle(event);
}
function bindingLifecycleEventFrom(
data: unknown,
reason: FeishuBindingLifecycleEvent["reason"],
): FeishuBindingLifecycleEvent | null {
if (typeof data !== "object" || data === null) return null;
const event = data as { readonly chat_id?: unknown; readonly event_id?: unknown; readonly header?: { readonly event_id?: unknown } };
if (typeof event.chat_id !== "string" || event.chat_id.trim() === "") return null;
const eventId = typeof event.event_id === "string" && event.event_id !== ""
? event.event_id
: typeof event.header?.event_id === "string" && event.header.event_id !== ""
? event.header.event_id
: undefined;
if (eventId === undefined) return null;
return { chatId: event.chat_id, reason, eventId };
}
export async function startFeishuListener( export async function startFeishuListener(
config: FeishuConfig, config: FeishuConfig,
logger: FastifyBaseLogger, logger: FastifyBaseLogger,
+8 -3
View File
@@ -66,7 +66,7 @@ export async function upsertScopedFeishuIdentityInTransaction(
const principalId = scopedFeishuPrincipalId("USER", connectionId, openId); const principalId = scopedFeishuPrincipalId("USER", connectionId, openId);
const userId = deterministicFeishuUserId(connectionId, openId); const userId = deterministicFeishuUserId(connectionId, openId);
const connection = await lockActiveConnection(prisma, connectionId); const connection = await requireActiveFeishuApplicationConnectionInTransaction(prisma, connectionId);
if (input.expectedOrganizationId !== undefined && if (input.expectedOrganizationId !== undefined &&
input.expectedOrganizationId !== connection.organizationId) { input.expectedOrganizationId !== connection.organizationId) {
throw new Error("Feishu identity Organization scope mismatch"); throw new Error("Feishu identity Organization scope mismatch");
@@ -114,7 +114,7 @@ export async function resolveScopedFeishuIdentity(
const connectionId = nonEmpty(input.connectionId, "Feishu connectionId"); const connectionId = nonEmpty(input.connectionId, "Feishu connectionId");
const openId = nonEmpty(input.openId, "Feishu openId"); const openId = nonEmpty(input.openId, "Feishu openId");
return prisma.$transaction(async (tx) => { return prisma.$transaction(async (tx) => {
const connection = await lockActiveConnection(tx, connectionId); const connection = await requireActiveFeishuApplicationConnectionInTransaction(tx, connectionId);
if (input.expectedOrganizationId !== undefined && if (input.expectedOrganizationId !== undefined &&
input.expectedOrganizationId !== connection.organizationId) { input.expectedOrganizationId !== connection.organizationId) {
throw new Error("Feishu identity Organization scope mismatch"); throw new Error("Feishu identity Organization scope mismatch");
@@ -127,7 +127,12 @@ export async function resolveScopedFeishuIdentity(
}); });
} }
async function lockActiveConnection( /**
* Lock and prove that a Feishu application connection is currently usable.
* Callers handling provider-originated data use this before trusting a
* connection-scoped identifier.
*/
export async function requireActiveFeishuApplicationConnectionInTransaction(
prisma: Prisma.TransactionClient, prisma: Prisma.TransactionClient,
connectionId: string, connectionId: string,
): Promise<{ readonly organizationId: string }> { ): Promise<{ readonly organizationId: string }> {
+6 -4
View File
@@ -47,8 +47,8 @@ export class MessageBatcher {
this.extendedDebounceMs = options.extendedDebounceMs ?? DEFAULT_OPTIONS.extendedDebounceMs; this.extendedDebounceMs = options.extendedDebounceMs ?? DEFAULT_OPTIONS.extendedDebounceMs;
} }
async enqueue(chatId: string, senderOpenId: string, text: string): Promise<void> { async enqueue(chatId: string, senderOpenId: string, text: string, discriminator?: string): Promise<void> {
const key = messageBatchKey(chatId, senderOpenId); const key = messageBatchKey(chatId, senderOpenId, discriminator);
await this.runSerial(key, async () => { await this.runSerial(key, async () => {
const existing = this.pending.get(key); const existing = this.pending.get(key);
const batch = const batch =
@@ -120,6 +120,8 @@ export class MessageBatcher {
} }
} }
export function messageBatchKey(chatId: string, senderOpenId: string): string { export function messageBatchKey(chatId: string, senderOpenId: string, discriminator?: string): string {
return `${chatId}:${senderOpenId}`; return discriminator === undefined
? `${chatId}:${senderOpenId}`
: `${chatId}:${senderOpenId}:${discriminator}`;
} }
+381
View File
@@ -0,0 +1,381 @@
import { Prisma, type PrismaClient } from "@prisma/client";
import { lockActiveProjectOrganization } from "../org/status.js";
export interface ProjectConsoleState {
readonly organizationId: string;
readonly projectId: string;
readonly projectName: string;
readonly folderId: string | null;
readonly breadcrumb: string;
readonly selectedRole: { readonly id: string; readonly roleId: string; readonly label: string };
readonly roles: readonly { readonly id: string; readonly roleId: string; readonly label: string }[];
readonly currentSession: {
readonly id: string;
readonly title: string | null;
readonly updatedAt: Date;
readonly runCount: number;
readonly sdkSessionReady: boolean;
} | null;
}
export interface FolderDestinationPage {
readonly folderId: string | null;
readonly parentFolderId: string | null;
readonly breadcrumb: string;
readonly childFolders: readonly { readonly id: string; readonly name: string }[];
}
export async function browseFolderDestinations(
prisma: PrismaClient,
input: { readonly organizationId: string; readonly folderId: string | null },
): Promise<FolderDestinationPage> {
const folder = input.folderId === null ? null : await prisma.folder.findFirst({
where: { id: input.folderId, organizationId: input.organizationId, archivedAt: null },
select: { id: true, parentId: true },
});
if (input.folderId !== null && folder === null) throw new Error(`active folder not found: ${input.folderId}`);
const childFolders = await prisma.folder.findMany({
where: {
organizationId: input.organizationId,
parentId: input.folderId,
archivedAt: null,
kind: { not: "SYSTEM_INBOX" },
},
orderBy: [{ sortKey: "asc" }, { name: "asc" }, { id: "asc" }],
select: { id: true, name: true },
});
return {
folderId: input.folderId,
parentFolderId: folder?.parentId ?? null,
breadcrumb: input.folderId === null ? "根目录" : await folderBreadcrumb(prisma, input.folderId),
childFolders,
};
}
export async function createFolderAndMoveProject(
prisma: PrismaClient,
input: {
readonly organizationId: string;
readonly projectId: string;
readonly parentFolderId: string | null;
readonly name: string;
readonly actorUserId?: string | undefined;
},
): Promise<{ readonly folderId: string; readonly folderName: string }> {
const name = input.name.trim();
if (name === "") throw new Error("folder name is required");
if (name.length > 100) throw new Error("folder name must not exceed 100 characters");
return prisma.$transaction(async (tx) => {
await lockActiveProjectOrganization(tx, input.projectId);
const project = await tx.project.findFirst({
where: { id: input.projectId, organizationId: input.organizationId, archivedAt: null },
select: { id: true, folderId: true },
});
if (project === null) throw new Error("active project not found in Organization");
if (input.parentFolderId !== null) {
const parent = await tx.folder.findFirst({
where: {
id: input.parentFolderId,
organizationId: input.organizationId,
archivedAt: null,
kind: { not: "SYSTEM_INBOX" },
},
select: { id: true },
});
if (parent === null) throw new Error("active destination folder not found in Organization");
}
const folder = await tx.folder.create({
data: {
organizationId: input.organizationId,
parentId: input.parentFolderId,
name,
},
select: { id: true, name: true },
});
await tx.project.update({ where: { id: project.id }, data: { folderId: folder.id } });
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
projectId: project.id,
...(input.actorUserId !== undefined ? { actorUserId: input.actorUserId } : {}),
action: "folder.created_and_project_moved_from_feishu",
metadata: {
folderId: folder.id,
parentFolderId: input.parentFolderId,
previousFolderId: project.folderId,
name: folder.name,
},
},
});
return { folderId: folder.id, folderName: folder.name };
});
}
export async function loadProjectConsole(
prisma: PrismaClient,
input: { readonly projectId: string; readonly chatId: string },
): Promise<ProjectConsoleState> {
const binding = await prisma.projectGroupBinding.findFirst({
where: { projectId: input.projectId, chatId: input.chatId, archivedAt: null },
select: {
selectedRole: { select: { id: true, roleId: true, label: true, disabledAt: true, organizationId: true } },
project: {
select: {
id: true,
name: true,
folderId: true,
organizationId: true,
archivedAt: true,
organization: {
select: {
status: true,
agentRoles: {
where: { disabledAt: null },
orderBy: [{ sortOrder: "asc" }, { roleId: "asc" }],
select: { id: true, roleId: true, label: true },
},
},
},
},
},
},
});
if (binding === null) throw new Error("project console requires an active binding to this chat");
const { project, selectedRole } = binding;
if (project.archivedAt !== null) throw new Error(`project ${project.id} is archived`);
if (project.organization.status !== "ACTIVE") {
throw new Error(`organization ${project.organizationId} is ${project.organization.status}`);
}
if (selectedRole.disabledAt !== null || selectedRole.organizationId !== project.organizationId) {
throw new Error("project group selected role is unavailable or cross-Organization");
}
const model = await selectedRoleModel(prisma, project.organizationId, selectedRole.id);
const currentSession = await prisma.agentSession.findFirst({
where: {
projectId: project.id,
roleId: selectedRole.roleId,
...(model === null ? {} : { model }),
archivedAt: null,
},
orderBy: { updatedAt: "desc" },
select: { id: true, title: true, updatedAt: true, metadata: true, _count: { select: { runs: true } } },
});
return {
organizationId: project.organizationId,
projectId: project.id,
projectName: project.name,
folderId: project.folderId,
breadcrumb: project.folderId === null ? "根目录" : await folderBreadcrumb(prisma, project.folderId),
selectedRole: { id: selectedRole.id, roleId: selectedRole.roleId, label: selectedRole.label },
roles: project.organization.agentRoles,
currentSession: currentSession === null ? null : {
id: currentSession.id,
title: currentSession.title,
updatedAt: currentSession.updatedAt,
runCount: currentSession._count.runs,
sdkSessionReady: hasClaudeSessionId(currentSession.metadata),
},
};
}
function hasClaudeSessionId(metadata: unknown): boolean {
if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) return false;
const value = (metadata as Record<string, unknown>)["claudeSessionId"];
return typeof value === "string" && value !== "";
}
function isUserResumable(metadata: unknown): boolean {
return typeof metadata === "object" && metadata !== null && !Array.isArray(metadata) &&
(metadata as Record<string, unknown>)["userResumable"] === true;
}
function userResumableMetadata(metadata: unknown, value: boolean): Prisma.InputJsonObject {
const base = typeof metadata === "object" && metadata !== null && !Array.isArray(metadata)
? metadata as Prisma.JsonObject
: {};
return { ...base, userResumable: value };
}
export async function selectProjectGroupRole(
prisma: PrismaClient,
input: {
readonly projectId: string;
readonly chatId: string;
readonly agentRoleId: string;
readonly actorUserId: string;
},
): Promise<void> {
await prisma.$transaction(async (tx) => {
await lockActiveProjectOrganization(tx, input.projectId);
const binding = await tx.projectGroupBinding.findFirst({
where: { projectId: input.projectId, chatId: input.chatId, archivedAt: null },
select: { id: true, project: { select: { organizationId: true } } },
});
if (binding === null) throw new Error("role selection requires an active binding to this chat");
const role = await tx.organizationAgentRole.findFirst({
where: {
id: input.agentRoleId,
organizationId: binding.project.organizationId,
disabledAt: null,
},
select: { id: true, roleId: true },
});
if (role === null) throw new Error(`active role not found: ${input.agentRoleId}`);
await tx.projectGroupBinding.update({
where: { id: binding.id },
data: { selectedAgentRoleId: role.id },
});
await tx.auditEntry.create({
data: {
projectId: input.projectId,
actorUserId: input.actorUserId,
action: "project_group.role_selected",
metadata: { chatId: input.chatId, roleId: role.roleId },
},
});
});
}
export async function archiveCurrentRoleSession(
prisma: PrismaClient,
input: { readonly projectId: string; readonly chatId: string; readonly actorUserId: string },
): Promise<boolean> {
return prisma.$transaction(async (tx) => {
await lockActiveProjectOrganization(tx, input.projectId);
const activeRun = await tx.agentRun.findFirst({
where: { projectId: input.projectId, status: { in: ["ACTIVE", "WAITING_FOR_USER"] } },
select: { id: true },
});
if (activeRun !== null) throw new Error(`cannot archive a session while run ${activeRun.id} is active`);
const binding = await tx.projectGroupBinding.findFirst({
where: { projectId: input.projectId, chatId: input.chatId, archivedAt: null },
select: { selectedRole: { select: { roleId: true } } },
});
if (binding === null) throw new Error("session operation requires an active binding to this chat");
const session = await tx.agentSession.findFirst({
where: { projectId: input.projectId, roleId: binding.selectedRole.roleId, archivedAt: null },
orderBy: { updatedAt: "desc" },
select: { id: true, metadata: true },
});
if (session !== null) {
await tx.agentSession.update({
where: { id: session.id },
data: { archivedAt: new Date(), metadata: userResumableMetadata(session.metadata, true) },
});
}
await tx.auditEntry.create({
data: {
projectId: input.projectId,
actorUserId: input.actorUserId,
action: "agent_session.new_requested",
metadata: { chatId: input.chatId, roleId: binding.selectedRole.roleId, archivedSessionId: session?.id ?? null },
},
});
return session !== null;
});
}
export async function listRoleSessionHistory(
prisma: PrismaClient,
input: { readonly projectId: string; readonly chatId: string },
): Promise<readonly { readonly id: string; readonly title: string | null; readonly updatedAt: Date; readonly runCount: number }[]> {
const binding = await prisma.projectGroupBinding.findFirst({
where: { projectId: input.projectId, chatId: input.chatId, archivedAt: null },
select: { selectedRole: { select: { roleId: true } } },
});
if (binding === null) throw new Error("session history requires an active binding to this chat");
const sessions = await prisma.agentSession.findMany({
where: { projectId: input.projectId, roleId: binding.selectedRole.roleId, archivedAt: { not: null } },
orderBy: { updatedAt: "desc" },
take: 10,
select: { id: true, title: true, updatedAt: true, metadata: true, _count: { select: { runs: true } } },
});
return sessions.filter((session) => isUserResumable(session.metadata)).map((session) => ({
id: session.id,
title: session.title,
updatedAt: session.updatedAt,
runCount: session._count.runs,
}));
}
export async function resumeRoleSession(
prisma: PrismaClient,
input: {
readonly projectId: string;
readonly chatId: string;
readonly sessionId: string;
readonly actorUserId: string;
},
): Promise<void> {
await prisma.$transaction(async (tx) => {
await lockActiveProjectOrganization(tx, input.projectId);
const activeRun = await tx.agentRun.findFirst({
where: { projectId: input.projectId, status: { in: ["ACTIVE", "WAITING_FOR_USER"] } },
select: { id: true },
});
if (activeRun !== null) throw new Error(`cannot resume a session while run ${activeRun.id} is active`);
const binding = await tx.projectGroupBinding.findFirst({
where: { projectId: input.projectId, chatId: input.chatId, archivedAt: null },
select: { selectedRole: { select: { roleId: true } } },
});
if (binding === null) throw new Error("session resume requires an active binding to this chat");
const target = await tx.agentSession.findFirst({
where: { id: input.sessionId, projectId: input.projectId, roleId: binding.selectedRole.roleId, archivedAt: { not: null } },
select: { id: true, provider: true, model: true, metadata: true },
});
if (target === null || !isUserResumable(target.metadata)) {
throw new Error("user-resumable archived session not found for the selected role");
}
const activeSessions = await tx.agentSession.findMany({
where: {
projectId: input.projectId,
roleId: binding.selectedRole.roleId,
provider: target.provider,
model: target.model,
archivedAt: null,
},
select: { id: true, metadata: true },
});
const archivedAt = new Date();
for (const session of activeSessions) {
await tx.agentSession.update({
where: { id: session.id },
data: { archivedAt, metadata: userResumableMetadata(session.metadata, true) },
});
}
await tx.agentSession.update({
where: { id: target.id },
data: { archivedAt: null, metadata: userResumableMetadata(target.metadata, false) },
});
await tx.auditEntry.create({
data: {
projectId: input.projectId,
actorUserId: input.actorUserId,
action: "agent_session.resumed",
metadata: { chatId: input.chatId, roleId: binding.selectedRole.roleId, sessionId: target.id },
},
});
});
}
async function selectedRoleModel(
prisma: PrismaClient,
organizationId: string,
roleId: string,
): Promise<string | null> {
const role = await prisma.organizationAgentRole.findFirst({
where: { id: roleId, organizationId, disabledAt: null },
select: { defaultModel: true },
});
if (role === null) throw new Error(`selected role not found: ${roleId}`);
return role.defaultModel;
}
async function folderBreadcrumb(prisma: PrismaClient, folderId: string): Promise<string> {
const rows = await prisma.$queryRaw<Array<{ breadcrumb: string }>>(Prisma.sql`
SELECT cph_folder_breadcrumb(${folderId}) AS breadcrumb
`);
const breadcrumb = rows[0]?.breadcrumb;
if (breadcrumb === undefined) throw new Error(`failed to resolve folder breadcrumb: ${folderId}`);
return breadcrumb;
}
+476 -97
View File
@@ -1,89 +1,61 @@
export interface OnboardingProjectOption { import type { ProjectDiscoveryPage, ProjectFolderPage } from "../projectDiscovery.js";
readonly projectId: string;
readonly name: string;
readonly folderName?: string | undefined;
}
export interface OnboardingFolderOption { export type ProjectOnboardingView =
readonly folderId: string; | { readonly mode: "search"; readonly result: ProjectDiscoveryPage }
readonly name: string; | { readonly mode: "browse"; readonly result: ProjectFolderPage };
}
export interface ProjectOnboardingActionValue { export interface ProjectOnboardingActionValue {
readonly action: "create_project_from_chat" | "bind_project"; readonly action:
| "create_project_from_chat"
| "bind_project"
| "browse_folder"
| "search_page"
| "rename_project"
| "select_agent_role"
| "new_agent_session"
| "show_session_history"
| "resume_agent_session"
| "compact_agent_session"
| "browse_move_destination"
| "move_project"
| "create_folder";
readonly organization_id: string; readonly organization_id: string;
readonly project_id?: string | undefined; readonly project_id?: string | undefined;
readonly folder_id?: string | undefined; readonly folder_id?: string | undefined;
readonly search_query?: string | undefined;
readonly page?: number | undefined;
readonly agent_role_id?: string | undefined;
readonly session_id?: string | undefined;
} }
export function buildUnboundChatOnboardingCard(params: { export function buildUnboundChatOnboardingCard(params: {
readonly organizationId: string; readonly organizationId: string;
readonly organizationName: string; readonly organizationName: string;
readonly folders: readonly OnboardingFolderOption[];
readonly projects: readonly OnboardingProjectOption[];
readonly canCreateProject: boolean; readonly canCreateProject: boolean;
readonly view: ProjectOnboardingView;
}): Record<string, unknown> { }): Record<string, unknown> {
const actions: unknown[] = []; const elements: unknown[] = [{
if (params.canCreateProject) {
const folders = params.folders.length === 0 ? [{ folderId: undefined, name: "默认位置" }] : params.folders.slice(0, 3);
for (const folder of folders) {
actions.push({
tag: "button",
text: { tag: "plain_text", content: `新建到 ${buttonLabel(folder.name, 14)}` },
type: "primary",
value: {
project_onboarding: {
action: "create_project_from_chat",
organization_id: params.organizationId,
...(folder.folderId !== undefined ? { folder_id: folder.folderId } : {}),
},
},
});
}
}
for (const project of params.projects.slice(0, 5)) {
actions.push({
tag: "button",
text: { tag: "plain_text", content: buttonProjectLabel(project) },
type: "default",
value: {
project_onboarding: {
action: "bind_project",
organization_id: params.organizationId,
project_id: project.projectId,
},
},
});
}
const elements: unknown[] = [
{
tag: "markdown", tag: "markdown",
content: [ content: onboardingSummary(params.organizationName, params.view),
`这个飞书群还没有绑定项目。`, }];
``,
`组织: **${escapeMarkdown(params.organizationName)}**`,
`可以选择 folder 新建项目并绑定到本群,或绑定你已经有管理权限的未绑定项目。`,
].join("\n"),
},
];
if (actions.length > 0) { if (params.view.mode === "browse") {
elements.push({ tag: "action", actions }); appendFolderNavigation(elements, params.organizationId, params.view.result);
} else {
elements.push({
tag: "markdown",
content: "你当前没有可绑定项目,也没有新建项目权限。请联系组织管理员。",
});
} }
appendProjectResults(
elements,
params.organizationId,
params.view.mode === "search" ? params.view.result.items : params.view.result.projects,
);
appendPagination(elements, params.organizationId, params.view);
appendCreation(elements, params.organizationId, params.canCreateProject, params.view);
if (elements.length === 1) {
elements.push({ tag: "markdown", content: "当前目录没有可浏览内容。" });
}
return { return {
config: { wide_screen_mode: true }, config: { wide_screen_mode: true },
header: { header: { title: { tag: "plain_text", content: "绑定项目" }, template: "blue" },
title: { tag: "plain_text", content: "绑定项目" },
template: "blue",
},
elements, elements,
}; };
} }
@@ -95,54 +67,461 @@ export function buildProjectOnboardingResolvedCard(params: {
}): Record<string, unknown> { }): Record<string, unknown> {
return { return {
config: { wide_screen_mode: true }, config: { wide_screen_mode: true },
header: { header: { title: { tag: "plain_text", content: params.title }, template: params.template },
title: { tag: "plain_text", content: params.title },
template: params.template,
},
elements: [{ tag: "markdown", content: params.body }], elements: [{ tag: "markdown", content: params.body }],
}; };
} }
export function buildProjectManagementCard(params: {
readonly organizationId: string;
readonly projectId: string;
readonly projectName: string;
readonly title?: string | undefined;
readonly breadcrumb?: string | undefined;
readonly selectedRole?: { readonly id: string; readonly roleId: string; readonly label: string } | undefined;
readonly roles?: readonly { readonly id: string; readonly roleId: string; readonly label: string }[] | undefined;
readonly currentSession?: {
readonly id: string;
readonly title: string | null;
readonly updatedAt: Date;
readonly runCount: number;
readonly sdkSessionReady: boolean;
} | null | undefined;
readonly canManageProject?: boolean | undefined;
}): Record<string, unknown> {
const elements: unknown[] = [{
tag: "markdown",
content: [
`当前项目: **${escapeMarkdown(params.projectName)}**`,
`所在目录: **${escapeMarkdown(params.breadcrumb ?? "根目录")}**`,
params.selectedRole === undefined
? "当前角色: **未配置**"
: `当前角色: **${escapeMarkdown(params.selectedRole.label)}**`,
sessionSummary(params.currentSession),
].join("\n"),
}];
const roles = params.roles ?? [];
if (roles.length > 0) {
elements.push({ tag: "markdown", content: "**切换角色**" });
for (let index = 0; index < roles.length; index += 5) {
elements.push({
tag: "action",
actions: roles.slice(index, index + 5).map((role) => actionButton(
role.id === params.selectedRole?.id ? `${role.label}` : role.label,
{
action: "select_agent_role",
organization_id: params.organizationId,
project_id: params.projectId,
agent_role_id: role.id,
},
role.id === params.selectedRole?.id ? "primary" : "default",
)),
});
}
}
const sessionActions = [
actionButton("新开会话", {
action: "new_agent_session", organization_id: params.organizationId, project_id: params.projectId,
}),
actionButton("历史会话", {
action: "show_session_history", organization_id: params.organizationId, project_id: params.projectId,
}),
];
if (params.currentSession?.sdkSessionReady === true) {
sessionActions.push(actionButton("压缩上下文", {
action: "compact_agent_session", organization_id: params.organizationId, project_id: params.projectId,
}));
}
elements.push(
{ tag: "markdown", content: "**当前角色会话**" },
{ tag: "action", actions: sessionActions },
);
if (params.canManageProject === true) {
elements.push(
{ tag: "markdown", content: "**项目管理**" },
{ tag: "action", actions: [actionButton("移动项目", {
action: "browse_move_destination",
organization_id: params.organizationId,
project_id: params.projectId,
page: 1,
})] },
renameProjectForm(params),
);
}
return {
config: { wide_screen_mode: true },
header: {
title: { tag: "plain_text", content: params.title ?? "项目管理" },
template: "green",
},
elements,
};
}
export function buildSessionHistoryCard(params: {
readonly organizationId: string;
readonly projectId: string;
readonly roleLabel: string;
readonly sessions: readonly {
readonly id: string;
readonly title: string | null;
readonly updatedAt: Date;
readonly runCount: number;
}[];
}): Record<string, unknown> {
const elements: unknown[] = [{ tag: "markdown", content: `角色: **${escapeMarkdown(params.roleLabel)}**` }];
if (params.sessions.length === 0) elements.push({ tag: "markdown", content: "没有可恢复的历史会话。" });
for (const session of params.sessions) {
elements.push(
{
tag: "markdown",
content: `**${escapeMarkdown(session.title ?? "未命名会话")}**\n${session.runCount} runs · ${formatDate(session.updatedAt)}`,
},
{ tag: "action", actions: [actionButton("恢复此会话", {
action: "resume_agent_session",
organization_id: params.organizationId,
project_id: params.projectId,
session_id: session.id,
}, "primary")] },
);
}
return {
config: { wide_screen_mode: true },
header: { title: { tag: "plain_text", content: "历史会话" }, template: "blue" },
elements,
};
}
export function buildMoveProjectCard(params: {
readonly organizationId: string;
readonly projectId: string;
readonly projectName: string;
readonly folderId: string | null;
readonly parentFolderId: string | null;
readonly breadcrumb: string;
readonly childFolders: readonly { readonly id: string; readonly name: string }[];
readonly canCreateFolder: boolean;
}): Record<string, unknown> {
const navigation: unknown[] = [];
if (params.folderId !== null) {
navigation.push(actionButton("⬆ 上一级", {
action: "browse_move_destination",
organization_id: params.organizationId,
project_id: params.projectId,
...(params.parentFolderId !== null ? { folder_id: params.parentFolderId } : {}),
}));
}
for (const folder of params.childFolders) {
navigation.push(actionButton(`📁 ${buttonLabel(folder.name, 22)}`, {
action: "browse_move_destination",
organization_id: params.organizationId,
project_id: params.projectId,
folder_id: folder.id,
}));
}
const elements: unknown[] = [
{ tag: "markdown", content: `移动 **${escapeMarkdown(params.projectName)}**\n当前位置: **${escapeMarkdown(params.breadcrumb)}**` },
];
for (let index = 0; index < navigation.length; index += 5) {
elements.push({ tag: "action", actions: navigation.slice(index, index + 5) });
}
elements.push({ tag: "action", actions: [actionButton("移动到这里", {
action: "move_project",
organization_id: params.organizationId,
project_id: params.projectId,
...(params.folderId !== null ? { folder_id: params.folderId } : {}),
}, "primary")] });
if (params.canCreateFolder) {
elements.push(createFolderAndMoveForm({
organizationId: params.organizationId,
projectId: params.projectId,
parentFolderId: params.folderId,
}));
}
return {
config: { wide_screen_mode: true },
header: { title: { tag: "plain_text", content: "移动项目" }, template: "blue" },
elements,
};
}
export function projectOnboardingActionFromValue(value: unknown): ProjectOnboardingActionValue | null { export function projectOnboardingActionFromValue(value: unknown): ProjectOnboardingActionValue | null {
const raw = unwrapValue(value); const raw = unwrapValue(value);
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; if (typeof raw !== "object" || raw === null || Array.isArray(raw) || !("project_onboarding" in raw)) return null;
if (!("project_onboarding" in raw)) return null;
const action = raw.project_onboarding; const action = raw.project_onboarding;
if (typeof action !== "object" || action === null || Array.isArray(action)) return null; if (typeof action !== "object" || action === null || Array.isArray(action)) return null;
const rawAction = (action as { action?: unknown }).action; const fields = action as Record<string, unknown>;
const organizationId = (action as { organization_id?: unknown }).organization_id; const rawAction = fields.action;
const projectId = (action as { project_id?: unknown }).project_id; const organizationId = fields.organization_id;
const folderId = (action as { folder_id?: unknown }).folder_id; const projectId = fields.project_id;
if ((rawAction !== "create_project_from_chat" && rawAction !== "bind_project") || typeof organizationId !== "string" || organizationId === "") { const folderId = fields.folder_id;
return null; const searchQuery = fields.search_query;
} const page = fields.page;
if (rawAction === "bind_project" && (typeof projectId !== "string" || projectId === "")) { const agentRoleId = fields.agent_role_id;
return null; const sessionId = fields.session_id;
} if (!isAction(rawAction) || typeof organizationId !== "string" || organizationId === "") return null;
if (folderId !== undefined && (typeof folderId !== "string" || folderId === "")) { if ((rawAction === "bind_project" || rawAction === "rename_project") && (typeof projectId !== "string" || projectId === "")) return null;
return null; if (rawAction === "search_page" && (typeof searchQuery !== "string" || !validPage(page))) return null;
} if (folderId !== undefined && (typeof folderId !== "string" || folderId === "")) return null;
if (page !== undefined && !validPage(page)) return null;
if (rawAction === "select_agent_role" && (typeof agentRoleId !== "string" || agentRoleId === "")) return null;
if (rawAction === "resume_agent_session" && (typeof sessionId !== "string" || sessionId === "")) return null;
return { return {
action: rawAction, action: rawAction,
organization_id: organizationId, organization_id: organizationId,
...(typeof projectId === "string" && projectId !== "" ? { project_id: projectId } : {}), ...(typeof projectId === "string" && projectId !== "" ? { project_id: projectId } : {}),
...(typeof folderId === "string" && folderId !== "" ? { folder_id: folderId } : {}), ...(typeof folderId === "string" && folderId !== "" ? { folder_id: folderId } : {}),
...(typeof searchQuery === "string" ? { search_query: searchQuery.slice(0, 100) } : {}),
...(typeof page === "number" ? { page } : {}),
...(typeof agentRoleId === "string" && agentRoleId !== "" ? { agent_role_id: agentRoleId } : {}),
...(typeof sessionId === "string" && sessionId !== "" ? { session_id: sessionId } : {}),
}; };
} }
function onboardingSummary(organizationName: string, view: ProjectOnboardingView): string {
if (view.mode === "search") {
const result = view.result;
return [
"这个飞书群还没有绑定项目。",
"",
`组织: **${escapeMarkdown(organizationName)}**`,
`搜索: **${escapeMarkdown(result.query)}**`,
result.totalItems === 0
? "没有匹配的可绑定项目,请换一个关键词。"
: `共 **${result.totalItems}** 个匹配结果 · 第 **${result.page}/${result.totalPages}** 页`,
].join("\n");
}
const location = view.result.breadcrumb === "" ? "根目录" : view.result.breadcrumb;
return [
"这个飞书群还没有绑定项目。",
"",
`组织: **${escapeMarkdown(organizationName)}**`,
`当前位置: **${escapeMarkdown(location)}**`,
"可以进入 folder 浏览,或选择有管理权限的未绑定项目。",
].join("\n");
}
function appendFolderNavigation(elements: unknown[], organizationId: string, result: ProjectFolderPage): void {
const navigation: unknown[] = [];
if (result.folderId !== null) {
navigation.push(actionButton("⬆ 上一级", {
action: "browse_folder",
organization_id: organizationId,
...(result.parentFolderId !== null ? { folder_id: result.parentFolderId } : {}),
page: 1,
}));
}
for (const folder of result.childFolders) {
navigation.push(actionButton(`📁 ${buttonLabel(folder.name, 22)}`, {
action: "browse_folder",
organization_id: organizationId,
folder_id: folder.folderId,
page: 1,
}));
}
for (let index = 0; index < navigation.length; index += 5) {
elements.push({ tag: "action", actions: navigation.slice(index, index + 5) });
}
}
function appendProjectResults(
elements: unknown[],
organizationId: string,
projects: readonly ProjectDiscoveryPage["items"][number][],
): void {
for (const project of projects) {
const path = project.breadcrumb === "" ? "根目录" : project.breadcrumb;
elements.push({
tag: "markdown",
content: `**${escapeMarkdown(project.name)}**\n${escapeMarkdown(path)}`,
});
elements.push({
tag: "action",
actions: [actionButton("绑定这个项目", {
action: "bind_project",
organization_id: organizationId,
project_id: project.projectId,
})],
});
}
}
function appendPagination(elements: unknown[], organizationId: string, view: ProjectOnboardingView): void {
const result = projectPage(view);
if (result.totalPages <= 1) return;
const actions: unknown[] = [];
if (result.page > 1) actions.push(pageButton("上一页", organizationId, view, result.page - 1));
if (result.page < result.totalPages) actions.push(pageButton("下一页", organizationId, view, result.page + 1));
elements.push({ tag: "action", actions });
}
function appendCreation(
elements: unknown[],
organizationId: string,
canCreateProject: boolean,
view: ProjectOnboardingView,
): void {
if (!canCreateProject || view.mode !== "browse") return;
elements.push({
tag: "action",
actions: [actionButton(view.result.folderId === null ? "新建项目" : "在此 folder 新建项目", {
action: "create_project_from_chat",
organization_id: organizationId,
...(view.result.folderId !== null ? { folder_id: view.result.folderId } : {}),
}, "primary")],
});
}
function pageButton(label: string, organizationId: string, view: ProjectOnboardingView, page: number): unknown {
if (view.mode === "search") {
return actionButton(label, {
action: "search_page",
organization_id: organizationId,
search_query: view.result.query,
page,
});
}
return actionButton(label, {
action: "browse_folder",
organization_id: organizationId,
...(view.result.folderId !== null ? { folder_id: view.result.folderId } : {}),
page,
});
}
function projectPage(view: ProjectOnboardingView): ProjectDiscoveryPage {
if (view.mode === "search") return view.result;
return {
query: "",
page: view.result.page,
pageSize: view.result.pageSize,
totalItems: view.result.totalFolders + view.result.totalProjects,
totalPages: view.result.totalPages,
items: view.result.projects,
};
}
function renameProjectForm(params: {
readonly organizationId: string;
readonly projectId: string;
readonly projectName: string;
}): unknown {
return {
tag: "form",
name: "project_rename_form",
fallback: {
tag: "fallback_text",
text: { tag: "plain_text", content: "请升级飞书客户端后修改项目名称。" },
},
elements: [
{
tag: "input",
name: "project_name",
required: true,
max_length: 100,
default_value: params.projectName,
placeholder: { tag: "plain_text", content: "请输入项目名称" },
},
{
tag: "button",
name: "project_rename_submit",
text: { tag: "plain_text", content: "保存项目名称" },
type: "primary",
complex_interaction: true,
action_type: "form_submit",
value: { project_onboarding: {
action: "rename_project",
organization_id: params.organizationId,
project_id: params.projectId,
} },
},
],
};
}
function createFolderAndMoveForm(params: {
readonly organizationId: string;
readonly projectId: string;
readonly parentFolderId: string | null;
}): unknown {
return {
tag: "form",
name: "folder_create_form",
elements: [
{
tag: "input",
name: "folder_name",
required: true,
max_length: 100,
placeholder: { tag: "plain_text", content: "在当前目标目录下新建 Folder" },
},
{
tag: "button",
name: "folder_create_submit",
text: { tag: "plain_text", content: "新建并移动" },
type: "default",
complex_interaction: true,
action_type: "form_submit",
value: { project_onboarding: {
action: "create_folder",
organization_id: params.organizationId,
project_id: params.projectId,
...(params.parentFolderId !== null ? { folder_id: params.parentFolderId } : {}),
} },
},
],
};
}
function sessionSummary(session: {
readonly title: string | null;
readonly updatedAt: Date;
readonly runCount: number;
} | null | undefined): string {
if (session === null || session === undefined) return "当前会话: **尚未开始**";
return `当前会话: **${escapeMarkdown(session.title ?? "未命名会话")}** · ${session.runCount} runs · ${formatDate(session.updatedAt)}`;
}
function formatDate(value: Date): string {
return new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(value);
}
function actionButton(
label: string,
value: ProjectOnboardingActionValue,
type: "default" | "primary" = "default",
): unknown {
return {
tag: "button",
text: { tag: "plain_text", content: label },
type,
value: { project_onboarding: value },
};
}
function isAction(value: unknown): value is ProjectOnboardingActionValue["action"] {
return value === "create_project_from_chat" || value === "bind_project"
|| value === "browse_folder" || value === "search_page" || value === "rename_project"
|| value === "select_agent_role" || value === "new_agent_session"
|| value === "show_session_history" || value === "resume_agent_session"
|| value === "compact_agent_session" || value === "browse_move_destination"
|| value === "move_project" || value === "create_folder";
}
function validPage(value: unknown): value is number {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 1 && value <= 10_000;
}
function unwrapValue(value: unknown): unknown { function unwrapValue(value: unknown): unknown {
if (typeof value !== "string") return value; if (typeof value !== "string") return value;
try { try { return JSON.parse(value); } catch { return value; }
return JSON.parse(value);
} catch {
return value;
}
}
function buttonProjectLabel(project: OnboardingProjectOption): string {
const folderPrefix = project.folderName === undefined ? "" : `${project.folderName} / `;
const label = `${folderPrefix}${project.name}`;
return buttonLabel(label, 24);
} }
function buttonLabel(label: string, maxLength: number): string { function buttonLabel(label: string, maxLength: number): string {
+90 -333
View File
@@ -1,11 +1,6 @@
import type { PrismaClient } from "@prisma/client"; import type { PrismaClient } from "@prisma/client";
import type { FastifyBaseLogger } from "fastify";
import { decimalToNumberOrNull, formatInteger, formatUsd } from "../agent/cost.js"; import { decimalToNumberOrNull, formatInteger, formatUsd } from "../agent/cost.js";
import type { ModelRegistry, RoleEntry } from "../agent/models.js";
import type { RuntimeSettings } from "../settings/runtime.js";
import { lockActiveProjectOrganization } from "../org/status.js";
import { sendText, type FeishuRuntime, type SendMessageOptions } from "./client.js"; import { sendText, type FeishuRuntime, type SendMessageOptions } from "./client.js";
import type { TriggerQueue } from "./triggerQueue.js";
export interface SlashInvocation { export interface SlashInvocation {
readonly name: string; readonly name: string;
@@ -21,20 +16,13 @@ export interface SlashCommandRunContext {
} }
export interface SlashCommandDefinition { export interface SlashCommandDefinition {
readonly name: string; readonly name: "help" | "project" | "usage";
readonly usage: string; readonly usage: string;
readonly summary: string; readonly summary: string;
readonly details: readonly string[]; readonly details: readonly string[];
run(context: SlashCommandRunContext): Promise<void>; run(context: SlashCommandRunContext): Promise<void>;
} }
export interface SlashCommandRegistryDeps {
readonly prisma: PrismaClient;
readonly settings: RuntimeSettings;
readonly logger: FastifyBaseLogger;
readonly triggerQueue: TriggerQueue;
}
const TERMINAL_RUN_STATUSES = ["COMPLETED", "FAILED", "TIMED_OUT", "CANCELED"] as const; const TERMINAL_RUN_STATUSES = ["COMPLETED", "FAILED", "TIMED_OUT", "CANCELED"] as const;
export function parseSlashInvocation(prompt: string): SlashInvocation | null { export function parseSlashInvocation(prompt: string): SlashInvocation | null {
@@ -43,166 +31,94 @@ export function parseSlashInvocation(prompt: string): SlashInvocation | null {
const tokens = trimmed.split(/\s+/); const tokens = trimmed.split(/\s+/);
const rawCommand = tokens[0]; const rawCommand = tokens[0];
if (rawCommand === undefined || rawCommand.length <= 1) return null; if (rawCommand === undefined || rawCommand.length <= 1) return null;
return { return { name: rawCommand.slice(1), args: tokens.slice(1) };
name: rawCommand.slice(1),
args: tokens.slice(1),
};
} }
export function parseSlashHelpSubcommand(invocation: SlashInvocation): string | null { export function createSlashCommandRegistry(
if (invocation.name === "help") return null; deps: { readonly prisma: PrismaClient },
return invocation.args.length === 1 && invocation.args[0] === "help" ): ReadonlyMap<string, SlashCommandDefinition> {
? invocation.name
: null;
}
export function createSlashCommandRegistry(deps: SlashCommandRegistryDeps): ReadonlyMap<string, SlashCommandDefinition> {
const commands = new Map<string, SlashCommandDefinition>(); const commands = new Map<string, SlashCommandDefinition>();
const add = (command: SlashCommandDefinition): void => { const add = (command: SlashCommandDefinition): void => {
if (commands.has(command.name)) { if (commands.has(command.name)) throw new Error(`duplicate slash command definition: /${command.name}`);
throw new Error(`duplicate slash command definition: /${command.name}`);
}
commands.set(command.name, command); commands.set(command.name, command);
}; };
add({ add({
name: "help", name: "help",
usage: "/help [command]", usage: "/help [project|usage]",
summary: "查看可用 slash 命令或单个命令说明。", summary: "查看 Hub 命令。",
details: [ details: ["未知 slash 命令会明确报错,不会发送给 Agent。"],
"不创建 agent run,也不改变当前会话。", run: async ({ invocation, chatId, rt, sendOptions }) => {
"支持 /help new 和 /new help 两种写法。", if (invocation.args.length > 1) {
], await sendText(rt, chatId, "用法: /help [project|usage]", sendOptions);
return;
}
const target = invocation.args[0];
await sendText(rt, chatId, target === undefined
? formatSlashOverview(commands)
: formatSlashHelpTarget(target, commands), sendOptions);
},
});
add({
name: "project",
usage: "/project",
summary: "打开当前项目控制台,切换角色、管理会话和项目。",
details: ["不同区域按当前操作者的项目与组织权限显示。"],
run: async ({ chatId, rt, sendOptions }) => {
await sendText(rt, chatId, "请在绑定的项目群中使用 /project。", sendOptions);
},
});
add({
name: "usage",
usage: "/usage [current|project]",
summary: "查看 Hub 记录的真实 Agent 用量与成本。",
details: ["current 只统计当前角色的活跃会话;project 统计整个项目。"],
run: async ({ invocation, projectId, chatId, rt, sendOptions }) => { run: async ({ invocation, projectId, chatId, rt, sendOptions }) => {
const registry = await deps.settings.modelRegistry({ projectId }); const scope = invocation.args[0] ?? "current";
await sendText(rt, chatId, formatHelpCommandInvocation(invocation, registry, commands), sendOptions); if (invocation.args.length > 1 || (scope !== "current" && scope !== "project")) {
}, await sendText(rt, chatId, "用法: /usage [current|project]", sendOptions);
});
add({
name: "new",
usage: "/new",
summary: "开新会话,下次 @bot 将从头开始。",
details: [
"归档当前未归档的 agent session。",
"不会清空当前项目的等待队列。",
],
run: async ({ projectId, chatId, rt, sendOptions }) => {
await deps.prisma.$transaction(async (tx) => {
await lockActiveProjectOrganization(tx, projectId);
await tx.agentSession.updateMany({
where: { projectId, archivedAt: null },
data: { archivedAt: new Date() },
});
});
await sendText(rt, chatId, "已开新会话,下次 @bot 将从头开始。", sendOptions);
},
});
add({
name: "resume",
usage: "/resume",
summary: "恢复最近一次已归档的会话。",
details: [
"只恢复当前项目最近归档的 agent session。",
"没有可恢复会话时只回复提示,不会创建 agent run。",
],
run: async ({ projectId, chatId, rt, sendOptions }) => {
const resumed = await deps.prisma.$transaction(async (tx) => {
await lockActiveProjectOrganization(tx, projectId);
const latest = await tx.agentSession.findFirst({
where: { projectId, archivedAt: { not: null } },
orderBy: { archivedAt: "desc" },
select: { id: true },
});
if (latest === null) return false;
await tx.agentSession.update({
where: { id: latest.id },
data: { archivedAt: null },
});
return true;
});
if (!resumed) {
await sendText(rt, chatId, "没有可恢复的会话。", sendOptions);
return; return;
} }
await sendText(rt, chatId, "已恢复上一个会话。", sendOptions); const sessionIds = scope === "project"
}, ? undefined
}); : await currentRoleSessionIds(deps.prisma, projectId, chatId);
add({
name: "cost",
usage: "/cost",
summary: "查看当前会话已记录的 agent 成本。",
details: [
"只读取当前项目未归档 agent session 下已经结束的 run,不创建 agent run。",
"只统计运行时真实记录到 AgentRun.costUsd 的成本;未记录成本的 run 会单独列出。",
],
run: async ({ invocation, projectId, chatId, rt, sendOptions }) => {
if (invocation.args.length > 0) {
await sendText(rt, chatId, ["用法错误: /cost 暂不接受参数。", "", formatBuiltinSlashCommandHelp(commands.get("cost")!)].join("\n"), sendOptions);
return;
}
const sessions = await deps.prisma.agentSession.findMany({
where: { projectId, archivedAt: null },
orderBy: { updatedAt: "asc" },
select: { id: true },
});
if (sessions.length === 0) {
await sendText(rt, chatId, "当前会话还没有 agent session。", sendOptions);
return;
}
const runs = await deps.prisma.agentRun.findMany({ const runs = await deps.prisma.agentRun.findMany({
where: { where: {
projectId, projectId,
sessionId: { in: sessions.map((session) => session.id) }, ...(sessionIds === undefined ? {} : { sessionId: { in: sessionIds } }),
status: { in: [...TERMINAL_RUN_STATUSES] }, status: { in: [...TERMINAL_RUN_STATUSES] },
finishedAt: { not: null }, finishedAt: { not: null },
}, },
orderBy: { finishedAt: "asc" }, orderBy: { finishedAt: "asc" },
select: { select: { model: true, provider: true, inputTokens: true, outputTokens: true, costUsd: true },
model: true,
provider: true,
inputTokens: true,
outputTokens: true,
costUsd: true,
},
}); });
await sendText(rt, chatId, formatUsageReport(runs, scope), sendOptions);
await sendText(rt, chatId, formatCostReport(runs), sendOptions);
},
});
add({
name: "reset",
usage: "/reset",
summary: "重置当前会话并清空等待队列。",
details: [
"归档当前未归档的 agent session。",
"清空当前项目已经排队、尚未开始的触发请求。",
],
run: async ({ projectId, chatId, rt, sendOptions }) => {
await deps.prisma.$transaction(async (tx) => {
await lockActiveProjectOrganization(tx, projectId);
await tx.agentSession.updateMany({
where: { projectId, archivedAt: null },
data: { archivedAt: new Date() },
});
});
const cleared = deps.triggerQueue.clear(projectId);
if (cleared > 0) {
deps.logger.info({ projectId, cleared }, "feishu trigger: cleared queued triggers on reset");
}
await sendText(rt, chatId, "已重置,下次 @bot 将从头开始。", sendOptions);
}, },
}); });
return commands; return commands;
} }
interface CostReportRun { async function currentRoleSessionIds(
prisma: PrismaClient,
projectId: string,
chatId: string,
): Promise<string[]> {
const binding = await prisma.projectGroupBinding.findFirst({
where: { projectId, chatId, archivedAt: null },
select: { selectedRole: { select: { roleId: true } } },
});
if (binding === null) throw new Error("active project-group binding not found");
const sessions = await prisma.agentSession.findMany({
where: { projectId, roleId: binding.selectedRole.roleId, archivedAt: null },
select: { id: true },
});
return sessions.map((session) => session.id);
}
interface UsageRun {
readonly model: string; readonly model: string;
readonly provider: string; readonly provider: string;
readonly inputTokens: number | null; readonly inputTokens: number | null;
@@ -210,225 +126,66 @@ interface CostReportRun {
readonly costUsd: unknown; readonly costUsd: unknown;
} }
interface CostReportBucket { function formatUsageReport(runs: readonly UsageRun[], scope: "current" | "project"): string {
readonly provider: string; if (runs.length === 0) return `${scope === "current" ? "当前角色会话" : "当前项目"}还没有已结束的 Agent run。`;
readonly model: string; const buckets = new Map<string, {
runs: number; provider: string; model: string; runs: number; inputTokens: number; outputTokens: number; costUsd: number;
inputTokens: number; }>();
outputTokens: number;
costUsd: number;
}
function formatCostReport(runs: readonly CostReportRun[]): string {
if (runs.length === 0) {
return "当前会话还没有已结束的 agent run。";
}
const buckets = new Map<string, CostReportBucket>();
let recordedRuns = 0; let recordedRuns = 0;
let unrecordedRuns = 0; let unrecordedRuns = 0;
let totalInputTokens = 0; let totalInputTokens = 0;
let totalOutputTokens = 0; let totalOutputTokens = 0;
let totalCostUsd = 0; let totalCostUsd = 0;
for (const run of runs) { for (const run of runs) {
const costUsd = decimalToNumberOrNull(run.costUsd); const costUsd = decimalToNumberOrNull(run.costUsd);
if (costUsd === null) { if (costUsd === null) { unrecordedRuns++; continue; }
unrecordedRuns++;
continue;
}
recordedRuns++; recordedRuns++;
const inputTokens = run.inputTokens ?? 0;
const outputTokens = run.outputTokens ?? 0;
totalInputTokens += inputTokens;
totalOutputTokens += outputTokens;
totalCostUsd += costUsd;
const key = `${run.provider}\u0000${run.model}`; const key = `${run.provider}\u0000${run.model}`;
let bucket = buckets.get(key); const bucket = buckets.get(key) ?? {
if (bucket === undefined) { provider: run.provider, model: run.model, runs: 0, inputTokens: 0, outputTokens: 0, costUsd: 0,
bucket = {
provider: run.provider,
model: run.model,
runs: 0,
inputTokens: 0,
outputTokens: 0,
costUsd: 0,
}; };
buckets.set(key, bucket);
}
bucket.runs++; bucket.runs++;
bucket.inputTokens += inputTokens; bucket.inputTokens += run.inputTokens ?? 0;
bucket.outputTokens += outputTokens; bucket.outputTokens += run.outputTokens ?? 0;
bucket.costUsd += costUsd; bucket.costUsd += costUsd;
buckets.set(key, bucket);
totalInputTokens += run.inputTokens ?? 0;
totalOutputTokens += run.outputTokens ?? 0;
totalCostUsd += costUsd;
} }
if (recordedRuns === 0) {
return [
"当前会话已有已结束 agent run,但还没有任何 run 记录到真实成本。",
`未记录成本: ${formatInteger(unrecordedRuns)} runs。`,
"后续 run 需要 SDK 返回 total_cost_usd 才会进入 /cost 合计。",
].join("\n");
}
const lines = [ const lines = [
"当前会话已记录 agent 成本:", `${scope === "current" ? "当前角色会话" : "当前项目"}用量`,
`总计: ${formatUsd(totalCostUsd)}`, `已记录成本: ${formatInteger(recordedRuns)} runs · ${formatUsd(totalCostUsd)}`,
`Runs: ${formatInteger(recordedRuns)} 已记录${unrecordedRuns > 0 ? ` / ${formatInteger(unrecordedRuns)} 未记录` : ""}`,
`Tokens: input ${formatInteger(totalInputTokens)} / output ${formatInteger(totalOutputTokens)}`, `Tokens: input ${formatInteger(totalInputTokens)} / output ${formatInteger(totalOutputTokens)}`,
"",
"按模型:",
]; ];
if (unrecordedRuns > 0) lines.push(`另有 ${formatInteger(unrecordedRuns)} 个 run 未记录成本。`);
const sortedBuckets = [...buckets.values()].sort((a, b) => b.costUsd - a.costUsd); for (const bucket of buckets.values()) {
for (const bucket of sortedBuckets) { lines.push(`- ${bucket.provider} / ${bucket.model}: ${formatInteger(bucket.runs)} runs, ${formatUsd(bucket.costUsd)}`);
lines.push(
`- ${bucket.provider} / ${bucket.model}: ${formatInteger(bucket.runs)} runs, ${formatUsd(bucket.costUsd)}, input ${formatInteger(bucket.inputTokens)} / output ${formatInteger(bucket.outputTokens)}`,
);
} }
return lines.join("\n"); return lines.join("\n");
} }
function formatHelpCommandInvocation(
invocation: SlashInvocation,
registry: ModelRegistry,
slashCommands: ReadonlyMap<string, SlashCommandDefinition>,
): string {
if (invocation.args.length > 1) {
const helpCommand = slashCommands.get("help");
if (helpCommand === undefined) {
throw new Error("slash command registry is missing /help");
}
return [
"用法错误: /help 只接受一个命令名。",
"",
formatBuiltinSlashCommandHelp(helpCommand),
].join("\n");
}
const target = invocation.args[0];
if (target === undefined) return formatSlashOverview(registry, slashCommands);
const normalizedTarget = normalizeHelpTarget(target);
if (normalizedTarget === "") return formatSlashOverview(registry, slashCommands);
return formatSlashHelpTarget(normalizedTarget, registry, slashCommands);
}
export function formatSlashHelpTarget( export function formatSlashHelpTarget(
target: string, target: string,
registry: ModelRegistry, commands: ReadonlyMap<string, SlashCommandDefinition>,
slashCommands: ReadonlyMap<string, SlashCommandDefinition>,
): string { ): string {
const normalizedTarget = normalizeHelpTarget(target); const normalized = target.trim().replace(/^\/+/, "");
if (normalizedTarget === "") return formatSlashOverview(registry, slashCommands); const command = commands.get(normalized);
return formatSlashCommandHelp(normalizedTarget, registry, slashCommands) ?? formatUnknownSlashHelp(normalizedTarget, registry, slashCommands); if (command === undefined) return [`未知 slash 命令 /${normalized}`, "", formatSlashOverview(commands)].join("\n");
}
function normalizeHelpTarget(target: string): string {
return target.trim().replace(/^\/+/, "");
}
function formatSlashOverview(
registry: ModelRegistry,
slashCommands: ReadonlyMap<string, SlashCommandDefinition>,
): string {
const lines = [
"可用 slash 命令:",
...[...slashCommands.values()].map((command) => `/${command.name} - ${command.summary}`),
];
const roles = visibleRoleCommands(registry, slashCommands);
if (roles.length > 0) {
lines.push("", "角色命令:");
for (const role of roles) {
lines.push(`/${role.id} <需求> - 使用“${role.label}”角色发起请求。`);
}
} else {
lines.push("", "当前没有配置角色命令。");
}
lines.push("", "查看单个命令: /help new 或 /new help。");
return lines.join("\n");
}
function formatSlashCommandHelp(
commandName: string,
registry: ModelRegistry,
slashCommands: ReadonlyMap<string, SlashCommandDefinition>,
): string | null {
const normalizedName = normalizeHelpTarget(commandName);
const slashCommand = slashCommands.get(normalizedName);
if (slashCommand !== undefined) return formatBuiltinSlashCommandHelp(slashCommand);
const role = registry.role(normalizedName);
if (role === undefined) return null;
if (slashCommands.has(role.id)) return null;
return formatRoleSlashCommandHelp(role);
}
function formatBuiltinSlashCommandHelp(command: SlashCommandDefinition): string {
const helpUsage = command.name === "help"
? "帮助: /help help"
: `帮助: /help ${command.name} 或 /${command.name} help`;
return [ return [
`/${command.name}`, `/${command.name}`,
command.summary, command.summary,
"", "",
`用法: ${command.usage}`, `用法: ${command.usage}`,
...command.details.map((detail) => `- ${detail}`), ...command.details.map((detail) => `- ${detail}`),
"",
helpUsage,
].join("\n"); ].join("\n");
} }
function formatRoleSlashCommandHelp(role: RoleEntry): string { function formatSlashOverview(commands: ReadonlyMap<string, SlashCommandDefinition>): string {
const lines = [
`/${role.id}`,
`使用“${role.label}”角色发起一次 agent run。`,
"",
`用法: /${role.id} <需求>`,
"- 真正运行时仍会按当前项目的 role.trigger 授权检查。",
];
if (role.defaultModel !== undefined) {
lines.push(`- 默认模型: ${role.defaultModel}`);
}
lines.push(
`- 工具范围: ${roleToolsDescription(role)}`,
`- Skills: ${roleSkillsDescription(role)}`,
"",
`帮助: /help ${role.id} 或 /${role.id} help`,
);
return lines.join("\n");
}
function roleSkillsDescription(role: RoleEntry): string {
if (role.skills === undefined || role.skills.length === 0) return "无";
return role.skills.map((skill) => `${skill.name}@${skill.version}`).join(", ");
}
function roleToolsDescription(role: RoleEntry): string {
if (role.tools === undefined) return "全部已注册工具";
if (role.tools.length === 0) return "无";
return role.tools.join(", ");
}
function formatUnknownSlashHelp(
commandName: string,
registry: ModelRegistry,
slashCommands: ReadonlyMap<string, SlashCommandDefinition>,
): string {
const normalizedName = normalizeHelpTarget(commandName);
return [ return [
`未知 slash 命令 /${normalizedName}`, "可用 slash 命令:",
...[...commands.values()].map((command) => `/${command.name} - ${command.summary}`),
"", "",
formatSlashOverview(registry, slashCommands), "普通消息会使用 /project 中选定的当前角色。",
].join("\n"); ].join("\n");
} }
function visibleRoleCommands(
registry: ModelRegistry,
slashCommands: ReadonlyMap<string, SlashCommandDefinition>,
): readonly RoleEntry[] {
return registry
.listRoles()
.filter((role) => !slashCommands.has(role.id));
}
+420 -120
View File
@@ -52,22 +52,36 @@ import {
type StagedMessageResourceBatch, type StagedMessageResourceBatch,
} from "./resourceStaging.js"; } from "./resourceStaging.js";
import { TriggerQueue, triggerQueue as defaultTriggerQueue, type QueuedTrigger } from "./triggerQueue.js"; import { TriggerQueue, triggerQueue as defaultTriggerQueue, type QueuedTrigger } from "./triggerQueue.js";
import { createSlashCommandRegistry, formatSlashHelpTarget, parseSlashHelpSubcommand, parseSlashInvocation } from "./slashCommands.js"; import { createSlashCommandRegistry, parseSlashInvocation } from "./slashCommands.js";
import { cphHubMcpToolsForRole, roleToolsAllow } from "../agent/roleTools.js"; import { cphHubMcpToolsForRole, roleToolsAllow } from "../agent/roleTools.js";
import { import {
bindFeishuChatToProject, bindFeishuChatToProject,
createProjectFromFeishuChat, createProjectFromFeishuChat,
ensureOrganizationProjectSettings, ensureOrganizationProjectSettings,
moveProjectToFolder,
renameProjectForActor,
} from "../projectOnboarding.js"; } from "../projectOnboarding.js";
import { import {
buildProjectManagementCard,
buildMoveProjectCard,
buildSessionHistoryCard,
buildProjectOnboardingResolvedCard, buildProjectOnboardingResolvedCard,
buildUnboundChatOnboardingCard, buildUnboundChatOnboardingCard,
projectOnboardingActionFromValue, projectOnboardingActionFromValue,
type OnboardingFolderOption,
type OnboardingProjectOption,
type ProjectOnboardingActionValue, type ProjectOnboardingActionValue,
type ProjectOnboardingView,
} from "./projectOnboardingCard.js"; } from "./projectOnboardingCard.js";
import {
archiveCurrentRoleSession,
browseFolderDestinations,
createFolderAndMoveProject,
listRoleSessionHistory,
loadProjectConsole,
resumeRoleSession,
selectProjectGroupRole,
} from "./projectConsole.js";
import { SiloFixedWindowRateLimiter } from "../deployment/siloRateLimit.js"; import { SiloFixedWindowRateLimiter } from "../deployment/siloRateLimit.js";
import { browseBindableFolder, discoverBindableProjects } from "../projectDiscovery.js";
export { ApprovalManager } from "./approval.js"; export { ApprovalManager } from "./approval.js";
export type { ApprovalResult, PendingApproval } from "./approval.js"; export type { ApprovalResult, PendingApproval } from "./approval.js";
@@ -111,6 +125,7 @@ interface TriggerRunContext {
readonly projectId: string; readonly projectId: string;
readonly senderOpenId: string; readonly senderOpenId: string;
readonly actor: TriggerActor; readonly actor: TriggerActor;
readonly roleId: string;
} }
type StartAgentRunOutcome = "started" | "queued" | "rejected" | "locked" | "skipped"; type StartAgentRunOutcome = "started" | "queued" | "rejected" | "locked" | "skipped";
@@ -148,9 +163,6 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
: new SiloFixedWindowRateLimiter(deps.maxFeishuEventsPerMinute, 60_000); : new SiloFixedWindowRateLimiter(deps.maxFeishuEventsPerMinute, 60_000);
const slashCommands = createSlashCommandRegistry({ const slashCommands = createSlashCommandRegistry({
prisma: deps.prisma, prisma: deps.prisma,
settings: deps.settings,
logger: deps.logger,
triggerQueue,
}); });
const batchContexts = new Map<string, TriggerRunContext>(); const batchContexts = new Map<string, TriggerRunContext>();
// runId → AbortController for live runs. Registered when a run starts, // runId → AbortController for live runs. Registered when a run starts,
@@ -176,10 +188,47 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
} }
}, deps.messageBatcherOptions); }, deps.messageBatcherOptions);
async function projectConsoleCard(
projectId: string,
chatId: string,
actorOpenId: string,
title?: string,
): Promise<Record<string, unknown>> {
const state = await loadProjectConsole(deps.prisma, { projectId, chatId });
const actor = { feishuOpenId: actorOpenId, chatId };
const [manageDecision, organization, roleDecisions] = await Promise.all([
authorizer.can({ actor, action: "collaborator.manage", resource: { type: "PROJECT", id: projectId } }),
resolveSingleActiveOrganizationForFeishuUser(actorOpenId),
Promise.all(state.roles.map(async (role) => ({
role,
decision: await authorizer.can({
actor,
action: "role.trigger",
resource: { type: "PROJECT", id: projectId },
roleId: role.roleId,
}),
}))),
]);
const visibleRoles = roleDecisions.filter(({ decision }) => decision.allowed).map(({ role }) => role);
const isOrgAdmin = organization.status === "ok" &&
organization.organizationId === state.organizationId && isOrgAdminRole(organization.role);
return buildProjectManagementCard({
organizationId: state.organizationId,
projectId: state.projectId,
projectName: state.projectName,
breadcrumb: state.breadcrumb,
selectedRole: state.selectedRole,
roles: visibleRoles,
currentSession: state.currentSession,
canManageProject: manageDecision.allowed || isOrgAdmin,
...(title !== undefined ? { title } : {}),
});
}
async function startAgentRun( async function startAgentRun(
context: TriggerRunContext, context: TriggerRunContext,
cleanPrompt: string, cleanPrompt: string,
options: { readonly queueIfLocked?: boolean } = {}, options: { readonly queueIfLocked?: boolean; readonly nativeSlash?: boolean } = {},
): Promise<StartAgentRunOutcome> { ): Promise<StartAgentRunOutcome> {
const { msg, rt, chatId, projectId, senderOpenId, actor } = context; const { msg, rt, chatId, projectId, senderOpenId, actor } = context;
const sendOptions = sendOptionsForTriggerMessage(msg); const sendOptions = sendOptionsForTriggerMessage(msg);
@@ -193,7 +242,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
return "rejected"; return "rejected";
} }
if (!queueIfLocked) return "locked"; if (!queueIfLocked) return "locked";
return enqueueLockedTrigger(context, cleanPrompt); return enqueueLockedTrigger(context, cleanPrompt, options.nativeSlash === true ? "native_compact" : "conversation");
} }
const project = await deps.prisma.project.findUnique({ const project = await deps.prisma.project.findUnique({
@@ -205,15 +254,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
return "skipped"; return "skipped";
} }
// 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 models = await deps.settings.modelRegistry({ projectId }); const models = await deps.settings.modelRegistry({ projectId });
const { roleId: parsedRole, prompt: parsedAgentPrompt } = extractRole(cleanPrompt, models); const roleId = context.roleId;
const roleId = parsedRole ?? "draft";
// ADR-0019: role trigger is the second gate after project agent.trigger. // ADR-0019: role trigger is the second gate after project agent.trigger.
// Unconfigured roles remain open for back-compat; configured roles require // Configured roles require a matching active RoleTriggerGrant for any
// a matching active RoleTriggerGrant for any resolved principal. // resolved principal; otherwise the role remains open within the project.
const roleDecision = await authorizer.can({ const roleDecision = await authorizer.can({
actor, actor,
action: "role.trigger", action: "role.trigger",
@@ -260,8 +305,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
projectWorkspaceRoot, projectWorkspaceRoot,
deps.resourceLimits, deps.resourceLimits,
); );
const agentPrompt = appendStagedResourcePaths(parsedAgentPrompt, stagedResources, project.workspaceDir); const agentPrompt = appendStagedResourcePaths(cleanPrompt, stagedResources, project.workspaceDir);
const promptForAgent = withFeishuTriggerContext(agentPrompt, feishuTriggerContext); const promptForAgent = options.nativeSlash === true
? cleanPrompt
: withFeishuTriggerContext(agentPrompt, feishuTriggerContext);
// Linearization point for org lifecycle + session/run/lock admission. // Linearization point for org lifecycle + session/run/lock admission.
// FOR SHARE on the Organization row conflicts with a concurrent status // FOR SHARE on the Organization row conflicts with a concurrent status
@@ -621,10 +668,16 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
return "started"; return "started";
} }
async function enqueueLockedTrigger(context: TriggerRunContext, cleanPrompt: string): Promise<StartAgentRunOutcome> { async function enqueueLockedTrigger(
context: TriggerRunContext,
cleanPrompt: string,
executionKind: QueuedTrigger["executionKind"] = "conversation",
): Promise<StartAgentRunOutcome> {
const position = triggerQueue.enqueue(context.projectId, { const position = triggerQueue.enqueue(context.projectId, {
chatId: context.chatId, chatId: context.chatId,
prompt: cleanPrompt, prompt: cleanPrompt,
roleId: context.roleId,
executionKind,
msg: context.msg, msg: context.msg,
senderOpenId: context.senderOpenId, senderOpenId: context.senderOpenId,
actor: context.actor, actor: context.actor,
@@ -662,7 +715,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
const next = triggerQueue.dequeue(projectId); const next = triggerQueue.dequeue(projectId);
if (next === null) return; if (next === null) return;
const outcome = await startAgentRun(contextFromQueuedTrigger(next, rt), next.prompt, { queueIfLocked: false }); const outcome = await startAgentRun(contextFromQueuedTrigger(next, rt), next.prompt, {
queueIfLocked: false,
nativeSlash: next.executionKind === "native_compact",
});
if (outcome === "started") return; if (outcome === "started") return;
if (outcome === "locked") { if (outcome === "locked") {
requeueTrigger(next); requeueTrigger(next);
@@ -679,6 +735,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
projectId: trigger.projectId, projectId: trigger.projectId,
senderOpenId: trigger.senderOpenId, senderOpenId: trigger.senderOpenId,
actor: trigger.actor, actor: trigger.actor,
roleId: trigger.roleId,
}; };
} }
@@ -686,6 +743,8 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
const position = triggerQueue.enqueue(trigger.projectId, { const position = triggerQueue.enqueue(trigger.projectId, {
chatId: trigger.chatId, chatId: trigger.chatId,
prompt: trigger.prompt, prompt: trigger.prompt,
roleId: trigger.roleId,
executionKind: trigger.executionKind,
msg: trigger.msg, msg: trigger.msg,
senderOpenId: trigger.senderOpenId, senderOpenId: trigger.senderOpenId,
actor: trigger.actor, actor: trigger.actor,
@@ -754,21 +813,257 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
} }
try { try {
const organization = await resolveSingleActiveOrganizationForFeishuUser(operatorOpenId);
if (organization.status !== "ok") throw new Error(organization.message);
if (organization.organizationId !== action.organization_id) {
throw new Error("project onboarding organization mismatch");
}
if (
action.action === "select_agent_role" ||
action.action === "new_agent_session" ||
action.action === "show_session_history" ||
action.action === "resume_agent_session" ||
action.action === "compact_agent_session"
) {
const projectId = action.project_id;
if (projectId === undefined) throw new Error(`${action.action} requires project_id`);
const binding = await deps.prisma.projectGroupBinding.findFirst({
where: { projectId, chatId, archivedAt: null },
select: { id: true },
});
if (binding === null) throw new Error("project console action requires the project to be bound to this chat");
const state = await loadProjectConsole(deps.prisma, { projectId, chatId });
const targetRole = action.action === "select_agent_role"
? state.roles.find((role) => role.id === action.agent_role_id)
: state.selectedRole;
if (targetRole === undefined) throw new Error("selected Agent role is unavailable");
const actor = { feishuOpenId: operatorOpenId, chatId };
const [triggerDecision, roleDecision] = await Promise.all([
authorizer.can({ actor, action: "agent.trigger", resource: { type: "PROJECT", id: projectId } }),
authorizer.can({
actor,
action: "role.trigger",
resource: { type: "PROJECT", id: projectId },
roleId: targetRole.roleId,
}),
]);
if (!triggerDecision.allowed || !roleDecision.allowed || roleDecision.actorUserId === undefined) {
throw new Error(`not authorized for Agent role ${targetRole.roleId}`);
}
if (action.action === "select_agent_role") {
await selectProjectGroupRole(deps.prisma, {
projectId,
chatId,
agentRoleId: targetRole.id,
actorUserId: roleDecision.actorUserId,
});
if (messageId === undefined) throw new Error("role selection requires message id");
await patchCard(rt, messageId, await projectConsoleCard(
projectId,
chatId,
operatorOpenId,
`已切换至 ${targetRole.label}`,
));
return;
}
if (action.action === "new_agent_session") {
await archiveCurrentRoleSession(deps.prisma, {
projectId,
chatId,
actorUserId: roleDecision.actorUserId,
});
if (messageId === undefined) throw new Error("new session requires message id");
await patchCard(rt, messageId, await projectConsoleCard(projectId, chatId, operatorOpenId, "已新开会话"));
return;
}
if (action.action === "show_session_history") {
const sessions = await listRoleSessionHistory(deps.prisma, { projectId, chatId });
if (messageId === undefined) throw new Error("session history requires message id");
await patchCard(rt, messageId, buildSessionHistoryCard({
organizationId: state.organizationId,
projectId,
roleLabel: state.selectedRole.label,
sessions,
}));
return;
}
if (action.action === "resume_agent_session") {
if (action.session_id === undefined) throw new Error("resume_agent_session requires session_id");
await resumeRoleSession(deps.prisma, {
projectId,
chatId,
sessionId: action.session_id,
actorUserId: roleDecision.actorUserId,
});
if (messageId === undefined) throw new Error("session resume requires message id");
await patchCard(rt, messageId, await projectConsoleCard(projectId, chatId, operatorOpenId, "已恢复历史会话"));
return;
}
if (state.currentSession === null || !state.currentSession.sdkSessionReady) {
throw new Error("当前角色还没有可压缩的 Claude 会话");
}
const commandMessage: MessageReceiveEvent["message"] = {
message_id: messageId ?? randomUUID(),
chat_id: chatId,
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "/compact" }),
mentions: [],
};
await startAgentRun({
msg: commandMessage,
rt,
chatId,
projectId,
senderOpenId: operatorOpenId,
actor,
roleId: state.selectedRole.roleId,
}, "/compact", { nativeSlash: true });
return;
}
if (
action.action === "browse_move_destination" ||
action.action === "move_project" ||
action.action === "create_folder"
) {
const projectId = action.project_id;
if (projectId === undefined) throw new Error(`${action.action} requires project_id`);
const state = await loadProjectConsole(deps.prisma, { projectId, chatId });
const isOrgAdmin = isOrgAdminRole(organization.role);
const manageDecision = await authorizer.can({
actor: { feishuOpenId: operatorOpenId, chatId },
action: "collaborator.manage",
resource: { type: "PROJECT", id: projectId },
});
if (!isOrgAdmin && !manageDecision.allowed) throw new Error("project MANAGE permission is required");
if (action.action === "browse_move_destination") {
const page = await browseFolderDestinations(deps.prisma, {
organizationId: state.organizationId,
folderId: action.folder_id ?? null,
});
if (messageId === undefined) throw new Error("folder navigation requires message id");
await patchCard(rt, messageId, buildMoveProjectCard({
organizationId: state.organizationId,
projectId,
projectName: state.projectName,
canCreateFolder: isOrgAdmin,
...page,
}));
return;
}
if (action.action === "move_project") {
await moveProjectToFolder(deps.prisma, { projectId, folderId: action.folder_id ?? null });
await writeAudit(deps.prisma, {
projectId,
...(manageDecision.actorUserId !== undefined ? { actorUserId: manageDecision.actorUserId } : {}),
action: "project.moved_from_feishu",
metadata: { folderId: action.folder_id ?? null },
});
if (messageId === undefined) throw new Error("project move requires message id");
await patchCard(rt, messageId, await projectConsoleCard(projectId, chatId, operatorOpenId, "项目已移动"));
return;
}
if (!isOrgAdmin) throw new Error("creating an Organization folder requires OWNER or ADMIN");
const folderName = event.action.form_value?.["folder_name"];
if (typeof folderName !== "string") throw new Error("create folder form is missing folder_name");
const folder = await createFolderAndMoveProject(deps.prisma, {
organizationId: state.organizationId,
projectId,
parentFolderId: action.folder_id ?? null,
name: folderName.slice(0, 100),
...(manageDecision.actorUserId !== undefined ? { actorUserId: manageDecision.actorUserId } : {}),
});
if (messageId === undefined) throw new Error("folder creation requires message id");
await patchCard(rt, messageId, await projectConsoleCard(
projectId,
chatId,
operatorOpenId,
`已新建目录“${folder.folderName}”并移动项目`,
));
return;
}
if (action.action === "rename_project") {
const projectId = action.project_id;
if (projectId === undefined) throw new Error("rename_project action requires project_id");
const binding = await deps.prisma.projectGroupBinding.findFirst({
where: { chatId, projectId, archivedAt: null },
select: { id: true },
});
if (binding === null) throw new Error("project rename requires the project to be bound to this chat");
const submittedName = event.action.form_value?.["project_name"];
if (typeof submittedName !== "string") throw new Error("project rename form is missing project_name");
const renamed = await renameProjectForActor(deps.prisma, {
organizationId: organization.organizationId,
projectId,
actorFeishuOpenId: operatorOpenId,
name: submittedName.slice(0, 100),
});
if (messageId === undefined) throw new Error("project rename requires message id");
await patchCard(rt, messageId, await projectConsoleCard(
renamed.projectId,
chatId,
operatorOpenId,
"项目名称已更新",
));
deps.logger.info({ chatId, projectId, operatorOpenId }, "project onboarding: project renamed");
return;
}
if (action.action === "browse_folder" || action.action === "search_page") {
const activeBinding = await deps.prisma.projectGroupBinding.findFirst({
where: { chatId, archivedAt: null },
select: { projectId: true },
});
if (activeBinding !== null) throw new Error(`chat is already bound to project ${activeBinding.projectId}`);
const settings = await ensureOrganizationProjectSettings(deps.prisma, organization.organizationId);
const view = action.action === "search_page"
? await searchOnboardingView({
organizationId: organization.organizationId,
actorFeishuOpenId: operatorOpenId,
isOrgAdmin: isOrgAdminRole(organization.role),
query: action.search_query ?? "",
page: action.page ?? 1,
})
: await browseOnboardingView({
organizationId: organization.organizationId,
actorFeishuOpenId: operatorOpenId,
isOrgAdmin: isOrgAdminRole(organization.role),
folderId: action.folder_id ?? null,
page: action.page ?? 1,
});
if (messageId === undefined) throw new Error("project onboarding navigation requires message id");
await patchCard(rt, messageId, buildUnboundChatOnboardingCard({
organizationId: organization.organizationId,
organizationName: organization.organizationName,
canCreateProject: settings.membersCanCreateProjects || isOrgAdminRole(organization.role),
view,
}));
deps.logger.info(
{ chatId, operatorOpenId, action: action.action, folderId: action.folder_id, page: action.page },
"project onboarding: navigated discovery card",
);
return;
}
if (action.action === "create_project_from_chat") { if (action.action === "create_project_from_chat") {
const name = defaultProjectNameForChat(chatId); const name = defaultProjectNameForChat(chatId);
const project = await createProjectFromFeishuChat(deps.prisma, { const project = await createProjectFromFeishuChat(deps.prisma, {
organizationId: action.organization_id, organizationId: organization.organizationId,
actorFeishuOpenId: operatorOpenId, actorFeishuOpenId: operatorOpenId,
chatId, chatId,
name, name,
workspaceRoot: projectWorkspaceRoot, workspaceRoot: projectWorkspaceRoot,
folderId: action.folder_id, folderId: action.folder_id,
}); });
await resolveProjectOnboardingCard(rt, messageId, { if (messageId !== undefined) {
title: "已创建并绑定项目", await patchCard(rt, messageId, await projectConsoleCard(
body: `项目 **${escapeCardMarkdown(name)}** 已绑定到本群。后续 @bot 会进入这个项目的 session。`, project.projectId,
template: "green", chatId,
}); operatorOpenId,
"已创建并绑定项目",
));
}
deps.logger.info({ chatId, projectId: project.projectId, operatorOpenId }, "project onboarding: created project from chat"); deps.logger.info({ chatId, projectId: project.projectId, operatorOpenId }, "project onboarding: created project from chat");
return; return;
} }
@@ -777,16 +1072,27 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
if (projectId === undefined) { if (projectId === undefined) {
throw new Error("bind_project action requires project_id"); throw new Error("bind_project action requires project_id");
} }
const target = await deps.prisma.project.findUnique({
where: { id: projectId },
select: { organizationId: true },
});
if (target === null) throw new Error(`project not found: ${projectId}`);
if (target.organizationId !== organization.organizationId) {
throw new Error("project onboarding project organization mismatch");
}
const project = await bindFeishuChatToProject(deps.prisma, { const project = await bindFeishuChatToProject(deps.prisma, {
projectId, projectId,
actorFeishuOpenId: operatorOpenId, actorFeishuOpenId: operatorOpenId,
chatId, chatId,
}); });
await resolveProjectOnboardingCard(rt, messageId, { if (messageId !== undefined) {
title: "已绑定项目", await patchCard(rt, messageId, await projectConsoleCard(
body: `本群已绑定到项目 \`${project.projectId}\`。后续 @bot 会进入这个项目的 session。`, project.projectId,
template: "green", chatId,
}); operatorOpenId,
"已绑定项目",
));
}
deps.logger.info({ chatId, projectId: project.projectId, operatorOpenId }, "project onboarding: bound existing project"); deps.logger.info({ chatId, projectId: project.projectId, operatorOpenId }, "project onboarding: bound existing project");
} catch (e) { } catch (e) {
const reason = e instanceof Error ? e.message : String(e); const reason = e instanceof Error ? e.message : String(e);
@@ -818,7 +1124,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
} }
const binding = await deps.prisma.projectGroupBinding.findFirst({ const binding = await deps.prisma.projectGroupBinding.findFirst({
where: { chatId, archivedAt: null }, where: { chatId, archivedAt: null },
select: { projectId: true }, select: { projectId: true, selectedRole: { select: { roleId: true } } },
}); });
if (binding === null) { if (binding === null) {
deps.logger.debug({ chatId }, "feishu interrupt: chat not bound to any project"); deps.logger.debug({ chatId }, "feishu interrupt: chat not bound to any project");
@@ -923,7 +1229,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
// ADR-0001: resolve chat → project. Unknown chat ⇒ not a project group. // ADR-0001: resolve chat → project. Unknown chat ⇒ not a project group.
const binding = await deps.prisma.projectGroupBinding.findFirst({ const binding = await deps.prisma.projectGroupBinding.findFirst({
where: { chatId, archivedAt: null }, where: { chatId, archivedAt: null },
select: { projectId: true }, select: { projectId: true, selectedRole: { select: { roleId: true } } },
}); });
if (binding === null) { if (binding === null) {
deps.logger.debug({ chatId }, "feishu trigger: chat not bound to any project"); deps.logger.debug({ chatId }, "feishu trigger: chat not bound to any project");
@@ -995,22 +1301,35 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
} }
} }
if (cleanPrompt === null) return; if (cleanPrompt === null) return;
const runContext: TriggerRunContext = { msg, rt, chatId, projectId, senderOpenId, actor }; const runContext: TriggerRunContext = {
msg,
rt,
chatId,
projectId,
senderOpenId,
actor,
roleId: binding.selectedRole.roleId,
};
// Slash commands: session management, not agent runs. These bypass the // Hub owns a closed slash-command protocol. Unknown commands fail visibly;
// batcher. Session commands bypass the lock because they don't create runs; // they are never downgraded to Agent text or forwarded to the SDK.
// role/unknown slash prompts still use the normal run path and queue if locked.
if (cleanPrompt.startsWith("/")) { if (cleanPrompt.startsWith("/")) {
const invocation = parseSlashInvocation(cleanPrompt); const invocation = parseSlashInvocation(cleanPrompt);
const helpSubcommandTarget = invocation === null ? null : parseSlashHelpSubcommand(invocation); if (invocation !== null) {
if (helpSubcommandTarget !== null) { if (invocation.name === "project") {
// Help is generated on demand because role/tool configuration is runtime data. if (invocation.args.length > 0) {
const models = await deps.settings.modelRegistry({ projectId }); await sendText(rt, chatId, "用法: /project", sendOptionsForTriggerMessage(msg));
await sendText(rt, chatId, formatSlashHelpTarget(helpSubcommandTarget, models, slashCommands), sendOptionsForTriggerMessage(msg)); return;
}
await sendCard(
rt,
chatId,
await projectConsoleCard(projectId, chatId, senderOpenId),
sendOptionsForTriggerMessage(msg),
);
deps.logger.info({ chatId, projectId, senderOpenId }, "feishu project console opened");
return; return;
} }
if (invocation !== null) {
const slashCommand = slashCommands.get(invocation.name); const slashCommand = slashCommands.get(invocation.name);
if (slashCommand !== undefined) { if (slashCommand !== undefined) {
try { try {
@@ -1026,8 +1345,8 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
return; return;
} }
} }
const name = invocation?.name ?? cleanPrompt.slice(1).trim();
await startAgentRun(runContext, cleanPrompt); await sendText(rt, chatId, `未知 slash 命令 /${name}。使用 /help 查看可用命令。`, sendOptionsForTriggerMessage(msg));
return; return;
} }
@@ -1041,11 +1360,20 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
await enqueueLockedTrigger(runContext, cleanPrompt); await enqueueLockedTrigger(runContext, cleanPrompt);
return; return;
} }
const key = messageBatchKey(chatId, senderOpenId); const key = messageBatchKey(chatId, senderOpenId, runContext.roleId);
for (const [pendingKey, pendingContext] of batchContexts) {
if (
pendingKey !== key &&
pendingContext.chatId === chatId &&
pendingContext.senderOpenId === senderOpenId
) {
await messageBatcher.flushNow(pendingKey);
}
}
if (!batchContexts.has(key)) { if (!batchContexts.has(key)) {
batchContexts.set(key, runContext); batchContexts.set(key, runContext);
} }
await messageBatcher.enqueue(chatId, senderOpenId, cleanPrompt); await messageBatcher.enqueue(chatId, senderOpenId, cleanPrompt, runContext.roleId);
return; return;
} }
@@ -1070,12 +1398,22 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
const settings = await ensureOrganizationProjectSettings(deps.prisma, organization.organizationId); const settings = await ensureOrganizationProjectSettings(deps.prisma, organization.organizationId);
const canCreateProject = settings.membersCanCreateProjects || isOrgAdminRole(organization.role); const canCreateProject = settings.membersCanCreateProjects || isOrgAdminRole(organization.role);
const projects = await listBindableProjectsForActor({ const searchQuery = (extractPrompt(msg) ?? "").trim().slice(0, 100);
const view = searchQuery === ""
? await browseOnboardingView({
organizationId: organization.organizationId, organizationId: organization.organizationId,
actorFeishuOpenId: senderOpenId, actorFeishuOpenId: senderOpenId,
isOrgAdmin: isOrgAdminRole(organization.role), isOrgAdmin: isOrgAdminRole(organization.role),
folderId: null,
page: 1,
})
: await searchOnboardingView({
organizationId: organization.organizationId,
actorFeishuOpenId: senderOpenId,
isOrgAdmin: isOrgAdminRole(organization.role),
query: searchQuery,
page: 1,
}); });
const folders = await listCreatableRootFolders(organization.organizationId);
await sendCard( await sendCard(
rt, rt,
@@ -1083,9 +1421,8 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
buildUnboundChatOnboardingCard({ buildUnboundChatOnboardingCard({
organizationId: organization.organizationId, organizationId: organization.organizationId,
organizationName: organization.organizationName, organizationName: organization.organizationName,
folders,
projects,
canCreateProject, canCreateProject,
view,
}), }),
sendOptionsForTriggerMessage(msg), sendOptionsForTriggerMessage(msg),
); );
@@ -1178,53 +1515,44 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
}; };
} }
async function listBindableProjectsForActor(input: { async function searchOnboardingView(input: {
readonly organizationId: string; readonly organizationId: string;
readonly actorFeishuOpenId: string; readonly actorFeishuOpenId: string;
readonly isOrgAdmin: boolean; readonly isOrgAdmin: boolean;
}): Promise<readonly OnboardingProjectOption[]> { readonly query: string;
const candidates = await deps.prisma.project.findMany({ readonly page: number;
where: { }): Promise<ProjectOnboardingView> {
return {
mode: "search",
result: await discoverBindableProjects({
prisma: deps.prisma,
organizationId: input.organizationId, organizationId: input.organizationId,
archivedAt: null, actorFeishuOpenId: input.actorFeishuOpenId,
groupBindings: { none: { archivedAt: null } }, isOrgAdmin: input.isOrgAdmin,
}, query: input.query,
select: { page: input.page,
id: true, }),
name: true, };
folder: { select: { name: true } },
},
orderBy: { updatedAt: "desc" },
take: 20,
});
const allowed: OnboardingProjectOption[] = [];
for (const project of candidates) {
if (!input.isOrgAdmin) {
const decision = await authorizer.can({
actor: { feishuOpenId: input.actorFeishuOpenId },
action: "collaborator.manage",
resource: { type: "PROJECT", id: project.id },
});
if (!decision.allowed) continue;
}
allowed.push({
projectId: project.id,
name: project.name,
...(project.folder?.name !== undefined ? { folderName: project.folder.name } : {}),
});
if (allowed.length >= 5) break;
}
return allowed;
} }
async function listCreatableRootFolders(organizationId: string): Promise<readonly OnboardingFolderOption[]> { async function browseOnboardingView(input: {
const folders = await deps.prisma.folder.findMany({ readonly organizationId: string;
where: { organizationId, parentId: null, archivedAt: null }, readonly actorFeishuOpenId: string;
select: { id: true, name: true }, readonly isOrgAdmin: boolean;
orderBy: [{ sortKey: "asc" }, { name: "asc" }], readonly folderId: string | null;
take: 3, readonly page: number;
}); }): Promise<ProjectOnboardingView> {
return folders.map((folder) => ({ folderId: folder.id, name: folder.name })); return {
mode: "browse",
result: await browseBindableFolder({
prisma: deps.prisma,
organizationId: input.organizationId,
actorFeishuOpenId: input.actorFeishuOpenId,
isOrgAdmin: input.isOrgAdmin,
folderId: input.folderId,
page: input.page,
}),
};
} }
return Object.assign(onMessage, { onCardAction, approvalManager }); return Object.assign(onMessage, { onCardAction, approvalManager });
@@ -1488,10 +1816,6 @@ function shortId(value: string): string {
return value.length <= 8 ? value : value.slice(-8); return value.length <= 8 ? value : value.slice(-8);
} }
function escapeCardMarkdown(value: string): string {
return value.replace(/([`*_{}[\]<>])/g, "\\$1");
}
/** Lark text-message content: `{"text":"@_user_1 do something"}`. */ /** Lark text-message content: `{"text":"@_user_1 do something"}`. */
const TextMessageContentSchema = z.object({ text: z.string() }); const TextMessageContentSchema = z.object({ text: z.string() });
@@ -1634,27 +1958,3 @@ async function compensateFailedResourceAdmission(
); );
} }
} }
/**
* Parse a leading `/<role>` command from a prompt (e.g. `/draft 帮我写教案`).
* Returns the role id and the remaining prompt with the command stripped.
* Control/help commands already handled upstream 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) };
}
+3
View File
@@ -4,6 +4,9 @@ export interface QueuedTrigger {
readonly projectId: string; readonly projectId: string;
readonly chatId: string; readonly chatId: string;
readonly prompt: string; readonly prompt: string;
/** Role frozen when the message was accepted; later group switches cannot change it. */
readonly roleId: string;
readonly executionKind: "conversation" | "native_compact";
readonly msg: MessageReceiveEvent["message"]; readonly msg: MessageReceiveEvent["message"];
readonly senderOpenId: string; readonly senderOpenId: string;
readonly actor: { readonly feishuOpenId: string; readonly chatId: string }; readonly actor: { readonly feishuOpenId: string; readonly chatId: string };
+5
View File
@@ -2,6 +2,7 @@ import Fastify from "fastify";
import { registerAdminPlugin } from "./admin/plugin.js"; import { registerAdminPlugin } from "./admin/plugin.js";
import { prisma } from "./db.js"; import { prisma } from "./db.js";
import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js"; import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
import { archiveFeishuBindingForLifecycleEvent } from "./feishu/bindingLifecycle.js";
import { makeTriggerHandler } from "./feishu/trigger.js"; import { makeTriggerHandler } from "./feishu/trigger.js";
import { removeAbandonedMessageResourceStages } from "./feishu/resourceStaging.js"; import { removeAbandonedMessageResourceStages } from "./feishu/resourceStaging.js";
import { triggerQueue } from "./feishu/triggerQueue.js"; import { triggerQueue } from "./feishu/triggerQueue.js";
@@ -177,6 +178,10 @@ export async function startHub(): Promise<void> {
process.exitCode = 1; process.exitCode = 1;
void app.close().catch((error) => app.log.error({ err: error }, "Hub close after Feishu failure failed")); void app.close().catch((error) => app.log.error({ err: error }, "Hub close after Feishu failure failed"));
}, },
async (event) => {
const result = await archiveFeishuBindingForLifecycleEvent(prisma, event);
app.log.info({ ...event, archived: result.archived, projectId: result.projectId }, "feishu binding lifecycle event handled");
},
); );
} else { } else {
app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED"); app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED");
+18 -3
View File
@@ -4,7 +4,7 @@
* Folders are transparent navigation nodes (not ACL resources). Project grants * Folders are transparent navigation nodes (not ACL resources). Project grants
* stay on PROJECT. Archive folder refuses when active children/projects remain. * stay on PROJECT. Archive folder refuses when active children/projects remain.
*/ */
import type { Prisma, PrismaClient } from "@prisma/client"; import { Prisma, type PrismaClient } from "@prisma/client";
import { import {
archiveFeishuChatBinding, archiveFeishuChatBinding,
createFolder, createFolder,
@@ -123,11 +123,25 @@ export async function renameFolder(
return prisma.$transaction(async (tx) => { return prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId); await lockActiveOrganization(tx, input.organizationId);
const folder = await requireActiveFolder(tx, input.folderId, input.organizationId); const folder = await requireActiveFolder(tx, input.folderId, input.organizationId);
if (folder.kind === "SYSTEM_INBOX" && (input.name !== undefined || input.parentId !== undefined)) {
throw new Error("system Inbox cannot be renamed or moved");
}
if (input.parentId !== undefined && input.parentId !== null) { if (input.parentId !== undefined && input.parentId !== null) {
if (input.parentId === folder.id) { if (input.parentId === folder.id) {
throw new Error("folder cannot be its own parent"); throw new Error("folder cannot be its own parent");
} }
await requireActiveFolder(tx, input.parentId, input.organizationId); await requireActiveFolder(tx, input.parentId, input.organizationId);
const descendant = await tx.$queryRaw<Array<{ found: boolean }>>(Prisma.sql`
WITH RECURSIVE descendants AS (
SELECT "id" FROM "Folder" WHERE "parentId" = ${folder.id} AND "archivedAt" IS NULL
UNION ALL
SELECT child."id" FROM "Folder" child
JOIN descendants parent ON child."parentId" = parent."id"
WHERE child."archivedAt" IS NULL
)
SELECT EXISTS(SELECT 1 FROM descendants WHERE "id" = ${input.parentId}) AS found
`);
if (descendant[0]?.found === true) throw new Error("folder cannot be moved below its descendant");
} }
const name = const name =
input.name !== undefined ? requireNonEmpty(input.name, "folder name") : undefined; input.name !== undefined ? requireNonEmpty(input.name, "folder name") : undefined;
@@ -152,6 +166,7 @@ export async function archiveFolder(
return prisma.$transaction(async (tx) => { return prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId); await lockActiveOrganization(tx, input.organizationId);
const folder = await requireActiveFolder(tx, input.folderId, input.organizationId); const folder = await requireActiveFolder(tx, input.folderId, input.organizationId);
if (folder.kind === "SYSTEM_INBOX") throw new Error("system Inbox cannot be archived");
const childFolders = await tx.folder.count({ const childFolders = await tx.folder.count({
where: { parentId: folder.id, archivedAt: null }, where: { parentId: folder.id, archivedAt: null },
}); });
@@ -324,10 +339,10 @@ async function requireActiveFolder(
prisma: PrismaClient | Prisma.TransactionClient, prisma: PrismaClient | Prisma.TransactionClient,
folderId: string, folderId: string,
organizationId: string, organizationId: string,
): Promise<{ readonly id: string; readonly organizationId: string }> { ): Promise<{ readonly id: string; readonly organizationId: string; readonly kind: "REGULAR" | "SYSTEM_INBOX" }> {
const folder = await prisma.folder.findUnique({ const folder = await prisma.folder.findUnique({
where: { id: folderId }, where: { id: folderId },
select: { id: true, organizationId: true, archivedAt: true }, select: { id: true, organizationId: true, kind: true, archivedAt: true },
}); });
if (folder === null || folder.archivedAt !== null || folder.organizationId !== organizationId) { if (folder === null || folder.archivedAt !== null || folder.organizationId !== organizationId) {
throw new Error(`active folder not found: ${folderId}`); throw new Error(`active folder not found: ${folderId}`);
+315
View File
@@ -0,0 +1,315 @@
import { Prisma, type PrismaClient } from "@prisma/client";
import { PrismaPrincipalResolver } from "./permission.js";
const DEFAULT_PAGE_SIZE = 8;
const MAX_PAGE_SIZE = 10;
export interface ProjectDiscoveryItem {
readonly projectId: string;
readonly name: string;
readonly breadcrumb: string;
}
export interface ProjectDiscoveryPage {
readonly query: string;
readonly page: number;
readonly pageSize: number;
readonly totalItems: number;
readonly totalPages: number;
readonly items: readonly ProjectDiscoveryItem[];
}
export interface ProjectFolderPage {
readonly folderId: string | null;
readonly parentFolderId: string | null;
readonly breadcrumb: string;
readonly page: number;
readonly pageSize: number;
readonly totalPages: number;
readonly totalFolders: number;
readonly totalProjects: number;
readonly childFolders: readonly { readonly folderId: string; readonly name: string }[];
readonly projects: readonly ProjectDiscoveryItem[];
}
interface DiscoveryCandidate extends ProjectDiscoveryItem {
readonly updatedAt: Date;
}
export function normalizeProjectSearchQuery(value: string): string {
return value.normalize("NFKC").toLocaleLowerCase("und").replace(/[\s_.:/\\-]+/gu, "");
}
export async function discoverBindableProjects(input: {
readonly prisma: PrismaClient;
readonly organizationId: string;
readonly actorFeishuOpenId: string;
readonly isOrgAdmin: boolean;
readonly query: string;
readonly page?: number | undefined;
readonly pageSize?: number | undefined;
}): Promise<ProjectDiscoveryPage> {
const query = input.query.trim().slice(0, 100);
const normalizedQuery = normalizeProjectSearchQuery(query);
const manageableIds = input.isOrgAdmin
? null
: await listManageableProjectIds(input.prisma, input.organizationId, input.actorFeishuOpenId);
const pageSize = normalizedPageSize(input.pageSize);
const totalItems = await countSearchCandidates(
input.prisma,
input.organizationId,
normalizedQuery,
searchTokens(query),
manageableIds,
);
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
const page = normalizedPage(input.page, totalPages);
const items = await searchCandidates(
input.prisma,
input.organizationId,
normalizedQuery,
searchTokens(query),
manageableIds,
page,
pageSize,
);
return { query, page, pageSize, totalItems, totalPages, items };
}
export async function browseBindableFolder(input: {
readonly prisma: PrismaClient;
readonly organizationId: string;
readonly actorFeishuOpenId: string;
readonly isOrgAdmin: boolean;
readonly folderId: string | null;
readonly page?: number | undefined;
readonly pageSize?: number | undefined;
}): Promise<ProjectFolderPage> {
const folder = input.folderId === null
? null
: await input.prisma.folder.findFirst({
where: { id: input.folderId, organizationId: input.organizationId, archivedAt: null },
select: { id: true, parentId: true },
});
if (input.folderId !== null && folder === null) throw new Error(`folder not found: ${input.folderId}`);
const manageableIds = input.isOrgAdmin
? null
: await listManageableProjectIds(input.prisma, input.organizationId, input.actorFeishuOpenId);
const inbox = input.folderId === null
? await input.prisma.folder.findFirst({
where: { organizationId: input.organizationId, kind: "SYSTEM_INBOX", archivedAt: null },
select: { id: true },
})
: null;
const projectWhere: Prisma.ProjectSearchDocumentWhereInput = {
organizationId: input.organizationId,
...(manageableIds === null ? {} : { projectId: { in: [...manageableIds] } }),
project: {
...(input.folderId === null
? { OR: [{ folderId: null }, ...(inbox === null ? [] : [{ folderId: inbox.id }])] }
: { folderId: input.folderId }),
archivedAt: null,
groupBindings: { none: { archivedAt: null } },
},
};
const folderWhere: Prisma.FolderWhereInput = {
organizationId: input.organizationId,
parentId: input.folderId,
archivedAt: null,
...(input.folderId === null ? { kind: { not: "SYSTEM_INBOX" } } : {}),
};
const [breadcrumb, totalFolders, totalProjects] = await Promise.all([
input.folderId === null ? Promise.resolve("") : folderBreadcrumb(input.prisma, input.folderId),
input.prisma.folder.count({ where: folderWhere }),
input.prisma.projectSearchDocument.count({ where: projectWhere }),
]);
const pageSize = normalizedPageSize(input.pageSize);
const totalPages = Math.max(1, Math.ceil((totalFolders + totalProjects) / pageSize));
const page = normalizedPage(input.page, totalPages);
const offset = (page - 1) * pageSize;
const folderSkip = Math.min(offset, totalFolders);
const folderTake = Math.min(pageSize, Math.max(0, totalFolders - folderSkip));
const projectSkip = Math.max(0, offset - totalFolders);
const projectTake = pageSize - folderTake;
const [childFolders, candidates] = await Promise.all([
folderTake === 0 ? Promise.resolve([]) : input.prisma.folder.findMany({
where: folderWhere,
select: { id: true, name: true },
orderBy: [{ sortKey: "asc" }, { name: "asc" }, { id: "asc" }],
skip: folderSkip,
take: folderTake,
}),
projectTake === 0 ? Promise.resolve([]) : input.prisma.projectSearchDocument.findMany({
where: projectWhere,
select: { projectId: true, name: true, breadcrumb: true },
orderBy: [{ name: "asc" }, { projectId: "asc" }],
skip: projectSkip,
take: projectTake,
}),
]);
return {
folderId: input.folderId,
parentFolderId: folder?.parentId ?? null,
breadcrumb,
page,
pageSize,
totalPages,
totalFolders,
totalProjects,
childFolders: childFolders.map((child) => ({ folderId: child.id, name: child.name })),
projects: candidates,
};
}
async function searchCandidates(
prisma: PrismaClient,
organizationId: string,
normalizedQuery: string,
tokens: readonly string[],
manageableIds: readonly string[] | null,
page: number,
pageSize: number,
): Promise<readonly DiscoveryCandidate[]> {
const access = accessPredicate(manageableIds);
const offset = (page - 1) * pageSize;
if (normalizedQuery === "") {
return prisma.$queryRaw<DiscoveryCandidate[]>(Prisma.sql`
SELECT d."projectId", d."name", d."breadcrumb", p."updatedAt"
FROM "ProjectSearchDocument" d
JOIN "Project" p ON p."id" = d."projectId"
WHERE d."organizationId" = ${organizationId}
AND p."archivedAt" IS NULL
AND NOT EXISTS (
SELECT 1 FROM "ProjectGroupBinding" b
WHERE b."projectId" = p."id" AND b."archivedAt" IS NULL
)
${access}
ORDER BY p."updatedAt" DESC, p."id" DESC
LIMIT ${pageSize} OFFSET ${offset}
`);
}
const matches = searchPredicate(normalizedQuery, tokens);
return prisma.$queryRaw<DiscoveryCandidate[]>(Prisma.sql`
SELECT d."projectId", d."name", d."breadcrumb", p."updatedAt"
FROM "ProjectSearchDocument" d
JOIN "Project" p ON p."id" = d."projectId"
WHERE d."organizationId" = ${organizationId}
AND p."archivedAt" IS NULL
AND NOT EXISTS (
SELECT 1 FROM "ProjectGroupBinding" b
WHERE b."projectId" = p."id" AND b."archivedAt" IS NULL
)
${access}
AND ${matches}
ORDER BY
CASE
WHEN d."normalizedCode" = ${normalizedQuery} THEN 0
WHEN d."normalizedName" = ${normalizedQuery} THEN 1
WHEN strpos(d."normalizedCode", ${normalizedQuery}) = 1 THEN 2
WHEN strpos(d."normalizedName", ${normalizedQuery}) = 1 THEN 3
WHEN strpos(d."normalizedName", ${normalizedQuery}) > 0 THEN 4
WHEN strpos(d."normalizedBreadcrumb", ${normalizedQuery}) > 0 THEN 5
ELSE 6
END,
similarity(d."normalizedName", ${normalizedQuery}) DESC,
p."updatedAt" DESC,
p."id" ASC
LIMIT ${pageSize} OFFSET ${offset}
`);
}
async function countSearchCandidates(
prisma: PrismaClient,
organizationId: string,
normalizedQuery: string,
tokens: readonly string[],
manageableIds: readonly string[] | null,
): Promise<number> {
const access = accessPredicate(manageableIds);
const queryPredicate = normalizedQuery === "" ? Prisma.empty : Prisma.sql`AND ${searchPredicate(normalizedQuery, tokens)}`;
const rows = await prisma.$queryRaw<Array<{ count: number }>>(Prisma.sql`
SELECT count(*)::int AS count
FROM "ProjectSearchDocument" d
JOIN "Project" p ON p."id" = d."projectId"
WHERE d."organizationId" = ${organizationId}
AND p."archivedAt" IS NULL
AND NOT EXISTS (
SELECT 1 FROM "ProjectGroupBinding" b
WHERE b."projectId" = p."id" AND b."archivedAt" IS NULL
)
${access}
${queryPredicate}
`);
return rows[0]?.count ?? 0;
}
async function listManageableProjectIds(
prisma: PrismaClient,
organizationId: string,
actorFeishuOpenId: string,
): Promise<readonly string[]> {
const resolution = await new PrismaPrincipalResolver(prisma).resolveActor(
{ feishuOpenId: actorFeishuOpenId },
{ organizationId },
);
const grants = await prisma.permissionGrant.findMany({
where: {
resourceType: "PROJECT",
role: "MANAGE",
revokedAt: null,
OR: resolution.principals.map((principal) => ({
principalType: principal.type,
principalId: principal.id,
})),
},
select: { resourceId: true },
});
const projectIds = [...new Set(grants.map((grant) => grant.resourceId))];
if (projectIds.length === 0) return [];
const projects = await prisma.project.findMany({
where: { id: { in: projectIds }, organizationId, archivedAt: null },
select: { id: true },
});
return projects.map((project) => project.id);
}
function searchPredicate(normalizedQuery: string, tokens: readonly string[]): Prisma.Sql {
const tokenMatch = tokens.length <= 1
? Prisma.sql`FALSE`
: Prisma.sql`(${Prisma.join(tokens.map((token) => Prisma.sql`strpos(d."normalizedSearchText", ${token}) > 0`), " AND ")})`;
return Prisma.sql`(
strpos(d."normalizedCode", ${normalizedQuery}) > 0
OR strpos(d."normalizedName", ${normalizedQuery}) > 0
OR strpos(d."normalizedBreadcrumb", ${normalizedQuery}) > 0
OR d."normalizedName" % ${normalizedQuery}
OR ${tokenMatch}
)`;
}
function accessPredicate(manageableIds: readonly string[] | null): Prisma.Sql {
if (manageableIds === null) return Prisma.empty;
if (manageableIds.length === 0) return Prisma.sql`AND FALSE`;
return Prisma.sql`AND p."id" IN (${Prisma.join(manageableIds)})`;
}
function searchTokens(query: string): readonly string[] {
return query.normalize("NFKC").trim().split(/\s+/u).map(normalizeProjectSearchQuery).filter(Boolean);
}
function normalizedPageSize(value = DEFAULT_PAGE_SIZE): number {
return Math.min(MAX_PAGE_SIZE, Math.max(1, Math.trunc(value)));
}
function normalizedPage(value: number | undefined, totalPages: number): number {
return Math.min(totalPages, Math.max(1, Math.trunc(value ?? 1)));
}
async function folderBreadcrumb(prisma: PrismaClient, folderId: string): Promise<string> {
const rows = await prisma.$queryRaw<Array<{ breadcrumb: string }>>(Prisma.sql`
SELECT cph_folder_breadcrumb(${folderId}) AS breadcrumb
`);
const breadcrumb = rows[0]?.breadcrumb;
if (breadcrumb === undefined) throw new Error(`failed to resolve folder breadcrumb: ${folderId}`);
return breadcrumb;
}
+84 -5
View File
@@ -27,6 +27,8 @@ export interface CreateOrgAdminProjectInput {
readonly workspaceRoot: string; readonly workspaceRoot: string;
readonly folderId?: string | undefined; readonly folderId?: string | undefined;
readonly sortKey?: string | undefined; readonly sortKey?: string | undefined;
/** Stable internal identifier for resumable imports; ordinary callers must omit it. */
readonly projectId?: string | undefined;
} }
export interface CreateFeishuChatProjectInput { export interface CreateFeishuChatProjectInput {
@@ -51,6 +53,13 @@ export interface ArchiveFeishuChatBindingInput {
readonly actorFeishuOpenId: string; readonly actorFeishuOpenId: string;
} }
export interface RenameProjectForActorInput {
readonly organizationId: string;
readonly projectId: string;
readonly actorFeishuOpenId: string;
readonly name: string;
}
export interface CreateFolderInput { export interface CreateFolderInput {
readonly organizationId: string; readonly organizationId: string;
readonly name: string; readonly name: string;
@@ -129,6 +138,45 @@ export async function moveProjectToFolder(
}); });
} }
export async function renameProjectForActor(
prisma: PrismaClient,
input: RenameProjectForActorInput,
): Promise<{ readonly projectId: string; readonly name: string }> {
const name = requireNonEmpty(input.name, "project name");
const project = await prisma.project.findUnique({
where: { id: input.projectId },
select: { id: true, organizationId: true, name: true },
});
if (project === null) throw new Error(`project not found: ${input.projectId}`);
if (project.organizationId !== input.organizationId) {
throw new Error(`project ${project.id} does not belong to organization ${input.organizationId}`);
}
const actor = await requireProjectManager(prisma, project.id, project.organizationId, input.actorFeishuOpenId);
const updated = await prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, project.organizationId);
const current = await tx.project.findUniqueOrThrow({
where: { id: project.id },
select: { name: true },
});
const renamed = await tx.project.update({
where: { id: project.id },
data: { name },
select: { id: true, name: true },
});
await tx.auditEntry.create({
data: {
organizationId: project.organizationId,
projectId: project.id,
actorUserId: actor.userId,
action: "project.renamed",
metadata: { oldName: current.name, newName: name, actorVia: actor.via },
},
});
return renamed;
});
return { projectId: updated.id, name: updated.name };
}
export async function createProjectFromOrgAdmin( export async function createProjectFromOrgAdmin(
prisma: PrismaClient, prisma: PrismaClient,
input: CreateOrgAdminProjectInput, input: CreateOrgAdminProjectInput,
@@ -147,6 +195,7 @@ export async function createProjectFromOrgAdmin(
workspaceRoot: input.workspaceRoot, workspaceRoot: input.workspaceRoot,
folderId: input.folderId, folderId: input.folderId,
sortKey: input.sortKey, sortKey: input.sortKey,
projectId: input.projectId,
chatId: undefined, chatId: undefined,
}); });
} }
@@ -195,6 +244,7 @@ export async function bindFeishuChatToProject(
const actor = await requireProjectManager(prisma, project.id, project.organizationId, input.actorFeishuOpenId); const actor = await requireProjectManager(prisma, project.id, project.organizationId, input.actorFeishuOpenId);
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
await requireActiveOrganizationTx(tx, project.organizationId); await requireActiveOrganizationTx(tx, project.organizationId);
const defaultRole = await requireDefaultAgentRole(tx, project.organizationId);
const activeProjectBinding = await tx.projectGroupBinding.findFirst({ const activeProjectBinding = await tx.projectGroupBinding.findFirst({
where: { projectId: project.id, archivedAt: null }, where: { projectId: project.id, archivedAt: null },
select: { chatId: true }, select: { chatId: true },
@@ -210,7 +260,13 @@ export async function bindFeishuChatToProject(
throw new Error(`Feishu chat ${chatId} is already bound to project ${activeChatBinding.projectId}`); throw new Error(`Feishu chat ${chatId} is already bound to project ${activeChatBinding.projectId}`);
} }
await tx.projectGroupBinding.create({ await tx.projectGroupBinding.create({
data: { projectId: project.id, chatId, createdByUserId: actor.userId }, data: {
organizationId: project.organizationId,
projectId: project.id,
chatId,
createdByUserId: actor.userId,
selectedAgentRoleId: defaultRole.id,
},
}); });
await replaceProjectGrant(tx, { await replaceProjectGrant(tx, {
projectId: project.id, projectId: project.id,
@@ -291,6 +347,7 @@ async function createManagedProject(
readonly workspaceRoot: string; readonly workspaceRoot: string;
readonly folderId: string | undefined; readonly folderId: string | undefined;
readonly sortKey?: string | undefined; readonly sortKey?: string | undefined;
readonly projectId?: string | undefined;
readonly chatId: string | undefined; readonly chatId: string | undefined;
}, },
): Promise<ProjectOnboardingResult> { ): Promise<ProjectOnboardingResult> {
@@ -301,7 +358,7 @@ async function createManagedProject(
if (organization === null) throw new Error(`organization not found: ${input.organizationId}`); if (organization === null) throw new Error(`organization not found: ${input.organizationId}`);
requireActiveOrganizationStatus(organization.id, organization.status); requireActiveOrganizationStatus(organization.id, organization.status);
const projectId = createProjectId(); const projectId = input.projectId ?? createProjectId();
const workspaceDir = projectWorkspaceDir({ const workspaceDir = projectWorkspaceDir({
workspaceRoot: input.workspaceRoot, workspaceRoot: input.workspaceRoot,
organizationSlug: organization.slug, organizationSlug: organization.slug,
@@ -341,8 +398,15 @@ async function createManagedProject(
createdByUserId: input.actorUserId, createdByUserId: input.actorUserId,
}); });
if (input.chatId !== undefined) { if (input.chatId !== undefined) {
const defaultRole = await requireDefaultAgentRole(tx, organization.id);
await tx.projectGroupBinding.create({ await tx.projectGroupBinding.create({
data: { projectId: created.id, chatId: input.chatId, createdByUserId: input.actorUserId }, data: {
organizationId: organization.id,
projectId: created.id,
chatId: input.chatId,
createdByUserId: input.actorUserId,
selectedAgentRoleId: defaultRole.id,
},
}); });
await replaceProjectGrant(tx, { await replaceProjectGrant(tx, {
projectId: created.id, projectId: created.id,
@@ -490,12 +554,12 @@ async function ensureOrganizationProjectSettingsTx(
async function ensureInboxFolder(prisma: Prisma.TransactionClient, organizationId: string): Promise<{ readonly id: string }> { async function ensureInboxFolder(prisma: Prisma.TransactionClient, organizationId: string): Promise<{ readonly id: string }> {
const existing = await prisma.folder.findFirst({ const existing = await prisma.folder.findFirst({
where: { organizationId, parentId: null, name: "Inbox", archivedAt: null }, where: { organizationId, kind: "SYSTEM_INBOX", archivedAt: null },
select: { id: true }, select: { id: true },
}); });
if (existing !== null) return existing; if (existing !== null) return existing;
return prisma.folder.create({ return prisma.folder.create({
data: { organizationId, name: "Inbox", sortKey: "000000" }, data: { organizationId, name: "Inbox", kind: "SYSTEM_INBOX", sortKey: "000000" },
select: { id: true }, select: { id: true },
}); });
} }
@@ -517,6 +581,21 @@ async function assertFolderInOrganization(
} }
} }
async function requireDefaultAgentRole(
tx: Prisma.TransactionClient,
organizationId: string,
): Promise<{ readonly id: string }> {
const roles = await tx.organizationAgentRole.findMany({
where: { organizationId, isDefault: true, disabledAt: null },
select: { id: true },
take: 2,
});
if (roles.length !== 1) {
throw new Error(`organization ${organizationId} must have exactly one active default Agent role`);
}
return roles[0]!;
}
async function requireActiveOrganization(prisma: PrismaClient, organizationId: string): Promise<void> { async function requireActiveOrganization(prisma: PrismaClient, organizationId: string): Promise<void> {
const organization = await prisma.organization.findUnique({ const organization = await prisma.organization.findUnique({
where: { id: organizationId }, where: { id: organizationId },
+4 -3
View File
@@ -177,6 +177,10 @@ export class DatabaseRuntimeSettings implements RuntimeSettings {
if (project.organization.agentRoles.length === 0) { if (project.organization.agentRoles.length === 0) {
throw new Error(`no active Agent roles configured for organization ${project.organization.id}`); throw new Error(`no active Agent roles configured for organization ${project.organization.id}`);
} }
const defaultRoles = project.organization.agentRoles.filter((role) => role.isDefault);
if (defaultRoles.length !== 1) {
throw new Error(`organization ${project.organization.id} must have exactly one active default Agent role`);
}
const defaults = await this.envSettings.modelRegistry(scope); const defaults = await this.envSettings.modelRegistry(scope);
const enabledModels = new Set(defaults.listModels().map((model) => model.id)); const enabledModels = new Set(defaults.listModels().map((model) => model.id));
@@ -206,9 +210,6 @@ export class DatabaseRuntimeSettings implements RuntimeSettings {
}; };
}), }),
})); }));
if (!roles.some((role) => role.id === "draft")) {
throw new Error(`default Agent role draft is not configured for organization ${project.organization.id}`);
}
return new InMemoryModelRegistry(defaults.listModels(), roles); return new InMemoryModelRegistry(defaults.listModels(), roles);
} }
+61 -1
View File
@@ -206,7 +206,12 @@ describe("admin explorer API", () => {
it("blocks cross-org project access by id", async () => { it("blocks cross-org project access by id", async () => {
const token = await seedAdmin(); const token = await seedAdmin();
await prisma.organization.create({ await prisma.organization.create({
data: { id: "org_other", slug: "other", name: "Other" }, data: {
id: "org_other",
slug: "other",
name: "Other",
agentRoles: { create: { roleId: "draft", label: "草稿", isDefault: true } },
},
}); });
await prisma.project.create({ await prisma.project.create({
data: { data: {
@@ -284,4 +289,59 @@ describe("admin explorer API", () => {
}); });
}, },
); );
it("rejects moving a folder below one of its descendants", async () => {
const parent = await prisma.folder.create({
data: { id: "folder-cycle-parent", organizationId: DEFAULT_ORG_ID, name: "Parent" },
});
const child = await prisma.folder.create({
data: { id: "folder-cycle-child", organizationId: DEFAULT_ORG_ID, parentId: parent.id, name: "Child" },
});
await expect(renameFolder(prisma, {
organizationId: DEFAULT_ORG_ID,
folderId: parent.id,
parentId: child.id,
})).rejects.toThrow("folder cannot be moved below its descendant");
});
it("keeps the system Inbox identity immutable", async () => {
const inbox = await prisma.folder.findFirstOrThrow({
where: { organizationId: DEFAULT_ORG_ID, kind: "SYSTEM_INBOX" },
});
await expect(renameFolder(prisma, {
organizationId: DEFAULT_ORG_ID,
folderId: inbox.id,
name: "课程",
})).rejects.toThrow("system Inbox cannot be renamed or moved");
await expect(renameFolder(prisma, {
organizationId: DEFAULT_ORG_ID,
folderId: inbox.id,
parentId: null,
})).rejects.toThrow("system Inbox cannot be renamed or moved");
await expect(archiveFolder(prisma, {
organizationId: DEFAULT_ORG_ID,
folderId: inbox.id,
})).rejects.toThrow("system Inbox cannot be archived");
await expect(prisma.folder.update({
where: { id: inbox.id },
data: { kind: "REGULAR" },
})).rejects.toThrow("system Inbox identity cannot be changed");
await expect(prisma.folder.update({
where: { id: inbox.id },
data: { id: `${inbox.id}-moved` },
})).rejects.toThrow("system Inbox identity cannot be changed");
await expect(prisma.folder.delete({ where: { id: inbox.id } }))
.rejects.toThrow("system Inbox cannot be deleted while its organization exists");
await expect(prisma.folder.create({
data: {
organizationId: DEFAULT_ORG_ID,
name: "Malformed",
kind: "SYSTEM_INBOX",
parentId: inbox.id,
},
})).rejects.toThrow("system Inbox must be an active root folder named Inbox");
});
}); });
@@ -60,7 +60,10 @@ describe("Organization Agent configuration management", () => {
expect(role.tools).toEqual(["read_file", "write_file", "cph_build"]); expect(role.tools).toEqual(["read_file", "write_file", "cph_build"]);
expect(role.skillBindings.map((binding) => binding.skill.name)).toEqual(["outline", "typst"]); expect(role.skillBindings.map((binding) => binding.skill.name)).toEqual(["outline", "typst"]);
await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: "session-old-role-config" } })) await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: "session-old-role-config" } }))
.resolves.toMatchObject({ archivedAt: expect.any(Date) }); .resolves.toMatchObject({
archivedAt: expect.any(Date),
metadata: expect.objectContaining({ userResumable: false }),
});
}); });
it("rejects unknown, disabled and cross-Organization skills", async () => { it("rejects unknown, disabled and cross-Organization skills", async () => {
@@ -81,6 +84,40 @@ describe("Organization Agent configuration management", () => {
})).rejects.toThrow("active skills not found in organization"); })).rejects.toThrow("active skills not found in organization");
}); });
it("switches the Organization default role atomically", async () => {
await configuration.upsertRole({
organizationId: DEFAULT_ORG_ID,
roleId: "review",
label: "审校",
tools: ["read_file"],
isDefault: true,
});
await expect(prisma.organizationAgentRole.findMany({
where: { organizationId: DEFAULT_ORG_ID, isDefault: true, disabledAt: null },
select: { roleId: true },
})).resolves.toEqual([{ roleId: "review" }]);
await expect(configuration.upsertRole({
organizationId: DEFAULT_ORG_ID,
roleId: "review",
label: "审校",
isDefault: false,
})).rejects.toThrow("cannot unset the active default role without selecting a replacement");
});
it("rejects a zero-default committed state at the database boundary", async () => {
await expect(prisma.organizationAgentRole.updateMany({
where: { organizationId: DEFAULT_ORG_ID, isDefault: true, disabledAt: null },
data: { isDefault: false },
})).rejects.toThrow("must have exactly one active default Agent role");
});
it("rejects creating an Organization without its default role in the same transaction", async () => {
await expect(prisma.organization.create({
data: { id: "org_without_role", slug: "without-role", name: "Without Role" },
})).rejects.toThrow("must have exactly one active default Agent role");
});
async function makeSkill(parent: string, name: string): Promise<string> { async function makeSkill(parent: string, name: string): Promise<string> {
const source = join(parent, "sources", name); const source = join(parent, "sources", name);
await mkdir(source, { recursive: true }); await mkdir(source, { recursive: true });
@@ -42,22 +42,18 @@ describe("Organization-scoped Agent runtime configuration", () => {
}), }),
]); ]);
const [roleA, roleB] = await Promise.all([ const [roleA, roleB] = await Promise.all([
prisma.organizationAgentRole.create({ prisma.organizationAgentRole.update({
where: { organizationId_roleId: { organizationId: DEFAULT_ORG_ID, roleId: "draft" } },
data: { data: {
id: "role-a",
organizationId: DEFAULT_ORG_ID,
roleId: "draft",
label: "A Draft", label: "A Draft",
defaultModel: "anthropic/claude-sonnet-5", defaultModel: "anthropic/claude-sonnet-5",
systemPrompt: "prompt-a", systemPrompt: "prompt-a",
tools: ["read_file", "cph_build"], tools: ["read_file", "cph_build"],
}, },
}), }),
prisma.organizationAgentRole.create({ prisma.organizationAgentRole.update({
where: { organizationId_roleId: { organizationId: "org_other", roleId: "draft" } },
data: { data: {
id: "role-b",
organizationId: "org_other",
roleId: "draft",
label: "B Draft", label: "B Draft",
systemPrompt: "prompt-b", systemPrompt: "prompt-b",
tools: [], tools: [],
@@ -105,8 +101,8 @@ describe("Organization-scoped Agent runtime configuration", () => {
disabledAt: new Date(), disabledAt: new Date(),
}, },
}); });
const role = await prisma.organizationAgentRole.create({ const role = await prisma.organizationAgentRole.findUniqueOrThrow({
data: { id: "role-a", organizationId: DEFAULT_ORG_ID, roleId: "draft", label: "Draft" }, where: { organizationId_roleId: { organizationId: DEFAULT_ORG_ID, roleId: "draft" } },
}); });
await prisma.organizationAgentRoleSkill.create({ await prisma.organizationAgentRoleSkill.create({
data: { organizationId: DEFAULT_ORG_ID, agentRoleId: role.id, agentSkillId: skill.id }, data: { organizationId: DEFAULT_ORG_ID, agentRoleId: role.id, agentSkillId: skill.id },
@@ -0,0 +1,181 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { archiveFeishuBindingForLifecycleEvent } from "../../src/feishu/bindingLifecycle.js";
import { scopedFeishuPrincipalId } from "../../src/feishu/identityNamespace.js";
import { DEFAULT_ORG_ID, prisma, resetDb, seedProject } from "./helpers.js";
const CONNECTION_ID = "feishu-connection-lifecycle";
describe("Feishu binding lifecycle events", () => {
beforeEach(async () => {
await resetDb();
});
afterAll(async () => prisma.$disconnect());
it("archives a bound project and revokes its chat grant when the chat is dissolved", async () => {
await seedProject("project-dissolved", "chat-dissolved");
await seedFeishuConnection();
await prisma.permissionGrant.create({
data: {
resourceType: "PROJECT",
resourceId: "project-dissolved",
principalType: "FEISHU_CHAT",
principalId: scopedFeishuPrincipalId("CHAT", CONNECTION_ID, "chat-dissolved"),
role: "EDIT",
},
});
await prisma.permissionGrant.create({
data: {
resourceType: "PROJECT",
resourceId: "project-dissolved",
principalType: "FEISHU_CHAT",
principalId: "chat-dissolved",
role: "EDIT",
},
});
await prisma.permissionGrant.create({
data: {
resourceType: "PROJECT",
resourceId: "project-dissolved",
principalType: "FEISHU_CHAT",
principalId: scopedFeishuPrincipalId("CHAT", CONNECTION_ID, "chat-unrelated"),
role: "EDIT",
},
});
await expect(archiveFeishuBindingForLifecycleEvent(prisma, {
chatId: "chat-dissolved",
eventId: "event-dissolved",
reason: "chat_dissolved",
})).resolves.toEqual({ archived: true, projectId: "project-dissolved" });
await expect(prisma.projectGroupBinding.findFirst({
where: { chatId: "chat-dissolved" },
select: { archivedAt: true },
})).resolves.toMatchObject({ archivedAt: expect.any(Date) });
await expect(prisma.permissionGrant.findFirst({
where: {
resourceId: "project-dissolved",
principalId: scopedFeishuPrincipalId("CHAT", CONNECTION_ID, "chat-dissolved"),
},
select: { revokedAt: true },
})).resolves.toMatchObject({ revokedAt: expect.any(Date) });
await expect(prisma.permissionGrant.findFirst({
where: { resourceId: "project-dissolved", principalId: "chat-dissolved" },
select: { revokedAt: true },
})).resolves.toMatchObject({ revokedAt: expect.any(Date) });
await expect(prisma.permissionGrant.findFirst({
where: {
resourceId: "project-dissolved",
principalId: scopedFeishuPrincipalId("CHAT", CONNECTION_ID, "chat-unrelated"),
},
select: { revokedAt: true },
})).resolves.toEqual({ revokedAt: null });
await expect(prisma.auditEntry.findFirst({
where: { projectId: "project-dissolved", action: "project.chat_binding_archived" },
select: { organizationId: true, metadata: true },
})).resolves.toEqual({
organizationId: DEFAULT_ORG_ID,
metadata: { chatId: "chat-dissolved", eventId: "event-dissolved", reason: "chat_dissolved" },
});
});
it("is idempotent when Feishu redelivers a lifecycle event", async () => {
await seedProject("project-redelivery", "chat-redelivery");
await seedFeishuConnection();
await archiveFeishuBindingForLifecycleEvent(prisma, {
chatId: "chat-redelivery",
eventId: "event-redelivery",
reason: "bot_removed",
});
await expect(archiveFeishuBindingForLifecycleEvent(prisma, {
chatId: "chat-redelivery",
eventId: "event-redelivery",
reason: "bot_removed",
})).resolves.toEqual({ archived: false });
await expect(prisma.auditEntry.count({
where: { projectId: "project-redelivery", action: "project.chat_binding_archived" },
})).resolves.toBe(1);
});
it("does not archive a later binding when an old lifecycle event is redelivered", async () => {
await seedProject("project-original", "chat-rebound");
await prisma.project.create({
data: {
id: "project-rebound",
organizationId: DEFAULT_ORG_ID,
name: "Rebound project",
workspaceDir: "/tmp/test-project-rebound",
},
});
await seedFeishuConnection();
await archiveFeishuBindingForLifecycleEvent(prisma, {
chatId: "chat-rebound",
eventId: "event-before-rebind",
reason: "bot_removed",
});
await prisma.projectGroupBinding.create({
data: {
organizationId: DEFAULT_ORG_ID,
projectId: "project-rebound",
chatId: "chat-rebound",
selectedAgentRoleId: `agent_role_draft_${DEFAULT_ORG_ID}`,
},
});
await expect(archiveFeishuBindingForLifecycleEvent(prisma, {
chatId: "chat-rebound",
eventId: "event-before-rebind",
reason: "bot_removed",
})).resolves.toEqual({ archived: false });
await expect(prisma.projectGroupBinding.findFirst({
where: { projectId: "project-rebound", archivedAt: null },
select: { id: true },
})).resolves.not.toBeNull();
});
it("leaves unrelated active bindings untouched", async () => {
await seedProject("project-kept", "chat-kept");
await expect(archiveFeishuBindingForLifecycleEvent(prisma, {
chatId: "chat-unknown",
eventId: "event-unknown",
reason: "chat_dissolved",
})).resolves.toEqual({ archived: false });
await expect(prisma.projectGroupBinding.findFirst({
where: { chatId: "chat-kept", archivedAt: null },
select: { id: true },
})).resolves.not.toBeNull();
});
});
async function seedFeishuConnection(): Promise<void> {
await prisma.organizationFeishuApplicationConnection.create({
data: {
id: CONNECTION_ID,
organizationId: DEFAULT_ORG_ID,
appIdentityFingerprint: "fingerprint-lifecycle",
},
});
await prisma.feishuApplicationCredentialVersion.create({
data: {
id: "feishu-secret-version-lifecycle",
connectionId: CONNECTION_ID,
version: 1,
keyId: "test-active",
envelope: { fixture: true },
},
});
await prisma.organizationFeishuApplicationConnection.update({
where: { id: CONNECTION_ID },
data: {
status: "ACTIVE",
activeSecretVersionId: "feishu-secret-version-lifecycle",
activatedAt: new Date(),
},
});
}
+44 -44
View File
@@ -34,41 +34,20 @@ export const prisma = new PrismaClient({
/** Truncate all tables before each test for isolation. */ /** Truncate all tables before each test for isolation. */
export async function resetDb(): Promise<void> { export async function resetDb(): Promise<void> {
const tables = [ // User and Organization are the aggregate roots for all domain rows; their
"OrganizationAgentRoleSkill", // declared FK cascades clear projects, search documents, permissions,
"OrganizationAgentRole", // sessions and connections without repeatedly truncating pg_trgm indexes.
"OrganizationAgentSkill", // Event receipts and global audit rows are independent roots.
"FeishuEventReceipt", await prisma.$transaction([
"FeishuUserIdentity", prisma.feishuEventReceipt.deleteMany(),
"FeishuApplicationCredentialVersion", prisma.auditEntry.deleteMany(),
"OrganizationFeishuApplicationConnection", // Permission resource ids are intentionally polymorphic strings, so these
"ProviderCredentialVersion", // two tables have no FK to Project and must be cleared explicitly.
"OrganizationProviderConnection", prisma.permissionGrant.deleteMany(),
"AgentFileChange", prisma.permissionSettings.deleteMany(),
"AgentMessage", prisma.user.deleteMany(),
"AuditEntry", prisma.organization.deleteMany(),
"PermissionSettings", ]);
"PermissionGrant",
"RoleTriggerGrant",
"ExternalPrincipalMembership",
"ExternalDirectoryConnection",
"TeamExternalBinding",
"TeamMembership",
"Team",
"ProjectAgentLock",
"AgentRun",
"AgentSession",
"ProjectGroupBinding",
"Folder",
"OrganizationProjectSettings",
"OrganizationMembership",
"PlatformRoleAssignment",
"User",
"Project",
"Organization",
];
// Truncate with CASCADE to wipe dependent rows in one shot.
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tables.map((t) => `"${t}"`).join(", ")} RESTART IDENTITY CASCADE`);
await seedTestOrganization(); await seedTestOrganization();
} }
@@ -76,22 +55,36 @@ export async function seedTestOrganization(
id: string = DEFAULT_ORG_ID, id: string = DEFAULT_ORG_ID,
slug: string = "test-default", slug: string = "test-default",
): Promise<void> { ): Promise<void> {
await prisma.organization.upsert({ await prisma.$transaction(async (tx) => {
await tx.organization.upsert({
where: { id }, where: { id },
update: {}, update: {},
create: { create: { id, slug, name: "Test Default Organization" },
id,
slug,
name: "Test Default Organization",
},
}); });
await prisma.organizationProjectSettings.upsert({ await tx.organizationProjectSettings.upsert({
where: { organizationId: id }, where: { organizationId: id },
update: {}, update: {},
create: { organizationId: id, membersCanCreateProjects: true }, create: { organizationId: id, membersCanCreateProjects: true },
}); });
const defaultRole = await tx.organizationAgentRole.upsert({
where: { organizationId_roleId: { organizationId: id, roleId: "draft" } },
update: { label: "草稿", isDefault: true, disabledAt: null },
create: {
id: `agent_role_draft_${id}`,
organizationId: id,
roleId: "draft",
label: "草稿",
sortOrder: 10,
isDefault: true,
},
});
await tx.organizationAgentRole.updateMany({
where: { organizationId: id, id: { not: defaultRole.id }, isDefault: true },
data: { isDefault: false },
});
});
const inbox = await prisma.folder.findFirst({ const inbox = await prisma.folder.findFirst({
where: { organizationId: id, parentId: null, name: "Inbox", archivedAt: null }, where: { organizationId: id, kind: "SYSTEM_INBOX", archivedAt: null },
select: { id: true }, select: { id: true },
}); });
if (inbox === null) { if (inbox === null) {
@@ -100,6 +93,7 @@ export async function seedTestOrganization(
id: `folder_inbox_${id}`, id: `folder_inbox_${id}`,
organizationId: id, organizationId: id,
name: "Inbox", name: "Inbox",
kind: "SYSTEM_INBOX",
sortKey: "000000", sortKey: "000000",
}, },
}); });
@@ -420,6 +414,12 @@ export async function seedProject(
}, },
}); });
await prisma.projectGroupBinding.create({ await prisma.projectGroupBinding.create({
data: { projectId, chatId, createdByUserId: "u_" + projectId }, data: {
organizationId: DEFAULT_ORG_ID,
projectId,
chatId,
createdByUserId: "u_" + projectId,
selectedAgentRoleId: `agent_role_draft_${DEFAULT_ORG_ID}`,
},
}); });
} }
@@ -0,0 +1,185 @@
import { mkdir, mkdtemp, readFile, rm, symlink, unlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
import { importLegacyProjects } from "../../src/deployment/legacyProjectImport.js";
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
const temporaryRoots: string[] = [];
describe("legacy teaching-material project import", () => {
beforeEach(async () => {
await resetDb();
await prisma.user.create({
data: {
id: "legacy-import-owner",
feishuOpenId: "ou_legacy_owner",
displayName: "Legacy Import Owner",
organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role: "OWNER" } },
},
});
});
afterEach(async () => {
while (temporaryRoots.length > 0) {
const root = temporaryRoots.pop();
if (root !== undefined) await rm(root, { recursive: true, force: true });
}
});
afterAll(async () => prisma.$disconnect());
it("imports each legacy project as an unbound resumable project under its old folder path", async () => {
const sourceRoot = await temporaryRoot("cph-legacy-source-");
const workspaceRoot = await temporaryRoot("cph-legacy-target-");
const projectSource = join(sourceRoot, "物理", "M-243-牛顿力学");
await mkdir(join(projectSource, "workspace", "chapters"), { recursive: true });
await mkdir(join(projectSource, "workspace", ".claude"), { recursive: true });
await mkdir(join(projectSource, "workspace", "chapters", ".cph"), { recursive: true });
await mkdir(join(projectSource, "_raw"), { recursive: true });
await writeFile(join(projectSource, "workspace", "project.toml"), "title = \"牛顿力学\"\n");
await writeFile(join(projectSource, "workspace", "chapters", "lesson.typ"), "= 牛顿第二定律\n");
await writeFile(join(projectSource, "workspace", ".claude", "session.json"), "{}\n");
await writeFile(join(projectSource, "workspace", "chapters", ".cph", "runtime.json"), "{}\n");
await writeFile(join(projectSource, "project.json"), "{\"id\":\"M-243\"}\n");
await writeFile(join(projectSource, "_raw", "source.txt"), "legacy source\n");
const stateFile = join(workspaceRoot, "migration-state", "state.json");
const manifest = [{
legacyId: "M-243",
name: "牛顿力学",
folderPath: ["物理"],
sourceRelativePath: "物理/M-243-牛顿力学",
}];
const first = await importLegacyProjects({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_legacy_owner",
workspaceRoot,
sourceRoot,
stateFile,
projects: manifest,
});
const imported = first.projects["M-243"];
expect(imported).toBeDefined();
if (imported === undefined) throw new Error("missing imported project state");
const project = await prisma.project.findUniqueOrThrow({
where: { id: imported.projectId },
include: { folder: { include: { parent: true } }, groupBindings: true },
});
expect(project.name).toBe("牛顿力学");
expect(project.folder?.name).toBe("物理");
expect(project.folder?.parent?.name).toBe("旧教学资产");
expect(project.groupBindings).toEqual([]);
await expect(readFile(join(imported.workspaceDir, "chapters", "lesson.typ"), "utf8"))
.resolves.toBe("= 牛顿第二定律\n");
await expect(readFile(join(imported.workspaceDir, ".claude", "session.json"), "utf8"))
.rejects.toMatchObject({ code: "ENOENT" });
await expect(readFile(join(imported.workspaceDir, "chapters", ".cph", "runtime.json"), "utf8"))
.rejects.toMatchObject({ code: "ENOENT" });
await expect(readFile(join(imported.workspaceDir, ".legacy-source", "project.json"), "utf8"))
.resolves.toContain("M-243");
await expect(readFile(join(imported.workspaceDir, ".legacy-source", "raw", "source.txt"), "utf8"))
.resolves.toBe("legacy source\n");
await unlink(stateFile);
const second = await importLegacyProjects({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_legacy_owner",
workspaceRoot,
sourceRoot,
stateFile,
projects: manifest,
});
expect(second.projects["M-243"]?.projectId).toBe(imported.projectId);
await expect(prisma.project.count({ where: { organizationId: DEFAULT_ORG_ID } })).resolves.toBe(1);
expect(JSON.parse(await readFile(stateFile, "utf8"))).toMatchObject({
version: 1,
projects: { "M-243": { projectId: imported.projectId } },
});
await expect(prisma.auditEntry.count({
where: { projectId: imported.projectId, action: "legacy_project.imported" },
})).resolves.toBe(1);
await writeFile(join(imported.workspaceDir, ".legacy-source", "migration.json"), "not json\n");
await expect(importLegacyProjects({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_legacy_owner",
workspaceRoot,
sourceRoot,
stateFile,
projects: manifest,
})).rejects.toThrow(/invalid legacy completion marker/);
await expect(prisma.project.count({ where: { id: imported.projectId } })).resolves.toBe(1);
});
it("rejects symlinks instead of importing paths outside the staged project", async () => {
const sourceRoot = await temporaryRoot("cph-legacy-symlink-");
const workspaceRoot = await temporaryRoot("cph-legacy-target-");
const projectSource = join(sourceRoot, "legacy");
await mkdir(join(projectSource, "workspace"), { recursive: true });
await writeFile(join(projectSource, "project.json"), "{}\n");
await symlink("/etc/passwd", join(projectSource, "workspace", "outside"));
await expect(importLegacyProjects({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_legacy_owner",
workspaceRoot,
sourceRoot,
stateFile: join(workspaceRoot, "state.json"),
projects: [{ legacyId: "symlink", name: "Symlink", folderPath: [], sourceRelativePath: "legacy" }],
})).rejects.toThrow(/rejects symbolic link/);
await expect(prisma.project.count()).resolves.toBe(0);
});
it("rejects a manifest path that escapes the staged source root", async () => {
const parent = await temporaryRoot("cph-legacy-escape-");
const sourceRoot = join(parent, "source");
const outside = join(parent, "outside");
await mkdir(sourceRoot);
await mkdir(outside);
await expect(importLegacyProjects({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_legacy_owner",
workspaceRoot: await temporaryRoot("cph-legacy-target-"),
sourceRoot,
stateFile: join(parent, "state.json"),
projects: [{
legacyId: "escape",
name: "Escape",
folderPath: [],
sourceRelativePath: "../outside",
}],
})).rejects.toThrow(/escapes source root/);
});
it("rejects malformed resume state before creating a project", async () => {
const sourceRoot = await temporaryRoot("cph-legacy-state-source-");
const workspaceRoot = await temporaryRoot("cph-legacy-state-target-");
const stateFile = join(workspaceRoot, "state.json");
await writeFile(stateFile, JSON.stringify({ version: 1, projects: { broken: { status: "MAYBE" } } }));
await expect(importLegacyProjects({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_legacy_owner",
workspaceRoot,
sourceRoot,
stateFile,
projects: [],
})).rejects.toThrow(/invalid legacy import state fields/);
await expect(prisma.project.count()).resolves.toBe(0);
});
});
async function temporaryRoot(prefix: string): Promise<string> {
const root = await mkdtemp(join(tmpdir(), prefix));
temporaryRoots.push(root);
return root;
}
@@ -0,0 +1,228 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import {
browseBindableFolder,
discoverBindableProjects,
normalizeProjectSearchQuery,
} from "../../src/projectDiscovery.js";
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
describe("project discovery", () => {
beforeEach(async () => {
await resetDb();
await seedUser("owner", "ou_owner", "OWNER");
await seedUser("member", "ou_member", "MEMBER");
});
afterAll(async () => prisma.$disconnect());
it("normalizes project codes across punctuation, width and case", () => {
expect(normalizeProjectSearchQuery(" TH-141 ")).toBe("th141");
expect(normalizeProjectSearchQuery("th_141")).toBe("th141");
});
it("searches normalized codes, Chinese names and complete folder breadcrumbs", async () => {
const root = await createFolder("root-legacy", null, "旧教学资产");
const subject = await createFolder("folder-physics", root.id, "物理竞赛教研");
const thermal = await createFolder("folder-thermal", subject.id, "TH_热学专题");
await createProject("project-th141", thermal.id, "TH-141_表面张力的严肃理论");
await createProject("project-fullwidth", thermal.id, "TH-142_全角编号");
await createProject("project-explicit-code", thermal.id, "表面课程", "PHY-001");
await createProject("project-other", thermal.id, "TH-211_理想气体静力学");
const byCode = await discover("TH141");
expect(byCode.items[0]).toMatchObject({
projectId: "project-th141",
breadcrumb: "旧教学资产 / 物理竞赛教研 / TH_热学专题",
});
await expect(discover("表面张力")).resolves.toMatchObject({
totalItems: 1,
items: [{ projectId: "project-th141" }],
});
await expect(discover("TH142")).resolves.toMatchObject({
items: [expect.objectContaining({ projectId: "project-fullwidth" })],
});
await expect(discover("PHY001")).resolves.toMatchObject({
items: [expect.objectContaining({ projectId: "project-explicit-code" })],
});
const byPath = await discover("物理竞赛教研");
expect(byPath.items.map((item) => item.projectId)).toEqual(expect.arrayContaining([
"project-th141",
"project-other",
]));
await expect(discover("物理竞赛 TH141")).resolves.toMatchObject({
items: [expect.objectContaining({ projectId: "project-th141" })],
});
await expect(discover("%")).resolves.toMatchObject({ totalItems: 0 });
});
it("refreshes descendant breadcrumbs after a folder rename", async () => {
const root = await createFolder("root-before", null, "旧目录");
const child = await createFolder("child", root.id, "子目录");
await createProject("project-refresh", child.id, "项目");
await prisma.folder.update({ where: { id: root.id }, data: { name: "新目录" } });
await expect(discover("新目录")).resolves.toMatchObject({
totalItems: 1,
items: [{ projectId: "project-refresh", breadcrumb: "新目录 / 子目录" }],
});
await expect(discover("旧目录")).resolves.toMatchObject({ totalItems: 0 });
});
it("filters authorization before counting and paginating", async () => {
for (let index = 0; index < 12; index += 1) {
await createProject(`project-${index}`, null, `TH-${index}`);
}
await prisma.permissionGrant.create({
data: {
resourceType: "PROJECT",
resourceId: "project-11",
principalType: "USER",
principalId: "ou_member",
role: "MANAGE",
},
});
const result = await discoverBindableProjects({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_member",
isOrgAdmin: false,
query: "TH",
page: 2,
pageSize: 5,
});
expect(result).toMatchObject({ page: 1, totalItems: 1, totalPages: 1 });
expect(result.items.map((item) => item.projectId)).toEqual(["project-11"]);
});
it("browses the preserved folder tree and paginates projects", async () => {
const root = await createFolder("root", null, "旧教学资产");
const child = await createFolder("child", root.id, "物理竞赛教研");
await createProject("project-root", root.id, "根目录项目");
const result = await browseBindableFolder({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_owner",
isOrgAdmin: true,
folderId: root.id,
});
expect(result.breadcrumb).toBe("旧教学资产");
expect(result.parentFolderId).toBeNull();
expect(result.childFolders).toEqual([{ folderId: child.id, name: "物理竞赛教研" }]);
expect(result.projects).toEqual([{
projectId: "project-root",
name: "根目录项目",
breadcrumb: "旧教学资产",
}]);
});
it("hides the system Inbox at root and presents its projects as unclassified", async () => {
const inbox = await prisma.folder.findFirstOrThrow({
where: { organizationId: DEFAULT_ORG_ID, kind: "SYSTEM_INBOX" },
});
const businessRoot = await createFolder("business-root", null, "业务目录");
const businessInbox = await createFolder("business-inbox", businessRoot.id, "Inbox");
await createProject("project-inbox", inbox.id, "新项目");
const result = await browseBindableFolder({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_owner",
isOrgAdmin: true,
folderId: null,
});
expect(result.childFolders.map((folder) => folder.folderId)).not.toContain(inbox.id);
expect(result.projects).toContainEqual({
projectId: "project-inbox",
name: "新项目",
breadcrumb: "未分类",
});
const businessResult = await browseBindableFolder({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_owner",
isOrgAdmin: true,
folderId: businessRoot.id,
});
expect(businessResult.childFolders).toContainEqual({ folderId: businessInbox.id, name: "Inbox" });
});
it("shares one card page budget across folders and projects", async () => {
const root = await createFolder("folder-page-root", null, "目录分页");
for (let index = 0; index < 6; index += 1) {
await createFolder(`folder-page-${index}`, root.id, `目录-${index}`);
await createProject(`project-page-${index}`, root.id, `项目-${index}`);
}
const first = await browseBindableFolder({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_owner",
isOrgAdmin: true,
folderId: root.id,
pageSize: 8,
});
const second = await browseBindableFolder({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_owner",
isOrgAdmin: true,
folderId: root.id,
page: 2,
pageSize: 8,
});
expect(first).toMatchObject({ page: 1, totalPages: 2, totalFolders: 6, totalProjects: 6 });
expect(first.childFolders.length + first.projects.length).toBe(8);
expect(second.childFolders.length + second.projects.length).toBe(4);
expect([...first.projects, ...second.projects].map((project) => project.projectId).sort()).toEqual(
Array.from({ length: 6 }, (_, index) => `project-page-${index}`),
);
});
});
async function seedUser(id: string, feishuOpenId: string, role: "OWNER" | "MEMBER"): Promise<void> {
await prisma.user.create({
data: {
id,
feishuOpenId,
displayName: id,
organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role } },
},
});
}
async function createFolder(id: string, parentId: string | null, name: string) {
return prisma.folder.create({
data: { id, organizationId: DEFAULT_ORG_ID, parentId, name },
});
}
async function createProject(id: string, folderId: string | null, name: string, code?: string) {
return prisma.project.create({
data: {
id,
organizationId: DEFAULT_ORG_ID,
folderId,
...(code !== undefined ? { code } : {}),
name,
workspaceDir: `/tmp/${id}`,
},
});
}
function discover(query: string) {
return discoverBindableProjects({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_owner",
isOrgAdmin: true,
query,
});
}
@@ -262,6 +262,30 @@ describe("ADR-0021 project onboarding", () => {
}); });
expect(oldChatGrant).toBeNull(); expect(oldChatGrant).toBeNull();
}); });
it("rejects selecting an Agent role from another Organization at the database boundary", async () => {
await seedUser("u-owner", "ou_owner", "OWNER");
const project = await createProjectFromOrgAdmin(prisma, {
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_owner",
name: "Tenant-scoped role project",
workspaceRoot: await tempWorkspaceRoot(),
});
await bindFeishuChatToProject(prisma, {
projectId: project.projectId,
actorFeishuOpenId: "ou_owner",
chatId: "chat-tenant-role",
});
await seedTestOrganization("org_other", "other");
const otherRole = await prisma.organizationAgentRole.findUniqueOrThrow({
where: { organizationId_roleId: { organizationId: "org_other", roleId: "draft" } },
});
await expect(prisma.projectGroupBinding.updateMany({
where: { projectId: project.projectId, chatId: "chat-tenant-role", archivedAt: null },
data: { selectedAgentRoleId: otherRole.id },
})).rejects.toThrow();
});
}); });
async function seedUser(id: string, feishuOpenId: string, role: "OWNER" | "ADMIN" | "MEMBER"): Promise<void> { async function seedUser(id: string, feishuOpenId: string, role: "OWNER" | "ADMIN" | "MEMBER"): Promise<void> {
+4 -3
View File
@@ -23,6 +23,7 @@ describe("Alpha Silo bootstrap", () => {
id: "org_default", id: "org_default",
slug: "legacy-default", slug: "legacy-default",
name: "Legacy Default Organization", name: "Legacy Default Organization",
agentRoles: { create: { roleId: "draft", label: "草稿", isDefault: true } },
projectSettings: { create: { membersCanCreateProjects: true } }, projectSettings: { create: { membersCanCreateProjects: true } },
folders: { folders: {
create: { create: {
@@ -56,10 +57,10 @@ describe("Alpha Silo bootstrap", () => {
await expect(prisma.organizationAgentRole.findMany({ await expect(prisma.organizationAgentRole.findMany({
where: { organizationId: "org_alpha", disabledAt: null }, where: { organizationId: "org_alpha", disabledAt: null },
orderBy: { sortOrder: "asc" }, orderBy: { sortOrder: "asc" },
select: { roleId: true, label: true }, select: { roleId: true, label: true, isDefault: true },
})).resolves.toEqual([ })).resolves.toEqual([
{ roleId: "draft", label: "草稿" }, { roleId: "draft", label: "草稿", isDefault: true },
{ roleId: "review", label: "审校" }, { roleId: "review", label: "审校", isDefault: false },
]); ]);
const persisted = JSON.stringify({ const persisted = JSON.stringify({
File diff suppressed because it is too large Load Diff
+61 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi, beforeEach } from "vitest"; import { describe, expect, it, vi, beforeEach } from "vitest";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import type { CardActionEvent, FeishuRuntime } from "../../src/feishu/client.js"; import type { CardActionEvent, FeishuBindingLifecycleEvent, FeishuRuntime } from "../../src/feishu/client.js";
import { startFeishuListenerWithClient } from "../../src/feishu/client.js"; import { startFeishuListenerWithClient } from "../../src/feishu/client.js";
const larkMock = vi.hoisted(() => { const larkMock = vi.hoisted(() => {
@@ -80,6 +80,57 @@ describe("Feishu card action callbacks", () => {
expect(logger.error).toHaveBeenCalledWith({ err }, "feishu card action handler threw"); expect(logger.error).toHaveBeenCalledWith({ err }, "feishu card action handler threw");
}); });
}); });
it("dispatches chat dissolution and bot-removal lifecycle events", async () => {
const onLifecycle = vi.fn(async (_event: FeishuBindingLifecycleEvent) => {});
await startFeishuListenerWithClient(
{ appId: "app-id", appSecret: "app-secret", botOpenId: "bot-open-id" },
fakeClient(),
silentLogger(),
async () => {},
undefined,
undefined,
onLifecycle,
);
await lifecycleHandler("im.chat.disbanded_v1")({
event_id: "event-dissolved",
chat_id: "chat-dissolved",
});
await lifecycleHandler("im.chat.member.bot.deleted_v1")({
header: { event_id: "event-bot-removed" },
chat_id: "chat-bot-removed",
});
expect(onLifecycle).toHaveBeenNthCalledWith(1, {
eventId: "event-dissolved",
chatId: "chat-dissolved",
reason: "chat_dissolved",
});
expect(onLifecycle).toHaveBeenNthCalledWith(2, {
eventId: "event-bot-removed",
chatId: "chat-bot-removed",
reason: "bot_removed",
});
});
it("propagates lifecycle archival failures to the WS dispatcher", async () => {
const failure = new Error("archive failed");
await startFeishuListenerWithClient(
{ appId: "app-id", appSecret: "app-secret", botOpenId: "bot-open-id" },
fakeClient(),
silentLogger(),
async () => {},
undefined,
undefined,
async () => { throw failure; },
);
await expect(lifecycleHandler("im.chat.disbanded_v1")({
event_id: "event-failure",
chat_id: "chat-failure",
})).rejects.toThrow(failure);
});
}); });
function cardActionHandler(): (data: unknown) => Promise<void> { function cardActionHandler(): (data: unknown) => Promise<void> {
@@ -91,6 +142,15 @@ function cardActionHandler(): (data: unknown) => Promise<void> {
return handler; return handler;
} }
function lifecycleHandler(eventType: string): (data: unknown) => Promise<void> {
const start = larkMock.starts[0];
if (start === undefined) throw new Error("WSClient.start was not called");
const dispatcher = start.eventDispatcher as { readonly handlers?: Record<string, (data: unknown) => Promise<void>> };
const handler = dispatcher.handlers?.[eventType];
if (handler === undefined) throw new Error(`${eventType} handler was not registered`);
return handler;
}
function makeCardActionEvent(): CardActionEvent { function makeCardActionEvent(): CardActionEvent {
return { return {
operator: { open_id: "ou_user" }, operator: { open_id: "ou_user" },
+1 -1
View File
@@ -274,7 +274,7 @@ function mockPrisma(): PrismaClient {
create: vi.fn(async () => ({ id: "receipt-1" })), create: vi.fn(async () => ({ id: "receipt-1" })),
}, },
projectGroupBinding: { projectGroupBinding: {
findFirst: vi.fn(async () => ({ projectId: "project-1" })), findFirst: vi.fn(async () => ({ projectId: "project-1", selectedRole: { roleId: "draft" } })),
}, },
project: { project: {
findUnique: vi.fn(async () => ({ workspaceDir: "/tmp/cph-project" })), findUnique: vi.fn(async () => ({ workspaceDir: "/tmp/cph-project" })),
@@ -0,0 +1,63 @@
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
const execute = promisify(execFile);
const temporaryRoots: string[] = [];
describe("legacy project manifest builder", () => {
afterEach(async () => {
while (temporaryRoots.length > 0) {
const root = temporaryRoots.pop();
if (root !== undefined) await rm(root, { recursive: true, force: true });
}
});
it("stops at a project root and excludes trash projects", async () => {
const root = await mkdtemp(join(tmpdir(), "cph-legacy-manifest-"));
temporaryRoots.push(root);
const projectRoot = join(root, "物理", "legacy__牛顿力学");
await mkdir(join(projectRoot, "workspace", "nested"), { recursive: true });
await mkdir(join(projectRoot, "_raw"), { recursive: true });
await mkdir(join(root, ".trash", "deleted"), { recursive: true });
await writeFile(join(projectRoot, "project.json"), JSON.stringify({
id: "legacy",
name: "牛顿力学",
folderPath: ["物理"],
}));
await writeFile(join(projectRoot, "workspace", "nested", "project.json"), "not metadata");
await writeFile(join(projectRoot, "_raw", "project.json"), "not metadata");
await writeFile(join(root, ".trash", "deleted", "project.json"), JSON.stringify({
id: "deleted",
name: "Deleted",
}));
const { stdout } = await execute(process.execPath, [
resolve("deploy/build_legacy_project_manifest.mjs"),
root,
]);
expect(JSON.parse(stdout)).toEqual([{
legacyId: "legacy",
name: "牛顿力学",
folderPath: ["物理"],
sourceRelativePath: "物理/legacy__牛顿力学",
}]);
});
it("reports untracked symlinked entries instead of silently omitting them", async () => {
const root = await mkdtemp(join(tmpdir(), "cph-legacy-manifest-link-"));
temporaryRoots.push(root);
await symlink("/tmp", join(root, "linked-project"));
const result = await execute(process.execPath, [
resolve("deploy/build_legacy_project_manifest.mjs"),
root,
]);
expect(result.stderr).toContain("skip untracked symbolic link");
expect(JSON.parse(result.stdout)).toEqual([]);
});
});
+3 -9
View File
@@ -1,17 +1,11 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { parseSlashHelpSubcommand, parseSlashInvocation } from "../../src/feishu/slashCommands.js"; import { parseSlashInvocation } from "../../src/feishu/slashCommands.js";
describe("slash command parser", () => { describe("slash command parser", () => {
it("parses a slash invocation without resolving it", () => { it("parses a slash invocation without resolving it", () => {
expect(parseSlashInvocation("/new")).toEqual({ name: "new", args: [] }); expect(parseSlashInvocation("/project")).toEqual({ name: "project", args: [] });
expect(parseSlashInvocation("/review 看看这节")).toEqual({ name: "review", args: ["看看这节"] }); expect(parseSlashInvocation("/usage project")).toEqual({ name: "usage", args: ["project"] });
expect(parseSlashInvocation("写教案")).toBeNull(); expect(parseSlashInvocation("写教案")).toBeNull();
}); });
it("parses help subcommands only in the exact /<command> help form", () => {
expect(parseSlashHelpSubcommand({ name: "new", args: ["help"] })).toBe("new");
expect(parseSlashHelpSubcommand({ name: "unknown", args: ["help"] })).toBe("unknown");
expect(parseSlashHelpSubcommand({ name: "new", args: ["help", "please"] })).toBeNull();
expect(parseSlashHelpSubcommand({ name: "help", args: ["new"] })).toBeNull();
});
}); });
+2
View File
@@ -117,6 +117,8 @@ function makeTrigger(prompt: string): Omit<QueuedTrigger, "projectId" | "enqueue
return { return {
chatId: "chat-1", chatId: "chat-1",
prompt, prompt,
roleId: "draft",
executionKind: "conversation",
msg: makeMessage(prompt), msg: makeMessage(prompt),
senderOpenId: "ou-user", senderOpenId: "ou-user",
actor: { feishuOpenId: "ou-user", chatId: "chat-1" }, actor: { feishuOpenId: "ou-user", chatId: "chat-1" },
+1 -1
View File
@@ -49,7 +49,7 @@ agent 不得用预训练先验脑补本领域(领域很新,无先验);prose 是
### 命名 ### 命名
- **模块 / 命名空间**:PascalCase,对应分层,如 `Spec.System.Agent.Run``Spec.Courseware.Validity` - **模块 / 命名空间**:PascalCase,对应分层,如 `Spec.System.Run``Spec.Courseware.Validity`
- **类型**:PascalCase。 - **类型**:PascalCase。
- **谓词 / `Prop`**:用意图清晰的命名,如 `Legal…``ValidTransition``Can…` - **谓词 / `Prop`**:用意图清晰的命名,如 `Legal…``ValidTransition``Can…`
- **文件粒度**:原则上"一个带独立不变式的概念一个文件"。 - **文件粒度**:原则上"一个带独立不变式的概念一个文件"。
+1 -1
View File
@@ -16,6 +16,6 @@ import Spec.Courseware.Open
- **`Check`** checker :`Severity` + 6 + ** lesson = error - **`Check`** checker :`Severity` + 6 + ** lesson = error
**( + `Oracle` );线 5 compile **( + `Oracle` );线 5 compile
- **`Open`** OPEN ( OPEN, surface): `QuestionBank` - **`Open`** ( OPEN, surface ): `QuestionBank`
`Course` `Course`
-/ -/
+2 -2
View File
@@ -6,7 +6,7 @@ import Spec.Courseware.Export.Render
"站在 Lean 位置" rule-based checker,(ADR-0010, ADR-0012 "站在 Lean 位置" rule-based checker,(ADR-0010, ADR-0012
) lesson ,****(`DiagKind`)****(`Severity`) ) lesson ,****(`DiagKind`)****(`Severity`)
(); 7 ,"**合法 lesson = 无 :(); 7 ,"**合法 lesson = 无
error **"建成判定(ADR-0005 deferred 的""的回填);对**模型外设施** error **"建成判定(ADR-0005 deferred 的""的回填);对**模型外设施**
(typst schema )** + `Oracle` ** (typst schema )** + `Oracle` **
"存在这条诊断、什么意思、什么级别",, Lean ( typst "存在这条诊断、什么意思、什么级别",, Lean ( typst
@@ -50,7 +50,7 @@ inductive DiagKind where
/-- 每类诊断的**严重级别**(`PINNED`, ADR-0010)。六类 `error`(阻断);**唯 /-- 每类诊断的**严重级别**(`PINNED`, ADR-0010)。六类 `error`(阻断);**唯
`renderIgnored` `warning`**ADR-0005 "缺渲染 ⇒ warning,不阻断导出" `renderIgnored` `warning`**ADR-0005 "缺渲染 ⇒ warning,不阻断导出"
使"哪类阻断"( `DiagCode` ) -/ 使"哪类阻断"( `DiagCode` ) -/
def DiagKind.severity : DiagKind Severity def DiagKind.severity : DiagKind Severity
| .partPathMissing => .error | .partPathMissing => .error
| .unknownKind => .error | .unknownKind => .error
+2 -2
View File
@@ -5,8 +5,8 @@ import Spec.Courseware.Check.Diagnostic
checker `check` ****,;`compile` **** checker `check` ****,;`compile` ****
(, (,
,)**** Lean(:** ,)**** Lean( 5 ):**
**):`load``cph-model`;`structural`part / kind; **:`load``cph-model`;`structural`part / kind;
`schema``cph-schema`;`compile``cph-typst`();`coverage``renderIgnored` `schema``cph-schema`;`compile``cph-typst`();`coverage``renderIgnored`
-/ -/
+1 -1
View File
@@ -2,7 +2,7 @@
# Artifact export target (ADR-0009 / 0011) # Artifact export target (ADR-0009 / 0011)
ADR-0009: export target ** build**,****ADR-0011 ADR-0009: export target ** build**,****ADR-0011
:** ADT**"产物到底指什么"( / ) :** ADT**"产物到底指什么"( / )
, + doc,/glob `String` , + doc,/glob `String`
doc (), doc (),
-/ -/
+3 -3
View File
@@ -4,7 +4,7 @@ import Spec.Courseware.Export.Artifact
/-! /-!
# Render export target = artifact + typed steps(ADR-0009 / 0011) # Render export target = artifact + typed steps(ADR-0009 / 0011)
ADR-0009:export target build, `Artifact`ADR-0011 build ADR-0009:export target build, `Artifact`ADR-0011 build
****: target `artifact` + ** typed step** ****: target `artifact` + ** typed step**
- `typstCompile template` ****( `exports/student.typ`) - `typstCompile template` ****( `exports/student.typ`)
@@ -20,7 +20,7 @@ ADR-0009:export target 是一次 build,产出一个有类型的 `Artifact`。ADR
**shell step (ADR-0013)** `shell` :****, **shell step (ADR-0013)** `shell` :****,
`run` shell****,( `run` shell****,(
),: ),:
1. **opt-in by construction** **** build shell target , 1. **opt-in by construction** **** build shell target ,
`check` `check` (lesson ), `check` `check` (lesson ),
2. **** shell step 退 **build-**, lesson ; 2. **** shell step 退 **build-**, lesson ;
@@ -46,7 +46,7 @@ namespace Spec.Courseware
variable (P : Primitives) variable (P : Primitives)
/-- 一个 build **step**(`PINNED` typed, ADR-0011;可扩展)。MVP 仅一个 `typstCompile`; /-- 一个 build **step**(`PINNED` typed, ADR-0011;可扩展)。MVP 仅一个 `typstCompile`;
`steps` list FileTree / build shell `steps` list FileTree / build shell
(, ADR-0011 OPEN) -/ (, ADR-0011 OPEN) -/
inductive Step where inductive Step where
/-- 编译模板文件 `template`(相对工程根)成产物;框架注入 manifest。typed 的理由: /-- 编译模板文件 `template`(相对工程根)成产物;框架注入 manifest。typed 的理由:
+1 -1
View File
@@ -7,7 +7,7 @@ import Spec.Courseware.Model.Info
/-! /-!
# Courseware.Model # Courseware.Model
(`Primitives`)(`RichContent`)(`Element`) (`Primitives`)(`RichContent`)(`Element`)
(`Lesson`)(`Info`:canonical author vs `RawInfo` ) (`Lesson`)(`Info`:canonical author vs `RawInfo` )
ADR-0005 / 0006 / 0008 ADR-0005 / 0006 / 0008
-/ -/
+1 -1
View File
@@ -3,7 +3,7 @@ import Spec.Courseware.Model.Primitives
/-! /-!
# Element # Element
ADR-0005:element = kind + kind schema ADR-0005:element = kind + kind schema
,使"数据必须匹配其 kind" ,使"数据必须匹配其 kind"
-/ -/
+2 -2
View File
@@ -5,7 +5,7 @@
**:(), canonical author **:(), canonical author
****, ****,
:on-disk ****()**** :on-disk ****()****
`author = ""`, `author = ["", ""]`"字符串或数组"** `author = ""`, `author = ["", ""]`"字符串或数组"**
**:`RawInfo` canonical `Info`,canonical **:`RawInfo` canonical `Info`,canonical
`List String`,raw `Info`(canonical) `RawInfo` `List String`,raw `Info`(canonical) `RawInfo`
@@ -24,7 +24,7 @@ inductive RawAuthor where
| many (names : List String) | many (names : List String)
/-- raw 作者归一化为**有序作者列表**(`PINNED`, ADR-0008)。单作者 ⇒ 单元素列表;数组 /-- raw 作者归一化为**有序作者列表**(`PINNED`, ADR-0008)。单作者 ⇒ 单元素列表;数组
canonical `List String` -/ "canonical 接收端始终是 `List String`" -/
def RawAuthor.normalize : RawAuthor List String def RawAuthor.normalize : RawAuthor List String
| .one n => [n] | .one n => [n]
| .many ns => ns | .many ns => ns
+6 -6
View File
@@ -1,26 +1,26 @@
/-! /-!
# Primitives Courseware # Primitives Courseware
(ADR-0005):element kind kind (ADR-0005):element kind kind
schema export target `Primitives`, schema export target `Primitives`,
element / lesson / ****,**** element / lesson / ****,****
: PINNED( schema ADR-0006 ) Lean : PINNED( schema ADR-0006 ) Lean
JSON Schema / typst , Lean, JSON Schema / typst , Lean,
`Courseware.RichContent` prose `Courseware.RichContent`
-/ -/
namespace Spec.Courseware namespace Spec.Courseware
/-- Courseware 契约基元载体(关系 `PINNED`, ADR-0005;各基元表示留给实现, ADR-0006)。 -/ /-- Courseware 契约基元载体(关系 `PINNED`, ADR-0005;各基元表示留给实现, ADR-0006)。 -/
structure Primitives where structure Primitives where
/-- element kind 标识(`PINNED` **开放宇宙**, ADR-0005;表示 `OPEN`)。用抽象 /-- element kind 标识(`PINNED` **开放宇宙**, ADR-0005;表示 `OPEN`)。刻意用抽象
`inductive`:ADR-0005 kind (stdlib + ), `inductive`:ADR-0005 kind (stdlib + ),
****( `RunState` "尚未封闭") -/ ****( `RunState` "尚未封闭") -/
KindId : Type KindId : Type
/-- 某 kind 的合法数据类型(`PINNED` 依赖关系, ADR-0005;schema 形态 `PINNED` /-- 某 kind 的合法数据类型(`PINNED` 依赖关系, ADR-0005;schema 形态 `PINNED`
ADR-0006,) kind :`ElementData k` "符合 `k` schema 的 ADR-0006,) kind :`ElementData k` "符合 `k` schema 的
"。schema 形态(声明式 JSON Schema + `content` 叶子 = typst 源) ADR-0006 "。schema 形态(声明式 JSON Schema + `content` 叶子 = typst 源) ADR-0006
, JSON/typst , Lean;"数据符合 , JSON/typst , Lean;"数据符合
kind schema"这条关系,故此处仍是抽象类型。 -/ kind schema"这条关系,故此处仍是抽象类型。 -/
ElementData : KindId Type ElementData : KindId Type
/-- export target 标识(`PINNED` 角色, ADR-0005;表示 `OPEN`)。一个 target 是一次 /-- export target 标识(`PINNED` 角色, ADR-0005;表示 `OPEN`)。一个 target 是一次
+4 -4
View File
@@ -1,5 +1,5 @@
/-! /-!
# RichContent (ADR-0006 ) # RichContent (ADR-0006 prose )
ADR-0006:element schema "叶子" `content` ,****, ADR-0006:element schema "叶子" `content` ,****,
**format** (ADR-0015) format: **format** (ADR-0015) format:
@@ -13,7 +13,7 @@ ADR-0006:element schema 的"叶子"可以是 `content` 类型,其值是一段**
,****; import + `@package`()markdown format ,****; import + `@package`()markdown format
typst ,( markdown step , `Export/Render`) typst ,( markdown step , `Export/Render`)
+ :typst `Content`/`Module` JSON Schema format prose + :typst `Content`/`Module` JSON Schema format
, Lean,"富内容由一个虚拟路径定位"+"叶子带 format" , Lean,"富内容由一个虚拟路径定位"+"叶子带 format"
-/ -/
@@ -33,8 +33,8 @@ inductive ContentFormat where
| markdown | markdown
/-- 对一段富内容的**引用**:它坐落在某个虚拟路径上(`PINNED` 关系, ADR-0006),并带一个 /-- 对一段富内容的**引用**:它坐落在某个虚拟路径上(`PINNED` 关系, ADR-0006),并带一个
**format**(`PINNED`, ADR-0015) `Content`(); **format**(`PINNED`, ADR-0015)**** `Content`();
"富内容经由一个 `VPath` 定位 + 带 format", `Primitives.ElementData` `content` -/ "富内容经由一个 `VPath` 定位 + 带 format", `Primitives.ElementData` `content` -/
structure RichContentRef where structure RichContentRef where
/-- 该富内容所在的虚拟路径(ADR-0006;落盘后为真实相对路径, ADR-0007)。 -/ /-- 该富内容所在的虚拟路径(ADR-0006;落盘后为真实相对路径, ADR-0007)。 -/
vpath : VPath vpath : VPath
+2 -2
View File
@@ -2,8 +2,8 @@ import Spec.Courseware.Open.QuestionBank
import Spec.Courseware.Open.Course import Spec.Courseware.Open.Course
/-! /-!
# Courseware.Open OPEN () # Courseware.Open ( OPEN)
element (`QuestionBank`)(`Course`) surface element (`QuestionBank`)(`Course`) surface
OPEN , ADR , 2 , ADR
-/ -/
+1 -1
View File
@@ -5,6 +5,6 @@ ADR-0005:工程文件的粒度是**单节课**;course / 单元**不是**工程
****"编排":( )?lesson ****"编排":( )?lesson
? lesson ()? `OPEN` ? lesson ()? `OPEN`
`Course := List Lesson`( 2 **** `Course := List Lesson`(
"扁平有序、无层级") surface: ADR "扁平有序、无层级") surface: ADR
-/ -/
+1 -1
View File
@@ -5,6 +5,6 @@
,lesson ** element **, ,lesson ** element **,
"纯引用可能不够"element / lesson / ? "纯引用可能不够"element / lesson / ?
`OPEN` `QuestionRef` `OPEN` 2 **** `QuestionRef`
, surface ADR , surface ADR
-/ -/
+10 -17
View File
@@ -1,10 +1,10 @@
/-! /-!
# Prelude System # Prelude System
(runsessionprincipalchatplatform identity/audit) (runsessionprincipalchatplatform identity/audit)
(UUID / principal ),, opaque (UUID / principal ),, opaque
`Identifiers`,System "锁 owner 是哪个 run" `Identifiers`,System "锁 owner 是哪个 run"
, ****,
-/ -/
namespace Spec.System namespace Spec.System
@@ -15,33 +15,26 @@ structure Identifiers where
ProjectId : Type ProjectId : Type
/-- SaaS 租户/组织标识(`OPEN` 表示;ADR-0020 tenant root)。 -/ /-- SaaS 租户/组织标识(`OPEN` 表示;ADR-0020 tenant root)。 -/
OrganizationId : Type OrganizationId : Type
/-- 租户层用户标识(`OPEN` 表示;独立实体,非飞书身份派生;见 `Hierarchy.User`)。 -/
UserId : Type
/-- 飞书用户 open_id(`OPEN` 表示;单应用作用域内唯一)。 -/
FeishuOpenId : Type
/-- 飞书 user_id(`OPEN` 表示;租户内唯一,换 app 不变)。 -/
FeishuUserId : Type
/-- 飞书企业应用 app_id(`OPEN` 表示)。 -/
FeishuAppId : Type
/-- 飞书 app_secret 信封引用(`OPEN` 表示;ADR-0024)。 -/
FeishuAppSecretRef : Type
/-- Hub teacher team 标识(`OPEN` 表示;ADR-0020 org-scoped team)。 -/ /-- Hub teacher team 标识(`OPEN` 表示;ADR-0020 org-scoped team)。 -/
TeamId : Type TeamId : Type
/-- Project explorer folder 标识(`OPEN` 表示;ADR-0021 透明组织节点,非权限资源)。 -/ /-- Project explorer folder 标识(`OPEN` 表示;ADR-0021 透明组织节点,非权限资源)。 -/
FolderId : Type FolderId : Type
/-- 一次 agent 任务的标识(`OPEN` 表示;锁的 owner、审计主体,`AgentRun`。provider 无关, /-- 一次 agent 任务的标识(`OPEN` 表示;锁的 owner、审计主体,`AgentRun`。provider 无关,
ADR-0017;`@bot` , provider) -/ ADR-0017;`@Claude` , provider) -/
RunId : Type RunId : Type
/-- 长生命周期 agent 会话标识(`OPEN` 表示;**provider/model 绑定, ADR-0017**——一次 /-- 长生命周期 agent 会话标识(`OPEN` 表示;**provider/model 绑定, ADR-0017**——一次
session provider/model; model session, session ADR-0003 session provider/model; model session, session ADR-0003
/, session provider/model run , ADR-0002) -/ /, session provider/model run , ADR-0002) -/
SessionId : Type SessionId : Type
/-- Organization-scoped Agent role 标识(`OPEN` 表示, ADR-0017);role 是动态运行配置,
slash command -/
AgentRoleId : Type
/-- 权限主体标识(`OPEN` 表示及其子类型学;ADR-0004 的 user/chat/department/ /-- 权限主体标识(`OPEN` 表示及其子类型学;ADR-0004 的 user/chat/department/
, opaque ) -/ , plumbing, opaque ) -/
Principal : Type Principal : Type
/-- 飞书项目群 chat 标识(`OPEN` 表示;ADR-0001 协作空间、ADR-0003 锚点引用、 /-- 飞书项目群 chat 标识(`OPEN` 表示;ADR-0001 协作空间、ADR-0003 锚点引用、
ADR-0004 `feishu_chat` principal `Principal` ADR-0004 `feishu_chat` principal `Principal`
principal OPEN,"chat 是 principal 的哪种子型") -/ principal OPEN ,"chat 是 principal 的哪种子型") -/
ChatId : Type ChatId : Type
/-- 平台管理员身份标识(`OPEN` 表示;ADR-0023,不复用客户 `User` 标识)。 -/ /-- 平台管理员身份标识(`OPEN` 表示;ADR-0023,不复用客户 `User` 标识)。 -/
PlatformIdentityId : Type PlatformIdentityId : Type
+14 -22
View File
@@ -1,49 +1,41 @@
import Spec.System.Hierarchy
import Spec.System.ProjectGroup import Spec.System.ProjectGroup
import Spec.System.Organization import Spec.System.Organization
import Spec.System.User
import Spec.System.Connections
import Spec.System.ProjectWorkspace import Spec.System.ProjectWorkspace
import Spec.System.Capacity import Spec.System.Capacity
import Spec.System.PlatformAdministration import Spec.System.PlatformAdministration
import Spec.System.Agent.Run import Spec.System.Run
import Spec.System.Agent.AgentRole
import Spec.System.Agent.Memory
import Spec.System.Agent.AgentSurface
import Spec.System.Lock import Spec.System.Lock
import Spec.System.Memory
import Spec.System.AgentSurface
import Spec.System.Permission import Spec.System.Permission
import Spec.System.PermissionGrant import Spec.System.PermissionGrant
import Spec.System.Audit import Spec.System.Audit
/-! /-!
# System Hub # System Hub
:AgentRun :AgentRunlikec4
likec4 ;: (`docs/architecture/likec4/`)****; likec4
****:
- `Hierarchy` :
- `User` (; `OPEN`)
- `Connections` :() + /
- `ProjectGroup` project 1:1 (ADR-0001);, - `ProjectGroup` project 1:1 (ADR-0001);,
- `Organization` SaaS (ADR-0020);project/team ,TEAM grant org; - `Organization` SaaS tenant root(ADR-0020);project/team ,TEAM grant org;
connection secret fail-closed resolver(ADR-0024); connection secret 使 fail-closed resolver(ADR-0024)
owner/admin/member(`OrganizationRole`)()
- `ProjectWorkspace` org project explorer:folder ,project - `ProjectWorkspace` org project explorer:folder ,project
(ADR-0021) (ADR-0021)
- `Capacity` platform ceiling org policy admission request - `Capacity` platform ceiling org policy admission request
(ADR-0022) (ADR-0022)
- `PlatformAdministration` / - `PlatformAdministration` /
fail-closed 线 emergency grant(ADR-0023) fail-closed 线 emergency grant(ADR-0023)
- `AgentRole` org-scoped agent + (ADR-0017/0018)
- `Run` AgentRun ( OPEN) - `Run` AgentRun ( OPEN)
- `Lock` owner=run(ADR-0002),"持锁者必为非终止 run" - `Lock` owner=run(ADR-0002),"持锁者必为非终止 run"
- `Memory` :(ADR-0003)+ MCP run/project - `Memory` :(ADR-0003)+ MCP run/project
- `AgentSurface` agent run (ADR-0018); Lock - `AgentSurface` agent run (ADR-0018); Lock
Lock ,Surface OPEN Lock ,Surface OPEN
- `Permission` readeditmanage ;force-release - `Permission` readeditmanage ;force-release
- `PermissionGrant` grant(resource×principal×role) settings( policy ) - `PermissionGrant` grant(resource×principal×role) settings( policy )
(ADR-0004); OPEN (ADR-0004);role-capability settings-policy OPEN
- `Audit` customer Project/Run ( OPEN);Platform Audit - `Audit` customer Project/Run ( plumbing,OPEN);Platform
`PlatformAdministration` Audit `PlatformAdministration`
`Spec.Prelude`:ADR-0001..0004, 0018, 0020..0024 `Spec.Prelude`:ADR-0001..0004, 0018, 0020..0024
-/ -/
-60
View File
@@ -1,60 +0,0 @@
import Spec.Prelude
/-!
# AgentRole Agent (ADR-0017, ADR-0018)
AgentRole org-scoped :system prompttool allowlistdefault model
skill CLI ,
Role (model/prompt/tools/skill ), role active sessions
run provider context
label/(ADR-0017)
Run role skill run-scoped ,
run (ADR-0018)Skill org-scoped; `OPEN`
-/
namespace Spec.System
variable (I : Identifiers)
/-- Agent 角色(`PINNED`, org-scoped, ADR-0017)。 -/
structure AgentRole where
/-- 所属组织(`PINNED`)。 -/
organization : I.OrganizationId
/-- 斜杠命令名(`OPEN` 表示;如 /draft)。 -/
roleId : String
/-- system prompt(`PINNED`)。 -/
systemPrompt : String
/-- tool allowlist(`PINNED`;tool 标识集合 `OPEN`)。 -/
tools : List String
/-- 默认 model(`PINNED`;model ID 表示 `OPEN`)。 -/
defaultModel : String
/-- Agent 技能(`PINNED`, org-scoped, ADR-0017/0018)。 -/
structure AgentSkill where
/-- 所属组织(`PINNED`)。 -/
organization : I.OrganizationId
/-- 名称(`PINNED`)。 -/
name : String
/-- 版本(`PINNED`)。 -/
version : String
/-- 内容摘要(`PINNED`;SHA-256 content-addressed)。 -/
contentDigest : String
/-- 描述(`OPEN`)。 -/
description : String
/-- Role-Skill 绑定(`PINNED`, ADR-0017)。一个 role 可绑定零或多个 skill。 -/
structure AgentRoleSkillBinding where
/-- 所属组织(`PINNED`)。 -/
organization : I.OrganizationId
/-- 绑定的 role(`PINNED`)。 -/
roleId : String
/-- skill 名称(`PINNED`)。 -/
skillName : String
/-- skill 版本(`PINNED`)。 -/
skillVersion : String
/-- 排序(`PINNED`)。 -/
sortOrder : Nat
end Spec.System
-40
View File
@@ -1,40 +0,0 @@
import Spec.Prelude
import Spec.System.Agent.Run
/-!
# AgentSurface Agent (ADR-0018)
Agent run , run project
(ADR-0007),
`Lock`(ADR-0002):Lock (),Surface ()
run × project
shell ()
OS SDK ,`OPEN`(ADR-0018)
-/
namespace Spec.System
variable (I : Identifiers) (Path : Type)
/-- Agent 在一次 run 内发起的文件操作(`PINNED` 关系, ADR-0018)。由某 run 发起、
; `Authorized` -/
structure AgentFileOp where
/-- 发起操作的 run(授权上下文主体, ADR-0018;与 `Lock` 同作用域 run × project)。 -/
run : I.RunId
/-- 操作目标路径(`PINNED`, ADR-0018)。 -/
path : Path
/-- 工作区边界良构:run 的文件操作路径必须落在该 run 所属 project 的工作区目录内
(`PINNED` , ADR-0018)`runWorkspace` `pathWithin`
( `OPEN`);"操作路径必须以 run 的工作区为根", agent
宿 -/
def AgentFileOp.Authorized
(op : AgentFileOp I Path)
(runWorkspace : I.RunId Option Path)
(pathWithin : Path Path Prop) : Prop :=
w, runWorkspace op.run = some w pathWithin op.path w
end Spec.System
+44
View File
@@ -0,0 +1,44 @@
import Spec.Prelude
import Spec.System.Run
/-!
# AgentSurface Agent (ADR-0018)
ADR-0001/0002/0004 "协作治理" `triggerAgent`: run
agent ADR / ADR-0017
Claude Code SDK `bypassPermissions` + Read/Write/Bash/Glob/Grep,
agent shell 宿****:spec ADR
:agent run , run project
(ADR-0007:; ADR "agent 操作落在该树内")
, `Lock`(ADR-0002):Lock ****(),Surface
****() run × project
shell (),****
OS (bubblewrap/)SDK ,`OPEN`
(ADR-0018),
-/
namespace Spec.System
variable (I : Identifiers) (Path : Type)
/-- Agent 在一次 run 内发起的文件操作(`PINNED` 关系, ADR-0018)。由某 run 发起、
; `Authorized` -/
structure AgentFileOp where
/-- 发起操作的 run(授权上下文主体, ADR-0018;与 `Lock` 同作用域 run × project)。 -/
run : I.RunId
/-- 操作目标路径(`PINNED` 字段, ADR-0018)。 -/
path : Path
/-- 工作区边界良构:run 的文件操作路径必须落在该 run 所属 project 的工作区目录内
(`PINNED` , ADR-0018)`runWorkspace` `pathWithin`
( `OPEN`"在内" plumbing,);
"操作路径必须以 run 的工作区为根", agent 宿 -/
def AgentFileOp.Authorized
(op : AgentFileOp I Path)
(runWorkspace : I.RunId Option Path)
(pathWithin : Path Path Prop) : Prop :=
w, runWorkspace op.run = some w pathWithin op.path w
end Spec.System
+9 -6
View File
@@ -1,18 +1,21 @@
import Spec.Prelude import Spec.Prelude
/-! /-!
# Audit Project/Run # Audit Project/Run ()
( schema) ADR , likec4 `AuditLog` (`AgentRun -> AuditLog 'records lifecycle events'`),
"审计以 run 为主体记录其生命周期事件" ****( schema) ADR /
, `OPEN`ADR-0023 Platform Audit , , plumbing:
`Spec.System.PlatformAdministration`, "审计以 run 为主体记录其生命周期事件", `OPEN`(
:)
-/ -/
namespace Spec.System namespace Spec.System
/-- 审计条目的最小骨架(关系 `PINNED` / 内容 `OPEN`, likec4)。只承诺"一条审计记录 /-- 审计条目的最小骨架(关系 `PINNED` / 内容 `OPEN`, likec4)。只承诺"一条审计记录
run";事件类型、时间、actor、详情等字段 `OPEN`。 -/ run";事件类型、时间、actor、详情等字段 `OPEN`,待真实分歧点出现时由
ADR ADR-0023 Platform Audit fail-closed ,
`Spec.System.PlatformAdministration`, -/
structure AuditEntry (I : Identifiers) where structure AuditEntry (I : Identifiers) where
/-- 该审计条目所属的 run(`PINNED` 关系, likec4)。 -/ /-- 该审计条目所属的 run(`PINNED` 关系, likec4)。 -/
run : I.RunId run : I.RunId
+2 -2
View File
@@ -4,8 +4,8 @@ import Spec.Prelude
# Capacity SaaS capacity admission and abuse controls (ADR-0022) # Capacity SaaS capacity admission and abuse controls (ADR-0022)
, Organization , Organization
ADR-0022 admission; ADR-0022 admission;
, `OPEN` , `OPEN`,
-/ -/
namespace Spec.System namespace Spec.System
-24
View File
@@ -1,24 +0,0 @@
import Spec.System.Connections.Prelude
import Spec.System.Connections.Feishu
/-!
# Connections
/ `Connections.Prelude`;
`Connections.Feishu`
-/
namespace Spec.System
/-- 组织连接绑定(`PINNED`)。 -/
inductive ConnectionBinding (I : Identifiers) where
/-- 飞书(`PINNED`)。 -/
| feishu : FeishuAppBinding I ConnectionBinding I
/-- 用户连接信息(`PINNED`)。 -/
inductive ConnectionProfile (I : Identifiers) where
/-- 飞书(`PINNED`)。 -/
| feishu : FeishuProfile I ConnectionProfile I
end Spec.System
-31
View File
@@ -1,31 +0,0 @@
import Spec.Prelude
/-!
# Feishu
(1:1) API
-/
namespace Spec.System
variable (I : Identifiers)
/-- 组织的飞书应用绑定(`PINNED`, 1:1)。 -/
structure FeishuAppBinding where
/-- 飞书企业应用 app_id(`OPEN` 表示)。 -/
appId : I.FeishuAppId
/-- app_secret 信封引用(`PINNED`, ADR-0024)。 -/
appSecretEnvelope : I.FeishuAppSecretRef
/-- 用户的飞书信息(`PINNED`)。 -/
structure FeishuProfile where
/-- 应用内身份(`OPEN`);调 API 的直接句柄,换应用即变。 -/
openId : I.FeishuOpenId
/-- 租户内身份(`OPEN`);换应用不变,比 open_id 稳定。 -/
userId : I.FeishuUserId
/-- 显示名(`OPEN`)。 -/
name : Option String
/-- 头像 URL(`OPEN`)。 -/
avatarUrl : Option String
end Spec.System
-15
View File
@@ -1,15 +0,0 @@
/-!
# Connections.Prelude
/ IdP ()
provider connection , `Spec.System.Organization`
-/
namespace Spec.System
/-- 连接提供商(`PINNED`;当前仅飞书,未来可扩展钉钉/企微)。 -/
inductive ConnectionProvider where
/-- 飞书(`PINNED`)。 -/
| feishu
end Spec.System
-46
View File
@@ -1,46 +0,0 @@
import Spec.Prelude
import Spec.System.Connections
/-!
# Hierarchy
: ()
- ****(Platform): SaaS
- ****(Organization): SaaS
- ****(User):
****: "管理员"
-/
namespace Spec.System
variable (I : Identifiers)
/-- 平台(`PINNED`, SaaS 提供方)。只有一个,独立于组织。管理面见 `PlatformAdministration`(ADR-0023)。 -/
structure Platform where
/-- 平台自有飞书应用(`PINNED`, ADR-0023)。 -/
application : I.PlatformFeishuApplicationId
/-- 组织(`PINNED`, ADR-0020)。project/team 必须归属且仅归属一个 org。
`Connections`/tenancy/ `Spec.System.Organization` -/
structure Organization where
/-- 组织标识(`OPEN` 表示)。 -/
id : I.OrganizationId
/-- 外部连接(`PINNED`)。 -/
connections : List (ConnectionBinding I)
/-- 用户(`PINNED`, 租户层独立实体)。必属一个组织。外部连接见 `Connections`。 -/
structure User where
/-- 用户标识(`OPEN` 表示;组织内唯一,不可改;登录用)。 -/
id : I.UserId
/-- 所属组织(`PINNED`, ADR-0020)。 -/
organization : I.OrganizationId
/-- 显示名(`PINNED`, 可改)。 -/
displayName : String
/-- 密码哈希(`OPEN` 表示;id + 密码登录)。 -/
passwordHash : String
/-- 外部连接(`PINNED`)。 -/
connections : List (ConnectionProfile I)
end Spec.System
+13 -10
View File
@@ -1,32 +1,35 @@
import Spec.Prelude import Spec.Prelude
import Spec.System.Agent.Run import Spec.System.Run
/-! /-!
# Lock # Lock
ADR-0002: agent , owner `AgentRun`( ADR-0002 : Claude , **owner `AgentRun`**
teacher / chat / session) run ( teacher / chat / session),
likec4 ** run**
-/ -/
namespace Spec.System namespace Spec.System
variable (I : Identifiers) variable (I : Identifiers)
/-- 项目级锁(`PINNED`, ADR-0002)。`owner : RunId`从类型上编码"lock owner = run_id": /-- 项目级锁(`PINNED`, ADR-0002)。`owner : RunId`(非 SessionId/Principal)从类型
session / teacher -/ "lock owner = run_id": session / teacher -/
structure ProjectAgentLock where structure ProjectAgentLock where
/-- 作用域:项目级(`PINNED`, ADR-0002)。 -/ /-- 作用域:项目级(`PINNED`, ADR-0002 `scope = project_id`)。 -/
scope : I.ProjectId scope : I.ProjectId
/-- 持有者:一个 run(`PINNED`, ADR-0002)。 -/ /-- 持有者:一个 run(`PINNED`, ADR-0002 `owner = run_id`)。 -/
owner : I.RunId owner : I.RunId
/-- 锁表:每项目当前持锁 run(`PINNED` 排他性, ADR-0002)。`ProjectId → Option RunId` /-- 锁表:每项目当前持锁 run(`PINNED` 排他性, ADR-0002)。`ProjectId → Option RunId`
owner -/ **** owner -/
def LockTable := I.ProjectId Option I.RunId def LockTable := I.ProjectId Option I.RunId
/-- 锁表良构:持锁者必为非终止 run(`PINNED`, ADR-0002)。 /-- 锁表良构:**持锁者必为非终止 run**(`PINNED` 平台核心不变式, ADR-0002)。
`p` `r` , `r` run -/ "锁在 run 终止时释放": `p` `r` , `r`
Lock Run likec4 "run owns lock while running","终止即
"这个约束;它正是契约相对结构图的增量。 -/
def LockTable.WellFormed def LockTable.WellFormed
(lt : LockTable I) (statusOf : I.RunId RunState) : Prop := (lt : LockTable I) (statusOf : I.RunId RunState) : Prop :=
p r, lt p = some r ¬ (statusOf r).Terminal p r, lt p = some r ¬ (statusOf r).Terminal
@@ -3,13 +3,13 @@ import Spec.Prelude
/-! /-!
# Memory :(ADR-0003) # Memory :(ADR-0003)
ADR-0003:Hub ,;Claude ADR-0003:Hub ****,;Claude
API (ADR , OPEN), API ****(ADR , OPENADR
:MCP run/project ,Claude chat id "例如",), likec4 :**MCP
(ADR-0003 Consequences ) run/project ,Claude chat id**(ADR-0003 Consequences )
"chat id 与 project 绑定" `ProjectGroup.GroupBinding`(ADR-0001), "chat id 与 project 绑定" `ProjectGroup.GroupBinding`(ADR-0001),
(线) ,(线)
-/ -/
namespace Spec.System namespace Spec.System
@@ -17,8 +17,9 @@ namespace Spec.System
variable (I : Identifiers) variable (I : Identifiers)
variable (MessageId CardId : Type) variable (MessageId CardId : Type)
/-- 上下文锚点(`PINNED` 类别, ADR-0003;枚举完整性 `OPEN`——ADR 是"例如"式列举, /-- 上下文锚点(`PINNED` 类别, ADR-0003 列定;**枚举完整性 `OPEN`**——ADR 是"例如"式
surface) Hub , -/ , surface,) Hub ,
-/
inductive Anchor where inductive Anchor where
/-- 触发某次 run 的消息(`PINNED` 类别, ADR-0003 "trigger message id")。 -/ /-- 触发某次 run 的消息(`PINNED` 类别, ADR-0003 "trigger message id")。 -/
| triggerMessage : MessageId Anchor | triggerMessage : MessageId Anchor
@@ -34,11 +35,13 @@ MCP tools to read … through Feishu APIs")。 -/
structure McpReadRequest where structure McpReadRequest where
/-- 发起请求的 run(授权上下文主体, ADR-0003)。 -/ /-- 发起请求的 run(授权上下文主体, ADR-0003)。 -/
run : I.RunId run : I.RunId
/-- 请求读取的 chat(授权由下方 `Authorized` 约束:不允许越界)。 -/ /-- 请求读取的 chat(是否允许越界由下方 `Authorized` 钉死:不允许)。 -/
chat : I.ChatId chat : I.ChatId
/-- 请求获授权:其 chat 必须等于该 run 所属 project 的绑定群(`PINNED` 安全不变式, /-- 请求获授权:其 chat 必须等于该 run 所属 project 的绑定群(`PINNED` 安全不变式,
ADR-0003)"chat 必须匹配 runproject 绑定", Claude chat id -/ 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 def McpReadRequest.Authorized
(req : McpReadRequest I) (req : McpReadRequest I)
(runProject : I.RunId Option I.ProjectId) (runProject : I.RunId Option I.ProjectId)
+18 -40
View File
@@ -1,12 +1,18 @@
import Spec.Prelude import Spec.Prelude
/-! /-!
# Organization SaaS (ADR-0020, ADR-0024) # Organization SaaS tenant root (ADR-0020, ADR-0024)
project/team org;teamproject grant org ADR-0020:Hub SaaS ,`Organization` tenant root
`Hierarchy.Organization`; `Spec.System.User`; `Project` `Team` organization;team project
`PlatformAdministration`(ADR-0023) ADR-0024Agent organization (platform staff / break-glass / )
`Spec.System.Agent.AgentRole` project `read/edit/manage` ,
:tenant root project/team team grant
org, model provider connection
provider `OPEN`;// ADR-0023
`Spec.System.PlatformAdministration`
writer authority fail-closed resolver ADR-0024
-/ -/
namespace Spec.System namespace Spec.System
@@ -35,17 +41,19 @@ structure TeamProjectGrantScope where
project : I.ProjectId project : I.ProjectId
/-- 获得授权的 team principal(`PINNED`, ADR-0020)。 -/ /-- 获得授权的 team principal(`PINNED`, ADR-0020)。 -/
team : I.TeamId team : I.TeamId
/-- Team-project grant 是良构的 iff project 与 team 解析到同一 organization /-- Team-project grant 是良构的 iff project 与 team 解析到同一 organization
(`PINNED`, ADR-0020)`projectOrg`/`teamOrg` ( `OPEN`); org (`PINNED`, ADR-0020)`projectOrg`/`teamOrg` ( `OPEN`);
team grant -/ org team grant -/
def TeamProjectGrantScope.WellScoped def TeamProjectGrantScope.WellScoped
(grant : TeamProjectGrantScope I) (grant : TeamProjectGrantScope I)
(projectOrg : I.ProjectId Option I.OrganizationId) (projectOrg : I.ProjectId Option I.OrganizationId)
(teamOrg : I.TeamId Option I.OrganizationId) : Prop := (teamOrg : I.TeamId Option I.OrganizationId) : Prop :=
o, projectOrg grant.project = some o teamOrg grant.team = some o o, projectOrg grant.project = some o teamOrg grant.team = some o
/- BYOK 由组织所有者/管理员管理;platform-managed 由平台管理员管理。两种模式都不允许 /-- Organization 的 model provider 凭据归属模式(`PINNED`, ADR-0021):BYOK 由 org
org process-global key `OPEN` -/ ;platform-managed org
org process-global provider key `OPEN` -/
inductive ProviderCredentialMode where inductive ProviderCredentialMode where
| byok | byok
| platformManaged | platformManaged
@@ -70,7 +78,7 @@ inductive OrganizationConnectionStatus where
/-- Organization secret version 的信封绑定上下文(`PINNED`, ADR-0024):认证附加数据必须 /-- Organization secret version 的信封绑定上下文(`PINNED`, ADR-0024):认证附加数据必须
organizationconnectionsecret version purpose, org organizationconnectionsecret version purpose, org
connection , opaque -/ connection plumbing, opaque -/
structure OrganizationSecretBinding structure OrganizationSecretBinding
(OrganizationId ConnectionId SecretVersionId Purpose : Type) where (OrganizationId ConnectionId SecretVersionId Purpose : Type) where
/-- secret 所属 organization(`PINNED`, ADR-0024)。 -/ /-- secret 所属 organization(`PINNED`, ADR-0024)。 -/
@@ -96,34 +104,4 @@ def OrganizationSecretResolvable
organizationActive && connectionActive && organizationActive && connectionActive &&
(binding.organization == requestedOrganization) && authenticatedEnvelope (binding.organization == requestedOrganization) && authenticatedEnvelope
/-- 组织成员角色(`PINNED` 封闭三档)。与项目层 `Role`、平台层 `PlatformRole` 互不相交。 -/
inductive OrganizationRole where
/-- 组织所有者(`PINNED`):bootstrap,独家管 owner 群体,受最后所有者保护。 -/
| owner
/-- 组织管理员(`PINNED`):管成员/policy/BYOK,不能管 owner 群体。 -/
| admin
/-- 组织成员(`PINNED`):普通成员。 -/
| member
/-- 组织成员关系(`PINNED`)。 -/
structure OrganizationMembership where
/-- 成员(`PINNED`;独立实体,见 `Hierarchy.User`)。 -/
user : I.UserId
/-- 组织(`PINNED`)。 -/
organization : I.OrganizationId
/-- 角色(`PINNED`)。 -/
role : OrganizationRole
/-- 只有组织所有者能管 owner 群体(`PINNED`)。 -/
def CanManageOwnerGroup (actorRole : OrganizationRole) : Prop :=
actorRole = .owner
/-- 最后所有者保护(`PINNED`):撤销 owner 时同 org 必须还有一个不同的 owner。 -/
def LastOwnerProtected
(target : OrganizationMembership I)
(otherOwnersInOrg : List I.UserId) : Prop :=
target.role .owner
other, other otherOwnersInOrg other target.user
end Spec.System end Spec.System
+2 -1
View File
@@ -5,7 +5,8 @@ import Spec.Prelude
ADR-0004:"飞书云文档式"grant(`resource + principal + role`) settings ADR-0004:"飞书云文档式"grant(`resource + principal + role`) settings
;role `read / edit / manage`, **read edit manage** ; ;role `read / edit / manage`, **read edit manage** ;
**admin-only**, role **admin-only**, role "高 role 含低
role "的单调性钉死。
-/ -/
namespace Spec.System namespace Spec.System
+12 -9
View File
@@ -4,14 +4,16 @@ import Spec.System.Permission
/-! /-!
# PermissionGrant (ADR-0004) # PermissionGrant (ADR-0004)
ADR-0004 "飞书云文档式":grant(`resource × principal × role`) settings ADR-0004 "飞书云文档式":**grant**(`resource × principal × role`) **settings**
( policy );role ( `Permission`),settings "此资源是否 ( policy );role "谁能"(, `Permission`),settings "此资源
" "(策略)。本模块把 grant/settings 的结构钉死——`Permission` 已落 role 能
,"授权如何挂到资源/主体上"
principal (user/chat/department/) policy `OPEN`role-capability principal (user/chat/department/) policy `OPEN`(ADR ,
settings-policy `OPEN`ADR-0004 , )**role-capability settings-policy ** `OPEN`
ADR-0020 TEAM principal PROJECT resource ADR-0004 ,(AND?settings role?),
organization, `Spec.System.Organization` surface,ADR-0020 TEAM principal PROJECT resource
organization; tenant well-scopedness `Spec.System.Organization`
-/ -/
namespace Spec.System namespace Spec.System
@@ -43,8 +45,9 @@ structure PermissionGrant where
role : Role role : Role
/-- 资源策略设置(`PINNED` 结构 + 六旋钮, ADR-0004 `PermissionSettings`):与 grant 分离, /-- 资源策略设置(`PINNED` 结构 + 六旋钮, ADR-0004 `PermissionSettings`):与 grant 分离,
"此资源是否开某类操作" ADR ; `OPEN`(ADR ) "此资源是否开某类操作" ADR ; `OPEN`(ADR ,
opaque `Policy` :"旋钮存在且相互独立";/ ADR -/ ) opaque `Policy` :"旋钮存在且相互独立","各旋钮
"——值域是实现/后续 ADR 的事。 -/
structure PermissionSettings where structure PermissionSettings where
/-- 设置所属资源(`PINNED`, ADR-0004)。 -/ /-- 设置所属资源(`PINNED`, ADR-0004)。 -/
resource : Resource I ArtifactId resource : Resource I ArtifactId
+2 -2
View File
@@ -8,8 +8,8 @@ import Spec.Prelude
invitation sessionmutation invitation sessionmutation
,线 ,线
cookie token hash TTL cookie token hash TTL
/recovery key CLI/SQL `OPEN` /recovery key CLI/SQL `OPEN`,
-/ -/
namespace Spec.System namespace Spec.System
+55 -13
View File
@@ -1,25 +1,34 @@
import Spec.Prelude import Spec.Prelude
/-! /-!
# ProjectGroup (ADR-0001) # ProjectGroup (ADR-0001/0017)
project ;,( `AgentRun`, ADR-0001 : project ****;,**
`Lock` / ADR-0002) agent ;/ owner**( `AgentRun`, `Lock` / ADR-0002), session Claude
agent ;/ Claude
projectgroup **active**likec4 "project has group","恰好一个、
"
**(`PINNED`, ADR-0021):** active binding 1:1; archived **(`PINNED`, ADR-0021):** active binding 1:1; archived
historical binding rows , `GroupBinding` active historical binding rows /, `GroupBinding` active
/ `OPEN`;pilot org admin / `OPEN`;pilot org admin
**(`PINNED`, ADR-0017):** active binding Organization-scoped Agent
role; role,使,
, role provider session Organization
active role binding,role role actor
project `agent.trigger` role `role.trigger`; project `MANAGE`
-/ -/
namespace Spec.System namespace Spec.System
variable (I : Identifiers) variable (I : Identifiers)
/-- 飞书项目群(`PINNED` 长生命周期协作空间, ADR-0001)。承载 project 与飞书 chat 的 /-- 飞书项目群(`PINNED` 长生命周期协作空间, ADR-0001)。承载 project 与飞书 chat 的绑定;
;( `AgentRun`, `Lock`) -/ ** owner**( `AgentRun`, `Lock`); session -/
structure ProjectGroup where structure ProjectGroup where
/-- 群对应的飞书 chat(`PINNED` 关系, ADR-0001;chat 标识见 `Identifiers.ChatId`)。 -/ /-- 群对应的飞书 chat(`PINNED` 关系, ADR-0001 "one project has one Feishu project
group";chat 标识见 `Identifiers.ChatId`)。 -/
chat : I.ChatId chat : I.ChatId
/-- 项目↔active 群绑定表(`PINNED` 每项目至多一个 active 群, ADR-0001/0021)。 /-- 项目↔active 群绑定表(`PINNED` 每项目至多一个 active 群, ADR-0001/0021)。
@@ -27,10 +36,43 @@ structure ProjectGroup where
); -/ ); -/
def GroupBinding := I.ProjectId Option I.ChatId def GroupBinding := I.ProjectId Option I.ChatId
/-- Active 绑定良构:单射——不同 project 不绑同一 active chat(`PINNED`, ADR-0001/0021)。 /-- Active 绑定良构:**单射**——不同 project 不绑同一 active chat(`PINNED` 1:1 的另一半,
"每 project 至多一个群" `Option` ;"每群至多属于一个 project" ADR-0001/0021)"每 project 至多一个群" `Option` ;"每群至多属于一个
archived historical bindings -/ project"。archived historical bindings 不在本快照不变式内。 -/
def GroupBinding.WellFormed (b : GroupBinding I) : Prop := def GroupBinding.WellFormed (b : GroupBinding I) : Prop :=
p₁ p₂ c, b p₁ = some c b p₂ = some c p₁ = p₂ p₁ p₂ c, b p₁ = some c b p₂ = some c p₁ = p₂
/-- 每个 Organization 的 active 默认 Agent role(`PINNED`, ADR-0017)。函数形状钉死每个
Organization ;role active `WellScoped` -/
def OrganizationDefaultRole := I.OrganizationId I.AgentRoleId
/-- 默认 role 必须属于作为其 key 的同一个 Organization(`PINNED`, ADR-0017)。 -/
def OrganizationDefaultRole.WellScoped
(defaults : OrganizationDefaultRole I)
(roleOrg : I.AgentRoleId Option I.OrganizationId) : Prop :=
o, roleOrg (defaults o) = some o
/-- Active 项目群选择的 Agent role(`PINNED`, ADR-0017)。role 属于项目 Organization;
plumbing, `WellScoped` -/
structure GroupSelectedRole where
/-- 被选择 role 的项目。 -/
project : I.ProjectId
/-- 作用于该项目 active 群的动态 Agent role。 -/
role : I.AgentRoleId
/-- 群所选 role 必须与 project 同 Organization(`PINNED`, ADR-0017)。 -/
def GroupSelectedRole.WellScoped
(selection : GroupSelectedRole I)
(projectOrg : I.ProjectId Option I.OrganizationId)
(roleOrg : I.AgentRoleId Option I.OrganizationId) : Prop :=
o, projectOrg selection.project = some o roleOrg selection.role = some o
/-- 已接收群工作冻结其 role(`PINNED`, ADR-0017):调度时使用 admission 中记录的 role,
role -/
structure GroupRunRoleSnapshot where
/-- 被接收的一次 run。 -/
run : I.RunId
/-- 接收时冻结的 role。 -/
role : I.AgentRoleId
end Spec.System end Spec.System
+6 -5
View File
@@ -3,11 +3,12 @@ import Spec.Prelude
/-! /-!
# ProjectWorkspace project explorer (ADR-0021) # ProjectWorkspace project explorer (ADR-0021)
org "文件管理器式":folder , ADR-0021 org "文件管理器式" folder + project:
project folder ,project
:folder/project orgfolder project :folder/project orgfolder
org policy folder visibility/team policy/ `OPEN` project org policy folder visibility/team policy/
,
-/ -/
namespace Spec.System namespace Spec.System
@@ -38,7 +39,7 @@ def ProjectFolderPlacement.WellScoped
o, projectOrg placement.project = some o folderOrg placement.folder = some o o, projectOrg placement.project = some o folderOrg placement.folder = some o
/-- Folder 当前透明(`PINNED`, ADR-0021):folder 不是权限资源,不持有 grants,移动 project /-- Folder 当前透明(`PINNED`, ADR-0021):folder 不是权限资源,不持有 grants,移动 project
project folder policy , -/ project folder policy , -/
structure FolderTransparent where structure FolderTransparent where
/-- 透明性命题本身;字段存在是为了让 contract 明确可引用(`PINNED`, ADR-0021)。 -/ /-- 透明性命题本身;字段存在是为了让 contract 明确可引用(`PINNED`, ADR-0021)。 -/
current : True current : True
@@ -1,16 +1,16 @@
/-! /-!
# Run AgentRun # Run AgentRun
`@bot` `AgentRun`(ADR-0001),(ADR-0002) `@Claude` `AgentRun`(ADR-0001),(ADR-0002)
ADR ,( Lock ADR / likec4 ,******
), **( Lock ),
-/ -/
namespace Spec.System namespace Spec.System
/-- AgentRun 运行状态(状态名 `PINNED`, ADR-0001..0003, ADR-0022;完整性 `OPEN` /-- AgentRun 运行状态(状态名 `PINNED`, ADR-0001..0003, ADR-0022 + likec4;
ADR "状态就是这些";( pending) surface) ** `OPEN`**"状态恰好这些";( pending)
`RunState.Terminal` -/ surface,) `RunState.Terminal` -/
inductive RunState where inductive RunState where
| active | active
| waitingForUser | waitingForUser
-12
View File
@@ -1,12 +0,0 @@
import Spec.Prelude
/-!
# User
`Hierarchy.User`; `Spec.System.Connections`
; `OPEN`
-/
namespace Spec.System
end Spec.System