feat: add deployable alpha silo

This commit is contained in:
2026-07-11 00:25:45 +08:00
parent 44557da499
commit 9e954790dc
57 changed files with 2792 additions and 400 deletions
+40 -6
View File
@@ -25,6 +25,7 @@ export interface FeishuConfig {
export interface FeishuRuntime {
readonly client: lark.Client;
readonly logger: FastifyBaseLogger;
readonly isListenerReady?: () => boolean;
}
export interface OutboundPayload {
@@ -494,6 +495,7 @@ export async function downloadMessageFile(
workspaceDir: string,
workspaceRelativePath: string,
resourceType: "image" | "file",
maxBytes?: number,
): Promise<string> {
try {
return await withRetry(async () => {
@@ -509,6 +511,7 @@ export async function downloadMessageFile(
workspaceDir,
workspaceRelativePath,
source,
maxBytes,
);
} catch (error) {
source.destroy();
@@ -911,20 +914,41 @@ export function createLarkClient(config: FeishuConfig): lark.Client {
});
}
export function startFeishuListenerWithClient(
export async function startFeishuListenerWithClient(
config: FeishuConfig,
client: lark.Client,
logger: FastifyBaseLogger,
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>,
): FeishuRuntime {
onTerminalError?: (error: Error) => void,
): Promise<FeishuRuntime> {
let state: "STARTING" | "READY" | "FAILED" = "STARTING";
let resolveReady!: () => void;
let rejectReady!: (error: Error) => void;
const ready = new Promise<void>((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
});
const wsClient = new lark.WSClient({
appId: config.appId,
appSecret: config.appSecret,
domain: lark.Domain.Feishu,
loggerLevel: lark.LoggerLevel.info,
handshakeTimeoutMs: 10_000,
onReady: () => {
state = "READY";
resolveReady();
logger.info("Feishu listener ready");
},
onError: (error) => {
const wasStarting = state === "STARTING";
state = "FAILED";
logger.error({ err: error }, "Feishu listener terminal failure");
if (wasStarting) rejectReady(error);
else onTerminalError?.(error);
},
});
const rt: FeishuRuntime = { client, logger };
const rt: FeishuRuntime = { client, logger, isListenerReady: () => state === "READY" };
const handlers: Record<string, (data: unknown) => Promise<void>> = {
"im.message.receive_v1": async (data) => {
try { await onMessage(data as MessageReceiveEvent, rt); }
@@ -938,15 +962,25 @@ export function startFeishuListenerWithClient(
.catch((e) => { logger.error({ err: e }, "feishu card action handler threw"); });
};
}
void wsClient.start({ eventDispatcher: new lark.EventDispatcher({}).register(handlers) });
await wsClient.start({ eventDispatcher: new lark.EventDispatcher({}).register(handlers) });
let timeout: ReturnType<typeof setTimeout> | undefined;
const startupTimeout = new Promise<never>((_resolve, reject) => {
timeout = setTimeout(() => reject(new Error("Feishu listener did not become ready within 15 seconds")), 15_000);
timeout.unref();
});
try {
await Promise.race([ready, startupTimeout]);
} finally {
if (timeout !== undefined) clearTimeout(timeout);
}
return rt;
}
export function startFeishuListener(
export async function startFeishuListener(
config: FeishuConfig,
logger: FastifyBaseLogger,
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>,
): FeishuRuntime {
): Promise<FeishuRuntime> {
return startFeishuListenerWithClient(config, createLarkClient(config), logger, onMessage, onCardAction);
}
+207
View File
@@ -0,0 +1,207 @@
import { createHash } from "node:crypto";
import type { Prisma, PrismaClient } from "@prisma/client";
import { lockActiveOrganization } from "../org/status.js";
export type FeishuPrincipalKind = "USER" | "CHAT" | "DEPARTMENT" | "USER_GROUP" | "EVENT" | "CALLBACK";
export interface ScopedFeishuIdentity {
readonly identityId: string;
readonly connectionId: string;
readonly organizationId: string;
readonly userId: string;
readonly openId: string;
readonly unionId: string | null;
readonly principalId: string;
readonly displayName: string;
readonly avatarUrl: string | null;
}
export function scopedFeishuPrincipalId(
kind: FeishuPrincipalKind,
connectionId: string,
providerLocalId: string,
): string {
const normalizedConnectionId = nonEmpty(connectionId, "Feishu connectionId");
const normalizedProviderLocalId = nonEmpty(providerLocalId, `Feishu ${kind} identifier`);
const digest = createHash("sha256")
.update("cph-feishu-principal\0")
.update(kind, "utf8")
.update("\0")
.update(normalizedConnectionId, "utf8")
.update("\0")
.update(normalizedProviderLocalId, "utf8")
.digest("base64url");
return `feishu:${kind.toLowerCase()}:${normalizedConnectionId}:${digest}`;
}
export async function upsertScopedFeishuIdentity(
prisma: PrismaClient,
input: {
readonly connectionId: string;
readonly openId: string;
readonly unionId?: string | undefined;
readonly displayName: string;
readonly avatarUrl?: string | undefined;
},
): Promise<ScopedFeishuIdentity> {
return prisma.$transaction((tx) => upsertScopedFeishuIdentityInTransaction(tx, input));
}
export async function upsertScopedFeishuIdentityInTransaction(
prisma: Prisma.TransactionClient,
input: {
readonly connectionId: string;
readonly expectedOrganizationId?: string | undefined;
readonly openId: string;
readonly unionId?: string | undefined;
readonly displayName: string;
readonly avatarUrl?: string | undefined;
},
): Promise<ScopedFeishuIdentity> {
const connectionId = nonEmpty(input.connectionId, "Feishu connectionId");
const openId = nonEmpty(input.openId, "Feishu openId");
const displayName = nonEmpty(input.displayName, "Feishu displayName");
const unionId = optionalNonEmpty(input.unionId, "Feishu unionId");
const avatarUrl = optionalNonEmpty(input.avatarUrl, "Feishu avatarUrl");
const principalId = scopedFeishuPrincipalId("USER", connectionId, openId);
const userId = deterministicFeishuUserId(connectionId, openId);
const connection = await lockActiveConnection(prisma, connectionId);
if (input.expectedOrganizationId !== undefined &&
input.expectedOrganizationId !== connection.organizationId) {
throw new Error("Feishu identity Organization scope mismatch");
}
const identity = await prisma.feishuUserIdentity.upsert({
where: { connectionId_openId: { connectionId, openId } },
update: {
...(unionId !== undefined ? { unionId } : {}),
user: {
update: {
displayName,
...(avatarUrl !== undefined ? { avatarUrl } : {}),
},
},
},
create: {
openId,
...(unionId !== undefined ? { unionId } : {}),
connection: { connect: { id: connectionId } },
user: {
connectOrCreate: {
where: { id: userId },
create: {
id: userId,
feishuOpenId: principalId,
displayName,
...(avatarUrl !== undefined ? { avatarUrl } : {}),
},
},
},
},
include: { user: true },
});
return scopedIdentity(connection.organizationId, identity);
}
export async function resolveScopedFeishuIdentity(
prisma: PrismaClient,
input: {
readonly connectionId: string;
readonly openId: string;
readonly expectedOrganizationId?: string | undefined;
},
): Promise<ScopedFeishuIdentity | null> {
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);
if (input.expectedOrganizationId !== undefined &&
input.expectedOrganizationId !== connection.organizationId) {
throw new Error("Feishu identity Organization scope mismatch");
}
const identity = await tx.feishuUserIdentity.findUnique({
where: { connectionId_openId: { connectionId, openId } },
include: { user: true },
});
return identity === null ? null : scopedIdentity(connection.organizationId, identity);
});
}
async function lockActiveConnection(
prisma: Prisma.TransactionClient,
connectionId: string,
): Promise<{ readonly organizationId: string }> {
const scope = await prisma.organizationFeishuApplicationConnection.findUnique({
where: { id: connectionId },
select: { organizationId: true },
});
if (scope === null) throw new Error("active Feishu Application Connection not found");
await lockActiveOrganization(prisma, scope.organizationId);
await prisma.$queryRaw`
SELECT "id" FROM "OrganizationFeishuApplicationConnection"
WHERE "id" = ${connectionId} FOR SHARE
`;
const connection = await prisma.organizationFeishuApplicationConnection.findUnique({
where: { id: connectionId },
select: {
organizationId: true,
status: true,
activeSecretVersion: { select: { connectionId: true, retiredAt: true } },
},
});
if (connection === null || connection.status !== "ACTIVE" || connection.activeSecretVersion === null ||
connection.organizationId !== scope.organizationId ||
connection.activeSecretVersion.connectionId !== connectionId ||
connection.activeSecretVersion.retiredAt !== null) {
throw new Error("active Feishu Application Connection not found");
}
return { organizationId: connection.organizationId };
}
function scopedIdentity(
organizationId: string,
identity: {
readonly id: string;
readonly connectionId: string;
readonly userId: string;
readonly openId: string;
readonly unionId: string | null;
readonly user: {
readonly displayName: string;
readonly avatarUrl: string | null;
};
},
): ScopedFeishuIdentity {
return {
identityId: identity.id,
connectionId: identity.connectionId,
organizationId,
userId: identity.userId,
openId: identity.openId,
unionId: identity.unionId,
principalId: scopedFeishuPrincipalId("USER", identity.connectionId, identity.openId),
displayName: identity.user.displayName,
avatarUrl: identity.user.avatarUrl,
};
}
export function deterministicFeishuUserId(connectionId: string, openId: string): string {
const digest = createHash("sha256")
.update("cph-feishu-user\0")
.update(connectionId, "utf8")
.update("\0")
.update(openId, "utf8")
.digest("base64url");
return `feishu_${digest}`;
}
function nonEmpty(value: string, label: string): string {
const normalized = value.trim();
if (normalized === "") throw new Error(`${label} is required`);
return normalized;
}
function optionalNonEmpty(value: string | undefined, label: string): string | undefined {
if (value === undefined) return undefined;
return nonEmpty(value, label);
}
+5
View File
@@ -37,8 +37,12 @@ export async function stageMessageResources(
messageId: string,
requests: readonly MessageResourceStageRequest[],
workspaceRoot: string,
limits?: { readonly maxFiles: number; readonly maxBytesPerFile: number },
): Promise<StagedMessageResourceBatch | null> {
if (requests.length === 0) return null;
if (limits !== undefined && requests.length > limits.maxFiles) {
throw new Error(`Feishu message has ${requests.length} resources; limit is ${limits.maxFiles}`);
}
const stagingBase = await requirePrivateStagingBase(workspaceRoot);
const stagingRoot = join(stagingBase, randomUUID());
const resources: StagedMessageResource[] = [];
@@ -53,6 +57,7 @@ export async function stageMessageResources(
stagingRoot,
`resource-${index}`,
request.resourceType,
limits?.maxBytesPerFile,
);
resources.push({
resourceType: request.resourceType,
+132 -11
View File
@@ -68,6 +68,7 @@ import {
type OnboardingProjectOption,
type ProjectOnboardingActionValue,
} from "./projectOnboardingCard.js";
import { SiloFixedWindowRateLimiter } from "../deployment/siloRateLimit.js";
export { ApprovalManager } from "./approval.js";
export type { ApprovalResult, PendingApproval } from "./approval.js";
@@ -84,6 +85,15 @@ interface TriggerDeps {
readonly messageBatcherOptions?: MessageBatcherOptions | undefined;
readonly triggerQueue?: TriggerQueue | undefined;
readonly projectWorkspaceRoot: string;
/** Alpha Silo policy: never acknowledge work into the process-local queue. */
readonly rejectWhenBusy?: boolean | undefined;
readonly resourceLimits?: {
readonly maxFilesPerMessage: number;
readonly maxBytesPerFile: number;
} | undefined;
readonly allowLegacyFeishuIdentity?: boolean | undefined;
/** Alpha Silo aggregate ingress ceiling across message and card events. */
readonly maxFeishuEventsPerMinute?: number | undefined;
}
interface TriggerActor {
@@ -102,6 +112,13 @@ interface TriggerRunContext {
type StartAgentRunOutcome = "started" | "queued" | "rejected" | "locked" | "skipped";
class InstanceCapacityExhaustedError extends Error {
constructor(readonly limit: number) {
super(`Silo Agent capacity exhausted (${limit} active run(s))`);
this.name = "InstanceCapacityExhaustedError";
}
}
export interface TriggerHandler {
(event: MessageReceiveEvent, rt: FeishuRuntime): Promise<void>;
readonly onCardAction: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>;
@@ -120,6 +137,9 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
const runAgent = deps.runAgent ?? defaultRunAgent;
const approvalManager = new ApprovalManager();
const triggerQueue = deps.triggerQueue ?? defaultTriggerQueue;
const feishuEventLimiter = deps.maxFeishuEventsPerMinute === undefined
? undefined
: new SiloFixedWindowRateLimiter(deps.maxFeishuEventsPerMinute, 60_000);
const slashCommands = createSlashCommandRegistry({
prisma: deps.prisma,
settings: deps.settings,
@@ -162,6 +182,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
// ADR-0002: if locked by another run, enqueue the trigger for this project.
const existing = await currentLockRunId(deps.prisma, projectId);
if (existing !== null) {
if (deps.rejectWhenBusy === true) {
await sendText(rt, chatId, "当前项目正在处理中,请稍后重试。", sendOptions);
return "rejected";
}
if (!queueIfLocked) return "locked";
return enqueueLockedTrigger(context, cleanPrompt);
}
@@ -224,7 +248,12 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
senderOpenId,
});
const senderMetadata = await senderAuditMetadata(rt, senderOpenId);
const stagedResources = await stageTriggerMessageResources(rt, msg, projectWorkspaceRoot);
const stagedResources = await stageTriggerMessageResources(
rt,
msg,
projectWorkspaceRoot,
deps.resourceLimits,
);
const agentPrompt = appendStagedResourcePaths(parsedAgentPrompt, stagedResources, project.workspaceDir);
const promptForAgent = withFeishuTriggerContext(agentPrompt, feishuTriggerContext);
@@ -239,6 +268,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
try {
admission = await deps.prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, roleDecision.organizationId);
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext('cph-alpha-silo-agent-capacity'))`;
const activeRuns = await tx.agentRun.count({ where: { status: "ACTIVE" } });
if (activeRuns >= runPolicy.maxConcurrentRuns) {
throw new InstanceCapacityExhaustedError(runPolicy.maxConcurrentRuns);
}
// ADR-0017: provider+role+model-bound session. Reuse if one exists for
// this tuple; otherwise create. Role remains part of the key.
@@ -299,9 +333,21 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
await sendText(rt, chatId, "组织当前不可用,拒绝触发。", sendOptions);
return "skipped";
}
if (error instanceof InstanceCapacityExhaustedError) {
deps.logger.info(
{ projectId, maxConcurrentRuns: error.limit },
"feishu trigger: Silo Agent capacity exhausted",
);
await sendText(rt, chatId, "当前组织处理任务已满,请稍后重试。", sendOptions);
return "rejected";
}
if (!isPrismaUniqueConstraintError(error)) throw error;
const current = await currentLockRunId(deps.prisma, projectId);
if (current === null) throw error;
if (deps.rejectWhenBusy === true) {
await sendText(rt, chatId, "当前项目正在处理中,请稍后重试。", sendOptions);
return "rejected";
}
if (!queueIfLocked) return "locked";
return enqueueLockedTrigger(context, cleanPrompt);
}
@@ -409,6 +455,12 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
// Abort handle for this run; the interrupt card action calls abort() to
// stop the SDK query. Cleared in the finally below.
const abortController = new AbortController();
let wallTimeExceeded = false;
const wallTimeTimer = setTimeout(() => {
wallTimeExceeded = true;
abortController.abort("run_wall_time_exceeded");
}, runPolicy.maxRunSeconds * 1000);
wallTimeTimer.unref();
activeRuns.set(run.id, abortController);
const agentExecution = (async () => {
const providerLease = await provider.openAgentLease({ runId: run.id });
@@ -477,7 +529,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
})();
agentExecution
.then(async (result) => {
const interrupted = result.status === "interrupted";
const interrupted = result.status === "interrupted" && !wallTimeExceeded;
const finalText =
result.text !== ""
? result.text
@@ -495,7 +547,15 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
await deps.prisma.agentRun.update({
where: { id: run.id },
data: {
status: interrupted ? "CANCELED" : result.status === "completed" ? "COMPLETED" : result.status === "length" ? "TIMED_OUT" : "FAILED",
status: wallTimeExceeded
? "TIMED_OUT"
: interrupted
? "CANCELED"
: result.status === "completed"
? "COMPLETED"
: result.status === "length"
? "TIMED_OUT"
: "FAILED",
inputTokens: result.usage.inputTokens,
outputTokens: result.usage.outputTokens,
...(result.costUsd !== undefined ? { costUsd: result.costUsd, costSource: "provider_reported" } : {}),
@@ -528,6 +588,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.failed", metadata: { error: String(e) } });
})
.finally(async () => {
clearTimeout(wallTimeTimer);
try {
await releaseLock(deps.prisma, run.id);
} catch (e) {
@@ -621,6 +682,18 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
}
const onCardAction = async (event: CardActionEvent, rt: FeishuRuntime): Promise<void> => {
const rateDecision = feishuEventLimiter?.consume();
if (rateDecision !== undefined && !rateDecision.allowed) {
deps.logger.warn(
{
retryAfterSeconds: rateDecision.retryAfterSeconds,
chatId: nonEmpty(event.context?.open_chat_id),
operatorOpenId: nonEmpty(event.operator.open_id),
},
"feishu trigger: Silo event rate exceeded; card action rejected",
);
return;
}
// Approval buttons (file-delivery / explicit approval cards).
const approvalAction = approvalActionFromValue(event.action.value);
if (approvalAction !== null) {
@@ -803,6 +876,28 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
});
}
const rateDecision = feishuEventLimiter?.consume();
if (rateDecision !== undefined && !rateDecision.allowed) {
deps.logger.warn(
{
retryAfterSeconds: rateDecision.retryAfterSeconds,
chatId: msg.chat_id,
messageId: msg.message_id,
senderOpenId: event.sender.sender_id.open_id,
},
"feishu trigger: Silo event rate exceeded; message rejected",
);
if (msg.chat_id !== "") {
await sendText(
rt,
msg.chat_id,
`当前服务请求过多,请约 ${rateDecision.retryAfterSeconds} 秒后重试。`,
sendOptionsForTriggerMessage(msg),
);
}
return;
}
// Only react to supported inbound message types in group chats.
if (msg.message_type !== "text" && msg.message_type !== "file" && msg.message_type !== "image" && msg.message_type !== "post") return;
const chatId = msg.chat_id;
@@ -913,6 +1008,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
if (msg.message_type === "text") {
const existing = await currentLockRunId(deps.prisma, projectId);
if (existing !== null) {
if (deps.rejectWhenBusy === true) {
await sendText(rt, chatId, "当前项目正在处理中,请稍后重试。", sendOptionsForTriggerMessage(msg));
return;
}
await enqueueLockedTrigger(runContext, cleanPrompt);
return;
}
@@ -975,10 +1074,13 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
}
| { readonly status: "error"; readonly message: string }
> {
const user = await deps.prisma.user.findUnique({
where: { feishuOpenId },
const identity = await deps.prisma.feishuUserIdentity.findFirst({
where: {
openId: feishuOpenId,
connection: { status: "ACTIVE", organization: { status: "ACTIVE" } },
},
select: {
organizationMemberships: {
user: { select: { organizationMemberships: {
where: {
revokedAt: null,
organization: { status: "ACTIVE" },
@@ -988,12 +1090,22 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
organization: { select: { id: true, name: true } },
},
orderBy: { createdAt: "asc" },
},
} } },
},
});
if (user === null) {
return { status: "error", message: "请先登录并加入组织后,再绑定项目。" };
}
const user = identity?.user ?? (deps.allowLegacyFeishuIdentity === true
? await deps.prisma.user.findUnique({
where: { feishuOpenId },
select: {
organizationMemberships: {
where: { revokedAt: null, organization: { status: "ACTIVE" } },
select: { role: true, organization: { select: { id: true, name: true } } },
orderBy: { createdAt: "asc" },
},
},
})
: null);
if (user === null) return { status: "error", message: "请先登录并加入组织后,再绑定项目。" };
if (user.organizationMemberships.length === 0) {
return { status: "error", message: "你还不属于任何可用组织,请联系组织管理员。" };
}
@@ -1378,6 +1490,7 @@ async function stageTriggerMessageResources(
rt: FeishuRuntime,
msg: MessageReceiveEvent["message"],
workspaceRoot: string,
limits?: TriggerDeps["resourceLimits"],
): Promise<StagedMessageResourceBatch | null> {
const requests: MessageResourceStageRequest[] = [];
if (msg.message_type === "post") {
@@ -1407,7 +1520,15 @@ async function stageTriggerMessageResources(
});
}
}
return stageMessageResources(rt, msg.message_id, requests, workspaceRoot);
return stageMessageResources(
rt,
msg.message_id,
requests,
workspaceRoot,
limits === undefined
? undefined
: { maxFiles: limits.maxFilesPerMessage, maxBytesPerFile: limits.maxBytesPerFile },
);
}
function appendStagedResourcePaths(