diff --git a/AGENTS.md b/AGENTS.md index 9cf5896..5691183 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,8 +30,17 @@ 控制面与 Docker adapter 后置(见 ADR-0025)。 - Agent role 与 skill 是 Organization-scoped 动态运行配置:role 组合 model、system prompt、 tools 与已安装 skill;skill 版本进入 content-addressed 持久存储,run 只读加载所选快照。 + 每个 Organization 必须且只能有一个启用中的默认 role;新建群绑定从该默认值初始化,之后 + `ProjectGroupBinding` 持久化群内当前 role,run 在接纳时冻结该 role。飞书公开 slash 协议只含 + `/project`、`/usage`、`/help`;role、会话、目录操作走 `/project` 卡片,Claude 原生 + `/compact` 只能由卡片动作以未经包装的精确 prompt 转发。 `settingSources: []` 继续禁用项目/用户配置加载,不得把任意 workspace `.claude` 配置变成 运行时能力(见 ADR-0018)。 +- 项目发现由 `ProjectDiscovery` 模块统一承载:PostgreSQL `pg_trgm` 搜索派生文档、项目编号 + 归一化、完整 Folder breadcrumb、MANAGE 授权过滤与分页都在该模块内;飞书卡片只是 adapter。 + `Project`/`Folder` 仍是事实来源,搜索文档必须可重建且由数据库触发器同步,禁止调用方双写。 + 系统 `Inbox` 只作为未分类项目的内部落点,不作为业务 folder 暴露;已绑定群通过 + `@bot /project` 随时打开项目管理卡片,重命名仍走 org-scoped MANAGE 授权与审计。 ## 纪律 diff --git a/docs/adr/0017-agent-session-is-provider-bound.md b/docs/adr/0017-agent-session-is-provider-bound.md index 63db306..2c7228c 100644 --- a/docs/adr/0017-agent-session-is-provider-bound.md +++ b/docs/adr/0017-agent-session-is-provider-bound.md @@ -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 `claudeSessionId` and pass it back to the next `query()` call as `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` -and `/review` must not resume the same Claude runtime cursor by accident. +tool surfaces can differ even when the underlying model is the same. A Feishu +project group's active binding selects one Organization role for ordinary +messages. Switching that selection routes future messages to the selected +role's own session; it never mutates or merges provider cursors across roles. +Work freezes the selected role when accepted so queued requests cannot drift +after a later switch. Role definitions are Organization-scoped runtime data. A role bundle selects its default model, system prompt, tool allowlist and installed Agent skill @@ -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; 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: ``` ANTHROPIC_BASE_URL=https://openrouter.ai/api diff --git a/docs/para-26071100-feishu-setup.md b/docs/para-26071100-feishu-setup.md index a6ab17f..da2ceee 100644 --- a/docs/para-26071100-feishu-setup.md +++ b/docs/para-26071100-feishu-setup.md @@ -72,8 +72,10 @@ Educraft 机器人以应用身份调用上述 API,因此这些 scope 全部放 1. 在“事件配置”中将订阅方式设为“使用长连接接收事件”。 2. 添加事件“接收消息” `im.message.receive_v1`。 -3. 在“回调配置”中同样选择长连接。 -4. 添加回调“卡片回传交互” `card.action.trigger`,用于审批、运行中断和项目创建/绑定按钮。 +3. 添加事件“解散群” `im.chat.disbanded_v1`,用于立即归档该群的项目绑定。 +4. 添加事件“机器人被移出群” `im.chat.member.bot.deleted_v1`,用于立即归档该群的项目绑定。 +5. 在“回调配置”中同样选择长连接。 +6. 添加回调“卡片回传交互” `card.action.trigger`,用于审批、运行中断和项目创建/绑定按钮。 ![长连接与消息事件配置](assets/feishu-setup/03-events.png) @@ -174,7 +176,7 @@ App ID:cli_... App Secret:(通过安全渠道单独发送) 应用已发布:是 / 否 机器人能力已启用:是 / 否 -消息事件和卡片回调已配置:是 / 否 +消息事件(含解散群、机器人被移出群)和卡片回调已配置:是 / 否 OAuth 重定向 URL 已配置:是 / 否 【首位 OWNER】 diff --git a/hub/deploy/build_legacy_project_manifest.mjs b/hub/deploy/build_legacy_project_manifest.mjs new file mode 100644 index 0000000..fd25fac --- /dev/null +++ b/hub/deploy/build_legacy_project_manifest.mjs @@ -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 "); +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; +} diff --git a/hub/package-lock.json b/hub/package-lock.json index 0b0848c..893906b 100644 --- a/hub/package-lock.json +++ b/hub/package-lock.json @@ -1,12 +1,12 @@ { "name": "@paradigm/hub", - "version": "0.0.20", + "version": "0.0.25", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@paradigm/hub", - "version": "0.0.20", + "version": "0.0.25", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.202", "@fastify/cookie": "^11.0.2", diff --git a/hub/package.json b/hub/package.json index f2f6394..03fbed8 100644 --- a/hub/package.json +++ b/hub/package.json @@ -1,6 +1,6 @@ { "name": "@paradigm/hub", - "version": "0.0.20", + "version": "0.0.25", "private": true, "type": "module", "engines": { diff --git a/hub/prisma/migrations/20260712010000_project_discovery_search/migration.sql b/hub/prisma/migrations/20260712010000_project_discovery_search/migration.sql new file mode 100644 index 0000000..fdd3ffa --- /dev/null +++ b/hub/prisma/migrations/20260712010000_project_discovery_search/migration.sql @@ -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"; diff --git a/hub/prisma/migrations/20260713170000_project_group_selected_role/migration.sql b/hub/prisma/migrations/20260713170000_project_group_selected_role/migration.sql new file mode 100644 index 0000000..984c93a --- /dev/null +++ b/hub/prisma/migrations/20260713170000_project_group_selected_role/migration.sql @@ -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; diff --git a/hub/prisma/migrations/20260713173000_project_group_role_tenant_scope/migration.sql b/hub/prisma/migrations/20260713173000_project_group_role_tenant_scope/migration.sql new file mode 100644 index 0000000..4cd03c3 --- /dev/null +++ b/hub/prisma/migrations/20260713173000_project_group_role_tenant_scope/migration.sql @@ -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"); diff --git a/hub/prisma/migrations/20260713174500_agent_role_default_invariant/migration.sql b/hub/prisma/migrations/20260713174500_agent_role_default_invariant/migration.sql new file mode 100644 index 0000000..bc8400f --- /dev/null +++ b/hub/prisma/migrations/20260713174500_agent_role_default_invariant/migration.sql @@ -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(); diff --git a/hub/prisma/migrations/20260713175000_agent_role_tenant_immutable/migration.sql b/hub/prisma/migrations/20260713175000_agent_role_tenant_immutable/migration.sql new file mode 100644 index 0000000..d421245 --- /dev/null +++ b/hub/prisma/migrations/20260713175000_agent_role_tenant_immutable/migration.sql @@ -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; +$$; diff --git a/hub/prisma/migrations/20260713175500_organization_requires_default_role/migration.sql b/hub/prisma/migrations/20260713175500_organization_requires_default_role/migration.sql new file mode 100644 index 0000000..c703701 --- /dev/null +++ b/hub/prisma/migrations/20260713175500_organization_requires_default_role/migration.sql @@ -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(); diff --git a/hub/prisma/schema.prisma b/hub/prisma/schema.prisma index 1691cf2..b7707e8 100644 --- a/hub/prisma/schema.prisma +++ b/hub/prisma/schema.prisma @@ -45,7 +45,9 @@ model Organization { feishuApplicationConnection OrganizationFeishuApplicationConnection? agentSkills OrganizationAgentSkill[] agentRoles OrganizationAgentRole[] - auditEntries AuditEntry[] @relation("organizationAudit") + projectGroupBindings ProjectGroupBinding[] + auditEntries AuditEntry[] @relation("organizationAudit") + projectSearchDocuments ProjectSearchDocument[] @@index([status]) } @@ -115,12 +117,14 @@ model OrganizationAgentRole { systemPrompt String? tools Json? sortOrder Int @default(0) + isDefault Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt disabledAt DateTime? - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - skillBindings OrganizationAgentRoleSkill[] + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + skillBindings OrganizationAgentRoleSkill[] + selectedByBindings ProjectGroupBinding[] @relation("selectedAgentRole") @@unique([organizationId, roleId]) @@unique([organizationId, id]) @@ -131,10 +135,10 @@ model OrganizationAgentRole { /// gives stable skill listing and prompt discovery order for a role bundle. model OrganizationAgentRoleSkill { organizationId String - agentRoleId String - agentSkillId String - sortOrder Int @default(0) - createdAt DateTime @default(now()) + agentRoleId String + agentSkillId String + sortOrder Int @default(0) + createdAt DateTime @default(now()) role OrganizationAgentRole @relation(fields: [organizationId, agentRoleId], references: [organizationId, id], onDelete: Cascade) skill OrganizationAgentSkill @relation(fields: [organizationId, agentSkillId], references: [organizationId, id], onDelete: Cascade) @@ -168,16 +172,16 @@ model User { platformRoles PlatformRoleAssignment[] organizationMemberships OrganizationMembership[] - createdProjects Project[] @relation("projectCreator") - requestedRuns AgentRun[] @relation("runRequester") - heldLocks ProjectAgentLock[] @relation("lockHolder") - feishuBindings ProjectGroupBinding[] @relation("bindingCreator") + createdProjects Project[] @relation("projectCreator") + requestedRuns AgentRun[] @relation("runRequester") + heldLocks ProjectAgentLock[] @relation("lockHolder") + feishuBindings ProjectGroupBinding[] @relation("bindingCreator") teamMemberships TeamMembership[] externalPrincipalMemberships ExternalPrincipalMembership[] - permissionGrants PermissionGrant[] @relation("grantCreator") - roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator") - auditEntries AuditEntry[] @relation("auditActor") - providerCredentialVersions ProviderCredentialVersion[] @relation("providerCredentialVersionCreator") + permissionGrants PermissionGrant[] @relation("grantCreator") + roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator") + auditEntries AuditEntry[] @relation("auditActor") + providerCredentialVersions ProviderCredentialVersion[] @relation("providerCredentialVersionCreator") feishuCredentialVersions FeishuApplicationCredentialVersion[] @relation("feishuCredentialVersionCreator") feishuIdentities FeishuUserIdentity[] } @@ -196,7 +200,7 @@ model OrganizationFeishuApplicationConnection { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) secretVersions FeishuApplicationCredentialVersion[] @relation("feishuCredentialVersions") activeSecretVersion FeishuApplicationCredentialVersion? @relation("activeFeishuCredentialVersion", fields: [activeSecretVersionId], references: [id], onDelete: Restrict) userIdentities FeishuUserIdentity[] @@ -228,19 +232,19 @@ model FeishuUserIdentity { /// ADR-0024: all Feishu app material, including provider-local app/bot ids, is /// inside one immutable authenticated envelope version. model FeishuApplicationCredentialVersion { - id String @id @default(cuid()) + id String @id @default(cuid()) connectionId String version Int - envelopeVersion Int @default(1) + envelopeVersion Int @default(1) keyId String envelope Json createdByUserId String? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) retiredAt DateTime? connection OrganizationFeishuApplicationConnection @relation("feishuCredentialVersions", fields: [connectionId], references: [id], onDelete: Cascade) activeFor OrganizationFeishuApplicationConnection? @relation("activeFeishuCredentialVersion") - createdBy User? @relation("feishuCredentialVersionCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) + createdBy User? @relation("feishuCredentialVersionCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) @@unique([connectionId, version]) @@index([connectionId, retiredAt]) @@ -287,14 +291,14 @@ enum OrganizationConnectionStatus { /// per immutable version. keyId/envelopeVersion are redacted rotation metadata; /// all provider URL/token/API-key fields remain inside envelope ciphertext. model ProviderCredentialVersion { - id String @id @default(cuid()) + id String @id @default(cuid()) connectionId String version Int - envelopeVersion Int @default(1) + envelopeVersion Int @default(1) keyId String envelope Json createdByUserId String? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) retiredAt DateTime? connection OrganizationProviderConnection @relation("providerCredentialVersions", fields: [connectionId], references: [id], onDelete: Cascade) @@ -437,14 +441,20 @@ model ExternalPrincipalMembership { /// ADR-0021: transparent project explorer folder. Folders are org-scoped /// navigation/aggregation nodes, not permission resources; project grants stay /// attached to PROJECT resources. +enum FolderKind { + REGULAR + SYSTEM_INBOX +} + model Folder { - id String @id @default(cuid()) + id String @id @default(cuid()) organizationId String parentId String? name String - sortKey String @default("") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + kind FolderKind @default(REGULAR) + sortKey String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt archivedAt DateTime? organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) @@ -460,6 +470,7 @@ model Project { id String @id @default(cuid()) organizationId String folderId String? + code String? name String workspaceDir String createdByUserId String? @@ -467,39 +478,68 @@ model Project { updatedAt DateTime @updatedAt archivedAt DateTime? - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull) - createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull) + createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) groupBindings ProjectGroupBinding[] agentSessions AgentSession[] agentRuns AgentRun[] agentLock ProjectAgentLock? - roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants") - auditEntries AuditEntry[] @relation("projectAudit") - fileChanges AgentFileChange[] @relation("projectFileChanges") + roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants") + auditEntries AuditEntry[] @relation("projectAudit") + fileChanges AgentFileChange[] @relation("projectFileChanges") + searchDocument ProjectSearchDocument? + @@unique([organizationId, id]) @@index([organizationId, archivedAt]) @@index([folderId, 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 /// (1:1). Historical archived bindings are retained for audit; partial unique /// indexes in migrations enforce one active binding per project and per chat. model ProjectGroupBinding { - id String @id @default(cuid()) - projectId String - chatId String - createdByUserId String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - archivedAt DateTime? + id String @id @default(cuid()) + organizationId String + projectId String + chatId String + createdByUserId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + archivedAt DateTime? + selectedAgentRoleId String - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - createdBy User? @relation("bindingCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) + 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) + selectedRole OrganizationAgentRole @relation("selectedAgentRole", fields: [organizationId, selectedAgentRoleId], references: [organizationId, id], onDelete: Restrict) + @@index([organizationId]) @@index([projectId, archivedAt]) @@index([chatId, archivedAt]) + @@index([selectedAgentRoleId]) } // --- AgentRun, session, lock (ADR-0002, 0017) ----------------------------- @@ -697,17 +737,17 @@ model RoleTriggerGrant { /// spec AuditEntry: minimal skeleton — one entry relates to a run. Event type, /// actor, timestamp, details are OPEN. This table mirrors that: `runId` is the model AuditEntry { - id String @id @default(cuid()) - runId String? - projectId String? + id String @id @default(cuid()) + runId String? + projectId String? organizationId String? - actorUserId String? - action String - metadata Json - createdAt DateTime @default(now()) + actorUserId String? + action String + metadata Json + createdAt DateTime @default(now()) - actor User? @relation("auditActor", fields: [actorUserId], references: [id], onDelete: SetNull) - project Project? @relation("projectAudit", fields: [projectId], references: [id], onDelete: SetNull) + actor User? @relation("auditActor", fields: [actorUserId], references: [id], onDelete: SetNull) + project Project? @relation("projectAudit", fields: [projectId], references: [id], onDelete: SetNull) organization Organization? @relation("organizationAudit", fields: [organizationId], references: [id], onDelete: SetNull) @@index([runId]) diff --git a/hub/src/agent/configuration.ts b/hub/src/agent/configuration.ts index 3c16fd1..598d7d3 100644 --- a/hub/src/agent/configuration.ts +++ b/hub/src/agent/configuration.ts @@ -89,6 +89,7 @@ export class OrganizationAgentConfiguration { readonly systemPrompt?: string | null | undefined; readonly tools?: readonly string[] | null | undefined; readonly sortOrder?: number | undefined; + readonly isDefault?: boolean | undefined; }): Promise<{ readonly id: string; readonly roleId: string }> { await this.requireActiveOrganization(input.organizationId); 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) => { const previous = await tx.organizationAgentRole.findUnique({ 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({ where: { organizationId_roleId: { @@ -124,6 +139,7 @@ export class OrganizationAgentConfiguration { systemPrompt: normalizeOptionalText(input.systemPrompt), tools: createTools, sortOrder, + isDefault: effectiveIsDefault, }, update: { label, @@ -131,6 +147,7 @@ export class OrganizationAgentConfiguration { ...(input.systemPrompt !== undefined ? { systemPrompt: normalizeOptionalText(input.systemPrompt) } : {}), ...(updateTools !== undefined ? { tools: updateTools } : {}), sortOrder, + isDefault: effectiveIsDefault, disabledAt: null, }, select: { id: true, roleId: true }, @@ -140,6 +157,12 @@ export class OrganizationAgentConfiguration { (input.systemPrompt !== undefined && normalizeOptionalText(input.systemPrompt) !== previous.systemPrompt) || (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]); await tx.auditEntry.create({ data: { @@ -154,6 +177,8 @@ export class OrganizationAgentConfiguration { : normalizeOptionalText(input.systemPrompt) !== null, tools: input.tools === undefined ? "unchanged" : input.tools === null ? "all" : [...input.tools], sortOrder, + isDefault: effectiveIsDefault, + defaultExplicit: input.isDefault !== undefined, }, }, }); @@ -237,14 +262,26 @@ async function archiveRoleSessions( roleIds: readonly string[], ): Promise { if (roleIds.length === 0) return; - await tx.agentSession.updateMany({ + const sessions = await tx.agentSession.findMany({ where: { roleId: { in: [...new Set(roleIds)] }, - archivedAt: null, 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 { diff --git a/hub/src/agent/models.ts b/hub/src/agent/models.ts index 3987c70..89b0070 100644 --- a/hub/src/agent/models.ts +++ b/hub/src/agent/models.ts @@ -12,8 +12,8 @@ * A named role preset — the full per-run agent bundle. Roles are **data**, not * a code enum: admin/teachers define them (ADR-0017: role-based routing is * product config, not a spec invariant). `roleId` is an opaque string and - * doubles as the slash-command name (`/draft ...`) — the registry holds the - * role set, so new roles are added by configuration, not by editing code. + * is selected through the project console — the registry holds the role set, + * so new roles are added by configuration, not by editing code. * * A role bundles everything that distinguishes one agent persona from another: * model, system prompt, and the tool surface (files / cph / feishu / skills / diff --git a/hub/src/agent/runner.ts b/hub/src/agent/runner.ts index 586ead0..ba47e96 100644 --- a/hub/src/agent/runner.ts +++ b/hub/src/agent/runner.ts @@ -293,6 +293,9 @@ export async function runAgent(req: RunRequest): Promise { case "result": { const result = message as SDKResultMessage; 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; if (result.subtype !== "success") { error = `result_${result.subtype}`; diff --git a/hub/src/deployment/agent-config-cli.ts b/hub/src/deployment/agent-config-cli.ts index c1dc35e..9091209 100644 --- a/hub/src/deployment/agent-config-cli.ts +++ b/hub/src/deployment/agent-config-cli.ts @@ -47,6 +47,7 @@ async function main(argv: readonly string[]): Promise { : {}), ...(tools !== undefined ? { tools } : {}), ...(sortOrderRaw !== undefined ? { sortOrder: integer(sortOrderRaw, "sort-order") } : {}), + ...(options.has("default") ? { isDefault: boolean(options.get("default")!, "default") } : {}), }); console.log(JSON.stringify(role)); return; @@ -79,6 +80,7 @@ async function main(argv: readonly string[]): Promise { systemPromptConfigured: role.systemPrompt !== null, tools: role.tools, disabled: role.disabledAt !== null, + default: role.isDefault, skills: role.skillBindings.map((binding) => ({ name: binding.skill.name, version: binding.skill.version, @@ -138,10 +140,16 @@ function integer(raw: string, name: string): number { 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 { console.log(`Usage: 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 list --organization ORG agent-config verify-store --organization ORG`); diff --git a/hub/src/deployment/bootstrap-silo.ts b/hub/src/deployment/bootstrap-silo.ts index d33adda..e4bb51b 100644 --- a/hub/src/deployment/bootstrap-silo.ts +++ b/hub/src/deployment/bootstrap-silo.ts @@ -233,6 +233,7 @@ async function initializeSilo( roleId: "draft", label: "草稿", sortOrder: 10, + isDefault: true, }, { organizationId: input.organization.id, diff --git a/hub/src/deployment/legacy-project-import-cli.ts b/hub/src/deployment/legacy-project-import-cli.ts new file mode 100644 index 0000000..965912f --- /dev/null +++ b/hub/src/deployment/legacy-project-import-cli.ts @@ -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 { + 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 { + const options = new Map(); + 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, 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()); diff --git a/hub/src/deployment/legacyProjectImport.ts b/hub/src/deployment/legacyProjectImport.ts new file mode 100644 index 0000000..f452666 --- /dev/null +++ b/hub/src/deployment/legacyProjectImport.ts @@ -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>; +} + +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 { + const sourceRoot = await realpath(input.sourceRoot); + const state = await readState(input.stateFile); + const projects = { ...state.projects }; + const seen = new Set(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + 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 { + 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); +} diff --git a/hub/src/feishu/bindingLifecycle.ts b/hub/src/feishu/bindingLifecycle.ts new file mode 100644 index 0000000..8192eec --- /dev/null +++ b/hub/src/feishu/bindingLifecycle.ts @@ -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; +} diff --git a/hub/src/feishu/client.ts b/hub/src/feishu/client.ts index f758a51..d9d7b64 100644 --- a/hub/src/feishu/client.ts +++ b/hub/src/feishu/client.ts @@ -100,11 +100,19 @@ export interface CardActionEvent { readonly value?: unknown; readonly tag?: string; readonly option?: string; + readonly form_value?: Readonly>; }; readonly context?: { readonly open_message_id?: string; readonly open_chat_id?: 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 { readonly name?: string | undefined; readonly i18n_name?: { @@ -921,6 +929,7 @@ export async function startFeishuListenerWithClient( onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise, onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise, onTerminalError?: (error: Error) => void, + onBindingLifecycle?: (event: FeishuBindingLifecycleEvent) => Promise, ): Promise { let state: "STARTING" | "READY" | "FAILED" = "STARTING"; let resolveReady!: () => void; @@ -962,6 +971,14 @@ export async function startFeishuListenerWithClient( .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) }); let timeout: ReturnType | undefined; const startupTimeout = new Promise((_resolve, reject) => { @@ -976,6 +993,36 @@ export async function startFeishuListenerWithClient( return rt; } +async function dispatchBindingLifecycleEvent( + data: unknown, + reason: FeishuBindingLifecycleEvent["reason"], + onBindingLifecycle: (event: FeishuBindingLifecycleEvent) => Promise, + logger: FastifyBaseLogger, +): Promise { + 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( config: FeishuConfig, logger: FastifyBaseLogger, diff --git a/hub/src/feishu/identityNamespace.ts b/hub/src/feishu/identityNamespace.ts index 17156d5..6e4da27 100644 --- a/hub/src/feishu/identityNamespace.ts +++ b/hub/src/feishu/identityNamespace.ts @@ -66,7 +66,7 @@ export async function upsertScopedFeishuIdentityInTransaction( const principalId = scopedFeishuPrincipalId("USER", connectionId, openId); const userId = deterministicFeishuUserId(connectionId, openId); - const connection = await lockActiveConnection(prisma, connectionId); + const connection = await requireActiveFeishuApplicationConnectionInTransaction(prisma, connectionId); if (input.expectedOrganizationId !== undefined && input.expectedOrganizationId !== connection.organizationId) { throw new Error("Feishu identity Organization scope mismatch"); @@ -114,7 +114,7 @@ export async function resolveScopedFeishuIdentity( const connectionId = nonEmpty(input.connectionId, "Feishu connectionId"); const openId = nonEmpty(input.openId, "Feishu openId"); return prisma.$transaction(async (tx) => { - const connection = await lockActiveConnection(tx, connectionId); + const connection = await requireActiveFeishuApplicationConnectionInTransaction(tx, connectionId); if (input.expectedOrganizationId !== undefined && input.expectedOrganizationId !== connection.organizationId) { 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, connectionId: string, ): Promise<{ readonly organizationId: string }> { diff --git a/hub/src/feishu/messageBatcher.ts b/hub/src/feishu/messageBatcher.ts index 59afb37..a43e98f 100644 --- a/hub/src/feishu/messageBatcher.ts +++ b/hub/src/feishu/messageBatcher.ts @@ -47,8 +47,8 @@ export class MessageBatcher { this.extendedDebounceMs = options.extendedDebounceMs ?? DEFAULT_OPTIONS.extendedDebounceMs; } - async enqueue(chatId: string, senderOpenId: string, text: string): Promise { - const key = messageBatchKey(chatId, senderOpenId); + async enqueue(chatId: string, senderOpenId: string, text: string, discriminator?: string): Promise { + const key = messageBatchKey(chatId, senderOpenId, discriminator); await this.runSerial(key, async () => { const existing = this.pending.get(key); const batch = @@ -120,6 +120,8 @@ export class MessageBatcher { } } -export function messageBatchKey(chatId: string, senderOpenId: string): string { - return `${chatId}:${senderOpenId}`; +export function messageBatchKey(chatId: string, senderOpenId: string, discriminator?: string): string { + return discriminator === undefined + ? `${chatId}:${senderOpenId}` + : `${chatId}:${senderOpenId}:${discriminator}`; } diff --git a/hub/src/feishu/projectConsole.ts b/hub/src/feishu/projectConsole.ts new file mode 100644 index 0000000..7b27c5b --- /dev/null +++ b/hub/src/feishu/projectConsole.ts @@ -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 { + 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 { + 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)["claudeSessionId"]; + return typeof value === "string" && value !== ""; +} + +function isUserResumable(metadata: unknown): boolean { + return typeof metadata === "object" && metadata !== null && !Array.isArray(metadata) && + (metadata as Record)["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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const rows = await prisma.$queryRaw>(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; +} diff --git a/hub/src/feishu/projectOnboardingCard.ts b/hub/src/feishu/projectOnboardingCard.ts index 75f0657..24decf4 100644 --- a/hub/src/feishu/projectOnboardingCard.ts +++ b/hub/src/feishu/projectOnboardingCard.ts @@ -1,89 +1,61 @@ -export interface OnboardingProjectOption { - readonly projectId: string; - readonly name: string; - readonly folderName?: string | undefined; -} +import type { ProjectDiscoveryPage, ProjectFolderPage } from "../projectDiscovery.js"; -export interface OnboardingFolderOption { - readonly folderId: string; - readonly name: string; -} +export type ProjectOnboardingView = + | { readonly mode: "search"; readonly result: ProjectDiscoveryPage } + | { readonly mode: "browse"; readonly result: ProjectFolderPage }; 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 project_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: { readonly organizationId: string; readonly organizationName: string; - readonly folders: readonly OnboardingFolderOption[]; - readonly projects: readonly OnboardingProjectOption[]; readonly canCreateProject: boolean; + readonly view: ProjectOnboardingView; }): Record { - const actions: 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 } : {}), - }, - }, - }); - } + const elements: unknown[] = [{ + tag: "markdown", + content: onboardingSummary(params.organizationName, params.view), + }]; + + if (params.view.mode === "browse") { + appendFolderNavigation(elements, params.organizationId, params.view.result); } + 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); - 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, - }, - }, - }); + if (elements.length === 1) { + elements.push({ tag: "markdown", content: "当前目录没有可浏览内容。" }); } - - const elements: unknown[] = [ - { - tag: "markdown", - content: [ - `这个飞书群还没有绑定项目。`, - ``, - `组织: **${escapeMarkdown(params.organizationName)}**`, - `可以选择 folder 新建项目并绑定到本群,或绑定你已经有管理权限的未绑定项目。`, - ].join("\n"), - }, - ]; - - if (actions.length > 0) { - elements.push({ tag: "action", actions }); - } else { - elements.push({ - tag: "markdown", - content: "你当前没有可绑定项目,也没有新建项目权限。请联系组织管理员。", - }); - } - return { config: { wide_screen_mode: true }, - header: { - title: { tag: "plain_text", content: "绑定项目" }, - template: "blue", - }, + header: { title: { tag: "plain_text", content: "绑定项目" }, template: "blue" }, elements, }; } @@ -95,54 +67,461 @@ export function buildProjectOnboardingResolvedCard(params: { }): Record { return { config: { wide_screen_mode: true }, - header: { - title: { tag: "plain_text", content: params.title }, - template: params.template, - }, + header: { title: { tag: "plain_text", content: params.title }, template: params.template }, 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 { + 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 { + 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 { + 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 { const raw = unwrapValue(value); - if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; - if (!("project_onboarding" in raw)) return null; + if (typeof raw !== "object" || raw === null || Array.isArray(raw) || !("project_onboarding" in raw)) return null; const action = raw.project_onboarding; if (typeof action !== "object" || action === null || Array.isArray(action)) return null; - const rawAction = (action as { action?: unknown }).action; - const organizationId = (action as { organization_id?: unknown }).organization_id; - const projectId = (action as { project_id?: unknown }).project_id; - const folderId = (action as { folder_id?: unknown }).folder_id; - if ((rawAction !== "create_project_from_chat" && rawAction !== "bind_project") || typeof organizationId !== "string" || organizationId === "") { - return null; - } - if (rawAction === "bind_project" && (typeof projectId !== "string" || projectId === "")) { - return null; - } - if (folderId !== undefined && (typeof folderId !== "string" || folderId === "")) { - return null; - } + const fields = action as Record; + const rawAction = fields.action; + const organizationId = fields.organization_id; + const projectId = fields.project_id; + const folderId = fields.folder_id; + const searchQuery = fields.search_query; + const page = fields.page; + const agentRoleId = fields.agent_role_id; + const sessionId = fields.session_id; + if (!isAction(rawAction) || typeof organizationId !== "string" || organizationId === "") return null; + if ((rawAction === "bind_project" || rawAction === "rename_project") && (typeof projectId !== "string" || projectId === "")) 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 { action: rawAction, organization_id: organizationId, ...(typeof projectId === "string" && projectId !== "" ? { project_id: projectId } : {}), ...(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 { if (typeof value !== "string") return value; - try { - 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); + try { return JSON.parse(value); } catch { return value; } } function buttonLabel(label: string, maxLength: number): string { diff --git a/hub/src/feishu/slashCommands.ts b/hub/src/feishu/slashCommands.ts index 53e2c8c..2753b10 100644 --- a/hub/src/feishu/slashCommands.ts +++ b/hub/src/feishu/slashCommands.ts @@ -1,11 +1,6 @@ import type { PrismaClient } from "@prisma/client"; -import type { FastifyBaseLogger } from "fastify"; 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 type { TriggerQueue } from "./triggerQueue.js"; export interface SlashInvocation { readonly name: string; @@ -21,20 +16,13 @@ export interface SlashCommandRunContext { } export interface SlashCommandDefinition { - readonly name: string; + readonly name: "help" | "project" | "usage"; readonly usage: string; readonly summary: string; readonly details: readonly string[]; run(context: SlashCommandRunContext): Promise; } -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; export function parseSlashInvocation(prompt: string): SlashInvocation | null { @@ -43,166 +31,94 @@ export function parseSlashInvocation(prompt: string): SlashInvocation | null { const tokens = trimmed.split(/\s+/); const rawCommand = tokens[0]; if (rawCommand === undefined || rawCommand.length <= 1) return null; - return { - name: rawCommand.slice(1), - args: tokens.slice(1), - }; + return { name: rawCommand.slice(1), args: tokens.slice(1) }; } -export function parseSlashHelpSubcommand(invocation: SlashInvocation): string | null { - if (invocation.name === "help") return null; - return invocation.args.length === 1 && invocation.args[0] === "help" - ? invocation.name - : null; -} - -export function createSlashCommandRegistry(deps: SlashCommandRegistryDeps): ReadonlyMap { +export function createSlashCommandRegistry( + deps: { readonly prisma: PrismaClient }, +): ReadonlyMap { const commands = new Map(); const add = (command: SlashCommandDefinition): void => { - if (commands.has(command.name)) { - throw new Error(`duplicate slash command definition: /${command.name}`); - } + if (commands.has(command.name)) throw new Error(`duplicate slash command definition: /${command.name}`); commands.set(command.name, command); }; add({ name: "help", - usage: "/help [command]", - summary: "查看可用 slash 命令或单个命令说明。", - details: [ - "不创建 agent run,也不改变当前会话。", - "支持 /help new 和 /new help 两种写法。", - ], + usage: "/help [project|usage]", + summary: "查看 Hub 命令。", + details: ["未知 slash 命令会明确报错,不会发送给 Agent。"], + run: async ({ invocation, chatId, rt, sendOptions }) => { + 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 }) => { - const registry = await deps.settings.modelRegistry({ projectId }); - await sendText(rt, chatId, formatHelpCommandInvocation(invocation, registry, commands), 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); + const scope = invocation.args[0] ?? "current"; + if (invocation.args.length > 1 || (scope !== "current" && scope !== "project")) { + await sendText(rt, chatId, "用法: /usage [current|project]", sendOptions); return; } - await sendText(rt, chatId, "已恢复上一个会话。", sendOptions); - }, - }); - - 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 sessionIds = scope === "project" + ? undefined + : await currentRoleSessionIds(deps.prisma, projectId, chatId); const runs = await deps.prisma.agentRun.findMany({ where: { projectId, - sessionId: { in: sessions.map((session) => session.id) }, + ...(sessionIds === undefined ? {} : { sessionId: { in: sessionIds } }), status: { in: [...TERMINAL_RUN_STATUSES] }, finishedAt: { not: null }, }, orderBy: { finishedAt: "asc" }, - select: { - model: true, - provider: true, - inputTokens: true, - outputTokens: true, - costUsd: true, - }, + select: { model: true, provider: true, inputTokens: true, outputTokens: true, costUsd: true }, }); - - 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); + await sendText(rt, chatId, formatUsageReport(runs, scope), sendOptions); }, }); return commands; } -interface CostReportRun { +async function currentRoleSessionIds( + prisma: PrismaClient, + projectId: string, + chatId: string, +): Promise { + 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 provider: string; readonly inputTokens: number | null; @@ -210,225 +126,66 @@ interface CostReportRun { readonly costUsd: unknown; } -interface CostReportBucket { - readonly provider: string; - readonly model: string; - runs: number; - inputTokens: number; - outputTokens: number; - costUsd: number; -} - -function formatCostReport(runs: readonly CostReportRun[]): string { - if (runs.length === 0) { - return "当前会话还没有已结束的 agent run。"; - } - - const buckets = new Map(); +function formatUsageReport(runs: readonly UsageRun[], scope: "current" | "project"): string { + if (runs.length === 0) return `${scope === "current" ? "当前角色会话" : "当前项目"}还没有已结束的 Agent run。`; + const buckets = new Map(); let recordedRuns = 0; let unrecordedRuns = 0; let totalInputTokens = 0; let totalOutputTokens = 0; let totalCostUsd = 0; - for (const run of runs) { const costUsd = decimalToNumberOrNull(run.costUsd); - if (costUsd === null) { - unrecordedRuns++; - continue; - } - + if (costUsd === null) { unrecordedRuns++; continue; } recordedRuns++; - const inputTokens = run.inputTokens ?? 0; - const outputTokens = run.outputTokens ?? 0; - totalInputTokens += inputTokens; - totalOutputTokens += outputTokens; - totalCostUsd += costUsd; - const key = `${run.provider}\u0000${run.model}`; - let bucket = buckets.get(key); - if (bucket === undefined) { - bucket = { - provider: run.provider, - model: run.model, - runs: 0, - inputTokens: 0, - outputTokens: 0, - costUsd: 0, - }; - buckets.set(key, bucket); - } + const bucket = buckets.get(key) ?? { + provider: run.provider, model: run.model, runs: 0, inputTokens: 0, outputTokens: 0, costUsd: 0, + }; bucket.runs++; - bucket.inputTokens += inputTokens; - bucket.outputTokens += outputTokens; + bucket.inputTokens += run.inputTokens ?? 0; + bucket.outputTokens += run.outputTokens ?? 0; 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 = [ - "当前会话已记录 agent 成本:", - `总计: ${formatUsd(totalCostUsd)}`, - `Runs: ${formatInteger(recordedRuns)} 已记录${unrecordedRuns > 0 ? ` / ${formatInteger(unrecordedRuns)} 未记录` : ""}`, + `${scope === "current" ? "当前角色会话" : "当前项目"}用量`, + `已记录成本: ${formatInteger(recordedRuns)} runs · ${formatUsd(totalCostUsd)}`, `Tokens: input ${formatInteger(totalInputTokens)} / output ${formatInteger(totalOutputTokens)}`, - "", - "按模型:", ]; - - const sortedBuckets = [...buckets.values()].sort((a, b) => b.costUsd - a.costUsd); - for (const bucket of sortedBuckets) { - lines.push( - `- ${bucket.provider} / ${bucket.model}: ${formatInteger(bucket.runs)} runs, ${formatUsd(bucket.costUsd)}, input ${formatInteger(bucket.inputTokens)} / output ${formatInteger(bucket.outputTokens)}`, - ); + if (unrecordedRuns > 0) lines.push(`另有 ${formatInteger(unrecordedRuns)} 个 run 未记录成本。`); + for (const bucket of buckets.values()) { + lines.push(`- ${bucket.provider} / ${bucket.model}: ${formatInteger(bucket.runs)} runs, ${formatUsd(bucket.costUsd)}`); } - return lines.join("\n"); } -function formatHelpCommandInvocation( - invocation: SlashInvocation, - registry: ModelRegistry, - slashCommands: ReadonlyMap, -): 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( target: string, - registry: ModelRegistry, - slashCommands: ReadonlyMap, + commands: ReadonlyMap, ): string { - const normalizedTarget = normalizeHelpTarget(target); - if (normalizedTarget === "") return formatSlashOverview(registry, slashCommands); - return formatSlashCommandHelp(normalizedTarget, registry, slashCommands) ?? formatUnknownSlashHelp(normalizedTarget, registry, slashCommands); -} - -function normalizeHelpTarget(target: string): string { - return target.trim().replace(/^\/+/, ""); -} - -function formatSlashOverview( - registry: ModelRegistry, - slashCommands: ReadonlyMap, -): 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 | 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`; + const normalized = target.trim().replace(/^\/+/, ""); + const command = commands.get(normalized); + if (command === undefined) return [`未知 slash 命令 /${normalized}。`, "", formatSlashOverview(commands)].join("\n"); return [ `/${command.name}`, command.summary, "", `用法: ${command.usage}`, ...command.details.map((detail) => `- ${detail}`), - "", - helpUsage, ].join("\n"); } -function formatRoleSlashCommandHelp(role: RoleEntry): 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 { - const normalizedName = normalizeHelpTarget(commandName); +function formatSlashOverview(commands: ReadonlyMap): string { return [ - `未知 slash 命令 /${normalizedName}。`, + "可用 slash 命令:", + ...[...commands.values()].map((command) => `/${command.name} - ${command.summary}`), "", - formatSlashOverview(registry, slashCommands), + "普通消息会使用 /project 中选定的当前角色。", ].join("\n"); } - -function visibleRoleCommands( - registry: ModelRegistry, - slashCommands: ReadonlyMap, -): readonly RoleEntry[] { - return registry - .listRoles() - .filter((role) => !slashCommands.has(role.id)); -} diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index afbeca8..16fe5ee 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -52,22 +52,36 @@ import { type StagedMessageResourceBatch, } from "./resourceStaging.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 { bindFeishuChatToProject, createProjectFromFeishuChat, ensureOrganizationProjectSettings, + moveProjectToFolder, + renameProjectForActor, } from "../projectOnboarding.js"; import { + buildProjectManagementCard, + buildMoveProjectCard, + buildSessionHistoryCard, buildProjectOnboardingResolvedCard, buildUnboundChatOnboardingCard, projectOnboardingActionFromValue, - type OnboardingFolderOption, - type OnboardingProjectOption, type ProjectOnboardingActionValue, + type ProjectOnboardingView, } from "./projectOnboardingCard.js"; +import { + archiveCurrentRoleSession, + browseFolderDestinations, + createFolderAndMoveProject, + listRoleSessionHistory, + loadProjectConsole, + resumeRoleSession, + selectProjectGroupRole, +} from "./projectConsole.js"; import { SiloFixedWindowRateLimiter } from "../deployment/siloRateLimit.js"; +import { browseBindableFolder, discoverBindableProjects } from "../projectDiscovery.js"; export { ApprovalManager } from "./approval.js"; export type { ApprovalResult, PendingApproval } from "./approval.js"; @@ -111,6 +125,7 @@ interface TriggerRunContext { readonly projectId: string; readonly senderOpenId: string; readonly actor: TriggerActor; + readonly roleId: string; } type StartAgentRunOutcome = "started" | "queued" | "rejected" | "locked" | "skipped"; @@ -148,9 +163,6 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { : new SiloFixedWindowRateLimiter(deps.maxFeishuEventsPerMinute, 60_000); const slashCommands = createSlashCommandRegistry({ prisma: deps.prisma, - settings: deps.settings, - logger: deps.logger, - triggerQueue, }); const batchContexts = new Map(); // runId → AbortController for live runs. Registered when a run starts, @@ -176,10 +188,47 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { } }, deps.messageBatcherOptions); + async function projectConsoleCard( + projectId: string, + chatId: string, + actorOpenId: string, + title?: string, + ): Promise> { + 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( context: TriggerRunContext, cleanPrompt: string, - options: { readonly queueIfLocked?: boolean } = {}, + options: { readonly queueIfLocked?: boolean; readonly nativeSlash?: boolean } = {}, ): Promise { const { msg, rt, chatId, projectId, senderOpenId, actor } = context; const sendOptions = sendOptionsForTriggerMessage(msg); @@ -193,7 +242,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { return "rejected"; } 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({ @@ -205,15 +254,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { return "skipped"; } - // ADR-0017: role-as-data. Parse a leading `/` 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 { roleId: parsedRole, prompt: parsedAgentPrompt } = extractRole(cleanPrompt, models); - const roleId = parsedRole ?? "draft"; + const roleId = context.roleId; // ADR-0019: role trigger is the second gate after project agent.trigger. - // Unconfigured roles remain open for back-compat; configured roles require - // a matching active RoleTriggerGrant for any resolved principal. + // Configured roles require a matching active RoleTriggerGrant for any + // resolved principal; otherwise the role remains open within the project. const roleDecision = await authorizer.can({ actor, action: "role.trigger", @@ -260,8 +305,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { projectWorkspaceRoot, deps.resourceLimits, ); - const agentPrompt = appendStagedResourcePaths(parsedAgentPrompt, stagedResources, project.workspaceDir); - const promptForAgent = withFeishuTriggerContext(agentPrompt, feishuTriggerContext); + const agentPrompt = appendStagedResourcePaths(cleanPrompt, stagedResources, project.workspaceDir); + const promptForAgent = options.nativeSlash === true + ? cleanPrompt + : withFeishuTriggerContext(agentPrompt, feishuTriggerContext); // Linearization point for org lifecycle + session/run/lock admission. // FOR SHARE on the Organization row conflicts with a concurrent status @@ -621,10 +668,16 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { return "started"; } - async function enqueueLockedTrigger(context: TriggerRunContext, cleanPrompt: string): Promise { + async function enqueueLockedTrigger( + context: TriggerRunContext, + cleanPrompt: string, + executionKind: QueuedTrigger["executionKind"] = "conversation", + ): Promise { const position = triggerQueue.enqueue(context.projectId, { chatId: context.chatId, prompt: cleanPrompt, + roleId: context.roleId, + executionKind, msg: context.msg, senderOpenId: context.senderOpenId, actor: context.actor, @@ -662,7 +715,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { const next = triggerQueue.dequeue(projectId); 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 === "locked") { requeueTrigger(next); @@ -679,6 +735,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { projectId: trigger.projectId, senderOpenId: trigger.senderOpenId, actor: trigger.actor, + roleId: trigger.roleId, }; } @@ -686,6 +743,8 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { const position = triggerQueue.enqueue(trigger.projectId, { chatId: trigger.chatId, prompt: trigger.prompt, + roleId: trigger.roleId, + executionKind: trigger.executionKind, msg: trigger.msg, senderOpenId: trigger.senderOpenId, actor: trigger.actor, @@ -754,21 +813,257 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { } 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") { const name = defaultProjectNameForChat(chatId); const project = await createProjectFromFeishuChat(deps.prisma, { - organizationId: action.organization_id, + organizationId: organization.organizationId, actorFeishuOpenId: operatorOpenId, chatId, name, workspaceRoot: projectWorkspaceRoot, folderId: action.folder_id, }); - await resolveProjectOnboardingCard(rt, messageId, { - title: "已创建并绑定项目", - body: `项目 **${escapeCardMarkdown(name)}** 已绑定到本群。后续 @bot 会进入这个项目的 session。`, - template: "green", - }); + if (messageId !== undefined) { + await patchCard(rt, messageId, await projectConsoleCard( + project.projectId, + chatId, + operatorOpenId, + "已创建并绑定项目", + )); + } deps.logger.info({ chatId, projectId: project.projectId, operatorOpenId }, "project onboarding: created project from chat"); return; } @@ -777,16 +1072,27 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { if (projectId === undefined) { 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, { projectId, actorFeishuOpenId: operatorOpenId, chatId, }); - await resolveProjectOnboardingCard(rt, messageId, { - title: "已绑定项目", - body: `本群已绑定到项目 \`${project.projectId}\`。后续 @bot 会进入这个项目的 session。`, - template: "green", - }); + if (messageId !== undefined) { + await patchCard(rt, messageId, await projectConsoleCard( + project.projectId, + chatId, + operatorOpenId, + "已绑定项目", + )); + } deps.logger.info({ chatId, projectId: project.projectId, operatorOpenId }, "project onboarding: bound existing project"); } catch (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({ where: { chatId, archivedAt: null }, - select: { projectId: true }, + select: { projectId: true, selectedRole: { select: { roleId: true } } }, }); if (binding === null) { 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. const binding = await deps.prisma.projectGroupBinding.findFirst({ where: { chatId, archivedAt: null }, - select: { projectId: true }, + select: { projectId: true, selectedRole: { select: { roleId: true } } }, }); if (binding === null) { 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; - 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 - // batcher. Session commands bypass the lock because they don't create runs; - // role/unknown slash prompts still use the normal run path and queue if locked. + // Hub owns a closed slash-command protocol. Unknown commands fail visibly; + // they are never downgraded to Agent text or forwarded to the SDK. if (cleanPrompt.startsWith("/")) { const invocation = parseSlashInvocation(cleanPrompt); - const helpSubcommandTarget = invocation === null ? null : parseSlashHelpSubcommand(invocation); - if (helpSubcommandTarget !== null) { - // Help is generated on demand because role/tool configuration is runtime data. - const models = await deps.settings.modelRegistry({ projectId }); - await sendText(rt, chatId, formatSlashHelpTarget(helpSubcommandTarget, models, slashCommands), sendOptionsForTriggerMessage(msg)); - return; - } - if (invocation !== null) { + if (invocation.name === "project") { + if (invocation.args.length > 0) { + await sendText(rt, chatId, "用法: /project", 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; + } const slashCommand = slashCommands.get(invocation.name); if (slashCommand !== undefined) { try { @@ -1026,8 +1345,8 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { return; } } - - await startAgentRun(runContext, cleanPrompt); + const name = invocation?.name ?? cleanPrompt.slice(1).trim(); + await sendText(rt, chatId, `未知 slash 命令 /${name}。使用 /help 查看可用命令。`, sendOptionsForTriggerMessage(msg)); return; } @@ -1041,11 +1360,20 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { await enqueueLockedTrigger(runContext, cleanPrompt); 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)) { batchContexts.set(key, runContext); } - await messageBatcher.enqueue(chatId, senderOpenId, cleanPrompt); + await messageBatcher.enqueue(chatId, senderOpenId, cleanPrompt, runContext.roleId); return; } @@ -1070,12 +1398,22 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { const settings = await ensureOrganizationProjectSettings(deps.prisma, organization.organizationId); const canCreateProject = settings.membersCanCreateProjects || isOrgAdminRole(organization.role); - const projects = await listBindableProjectsForActor({ - organizationId: organization.organizationId, - actorFeishuOpenId: senderOpenId, - isOrgAdmin: isOrgAdminRole(organization.role), - }); - const folders = await listCreatableRootFolders(organization.organizationId); + const searchQuery = (extractPrompt(msg) ?? "").trim().slice(0, 100); + const view = searchQuery === "" + ? await browseOnboardingView({ + organizationId: organization.organizationId, + actorFeishuOpenId: senderOpenId, + isOrgAdmin: isOrgAdminRole(organization.role), + folderId: null, + page: 1, + }) + : await searchOnboardingView({ + organizationId: organization.organizationId, + actorFeishuOpenId: senderOpenId, + isOrgAdmin: isOrgAdminRole(organization.role), + query: searchQuery, + page: 1, + }); await sendCard( rt, @@ -1083,9 +1421,8 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { buildUnboundChatOnboardingCard({ organizationId: organization.organizationId, organizationName: organization.organizationName, - folders, - projects, canCreateProject, + view, }), sendOptionsForTriggerMessage(msg), ); @@ -1178,53 +1515,44 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { }; } - async function listBindableProjectsForActor(input: { + async function searchOnboardingView(input: { readonly organizationId: string; readonly actorFeishuOpenId: string; readonly isOrgAdmin: boolean; - }): Promise { - const candidates = await deps.prisma.project.findMany({ - where: { + readonly query: string; + readonly page: number; + }): Promise { + return { + mode: "search", + result: await discoverBindableProjects({ + prisma: deps.prisma, organizationId: input.organizationId, - archivedAt: null, - groupBindings: { none: { archivedAt: null } }, - }, - select: { - 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; + actorFeishuOpenId: input.actorFeishuOpenId, + isOrgAdmin: input.isOrgAdmin, + query: input.query, + page: input.page, + }), + }; } - async function listCreatableRootFolders(organizationId: string): Promise { - const folders = await deps.prisma.folder.findMany({ - where: { organizationId, parentId: null, archivedAt: null }, - select: { id: true, name: true }, - orderBy: [{ sortKey: "asc" }, { name: "asc" }], - take: 3, - }); - return folders.map((folder) => ({ folderId: folder.id, name: folder.name })); + async function browseOnboardingView(input: { + readonly organizationId: string; + readonly actorFeishuOpenId: string; + readonly isOrgAdmin: boolean; + readonly folderId: string | null; + readonly page: number; + }): Promise { + 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 }); @@ -1488,10 +1816,6 @@ function shortId(value: string): string { 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"}`. */ const TextMessageContentSchema = z.object({ text: z.string() }); @@ -1634,27 +1958,3 @@ async function compensateFailedResourceAdmission( ); } } - -/** - * Parse a leading `/` 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) }; -} diff --git a/hub/src/feishu/triggerQueue.ts b/hub/src/feishu/triggerQueue.ts index ae86f8c..f1475e3 100644 --- a/hub/src/feishu/triggerQueue.ts +++ b/hub/src/feishu/triggerQueue.ts @@ -4,6 +4,9 @@ export interface QueuedTrigger { readonly projectId: string; readonly chatId: 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 senderOpenId: string; readonly actor: { readonly feishuOpenId: string; readonly chatId: string }; diff --git a/hub/src/hub.ts b/hub/src/hub.ts index bc24897..a165496 100644 --- a/hub/src/hub.ts +++ b/hub/src/hub.ts @@ -2,6 +2,7 @@ import Fastify from "fastify"; import { registerAdminPlugin } from "./admin/plugin.js"; import { prisma } from "./db.js"; import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js"; +import { archiveFeishuBindingForLifecycleEvent } from "./feishu/bindingLifecycle.js"; import { makeTriggerHandler } from "./feishu/trigger.js"; import { removeAbandonedMessageResourceStages } from "./feishu/resourceStaging.js"; import { triggerQueue } from "./feishu/triggerQueue.js"; @@ -177,6 +178,10 @@ export async function startHub(): Promise { process.exitCode = 1; 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 { app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED"); diff --git a/hub/src/org/explorer.ts b/hub/src/org/explorer.ts index d286be2..a7842eb 100644 --- a/hub/src/org/explorer.ts +++ b/hub/src/org/explorer.ts @@ -4,7 +4,7 @@ * Folders are transparent navigation nodes (not ACL resources). Project grants * 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 { archiveFeishuChatBinding, createFolder, @@ -123,11 +123,25 @@ export async function renameFolder( return prisma.$transaction(async (tx) => { await lockActiveOrganization(tx, 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 === folder.id) { throw new Error("folder cannot be its own parent"); } await requireActiveFolder(tx, input.parentId, input.organizationId); + const descendant = await tx.$queryRaw>(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 = input.name !== undefined ? requireNonEmpty(input.name, "folder name") : undefined; @@ -152,6 +166,7 @@ export async function archiveFolder( return prisma.$transaction(async (tx) => { await lockActiveOrganization(tx, 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({ where: { parentId: folder.id, archivedAt: null }, }); @@ -324,10 +339,10 @@ async function requireActiveFolder( prisma: PrismaClient | Prisma.TransactionClient, folderId: 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({ 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) { throw new Error(`active folder not found: ${folderId}`); diff --git a/hub/src/projectDiscovery.ts b/hub/src/projectDiscovery.ts new file mode 100644 index 0000000..04ff0db --- /dev/null +++ b/hub/src/projectDiscovery.ts @@ -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 { + 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 { + 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 { + const access = accessPredicate(manageableIds); + const offset = (page - 1) * pageSize; + if (normalizedQuery === "") { + return prisma.$queryRaw(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(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 { + const access = accessPredicate(manageableIds); + const queryPredicate = normalizedQuery === "" ? Prisma.empty : Prisma.sql`AND ${searchPredicate(normalizedQuery, tokens)}`; + const rows = await prisma.$queryRaw>(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 { + 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 { + const rows = await prisma.$queryRaw>(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; +} diff --git a/hub/src/projectOnboarding.ts b/hub/src/projectOnboarding.ts index bf65864..f9d31f6 100644 --- a/hub/src/projectOnboarding.ts +++ b/hub/src/projectOnboarding.ts @@ -27,6 +27,8 @@ export interface CreateOrgAdminProjectInput { readonly workspaceRoot: string; readonly folderId?: string | undefined; readonly sortKey?: string | undefined; + /** Stable internal identifier for resumable imports; ordinary callers must omit it. */ + readonly projectId?: string | undefined; } export interface CreateFeishuChatProjectInput { @@ -51,6 +53,13 @@ export interface ArchiveFeishuChatBindingInput { readonly actorFeishuOpenId: string; } +export interface RenameProjectForActorInput { + readonly organizationId: string; + readonly projectId: string; + readonly actorFeishuOpenId: string; + readonly name: string; +} + export interface CreateFolderInput { readonly organizationId: 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( prisma: PrismaClient, input: CreateOrgAdminProjectInput, @@ -147,6 +195,7 @@ export async function createProjectFromOrgAdmin( workspaceRoot: input.workspaceRoot, folderId: input.folderId, sortKey: input.sortKey, + projectId: input.projectId, chatId: undefined, }); } @@ -195,6 +244,7 @@ export async function bindFeishuChatToProject( const actor = await requireProjectManager(prisma, project.id, project.organizationId, input.actorFeishuOpenId); await prisma.$transaction(async (tx) => { await requireActiveOrganizationTx(tx, project.organizationId); + const defaultRole = await requireDefaultAgentRole(tx, project.organizationId); const activeProjectBinding = await tx.projectGroupBinding.findFirst({ where: { projectId: project.id, archivedAt: null }, select: { chatId: true }, @@ -210,7 +260,13 @@ export async function bindFeishuChatToProject( throw new Error(`Feishu chat ${chatId} is already bound to project ${activeChatBinding.projectId}`); } 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, { projectId: project.id, @@ -291,6 +347,7 @@ async function createManagedProject( readonly workspaceRoot: string; readonly folderId: string | undefined; readonly sortKey?: string | undefined; + readonly projectId?: string | undefined; readonly chatId: string | undefined; }, ): Promise { @@ -301,7 +358,7 @@ async function createManagedProject( if (organization === null) throw new Error(`organization not found: ${input.organizationId}`); requireActiveOrganizationStatus(organization.id, organization.status); - const projectId = createProjectId(); + const projectId = input.projectId ?? createProjectId(); const workspaceDir = projectWorkspaceDir({ workspaceRoot: input.workspaceRoot, organizationSlug: organization.slug, @@ -341,8 +398,15 @@ async function createManagedProject( createdByUserId: input.actorUserId, }); if (input.chatId !== undefined) { + const defaultRole = await requireDefaultAgentRole(tx, organization.id); 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, { projectId: created.id, @@ -490,12 +554,12 @@ async function ensureOrganizationProjectSettingsTx( async function ensureInboxFolder(prisma: Prisma.TransactionClient, organizationId: string): Promise<{ readonly id: string }> { const existing = await prisma.folder.findFirst({ - where: { organizationId, parentId: null, name: "Inbox", archivedAt: null }, + where: { organizationId, kind: "SYSTEM_INBOX", archivedAt: null }, select: { id: true }, }); if (existing !== null) return existing; return prisma.folder.create({ - data: { organizationId, name: "Inbox", sortKey: "000000" }, + data: { organizationId, name: "Inbox", kind: "SYSTEM_INBOX", sortKey: "000000" }, 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 { const organization = await prisma.organization.findUnique({ where: { id: organizationId }, diff --git a/hub/src/settings/runtime.ts b/hub/src/settings/runtime.ts index 4b68640..c9a06b7 100644 --- a/hub/src/settings/runtime.ts +++ b/hub/src/settings/runtime.ts @@ -177,6 +177,10 @@ export class DatabaseRuntimeSettings implements RuntimeSettings { if (project.organization.agentRoles.length === 0) { 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 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); } diff --git a/hub/test/integration/admin-explorer.test.ts b/hub/test/integration/admin-explorer.test.ts index 1aa6ef0..2968545 100644 --- a/hub/test/integration/admin-explorer.test.ts +++ b/hub/test/integration/admin-explorer.test.ts @@ -206,7 +206,12 @@ describe("admin explorer API", () => { it("blocks cross-org project access by id", async () => { const token = await seedAdmin(); 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({ 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"); + }); }); diff --git a/hub/test/integration/agent-configuration.test.ts b/hub/test/integration/agent-configuration.test.ts index 384d614..9568e37 100644 --- a/hub/test/integration/agent-configuration.test.ts +++ b/hub/test/integration/agent-configuration.test.ts @@ -60,7 +60,10 @@ describe("Organization Agent configuration management", () => { expect(role.tools).toEqual(["read_file", "write_file", "cph_build"]); expect(role.skillBindings.map((binding) => binding.skill.name)).toEqual(["outline", "typst"]); 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 () => { @@ -81,6 +84,40 @@ describe("Organization Agent configuration management", () => { })).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 { const source = join(parent, "sources", name); await mkdir(source, { recursive: true }); diff --git a/hub/test/integration/agent-runtime-config.test.ts b/hub/test/integration/agent-runtime-config.test.ts index 2664d4c..308cadb 100644 --- a/hub/test/integration/agent-runtime-config.test.ts +++ b/hub/test/integration/agent-runtime-config.test.ts @@ -42,22 +42,18 @@ describe("Organization-scoped Agent runtime configuration", () => { }), ]); const [roleA, roleB] = await Promise.all([ - prisma.organizationAgentRole.create({ + prisma.organizationAgentRole.update({ + where: { organizationId_roleId: { organizationId: DEFAULT_ORG_ID, roleId: "draft" } }, data: { - id: "role-a", - organizationId: DEFAULT_ORG_ID, - roleId: "draft", label: "A Draft", defaultModel: "anthropic/claude-sonnet-5", systemPrompt: "prompt-a", tools: ["read_file", "cph_build"], }, }), - prisma.organizationAgentRole.create({ + prisma.organizationAgentRole.update({ + where: { organizationId_roleId: { organizationId: "org_other", roleId: "draft" } }, data: { - id: "role-b", - organizationId: "org_other", - roleId: "draft", label: "B Draft", systemPrompt: "prompt-b", tools: [], @@ -105,8 +101,8 @@ describe("Organization-scoped Agent runtime configuration", () => { disabledAt: new Date(), }, }); - const role = await prisma.organizationAgentRole.create({ - data: { id: "role-a", organizationId: DEFAULT_ORG_ID, roleId: "draft", label: "Draft" }, + const role = await prisma.organizationAgentRole.findUniqueOrThrow({ + where: { organizationId_roleId: { organizationId: DEFAULT_ORG_ID, roleId: "draft" } }, }); await prisma.organizationAgentRoleSkill.create({ data: { organizationId: DEFAULT_ORG_ID, agentRoleId: role.id, agentSkillId: skill.id }, diff --git a/hub/test/integration/feishu-binding-lifecycle.test.ts b/hub/test/integration/feishu-binding-lifecycle.test.ts new file mode 100644 index 0000000..278b1df --- /dev/null +++ b/hub/test/integration/feishu-binding-lifecycle.test.ts @@ -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 { + 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(), + }, + }); +} diff --git a/hub/test/integration/helpers.ts b/hub/test/integration/helpers.ts index 54b7974..e68f4b1 100644 --- a/hub/test/integration/helpers.ts +++ b/hub/test/integration/helpers.ts @@ -34,41 +34,20 @@ export const prisma = new PrismaClient({ /** Truncate all tables before each test for isolation. */ export async function resetDb(): Promise { - const tables = [ - "OrganizationAgentRoleSkill", - "OrganizationAgentRole", - "OrganizationAgentSkill", - "FeishuEventReceipt", - "FeishuUserIdentity", - "FeishuApplicationCredentialVersion", - "OrganizationFeishuApplicationConnection", - "ProviderCredentialVersion", - "OrganizationProviderConnection", - "AgentFileChange", - "AgentMessage", - "AuditEntry", - "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`); + // User and Organization are the aggregate roots for all domain rows; their + // declared FK cascades clear projects, search documents, permissions, + // sessions and connections without repeatedly truncating pg_trgm indexes. + // Event receipts and global audit rows are independent roots. + await prisma.$transaction([ + prisma.feishuEventReceipt.deleteMany(), + prisma.auditEntry.deleteMany(), + // Permission resource ids are intentionally polymorphic strings, so these + // two tables have no FK to Project and must be cleared explicitly. + prisma.permissionGrant.deleteMany(), + prisma.permissionSettings.deleteMany(), + prisma.user.deleteMany(), + prisma.organization.deleteMany(), + ]); await seedTestOrganization(); } @@ -76,22 +55,36 @@ export async function seedTestOrganization( id: string = DEFAULT_ORG_ID, slug: string = "test-default", ): Promise { - await prisma.organization.upsert({ - where: { id }, - update: {}, - create: { - id, - slug, - name: "Test Default Organization", - }, - }); - await prisma.organizationProjectSettings.upsert({ - where: { organizationId: id }, - update: {}, - create: { organizationId: id, membersCanCreateProjects: true }, + await prisma.$transaction(async (tx) => { + await tx.organization.upsert({ + where: { id }, + update: {}, + create: { id, slug, name: "Test Default Organization" }, + }); + await tx.organizationProjectSettings.upsert({ + where: { organizationId: id }, + update: {}, + 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({ - where: { organizationId: id, parentId: null, name: "Inbox", archivedAt: null }, + where: { organizationId: id, kind: "SYSTEM_INBOX", archivedAt: null }, select: { id: true }, }); if (inbox === null) { @@ -100,6 +93,7 @@ export async function seedTestOrganization( id: `folder_inbox_${id}`, organizationId: id, name: "Inbox", + kind: "SYSTEM_INBOX", sortKey: "000000", }, }); @@ -420,6 +414,12 @@ export async function seedProject( }, }); 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}`, + }, }); } diff --git a/hub/test/integration/legacy-project-import.test.ts b/hub/test/integration/legacy-project-import.test.ts new file mode 100644 index 0000000..1973f8f --- /dev/null +++ b/hub/test/integration/legacy-project-import.test.ts @@ -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 { + const root = await mkdtemp(join(tmpdir(), prefix)); + temporaryRoots.push(root); + return root; +} diff --git a/hub/test/integration/project-discovery.test.ts b/hub/test/integration/project-discovery.test.ts new file mode 100644 index 0000000..1cec78b --- /dev/null +++ b/hub/test/integration/project-discovery.test.ts @@ -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 { + 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, + }); +} diff --git a/hub/test/integration/project-onboarding.test.ts b/hub/test/integration/project-onboarding.test.ts index 74cd5d1..0866b66 100644 --- a/hub/test/integration/project-onboarding.test.ts +++ b/hub/test/integration/project-onboarding.test.ts @@ -262,6 +262,30 @@ describe("ADR-0021 project onboarding", () => { }); 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 { diff --git a/hub/test/integration/silo-bootstrap.test.ts b/hub/test/integration/silo-bootstrap.test.ts index e611148..a5e357e 100644 --- a/hub/test/integration/silo-bootstrap.test.ts +++ b/hub/test/integration/silo-bootstrap.test.ts @@ -23,6 +23,7 @@ describe("Alpha Silo bootstrap", () => { id: "org_default", slug: "legacy-default", name: "Legacy Default Organization", + agentRoles: { create: { roleId: "draft", label: "草稿", isDefault: true } }, projectSettings: { create: { membersCanCreateProjects: true } }, folders: { create: { @@ -56,10 +57,10 @@ describe("Alpha Silo bootstrap", () => { await expect(prisma.organizationAgentRole.findMany({ where: { organizationId: "org_alpha", disabledAt: null }, orderBy: { sortOrder: "asc" }, - select: { roleId: true, label: true }, + select: { roleId: true, label: true, isDefault: true }, })).resolves.toEqual([ - { roleId: "draft", label: "草稿" }, - { roleId: "review", label: "审校" }, + { roleId: "draft", label: "草稿", isDefault: true }, + { roleId: "review", label: "审校", isDefault: false }, ]); const persisted = JSON.stringify({ diff --git a/hub/test/integration/trigger.test.ts b/hub/test/integration/trigger.test.ts index e70f840..e303f7c 100644 --- a/hub/test/integration/trigger.test.ts +++ b/hub/test/integration/trigger.test.ts @@ -13,7 +13,6 @@ import { silentLogger, } from "./helpers.js"; import { InMemoryModelRegistry } from "../../src/agent/models.js"; -import { createSlashCommandRegistry } from "../../src/feishu/slashCommands.js"; import { makeTriggerHandler as makeProductionTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js"; import { TriggerQueue } from "../../src/feishu/triggerQueue.js"; import type { MessageReceiveEvent, CardActionEvent } from "../../src/feishu/client.js"; @@ -249,19 +248,18 @@ describe("trigger full lifecycle (integration)", () => { projectWorkspaceRoot: await tempWorkspaceRoot(), }); - await trigger(makeEvent("chat-unbound-card", "@_user_1 开始项目", "ou_onboard_card"), rt); + await trigger(makeEvent("chat-unbound-card", "@_user_1", "ou_onboard_card"), rt); expect(rt.sentCards).toHaveLength(1); expect(rt.sentTexts.at(-1)).toContain("这个飞书群还没有绑定项目"); const values = cardActionValues(rt.sentCards[0]); expect(values).toEqual(expect.arrayContaining([ - expect.objectContaining({ - project_onboarding: expect.objectContaining({ + { + project_onboarding: { action: "create_project_from_chat", organization_id: DEFAULT_ORG_ID, - folder_id: expect.any(String), - }), - }), + }, + }, ])); expect(runAgentCalls).toHaveLength(0); }); @@ -297,6 +295,80 @@ describe("trigger full lifecycle (integration)", () => { { principalType: "FEISHU_CHAT", principalId: "chat-onboard-create", role: "EDIT" }, ])); expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("已创建并绑定项目"); + expect(JSON.stringify(rt.sentPatches.at(-1))).toContain("project_rename_form"); + }); + + it("opens project management at any time and renames through a validated card form", async () => { + await seedProject("project-management", "chat-management", { role: "MANAGE" }); + const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent }); + + await trigger(makeEvent("chat-management", "@_user_1 /project"), rt); + + expect(cardHeaderTitle(rt.sentCards.at(-1))).toBe("项目管理"); + expect(JSON.stringify(rt.sentCards.at(-1))).toContain("project_rename_form"); + + await trigger.onCardAction(makeOnboardingEvent("chat-management", { + project_onboarding: { + action: "rename_project", + organization_id: DEFAULT_ORG_ID, + project_id: "project-management", + }, + }, "ou_test_user", { project_name: "表面张力课程" }), rt); + + await expect(prisma.project.findUniqueOrThrow({ where: { id: "project-management" } })) + .resolves.toMatchObject({ name: "表面张力课程" }); + await expect(prisma.auditEntry.findFirstOrThrow({ + where: { projectId: "project-management", action: "project.renamed" }, + })).resolves.toMatchObject({ + metadata: { oldName: "Test project-management", newName: "表面张力课程" }, + }); + expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("项目名称已更新"); + }); + + it("offers folder creation only inside move flow and atomically moves into the new folder", async () => { + await seedProject("project-folder-flow", "chat-folder-flow", { role: "MANAGE" }); + await prisma.organizationMembership.updateMany({ + where: { organizationId: DEFAULT_ORG_ID, userId: "u_project-folder-flow", revokedAt: null }, + data: { role: "ADMIN" }, + }); + const parent = await prisma.folder.create({ + data: { organizationId: DEFAULT_ORG_ID, name: "课程目录" }, + }); + const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent }); + + await trigger(makeEvent("chat-folder-flow", "@_user_1 /project"), rt); + expect(JSON.stringify(rt.sentCards.at(-1))).not.toContain("folder_create_form"); + + await trigger.onCardAction(makeOnboardingEvent("chat-folder-flow", { + project_onboarding: { + action: "browse_move_destination", + organization_id: DEFAULT_ORG_ID, + project_id: "project-folder-flow", + folder_id: parent.id, + }, + }, "ou_test_user"), rt); + const moveCard = JSON.stringify(rt.sentPatches.at(-1)); + expect(moveCard).toContain("folder_create_form"); + expect(moveCard).toContain("新建并移动"); + + await trigger.onCardAction(makeOnboardingEvent("chat-folder-flow", { + project_onboarding: { + action: "create_folder", + organization_id: DEFAULT_ORG_ID, + project_id: "project-folder-flow", + folder_id: parent.id, + }, + }, "ou_test_user", { folder_name: "力学单元" }), rt); + + const folder = await prisma.folder.findFirstOrThrow({ + where: { organizationId: DEFAULT_ORG_ID, parentId: parent.id, name: "力学单元" }, + }); + await expect(prisma.project.findUniqueOrThrow({ where: { id: "project-folder-flow" } })) + .resolves.toMatchObject({ folderId: folder.id }); + await expect(prisma.auditEntry.findFirstOrThrow({ + where: { projectId: "project-folder-flow", action: "folder.created_and_project_moved_from_feishu" }, + })).resolves.toMatchObject({ metadata: expect.objectContaining({ folderId: folder.id, parentFolderId: parent.id }) }); + expect(cardHeaderTitle(rt.sentPatches.at(-1))).toContain("已新建目录"); }); it("binds an existing manageable project from the unbound-chat onboarding card", async () => { @@ -361,6 +433,226 @@ describe("trigger full lifecycle (integration)", () => { expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("已绑定项目"); }); + it("rejects a forged bind card targeting a project in another organization", async () => { + await seedOnboardingUser("u-onboard-cross-org", "ou_onboard_cross_org", "MEMBER"); + await seedTestOrganization("org-other-card", "other-card"); + await prisma.project.create({ + data: { + id: "project-other-card", + organizationId: "org-other-card", + name: "Other project", + workspaceDir: join(await tempWorkspaceRoot(), "project-other-card"), + }, + }); + await prisma.permissionGrant.create({ + data: { + resourceType: "PROJECT", + resourceId: "project-other-card", + principalType: "USER", + principalId: "ou_onboard_cross_org", + role: "MANAGE", + }, + }); + const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent }); + + await trigger.onCardAction(makeOnboardingEvent("chat-cross-org", { + project_onboarding: { + action: "bind_project", + organization_id: DEFAULT_ORG_ID, + project_id: "project-other-card", + }, + }, "ou_onboard_cross_org"), rt); + + await expect(prisma.projectGroupBinding.count({ where: { chatId: "chat-cross-org" } })).resolves.toBe(0); + expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("绑定失败"); + }); + + it("uses unbound-chat mention text to search bindable projects", async () => { + await seedOnboardingUser("u-onboard-search", "ou_onboard_search", "OWNER"); + const folder = await prisma.folder.create({ + data: { organizationId: DEFAULT_ORG_ID, name: "物理竞赛" }, + }); + await prisma.project.createMany({ + data: [ + { + id: "p-search-newton", + organizationId: DEFAULT_ORG_ID, + folderId: folder.id, + name: "牛顿力学专题", + workspaceDir: join(await tempWorkspaceRoot(), "p-search-newton"), + }, + { + id: "p-search-optics", + organizationId: DEFAULT_ORG_ID, + folderId: folder.id, + name: "几何光学专题", + workspaceDir: join(await tempWorkspaceRoot(), "p-search-optics"), + }, + ], + }); + const trigger = makeTriggerHandler({ + prisma, + settings, + logger: silentLogger, + runAgent, + projectWorkspaceRoot: await tempWorkspaceRoot(), + }); + + await trigger(makeEvent("chat-onboard-search", "@_user_1 牛顿", "ou_onboard_search"), rt); + + expect(cardActionValues(rt.sentCards[0])).toContainEqual({ + project_onboarding: { + action: "bind_project", + organization_id: DEFAULT_ORG_ID, + project_id: "p-search-newton", + }, + }); + expect(cardActionValues(rt.sentCards[0])).not.toContainEqual(expect.objectContaining({ + project_onboarding: expect.objectContaining({ project_id: "p-search-optics" }), + })); + expect(JSON.stringify(rt.sentCards[0])).toContain("牛顿"); + expect(runAgentCalls).toHaveLength(0); + }); + + it("paginates every manageable search result through card actions", async () => { + await seedOnboardingUser("u-onboard-pages", "ou_onboard_pages", "OWNER"); + const workspaceRoot = await tempWorkspaceRoot(); + await prisma.project.createMany({ + data: Array.from({ length: 9 }, (_, index) => ({ + id: `project-page-${index}`, + organizationId: DEFAULT_ORG_ID, + name: `分页项目-${index}`, + workspaceDir: join(workspaceRoot, `project-page-${index}`), + })), + }); + const trigger = makeTriggerHandler({ + prisma, + settings, + logger: silentLogger, + runAgent, + projectWorkspaceRoot: workspaceRoot, + }); + + await trigger(makeEvent("chat-onboard-pages", "@_user_1 分页项目", "ou_onboard_pages"), rt); + + const firstPageValues = cardActionValues(rt.sentCards[0]); + expect(firstPageValues.filter((value) => JSON.stringify(value).includes('"bind_project"'))).toHaveLength(8); + expect(firstPageValues).toContainEqual({ + project_onboarding: { + action: "search_page", + organization_id: DEFAULT_ORG_ID, + search_query: "分页项目", + page: 2, + }, + }); + + await trigger.onCardAction(makeOnboardingEvent("chat-onboard-pages", { + project_onboarding: { + action: "search_page", + organization_id: DEFAULT_ORG_ID, + search_query: "分页项目", + page: 2, + }, + }, "ou_onboard_pages"), rt); + + const secondPageValues = cardActionValues(rt.sentPatches.at(-1)); + expect(secondPageValues.filter((value) => JSON.stringify(value).includes('"bind_project"'))).toHaveLength(1); + expect(JSON.stringify(rt.sentPatches.at(-1))).toContain("第 **2/2** 页"); + }); + + it("navigates the preserved folder tree from the onboarding card", async () => { + await seedOnboardingUser("u-onboard-folders", "ou_onboard_folders", "OWNER"); + const root = await prisma.folder.create({ + data: { id: "folder-legacy-root", organizationId: DEFAULT_ORG_ID, name: "旧教学资产" }, + }); + await prisma.folder.create({ + data: { id: "folder-physics-child", organizationId: DEFAULT_ORG_ID, parentId: root.id, name: "物理竞赛教研" }, + }); + const trigger = makeTriggerHandler({ + prisma, + settings, + logger: silentLogger, + runAgent, + projectWorkspaceRoot: await tempWorkspaceRoot(), + }); + + await trigger(makeEvent("chat-onboard-folders", "@_user_1", "ou_onboard_folders"), rt); + expect(cardActionValues(rt.sentCards[0])).toContainEqual({ + project_onboarding: { + action: "browse_folder", + organization_id: DEFAULT_ORG_ID, + folder_id: root.id, + page: 1, + }, + }); + + await trigger.onCardAction(makeOnboardingEvent("chat-onboard-folders", { + project_onboarding: { + action: "browse_folder", + organization_id: DEFAULT_ORG_ID, + folder_id: root.id, + page: 1, + }, + }, "ou_onboard_folders"), rt); + + expect(JSON.stringify(rt.sentPatches.at(-1))).toContain("旧教学资产"); + expect(cardActionValues(rt.sentPatches.at(-1))).toContainEqual({ + project_onboarding: { + action: "browse_folder", + organization_id: DEFAULT_ORG_ID, + folder_id: "folder-physics-child", + page: 1, + }, + }); + }); + + it("continues searching past unauthorized matches for a manageable project", async () => { + await seedOnboardingUser("u-onboard-page", "ou_onboard_page", "MEMBER"); + const workspaceRoot = await tempWorkspaceRoot(); + await prisma.project.createMany({ + data: [ + ...Array.from({ length: 20 }, (_, index) => ({ + id: `z-search-denied-${String(index).padStart(2, "0")}`, + organizationId: DEFAULT_ORG_ID, + name: `迁移项目 ${index}`, + workspaceDir: join(workspaceRoot, `denied-${index}`), + })), + { + id: "a-search-allowed", + organizationId: DEFAULT_ORG_ID, + name: "迁移项目 可管理", + workspaceDir: join(workspaceRoot, "allowed"), + }, + ], + }); + await prisma.permissionGrant.create({ + data: { + resourceType: "PROJECT", + resourceId: "a-search-allowed", + principalType: "USER", + principalId: "ou_onboard_page", + role: "MANAGE", + }, + }); + const trigger = makeTriggerHandler({ + prisma, + settings, + logger: silentLogger, + runAgent, + projectWorkspaceRoot: workspaceRoot, + }); + + await trigger(makeEvent("chat-onboard-page", "@_user_1 迁移", "ou_onboard_page"), rt); + + expect(cardActionValues(rt.sentCards[0])).toContainEqual({ + project_onboarding: { + action: "bind_project", + organization_id: DEFAULT_ORG_ID, + project_id: "a-search-allowed", + }, + }); + }); + it("batches quick text messages from the same chat and sender into one run", async () => { await seedProject("proj-1b", "chat-1b"); const trigger = makeTriggerHandler({ @@ -386,6 +678,40 @@ describe("trigger full lifecycle (integration)", () => { expectPromptFromSender(runAgentCalls[0]?.prompt, "ou_test_user", "第一段\n第二段"); }); + it("partitions batched messages by the role selected when each message arrives", async () => { + await seedProject("proj-batch-role", "chat-batch-role"); + const reviewRole = await prisma.organizationAgentRole.create({ + data: { + organizationId: DEFAULT_ORG_ID, + roleId: "review", + label: "审校", + defaultModel: "mock-model", + tools: ["read_file"], + }, + }); + const trigger = makeTriggerHandler({ + prisma, + settings, + logger: silentLogger, + runAgent, + messageBatcherOptions: { debounceMs: 10 }, + }); + + await trigger(makeEvent("chat-batch-role", "@_user_1 草稿消息"), rt); + await prisma.projectGroupBinding.updateMany({ + where: { projectId: "proj-batch-role", chatId: "chat-batch-role", archivedAt: null }, + data: { selectedAgentRoleId: reviewRole.id }, + }); + await trigger(makeEvent("chat-batch-role", "@_user_1 审校消息"), rt); + + await vi.waitFor(async () => { + expect(await prisma.agentRun.count({ where: { projectId: "proj-batch-role", status: "COMPLETED" } })).toBe(2); + }, { timeout: 5_000 }); + const runs = await prisma.agentRun.findMany({ where: { projectId: "proj-batch-role" } }); + expect(runs.find((run) => run.prompt.includes("草稿消息"))?.metadata).toMatchObject({ roleId: "draft" }); + expect(runs.find((run) => run.prompt.includes("审校消息"))?.metadata).toMatchObject({ roleId: "review" }); + }, 10_000); + it("keeps the project session shared while labeling each sender in the prompt", async () => { await seedProject("proj-speaker", "chat-speaker"); await prisma.permissionGrant.create({ @@ -430,24 +756,7 @@ describe("trigger full lifecycle (integration)", () => { expect(sessions).toHaveLength(1); }); - it("/new bypasses message batching", async () => { - await seedProject("proj-1c", "chat-1c"); - const trigger = makeTriggerHandler({ - prisma, - settings, - logger: silentLogger, - runAgent, - messageBatcherOptions: { debounceMs: 10_000 }, - }); - - await trigger(makeEvent("chat-1c", "@_user_1 /new"), rt); - - expect(rt.sentTexts).toContain("已开新会话,下次 @bot 将从头开始。"); - expect(runAgentCalls).toHaveLength(0); - expect(await prisma.agentRun.findMany()).toHaveLength(0); - }); - - it("/help lists control and role commands without creating a run", async () => { + it("/help lists only the closed Hub slash protocol", async () => { await seedProject("proj-help", "chat-help"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); @@ -455,15 +764,14 @@ describe("trigger full lifecycle (integration)", () => { const helpText = rt.sentTexts.at(-1) ?? ""; expect(helpText).toContain("可用 slash 命令"); - expect(helpText).toContain("/new"); - expect(helpText).toContain("/reset"); - expect(helpText).toContain("/draft <需求>"); - expect(helpText).toContain("/review <需求>"); + expect(helpText).toContain("/project"); + expect(helpText).toContain("/usage"); + expect(helpText).not.toMatch(/\/(?:new|reset|resume|draft|review|compact)\b/); expect(runAgentCalls).toHaveLength(0); expect(await prisma.agentRun.findMany()).toHaveLength(0); }); - it("/cost reports recorded current-session cost without creating a run", async () => { + it("/usage reports the selected role's active-session cost", async () => { await seedProject("proj-cost", "chat-cost"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); @@ -474,151 +782,25 @@ describe("trigger full lifecycle (integration)", () => { expect(runs[0]?.status).toBe("COMPLETED"); }); - await trigger(makeEvent("chat-cost", "@_user_1 /cost"), rt); + await trigger(makeEvent("chat-cost", "@_user_1 /usage"), rt); const costText = rt.sentTexts.at(-1) ?? ""; - expect(costText).toContain("当前会话已记录 agent 成本"); - expect(costText).toContain("总计: $0.0023"); - expect(costText).toContain("Runs: 1 已记录"); + expect(costText).toContain("当前角色会话用量"); + expect(costText).toContain("$0.0023"); expect(costText).toContain("openrouter / mock-model"); expect(runAgentCalls).toHaveLength(1); expect(await prisma.agentRun.findMany()).toHaveLength(1); }); - it("/cost surfaces finished runs without recorded cost", async () => { - await seedProject("proj-cost-missing", "chat-cost-missing"); - const session = await prisma.agentSession.create({ - data: { - projectId: "proj-cost-missing", - provider: "openrouter", - roleId: "draft", - model: "mock-model", - metadata: {}, - }, - select: { id: true }, - }); - await prisma.agentRun.create({ - data: { - projectId: "proj-cost-missing", - sessionId: session.id, - entrypoint: "FEISHU", - status: "COMPLETED", - prompt: "old test run", - model: "mock-model", - provider: "openrouter", - inputTokens: 10, - outputTokens: 5, - metadata: {}, - finishedAt: new Date(), - }, - }); - const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); - - await trigger(makeEvent("chat-cost-missing", "@_user_1 /cost"), rt); - - const costText = rt.sentTexts.at(-1) ?? ""; - expect(costText).toContain("还没有任何 run 记录到真实成本"); - expect(costText).toContain("未记录成本: 1 runs"); - expect(runAgentCalls).toHaveLength(0); - }); - - it("/help is built from the current registry on each request", async () => { - await seedProject("proj-help-live", "chat-help-live"); - let currentModels = new InMemoryModelRegistry( - [{ id: "mock-model", label: "Mock", toolCapable: true }], - [{ id: "draft", label: "草稿", defaultModel: "mock-model", systemPrompt: undefined, tools: undefined }], - ); - const dynamicSettings: RuntimeSettings = { - async provider(providerId, scope) { - return settings.provider(providerId, scope); - }, - async modelRegistry() { - return currentModels; - }, - async runPolicy(input) { - return settings.runPolicy(input); - }, - }; - const trigger = makeTriggerHandler({ prisma, settings: dynamicSettings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); - - await trigger(makeEvent("chat-help-live", "@_user_1 /help"), rt); - expect(rt.sentTexts.at(-1) ?? "").not.toContain("/coach <需求>"); - - currentModels = new InMemoryModelRegistry( - [{ id: "mock-model", label: "Mock", toolCapable: true }], - [ - { id: "draft", label: "草稿", defaultModel: "mock-model", systemPrompt: undefined, tools: undefined }, - { id: "coach", label: "教练", defaultModel: "mock-model", systemPrompt: undefined, tools: [] }, - ], - ); - - await trigger(makeEvent("chat-help-live", "@_user_1 /help"), rt); - - const helpText = rt.sentTexts.at(-1) ?? ""; - expect(helpText).toContain("/coach <需求>"); - expect(runAgentCalls).toHaveLength(0); - expect(await prisma.agentRun.findMany()).toHaveLength(0); - }); - it("/help returns command-specific help", async () => { - await seedProject("proj-help-reset", "chat-help-reset"); + await seedProject("proj-help-usage", "chat-help-usage"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); - await trigger(makeEvent("chat-help-reset", "@_user_1 /help reset"), rt); + await trigger(makeEvent("chat-help-usage", "@_user_1 /help usage"), rt); const helpText = rt.sentTexts.at(-1) ?? ""; - expect(helpText).toContain("/reset"); - expect(helpText).toContain("清空当前项目已经排队"); - expect(helpText).toContain("/reset help"); - expect(runAgentCalls).toHaveLength(0); - expect(await prisma.agentRun.findMany()).toHaveLength(0); - }); - - it("passes the selected role tool whitelist into the runner", async () => { - await seedProject("proj-role-tools", "chat-role-tools"); - const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); - - await trigger(makeEvent("chat-role-tools", "@_user_1 /review 检查讲义"), rt); - - await vi.waitFor(async () => { - const runs = await prisma.agentRun.findMany(); - expect(runs).toHaveLength(1); - expect(runs[0]?.status).toBe("COMPLETED"); - }); - - expect(runAgentCalls).toHaveLength(1); - expect(runAgentCalls[0]?.tools).toEqual(["read_file"]); - }); - - it("control commands support a help subcommand", async () => { - await seedProject("proj-new-help", "chat-new-help"); - const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); - - await trigger(makeEvent("chat-new-help", "@_user_1 /new help"), rt); - - const helpText = rt.sentTexts.at(-1) ?? ""; - expect(helpText).toContain("/new"); - expect(helpText).toContain("归档当前未归档"); - expect(helpText).toContain("/help new"); - expect(rt.sentTexts).not.toContain("已开新会话,下次 @bot 将从头开始。"); - expect(runAgentCalls).toHaveLength(0); - expect(await prisma.agentRun.findMany()).toHaveLength(0); - }); - - it("role commands support a help subcommand without consuming role grants", async () => { - await seedProject("proj-role-help", "chat-role-help"); - await prisma.roleTriggerGrant.create({ - data: { projectId: "proj-role-help", roleId: "review", principalType: "USER", principalId: "ou_other" }, - }); - const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); - - await trigger(makeEvent("chat-role-help", "@_user_1 /review help"), rt); - - const helpText = rt.sentTexts.at(-1) ?? ""; - expect(helpText).toContain("/review"); - expect(helpText).toContain("使用“审校”角色"); - expect(helpText).toContain("工具范围: read_file"); - expect(rt.sentTexts).not.toContain("无权限使用角色 review。"); + expect(helpText).toContain("/usage"); + expect(helpText).toContain("真实 Agent 用量与成本"); expect(runAgentCalls).toHaveLength(0); expect(await prisma.agentRun.findMany()).toHaveLength(0); }); @@ -710,7 +892,7 @@ describe("trigger full lifecycle (integration)", () => { }); it.each(["SUSPENDED", "ARCHIVED"] as const)( - "rejects triggers and resume commands when the organization is %s", + "rejects triggers when the organization is %s", async (status) => { await seedProject(`proj-org-${status}`, `chat-org-${status}`); const session = await prisma.agentSession.create({ @@ -726,7 +908,7 @@ describe("trigger full lifecycle (integration)", () => { await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status } }); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); - await trigger(makeEvent(`chat-org-${status}`, "@_user_1 /resume"), rt); + await trigger(makeEvent(`chat-org-${status}`, "@_user_1 写教案"), rt); expect(rt.sentTexts).toContain("无权限触发。"); expect(runAgentCalls).toHaveLength(0); @@ -737,87 +919,6 @@ describe("trigger full lifecycle (integration)", () => { }, ); - it.each(["SUSPENDED", "ARCHIVED"] as const)( - "rejects direct session mutation when the organization is %s", - async (status) => { - await seedProject(`proj-direct-${status}`, `chat-direct-${status}`); - const session = await prisma.agentSession.create({ - data: { - projectId: `proj-direct-${status}`, - provider: "openrouter", - roleId: "draft", - model: "mock-model", - metadata: {}, - archivedAt: new Date(), - }, - }); - await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status } }); - const commands = createSlashCommandRegistry({ - prisma, - settings, - logger: silentLogger, - triggerQueue: new TriggerQueue(), - }); - const resume = commands.get("resume"); - expect(resume).toBeDefined(); - - await expect(resume!.run({ - invocation: { name: "resume", args: [] }, - projectId: `proj-direct-${status}`, - chatId: `chat-direct-${status}`, - rt, - })).rejects.toThrow(`organization ${DEFAULT_ORG_ID} is ${status}`); - - await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: session.id } })).resolves.toMatchObject({ - archivedAt: expect.any(Date), - }); - }, - ); - - it("reports a lifecycle race that rejects a slash-command mutation", async () => { - await seedProject("proj-slash-race", "chat-slash-race"); - const session = await prisma.agentSession.create({ - data: { - projectId: "proj-slash-race", - provider: "openrouter", - roleId: "draft", - model: "mock-model", - metadata: {}, - archivedAt: new Date(), - }, - }); - await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "SUSPENDED" } }); - const trigger = makeTriggerHandler({ - prisma, - settings, - logger: silentLogger, - runAgent, - messageBatcherOptions: { maxMessages: 1 }, - authorizer: { - async can(request) { - return { - allowed: true, - reason: "authorized before concurrent suspension", - action: request.action, - resource: request.resource, - actor: request.actor, - organizationId: DEFAULT_ORG_ID, - principals: [{ type: "USER", id: "ou_test_user" }], - requiredRole: "EDIT", - effectiveRole: "EDIT", - }; - }, - }, - }); - - await trigger(makeEvent("chat-slash-race", "@_user_1 /resume"), rt); - - expect(rt.sentTexts).toContain("组织当前不可用,拒绝操作。"); - await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: session.id } })).resolves.toMatchObject({ - archivedAt: expect.any(Date), - }); - }); - it("queues a text trigger when project is already locked (ADR-0002)", async () => { await seedProject("proj-3", "chat-3"); // Manually create a lock by inserting a run + lock. @@ -884,6 +985,56 @@ describe("trigger full lifecycle (integration)", () => { }); }); + it("freezes the selected role when a trigger enters the queue", async () => { + await seedProject("proj-queue-role", "chat-queue-role"); + const reviewRole = await prisma.organizationAgentRole.create({ + data: { + organizationId: DEFAULT_ORG_ID, + roleId: "review", + label: "审校", + defaultModel: "mock-model", + tools: ["read_file"], + }, + }); + const firstRun = deferred(); + const secondRun = deferred(); + const pendingRuns = [firstRun, secondRun]; + const queuedRunAgent: TestRunner = async (req) => { + runAgentCalls.push(req); + const pending = pendingRuns.shift(); + if (pending === undefined) throw new Error("unexpected extra run"); + return pending.promise; + }; + const trigger = makeTriggerHandler({ + prisma, + settings, + logger: silentLogger, + runAgent: queuedRunAgent, + messageBatcherOptions: { maxMessages: 1 }, + }); + + await trigger(makeEvent("chat-queue-role", "@_user_1 第一个请求"), rt); + await vi.waitFor(() => expect(runAgentCalls).toHaveLength(1)); + await trigger(makeEvent("chat-queue-role", "@_user_1 排队的草稿请求"), rt); + await prisma.projectGroupBinding.updateMany({ + where: { projectId: "proj-queue-role", chatId: "chat-queue-role", archivedAt: null }, + data: { selectedAgentRoleId: reviewRole.id }, + }); + + firstRun.resolve(completedRunResult("first done", "sdk-session-first")); + await vi.waitFor(() => expect(runAgentCalls).toHaveLength(2)); + const queuedRun = await prisma.agentRun.findFirstOrThrow({ + where: { projectId: "proj-queue-role", prompt: { contains: "排队的草稿请求" } }, + }); + expect(queuedRun.metadata).toMatchObject({ roleId: "draft" }); + expect(runAgentCalls[1]?.tools).toBeUndefined(); + + secondRun.resolve(completedRunResult("second done", "sdk-session-second")); + await vi.waitFor(async () => { + expect(await prisma.agentRun.count({ where: { projectId: "proj-queue-role", status: "COMPLETED" } })).toBe(2); + }); + }); + it("reauthorizes a queued trigger and drops it after the organization is suspended", async () => { await seedProject("proj-queue-suspended", "chat-queue-suspended"); const firstRun = deferred(); @@ -1083,173 +1234,174 @@ describe("trigger full lifecycle (integration)", () => { expect(runs).toHaveLength(0); }); - it("/new archives current session (no run created)", async () => { + it("/project opens the role and session console without creating a run", async () => { await seedProject("proj-6", "chat-6"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); - // First @bot creates a session + run. - await trigger(makeEvent("chat-6", "@_user_1 写教案"), rt); - await vi.waitFor(async () => { - const runs = await prisma.agentRun.findMany(); - expect(runs).toHaveLength(1); - expect(runs[0]?.status).toBe("COMPLETED"); - }); + await trigger(makeEvent("chat-6", "@_user_1 /project"), rt); - // /new archives the session. - await trigger(makeEvent("chat-6", "@_user_1 /new"), rt); - expect(rt.sentTexts).toContain("已开新会话,下次 @bot 将从头开始。"); - - const sessions = await prisma.agentSession.findMany(); - expect(sessions).toHaveLength(1); - expect(sessions[0]?.archivedAt).not.toBeNull(); - // No new run created for /new. - expect(await prisma.agentRun.findMany()).toHaveLength(1); + expect(rt.sentCards).toHaveLength(1); + const card = JSON.stringify(rt.sentCards[0]); + expect(card).toContain("当前角色"); + expect(card).toContain("草稿"); + expect(card).toContain("新开会话"); + expect(card).toContain("历史会话"); + expect(runAgentCalls).toHaveLength(0); + expect(await prisma.agentRun.count()).toBe(0); }); - it("/resume un-archives the most recent session", async () => { - await seedProject("proj-7", "chat-7"); + it("creates, lists, and resumes the selected role's user-managed session history", async () => { + await seedProject("proj-session-console", "chat-session-console"); + const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); + await trigger(makeEvent("chat-session-console", "@_user_1 建立历史会话"), rt); + await vi.waitFor(async () => { + await expect(prisma.agentRun.findFirstOrThrow({ where: { projectId: "proj-session-console" } })) + .resolves.toMatchObject({ status: "COMPLETED" }); + }); + const session = await prisma.agentSession.findFirstOrThrow({ where: { projectId: "proj-session-console" } }); + + await trigger.onCardAction(makeOnboardingEvent("chat-session-console", { + project_onboarding: { + action: "new_agent_session", + organization_id: DEFAULT_ORG_ID, + project_id: "proj-session-console", + }, + }, "ou_test_user"), rt); + await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: session.id } })) + .resolves.toMatchObject({ archivedAt: expect.any(Date), metadata: expect.objectContaining({ userResumable: true }) }); + + await trigger.onCardAction(makeOnboardingEvent("chat-session-console", { + project_onboarding: { + action: "show_session_history", + organization_id: DEFAULT_ORG_ID, + project_id: "proj-session-console", + }, + }, "ou_test_user"), rt); + expect(JSON.stringify(rt.sentPatches.at(-1))).toContain(session.id); + + await trigger.onCardAction(makeOnboardingEvent("chat-session-console", { + project_onboarding: { + action: "resume_agent_session", + organization_id: DEFAULT_ORG_ID, + project_id: "proj-session-console", + session_id: session.id, + }, + }, "ou_test_user"), rt); + await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: session.id } })) + .resolves.toMatchObject({ archivedAt: null, metadata: expect.objectContaining({ userResumable: false }) }); + }); + + it("switches the bound role from the project card and uses it for later messages", async () => { + await seedProject("proj-role-switch", "chat-role-switch"); + const reviewRole = await prisma.organizationAgentRole.create({ + data: { + organizationId: DEFAULT_ORG_ID, + roleId: "review", + label: "审校", + defaultModel: "mock-model", + tools: ["read_file"], + sortOrder: 10, + }, + }); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); - // Create + archive a session via /new. - await trigger(makeEvent("chat-7", "@_user_1 写教案"), rt); + await trigger.onCardAction(makeOnboardingEvent("chat-role-switch", { + project_onboarding: { + action: "select_agent_role", + organization_id: DEFAULT_ORG_ID, + project_id: "proj-role-switch", + agent_role_id: reviewRole.id, + }, + }, "ou_test_user"), rt); + await trigger(makeEvent("chat-role-switch", "@_user_1 检查讲义"), rt); + await vi.waitFor(async () => { - expect(await prisma.agentRun.findMany()).toHaveLength(1); + expect(await prisma.agentRun.count()).toBe(1); }); - await trigger(makeEvent("chat-7", "@_user_1 /new"), rt); - - // /resume un-archives. - await trigger(makeEvent("chat-7", "@_user_1 /resume"), rt); - expect(rt.sentTexts).toContain("已恢复上一个会话。"); - - const sessions = await prisma.agentSession.findMany(); - expect(sessions).toHaveLength(1); - expect(sessions[0]?.archivedAt).toBeNull(); + expect(runAgentCalls[0]?.tools).toEqual(["read_file"]); + await expect(prisma.agentRun.findFirstOrThrow({ where: { projectId: "proj-role-switch" } })).resolves.toMatchObject({ + metadata: expect.objectContaining({ roleId: "review" }), + }); + await expect(prisma.projectGroupBinding.findFirstOrThrow({ + where: { projectId: "proj-role-switch", chatId: "chat-role-switch", archivedAt: null }, + select: { selectedAgentRoleId: true }, + })).resolves.toEqual({ selectedAgentRoleId: reviewRole.id }); }); - it("/reset archives current session", async () => { - await seedProject("proj-8", "chat-8"); - const queue = new TriggerQueue(); - const trigger = makeTriggerHandler({ - prisma, - settings, - logger: silentLogger, - runAgent, - messageBatcherOptions: { maxMessages: 1 }, - triggerQueue: queue, - }); - - await trigger(makeEvent("chat-8", "@_user_1 写教案"), rt); - await vi.waitFor(async () => { - const runs = await prisma.agentRun.findMany(); - expect(runs).toHaveLength(1); - expect(runs[0]?.status).toBe("COMPLETED"); - }); - - const queuedEvent = makeEvent("chat-8", "@_user_1 后续需求"); - queue.enqueue("proj-8", { - chatId: "chat-8", - prompt: extractPrompt(queuedEvent.message) ?? "后续需求", - msg: queuedEvent.message, - senderOpenId: "ou_test_user", - actor: { feishuOpenId: "ou_test_user", chatId: "chat-8" }, - }); - expect(queue.length("proj-8")).toBe(1); - - await trigger(makeEvent("chat-8", "@_user_1 /reset"), rt); - expect(rt.sentTexts).toContain("已重置,下次 @bot 将从头开始。"); - expect(queue.length("proj-8")).toBe(0); - - const sessions = await prisma.agentSession.findMany(); - expect(sessions).toHaveLength(1); - expect(sessions[0]?.archivedAt).not.toBeNull(); - }); - - it("unknown slash command falls through to agent", async () => { + it("unknown slash commands fail visibly instead of reaching the Agent", async () => { await seedProject("proj-9", "chat-9"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); await trigger(makeEvent("chat-9", "@_user_1 /unknown"), rt); - // Should create a run (falls through as a normal prompt). - await vi.waitFor(async () => { - const runs = await prisma.agentRun.findMany(); - expect(runs).toHaveLength(1); - expectPromptFromSender(runs[0]?.prompt, "ou_test_user", "/unknown"); - }); - }); - - it("denies /review when sender has no role grant (per-role gate)", async () => { - await seedProject("proj-10", "chat-10"); - // Someone else holds review; ou_test_user does not. - await prisma.roleTriggerGrant.create({ - data: { projectId: "proj-10", roleId: "review", principalType: "USER", principalId: "ou_other" }, - }); - const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); - - await trigger(makeEvent("chat-10", "@_user_1 /review 看看这节"), rt); - - expect(rt.sentTexts).toContain("无权限使用角色 review。"); + expect(rt.sentTexts.at(-1)).toContain("未知 slash 命令 /unknown"); expect(runAgentCalls).toHaveLength(0); - const runs = await prisma.agentRun.findMany(); - expect(runs).toHaveLength(0); + expect(await prisma.agentRun.count()).toBe(0); }); - it("allows /review when sender holds the role grant", async () => { - await seedProject("proj-11", "chat-11"); - await prisma.roleTriggerGrant.create({ - data: { projectId: "proj-11", roleId: "review", principalType: "USER", principalId: "ou_test_user" }, - }); + it("sends native /compact as an exact SDK prompt for the current role session", async () => { + await seedProject("proj-compact", "chat-compact"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); - await trigger(makeEvent("chat-11", "@_user_1 /review 看看这节"), rt); - + await trigger(makeEvent("chat-compact", "@_user_1 写第三单元"), rt); await vi.waitFor(async () => { - const runs = await prisma.agentRun.findMany(); - expect(runs).toHaveLength(1); - expect(runs[0]?.status).toBe("COMPLETED"); - expect(runs[0]?.metadata).toMatchObject({ roleId: "review" }); + await expect(prisma.agentRun.findFirstOrThrow({ where: { projectId: "proj-compact" } })) + .resolves.toMatchObject({ status: "COMPLETED" }); }); + const session = await prisma.agentSession.findFirstOrThrow({ where: { projectId: "proj-compact", archivedAt: null } }); + + await trigger.onCardAction(makeOnboardingEvent("chat-compact", { + project_onboarding: { + action: "compact_agent_session", + organization_id: DEFAULT_ORG_ID, + project_id: "proj-compact", + }, + }, "ou_test_user"), rt); + await vi.waitFor(async () => { + expect(await prisma.agentRun.count()).toBe(2); + }); + + expect(runAgentCalls[1]?.prompt).toBe("/compact"); + expect(runAgentCalls[1]?.resumeSessionId).toBe("sdk-session-1"); + expect(runAgentCalls[1]?.sessionId).toBe(session.id); + await expect(prisma.agentRun.findFirstOrThrow({ + where: { projectId: "proj-compact", prompt: "/compact" }, + })).resolves.toMatchObject({ metadata: expect.objectContaining({ roleId: "draft" }) }); }); - it("extractRole: /draft sets roleId=draft, strips command from prompt", async () => { - await seedProject("proj-12", "chat-12"); - const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); - - await trigger(makeEvent("chat-12", "@_user_1 /draft 写第三单元"), rt); + it("preserves native /compact semantics while the action waits in the project queue", async () => { + await seedProject("proj-compact-queued", "chat-compact-queued"); + const activeRun = deferred(); + const queuedRunner: TestRunner = async (req) => { + runAgentCalls.push(req); + if (runAgentCalls.length === 2) return activeRun.promise; + return completedRunResult(`done ${runAgentCalls.length}`, "sdk-session-queued"); + }; + const trigger = makeTriggerHandler({ + prisma, + settings, + logger: silentLogger, + runAgent: queuedRunner, + messageBatcherOptions: { maxMessages: 1 }, + }); + await trigger(makeEvent("chat-compact-queued", "@_user_1 建立会话"), rt); await vi.waitFor(async () => { - const runs = await prisma.agentRun.findMany(); - expect(runs).toHaveLength(1); - expectPromptFromSender(runs[0]?.prompt, "ou_test_user", "写第三单元"); - expect(runs[0]?.metadata).toMatchObject({ roleId: "draft" }); + expect(await prisma.agentRun.count({ where: { projectId: "proj-compact-queued", status: "COMPLETED" } })).toBe(1); }); - }); + await trigger(makeEvent("chat-compact-queued", "@_user_1 正在处理"), rt); + await vi.waitFor(() => expect(runAgentCalls).toHaveLength(2)); + await trigger.onCardAction(makeOnboardingEvent("chat-compact-queued", { + project_onboarding: { + action: "compact_agent_session", + organization_id: DEFAULT_ORG_ID, + project_id: "proj-compact-queued", + }, + }, "ou_test_user"), rt); + expect(rt.sentTexts).toContain("已加入队列(第1位),当前处理完成后将自动开始"); - it("keeps role sessions separate even when roles share a model", async () => { - await seedProject("proj-12b", "chat-12b"); - await prisma.roleTriggerGrant.create({ - data: { projectId: "proj-12b", roleId: "review", principalType: "USER", principalId: "ou_test_user" }, - }); - const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); - - await trigger(makeEvent("chat-12b", "@_user_1 /draft 写第三单元"), rt); - await vi.waitFor(async () => { - const runs = await prisma.agentRun.findMany(); - expect(runs).toHaveLength(1); - expect(runs[0]?.status).toBe("COMPLETED"); - }); - - await trigger(makeEvent("chat-12b", "@_user_1 /review 看看这节"), rt); - await vi.waitFor(async () => { - const runs = await prisma.agentRun.findMany(); - expect(runs).toHaveLength(2); - expect(runs.every((run) => run.status === "COMPLETED")).toBe(true); - }); - - const sessions = await prisma.agentSession.findMany({ orderBy: { roleId: "asc" } }); - expect(sessions.map((session) => session.roleId)).toEqual(["draft", "review"]); - expect(new Set(sessions.map((session) => session.model))).toEqual(new Set(["mock-model"])); - expect(new Set(sessions.map((session) => session.id)).size).toBe(2); - expect(runAgentCalls[1]?.resumeSessionId).toBeUndefined(); + activeRun.resolve(completedRunResult("active done", "sdk-session-active")); + await vi.waitFor(() => expect(runAgentCalls).toHaveLength(3)); + expect(runAgentCalls[2]?.prompt).toBe("/compact"); }); it("dedups a redelivered event by event_id (no second run)", async () => { @@ -1538,10 +1690,15 @@ function makeInterruptEvent(chatId: string, runId: string, openId: string): Card }; } -function makeOnboardingEvent(chatId: string, value: unknown, openId: string): CardActionEvent { +function makeOnboardingEvent( + chatId: string, + value: unknown, + openId: string, + formValue?: Readonly>, +): CardActionEvent { return { operator: { open_id: openId }, - action: { value, tag: "button" }, + action: { value, tag: "button", ...(formValue !== undefined ? { form_value: formValue } : {}) }, context: { open_chat_id: chatId, open_message_id: "card-message-1" }, }; } diff --git a/hub/test/unit/feishu-card-action-ack.test.ts b/hub/test/unit/feishu-card-action-ack.test.ts index 9d9b6f6..7025a8d 100644 --- a/hub/test/unit/feishu-card-action-ack.test.ts +++ b/hub/test/unit/feishu-card-action-ack.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; 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"; const larkMock = vi.hoisted(() => { @@ -80,6 +80,57 @@ describe("Feishu card action callbacks", () => { 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 { @@ -91,6 +142,15 @@ function cardActionHandler(): (data: unknown) => Promise { return handler; } +function lifecycleHandler(eventType: string): (data: unknown) => Promise { + const start = larkMock.starts[0]; + if (start === undefined) throw new Error("WSClient.start was not called"); + const dispatcher = start.eventDispatcher as { readonly handlers?: Record Promise> }; + const handler = dispatcher.handlers?.[eventType]; + if (handler === undefined) throw new Error(`${eventType} handler was not registered`); + return handler; +} + function makeCardActionEvent(): CardActionEvent { return { operator: { open_id: "ou_user" }, diff --git a/hub/test/unit/feishu-reactions.test.ts b/hub/test/unit/feishu-reactions.test.ts index 68656a0..1583030 100644 --- a/hub/test/unit/feishu-reactions.test.ts +++ b/hub/test/unit/feishu-reactions.test.ts @@ -274,7 +274,7 @@ function mockPrisma(): PrismaClient { create: vi.fn(async () => ({ id: "receipt-1" })), }, projectGroupBinding: { - findFirst: vi.fn(async () => ({ projectId: "project-1" })), + findFirst: vi.fn(async () => ({ projectId: "project-1", selectedRole: { roleId: "draft" } })), }, project: { findUnique: vi.fn(async () => ({ workspaceDir: "/tmp/cph-project" })), diff --git a/hub/test/unit/legacy-project-manifest.test.ts b/hub/test/unit/legacy-project-manifest.test.ts new file mode 100644 index 0000000..9529cb1 --- /dev/null +++ b/hub/test/unit/legacy-project-manifest.test.ts @@ -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([]); + }); +}); diff --git a/hub/test/unit/slash-commands.test.ts b/hub/test/unit/slash-commands.test.ts index 6a91924..3dd222d 100644 --- a/hub/test/unit/slash-commands.test.ts +++ b/hub/test/unit/slash-commands.test.ts @@ -1,17 +1,11 @@ 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", () => { it("parses a slash invocation without resolving it", () => { - expect(parseSlashInvocation("/new")).toEqual({ name: "new", args: [] }); - expect(parseSlashInvocation("/review 看看这节")).toEqual({ name: "review", args: ["看看这节"] }); + expect(parseSlashInvocation("/project")).toEqual({ name: "project", args: [] }); + expect(parseSlashInvocation("/usage project")).toEqual({ name: "usage", args: ["project"] }); expect(parseSlashInvocation("写教案")).toBeNull(); }); - it("parses help subcommands only in the exact / 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(); - }); }); diff --git a/hub/test/unit/trigger-queue.test.ts b/hub/test/unit/trigger-queue.test.ts index e54beda..136c5bc 100644 --- a/hub/test/unit/trigger-queue.test.ts +++ b/hub/test/unit/trigger-queue.test.ts @@ -117,6 +117,8 @@ function makeTrigger(prompt: string): Omit