Files
pikasTech-HWLAB/internal/db/runtime-store.mjs
T
2026-05-21 18:33:01 +00:00

624 lines
19 KiB
JavaScript

import { createHash, randomUUID } from "node:crypto";
import {
ENVIRONMENT_DEV,
ERROR_CODES,
HwlabProtocolError,
assertProtocolRecord
} from "../protocol/index.mjs";
import {
CLOUD_API_SERVICE_ID,
createProtocolAuditEvent,
deriveProtocolActorFromMeta
} from "../audit/index.mjs";
export const RUNTIME_STORE_KIND = "memory";
export function createCloudRuntimeStore(options = {}) {
return new CloudRuntimeStore(options);
}
export class CloudRuntimeStore {
constructor({ now = () => new Date().toISOString() } = {}) {
this.now = now;
this.gatewaySessions = new Map();
this.boxResources = new Map();
this.boxCapabilities = new Map();
this.hardwareOperations = new Map();
this.auditEvents = new Map();
this.evidenceRecords = new Map();
}
summary() {
return {
adapter: RUNTIME_STORE_KIND,
durable: false,
status: "degraded",
reason: "live DB persistence is not connected; L1 runtime writes are process-local only",
counts: {
gatewaySessions: this.gatewaySessions.size,
boxResources: this.boxResources.size,
boxCapabilities: this.boxCapabilities.size,
hardwareOperations: this.hardwareOperations.size,
auditEvents: this.auditEvents.size,
evidenceRecords: this.evidenceRecords.size
}
};
}
registerGatewaySession(params = {}, requestMeta = {}) {
const now = this.now();
const input = asObject(params.gatewaySession ?? params, "gatewaySession");
const gatewaySession = pruneUndefined({
gatewaySessionId: input.gatewaySessionId || makeId("gws"),
projectId: requireProtocolId(input, "projectId"),
serviceId: input.serviceId || "hwlab-gateway-simu",
gatewayId: input.gatewayId || makeId("gtw"),
endpoint: input.endpoint,
status: input.status || "connected",
environment: ENVIRONMENT_DEV,
startedAt: input.startedAt || now,
lastSeenAt: input.lastSeenAt || now,
stoppedAt: input.stoppedAt,
labels: normalizeJsonObject(input.labels)
});
assertProtocolRecord("gatewaySession", gatewaySession);
this.gatewaySessions.set(gatewaySession.gatewaySessionId, gatewaySession);
const auditEvent = this.recordAuditEvent({
requestMeta,
params,
action: "gateway.session.register",
targetType: "gateway_session",
targetId: gatewaySession.gatewaySessionId,
projectId: gatewaySession.projectId,
gatewaySessionId: gatewaySession.gatewaySessionId,
outcome: "accepted",
metadata: {
gatewayId: gatewaySession.gatewayId,
serviceId: gatewaySession.serviceId
}
});
return {
registered: true,
gatewaySession,
auditEvent,
persistence: this.summary()
};
}
registerBoxResource(params = {}, requestMeta = {}) {
const now = this.now();
const input = asObject(params.resource ?? params.boxResource ?? params, "boxResource");
const gatewaySessionId = requireProtocolId(input, "gatewaySessionId");
const gatewaySession = this.requireGatewaySession(gatewaySessionId);
const projectId = input.projectId || gatewaySession.projectId;
const resource = pruneUndefined({
resourceId: input.resourceId || makeId("res"),
projectId,
gatewaySessionId,
boxId: input.boxId || makeId("box"),
resourceType: input.resourceType || "simulator_endpoint",
name: input.name,
state: input.state || "available",
environment: ENVIRONMENT_DEV,
metadata: normalizeJsonObject(input.metadata),
createdAt: input.createdAt || now,
updatedAt: input.updatedAt || now
});
assertProtocolRecord("boxResource", resource);
this.boxResources.set(resource.resourceId, resource);
const auditEvent = this.recordAuditEvent({
requestMeta,
params,
action: "box.resource.register",
targetType: "box_resource",
targetId: resource.resourceId,
projectId: resource.projectId,
gatewaySessionId: resource.gatewaySessionId,
outcome: "accepted",
metadata: {
boxId: resource.boxId,
resourceType: resource.resourceType
}
});
return {
registered: true,
resource,
auditEvent,
persistence: this.summary()
};
}
reportBoxCapabilities(params = {}, requestMeta = {}) {
const now = this.now();
const inputs = normalizeCapabilityInputs(params);
const capabilities = [];
for (const input of inputs) {
const resourceId = requireProtocolId(input, "resourceId");
const resource = this.requireBoxResource(resourceId);
const capability = pruneUndefined({
capabilityId: input.capabilityId || makeId("cap"),
resourceId,
projectId: input.projectId || resource.projectId,
name: requireString(input, "name"),
description: input.description,
direction: input.direction || "bidirectional",
valueType: input.valueType || "object",
unit: input.unit,
constraints: normalizeJsonObject(input.constraints),
mutatesState: input.mutatesState ?? true,
createdAt: input.createdAt || now,
updatedAt: input.updatedAt || now
});
assertProtocolRecord("boxCapability", capability);
this.boxCapabilities.set(capability.capabilityId, capability);
capabilities.push(capability);
}
const first = capabilities[0];
const auditEvent = this.recordAuditEvent({
requestMeta,
params,
action: "box.capability.report",
targetType: "box_capability",
targetId: first.capabilityId,
projectId: first.projectId,
gatewaySessionId: this.boxResources.get(first.resourceId)?.gatewaySessionId,
outcome: "accepted",
metadata: {
capabilityIds: capabilities.map((capability) => capability.capabilityId)
}
});
return {
reported: true,
capabilities,
auditEvent,
persistence: this.summary()
};
}
requestHardwareOperation(params = {}, requestMeta = {}) {
const refs = this.requireOperationRefs(params, requestMeta);
const now = this.now();
const operation = pruneUndefined({
operationId: params.operationId || makeId("op"),
projectId: refs.projectId,
gatewaySessionId: refs.gatewaySession.gatewaySessionId,
agentSessionId: params.agentSessionId,
workerSessionId: params.workerSessionId,
resourceId: refs.resource.resourceId,
capabilityId: refs.capability.capabilityId,
requestedBy: refs.actor.actorId,
input: normalizeJsonObject(params.input),
status: "accepted",
environment: ENVIRONMENT_DEV,
requestedAt: params.requestedAt || now,
updatedAt: now
});
assertProtocolRecord("hardwareOperation", operation);
this.hardwareOperations.set(operation.operationId, operation);
const auditEvent = this.recordAuditEvent({
requestMeta,
params,
action: "hardware.operation.request",
targetType: "hardware_operation",
targetId: operation.operationId,
projectId: operation.projectId,
gatewaySessionId: operation.gatewaySessionId,
workerSessionId: operation.workerSessionId,
operationId: operation.operationId,
outcome: "accepted",
metadata: {
resourceId: operation.resourceId,
capabilityId: operation.capabilityId
}
});
return {
accepted: true,
status: operation.status,
operationId: operation.operationId,
operation,
auditEvent,
persistence: this.summary()
};
}
invokeHardwareShell(params = {}, requestMeta = {}) {
const shellInput = normalizeShellInput(params);
const operationResult = this.requestHardwareOperation(
{
...params,
input: {
...normalizeJsonObject(params.input),
shell: shellInput
}
},
requestMeta
);
const operation = {
...operationResult.operation,
output: {
shellExecuted: false,
dispatchStatus: "not_connected",
message: "cloud-api recorded the shell invoke request; no gateway shell adapter is connected in L1"
},
updatedAt: this.now()
};
assertProtocolRecord("hardwareOperation", operation);
this.hardwareOperations.set(operation.operationId, operation);
const evidenceRecord = this.writeEvidenceRecord(
{
projectId: operation.projectId,
operationId: operation.operationId,
kind: "trace",
mimeType: "application/json",
serviceId: CLOUD_API_SERVICE_ID,
metadata: {
traceId: requestMeta.traceId,
gatewaySessionId: operation.gatewaySessionId,
resourceId: operation.resourceId,
capabilityId: operation.capabilityId,
shellExecuted: false,
dispatchStatus: "not_connected"
},
content: {
operationId: operation.operationId,
shell: shellInput,
dispatchStatus: "not_connected"
}
},
requestMeta
).evidenceRecord;
const auditEvent = this.recordAuditEvent({
requestMeta,
params,
action: "hardware.invoke.shell",
targetType: "hardware_operation",
targetId: operation.operationId,
projectId: operation.projectId,
gatewaySessionId: operation.gatewaySessionId,
workerSessionId: operation.workerSessionId,
operationId: operation.operationId,
outcome: "accepted",
metadata: {
evidenceId: evidenceRecord.evidenceId,
shellExecuted: false,
dispatchStatus: "not_connected"
}
});
return {
accepted: true,
status: operation.status,
operationId: operation.operationId,
operation,
auditEvent,
evidenceRecord,
dispatch: operation.output,
persistence: this.summary()
};
}
writeAuditEvent(params = {}, requestMeta = {}) {
const eventInput = params.event ?? params.auditEvent ?? params;
const auditEvent = eventInput.auditId
? pruneUndefined({
...eventInput,
environment: ENVIRONMENT_DEV
})
: this.buildAuditEventFromInput(eventInput, requestMeta);
assertProtocolRecord("auditEvent", auditEvent);
this.auditEvents.set(auditEvent.auditId, auditEvent);
return {
written: true,
auditEvent,
persistence: this.summary()
};
}
queryAuditEvents(params = {}) {
const events = [...this.auditEvents.values()].filter((event) => matchesQuery(event, params, [
"auditId",
"traceId",
"projectId",
"gatewaySessionId",
"operationId",
"action",
"targetId"
]));
return {
events: limitResults(events, params.limit),
count: events.length,
persistence: this.summary()
};
}
writeEvidenceRecord(params = {}, requestMeta = {}) {
const now = this.now();
const input = asObject(params.record ?? params.evidenceRecord ?? params, "evidenceRecord");
const content = input.content ?? input.metadata ?? {};
const sha256 = input.sha256 || sha256Hex(content);
const evidenceId = input.evidenceId || makeId("evd");
const evidenceRecord = pruneUndefined({
evidenceId,
projectId: requireProtocolId(input, "projectId"),
operationId: requireProtocolId(input, "operationId"),
agentSessionId: input.agentSessionId,
workerSessionId: input.workerSessionId,
kind: input.kind || "trace",
uri: input.uri || `memory://hwlab/evidence/${evidenceId}`,
mimeType: input.mimeType,
sha256,
sizeBytes: input.sizeBytes ?? Buffer.byteLength(stableJson(content), "utf8"),
serviceId: input.serviceId || CLOUD_API_SERVICE_ID,
environment: ENVIRONMENT_DEV,
metadata: {
...normalizeJsonObject(input.metadata),
...(requestMeta.traceId ? { traceId: requestMeta.traceId } : {})
},
createdAt: input.createdAt || now
});
assertProtocolRecord("evidenceRecord", evidenceRecord);
this.evidenceRecords.set(evidenceRecord.evidenceId, evidenceRecord);
return {
written: true,
evidenceRecord,
persistence: this.summary()
};
}
queryEvidenceRecords(params = {}) {
const records = [...this.evidenceRecords.values()].filter((record) => matchesQuery(record, params, [
"evidenceId",
"projectId",
"operationId",
"agentSessionId",
"workerSessionId",
"kind",
"serviceId"
]));
return {
records: limitResults(records, params.limit),
count: records.length,
persistence: this.summary()
};
}
recordAuditEvent(input) {
return this.writeAuditEvent(this.buildAuditEventFromInput(input, input.requestMeta), input.requestMeta).auditEvent;
}
buildAuditEventFromInput(input = {}, requestMeta = {}) {
const actor = deriveProtocolActorFromMeta(requestMeta, input.params?.audit ?? input);
return createProtocolAuditEvent({
auditId: input.auditId,
traceId: requestMeta.traceId || input.traceId,
actorType: actor.actorType,
actorId: actor.actorId,
action: input.action,
targetType: input.targetType,
targetId: input.targetId,
projectId: input.projectId,
gatewaySessionId: input.gatewaySessionId,
workerSessionId: input.workerSessionId,
operationId: input.operationId,
serviceId: CLOUD_API_SERVICE_ID,
outcome: input.outcome,
reason: input.reason || input.params?.audit?.reason,
metadata: input.metadata,
occurredAt: input.occurredAt
});
}
requireOperationRefs(params, requestMeta = {}) {
const projectId = requireProtocolId(params, "projectId");
const gatewaySession = this.requireGatewaySession(requireProtocolId(params, "gatewaySessionId"));
const resource = this.requireBoxResource(requireProtocolId(params, "resourceId"));
const capability = this.requireBoxCapability(requireProtocolId(params, "capabilityId"));
if (gatewaySession.projectId !== projectId || resource.projectId !== projectId || capability.projectId !== projectId) {
throw new HwlabProtocolError("hardware operation references must belong to one project", {
code: ERROR_CODES.invalidParams,
data: {
projectId,
gatewaySessionId: gatewaySession.gatewaySessionId,
resourceId: resource.resourceId,
capabilityId: capability.capabilityId
}
});
}
if (resource.gatewaySessionId !== gatewaySession.gatewaySessionId) {
throw new HwlabProtocolError("resource is not registered under the requested gateway session", {
code: ERROR_CODES.resourceLocked,
data: {
gatewaySessionId: gatewaySession.gatewaySessionId,
resourceGatewaySessionId: resource.gatewaySessionId,
resourceId: resource.resourceId
}
});
}
if (capability.resourceId !== resource.resourceId) {
throw new HwlabProtocolError("capability is not registered on the requested resource", {
code: ERROR_CODES.capabilityUnavailable,
data: {
resourceId: resource.resourceId,
capabilityId: capability.capabilityId
}
});
}
return {
projectId,
gatewaySession,
resource,
capability,
actor: deriveProtocolActorFromMeta(requestMeta, params.audit ?? {})
};
}
requireGatewaySession(gatewaySessionId) {
const gatewaySession = this.gatewaySessions.get(gatewaySessionId);
if (!gatewaySession) {
throw new HwlabProtocolError(`gateway session ${gatewaySessionId} is not registered`, {
code: ERROR_CODES.sessionNotFound,
data: { gatewaySessionId }
});
}
return gatewaySession;
}
requireBoxResource(resourceId) {
const resource = this.boxResources.get(resourceId);
if (!resource) {
throw new HwlabProtocolError(`box resource ${resourceId} is not registered`, {
code: ERROR_CODES.capabilityUnavailable,
data: { resourceId }
});
}
return resource;
}
requireBoxCapability(capabilityId) {
const capability = this.boxCapabilities.get(capabilityId);
if (!capability) {
throw new HwlabProtocolError(`capability ${capabilityId} is not reported`, {
code: ERROR_CODES.capabilityUnavailable,
data: { capabilityId }
});
}
return capability;
}
}
function normalizeCapabilityInputs(params) {
const value = params.capabilities ?? params.capability ?? params;
if (Array.isArray(value)) {
if (value.length === 0) {
throw new HwlabProtocolError("capabilities must not be empty", {
code: ERROR_CODES.invalidParams
});
}
return value.map((item) => asObject(item, "capability"));
}
return [asObject(value, "capability")];
}
function normalizeShellInput(params) {
const input = normalizeJsonObject(params.input);
const command = params.command ?? input.command;
if (command === undefined || command === null || command === "") {
throw new HwlabProtocolError("hardware.invoke.shell requires input.command or command", {
code: ERROR_CODES.invalidParams,
data: {
required: ["projectId", "gatewaySessionId", "resourceId", "capabilityId", "input.command"]
}
});
}
if (Array.isArray(command)) {
return {
command: command.map((item) => String(item)),
cwd: input.cwd,
timeoutMs: input.timeoutMs
};
}
return {
command: String(command),
cwd: input.cwd,
timeoutMs: input.timeoutMs
};
}
function asObject(value, label) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new HwlabProtocolError(`${label} must be a JSON object`, {
code: ERROR_CODES.invalidParams
});
}
return value;
}
function requireString(input, field) {
if (typeof input[field] !== "string" || input[field].trim() === "") {
throw new HwlabProtocolError(`${field} is required`, {
code: ERROR_CODES.invalidParams,
data: { field }
});
}
return input[field];
}
function requireProtocolId(input, field) {
const value = requireString(input, field);
return value;
}
function normalizeJsonObject(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
return { ...value };
}
function pruneUndefined(input) {
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
}
function makeId(prefix) {
return `${prefix}_${randomUUID()}`;
}
function stableJson(value) {
return JSON.stringify(sortJson(value));
}
function sha256Hex(value) {
return createHash("sha256").update(stableJson(value)).digest("hex");
}
function sortJson(value) {
if (Array.isArray(value)) {
return value.map(sortJson);
}
if (value && typeof value === "object") {
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortJson(value[key])]));
}
return value;
}
function matchesQuery(record, params, fields) {
for (const field of fields) {
if (params[field] !== undefined && params[field] !== null && record[field] !== params[field]) {
return false;
}
}
return true;
}
function limitResults(records, limit) {
const parsed = Number.parseInt(limit ?? "", 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
return records;
}
return records.slice(0, parsed);
}