886 lines
32 KiB
TypeScript
886 lines
32 KiB
TypeScript
/*
|
|
* In-memory Cloud runtime store implementation.
|
|
* Public construction remains in runtime-store.ts.
|
|
*/
|
|
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";
|
|
import {
|
|
CLOUD_RUNTIME_DURABLE_ADAPTER_SCHEMA_VERSION,
|
|
CLOUD_RUNTIME_DURABLE_MIGRATION_TABLE,
|
|
CLOUD_RUNTIME_DURABLE_TABLE_COLUMNS
|
|
} from "./schema.ts";
|
|
|
|
|
|
import * as runtimeCore from "./runtime-store-core.ts";
|
|
|
|
const {
|
|
RUNTIME_STORE_KIND,
|
|
RUNTIME_STORE_KIND_POSTGRES,
|
|
RUNTIME_DURABLE_ADAPTER_MISSING,
|
|
RUNTIME_DURABLE_ADAPTER_UNCONFIGURED,
|
|
RUNTIME_DURABLE_ADAPTER_DRIVER_MISSING,
|
|
RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED,
|
|
RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED,
|
|
RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED,
|
|
RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED,
|
|
RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED,
|
|
RUNTIME_DURABILITY_REQUIRED_EVIDENCE,
|
|
RUNTIME_ADAPTER_ENV,
|
|
RUNTIME_DURABLE_ENV,
|
|
RUNTIME_DB_POOL_MAX_ENV,
|
|
requiredPostgresSchema,
|
|
postgresCountTables,
|
|
postgresRuntimeReadIndexes,
|
|
buildPostgresPoolConfig,
|
|
RUNTIME_DB_QUERY_TIMEOUT_MS_ENV,
|
|
RUNTIME_DB_QUERY_RETRY_MAX_ATTEMPTS_ENV,
|
|
RUNTIME_DB_QUERY_RETRY_INITIAL_DELAY_MS_ENV,
|
|
RUNTIME_DB_QUERY_RETRY_MAX_DELAY_MS_ENV,
|
|
RUNTIME_DB_POOL_RESET_COOLDOWN_MS_ENV,
|
|
DEFAULT_RUNTIME_DB_QUERY_RETRY_MAX_ATTEMPTS,
|
|
DEFAULT_RUNTIME_DB_QUERY_RETRY_INITIAL_DELAY_MS,
|
|
DEFAULT_RUNTIME_DB_QUERY_RETRY_MAX_DELAY_MS,
|
|
DEFAULT_RUNTIME_DB_POOL_RESET_COOLDOWN_MS,
|
|
normalizePositiveInteger,
|
|
delayRuntimeDbQueryRetry,
|
|
isReadyForDurableRuntimeRead,
|
|
normalizeCapabilityInputs,
|
|
normalizeShellInput,
|
|
normalizeAgentTraceEvent,
|
|
normalizeWorkbenchProjectionState,
|
|
normalizeWorkbenchProjectionAllocation,
|
|
normalizeWorkbenchFacts,
|
|
normalizeWorkbenchAggregateEventsForFacts,
|
|
appendWorkbenchFactEvent,
|
|
workbenchAggregateForFact,
|
|
normalizeWorkbenchAggregateEvent,
|
|
workbenchAggregateEventType,
|
|
workbenchInputDelivery,
|
|
workbenchInputStatus,
|
|
indexWorkbenchAggregateEvents,
|
|
normalizeWorkbenchSessionInputFact,
|
|
memoryWorkbenchSessionInputWithSeq,
|
|
inputFactWithAggregateSeq,
|
|
workbenchSessionInputWithSeq,
|
|
workbenchSessionInputPromotedSeq,
|
|
normalizeWorkbenchSessionFact,
|
|
normalizeWorkbenchMessageFact,
|
|
normalizeWorkbenchPartFact,
|
|
normalizeWorkbenchTurnFact,
|
|
assertWorkbenchTurnFinalResponseInvariant,
|
|
normalizeWorkbenchFactStatus,
|
|
workbenchFactFinalResponseText,
|
|
normalizeWorkbenchTraceEventFact,
|
|
normalizeWorkbenchProjectionCheckpoint,
|
|
arrayInput,
|
|
stableWorkbenchFactId,
|
|
requiredWorkbenchFactError,
|
|
compareTraceEventRecords,
|
|
compareProjectionStateRecords,
|
|
compareWorkbenchFactRecords,
|
|
compareWorkbenchInputFactRecords,
|
|
compareWorkbenchPartFactRecords,
|
|
compareWorkbenchTraceEventFacts,
|
|
sortWorkbenchFactRows,
|
|
WORKBENCH_FACT_FAMILIES,
|
|
workbenchFactFamilySet,
|
|
workbenchFactFamilyForTable,
|
|
workbenchSessionSummarySelectClause,
|
|
workbenchSessionSummaryFactFromRow,
|
|
workbenchFactOrder,
|
|
traceEventLevel,
|
|
positiveIntegerOrNull,
|
|
nonNegativeInteger,
|
|
nonNegativeIntegerOrNull,
|
|
projectionStatusOr,
|
|
projectionHealthOr,
|
|
resultSyncStateOr,
|
|
timestampOr,
|
|
textOr,
|
|
textList,
|
|
asObject,
|
|
requireString,
|
|
requireProtocolId,
|
|
normalizeJsonObject,
|
|
pruneUndefined,
|
|
makeId,
|
|
stableJson,
|
|
sha256Hex,
|
|
sortJson,
|
|
matchesQuery,
|
|
matchesWorkbenchSessionFactQuery,
|
|
limitResults,
|
|
normalizeRuntimeAdapter,
|
|
normalizePostgresSslMode,
|
|
postgresSslModeFromConnectionString,
|
|
postgresSslOption,
|
|
normalizePostgresConnectionStringForSslMode,
|
|
postgresAdapterContract,
|
|
addRuntimeDurabilityContract,
|
|
durabilityBlockedLayer,
|
|
runtimeSafety,
|
|
classifyRuntimeDbError,
|
|
isRuntimeDbConnectTimeout,
|
|
durableRuntimeReadBlockedError,
|
|
durableRuntimeReadBlockedData,
|
|
isRuntimeDbSslError,
|
|
summarizeRuntimeSchema,
|
|
notCheckedRuntimeMigration,
|
|
blockedRuntimeMigration,
|
|
runtimeGates,
|
|
defaultRuntimeGates,
|
|
defaultSchemaGate,
|
|
defaultMigrationGate,
|
|
readyGate,
|
|
blockedGate,
|
|
notCheckedGate,
|
|
gateFromReadiness,
|
|
snapshotMemory,
|
|
newRecords,
|
|
changedRecords,
|
|
withPersistence,
|
|
parseJsonColumn,
|
|
postgresPoolStats,
|
|
toCountKey,
|
|
normalizeRuntimeTimeoutMs,
|
|
normalizeRuntimePoolMax
|
|
} = runtimeCore;
|
|
|
|
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();
|
|
this.agentTraceEvents = new Map();
|
|
this.workbenchProjectionStates = new Map();
|
|
this.workbenchSessions = new Map();
|
|
this.workbenchMessages = new Map();
|
|
this.workbenchParts = new Map();
|
|
this.workbenchTurns = new Map();
|
|
this.workbenchTraceEvents = new Map();
|
|
this.workbenchEventSequences = new Map();
|
|
this.workbenchEvents = new Map();
|
|
this.workbenchSessionInputs = new Map();
|
|
this.workbenchProjectionCheckpoints = new Map();
|
|
}
|
|
|
|
summary() {
|
|
return addRuntimeDurabilityContract({
|
|
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,
|
|
agentTraceEvents: this.agentTraceEvents.size,
|
|
workbenchProjectionStates: this.workbenchProjectionStates.size,
|
|
workbenchSessions: this.workbenchSessions.size,
|
|
workbenchMessages: this.workbenchMessages.size,
|
|
workbenchParts: this.workbenchParts.size,
|
|
workbenchTurns: this.workbenchTurns.size,
|
|
workbenchTraceEvents: this.workbenchTraceEvents.size,
|
|
workbenchEventSequences: this.workbenchEventSequences.size,
|
|
workbenchEvents: this.workbenchEvents.size,
|
|
workbenchSessionInputs: this.workbenchSessionInputs.size,
|
|
workbenchProjectionCheckpoints: this.workbenchProjectionCheckpoints.size
|
|
}
|
|
});
|
|
}
|
|
|
|
async workbenchTransactionalRealtimeReadiness() {
|
|
return {
|
|
ready: true,
|
|
adapter: RUNTIME_STORE_KIND,
|
|
durable: false,
|
|
testRuntime: true,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
async assertWorkbenchTransactionalRealtimeReady() {
|
|
return this.workbenchTransactionalRealtimeReadiness();
|
|
}
|
|
|
|
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",
|
|
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()
|
|
};
|
|
}
|
|
|
|
writeAgentTraceEvent(params = {}, requestMeta = {}) {
|
|
const traceEvent = normalizeAgentTraceEvent(params.traceEvent ?? params.event ?? params, requestMeta, this.now());
|
|
this.agentTraceEvents.set(traceEvent.id, traceEvent);
|
|
return {
|
|
written: true,
|
|
traceEvent,
|
|
persistence: this.summary()
|
|
};
|
|
}
|
|
|
|
queryAgentTraceEvents(params = {}) {
|
|
const records = [...this.agentTraceEvents.values()].filter((record) => matchesQuery(record, params, [
|
|
"id",
|
|
"traceId",
|
|
"agentSessionId",
|
|
"workerSessionId",
|
|
"level"
|
|
]));
|
|
records.sort(compareTraceEventRecords);
|
|
return {
|
|
events: limitResults(records.map((record) => record.event), params.limit),
|
|
count: records.length,
|
|
persistence: this.summary()
|
|
};
|
|
}
|
|
|
|
writeWorkbenchProjectionState(params = {}, requestMeta = {}) {
|
|
const state = normalizeWorkbenchProjectionState(params.projectionState ?? params.state ?? params, requestMeta, this.now());
|
|
this.workbenchProjectionStates.set(state.traceId, state);
|
|
return {
|
|
written: true,
|
|
projectionState: state,
|
|
persistence: this.summary()
|
|
};
|
|
}
|
|
|
|
getWorkbenchProjectionState(params = {}) {
|
|
const traceId = textOr(params.traceId, "");
|
|
const state = traceId ? this.workbenchProjectionStates.get(traceId) ?? null : null;
|
|
return {
|
|
projectionState: state,
|
|
found: Boolean(state),
|
|
persistence: this.summary()
|
|
};
|
|
}
|
|
|
|
allocateWorkbenchProjectedSeq(params = {}, requestMeta = {}) {
|
|
const allocation = normalizeWorkbenchProjectionAllocation(params, requestMeta, this.now());
|
|
const existing = [...this.workbenchTraceEvents.values()].find((record) =>
|
|
record.traceId === allocation.traceId && record.sourceEventId === allocation.sourceEventId
|
|
);
|
|
if (existing) {
|
|
return {
|
|
...allocation,
|
|
projectedSeq: nonNegativeInteger(existing.projectedSeq),
|
|
reused: true,
|
|
persistence: this.summary(),
|
|
valuesPrinted: false
|
|
};
|
|
}
|
|
const eventMax = [...this.workbenchTraceEvents.values()]
|
|
.filter((record) => record.traceId === allocation.traceId)
|
|
.reduce((max, record) => Math.max(max, nonNegativeInteger(record.projectedSeq)), 0);
|
|
const checkpointMax = nonNegativeInteger(this.workbenchProjectionCheckpoints.get(allocation.traceId)?.projectedSeq);
|
|
return {
|
|
...allocation,
|
|
projectedSeq: Math.max(eventMax, checkpointMax) + 1,
|
|
reused: false,
|
|
persistence: this.summary(),
|
|
valuesPrinted: false
|
|
};
|
|
}
|
|
|
|
queryWorkbenchProjectionStates(params = {}) {
|
|
const dueAt = textOr(params.dueAt, "");
|
|
const statuses = Array.isArray(params.projectionStatuses) ? new Set(params.projectionStatuses.map((item) => textOr(item, "")).filter(Boolean)) : null;
|
|
const records = [...this.workbenchProjectionStates.values()]
|
|
.filter((record) => matchesQuery(record, params, ["traceId", "sessionId", "runId", "commandId", "sourceRunId", "sourceCommandId"]))
|
|
.filter((record) => !statuses || statuses.has(record.projectionStatus))
|
|
.filter((record) => !dueAt || !record.nextRetryAt || String(record.nextRetryAt) <= dueAt)
|
|
.sort(compareProjectionStateRecords);
|
|
return {
|
|
states: limitResults(records, params.limit),
|
|
count: records.length,
|
|
persistence: this.summary()
|
|
};
|
|
}
|
|
|
|
writeWorkbenchFacts(params = {}, requestMeta = {}) {
|
|
const facts = normalizeWorkbenchFacts(params.facts ?? params, requestMeta, this.now());
|
|
const inputFacts = facts.inputs.map((fact) => memoryWorkbenchSessionInputWithSeq(fact, this.workbenchSessionInputs));
|
|
for (const fact of inputFacts) this.workbenchSessionInputs.set(fact.inputId, fact);
|
|
for (const fact of facts.sessions) this.workbenchSessions.set(fact.sessionId, fact);
|
|
for (const fact of facts.messages) {
|
|
const previous = this.workbenchMessages.get(fact.messageId) ?? null;
|
|
this.workbenchMessages.set(fact.messageId, previous && (nonNegativeInteger(previous.projectedSeq) > 0 || previous.sealed === true) ? previous : fact);
|
|
}
|
|
for (const fact of facts.parts) this.workbenchParts.set(fact.partId, fact);
|
|
for (const fact of facts.turns) {
|
|
const previous = this.workbenchTurns.get(fact.turnId) ?? null;
|
|
this.workbenchTurns.set(fact.turnId, previous && (nonNegativeInteger(previous.projectedSeq) > 0 || previous.sealed === true) ? previous : fact);
|
|
}
|
|
for (const fact of facts.traceEvents) this.workbenchTraceEvents.set(fact.id, fact);
|
|
for (const fact of facts.checkpoints) {
|
|
const previous = this.workbenchProjectionCheckpoints.get(fact.traceId) ?? null;
|
|
if (!previous || nonNegativeInteger(fact.projectedSeq) >= nonNegativeInteger(previous.projectedSeq)) {
|
|
this.workbenchProjectionCheckpoints.set(fact.traceId, fact);
|
|
}
|
|
}
|
|
return {
|
|
written: true,
|
|
facts: { ...facts, inputs: inputFacts },
|
|
persistence: this.summary()
|
|
};
|
|
}
|
|
|
|
writeWorkbenchSessionAdmissionFact(params = {}, requestMeta = {}) {
|
|
const facts = normalizeWorkbenchFacts({ sessions: [params.fact ?? params.session ?? params] }, requestMeta, this.now());
|
|
const fact = facts.sessions[0];
|
|
if (!fact?.sessionId) throw new Error("Workbench session admission fact requires sessionId.");
|
|
const previous = this.workbenchSessions.get(fact.sessionId) ?? null;
|
|
const projected = previous && (nonNegativeInteger(previous.projectedSeq) > 0 || previous.sealed === true);
|
|
const ownership = {
|
|
ownerUserId: previous?.ownerUserId ?? fact.ownerUserId ?? null,
|
|
ownerRole: previous?.ownerRole ?? fact.ownerRole ?? null,
|
|
projectId: previous?.projectId ?? fact.projectId ?? null,
|
|
conversationId: previous?.conversationId ?? fact.conversationId ?? null,
|
|
threadId: previous?.threadId ?? fact.threadId ?? null
|
|
};
|
|
const stored = projected
|
|
? { ...previous, ...ownership }
|
|
: { ...previous, ...fact, ...ownership, createdAt: previous?.createdAt ?? fact.createdAt };
|
|
this.workbenchSessions.set(fact.sessionId, stored);
|
|
return { written: true, admissionOnly: true, fact: stored, persistence: this.summary(), valuesPrinted: false };
|
|
}
|
|
|
|
promoteWorkbenchTurnAdmission(params = {}, requestMeta = {}) {
|
|
const facts = normalizeWorkbenchFacts(params.facts ?? params, requestMeta, this.now());
|
|
const sessionFact = facts.sessions[0];
|
|
const inputFact = facts.inputs[0];
|
|
const turnFact = facts.turns[0];
|
|
if (!sessionFact?.sessionId || !inputFact?.inputId || !turnFact?.turnId) throw new Error("Workbench turn admission promotion requires session, input, and turn facts.");
|
|
const storedSession = this.writeWorkbenchSessionAdmissionFact({ fact: sessionFact }, requestMeta).fact;
|
|
const storedInput = memoryWorkbenchSessionInputWithSeq(inputFact, this.workbenchSessionInputs);
|
|
this.workbenchSessionInputs.set(storedInput.inputId, storedInput);
|
|
for (const fact of facts.messages) {
|
|
const previous = this.workbenchMessages.get(fact.messageId) ?? null;
|
|
if (!previous || (nonNegativeInteger(previous.projectedSeq) <= 0 && previous.sealed !== true)) this.workbenchMessages.set(fact.messageId, fact);
|
|
}
|
|
for (const fact of facts.parts) {
|
|
const previous = this.workbenchParts.get(fact.partId) ?? null;
|
|
if (!previous || (nonNegativeInteger(previous.projectedSeq) <= 0 && previous.sealed !== true)) this.workbenchParts.set(fact.partId, fact);
|
|
}
|
|
for (const fact of facts.turns) {
|
|
const previous = this.workbenchTurns.get(fact.turnId) ?? null;
|
|
if (!previous || (nonNegativeInteger(previous.projectedSeq) <= 0 && previous.sealed !== true)) this.workbenchTurns.set(fact.turnId, fact);
|
|
}
|
|
return {
|
|
written: true,
|
|
promoted: true,
|
|
outboxCommitted: true,
|
|
facts: { ...facts, sessions: [storedSession], inputs: [storedInput] },
|
|
persistence: this.summary(),
|
|
valuesPrinted: false
|
|
};
|
|
}
|
|
|
|
queryWorkbenchFacts(params = {}) {
|
|
const families = workbenchFactFamilySet(params);
|
|
const sessions = [...this.workbenchSessions.values()].filter((record) => matchesWorkbenchSessionFactQuery(record, params));
|
|
const inputs = [...this.workbenchSessionInputs.values()].filter((record) => matchesQuery(record, params, ["inputId", "sessionId", "turnId", "traceId", "messageId", "commandId", "delivery", "status"]));
|
|
const messages = [...this.workbenchMessages.values()].filter((record) => matchesQuery(record, params, ["messageId", "sessionId", "turnId", "traceId", "role", "status"]));
|
|
const parts = [...this.workbenchParts.values()].filter((record) => matchesQuery(record, params, ["partId", "messageId", "sessionId", "turnId", "traceId", "partType", "status"]));
|
|
const turns = [...this.workbenchTurns.values()].filter((record) => matchesQuery(record, params, ["turnId", "sessionId", "traceId", "messageId", "status"]));
|
|
const afterProjectedSeq = nonNegativeInteger(params.afterProjectedSeq);
|
|
const traceEvents = [...this.workbenchTraceEvents.values()].filter((record) => matchesQuery(record, params, ["id", "traceId", "sessionId", "turnId", "messageId", "eventType"]) && (afterProjectedSeq <= 0 || nonNegativeInteger(record.projectedSeq) > afterProjectedSeq));
|
|
const checkpoints = [...this.workbenchProjectionCheckpoints.values()].filter((record) => matchesQuery(record, params, ["traceId", "sessionId", "turnId", "runId", "commandId", "projectionStatus", "projectionHealth"]));
|
|
const facts = {
|
|
inputs: families.has("inputs") ? limitResults(sortWorkbenchFactRows(inputs, params, "inputs", compareWorkbenchFactRecords), params.limit) : [],
|
|
sessions: families.has("sessions") ? limitResults(sortWorkbenchFactRows(sessions, params, "sessions", compareWorkbenchFactRecords), params.limit) : [],
|
|
messages: families.has("messages") ? limitResults(sortWorkbenchFactRows(messages, params, "messages", compareWorkbenchFactRecords), params.limit) : [],
|
|
parts: families.has("parts") ? limitResults(sortWorkbenchFactRows(parts, params, "parts", compareWorkbenchPartFactRecords), params.limit) : [],
|
|
turns: families.has("turns") ? limitResults(sortWorkbenchFactRows(turns, params, "turns", compareWorkbenchFactRecords), params.limit) : [],
|
|
traceEvents: families.has("traceEvents") ? limitResults(sortWorkbenchFactRows(traceEvents, params, "traceEvents", compareWorkbenchTraceEventFacts), params.limit) : [],
|
|
checkpoints: families.has("checkpoints") ? limitResults(sortWorkbenchFactRows(checkpoints, params, "checkpoints", compareWorkbenchFactRecords), params.limit) : []
|
|
};
|
|
return {
|
|
facts,
|
|
count: Object.values(facts).reduce((sum, rows) => sum + rows.length, 0),
|
|
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;
|
|
}
|
|
}
|