forked from bai/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;
|
||||
}
|
||||
|
||||
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<FeishuApplicationConnection>,
|
||||
disableFeishuApplication: (slug: string) =>
|
||||
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: '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 },
|
||||
];
|
||||
|
||||
@@ -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[]
|
||||
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.
|
||||
|
||||
@@ -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 { 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,
|
||||
|
||||
@@ -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