diff --git a/hub/admin-web/src/lib/api.ts b/hub/admin-web/src/lib/api.ts index 4aab20e..ea12366 100644 --- a/hub/admin-web/src/lib/api.ts +++ b/hub/admin-web/src/lib/api.ts @@ -188,6 +188,42 @@ export interface UsageReport { totals: UsageTotals; } +export type CapacityDimension = + | 'requestRate' + | 'requestBodySize' + | 'agentConcurrency' + | 'admissionQueueLength' + | 'admissionQueueWait' + | 'fileSize' + | 'attachmentCount' + | 'archiveExpansion' + | 'projectStorage' + | 'organizationStorage' + | 'memberCount' + | 'projectCount' + | 'teamCount' + | 'folderCount' + | 'sessionCount' + | 'runWallTime' + | 'runTurns' + | 'runToolCalls' + | 'toolWallTime' + | 'runOutputSize' + | 'processMemory' + | 'processCpu' + | 'processCount'; + +export interface CapacityDimensionRow { + dimension: CapacityDimension; + platformCeiling: number | null; + organizationLimit: number | null; + effective: number | null; +} + +export interface CapacityPolicyView { + dimensions: CapacityDimensionRow[]; +} + // --- API --- export const api = { @@ -297,4 +333,9 @@ export const api = { ) => put(`${orgBase(slug)}/feishu-application-connection`, body) as Promise, disableFeishuApplication: (slug: string) => del(`${orgBase(slug)}/feishu-application-connection`) as Promise, + + capacityPolicy: (slug: string) => + get(`${orgBase(slug)}/capacity-policy`) as Promise, + setCapacityPolicy: (slug: string, body: { limits: Partial> }) => + put(`${orgBase(slug)}/capacity-policy`, body) as Promise, }; diff --git a/hub/admin-web/src/routes/+layout.svelte b/hub/admin-web/src/routes/+layout.svelte index bf629ff..e97023f 100644 --- a/hub/admin-web/src/routes/+layout.svelte +++ b/hub/admin-web/src/routes/+layout.svelte @@ -23,6 +23,7 @@ { key: 'members', label: '成员', icon: 'members' as const }, { key: 'teams', label: '团队', icon: 'teams' as const }, { key: 'projects', label: '项目', icon: 'projects' as const }, + { key: 'capacity', label: '容量', icon: 'overview' as const }, { key: 'provider', label: '供应方', icon: 'provider' as const }, { key: 'feishu', label: '飞书', icon: 'feishu' as const }, ]; diff --git a/hub/admin-web/src/routes/admin/org/[slug]/capacity/+page.svelte b/hub/admin-web/src/routes/admin/org/[slug]/capacity/+page.svelte new file mode 100644 index 0000000..d74df88 --- /dev/null +++ b/hub/admin-web/src/routes/admin/org/[slug]/capacity/+page.svelte @@ -0,0 +1,158 @@ + + + + {#snippet actions()} + + {/snippet} + + +

+ 未配置平台上限的维度暂不可设置组织限制(见 ADR-0022 / LayeredLimit.Valid)。 +

