forked from EduCraft/curriculum-project-hub
feat: org capacity policy admin surface
ADR-0022 / Spec.System.Capacity pins layered capacity limits: a platform ceiling per dimension is unbreakable, and each organization may only set a lower `organizationLimit`. The effective limit is the minimum of the two (`LayeredLimit.effective`); dimensions with no org override fall back to the platform ceiling. No dimension may be unlimited (a ceiling must exist before an org limit can be set, `LayeredLimit.Valid`). Add the backend: the pinned 23 CapacityDimension set + labels (src/capacity/dimensions.ts), platform ceilings sourced from existing runtime env vars plus `HUB_CEILING_<DIMENSION>` (src/capacity/ceilings.ts), the OrganizationCapacityPolicy prisma model + migration, the getCapacityPolicy/setCapacityPolicy service enforcing LayeredLimit.Valid, and org-admin GET/PUT /api/org/:orgSlug/capacity-policy routes wired into the org route tree. Add the admin-web surface: CapacityDimension/CapacityPolicyView api client types, capacityPolicy/setCapacityPolicy methods, a `容量` nav entry, and a capacity page that lists every dimension with its platform ceiling (or `未配置` when unset), an org-limit input (disabled until a ceiling exists), and a live effective-value column. Saving sends the partial limits map; the service rejects values above the ceiling or for unconfigured dimensions.
This commit is contained in:
@@ -188,6 +188,42 @@ export interface UsageReport {
|
|||||||
totals: UsageTotals;
|
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 ---
|
// --- API ---
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
@@ -297,4 +333,9 @@ export const api = {
|
|||||||
) => put(`${orgBase(slug)}/feishu-application-connection`, body) as Promise<FeishuApplicationConnection>,
|
) => put(`${orgBase(slug)}/feishu-application-connection`, body) as Promise<FeishuApplicationConnection>,
|
||||||
disableFeishuApplication: (slug: string) =>
|
disableFeishuApplication: (slug: string) =>
|
||||||
del(`${orgBase(slug)}/feishu-application-connection`) as Promise<FeishuApplicationConnection>,
|
del(`${orgBase(slug)}/feishu-application-connection`) as Promise<FeishuApplicationConnection>,
|
||||||
|
|
||||||
|
capacityPolicy: (slug: string) =>
|
||||||
|
get(`${orgBase(slug)}/capacity-policy`) as Promise<CapacityPolicyView>,
|
||||||
|
setCapacityPolicy: (slug: string, body: { limits: Partial<Record<CapacityDimension, number | null>> }) =>
|
||||||
|
put(`${orgBase(slug)}/capacity-policy`, body) as Promise<CapacityPolicyView>,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
{ key: 'members', label: '成员', icon: 'members' as const },
|
{ key: 'members', label: '成员', icon: 'members' as const },
|
||||||
{ key: 'teams', label: '团队', icon: 'teams' as const },
|
{ key: 'teams', label: '团队', icon: 'teams' as const },
|
||||||
{ key: 'projects', label: '项目', icon: 'projects' as const },
|
{ key: 'projects', label: '项目', icon: 'projects' as const },
|
||||||
|
{ key: 'capacity', label: '容量', icon: 'overview' as const },
|
||||||
{ key: 'provider', label: '供应方', icon: 'provider' as const },
|
{ key: 'provider', label: '供应方', icon: 'provider' as const },
|
||||||
{ key: 'feishu', label: '飞书', icon: 'feishu' as const },
|
{ key: 'feishu', label: '飞书', icon: 'feishu' as const },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { api, type CapacityDimension, type CapacityPolicyView } from '$lib/api';
|
||||||
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
|
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||||
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
|
import { toastError, toastSuccess } from '$lib/toast';
|
||||||
|
|
||||||
|
const slug = $derived(page.params.slug ?? '');
|
||||||
|
|
||||||
|
const DIMENSION_LABELS: Record<CapacityDimension, string> = {
|
||||||
|
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: '进程数',
|
||||||
|
};
|
||||||
|
|
||||||
|
let view = $state<CapacityPolicyView | null>(null);
|
||||||
|
let drafts = $state<Partial<Record<CapacityDimension, string>>>({});
|
||||||
|
let loading = $state(true);
|
||||||
|
let saving = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
view = await api.capacityPolicy(slug);
|
||||||
|
drafts = {};
|
||||||
|
for (const row of view.dimensions) {
|
||||||
|
drafts[row.dimension] = row.organizationLimit === null ? '' : String(row.organizationLimit);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!view) return;
|
||||||
|
saving = true;
|
||||||
|
const limits: Partial<Record<CapacityDimension, number | null>> = {};
|
||||||
|
for (const row of view.dimensions) {
|
||||||
|
const raw = drafts[row.dimension];
|
||||||
|
if (raw === undefined || raw.trim() === '') {
|
||||||
|
limits[row.dimension] = null;
|
||||||
|
} else {
|
||||||
|
const n = Number.parseInt(raw.trim(), 10);
|
||||||
|
limits[row.dimension] = Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
view = await api.setCapacityPolicy(slug, { limits });
|
||||||
|
drafts = {};
|
||||||
|
for (const row of view.dimensions) {
|
||||||
|
drafts[row.dimension] = row.organizationLimit === null ? '' : String(row.organizationLimit);
|
||||||
|
}
|
||||||
|
toastSuccess('容量策略已保存');
|
||||||
|
} catch (err) {
|
||||||
|
toastError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (slug) load();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageHeader title="容量策略" description="平台上限不可突破;组织可设更低限制(取两者较低值)。">
|
||||||
|
{#snippet actions()}
|
||||||
|
<button class="saas-btn-primary" disabled={saving || !view} onclick={save}>
|
||||||
|
{saving ? '保存中…' : '保存'}
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<p class="saas-muted mb-4">
|
||||||
|
未配置平台上限的维度暂不可设置组织限制(见 ADR-0022 / <code>LayeredLimit.Valid</code>)。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<LoadingState />
|
||||||
|
{:else if error}
|
||||||
|
<ErrorBanner message={error} onretry={load} />
|
||||||
|
{:else if view}
|
||||||
|
{#if view.dimensions.length === 0}
|
||||||
|
<EmptyState title="暂无容量维度" description="容量维度由 spec 定义。" />
|
||||||
|
{:else}
|
||||||
|
<div class="saas-card overflow-hidden">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>维度</th>
|
||||||
|
<th>平台上限</th>
|
||||||
|
<th>组织限制</th>
|
||||||
|
<th>有效值</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each view.dimensions as row}
|
||||||
|
<tr>
|
||||||
|
<td class="font-medium">{DIMENSION_LABELS[row.dimension] ?? row.dimension}</td>
|
||||||
|
<td class="tabular-nums">
|
||||||
|
{#if row.platformCeiling === null}
|
||||||
|
<span class="text-surface-500">未配置</span>
|
||||||
|
{:else}
|
||||||
|
{row.platformCeiling}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
class="saas-input w-32"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
placeholder="(用平台上限)"
|
||||||
|
disabled={row.platformCeiling === null}
|
||||||
|
bind:value={drafts[row.dimension]}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="tabular-nums text-surface-700">
|
||||||
|
{row.platformCeiling === null
|
||||||
|
? '—'
|
||||||
|
: drafts[row.dimension] && drafts[row.dimension] !== ''
|
||||||
|
? Math.min(row.platformCeiling, Number.parseInt(drafts[row.dimension]!, 10) || row.platformCeiling)
|
||||||
|
: row.platformCeiling}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<EmptyState title="容量数据不可用" description="无法加载容量策略。" />
|
||||||
|
{/if}
|
||||||
@@ -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;
|
||||||
@@ -37,6 +37,7 @@ model Organization {
|
|||||||
|
|
||||||
memberships OrganizationMembership[]
|
memberships OrganizationMembership[]
|
||||||
projectSettings OrganizationProjectSettings?
|
projectSettings OrganizationProjectSettings?
|
||||||
|
capacityPolicy OrganizationCapacityPolicy?
|
||||||
folders Folder[]
|
folders Folder[]
|
||||||
projects Project[]
|
projects Project[]
|
||||||
teams Team[]
|
teams Team[]
|
||||||
@@ -160,6 +161,20 @@ model OrganizationProjectSettings {
|
|||||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
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
|
/// A person known to the Hub. `feishuOpenId` remains a legacy compatibility
|
||||||
/// key; new customer-app identities live in FeishuUserIdentity and store a
|
/// key; new customer-app identities live in FeishuUserIdentity and store a
|
||||||
/// connection-scoped opaque USER principal here instead of a raw open_id.
|
/// connection-scoped opaque USER principal here instead of a raw open_id.
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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<string, number | null> = {};
|
||||||
|
for (const [key, value] of Object.entries(body.limits as Record<string, unknown>)) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import { registerMembersRoutes } from "./membersRoutes.js";
|
|||||||
import { registerSessionsAndUsageRoutes } from "./sessionsRoutes.js";
|
import { registerSessionsAndUsageRoutes } from "./sessionsRoutes.js";
|
||||||
import { registerTeamsRoutes } from "./teamsRoutes.js";
|
import { registerTeamsRoutes } from "./teamsRoutes.js";
|
||||||
import { registerProviderConnectionRoutes } from "./providerConnectionRoutes.js";
|
import { registerProviderConnectionRoutes } from "./providerConnectionRoutes.js";
|
||||||
|
import { registerCapacityRoutes } from "./capacityRoutes.js";
|
||||||
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
|
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
|
||||||
import type { ProviderReadinessProbe } from "../../connections/providerReadiness.js";
|
import type { ProviderReadinessProbe } from "../../connections/providerReadiness.js";
|
||||||
import type { FeishuReadinessProbe } from "../../connections/feishuReadiness.js";
|
import type { FeishuReadinessProbe } from "../../connections/feishuReadiness.js";
|
||||||
@@ -105,6 +106,10 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo
|
|||||||
prisma: config.prisma,
|
prisma: config.prisma,
|
||||||
sessionSecret: config.sessionSecret,
|
sessionSecret: config.sessionSecret,
|
||||||
});
|
});
|
||||||
|
await registerCapacityRoutes(app, {
|
||||||
|
prisma: config.prisma,
|
||||||
|
sessionSecret: config.sessionSecret,
|
||||||
|
});
|
||||||
await registerSessionsAndUsageRoutes(app, {
|
await registerSessionsAndUsageRoutes(app, {
|
||||||
prisma: config.prisma,
|
prisma: config.prisma,
|
||||||
sessionSecret: config.sessionSecret,
|
sessionSecret: config.sessionSecret,
|
||||||
|
|||||||
@@ -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_<DIMENSION>` 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<Record<CapacityDimension, string>> = {
|
||||||
|
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<Record<CapacityDimension, number>> {
|
||||||
|
const ceilings: Partial<Record<CapacityDimension, number>> = {};
|
||||||
|
for (const dimension of CEILING_DIMENSIONS) {
|
||||||
|
const value = positiveIntEnv(envNameFor(dimension));
|
||||||
|
if (value !== undefined) ceilings[dimension] = value;
|
||||||
|
}
|
||||||
|
return ceilings;
|
||||||
|
}
|
||||||
@@ -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<CapacityDimension, string> = {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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<CapacityPolicyView> {
|
||||||
|
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<Record<CapacityDimension, number | null>>;
|
||||||
|
},
|
||||||
|
): Promise<CapacityPolicyView> {
|
||||||
|
const ceilings = readPlatformCeilings();
|
||||||
|
const cleaned: Record<string, number> = {};
|
||||||
|
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<Record<CapacityDimension, number>> {
|
||||||
|
if (raw === null || typeof raw !== "object") return {};
|
||||||
|
const result: Partial<Record<CapacityDimension, number>> = {};
|
||||||
|
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||||
|
if (isCapacityDimension(key) && typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user