1349 lines
43 KiB
JavaScript
1349 lines
43 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 const RUNTIME_STORE_KIND_POSTGRES = "postgres";
|
|
export const RUNTIME_DURABLE_ADAPTER_MISSING = "runtime_durable_adapter_missing";
|
|
export const RUNTIME_DURABLE_ADAPTER_UNCONFIGURED = "runtime_durable_adapter_unconfigured";
|
|
export const RUNTIME_DURABLE_ADAPTER_DRIVER_MISSING = "runtime_durable_adapter_driver_missing";
|
|
export const RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED = "runtime_durable_adapter_auth_blocked";
|
|
export const RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED = "runtime_durable_adapter_schema_blocked";
|
|
export const RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED = "runtime_durable_adapter_query_blocked";
|
|
|
|
export const RUNTIME_ADAPTER_ENV = "HWLAB_CLOUD_RUNTIME_ADAPTER";
|
|
export const RUNTIME_DURABLE_ENV = "HWLAB_CLOUD_RUNTIME_DURABLE";
|
|
|
|
const requiredPostgresSchema = Object.freeze({
|
|
gateway_sessions: Object.freeze([
|
|
"id",
|
|
"project_id",
|
|
"gateway_service_id",
|
|
"status",
|
|
"started_at",
|
|
"ended_at",
|
|
"gateway_session_json"
|
|
]),
|
|
box_resources: Object.freeze([
|
|
"id",
|
|
"project_id",
|
|
"gateway_session_id",
|
|
"resource_state",
|
|
"labels_json",
|
|
"resource_json",
|
|
"updated_at"
|
|
]),
|
|
box_capabilities: Object.freeze([
|
|
"id",
|
|
"box_resource_id",
|
|
"capability_type",
|
|
"capability_json",
|
|
"updated_at"
|
|
]),
|
|
hardware_operations: Object.freeze([
|
|
"id",
|
|
"project_id",
|
|
"requested_by",
|
|
"operation_type",
|
|
"operation_json",
|
|
"status",
|
|
"requested_at",
|
|
"updated_at"
|
|
]),
|
|
audit_events: Object.freeze([
|
|
"id",
|
|
"request_id",
|
|
"actor",
|
|
"source",
|
|
"operation",
|
|
"target",
|
|
"result",
|
|
"timestamp",
|
|
"event_json"
|
|
]),
|
|
evidence_records: Object.freeze([
|
|
"id",
|
|
"project_id",
|
|
"operation_id",
|
|
"evidence_type",
|
|
"uri",
|
|
"metadata_json",
|
|
"created_at"
|
|
])
|
|
});
|
|
|
|
const postgresCountTables = Object.freeze([
|
|
"gateway_sessions",
|
|
"box_resources",
|
|
"box_capabilities",
|
|
"hardware_operations",
|
|
"audit_events",
|
|
"evidence_records"
|
|
]);
|
|
|
|
export function createCloudRuntimeStore(options = {}) {
|
|
return new CloudRuntimeStore(options);
|
|
}
|
|
|
|
export function createConfiguredCloudRuntimeStore(options = {}) {
|
|
const env = options.env ?? process.env;
|
|
const adapter = normalizeRuntimeAdapter(options.adapter ?? env?.[RUNTIME_ADAPTER_ENV]);
|
|
const durableRequested =
|
|
options.durable === true ||
|
|
env?.[RUNTIME_DURABLE_ENV] === "1" ||
|
|
env?.[RUNTIME_DURABLE_ENV] === "true";
|
|
|
|
if (adapter === RUNTIME_STORE_KIND_POSTGRES || durableRequested) {
|
|
return new PostgresCloudRuntimeStore({
|
|
...options,
|
|
env,
|
|
dbUrl: options.dbUrl ?? env?.HWLAB_CLOUD_DB_URL,
|
|
sslMode: options.sslMode ?? env?.HWLAB_CLOUD_DB_SSL_MODE
|
|
});
|
|
}
|
|
|
|
return createCloudRuntimeStore(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",
|
|
blocker: RUNTIME_DURABLE_ADAPTER_MISSING,
|
|
reason: "L1 runtime writes are process-local only; no DB-backed durable runtime adapter is configured",
|
|
adapterContract: {
|
|
required: "DB-backed durable runtime adapter using HWLAB_CLOUD_DB_URL",
|
|
sourceIssue: "pikasTech/HWLAB#164",
|
|
secretMaterialRequiredInSource: false
|
|
},
|
|
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;
|
|
}
|
|
}
|
|
|
|
export class PostgresCloudRuntimeStore {
|
|
constructor({
|
|
env = process.env,
|
|
dbUrl,
|
|
sslMode = "require",
|
|
now = () => new Date().toISOString(),
|
|
queryClient,
|
|
pgModuleLoader
|
|
} = {}) {
|
|
this.env = env;
|
|
this.dbUrl = dbUrl;
|
|
this.sslMode = sslMode;
|
|
this.now = now;
|
|
this.queryClient = queryClient;
|
|
this.pgModuleLoader = pgModuleLoader;
|
|
this.pool = null;
|
|
this.memory = new CloudRuntimeStore({ now });
|
|
this.lastReadiness = this.blockedSummary({
|
|
blocker: dbUrl || queryClient ? "runtime durable adapter has not completed schema readiness" : RUNTIME_DURABLE_ADAPTER_UNCONFIGURED,
|
|
reason: dbUrl || queryClient
|
|
? "Postgres runtime adapter is configured but has not completed a live schema query"
|
|
: "Postgres runtime adapter requires HWLAB_CLOUD_DB_URL or an injected query client"
|
|
});
|
|
}
|
|
|
|
summary() {
|
|
return {
|
|
...this.lastReadiness,
|
|
counts: this.lastReadiness.counts ?? this.memory.summary().counts
|
|
};
|
|
}
|
|
|
|
async readiness() {
|
|
if (!this.dbUrl && !this.queryClient) {
|
|
this.lastReadiness = this.blockedSummary({
|
|
blocker: RUNTIME_DURABLE_ADAPTER_UNCONFIGURED,
|
|
reason: "Postgres runtime adapter is selected but HWLAB_CLOUD_DB_URL is not injected"
|
|
});
|
|
return this.summary();
|
|
}
|
|
|
|
let rows;
|
|
try {
|
|
const result = await this.query(
|
|
"SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ANY($1::text[])",
|
|
[Object.keys(requiredPostgresSchema)]
|
|
);
|
|
rows = Array.isArray(result?.rows) ? result.rows : [];
|
|
} catch (error) {
|
|
this.lastReadiness = this.blockedSummary(classifyRuntimeDbError(error));
|
|
return this.summary();
|
|
}
|
|
|
|
const schema = summarizeRuntimeSchema(rows);
|
|
if (!schema.ready) {
|
|
this.lastReadiness = this.blockedSummary({
|
|
blocker: RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED,
|
|
reason: "Postgres runtime adapter connected, but required runtime tables or columns are missing",
|
|
schema
|
|
});
|
|
return this.summary();
|
|
}
|
|
|
|
const counts = await this.readCounts().catch(() => null);
|
|
this.lastReadiness = {
|
|
adapter: RUNTIME_STORE_KIND_POSTGRES,
|
|
durable: true,
|
|
ready: true,
|
|
status: "ready",
|
|
blocker: null,
|
|
reason: "Postgres durable runtime adapter completed a live schema readiness query",
|
|
liveRuntimeEvidence: true,
|
|
fixtureEvidence: false,
|
|
connection: {
|
|
queryAttempted: true,
|
|
queryResult: "schema_ready",
|
|
endpointRedacted: true,
|
|
valueRedacted: true
|
|
},
|
|
schema,
|
|
safety: runtimeSafety(),
|
|
adapterContract: postgresAdapterContract(),
|
|
counts: counts ?? this.memory.summary().counts
|
|
};
|
|
return this.summary();
|
|
}
|
|
|
|
async registerGatewaySession(params = {}, requestMeta = {}) {
|
|
await this.assertReadyForWrites();
|
|
const before = snapshotMemory(this.memory);
|
|
const result = this.memory.registerGatewaySession(params, requestMeta);
|
|
await this.persistChanges(before);
|
|
return withPersistence(result, this.summary());
|
|
}
|
|
|
|
async registerBoxResource(params = {}, requestMeta = {}) {
|
|
await this.assertReadyForWrites();
|
|
const input = asObject(params.resource ?? params.boxResource ?? params, "boxResource");
|
|
await this.hydrateGatewaySession(input.gatewaySessionId);
|
|
const before = snapshotMemory(this.memory);
|
|
const result = this.memory.registerBoxResource(params, requestMeta);
|
|
await this.persistChanges(before);
|
|
return withPersistence(result, this.summary());
|
|
}
|
|
|
|
async reportBoxCapabilities(params = {}, requestMeta = {}) {
|
|
await this.assertReadyForWrites();
|
|
const inputs = normalizeCapabilityInputs(params);
|
|
for (const input of inputs) {
|
|
await this.hydrateBoxResource(input.resourceId);
|
|
}
|
|
const before = snapshotMemory(this.memory);
|
|
const result = this.memory.reportBoxCapabilities(params, requestMeta);
|
|
await this.persistChanges(before);
|
|
return withPersistence(result, this.summary());
|
|
}
|
|
|
|
async requestHardwareOperation(params = {}, requestMeta = {}) {
|
|
await this.assertReadyForWrites();
|
|
await this.hydrateOperationRefs(params);
|
|
const before = snapshotMemory(this.memory);
|
|
const result = this.memory.requestHardwareOperation(params, requestMeta);
|
|
await this.persistChanges(before);
|
|
return withPersistence(result, this.summary());
|
|
}
|
|
|
|
async invokeHardwareShell(params = {}, requestMeta = {}) {
|
|
await this.assertReadyForWrites();
|
|
await this.hydrateOperationRefs(params);
|
|
const before = snapshotMemory(this.memory);
|
|
const result = this.memory.invokeHardwareShell(params, requestMeta);
|
|
await this.persistChanges(before);
|
|
return withPersistence(result, this.summary());
|
|
}
|
|
|
|
async writeAuditEvent(params = {}, requestMeta = {}) {
|
|
await this.assertReadyForWrites();
|
|
const before = snapshotMemory(this.memory);
|
|
const result = this.memory.writeAuditEvent(params, requestMeta);
|
|
await this.persistChanges(before);
|
|
return withPersistence(result, this.summary());
|
|
}
|
|
|
|
async queryAuditEvents(params = {}) {
|
|
const result = await this.query(
|
|
"SELECT event_json FROM audit_events ORDER BY timestamp ASC",
|
|
[]
|
|
);
|
|
const events = result.rows
|
|
.map((row) => parseJsonColumn(row.event_json, null))
|
|
.filter(Boolean)
|
|
.filter((event) => matchesQuery(event, params, [
|
|
"auditId",
|
|
"traceId",
|
|
"projectId",
|
|
"gatewaySessionId",
|
|
"operationId",
|
|
"action",
|
|
"targetId"
|
|
]));
|
|
|
|
return {
|
|
events: limitResults(events, params.limit),
|
|
count: events.length,
|
|
persistence: this.summary()
|
|
};
|
|
}
|
|
|
|
async writeEvidenceRecord(params = {}, requestMeta = {}) {
|
|
await this.assertReadyForWrites();
|
|
const before = snapshotMemory(this.memory);
|
|
const result = this.memory.writeEvidenceRecord(params, requestMeta);
|
|
await this.persistChanges(before);
|
|
return withPersistence(result, this.summary());
|
|
}
|
|
|
|
async queryEvidenceRecords(params = {}) {
|
|
const result = await this.query(
|
|
"SELECT metadata_json FROM evidence_records ORDER BY created_at ASC",
|
|
[]
|
|
);
|
|
const records = result.rows
|
|
.map((row) => parseJsonColumn(row.metadata_json, null))
|
|
.filter(Boolean)
|
|
.filter((record) => matchesQuery(record, params, [
|
|
"evidenceId",
|
|
"projectId",
|
|
"operationId",
|
|
"agentSessionId",
|
|
"workerSessionId",
|
|
"kind",
|
|
"serviceId"
|
|
]));
|
|
|
|
return {
|
|
records: limitResults(records, params.limit),
|
|
count: records.length,
|
|
persistence: this.summary()
|
|
};
|
|
}
|
|
|
|
async hydrateOperationRefs(params = {}) {
|
|
await this.hydrateGatewaySession(params.gatewaySessionId);
|
|
await this.hydrateBoxResource(params.resourceId);
|
|
await this.hydrateBoxCapability(params.capabilityId);
|
|
}
|
|
|
|
async hydrateGatewaySession(gatewaySessionId) {
|
|
if (!gatewaySessionId || this.memory.gatewaySessions.has(gatewaySessionId)) return;
|
|
const result = await this.query("SELECT gateway_session_json FROM gateway_sessions WHERE id = $1 LIMIT 1", [
|
|
gatewaySessionId
|
|
]);
|
|
const record = parseJsonColumn(result.rows?.[0]?.gateway_session_json, null);
|
|
if (record) this.memory.gatewaySessions.set(record.gatewaySessionId, record);
|
|
}
|
|
|
|
async hydrateBoxResource(resourceId) {
|
|
if (!resourceId || this.memory.boxResources.has(resourceId)) return;
|
|
const result = await this.query("SELECT resource_json FROM box_resources WHERE id = $1 LIMIT 1", [
|
|
resourceId
|
|
]);
|
|
const record = parseJsonColumn(result.rows?.[0]?.resource_json, null);
|
|
if (record) {
|
|
await this.hydrateGatewaySession(record.gatewaySessionId);
|
|
this.memory.boxResources.set(record.resourceId, record);
|
|
}
|
|
}
|
|
|
|
async hydrateBoxCapability(capabilityId) {
|
|
if (!capabilityId || this.memory.boxCapabilities.has(capabilityId)) return;
|
|
const result = await this.query("SELECT capability_json FROM box_capabilities WHERE id = $1 LIMIT 1", [
|
|
capabilityId
|
|
]);
|
|
const record = parseJsonColumn(result.rows?.[0]?.capability_json, null);
|
|
if (record) {
|
|
await this.hydrateBoxResource(record.resourceId);
|
|
this.memory.boxCapabilities.set(record.capabilityId, record);
|
|
}
|
|
}
|
|
|
|
async persistChanges(before) {
|
|
for (const record of newRecords(this.memory.gatewaySessions, before.gatewaySessions)) {
|
|
await this.persistGatewaySession(record);
|
|
}
|
|
for (const record of newRecords(this.memory.boxResources, before.boxResources)) {
|
|
await this.persistBoxResource(record);
|
|
}
|
|
for (const record of newRecords(this.memory.boxCapabilities, before.boxCapabilities)) {
|
|
await this.persistBoxCapability(record);
|
|
}
|
|
for (const record of changedRecords(this.memory.hardwareOperations, before.hardwareOperations)) {
|
|
await this.persistHardwareOperation(record);
|
|
}
|
|
for (const record of newRecords(this.memory.auditEvents, before.auditEvents)) {
|
|
await this.persistAuditEvent(record);
|
|
}
|
|
for (const record of newRecords(this.memory.evidenceRecords, before.evidenceRecords)) {
|
|
await this.persistEvidenceRecord(record);
|
|
}
|
|
}
|
|
|
|
async assertReadyForWrites() {
|
|
const readiness = await this.readiness();
|
|
if (readiness.ready !== true) {
|
|
throw new HwlabProtocolError("Postgres durable runtime adapter is not ready for writes", {
|
|
code: ERROR_CODES.internalError,
|
|
data: {
|
|
adapter: RUNTIME_STORE_KIND_POSTGRES,
|
|
blocker: readiness.blocker,
|
|
status: readiness.status,
|
|
schemaReady: Boolean(readiness.schema?.ready)
|
|
},
|
|
context: {
|
|
result: "failed"
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
async persistGatewaySession(record) {
|
|
await this.query(
|
|
"INSERT INTO gateway_sessions (id, project_id, gateway_service_id, status, started_at, ended_at, gateway_session_json) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (id) DO UPDATE SET project_id = EXCLUDED.project_id, gateway_service_id = EXCLUDED.gateway_service_id, status = EXCLUDED.status, started_at = EXCLUDED.started_at, ended_at = EXCLUDED.ended_at, gateway_session_json = EXCLUDED.gateway_session_json",
|
|
[
|
|
record.gatewaySessionId,
|
|
record.projectId,
|
|
record.serviceId,
|
|
record.status,
|
|
record.startedAt,
|
|
record.stoppedAt ?? null,
|
|
stableJson(record)
|
|
]
|
|
);
|
|
}
|
|
|
|
async persistBoxResource(record) {
|
|
await this.query(
|
|
"INSERT INTO box_resources (id, project_id, gateway_session_id, resource_state, labels_json, resource_json, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (id) DO UPDATE SET project_id = EXCLUDED.project_id, gateway_session_id = EXCLUDED.gateway_session_id, resource_state = EXCLUDED.resource_state, labels_json = EXCLUDED.labels_json, resource_json = EXCLUDED.resource_json, updated_at = EXCLUDED.updated_at",
|
|
[
|
|
record.resourceId,
|
|
record.projectId,
|
|
record.gatewaySessionId,
|
|
record.state,
|
|
stableJson(record.metadata ?? {}),
|
|
stableJson(record),
|
|
record.updatedAt
|
|
]
|
|
);
|
|
}
|
|
|
|
async persistBoxCapability(record) {
|
|
await this.query(
|
|
"INSERT INTO box_capabilities (id, box_resource_id, capability_type, capability_json, updated_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (id) DO UPDATE SET box_resource_id = EXCLUDED.box_resource_id, capability_type = EXCLUDED.capability_type, capability_json = EXCLUDED.capability_json, updated_at = EXCLUDED.updated_at",
|
|
[
|
|
record.capabilityId,
|
|
record.resourceId,
|
|
record.name,
|
|
stableJson(record),
|
|
record.updatedAt
|
|
]
|
|
);
|
|
}
|
|
|
|
async persistHardwareOperation(record) {
|
|
await this.query(
|
|
"INSERT INTO hardware_operations (id, project_id, requested_by, operation_type, operation_json, status, requested_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (id) DO UPDATE SET project_id = EXCLUDED.project_id, requested_by = EXCLUDED.requested_by, operation_type = EXCLUDED.operation_type, operation_json = EXCLUDED.operation_json, status = EXCLUDED.status, requested_at = EXCLUDED.requested_at, updated_at = EXCLUDED.updated_at",
|
|
[
|
|
record.operationId,
|
|
record.projectId,
|
|
record.requestedBy,
|
|
record.input?.shell ? "hardware.invoke.shell" : "hardware.operation.request",
|
|
stableJson(record),
|
|
record.status,
|
|
record.requestedAt,
|
|
record.updatedAt
|
|
]
|
|
);
|
|
}
|
|
|
|
async persistAuditEvent(record) {
|
|
await this.query(
|
|
"INSERT INTO audit_events (id, request_id, actor, source, operation, target, result, timestamp, event_json) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (id) DO UPDATE SET request_id = EXCLUDED.request_id, actor = EXCLUDED.actor, source = EXCLUDED.source, operation = EXCLUDED.operation, target = EXCLUDED.target, result = EXCLUDED.result, timestamp = EXCLUDED.timestamp, event_json = EXCLUDED.event_json",
|
|
[
|
|
record.auditId,
|
|
record.traceId,
|
|
stableJson({ type: record.actorType, id: record.actorId }),
|
|
stableJson({ serviceId: record.serviceId, environment: record.environment }),
|
|
record.action,
|
|
stableJson({ type: record.targetType, id: record.targetId }),
|
|
record.outcome ?? "accepted",
|
|
record.occurredAt,
|
|
stableJson(record)
|
|
]
|
|
);
|
|
}
|
|
|
|
async persistEvidenceRecord(record) {
|
|
await this.query(
|
|
"INSERT INTO evidence_records (id, project_id, operation_id, evidence_type, uri, metadata_json, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (id) DO UPDATE SET project_id = EXCLUDED.project_id, operation_id = EXCLUDED.operation_id, evidence_type = EXCLUDED.evidence_type, uri = EXCLUDED.uri, metadata_json = EXCLUDED.metadata_json, created_at = EXCLUDED.created_at",
|
|
[
|
|
record.evidenceId,
|
|
record.projectId,
|
|
record.operationId,
|
|
record.kind,
|
|
record.uri,
|
|
stableJson(record),
|
|
record.createdAt
|
|
]
|
|
);
|
|
}
|
|
|
|
async readCounts() {
|
|
const counts = {};
|
|
for (const table of postgresCountTables) {
|
|
const result = await this.query(`SELECT COUNT(*)::int AS count FROM ${table}`, []);
|
|
counts[toCountKey(table)] = Number(result.rows?.[0]?.count ?? 0);
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
async query(sql, params = []) {
|
|
const client = await this.getQueryClient();
|
|
return client.query(sql, params);
|
|
}
|
|
|
|
async getQueryClient() {
|
|
if (this.queryClient) {
|
|
return this.queryClient;
|
|
}
|
|
|
|
if (this.pool) {
|
|
return this.pool;
|
|
}
|
|
|
|
let pg;
|
|
try {
|
|
pg = await (this.pgModuleLoader ? this.pgModuleLoader() : import("pg"));
|
|
} catch (error) {
|
|
if (error?.code === "ERR_MODULE_NOT_FOUND") {
|
|
const driverError = new Error("Postgres runtime adapter requires the pg package");
|
|
driverError.code = "HWLAB_PG_DRIVER_MISSING";
|
|
throw driverError;
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
const Pool = pg.Pool ?? pg.default?.Pool;
|
|
if (typeof Pool !== "function") {
|
|
const driverError = new Error("Postgres runtime adapter could not load pg.Pool");
|
|
driverError.code = "HWLAB_PG_DRIVER_MISSING";
|
|
throw driverError;
|
|
}
|
|
|
|
this.pool = new Pool({
|
|
connectionString: this.dbUrl,
|
|
ssl: this.sslMode === "require" ? { rejectUnauthorized: false } : false,
|
|
connectionTimeoutMillis: normalizeRuntimeTimeoutMs(this.env?.HWLAB_CLOUD_DB_PROBE_TIMEOUT_MS)
|
|
});
|
|
return this.pool;
|
|
}
|
|
|
|
blockedSummary({ blocker, reason, schema, connection }) {
|
|
return {
|
|
adapter: RUNTIME_STORE_KIND_POSTGRES,
|
|
durable: true,
|
|
ready: false,
|
|
status: "blocked",
|
|
blocker,
|
|
reason,
|
|
liveRuntimeEvidence: false,
|
|
fixtureEvidence: false,
|
|
connection: {
|
|
queryAttempted: Boolean(connection?.queryAttempted),
|
|
queryResult: connection?.queryResult ?? "not_ready",
|
|
endpointRedacted: true,
|
|
valueRedacted: true,
|
|
errorCode: connection?.errorCode ?? null
|
|
},
|
|
schema: schema ?? {
|
|
ready: false,
|
|
checked: false,
|
|
missingTables: [],
|
|
missingColumns: []
|
|
},
|
|
safety: runtimeSafety(),
|
|
adapterContract: postgresAdapterContract()
|
|
};
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
function normalizeRuntimeAdapter(value) {
|
|
if (typeof value !== "string" || value.trim() === "") {
|
|
return RUNTIME_STORE_KIND;
|
|
}
|
|
const normalized = value.trim().toLowerCase();
|
|
if (["postgres", "postgresql", "pg", "db"].includes(normalized)) {
|
|
return RUNTIME_STORE_KIND_POSTGRES;
|
|
}
|
|
return RUNTIME_STORE_KIND;
|
|
}
|
|
|
|
function postgresAdapterContract() {
|
|
return {
|
|
required: "Postgres-backed durable runtime adapter using HWLAB_CLOUD_DB_URL",
|
|
adapterEnv: RUNTIME_ADAPTER_ENV,
|
|
adapterEnvValue: RUNTIME_STORE_KIND_POSTGRES,
|
|
sourceIssue: "pikasTech/HWLAB#164",
|
|
secretMaterialRequiredInSource: false,
|
|
fixtureEvidenceAllowed: false
|
|
};
|
|
}
|
|
|
|
function runtimeSafety() {
|
|
return {
|
|
devOnly: true,
|
|
secretsRead: false,
|
|
secretMaterialRead: false,
|
|
valuesRedacted: true,
|
|
endpointRedacted: true
|
|
};
|
|
}
|
|
|
|
function classifyRuntimeDbError(error) {
|
|
const code = typeof error?.code === "string" ? error.code : "UNKNOWN";
|
|
if (code === "HWLAB_PG_DRIVER_MISSING") {
|
|
return {
|
|
blocker: RUNTIME_DURABLE_ADAPTER_DRIVER_MISSING,
|
|
reason: "Postgres runtime adapter is selected, but the pg package is not installed in the runtime image",
|
|
connection: {
|
|
queryAttempted: false,
|
|
queryResult: "driver_missing",
|
|
errorCode: code
|
|
}
|
|
};
|
|
}
|
|
if (["28P01", "28000", "42501"].includes(code)) {
|
|
return {
|
|
blocker: RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED,
|
|
reason: "Postgres runtime adapter reached the DB driver but authentication or authorization blocked the schema query",
|
|
connection: {
|
|
queryAttempted: true,
|
|
queryResult: "auth_blocked",
|
|
errorCode: code
|
|
}
|
|
};
|
|
}
|
|
if (["42P01", "42703", "3F000"].includes(code)) {
|
|
return {
|
|
blocker: RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED,
|
|
reason: "Postgres runtime adapter reached the database, but the runtime schema is missing or incompatible",
|
|
connection: {
|
|
queryAttempted: true,
|
|
queryResult: "schema_blocked",
|
|
errorCode: code
|
|
}
|
|
};
|
|
}
|
|
return {
|
|
blocker: RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED,
|
|
reason: "Postgres runtime adapter could not complete a live runtime schema query",
|
|
connection: {
|
|
queryAttempted: true,
|
|
queryResult: "query_blocked",
|
|
errorCode: code
|
|
}
|
|
};
|
|
}
|
|
|
|
function summarizeRuntimeSchema(rows) {
|
|
const columnsByTable = new Map();
|
|
for (const row of rows) {
|
|
const table = row.table_name;
|
|
const column = row.column_name;
|
|
if (!table || !column) continue;
|
|
if (!columnsByTable.has(table)) {
|
|
columnsByTable.set(table, new Set());
|
|
}
|
|
columnsByTable.get(table).add(column);
|
|
}
|
|
|
|
const missingTables = [];
|
|
const missingColumns = [];
|
|
for (const [table, columns] of Object.entries(requiredPostgresSchema)) {
|
|
const observed = columnsByTable.get(table);
|
|
if (!observed) {
|
|
missingTables.push(table);
|
|
missingColumns.push(...columns.map((column) => `${table}.${column}`));
|
|
continue;
|
|
}
|
|
for (const column of columns) {
|
|
if (!observed.has(column)) {
|
|
missingColumns.push(`${table}.${column}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
ready: missingTables.length === 0 && missingColumns.length === 0,
|
|
checked: true,
|
|
requiredTables: Object.keys(requiredPostgresSchema),
|
|
missingTables,
|
|
missingColumns
|
|
};
|
|
}
|
|
|
|
function snapshotMemory(memory) {
|
|
return {
|
|
gatewaySessions: new Map(memory.gatewaySessions),
|
|
boxResources: new Map(memory.boxResources),
|
|
boxCapabilities: new Map(memory.boxCapabilities),
|
|
hardwareOperations: new Map(memory.hardwareOperations),
|
|
auditEvents: new Map(memory.auditEvents),
|
|
evidenceRecords: new Map(memory.evidenceRecords)
|
|
};
|
|
}
|
|
|
|
function newRecords(current, before) {
|
|
return [...current.entries()]
|
|
.filter(([id]) => !before.has(id))
|
|
.map(([, record]) => record);
|
|
}
|
|
|
|
function changedRecords(current, before) {
|
|
return [...current.entries()]
|
|
.filter(([id, record]) => stableJson(before.get(id)) !== stableJson(record))
|
|
.map(([, record]) => record);
|
|
}
|
|
|
|
function withPersistence(result, persistence) {
|
|
return {
|
|
...result,
|
|
persistence
|
|
};
|
|
}
|
|
|
|
function parseJsonColumn(value, fallback) {
|
|
if (!value) return fallback;
|
|
if (typeof value === "object") return value;
|
|
try {
|
|
return JSON.parse(String(value));
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function toCountKey(table) {
|
|
if (table === "gateway_sessions") return "gatewaySessions";
|
|
if (table === "box_resources") return "boxResources";
|
|
if (table === "box_capabilities") return "boxCapabilities";
|
|
if (table === "hardware_operations") return "hardwareOperations";
|
|
if (table === "audit_events") return "auditEvents";
|
|
if (table === "evidence_records") return "evidenceRecords";
|
|
return table;
|
|
}
|
|
|
|
function normalizeRuntimeTimeoutMs(value) {
|
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
return 1200;
|
|
}
|
|
return Math.min(parsed, 5000);
|
|
}
|