feat: add ERP agent pet bridge and startup guide
This commit is contained in:
@@ -0,0 +1,408 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
if (root) root.LskjBridgeClient = api;
|
||||
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
|
||||
"use strict";
|
||||
|
||||
const PROTOCOL_VERSION = "1.0";
|
||||
const SAFE_IDENTIFIER = /^[A-Za-z0-9_.:-]{8,128}$/;
|
||||
const SAFE_CODE = /^[A-Za-z0-9_.:-]{1,128}$/;
|
||||
const SAFE_COMMAND = /^[A-Za-z0-9_.:-]{1,128}$/;
|
||||
const SAFE_PLAN_ID = /^[A-Fa-f0-9]{32}$/;
|
||||
const RESPONSE_FIELDS = new Set([
|
||||
"protocolVersion", "requestId", "correlationId", "success", "code", "message", "data"
|
||||
]);
|
||||
const REQUIRED_RESPONSE_FIELDS = [
|
||||
"protocolVersion", "requestId", "correlationId", "success", "code", "data"
|
||||
];
|
||||
const METHODS = new Set([
|
||||
"health", "capabilities.list", "context.get", "command.plan", "command.execute"
|
||||
]);
|
||||
const RECOVERY_ACTIONS = new Set([
|
||||
"review_and_retry",
|
||||
"reconcile_execution",
|
||||
"restart_erp_pet",
|
||||
"inspect_existing_record",
|
||||
"replan",
|
||||
"correct_request",
|
||||
"contact_administrator",
|
||||
"wait_and_retry",
|
||||
"contact_support"
|
||||
]);
|
||||
const EXECUTE_DATA_FIELDS = ["result", "followupPlan", "followupCode"];
|
||||
const EXECUTE_RESULT_FIELDS = [
|
||||
"success", "code", "message", "recordId", "replayed", "data"
|
||||
];
|
||||
const DIAGNOSTIC_DATA_FIELDS = [
|
||||
"diagnosticContextSchemaVersion", "diagnosticId", "correlationId",
|
||||
"evidencePersisted", "evidenceContentHash", "outcome",
|
||||
"primaryFindingCode", "moduleOpenSucceeded", "eventCount",
|
||||
"failedEventCount", "slowEventCount", "traceTruncated",
|
||||
"summaryTruncated", "findings", "staticDiagnosis", "contextAvailable"
|
||||
];
|
||||
const DIAGNOSTIC_FINDING_FIELDS = [
|
||||
"severity", "code", "category", "stage", "confidence", "message",
|
||||
"recommendation", "occurrenceCount", "sqlFingerprint", "caller"
|
||||
];
|
||||
const STATIC_DIAGNOSIS_FIELDS = [
|
||||
"moduleCode", "moduleKind", "healthy", "issueCount", "issues"
|
||||
];
|
||||
const STATIC_ISSUE_FIELDS = ["severity", "code", "source"];
|
||||
const SAFE_HASH = /^[a-f0-9]{64}$/;
|
||||
const SAFE_DIAGNOSTIC_ID = /^diag-[A-Fa-f0-9]{32}$/;
|
||||
const SAFE_CALLER = /^caller_(?:[0-9]{4}|overflow)$/;
|
||||
|
||||
function createId() {
|
||||
if (globalThis.crypto && typeof globalThis.crypto.randomUUID === "function") {
|
||||
return globalThis.crypto.randomUUID().replace(/-/g, "");
|
||||
}
|
||||
return "id" + Date.now().toString(36) + Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
class WebViewTransport {
|
||||
constructor(webview, timeoutMs) {
|
||||
if (!webview || typeof webview.postMessage !== "function") {
|
||||
throw new TypeError("WebView2 transport is unavailable.");
|
||||
}
|
||||
this.webview = webview;
|
||||
// 写操作需要用户阅读预览并在 ERP 原生窗口再次确认,默认保留 3 分钟。
|
||||
this.timeoutMs = timeoutMs || 180000;
|
||||
this.pending = new Map();
|
||||
this.onMessage = this.onMessage.bind(this);
|
||||
this.webview.addEventListener("message", this.onMessage);
|
||||
}
|
||||
|
||||
send(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.pending.has(request.requestId)) {
|
||||
reject(protocolError("ERP 桥请求 ID 重复。"));
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(request.requestId);
|
||||
reject(clientError("bridge_timeout", "ERP 命令桥响应超时。"));
|
||||
}, this.timeoutMs);
|
||||
this.pending.set(request.requestId, { resolve, reject, timer });
|
||||
this.webview.postMessage({ type: "lserp.bridge.request", request });
|
||||
});
|
||||
}
|
||||
|
||||
onMessage(event) {
|
||||
const envelope = event && event.data;
|
||||
if (!envelope || envelope.type !== "lserp.bridge.response" || !envelope.response) return;
|
||||
const response = envelope.response;
|
||||
const waiter = this.pending.get(response.requestId);
|
||||
if (!waiter) return;
|
||||
clearTimeout(waiter.timer);
|
||||
this.pending.delete(response.requestId);
|
||||
waiter.resolve(response);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.webview.removeEventListener("message", this.onMessage);
|
||||
this.pending.forEach(waiter => {
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.reject(clientError("bridge_closed", "ERP 命令桥已关闭。"));
|
||||
});
|
||||
this.pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
class BridgeClient {
|
||||
constructor(transport, options) {
|
||||
if (!transport || typeof transport.send !== "function") {
|
||||
throw new TypeError("A bridge transport is required.");
|
||||
}
|
||||
const settings = options || {};
|
||||
this.transport = transport;
|
||||
this.clientSessionId = settings.clientSessionId || createId();
|
||||
if (typeof this.clientSessionId !== "string" || !SAFE_IDENTIFIER.test(this.clientSessionId)) {
|
||||
throw new TypeError("clientSessionId has an invalid format.");
|
||||
}
|
||||
this.idempotencyByPlan = new Map();
|
||||
}
|
||||
|
||||
async request(method, payload, correlationId) {
|
||||
validateRequest(method, payload);
|
||||
const trustedCorrelationId = correlationId === undefined || correlationId === null
|
||||
? createId()
|
||||
: correlationId;
|
||||
if (typeof trustedCorrelationId !== "string"
|
||||
|| !SAFE_IDENTIFIER.test(trustedCorrelationId)) {
|
||||
throw protocolError("ERP 桥关联 ID 格式无效。");
|
||||
}
|
||||
const request = {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
requestId: createId(),
|
||||
correlationId: trustedCorrelationId,
|
||||
clientSessionId: this.clientSessionId,
|
||||
method,
|
||||
payload: payload === undefined || payload === null ? {} : payload
|
||||
};
|
||||
const response = validateResponse(await this.transport.send(request), request);
|
||||
if (response.success !== true) {
|
||||
const error = new Error(response.message || "ERP 命令执行失败。");
|
||||
error.code = response.code;
|
||||
error.response = response;
|
||||
throw error;
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
health() {
|
||||
return this.request("health");
|
||||
}
|
||||
|
||||
context() {
|
||||
return this.request("context.get");
|
||||
}
|
||||
|
||||
capabilities() {
|
||||
return this.request("capabilities.list");
|
||||
}
|
||||
|
||||
plan(command, input) {
|
||||
return this.request("command.plan", {
|
||||
command,
|
||||
input: input === undefined || input === null ? {} : input
|
||||
});
|
||||
}
|
||||
|
||||
async execute(planId, bridgeCorrelationId) {
|
||||
if (typeof planId !== "string" || !SAFE_PLAN_ID.test(planId)
|
||||
|| typeof bridgeCorrelationId !== "string"
|
||||
|| !SAFE_IDENTIFIER.test(bridgeCorrelationId)) {
|
||||
throw protocolError("ERP 执行请求格式无效。");
|
||||
}
|
||||
let key = this.idempotencyByPlan.get(planId);
|
||||
if (!key) {
|
||||
key = createId();
|
||||
this.idempotencyByPlan.set(planId, key);
|
||||
}
|
||||
const data = await this.request(
|
||||
"command.execute",
|
||||
{ planId, idempotencyKey: key },
|
||||
bridgeCorrelationId);
|
||||
validateExecuteData(data, bridgeCorrelationId);
|
||||
const projected = Object.assign({}, data, { bridgeCorrelationId });
|
||||
if (isObject(data.followupPlan)) {
|
||||
projected.followupPlan = Object.assign(
|
||||
{}, data.followupPlan, { bridgeCorrelationId });
|
||||
}
|
||||
return projected;
|
||||
}
|
||||
}
|
||||
|
||||
function validateRequest(method, payload) {
|
||||
if (!METHODS.has(method)) throw protocolError("ERP 桥方法不受支持。");
|
||||
const value = payload === undefined || payload === null ? {} : payload;
|
||||
if (!isObject(value)) throw protocolError("ERP 桥 payload 必须是对象。");
|
||||
const fields = Object.keys(value);
|
||||
if (method === "health" || method === "capabilities.list" || method === "context.get") {
|
||||
if (fields.length !== 0) throw protocolError("该 ERP 桥方法不接受 payload 字段。");
|
||||
return;
|
||||
}
|
||||
if (method === "command.plan") {
|
||||
if (!hasExactFields(fields, ["command", "input"])
|
||||
|| typeof value.command !== "string"
|
||||
|| !SAFE_COMMAND.test(value.command)
|
||||
|| !isObject(value.input)) {
|
||||
throw protocolError("ERP 计划请求格式无效。");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!hasExactFields(fields, ["planId", "idempotencyKey"])
|
||||
|| typeof value.planId !== "string"
|
||||
|| !SAFE_PLAN_ID.test(value.planId)
|
||||
|| typeof value.idempotencyKey !== "string"
|
||||
|| !SAFE_IDENTIFIER.test(value.idempotencyKey)) {
|
||||
throw protocolError("ERP 执行请求格式无效。");
|
||||
}
|
||||
}
|
||||
|
||||
function validateResponse(response, request) {
|
||||
if (!isObject(response)) throw protocolError("ERP 桥响应必须是对象。");
|
||||
const fields = Object.keys(response);
|
||||
if (fields.some(field => !RESPONSE_FIELDS.has(field))
|
||||
|| REQUIRED_RESPONSE_FIELDS.some(field => !fields.includes(field))
|
||||
|| response.protocolVersion !== PROTOCOL_VERSION
|
||||
|| typeof response.requestId !== "string"
|
||||
|| !SAFE_IDENTIFIER.test(response.requestId)
|
||||
|| response.requestId !== request.requestId
|
||||
|| typeof response.correlationId !== "string"
|
||||
|| !SAFE_IDENTIFIER.test(response.correlationId)
|
||||
|| response.correlationId !== request.correlationId
|
||||
|| typeof response.success !== "boolean"
|
||||
|| typeof response.code !== "string"
|
||||
|| !SAFE_CODE.test(response.code)
|
||||
|| !isObject(response.data)
|
||||
|| (response.message !== undefined
|
||||
&& response.message !== null
|
||||
&& typeof response.message !== "string")
|
||||
|| (typeof response.message === "string" && response.message.length > 2000)) {
|
||||
throw protocolError("ERP 桥返回了无效或不匹配的协议消息。");
|
||||
}
|
||||
if (response.success === false) validateErrorRecovery(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
function validateErrorRecovery(response) {
|
||||
if (typeof response.message !== "string"
|
||||
|| !safeDisplayText(response.message, 1, 2000)
|
||||
|| !hasExactFields(Object.keys(response.data), ["recovery"])
|
||||
|| !isObject(response.data.recovery)) {
|
||||
throw protocolError("ERP 桥错误恢复契约无效。");
|
||||
}
|
||||
const recovery = response.data.recovery;
|
||||
if (!hasExactFields(
|
||||
Object.keys(recovery),
|
||||
["action", "retryable", "planInvalidated", "message"])
|
||||
|| typeof recovery.action !== "string"
|
||||
|| !RECOVERY_ACTIONS.has(recovery.action)
|
||||
|| typeof recovery.retryable !== "boolean"
|
||||
|| typeof recovery.planInvalidated !== "boolean"
|
||||
|| typeof recovery.message !== "string"
|
||||
|| !safeDisplayText(recovery.message, 1, 300)) {
|
||||
throw protocolError("ERP 桥错误恢复契约无效。");
|
||||
}
|
||||
}
|
||||
|
||||
function validateExecuteData(data, expectedCorrelationId) {
|
||||
if (!isObject(data)
|
||||
|| !hasExactFields(Object.keys(data), EXECUTE_DATA_FIELDS)
|
||||
|| !isObject(data.result)
|
||||
|| !hasExactFields(Object.keys(data.result), EXECUTE_RESULT_FIELDS)) {
|
||||
throw protocolError("ERP 执行成功回执契约无效。");
|
||||
}
|
||||
const result = data.result;
|
||||
if (result.success !== true
|
||||
|| typeof result.code !== "string"
|
||||
|| !SAFE_CODE.test(result.code)
|
||||
|| !safeDisplayText(result.message, 1, 300)
|
||||
|| (result.recordId !== null
|
||||
&& !safeDisplayText(result.recordId, 1, 256))
|
||||
|| typeof result.replayed !== "boolean"
|
||||
|| !isObject(result.data)
|
||||
|| (data.followupPlan !== null && !isObject(data.followupPlan))
|
||||
|| (data.followupCode !== null
|
||||
&& (typeof data.followupCode !== "string"
|
||||
|| !SAFE_CODE.test(data.followupCode)))) {
|
||||
throw protocolError("ERP 执行成功回执契约无效。");
|
||||
}
|
||||
const resultDataFields = Object.keys(result.data);
|
||||
if (resultDataFields.length === 0) return;
|
||||
validateDiagnosticData(result.data, expectedCorrelationId);
|
||||
}
|
||||
|
||||
function validateDiagnosticData(data, expectedCorrelationId) {
|
||||
if (!hasExactFields(Object.keys(data), DIAGNOSTIC_DATA_FIELDS)
|
||||
|| data.diagnosticContextSchemaVersion !== "1.0"
|
||||
|| typeof data.diagnosticId !== "string"
|
||||
|| !SAFE_DIAGNOSTIC_ID.test(data.diagnosticId)
|
||||
|| data.correlationId !== expectedCorrelationId
|
||||
|| typeof data.evidencePersisted !== "boolean"
|
||||
|| (data.evidenceContentHash !== null
|
||||
&& (typeof data.evidenceContentHash !== "string"
|
||||
|| !SAFE_HASH.test(data.evidenceContentHash)))
|
||||
|| data.evidencePersisted !== (data.evidenceContentHash !== null)
|
||||
|| !["failed", "degraded", "healthy"].includes(data.outcome)
|
||||
|| typeof data.primaryFindingCode !== "string"
|
||||
|| !SAFE_CODE.test(data.primaryFindingCode)
|
||||
|| typeof data.moduleOpenSucceeded !== "boolean"
|
||||
|| !boundedInteger(data.eventCount, 0, 200)
|
||||
|| !boundedInteger(data.failedEventCount, 0, data.eventCount)
|
||||
|| !boundedInteger(data.slowEventCount, 0, data.eventCount)
|
||||
|| typeof data.traceTruncated !== "boolean"
|
||||
|| typeof data.summaryTruncated !== "boolean"
|
||||
|| data.contextAvailable !== true
|
||||
|| !Array.isArray(data.findings)
|
||||
|| data.findings.length < 1
|
||||
|| data.findings.length > 16
|
||||
|| !isObject(data.staticDiagnosis)) {
|
||||
throw protocolError("ERP 初始化诊断回执契约无效。");
|
||||
}
|
||||
data.findings.forEach((finding, index) => {
|
||||
if (!isObject(finding)
|
||||
|| !hasExactFields(Object.keys(finding), DIAGNOSTIC_FINDING_FIELDS)
|
||||
|| !["error", "warning", "info"].includes(finding.severity)
|
||||
|| typeof finding.code !== "string"
|
||||
|| !SAFE_CODE.test(finding.code)
|
||||
|| typeof finding.category !== "string"
|
||||
|| !SAFE_CODE.test(finding.category)
|
||||
|| typeof finding.stage !== "string"
|
||||
|| !SAFE_CODE.test(finding.stage)
|
||||
|| !["observed", "inferred"].includes(finding.confidence)
|
||||
|| !safeDisplayText(finding.message, 1, 300)
|
||||
|| !safeDisplayText(finding.recommendation, 1, 500)
|
||||
|| !boundedInteger(finding.occurrenceCount, 1, 200)
|
||||
|| (finding.sqlFingerprint !== null
|
||||
&& (typeof finding.sqlFingerprint !== "string"
|
||||
|| !SAFE_HASH.test(finding.sqlFingerprint)))
|
||||
|| (finding.caller !== null
|
||||
&& (typeof finding.caller !== "string"
|
||||
|| !SAFE_CALLER.test(finding.caller)))) {
|
||||
throw protocolError("ERP 初始化诊断 finding 契约无效。");
|
||||
}
|
||||
if (index === 0 && finding.code !== data.primaryFindingCode) {
|
||||
throw protocolError("ERP 初始化诊断主结论不一致。");
|
||||
}
|
||||
});
|
||||
const staticDiagnosis = data.staticDiagnosis;
|
||||
if (!hasExactFields(
|
||||
Object.keys(staticDiagnosis),
|
||||
STATIC_DIAGNOSIS_FIELDS)
|
||||
|| typeof staticDiagnosis.moduleCode !== "string"
|
||||
|| !SAFE_COMMAND.test(staticDiagnosis.moduleCode)
|
||||
|| !["base", "bill"].includes(staticDiagnosis.moduleKind)
|
||||
|| typeof staticDiagnosis.healthy !== "boolean"
|
||||
|| !boundedInteger(staticDiagnosis.issueCount, 0, 200)
|
||||
|| !Array.isArray(staticDiagnosis.issues)
|
||||
|| staticDiagnosis.issues.length > 32
|
||||
|| staticDiagnosis.issues.length > staticDiagnosis.issueCount) {
|
||||
throw protocolError("ERP 静态诊断回执契约无效。");
|
||||
}
|
||||
staticDiagnosis.issues.forEach(issue => {
|
||||
if (!isObject(issue)
|
||||
|| !hasExactFields(Object.keys(issue), STATIC_ISSUE_FIELDS)
|
||||
|| !["error", "warning", "info"].includes(issue.severity)
|
||||
|| typeof issue.code !== "string"
|
||||
|| !SAFE_CODE.test(issue.code)
|
||||
|| typeof issue.source !== "string"
|
||||
|| !SAFE_CODE.test(issue.source)) {
|
||||
throw protocolError("ERP 静态诊断 issue 契约无效。");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function boundedInteger(value, minimum, maximum) {
|
||||
return Number.isInteger(value) && value >= minimum && value <= maximum;
|
||||
}
|
||||
|
||||
function safeDisplayText(value, minimumLength, maximumLength) {
|
||||
return typeof value === "string"
|
||||
&& value.length >= minimumLength
|
||||
&& value.length <= maximumLength
|
||||
&& !/[\u0000-\u001f\u007f-\u009f]/.test(value);
|
||||
}
|
||||
|
||||
function hasExactFields(actual, expected) {
|
||||
return actual.length === expected.length && expected.every(field => actual.includes(field));
|
||||
}
|
||||
|
||||
function isObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function protocolError(message) {
|
||||
return clientError("bridge_protocol_error", message || "ERP 桥协议错误。");
|
||||
}
|
||||
|
||||
function clientError(code, message) {
|
||||
const error = new Error(message || "ERP 桥调用失败。");
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
return Object.freeze({ createId, WebViewTransport, BridgeClient, validateResponse });
|
||||
});
|
||||
Reference in New Issue
Block a user