+ +{#if loading} + +{:else if error} + +{:else if view} + {#if view.dimensions.length === 0} + + {:else} +
+ + + + + + + + + + + {#each view.dimensions as row} + + + + + + + {/each} + +
维度平台上限组织限制有效值
{DIMENSION_LABELS[row.dimension] ?? row.dimension} + {#if row.platformCeiling === null} + 未配置 + {:else} + {row.platformCeiling} + {/if} + + + + {row.platformCeiling === null + ? '—' + : drafts[row.dimension] && drafts[row.dimension] !== '' + ? Math.min(row.platformCeiling, Number.parseInt(drafts[row.dimension]!, 10) || row.platformCeiling) + : row.platformCeiling} +
+
+ {/if} +{:else} + +{/if} diff --git a/hub/prisma/migrations/20260714130000_organization_capacity_policy/migration.sql b/hub/prisma/migrations/20260714130000_organization_capacity_policy/migration.sql new file mode 100644 index 0000000..61be42a --- /dev/null +++ b/hub/prisma/migrations/20260714130000_organization_capacity_policy/migration.sql @@ -0,0 +1,18 @@ +-- ADR-0022 layered capacity policy. Org admins may set per-dimension lower +-- `organizationLimit` overrides; platform ceilings come from deployment config +-- (see `src/capacity/ceilings.ts`). `limits` is a JSON map of +-- CapacityDimension → number (only the set dimensions); service enforces +-- `LayeredLimit.Valid` (org limit ≤ platform ceiling). + +-- CreateTable +CREATE TABLE "OrganizationCapacityPolicy" ( + "organizationId" TEXT NOT NULL, + "limits" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "OrganizationCapacityPolicy_pkey" PRIMARY KEY ("organizationId") +); + +-- AddForeignKey +ALTER TABLE "OrganizationCapacityPolicy" ADD CONSTRAINT "OrganizationCapacityPolicy_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/hub/prisma/schema.prisma b/hub/prisma/schema.prisma index 75ceeba..e5f080d 100644 --- a/hub/prisma/schema.prisma +++ b/hub/prisma/schema.prisma @@ -37,6 +37,7 @@ model Organization { memberships OrganizationMembership[] projectSettings OrganizationProjectSettings? + capacityPolicy OrganizationCapacityPolicy? folders Folder[] projects Project[] teams Team[] @@ -160,6 +161,20 @@ model OrganizationProjectSettings { organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) } +/// ADR-0022 layered capacity policy. Org admins may set per-dimension lower +/// limits; `limits` is a JSON map of CapacityDimension → number (only the set +/// ones). Each set value must be ≤ the platform ceiling for that dimension +/// (validated in service; spec `LayeredLimit.Valid`). Dimensions with no entry +/// fall back to the platform ceiling (`LayeredLimit.effective`). +model OrganizationCapacityPolicy { + organizationId String @id + limits Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) +} + /// A person known to the Hub. `feishuOpenId` remains a legacy compatibility /// key; new customer-app identities live in FeishuUserIdentity and store a /// connection-scoped opaque USER principal here instead of a raw open_id. diff --git a/hub/src/admin/routes/capacityRoutes.ts b/hub/src/admin/routes/capacityRoutes.ts new file mode 100644 index 0000000..71c778f --- /dev/null +++ b/hub/src/admin/routes/capacityRoutes.ts @@ -0,0 +1,64 @@ +/** + * Org-admin capacity policy routes (ADR-0022). + */ +import type { PrismaClient } from "@prisma/client"; +import type { FastifyInstance } from "fastify"; +import { getCapacityPolicy, setCapacityPolicy } from "../../org/capacityPolicy.js"; +import { isCapacityDimension } from "../../capacity/dimensions.js"; +import { requireOrgRole, type GuardDeps } from "../auth/guards.js"; +import { handleRouteError } from "../errors.js"; + +export async function registerCapacityRoutes( + app: FastifyInstance, + config: { readonly prisma: PrismaClient; readonly sessionSecret: string }, +): Promise { + const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret }; + + app.get("/api/org/:orgSlug/capacity-policy", async (request, reply) => { + try { + const { orgSlug } = request.params as { orgSlug: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + return await getCapacityPolicy(config.prisma, auth.organization.id); + } catch (err) { + return handleRouteError(reply, err); + } + }); + + app.put("/api/org/:orgSlug/capacity-policy", async (request, reply) => { + try { + const { orgSlug } = request.params as { orgSlug: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + const body = request.body as { limits?: unknown }; + if (body?.limits === null || typeof body.limits !== "object") { + return reply.status(400).send({ + error: { code: "bad_request", message: "limits object is required" }, + }); + } + const limits: Record = {}; + for (const [key, value] of Object.entries(body.limits as Record)) { + if (!isCapacityDimension(key)) { + return reply.status(400).send({ + error: { code: "bad_request", message: `unknown capacity dimension: ${key}` }, + }); + } + if (value === null) { + limits[key] = null; + } else if (typeof value === "number" && Number.isFinite(value)) { + limits[key] = value; + } else { + return reply.status(400).send({ + error: { code: "bad_request", message: `limit for ${key} must be a number or null` }, + }); + } + } + return await setCapacityPolicy(config.prisma, { + organizationId: auth.organization.id, + limits, + }); + } catch (err) { + return handleRouteError(reply, err); + } + }); +} diff --git a/hub/src/admin/routes/orgRoutes.ts b/hub/src/admin/routes/orgRoutes.ts index 8bec133..cb6e342 100644 --- a/hub/src/admin/routes/orgRoutes.ts +++ b/hub/src/admin/routes/orgRoutes.ts @@ -15,6 +15,7 @@ import { registerMembersRoutes } from "./membersRoutes.js"; import { registerSessionsAndUsageRoutes } from "./sessionsRoutes.js"; import { registerTeamsRoutes } from "./teamsRoutes.js"; import { registerProviderConnectionRoutes } from "./providerConnectionRoutes.js"; +import { registerCapacityRoutes } from "./capacityRoutes.js"; import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js"; import type { ProviderReadinessProbe } from "../../connections/providerReadiness.js"; import type { FeishuReadinessProbe } from "../../connections/feishuReadiness.js"; @@ -105,6 +106,10 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo prisma: config.prisma, sessionSecret: config.sessionSecret, }); + await registerCapacityRoutes(app, { + prisma: config.prisma, + sessionSecret: config.sessionSecret, + }); await registerSessionsAndUsageRoutes(app, { prisma: config.prisma, sessionSecret: config.sessionSecret, diff --git a/hub/src/capacity/ceilings.ts b/hub/src/capacity/ceilings.ts new file mode 100644 index 0000000..5c9b18d --- /dev/null +++ b/hub/src/capacity/ceilings.ts @@ -0,0 +1,78 @@ +/** + * ADR-0022 platform ceilings (spec `LayeredLimit.platformCeiling`). + * + * Platform ceilings are the unbreakable upper bounds; org policy may only + * configure lower limits (see `LayeredLimit.Valid`). Exact numeric values are + * `OPEN` per spec and calibrated by capacity testing — this module is the + * single place to wire them. + * + * Seven dimensions are sourced from existing runtime env vars + * (`HUB_HTTP_BODY_LIMIT_BYTES`, `HUB_MAX_FILES_PER_MESSAGE`, `HUB_MAX_FILE_BYTES`, + * `HUB_HTTP_REQUESTS_PER_MINUTE`, `HUB_AGENT_MAX_CONCURRENT_RUNS`, + * `HUB_AGENT_MAX_RUN_SECONDS`, `HUB_AGENT_MAX_TURNS`). The remaining dimensions + * are sourced from `HUB_CEILING_` env vars; until those are set the + * dimension has no configured ceiling and the policy UI surfaces it as + * "未配置" (cannot enforce a limit without a ceiling). + */ +import type { CapacityDimension } from "./dimensions.js"; + +function positiveIntEnv(name: string): number | undefined { + const raw = process.env[name]; + if (raw === undefined || raw === "") return undefined; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : undefined; +} + +const ENV_BACKED_CEILINGS: Partial> = { + requestBodySize: "HUB_HTTP_BODY_LIMIT_BYTES", + attachmentCount: "HUB_MAX_FILES_PER_MESSAGE", + fileSize: "HUB_MAX_FILE_BYTES", + requestRate: "HUB_HTTP_REQUESTS_PER_MINUTE", + agentConcurrency: "HUB_AGENT_MAX_CONCURRENT_RUNS", + runWallTime: "HUB_AGENT_MAX_RUN_SECONDS", + runTurns: "HUB_AGENT_MAX_TURNS", +}; + +const CEILING_DIMENSIONS: readonly CapacityDimension[] = [ + "requestRate", + "requestBodySize", + "agentConcurrency", + "admissionQueueLength", + "admissionQueueWait", + "fileSize", + "attachmentCount", + "archiveExpansion", + "projectStorage", + "organizationStorage", + "memberCount", + "projectCount", + "teamCount", + "folderCount", + "sessionCount", + "runWallTime", + "runTurns", + "runToolCalls", + "toolWallTime", + "runOutputSize", + "processMemory", + "processCpu", + "processCount", +]; + +function envNameFor(dimension: CapacityDimension): string { + return ENV_BACKED_CEILINGS[dimension] ?? `HUB_CEILING_${dimension.toUpperCase()}`; +} + +/** + * Returns the configured platform ceilings (only the dimensions currently + * wired via env). Unconfigured dimensions are absent — callers must surface + * them, not assume unlimited (spec `LayeredLimit` forbids unlimited ceilings). + */ +export function readPlatformCeilings(): Partial> { + const ceilings: Partial> = {}; + for (const dimension of CEILING_DIMENSIONS) { + const value = positiveIntEnv(envNameFor(dimension)); + if (value !== undefined) ceilings[dimension] = value; + } + return ceilings; +} diff --git a/hub/src/capacity/dimensions.ts b/hub/src/capacity/dimensions.ts new file mode 100644 index 0000000..6c774c3 --- /dev/null +++ b/hub/src/capacity/dimensions.ts @@ -0,0 +1,63 @@ +/** + * ADR-0022 capacity dimensions (spec `Spec.System.Capacity.CapacityDimension`). + * The 23 PINNED dimensions; exact numeric ceilings are `OPEN` and calibrated by + * capacity testing. This module is the single source of the dimension set shared + * by the platform-ceiling config and the org capacity-policy service. + */ +export const CAPACITY_DIMENSIONS = [ + "requestRate", + "requestBodySize", + "agentConcurrency", + "admissionQueueLength", + "admissionQueueWait", + "fileSize", + "attachmentCount", + "archiveExpansion", + "projectStorage", + "organizationStorage", + "memberCount", + "projectCount", + "teamCount", + "folderCount", + "sessionCount", + "runWallTime", + "runTurns", + "runToolCalls", + "toolWallTime", + "runOutputSize", + "processMemory", + "processCpu", + "processCount", +] as const; + +export type CapacityDimension = (typeof CAPACITY_DIMENSIONS)[number]; + +export const CAPACITY_DIMENSION_LABELS: Record = { + requestRate: "HTTP 请求速率", + requestBodySize: "请求体大小", + agentConcurrency: "智能体并发", + admissionQueueLength: "接纳队列长度", + admissionQueueWait: "接纳队列等待", + fileSize: "单文件大小", + attachmentCount: "每消息附件数", + archiveExpansion: "归档展开", + projectStorage: "项目存储", + organizationStorage: "组织存储", + memberCount: "成员数", + projectCount: "项目数", + teamCount: "团队数", + folderCount: "文件夹数", + sessionCount: "会话数", + runWallTime: "单次运行墙钟", + runTurns: "单次运行轮次", + runToolCalls: "单次运行工具调用", + toolWallTime: "工具墙钟", + runOutputSize: "运行输出大小", + processMemory: "进程内存", + processCpu: "进程 CPU", + processCount: "进程数", +}; + +export function isCapacityDimension(value: string): value is CapacityDimension { + return (CAPACITY_DIMENSIONS as readonly string[]).includes(value); +} diff --git a/hub/src/org/capacityPolicy.ts b/hub/src/org/capacityPolicy.ts new file mode 100644 index 0000000..7815aee --- /dev/null +++ b/hub/src/org/capacityPolicy.ts @@ -0,0 +1,102 @@ +/** + * Org capacity policy service (ADR-0022 / spec `Spec.System.Capacity`). + * + * Stores per-Organization lower `organizationLimit` overrides per + * `CapacityDimension`. Enforces `LayeredLimit.Valid`: a set limit must be ≤ the + * platform ceiling for that dimension. `LayeredLimit.effective` (min of the two) + * is the value capacity admission should use; dimensions with no org override + * fall back to the platform ceiling. + */ +import type { PrismaClient } from "@prisma/client"; +import { CAPACITY_DIMENSIONS, isCapacityDimension, type CapacityDimension } from "../capacity/dimensions.js"; +import { readPlatformCeilings } from "../capacity/ceilings.js"; +import { lockActiveOrganization } from "./status.js"; + +export interface CapacityPolicyView { + /** Per-dimension: platform ceiling (absent if not configured) + org limit. */ + readonly dimensions: ReadonlyArray<{ + readonly dimension: CapacityDimension; + readonly platformCeiling: number | null; + readonly organizationLimit: number | null; + readonly effective: number | null; + }>; +} + +export async function getCapacityPolicy( + prisma: PrismaClient, + organizationId: string, +): Promise { + const row = await prisma.organizationCapacityPolicy.findUnique({ + where: { organizationId }, + select: { limits: true }, + }); + const orgLimits = parseLimits(row?.limits); + const ceilings = readPlatformCeilings(); + const dimensions = CAPACITY_DIMENSIONS.map((dimension) => { + const platformCeiling = ceilings[dimension] ?? null; + const organizationLimit = orgLimits[dimension] ?? null; + const effective = effectiveLimit(platformCeiling, organizationLimit); + return { dimension, platformCeiling, organizationLimit, effective }; + }); + return { dimensions }; +} + +export async function setCapacityPolicy( + prisma: PrismaClient, + input: { + readonly organizationId: string; + /** Partial map of dimension → limit. null/absent clears a dimension. */ + readonly limits: Partial>; + }, +): Promise { + const ceilings = readPlatformCeilings(); + const cleaned: Record = {}; + for (const [dimension, value] of Object.entries(input.limits)) { + if (!isCapacityDimension(dimension)) { + throw new Error(`unknown capacity dimension: ${dimension}`); + } + if (value === null || value === undefined) continue; + if (!Number.isFinite(value) || value <= 0 || !Number.isInteger(value)) { + throw new Error(`limit for ${dimension} must be a positive integer`); + } + const ceiling = ceilings[dimension]; + if (ceiling === undefined) { + throw new Error( + `platform ceiling for ${dimension} is not configured; cannot set an organization limit (spec LayeredLimit.Valid)`, + ); + } + if (value > ceiling) { + throw new Error( + `organization limit ${value} for ${dimension} exceeds platform ceiling ${ceiling} (spec LayeredLimit.Valid)`, + ); + } + cleaned[dimension] = value; + } + + await prisma.$transaction(async (tx) => { + await lockActiveOrganization(tx, input.organizationId); + await tx.organizationCapacityPolicy.upsert({ + where: { organizationId: input.organizationId }, + create: { organizationId: input.organizationId, limits: cleaned }, + update: { limits: cleaned }, + }); + }); + return getCapacityPolicy(prisma, input.organizationId); +} + +function effectiveLimit(platformCeiling: number | null, organizationLimit: number | null): number | null { + if (platformCeiling === null) return null; + if (organizationLimit === null) return platformCeiling; + return Math.min(platformCeiling, organizationLimit); +} + +function parseLimits(raw: unknown): Partial> { + if (raw === null || typeof raw !== "object") return {}; + const result: Partial> = {}; + for (const [key, value] of Object.entries(raw as Record)) { + if (isCapacityDimension(key) && typeof value === "number" && Number.isFinite(value)) { + result[key] = value; + } + } + return result; +}