182 lines
8.0 KiB
TypeScript
182 lines
8.0 KiB
TypeScript
/**
|
||
* APS Provider(架构设计第 6 节):把 APS 的能力翻译成平台契约。
|
||
*
|
||
* 只做适配:声明能力、转发调用、归一化回执。不判断权限(那归 APS)、不碰模型上下文。
|
||
*
|
||
* **能力的权威来源是 APS 自己的 `/api/manifest`**(A5.1):平台启动注册时拉一次,
|
||
* 不在平台侧手抄一份,避免两边漂移。APS 起不来时 `describe()` 直接失败——注册不上,
|
||
* 比"注册上了但能力是旧的"安全。
|
||
*/
|
||
import {
|
||
ProviderError,
|
||
isErrorKind,
|
||
type Capability,
|
||
type CapabilityCall,
|
||
type ExecutionContext,
|
||
type HealthStatus,
|
||
type Provider,
|
||
type ProviderManifest,
|
||
} from '@aps/provider-sdk';
|
||
import type { ExecutionReceipt } from '@aps/contracts';
|
||
|
||
export const APS_PROVIDER_ID = 'aps';
|
||
|
||
export interface ApsProviderOptions {
|
||
/** APS 服务地址,如 http://127.0.0.1:8000 */
|
||
baseUrl: string;
|
||
/** 服务间令牌(B5 的鉴权位;首版 APS 未强制) */
|
||
token?: string;
|
||
/** 单次调用超时,默认 30s;写能力应短于平台的 execution 超时 */
|
||
timeoutMs?: number;
|
||
/** 便于测试注入 */
|
||
fetchImpl?: typeof fetch;
|
||
}
|
||
|
||
interface ApsManifestBody {
|
||
id?: unknown;
|
||
displayName?: unknown;
|
||
version?: unknown;
|
||
platformVersion?: unknown;
|
||
requiresIdentity?: unknown;
|
||
auth?: unknown;
|
||
capabilities?: unknown;
|
||
}
|
||
|
||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||
const RECEIPT_STATUSES = new Set(['accepted', 'pending-confirmation', 'succeeded', 'failed']);
|
||
|
||
function asString(value: unknown, fallback: string): string {
|
||
return typeof value === 'string' && value.trim() !== '' ? value : fallback;
|
||
}
|
||
|
||
/** APS 的能力声明与平台 provider-sdk 的 Capability 同形,这里只做字段校验与兜底 */
|
||
function toCapability(raw: unknown): Capability {
|
||
const item = (raw ?? {}) as Record<string, unknown>;
|
||
const id = asString(item.id, '');
|
||
if (id === '') throw new ProviderError('invalid_argument', 'APS manifest 里有能力缺 id');
|
||
const effect = item.effect === 'draft' || item.effect === 'write' ? item.effect : 'read';
|
||
const mode = item.mode === 'async' ? 'async' : 'sync';
|
||
const idempotency = item.idempotency === 'key' || item.idempotency === 'natural' ? item.idempotency : 'none';
|
||
const errors = Array.isArray(item.errors)
|
||
? item.errors.filter((kind): kind is Capability['errors'][number] => isErrorKind(kind))
|
||
: [];
|
||
return {
|
||
id,
|
||
description: asString(item.description, item.title === undefined ? id : String(item.title)),
|
||
effect,
|
||
mode,
|
||
inputSchema: (item.inputSchema as Record<string, unknown>) ?? { type: 'object' },
|
||
outputSchema: (item.outputSchema as Record<string, unknown>) ?? { type: 'object' },
|
||
idempotency,
|
||
errors: errors.length > 0 ? errors : ['unavailable', 'unknown_result'],
|
||
...(typeof item.title === 'string' ? { title: item.title } : {}),
|
||
...(typeof item.whenNotToUse === 'string' ? { whenNotToUse: item.whenNotToUse } : {}),
|
||
...(Array.isArray(item.synonyms) ? { synonyms: item.synonyms.filter((s): s is string => typeof s === 'string') } : {}),
|
||
};
|
||
}
|
||
|
||
export function createApsProvider(options: ApsProviderOptions): Provider {
|
||
const baseUrl = options.baseUrl.replace(/\/+$/, '');
|
||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||
const doFetch = options.fetchImpl ?? fetch;
|
||
const headers = (): Record<string, string> => ({
|
||
'content-type': 'application/json',
|
||
...(options.token === undefined ? {} : { authorization: `Bearer ${options.token}` }),
|
||
});
|
||
|
||
let manifestCache: ProviderManifest | undefined;
|
||
|
||
async function describe(): Promise<ProviderManifest> {
|
||
{
|
||
let response: Response;
|
||
try {
|
||
response = await doFetch(`${baseUrl}/api/manifest`, { headers: headers(), signal: AbortSignal.timeout(timeoutMs) });
|
||
} catch (error) {
|
||
throw new ProviderError('unavailable', `连不上 APS:${error instanceof Error ? error.message : '未知错误'}`);
|
||
}
|
||
if (!response.ok) {
|
||
throw new ProviderError('unavailable', `APS manifest 返回 HTTP ${response.status}`);
|
||
}
|
||
const body = (await response.json()) as ApsManifestBody;
|
||
const raw = Array.isArray(body.capabilities) ? body.capabilities : [];
|
||
const described: ProviderManifest = {
|
||
id: asString(body.id, APS_PROVIDER_ID),
|
||
displayName: asString(body.displayName, 'APS 排产系统'),
|
||
version: asString(body.version, '0.0.0'),
|
||
platformVersion: typeof body.platformVersion === 'number' ? body.platformVersion : 1,
|
||
requiresIdentity: body.requiresIdentity !== false,
|
||
capabilities: raw.map(toCapability),
|
||
};
|
||
manifestCache = described;
|
||
return described;
|
||
}
|
||
}
|
||
|
||
async function invoke(context: ExecutionContext, call: CapabilityCall): Promise<ExecutionReceipt> {
|
||
{
|
||
// 能力白名单:APS 上不存在的能力直接报错,不把一个 404/兜底响应当成回执
|
||
const manifest = manifestCache ?? (await describe());
|
||
if (!manifest.capabilities.some((capability) => capability.id === call.capabilityId)) {
|
||
throw new ProviderError('invalid_argument', `APS 没有能力 ${call.capabilityId}`);
|
||
}
|
||
const payload = {
|
||
params: call.params ?? {},
|
||
executionId: context.executionId,
|
||
deadlineAt: context.deadlineAt,
|
||
...(context.identity === undefined ? {} : { identity: { accountId: context.identity.accountId } }),
|
||
...(context.idempotencyKey === undefined ? {} : { idempotencyKey: context.idempotencyKey }),
|
||
};
|
||
let response: Response;
|
||
try {
|
||
response = await doFetch(`${baseUrl}/api/capabilities/${encodeURIComponent(call.capabilityId)}`, {
|
||
method: 'POST',
|
||
headers: headers(),
|
||
body: JSON.stringify(payload),
|
||
signal: AbortSignal.timeout(Math.max(1_000, Math.min(timeoutMs, context.deadlineAt - Date.now()))),
|
||
});
|
||
} catch (error) {
|
||
throw new ProviderError('unavailable', `调用 APS 失败:${error instanceof Error ? error.message : '未知错误'}`);
|
||
}
|
||
if (!response.ok) {
|
||
// 5xx 与服务不可达归 unavailable;4xx 是平台把请求拼错了,归 invalid_argument
|
||
const kind = response.status >= 500 ? 'unavailable' : 'invalid_argument';
|
||
throw new ProviderError(kind, `APS 返回 HTTP ${response.status}`);
|
||
}
|
||
const body = (await response.json()) as Partial<ExecutionReceipt> & { errorKind?: unknown };
|
||
const status = typeof body.status === 'string' && RECEIPT_STATUSES.has(body.status) ? body.status : 'failed';
|
||
return {
|
||
executionId: asString(body.executionId, context.executionId),
|
||
status: status as ExecutionReceipt['status'],
|
||
...(body.result === undefined ? {} : { result: body.result }),
|
||
// 分类非法时交给 Core 归一,provider 不猜
|
||
...(typeof body.errorKind === 'string' ? { errorKind: body.errorKind } : {}),
|
||
...(typeof body.approvalId === 'string' ? { approvalId: body.approvalId } : {}),
|
||
};
|
||
}
|
||
}
|
||
|
||
async function health(): Promise<HealthStatus> {
|
||
{
|
||
try {
|
||
const response = await doFetch(`${baseUrl}/api/health`, { headers: headers(), signal: AbortSignal.timeout(3_000) });
|
||
if (!response.ok) return { status: 'down', detail: `HTTP ${response.status}`, checkedAt: Date.now() };
|
||
const body = (await response.json()) as { status?: unknown; detail?: unknown };
|
||
const status = body.status === 'degraded' || body.status === 'down' || body.status === 'ok' ? body.status : 'ok';
|
||
return {
|
||
status,
|
||
...(typeof body.detail === 'string' ? { detail: body.detail } : {}),
|
||
checkedAt: Date.now(),
|
||
};
|
||
} catch (error) {
|
||
return { status: 'down', detail: error instanceof Error ? error.message : '健康探测失败', checkedAt: Date.now() };
|
||
}
|
||
}
|
||
}
|
||
|
||
return { describe, invoke, health };
|
||
}
|
||
|
||
/** 供平台按配置加载:模块导出统一的 createProvider,参数来自配置 */
|
||
export function createProvider(options: { baseUrl: string; token?: string; timeoutMs?: number }): Provider {
|
||
return createApsProvider(options);
|
||
} |