392 lines
17 KiB
TypeScript
392 lines
17 KiB
TypeScript
/**
|
||
* Core.invoke:工具调用的唯一出口(架构设计 4 / 术语表规则 3)。
|
||
*
|
||
* 一次调用经过五道关(A4 控制面):身份 → 策略 → (需要确认就先开确认卡)→ 凭据 → 执行 → 审计。
|
||
* 三条不变量:
|
||
* 1. 只有一个出口:引擎不论怎么调工具,都落到这里
|
||
* 2. 平台的允许不能覆盖业务系统的拒绝:Provider 回 forbidden 就是 forbidden,不改写成成功、不重试写操作
|
||
* 3. 库里与日志里没有参数与凭据明文:参数只留摘要(确认卡用的参数密文单独存),凭据只在执行期经句柄取用
|
||
*/
|
||
import { randomUUID } from 'node:crypto';
|
||
|
||
import type { ExecutionReceipt } from '@aps/contracts';
|
||
import { isErrorKind, ProviderError, type Capability, type ErrorKind, type ExecutionContext, type ExecutionIdentity, type ImpactPreview, type Provider } from '@aps/provider-sdk';
|
||
import type { ControlPlane } from '../control-plane/control-plane.js';
|
||
import type { AccountBinding } from '../identity/context.js';
|
||
import type { CapabilityRegistry } from '../registry/registry.js';
|
||
import { digestParams, ExecutionStore, type ExecutionRecord } from './store.js';
|
||
|
||
export interface InvokeRequest {
|
||
/** 调用者身份(A4.1 起由身份上下文补齐,现在只需要用户与租户) */
|
||
subject: { userId: string; tenantId: string };
|
||
sessionId: string;
|
||
projectId?: string | null;
|
||
capabilityId: string;
|
||
params: unknown;
|
||
idempotencyKey?: string;
|
||
/** 覆盖默认超时(毫秒) */
|
||
timeoutMs?: number;
|
||
}
|
||
|
||
export interface ApprovalInput {
|
||
decision: 'approve' | 'reject';
|
||
revision: number;
|
||
idempotencyKey: string;
|
||
note?: string;
|
||
}
|
||
|
||
export type ApprovalResult =
|
||
| { kind: 'ok'; approvalId: string; decision: 'approved' | 'rejected' | 'expired'; receipt?: ExecutionReceipt }
|
||
| { kind: 'not-found' }
|
||
| { kind: 'forbidden' };
|
||
|
||
export interface CoreOptions {
|
||
registry: CapabilityRegistry;
|
||
executions?: ExecutionStore;
|
||
defaultTimeoutMs?: number;
|
||
/** A4 控制面:不传则退化为「无策略的直通执行」(只用于老用例与本地试验) */
|
||
controlPlane?: ControlPlane;
|
||
}
|
||
|
||
export interface Core {
|
||
invoke(request: InvokeRequest): Promise<ExecutionReceipt>;
|
||
/** 确认卡响应:校验归属与版本 → 签发一次性授权 → 执行 → 落回执 */
|
||
approve(subject: { userId: string; tenantId: string }, approvalId: string, input: ApprovalInput): Promise<ApprovalResult>;
|
||
executions: ExecutionStore;
|
||
registry: CapabilityRegistry;
|
||
approvals?: ControlPlane['approvals'];
|
||
}
|
||
|
||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||
|
||
function failed(executionId: string, errorKind: ErrorKind, message: string): ExecutionReceipt {
|
||
return { executionId, status: 'failed', errorKind, result: { message } };
|
||
}
|
||
|
||
/** 把任意异常归一化成六分类之一(实施设计 5) */
|
||
function normalizeError(error: unknown): { kind: ErrorKind; message: string } {
|
||
if (error instanceof ProviderError) return { kind: error.kind, message: error.message };
|
||
if (error instanceof Error) return { kind: 'unknown_result', message: error.message };
|
||
return { kind: 'unknown_result', message: 'Provider 抛出未知错误' };
|
||
}
|
||
|
||
interface RunInput {
|
||
executionId: string;
|
||
capability: Capability;
|
||
provider: Provider;
|
||
providerId: string;
|
||
subject: { userId: string; tenantId: string };
|
||
sessionId: string;
|
||
projectId: string | null;
|
||
params: unknown;
|
||
idempotencyKey?: string;
|
||
timeoutMs: number;
|
||
binding?: AccountBinding;
|
||
}
|
||
|
||
export function createCore(options: CoreOptions): Core {
|
||
const executions = options.executions ?? new ExecutionStore();
|
||
const defaultTimeoutMs = options.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||
const control = options.controlPlane;
|
||
|
||
/** 真正落到 Provider 的那一步:签发一次性授权 → 调用 → 归一化 → 审计 */
|
||
async function run(input: RunInput): Promise<ExecutionReceipt> {
|
||
const paramsDigest = digestParams(input.params);
|
||
const timeoutMs = input.timeoutMs;
|
||
const context: ExecutionContext = {
|
||
executionId: input.executionId,
|
||
sessionId: input.sessionId,
|
||
projectId: input.projectId,
|
||
subject: { userId: input.subject.userId, tenantId: input.subject.tenantId },
|
||
...(input.binding === undefined
|
||
? {}
|
||
: { identity: { accountId: input.binding.accountId, displayName: input.binding.displayName } satisfies ExecutionIdentity }),
|
||
...(input.idempotencyKey === undefined ? {} : { idempotencyKey: input.idempotencyKey }),
|
||
deadlineAt: control ? control.now() + timeoutMs : Date.now() + timeoutMs,
|
||
};
|
||
|
||
// 凭据:只在这一层解开,句柄随上下文传下去;参数里永远没有它
|
||
const grant =
|
||
control && input.binding && control.credentials.has(input.subject, input.providerId)
|
||
? control.credentials.issue({
|
||
subject: input.subject,
|
||
providerId: input.providerId,
|
||
accountId: input.binding.accountId,
|
||
executionId: input.executionId,
|
||
ttlMs: timeoutMs,
|
||
})
|
||
: undefined;
|
||
if (grant) {
|
||
context.credentials = { grantId: grant.grantId, read: async () => grant.read() };
|
||
}
|
||
|
||
executions.create({
|
||
executionId: input.executionId,
|
||
capabilityId: input.capability.id,
|
||
providerId: input.providerId,
|
||
sessionId: input.sessionId,
|
||
projectId: input.projectId,
|
||
subjectUserId: input.subject.userId,
|
||
paramsDigest,
|
||
...(input.idempotencyKey === undefined ? {} : { idempotencyKey: input.idempotencyKey }),
|
||
receipt: { executionId: input.executionId, status: 'accepted' },
|
||
});
|
||
control?.audit.append({
|
||
actorUserId: input.subject.userId,
|
||
tenantId: input.subject.tenantId,
|
||
event: 'execution.started',
|
||
executionId: input.executionId,
|
||
sessionId: input.sessionId,
|
||
capabilityId: input.capability.id,
|
||
providerId: input.providerId,
|
||
paramsDigest,
|
||
...(input.binding === undefined ? {} : { decision: input.binding.accountId }),
|
||
});
|
||
|
||
// 超时判定:超时不等于成功也不等于失败——结果未知(实施设计 5)
|
||
const timeout = new Promise<ExecutionReceipt>((resolve) => {
|
||
setTimeout(() => resolve(failed(input.executionId, 'unknown_result', `调用超过 ${timeoutMs}ms,结果未知`)), timeoutMs).unref();
|
||
});
|
||
|
||
let receipt: ExecutionReceipt;
|
||
try {
|
||
receipt = await Promise.race([input.provider.invoke(context, { capabilityId: input.capability.id, params: input.params }), timeout]);
|
||
} catch (error) {
|
||
const normalized = normalizeError(error);
|
||
receipt = failed(input.executionId, normalized.kind, normalized.message);
|
||
} finally {
|
||
// 一次性授权用完即废:结果未知也不延长
|
||
if (grant) control?.credentials.revoke(grant.grantId);
|
||
}
|
||
|
||
// Provider 返回的分类非法时归一为 unknown_result,避免脏值进记录
|
||
if (receipt.status === 'failed' && receipt.errorKind !== undefined && !isErrorKind(receipt.errorKind)) {
|
||
receipt = failed(input.executionId, 'unknown_result', `Provider 返回了未知错误分类 ${String(receipt.errorKind)}`);
|
||
}
|
||
|
||
executions.complete(input.executionId, receipt);
|
||
control?.audit.append({
|
||
actorUserId: input.subject.userId,
|
||
tenantId: input.subject.tenantId,
|
||
event: receipt.status === 'failed' && receipt.errorKind === 'forbidden' ? 'provider.denied' : 'execution.finished',
|
||
executionId: input.executionId,
|
||
sessionId: input.sessionId,
|
||
capabilityId: input.capability.id,
|
||
providerId: input.providerId,
|
||
paramsDigest,
|
||
decision: receipt.status === 'failed' ? `failed:${receipt.errorKind ?? 'unknown_result'}` : receipt.status,
|
||
detail:
|
||
receipt.status === 'failed' && receipt.errorKind === 'forbidden'
|
||
? { by: 'provider', message: '业务系统拒绝了这次调用,平台不覆盖该结果' }
|
||
: {},
|
||
});
|
||
return receipt;
|
||
}
|
||
|
||
async function invoke(request: InvokeRequest): Promise<ExecutionReceipt> {
|
||
const capabilityId = request.capabilityId;
|
||
if (typeof capabilityId !== 'string' || capabilityId.trim() === '') {
|
||
return failed(`exec-${randomUUID()}`, 'invalid_argument', 'capabilityId 不能为空');
|
||
}
|
||
|
||
// 幂等:同一键的重试返回原回执,不重复执行
|
||
if (request.idempotencyKey) {
|
||
const existing = executions.findByIdempotencyKey(request.idempotencyKey);
|
||
if (existing) return existing.receipt;
|
||
}
|
||
|
||
const resolved = options.registry.resolve(capabilityId);
|
||
const executionId = `exec-${randomUUID()}`;
|
||
if (!resolved) {
|
||
return failed(executionId, 'invalid_argument', `能力 ${capabilityId} 未注册`);
|
||
}
|
||
|
||
const projectId = request.projectId ?? null;
|
||
const base: RunInput = {
|
||
executionId,
|
||
capability: resolved.capability,
|
||
provider: resolved.provider,
|
||
providerId: resolved.providerId,
|
||
subject: request.subject,
|
||
sessionId: request.sessionId,
|
||
projectId,
|
||
params: request.params,
|
||
...(request.idempotencyKey === undefined ? {} : { idempotencyKey: request.idempotencyKey }),
|
||
timeoutMs: request.timeoutMs ?? defaultTimeoutMs,
|
||
};
|
||
|
||
if (!control) return run(base);
|
||
|
||
const binding = control.identity.resolve(request.subject, resolved.providerId);
|
||
const decision = control.policy.decide({
|
||
subject: request.subject,
|
||
capability: resolved.capability,
|
||
providerId: resolved.providerId,
|
||
binding,
|
||
visible: control.isVisible(resolved.capability, request.subject),
|
||
requiresIdentity: resolved.requiresIdentity,
|
||
projectId,
|
||
});
|
||
|
||
if (decision.decision === 'deny') {
|
||
control.audit.append({
|
||
actorUserId: request.subject.userId,
|
||
tenantId: request.subject.tenantId,
|
||
event: 'policy.denied',
|
||
executionId,
|
||
sessionId: request.sessionId,
|
||
capabilityId,
|
||
providerId: resolved.providerId,
|
||
paramsDigest: digestParams(request.params),
|
||
decision: decision.code,
|
||
detail: { reason: decision.reason },
|
||
});
|
||
return failed(executionId, 'forbidden', decision.reason);
|
||
}
|
||
|
||
if (decision.decision === 'require-approval') {
|
||
return openApproval(base, binding, decision.code, decision.reason);
|
||
}
|
||
|
||
return run({ ...base, ...(binding === undefined ? {} : { binding }) });
|
||
}
|
||
|
||
/** 写操作:先开确认卡,落一条待确认动作,等用户点头再执行 */
|
||
async function openApproval(input: RunInput, binding: AccountBinding | undefined, code: string, reason: string): Promise<ExecutionReceipt> {
|
||
if (!control) return failed(input.executionId, 'unavailable', '平台未启用控制面,写操作不可用');
|
||
// 同一写请求重试:返回原来那张确认卡,不再开第二张
|
||
if (input.idempotencyKey !== undefined) {
|
||
const existing = control.approvals.findByIdempotencyKey(input.sessionId, input.idempotencyKey);
|
||
if (existing) return existing.receipt;
|
||
}
|
||
const paramsDigest = digestParams(input.params);
|
||
const preview = await previewFor(input);
|
||
const action = control.approvals.create({
|
||
sessionId: input.sessionId,
|
||
projectId: input.projectId,
|
||
ownerUserId: input.subject.userId,
|
||
tenantId: input.subject.tenantId,
|
||
capabilityId: input.capability.id,
|
||
providerId: input.providerId,
|
||
paramsDigest,
|
||
paramsPreview: preview.paramsPreview ?? {},
|
||
paramsSealed: control.credentials.sealText(JSON.stringify(input.params ?? null)),
|
||
impactSummary: preview.impactSummary,
|
||
revision: preview.revision,
|
||
expiresAt: control.now() + control.approvalTtlMs,
|
||
...(input.idempotencyKey === undefined ? {} : { idempotencyKey: input.idempotencyKey }),
|
||
});
|
||
const common = {
|
||
actorUserId: input.subject.userId,
|
||
tenantId: input.subject.tenantId,
|
||
executionId: action.executionId,
|
||
approvalId: action.approvalId,
|
||
sessionId: input.sessionId,
|
||
capabilityId: input.capability.id,
|
||
providerId: input.providerId,
|
||
paramsDigest,
|
||
} as const;
|
||
control.audit.append({ ...common, event: 'policy.requires_confirmation', decision: code, detail: { reason } });
|
||
control.audit.append({ ...common, event: 'approval.requested', decision: 'pending-confirmation', detail: { accountId: binding?.accountId ?? null, revision: action.revision, expiresAt: action.expiresAt } });
|
||
return { executionId: action.executionId, status: 'pending-confirmation', approvalId: action.approvalId };
|
||
}
|
||
|
||
/** 影响预览:Provider 说了算;它没说就退回能力描述(只影响确认卡信息量) */
|
||
async function previewFor(input: RunInput): Promise<ImpactPreview> {
|
||
if (!control) return { impactSummary: input.capability.description, revision: 0 };
|
||
if (input.provider.preview) {
|
||
try {
|
||
const context: ExecutionContext = {
|
||
executionId: input.executionId,
|
||
sessionId: input.sessionId,
|
||
projectId: input.projectId,
|
||
subject: { userId: input.subject.userId, tenantId: input.subject.tenantId },
|
||
deadlineAt: control.now() + input.timeoutMs,
|
||
};
|
||
return await input.provider.preview(context, { capabilityId: input.capability.id, params: input.params });
|
||
} catch {
|
||
/* 预览失败不阻塞确认流程:退回能力描述 */
|
||
}
|
||
}
|
||
return { impactSummary: input.capability.description, revision: 0 };
|
||
}
|
||
|
||
async function approve(subject: { userId: string; tenantId: string }, approvalId: string, input: ApprovalInput): Promise<ApprovalResult> {
|
||
if (!control) return { kind: 'not-found' };
|
||
const action = control.approvals.get(approvalId);
|
||
if (!action) return { kind: 'not-found' };
|
||
// 确认人是会话发起人本人:单用户场景不做委托与转派
|
||
if (action.ownerUserId !== subject.userId || action.tenantId !== subject.tenantId) return { kind: 'forbidden' };
|
||
|
||
const decided = control.approvals.decide(approvalId, {
|
||
decision: input.decision,
|
||
revision: input.revision,
|
||
decidedBy: subject.userId,
|
||
idempotencyKey: input.idempotencyKey,
|
||
});
|
||
if (!decided) return { kind: 'not-found' };
|
||
const { kind, action: current } = decided;
|
||
const common = {
|
||
actorUserId: subject.userId,
|
||
tenantId: subject.tenantId,
|
||
executionId: current.executionId,
|
||
approvalId,
|
||
sessionId: current.sessionId,
|
||
capabilityId: current.capabilityId,
|
||
providerId: current.providerId,
|
||
paramsDigest: current.paramsDigest,
|
||
} as const;
|
||
|
||
// 同一请求重放:返回同一结果,不重复执行
|
||
if (kind === 'replayed') {
|
||
return {
|
||
kind: 'ok',
|
||
approvalId,
|
||
decision: current.decision ?? 'expired',
|
||
...(current.decision === 'approved' ? { receipt: current.receipt } : {}),
|
||
};
|
||
}
|
||
|
||
if (kind === 'expired' || kind === 'revision-mismatch') {
|
||
control.audit.append({ ...common, event: 'approval.expired', decision: kind, detail: { requestedRevision: input.revision, currentRevision: action.revision } });
|
||
return { kind: 'ok', approvalId, decision: 'expired', ...(current.receipt.status === 'pending-confirmation' ? {} : { receipt: current.receipt }) };
|
||
}
|
||
|
||
if (kind === 'rejected') {
|
||
control.audit.append({ ...common, event: 'approval.decided', decision: 'rejected', ...(input.note === undefined ? {} : { detail: { note: input.note } }) });
|
||
return { kind: 'ok', approvalId, decision: 'rejected' };
|
||
}
|
||
|
||
control.audit.append({ ...common, event: 'approval.decided', decision: 'approved', ...(input.note === undefined ? {} : { detail: { note: input.note } }) });
|
||
|
||
const resolved = options.registry.resolve(current.capabilityId);
|
||
if (!resolved) {
|
||
const receipt = failed(current.executionId, 'invalid_argument', `能力 ${current.capabilityId} 已不在能力目录里`);
|
||
control.approvals.complete(current.executionId, receipt);
|
||
return { kind: 'ok', approvalId, decision: 'approved', receipt };
|
||
}
|
||
|
||
const plaintext = control.credentials.openText(current.paramsSealed);
|
||
const params: unknown = plaintext === undefined ? null : (JSON.parse(plaintext) as unknown);
|
||
const binding = control.identity.resolve(subject, current.providerId);
|
||
const receipt = await run({
|
||
executionId: current.executionId,
|
||
capability: resolved.capability,
|
||
provider: resolved.provider,
|
||
providerId: current.providerId,
|
||
subject,
|
||
sessionId: current.sessionId,
|
||
projectId: current.projectId,
|
||
params,
|
||
...(current.idempotencyKey === undefined ? {} : { idempotencyKey: current.idempotencyKey }),
|
||
timeoutMs: defaultTimeoutMs,
|
||
...(binding === undefined ? {} : { binding }),
|
||
});
|
||
control.approvals.complete(current.executionId, receipt);
|
||
return { kind: 'ok', approvalId, decision: 'approved', receipt };
|
||
}
|
||
|
||
return { invoke, approve, executions, registry: options.registry, ...(control === undefined ? {} : { approvals: control.approvals }) };
|
||
}
|
||
|
||
export type { ExecutionRecord }; |