Files
pikasTech-HWLAB/internal/protocol/index.mjs
T
2026-05-22 11:16:29 +08:00

497 lines
12 KiB
JavaScript

export const ENVIRONMENT_DEV = "dev";
export const DEV_ENDPOINT = "http://74.48.78.17:16667";
export const JSON_RPC_VERSION = "2.0";
export const SERVICE_IDS = Object.freeze([
"hwlab-cloud-api",
"hwlab-cloud-web",
"hwlab-agent-mgr",
"hwlab-agent-worker",
"hwlab-gateway",
"hwlab-gateway-simu",
"hwlab-box-simu",
"hwlab-patch-panel",
"hwlab-router",
"hwlab-tunnel-client",
"hwlab-edge-proxy",
"hwlab-cli",
"hwlab-agent-skills"
]);
export const TABLES = Object.freeze([
"projects",
"gateway_sessions",
"box_resources",
"box_capabilities",
"wiring_configs",
"patch_panel_status",
"hardware_operations",
"audit_events",
"agent_sessions",
"worker_sessions",
"agent_trace_events",
"evidence_records"
]);
export const ERROR_CODES = Object.freeze({
parseError: -32700,
invalidRequest: -32600,
methodNotFound: -32601,
invalidParams: -32602,
internalError: -32603,
hwlabUnknown: -32000,
projectNotFound: -32010,
sessionNotFound: -32011,
capabilityUnavailable: -32020,
resourceLocked: -32021,
wiringInvalid: -32022,
operationRejected: -32023,
evidenceMissing: -32030,
auditRequired: -32040
});
function freezeRecordShape({ required, allowed }) {
return Object.freeze({
required: Object.freeze([...required]),
allowed: Object.freeze([...allowed])
});
}
export const PROTOCOL_RECORD_SHAPES = Object.freeze({
gatewaySession: freezeRecordShape({
required: [
"gatewaySessionId",
"projectId",
"serviceId",
"gatewayId",
"status",
"environment",
"startedAt"
],
allowed: [
"gatewaySessionId",
"projectId",
"serviceId",
"gatewayId",
"endpoint",
"status",
"environment",
"startedAt",
"lastSeenAt",
"stoppedAt",
"labels"
]
}),
boxResource: freezeRecordShape({
required: [
"resourceId",
"projectId",
"gatewaySessionId",
"boxId",
"resourceType",
"state",
"environment",
"createdAt",
"updatedAt"
],
allowed: [
"resourceId",
"projectId",
"gatewaySessionId",
"boxId",
"resourceType",
"name",
"state",
"environment",
"metadata",
"createdAt",
"updatedAt"
]
}),
boxCapability: freezeRecordShape({
required: [
"capabilityId",
"resourceId",
"projectId",
"name",
"direction",
"valueType",
"mutatesState",
"createdAt",
"updatedAt"
],
allowed: [
"capabilityId",
"resourceId",
"projectId",
"name",
"description",
"direction",
"valueType",
"unit",
"constraints",
"mutatesState",
"createdAt",
"updatedAt"
]
}),
hardwareOperation: freezeRecordShape({
required: [
"operationId",
"projectId",
"gatewaySessionId",
"resourceId",
"capabilityId",
"requestedBy",
"status",
"environment",
"requestedAt",
"updatedAt"
],
allowed: [
"operationId",
"projectId",
"gatewaySessionId",
"agentSessionId",
"workerSessionId",
"resourceId",
"capabilityId",
"requestedBy",
"input",
"output",
"status",
"environment",
"requestedAt",
"startedAt",
"completedAt",
"updatedAt",
"error"
]
}),
auditEvent: freezeRecordShape({
required: [
"auditId",
"traceId",
"actorType",
"actorId",
"action",
"targetType",
"targetId",
"serviceId",
"environment",
"occurredAt"
],
allowed: [
"auditId",
"traceId",
"actorType",
"actorId",
"action",
"targetType",
"targetId",
"projectId",
"gatewaySessionId",
"workerSessionId",
"operationId",
"serviceId",
"environment",
"outcome",
"reason",
"metadata",
"occurredAt"
]
}),
evidenceRecord: freezeRecordShape({
required: [
"evidenceId",
"projectId",
"operationId",
"kind",
"uri",
"sha256",
"serviceId",
"environment",
"createdAt"
],
allowed: [
"evidenceId",
"projectId",
"operationId",
"agentSessionId",
"workerSessionId",
"kind",
"uri",
"mimeType",
"sha256",
"sizeBytes",
"serviceId",
"environment",
"metadata",
"createdAt"
]
})
});
export class HwlabProtocolError extends Error {
constructor(message, { code = ERROR_CODES.hwlabUnknown, data = {}, context = {} } = {}) {
super(message);
this.name = "HwlabProtocolError";
this.code = code;
this.data = data;
this.context = context;
}
}
const methodPattern = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9_]*)+$/;
const protocolIdPattern = /^[a-z][a-z0-9]*_[A-Za-z0-9._:-]+$/;
const actionPattern = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/;
const timestampFieldNames = new Set([
"startedAt",
"lastSeenAt",
"stoppedAt",
"createdAt",
"updatedAt",
"requestedAt",
"completedAt",
"occurredAt"
]);
const idFieldNames = new Set([
"gatewaySessionId",
"projectId",
"gatewayId",
"resourceId",
"boxId",
"capabilityId",
"operationId",
"agentSessionId",
"workerSessionId",
"requestedBy",
"auditId",
"traceId",
"actorId",
"targetId",
"evidenceId"
]);
const actorTypes = new Set(["user", "service", "agent", "worker", "system"]);
const auditOutcomes = new Set(["accepted", "succeeded", "failed", "rejected", "canceled"]);
const evidenceKinds = new Set(["log", "screenshot", "measurement", "artifact", "report", "trace"]);
const gatewayStatuses = new Set(["starting", "connected", "degraded", "disconnected", "stopped"]);
const resourceStates = new Set(["available", "reserved", "running", "maintenance", "offline"]);
const operationStatuses = new Set([
"requested",
"accepted",
"queued",
"running",
"succeeded",
"failed",
"rejected",
"canceled",
"timed_out"
]);
const resourceTypes = new Set([
"board",
"power_supply",
"serial_port",
"network_port",
"relay",
"sensor",
"simulator_endpoint"
]);
const capabilityDirections = new Set(["input", "output", "bidirectional"]);
const capabilityValueTypes = new Set(["boolean", "integer", "number", "string", "object", "binary"]);
export function isFrozenServiceId(serviceId) {
return SERVICE_IDS.includes(serviceId);
}
export function isProtocolId(value) {
return typeof value === "string" && protocolIdPattern.test(value);
}
export function validateMeta(meta) {
if (!meta || typeof meta !== "object" || Array.isArray(meta)) {
throw new Error("meta must be an object");
}
if (!meta.traceId) {
throw new Error("meta.traceId is required");
}
if (!isFrozenServiceId(meta.serviceId)) {
throw new Error(`unknown serviceId ${JSON.stringify(meta.serviceId)}`);
}
if (meta.environment !== ENVIRONMENT_DEV) {
throw new Error(`unsupported environment ${JSON.stringify(meta.environment)}`);
}
}
export function validateRequest(envelope) {
if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) {
throw new Error("request must be an object");
}
if (envelope.jsonrpc !== JSON_RPC_VERSION) {
throw new Error(`jsonrpc must be ${JSON_RPC_VERSION}`);
}
if (envelope.id === undefined || envelope.id === null) {
throw new Error("id is required");
}
if (!methodPattern.test(envelope.method)) {
throw new Error(`invalid method ${JSON.stringify(envelope.method)}`);
}
if (
envelope.params !== undefined &&
(!envelope.params || typeof envelope.params !== "object" || Array.isArray(envelope.params))
) {
throw new Error("params must be an object when present");
}
validateMeta(envelope.meta);
}
export function validateResponse(envelope) {
if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) {
throw new Error("response must be an object");
}
if (envelope.jsonrpc !== JSON_RPC_VERSION) {
throw new Error(`jsonrpc must be ${JSON_RPC_VERSION}`);
}
if (envelope.id === undefined || envelope.id === null) {
throw new Error("id is required");
}
const hasResult = Object.hasOwn(envelope, "result");
const hasError = Object.hasOwn(envelope, "error");
if (hasResult === hasError) {
throw new Error("response must contain exactly one of result or error");
}
if (hasError) {
validateRpcError(envelope.error);
}
validateMeta(envelope.meta);
}
export function validateRpcError(error) {
if (!error || typeof error !== "object" || Array.isArray(error)) {
throw new Error("error must be an object");
}
if (!Number.isInteger(error.code)) {
throw new Error("error.code must be an integer");
}
if (!error.message || typeof error.message !== "string") {
throw new Error("error.message is required");
}
}
export function errorNameForCode(code) {
for (const [name, value] of Object.entries(ERROR_CODES)) {
if (value === code) {
return name.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
}
}
return "hwlab_unknown";
}
export function createRpcErrorBody({ code = ERROR_CODES.hwlabUnknown, message = "HWLAB runtime error", data = {} }) {
const error = {
code,
name: errorNameForCode(code),
message,
data
};
validateRpcError(error);
return {
error
};
}
export function assertProtocolRecord(kind, record) {
const shape = PROTOCOL_RECORD_SHAPES[kind];
if (!shape) {
throw new Error(`unknown protocol record kind ${JSON.stringify(kind)}`);
}
if (!record || typeof record !== "object" || Array.isArray(record)) {
throw new Error(`${kind} must be an object`);
}
for (const field of shape.required) {
if (!Object.hasOwn(record, field)) {
throw new Error(`${kind}.${field} is required`);
}
}
const allowed = new Set(shape.allowed);
for (const [field, value] of Object.entries(record)) {
if (!allowed.has(field)) {
throw new Error(`${kind}.${field} is not part of the frozen protocol schema`);
}
assertProtocolField(kind, field, value);
}
}
export function assertProtocolRecords(kind, records) {
if (!Array.isArray(records)) {
throw new Error(`${kind} records must be an array`);
}
for (const record of records) {
assertProtocolRecord(kind, record);
}
}
function assertProtocolField(kind, field, value) {
if (idFieldNames.has(field) && !isProtocolId(value)) {
throw new Error(`${kind}.${field} must be a protocol id`);
}
if (timestampFieldNames.has(field) && Number.isNaN(Date.parse(value))) {
throw new Error(`${kind}.${field} must be an RFC 3339 timestamp`);
}
if (field === "environment" && value !== ENVIRONMENT_DEV) {
throw new Error(`${kind}.environment must be ${ENVIRONMENT_DEV}`);
}
if (field === "serviceId" && !isFrozenServiceId(value)) {
throw new Error(`${kind}.serviceId is not a frozen service id`);
}
if ((field === "action" || field === "name") && !actionPattern.test(value)) {
throw new Error(`${kind}.${field} must be a dotted lower-case action name`);
}
if (field === "actorType" && !actorTypes.has(value)) {
throw new Error(`${kind}.actorType is invalid`);
}
if (field === "outcome" && !auditOutcomes.has(value)) {
throw new Error(`${kind}.outcome is invalid`);
}
if (field === "kind" && !evidenceKinds.has(value)) {
throw new Error(`${kind}.kind is invalid`);
}
if (field === "status") {
assertStatusField(kind, value);
}
if (field === "state" && !resourceStates.has(value)) {
throw new Error(`${kind}.state is invalid`);
}
if (field === "resourceType" && !resourceTypes.has(value)) {
throw new Error(`${kind}.resourceType is invalid`);
}
if (field === "direction" && !capabilityDirections.has(value)) {
throw new Error(`${kind}.direction is invalid`);
}
if (field === "valueType" && !capabilityValueTypes.has(value)) {
throw new Error(`${kind}.valueType is invalid`);
}
if (field === "mutatesState" && typeof value !== "boolean") {
throw new Error(`${kind}.mutatesState must be boolean`);
}
if (field === "sha256" && !/^[a-f0-9]{64}$/.test(value)) {
throw new Error(`${kind}.sha256 must be lowercase sha256 hex`);
}
}
function assertStatusField(kind, value) {
if (kind === "gatewaySession" && !gatewayStatuses.has(value)) {
throw new Error(`${kind}.status is invalid`);
}
if (kind === "boxResource" && !resourceStates.has(value)) {
throw new Error(`${kind}.status is invalid`);
}
if (kind === "hardwareOperation" && !operationStatuses.has(value)) {
throw new Error(`${kind}.status is invalid`);
}
}