1559 lines
63 KiB
TypeScript
1559 lines
63 KiB
TypeScript
/*
|
|
* SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-0102 Agent编排 draft-2026-06-17-r0; PJ2026-0104010803 Workbench唯一投影 draft-2026-06-19-p1-agentrun-incremental-cursor; draft-2026-06-20-p1-zero-split-durable-realtime; draft-2026-06-24-p0-aggregate-event-stream; PJ2026-010205 HWLAB接入 draft-2026-06-25-p0-session-warm-runner-contract.
|
|
* 职责: Cloud runtime durable store。Code Agent trace/session/projection cursor 投影事实必须从同一持久化适配器写入和恢复。
|
|
*/
|
|
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_CORE_MIGRATION_ID,
|
|
CLOUD_RUNTIME_DURABLE_ADAPTER_SCHEMA_VERSION,
|
|
CLOUD_RUNTIME_DURABLE_MIGRATION_TABLE,
|
|
CLOUD_RUNTIME_DURABLE_TABLE_COLUMNS
|
|
} from "./schema.ts";
|
|
|
|
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_SSL_BLOCKED = "runtime_durable_adapter_ssl_blocked";
|
|
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_MIGRATION_BLOCKED = "runtime_durable_adapter_migration_blocked";
|
|
export const RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED = "runtime_durable_adapter_query_blocked";
|
|
export const RUNTIME_DURABILITY_REQUIRED_EVIDENCE = "runtime_adapter_schema_migration_read_query";
|
|
|
|
export const RUNTIME_ADAPTER_ENV = "HWLAB_CLOUD_RUNTIME_ADAPTER";
|
|
export const RUNTIME_DURABLE_ENV = "HWLAB_CLOUD_RUNTIME_DURABLE";
|
|
export const RUNTIME_DB_POOL_MAX_ENV = "HWLAB_CLOUD_DB_POOL_MAX";
|
|
|
|
export const requiredPostgresSchema = CLOUD_RUNTIME_DURABLE_TABLE_COLUMNS;
|
|
|
|
export const postgresCountTables = Object.freeze([
|
|
"users",
|
|
"user_sessions",
|
|
"gateway_sessions",
|
|
"box_resources",
|
|
"box_capabilities",
|
|
"hardware_operations",
|
|
"audit_events",
|
|
"evidence_records",
|
|
"agent_sessions",
|
|
"account_workspaces",
|
|
"worker_sessions",
|
|
"agent_trace_events",
|
|
"workbench_projection_state",
|
|
"workbench_sessions",
|
|
"workbench_messages",
|
|
"workbench_parts",
|
|
"workbench_turns",
|
|
"workbench_trace_events",
|
|
"workbench_event_sequences",
|
|
"workbench_events",
|
|
"workbench_session_inputs",
|
|
"workbench_projection_checkpoints"
|
|
]);
|
|
|
|
export const postgresRuntimeReadIndexes = Object.freeze([
|
|
"CREATE INDEX IF NOT EXISTS idx_agent_trace_events_trace_order ON agent_trace_events(trace_id, occurred_at, id)",
|
|
"CREATE INDEX IF NOT EXISTS idx_agent_trace_events_session_order ON agent_trace_events(agent_session_id, occurred_at, id)",
|
|
"CREATE INDEX IF NOT EXISTS idx_agent_trace_events_worker_order ON agent_trace_events(worker_session_id, occurred_at, id)",
|
|
"CREATE INDEX IF NOT EXISTS idx_workbench_projection_state_status_retry_updated ON workbench_projection_state(projection_status, next_retry_at, updated_at)",
|
|
"CREATE INDEX IF NOT EXISTS idx_workbench_projection_state_run_command ON workbench_projection_state(run_id, command_id)",
|
|
"CREATE INDEX IF NOT EXISTS idx_workbench_projection_state_session_updated ON workbench_projection_state(session_id, updated_at DESC)",
|
|
"CREATE INDEX IF NOT EXISTS idx_workbench_sessions_updated ON workbench_sessions(updated_at DESC, session_id)",
|
|
"CREATE INDEX IF NOT EXISTS idx_workbench_sessions_owner_updated ON workbench_sessions(owner_user_id, updated_at DESC, session_id)",
|
|
"CREATE INDEX IF NOT EXISTS idx_workbench_sessions_status_updated ON workbench_sessions(status, updated_at DESC, session_id)",
|
|
"CREATE INDEX IF NOT EXISTS idx_workbench_messages_session_updated ON workbench_messages(session_id, updated_at ASC, message_id)",
|
|
"CREATE INDEX IF NOT EXISTS idx_workbench_messages_trace ON workbench_messages(trace_id, message_id)",
|
|
"CREATE INDEX IF NOT EXISTS idx_workbench_messages_turn ON workbench_messages(turn_id, message_id)",
|
|
"CREATE INDEX IF NOT EXISTS idx_workbench_parts_message_index ON workbench_parts(message_id, part_index, part_id)",
|
|
"CREATE INDEX IF NOT EXISTS idx_workbench_parts_session_trace ON workbench_parts(session_id, trace_id, part_index)",
|
|
"CREATE INDEX IF NOT EXISTS idx_workbench_turns_session_updated ON workbench_turns(session_id, updated_at DESC, turn_id)",
|
|
"CREATE INDEX IF NOT EXISTS idx_workbench_turns_trace ON workbench_turns(trace_id, turn_id)",
|
|
"CREATE INDEX IF NOT EXISTS idx_workbench_trace_events_trace_projected_seq ON workbench_trace_events(trace_id, projected_seq)",
|
|
"CREATE INDEX IF NOT EXISTS idx_workbench_trace_events_session_projected_seq ON workbench_trace_events(session_id, projected_seq)",
|
|
"CREATE INDEX IF NOT EXISTS idx_workbench_event_sequences_session ON workbench_event_sequences(session_id, updated_at DESC, aggregate_id)",
|
|
"CREATE INDEX IF NOT EXISTS idx_workbench_event_sequences_trace ON workbench_event_sequences(trace_id, updated_at DESC, aggregate_id)",
|
|
"CREATE INDEX IF NOT EXISTS idx_workbench_events_trace_seq ON workbench_events(trace_id, event_seq)",
|
|
"CREATE INDEX IF NOT EXISTS idx_workbench_events_session_seq ON workbench_events(session_id, event_seq)",
|
|
"CREATE INDEX IF NOT EXISTS idx_workbench_events_type_seq ON workbench_events(event_type, event_seq)",
|
|
"CREATE INDEX IF NOT EXISTS idx_workbench_session_inputs_session_seq ON workbench_session_inputs(session_id, admitted_seq, input_id)",
|
|
"CREATE INDEX IF NOT EXISTS idx_workbench_session_inputs_trace ON workbench_session_inputs(trace_id, input_id)",
|
|
"CREATE INDEX IF NOT EXISTS idx_workbench_session_inputs_command ON workbench_session_inputs(command_id, input_id) WHERE command_id IS NOT NULL",
|
|
"CREATE INDEX IF NOT EXISTS idx_workbench_projection_checkpoints_status_updated ON workbench_projection_checkpoints(projection_status, updated_at DESC, trace_id)",
|
|
"CREATE INDEX IF NOT EXISTS idx_workbench_projection_checkpoints_session_updated ON workbench_projection_checkpoints(session_id, updated_at DESC, trace_id)"
|
|
]);
|
|
|
|
export function buildPostgresPoolConfig({ dbUrl, sslMode, timeoutMs, poolMax, queryTimeoutMs } = {}) {
|
|
const normalizedSslMode = normalizePostgresSslMode(sslMode ?? postgresSslModeFromConnectionString(dbUrl));
|
|
const normalizedPoolMax = normalizeRuntimePoolMax(poolMax);
|
|
const normalizedQueryTimeoutMs = queryTimeoutMs === undefined || queryTimeoutMs === null || String(queryTimeoutMs).trim() === ""
|
|
? null
|
|
: normalizeRuntimeTimeoutMs(queryTimeoutMs);
|
|
return {
|
|
connectionString: normalizePostgresConnectionStringForSslMode(dbUrl, normalizedSslMode),
|
|
ssl: postgresSslOption(normalizedSslMode),
|
|
connectionTimeoutMillis: normalizeRuntimeTimeoutMs(timeoutMs),
|
|
...(normalizedQueryTimeoutMs !== null ? { query_timeout: normalizedQueryTimeoutMs, statement_timeout: normalizedQueryTimeoutMs } : {}),
|
|
...(normalizedPoolMax !== null ? { max: normalizedPoolMax } : {})
|
|
};
|
|
}
|
|
|
|
export const RUNTIME_DB_QUERY_TIMEOUT_MS_ENV = "HWLAB_CLOUD_DB_QUERY_TIMEOUT_MS";
|
|
export const RUNTIME_DB_QUERY_RETRY_MAX_ATTEMPTS_ENV = "HWLAB_CLOUD_DB_QUERY_RETRY_MAX_ATTEMPTS";
|
|
export const RUNTIME_DB_QUERY_RETRY_INITIAL_DELAY_MS_ENV = "HWLAB_CLOUD_DB_QUERY_RETRY_INITIAL_DELAY_MS";
|
|
export const RUNTIME_DB_QUERY_RETRY_MAX_DELAY_MS_ENV = "HWLAB_CLOUD_DB_QUERY_RETRY_MAX_DELAY_MS";
|
|
export const RUNTIME_DB_POOL_RESET_COOLDOWN_MS_ENV = "HWLAB_CLOUD_DB_POOL_RESET_COOLDOWN_MS";
|
|
export const DEFAULT_RUNTIME_DB_QUERY_RETRY_MAX_ATTEMPTS = 5;
|
|
export const DEFAULT_RUNTIME_DB_QUERY_RETRY_INITIAL_DELAY_MS = 250;
|
|
export const DEFAULT_RUNTIME_DB_QUERY_RETRY_MAX_DELAY_MS = 5_000;
|
|
export const DEFAULT_RUNTIME_DB_POOL_RESET_COOLDOWN_MS = 10_000;
|
|
|
|
export function normalizePositiveInteger(value, fallback) {
|
|
const number = Number(value);
|
|
return Number.isFinite(number) && number > 0 ? Math.trunc(number) : fallback;
|
|
}
|
|
|
|
export function delayRuntimeDbQueryRetry(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, Math.max(0, Math.trunc(Number(ms) || 0))));
|
|
}
|
|
|
|
export function isReadyForDurableRuntimeRead(readiness = {}) {
|
|
return readiness.ready === true && readiness.durable === true && readiness.liveRuntimeEvidence === true;
|
|
}
|
|
|
|
export 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")];
|
|
}
|
|
|
|
export 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
|
|
};
|
|
}
|
|
|
|
export function normalizeAgentTraceEvent(value, requestMeta = {}, now) {
|
|
const input = normalizeJsonObject(value);
|
|
const traceId = textOr(input.traceId ?? requestMeta.traceId, "");
|
|
if (!traceId) {
|
|
throw new HwlabProtocolError("agent trace event requires traceId", {
|
|
code: ERROR_CODES.invalidParams,
|
|
data: { required: ["traceId"] }
|
|
});
|
|
}
|
|
const seq = positiveIntegerOrNull(input.seq);
|
|
const occurredAt = timestampOr(input.occurredAt ?? input.createdAt ?? input.timestamp, now);
|
|
const status = textOr(input.status, "observed");
|
|
const type = textOr(input.type ?? input.kind, "event");
|
|
const level = traceEventLevel(input.level ?? status ?? type);
|
|
const message = textOr(input.message ?? input.label ?? type, "");
|
|
const agentSessionId = textOr(input.agentSessionId ?? input.sessionId ?? requestMeta.agentSessionId ?? requestMeta.sessionId, "");
|
|
const workerSessionId = textOr(input.workerSessionId ?? input.workerSession?.id ?? input.workerId, "");
|
|
const id = textOr(input.id ?? input.eventId, "") || `tev_${sha256Hex({
|
|
traceId,
|
|
seq,
|
|
source: input.source ?? null,
|
|
sourceSeq: input.sourceSeq ?? null,
|
|
type,
|
|
status,
|
|
label: input.label ?? null,
|
|
message,
|
|
occurredAt
|
|
}).slice(0, 32)}`;
|
|
const event = pruneUndefined({
|
|
...input,
|
|
traceId,
|
|
seq: seq ?? undefined,
|
|
agentSessionId: agentSessionId || undefined,
|
|
workerSessionId: workerSessionId || undefined,
|
|
level,
|
|
createdAt: timestampOr(input.createdAt ?? occurredAt, occurredAt),
|
|
valuesPrinted: false
|
|
});
|
|
return {
|
|
id,
|
|
traceId,
|
|
agentSessionId: agentSessionId || null,
|
|
workerSessionId: workerSessionId || null,
|
|
level,
|
|
message,
|
|
occurredAt,
|
|
event
|
|
};
|
|
}
|
|
|
|
export function normalizeWorkbenchProjectionState(value, requestMeta = {}, now) {
|
|
const input = normalizeJsonObject(value);
|
|
const traceId = textOr(input.traceId ?? requestMeta.traceId, "");
|
|
const runId = textOr(input.runId ?? input.sourceRunId, "");
|
|
const commandId = textOr(input.commandId ?? input.sourceCommandId, "");
|
|
if (!traceId || !runId || !commandId) {
|
|
throw new HwlabProtocolError("workbench projection state requires traceId, runId, and commandId", {
|
|
code: ERROR_CODES.invalidParams,
|
|
data: { required: ["traceId", "runId", "commandId"] }
|
|
});
|
|
}
|
|
const timestamp = timestampOr(input.updatedAt, now);
|
|
const lastAgentRunSeq = nonNegativeInteger(input.lastAgentRunSeq ?? input.lastSourceSeq);
|
|
const lastProjectedSeq = nonNegativeInteger(input.lastProjectedSeq ?? lastAgentRunSeq);
|
|
const upstreamLatestSeq = nonNegativeIntegerOrNull(input.upstreamLatestSeq ?? input.sourceLatestSeq);
|
|
return pruneUndefined({
|
|
...input,
|
|
traceId,
|
|
sessionId: textOr(input.sessionId ?? requestMeta.sessionId, "") || null,
|
|
conversationId: textOr(input.conversationId, "") || null,
|
|
threadId: textOr(input.threadId, "") || null,
|
|
ownerUserId: textOr(input.ownerUserId, "") || null,
|
|
ownerRole: textOr(input.ownerRole, "") || null,
|
|
sourceRunId: runId,
|
|
sourceCommandId: commandId,
|
|
runId,
|
|
commandId,
|
|
lastSourceSeq: lastAgentRunSeq,
|
|
lastAgentRunSeq,
|
|
lastProjectedSeq,
|
|
sourceLatestSeq: upstreamLatestSeq ?? lastAgentRunSeq,
|
|
upstreamLatestSeq: upstreamLatestSeq ?? lastAgentRunSeq,
|
|
projectionStatus: projectionStatusOr(input.projectionStatus, "projecting"),
|
|
projectionHealth: projectionHealthOr(input.projectionHealth, "healthy"),
|
|
resultSyncState: resultSyncStateOr(input.resultSyncState, "not_started"),
|
|
lastProjectedAt: timestampOr(input.lastProjectedAt, timestamp),
|
|
lastResultSyncAt: input.lastResultSyncAt ? timestampOr(input.lastResultSyncAt, timestamp) : null,
|
|
lastErrorCode: textOr(input.lastErrorCode, "") || null,
|
|
lastErrorMessage: textOr(input.lastErrorMessage, "") || null,
|
|
failureCount: nonNegativeInteger(input.failureCount),
|
|
nextRetryAt: input.nextRetryAt ? timestampOr(input.nextRetryAt, timestamp) : null,
|
|
createdAt: timestampOr(input.createdAt, timestamp),
|
|
updatedAt: timestamp,
|
|
valuesPrinted: false
|
|
});
|
|
}
|
|
|
|
export function normalizeWorkbenchProjectionAllocation(value, requestMeta = {}, now) {
|
|
const input = normalizeJsonObject(value);
|
|
const traceId = textOr(input.traceId ?? requestMeta.traceId, "");
|
|
const sourceEventId = textOr(input.sourceEventId ?? input.eventId, "");
|
|
if (!traceId || !sourceEventId) {
|
|
throw new HwlabProtocolError("workbench projectedSeq allocation requires traceId and sourceEventId", {
|
|
code: ERROR_CODES.invalidParams,
|
|
data: { required: ["traceId", "sourceEventId"] }
|
|
});
|
|
}
|
|
const timestamp = timestampOr(input.updatedAt ?? input.occurredAt ?? input.createdAt, now);
|
|
return pruneUndefined({
|
|
...input,
|
|
traceId,
|
|
sessionId: textOr(input.sessionId ?? requestMeta.sessionId, "") || null,
|
|
turnId: textOr(input.turnId ?? requestMeta.turnId ?? traceId, "") || null,
|
|
runId: textOr(input.runId ?? input.sourceRunId, "") || null,
|
|
commandId: textOr(input.commandId ?? input.sourceCommandId, "") || null,
|
|
sourceSeq: nonNegativeInteger(input.sourceSeq ?? input.lastSourceSeq ?? input.lastAgentRunSeq),
|
|
sourceEventId,
|
|
eventType: textOr(input.eventType ?? input.type, "event"),
|
|
createdAt: timestampOr(input.createdAt, timestamp),
|
|
updatedAt: timestamp,
|
|
valuesPrinted: false
|
|
});
|
|
}
|
|
|
|
export function normalizeWorkbenchFacts(value, requestMeta = {}, now) {
|
|
const input = normalizeJsonObject(value);
|
|
return {
|
|
inputs: arrayInput(input.inputs ?? input.input).map((item) => normalizeWorkbenchSessionInputFact(item, requestMeta, now)),
|
|
sessions: arrayInput(input.sessions ?? input.session).map((item) => normalizeWorkbenchSessionFact(item, requestMeta, now)),
|
|
messages: arrayInput(input.messages ?? input.message).map((item) => normalizeWorkbenchMessageFact(item, requestMeta, now)),
|
|
parts: arrayInput(input.parts ?? input.part).map((item) => normalizeWorkbenchPartFact(item, requestMeta, now)),
|
|
turns: arrayInput(input.turns ?? input.turn).map((item) => normalizeWorkbenchTurnFact(item, requestMeta, now)),
|
|
traceEvents: arrayInput(input.traceEvents ?? input.traceEvent).map((item) => normalizeWorkbenchTraceEventFact(item, requestMeta, now)),
|
|
checkpoints: arrayInput(input.checkpoints ?? input.checkpoint).map((item) => normalizeWorkbenchProjectionCheckpoint(item, requestMeta, now))
|
|
};
|
|
}
|
|
|
|
export function normalizeWorkbenchAggregateEventsForFacts(facts = {}, requestMeta = {}, now) {
|
|
const events = [];
|
|
for (const fact of arrayInput(facts.inputs)) appendWorkbenchFactEvent(events, "inputs", fact, requestMeta, now);
|
|
for (const fact of arrayInput(facts.sessions)) appendWorkbenchFactEvent(events, "sessions", fact, requestMeta, now);
|
|
for (const fact of arrayInput(facts.messages)) appendWorkbenchFactEvent(events, "messages", fact, requestMeta, now);
|
|
for (const fact of arrayInput(facts.parts)) appendWorkbenchFactEvent(events, "parts", fact, requestMeta, now);
|
|
for (const fact of arrayInput(facts.traceEvents)) appendWorkbenchFactEvent(events, "traceEvents", fact, requestMeta, now);
|
|
for (const fact of arrayInput(facts.checkpoints)) appendWorkbenchFactEvent(events, "checkpoints", fact, requestMeta, now);
|
|
for (const fact of arrayInput(facts.turns)) appendWorkbenchFactEvent(events, "turns", fact, requestMeta, now);
|
|
return events;
|
|
}
|
|
|
|
export function appendWorkbenchFactEvent(events, factFamily, fact, requestMeta, now) {
|
|
const aggregate = workbenchAggregateForFact(fact, requestMeta);
|
|
const factId = textOr(fact.id ?? fact.inputId ?? fact.sessionId ?? fact.messageId ?? fact.partId ?? fact.turnId ?? fact.traceId, "");
|
|
const rawSourceEventId = textOr(fact.sourceEventId ?? fact.eventId, "") || factId;
|
|
const sourceEventId = rawSourceEventId ? `${factFamily}:${rawSourceEventId}` : null;
|
|
const eventType = workbenchAggregateEventType(factFamily, fact);
|
|
const projectedSeq = nonNegativeInteger(fact.projectedSeq);
|
|
const sourceSeq = nonNegativeInteger(fact.sourceSeq);
|
|
const occurredAt = timestampOr(fact.occurredAt ?? fact.updatedAt ?? fact.createdAt, now);
|
|
events.push(normalizeWorkbenchAggregateEvent({
|
|
...aggregate,
|
|
factFamily,
|
|
factId,
|
|
factKey: `${factFamily.slice(0, -1) || factFamily}:${factId}`,
|
|
eventId: stableWorkbenchFactId("wbe", { aggregateId: aggregate.aggregateId, factFamily, sourceEventId, eventType, factId }),
|
|
messageId: textOr(fact.messageId, "") || null,
|
|
sourceRunId: textOr(fact.sourceRunId ?? fact.runId, "") || null,
|
|
sourceCommandId: textOr(fact.sourceCommandId ?? fact.commandId, "") || null,
|
|
sourceSeq,
|
|
sourceEventId,
|
|
eventType,
|
|
projectionRevision: projectedSeq || sourceSeq,
|
|
terminal: Boolean(fact.terminal),
|
|
sealed: Boolean(fact.sealed),
|
|
payload: {
|
|
factFamily,
|
|
factId,
|
|
status: textOr(fact.status ?? fact.projectionStatus, "") || null,
|
|
eventType,
|
|
projectedSeq,
|
|
sourceSeq,
|
|
originalSourceEventId: rawSourceEventId || null,
|
|
valuesRedacted: true
|
|
},
|
|
occurredAt,
|
|
committedAt: timestampOr(fact.updatedAt ?? occurredAt, now)
|
|
}, requestMeta, now));
|
|
}
|
|
|
|
export function workbenchAggregateForFact(fact = {}, requestMeta = {}) {
|
|
const sessionId = textOr(fact.sessionId ?? requestMeta.sessionId, "") || null;
|
|
const turnId = textOr(fact.turnId ?? requestMeta.turnId, "") || null;
|
|
const traceId = textOr(fact.traceId ?? requestMeta.traceId, "") || null;
|
|
if (sessionId) return { aggregateId: `session:${sessionId}`, aggregateType: "session", sessionId, turnId, traceId };
|
|
if (turnId) return { aggregateId: `turn:${turnId}`, aggregateType: "turn", sessionId, turnId, traceId };
|
|
if (traceId) return { aggregateId: `trace:${traceId}`, aggregateType: "trace", sessionId, turnId, traceId };
|
|
throw requiredWorkbenchFactError("workbench aggregate event", ["sessionId", "turnId", "traceId"]);
|
|
}
|
|
|
|
export function normalizeWorkbenchAggregateEvent(value, requestMeta = {}, now) {
|
|
const input = normalizeJsonObject(value);
|
|
const aggregateId = textOr(input.aggregateId, "");
|
|
if (!aggregateId) throw requiredWorkbenchFactError("workbench aggregate event", ["aggregateId"]);
|
|
const timestamp = timestampOr(input.committedAt ?? input.occurredAt ?? input.updatedAt ?? input.createdAt, now);
|
|
const sourceSeq = nonNegativeInteger(input.sourceSeq);
|
|
const projectionRevision = nonNegativeInteger(input.projectionRevision ?? input.projectedSeq ?? sourceSeq);
|
|
return pruneUndefined({
|
|
...input,
|
|
eventSeq: nonNegativeInteger(input.eventSeq),
|
|
eventId: textOr(input.eventId ?? input.id, "") || stableWorkbenchFactId("wbe", { aggregateId, eventType: input.eventType, sourceEventId: input.sourceEventId, sourceSeq, projectionRevision }),
|
|
aggregateId,
|
|
aggregateType: textOr(input.aggregateType, "trace"),
|
|
aggregateSeq: nonNegativeInteger(input.aggregateSeq),
|
|
sessionId: textOr(input.sessionId ?? requestMeta.sessionId, "") || null,
|
|
turnId: textOr(input.turnId ?? requestMeta.turnId, "") || null,
|
|
traceId: textOr(input.traceId ?? requestMeta.traceId, "") || null,
|
|
messageId: textOr(input.messageId ?? requestMeta.messageId, "") || null,
|
|
sourceRunId: textOr(input.sourceRunId ?? input.runId, "") || null,
|
|
sourceCommandId: textOr(input.sourceCommandId ?? input.commandId, "") || null,
|
|
sourceSeq,
|
|
sourceEventId: textOr(input.sourceEventId, "") || null,
|
|
eventType: textOr(input.eventType ?? input.type, "event"),
|
|
projectionRevision,
|
|
terminal: Boolean(input.terminal),
|
|
sealed: Boolean(input.sealed),
|
|
payload: normalizeJsonObject(input.payload),
|
|
occurredAt: timestampOr(input.occurredAt, timestamp),
|
|
committedAt: timestamp,
|
|
valuesPrinted: false
|
|
});
|
|
}
|
|
|
|
export function workbenchAggregateEventType(factFamily, fact = {}) {
|
|
if (factFamily === "inputs") return `input_${textOr(fact.delivery, "queue")}_${textOr(fact.status, "admitted")}`;
|
|
if (factFamily === "sessions") return "session_upsert";
|
|
if (factFamily === "messages") return `${textOr(fact.role, "agent")}_message`;
|
|
if (factFamily === "parts") return `part_${textOr(fact.partType ?? fact.type, "text")}`;
|
|
if (factFamily === "turns") return fact.terminal || fact.sealed ? "turn_terminal" : "turn_update";
|
|
if (factFamily === "traceEvents") return `trace_${textOr(fact.eventType ?? fact.type, "event")}`;
|
|
if (factFamily === "checkpoints") return `checkpoint_${textOr(fact.projectionStatus, "projecting")}`;
|
|
return "event";
|
|
}
|
|
|
|
export function workbenchInputDelivery(value) {
|
|
const text = textOr(value, "queue").toLowerCase();
|
|
return ["queue", "steer", "cancel"].includes(text) ? text : "queue";
|
|
}
|
|
|
|
export function workbenchInputStatus(value) {
|
|
const text = textOr(value, "admitted").toLowerCase().replace(/-/gu, "_");
|
|
return ["admitting", "admitted", "promoted", "blocked", "failed", "canceled", "completed"].includes(text) ? text : "admitted";
|
|
}
|
|
|
|
export function indexWorkbenchAggregateEvents(events = []) {
|
|
const index = new Map();
|
|
for (const event of events) {
|
|
if (event?.factKey) index.set(event.factKey, event);
|
|
if (event?.factFamily && event?.factId) index.set(`${event.factFamily}:${event.factId}`, event);
|
|
if (event?.factFamily === "inputs" && event?.factId) index.set(`input:${event.factId}`, event);
|
|
if (event?.factFamily === "turns" && event?.turnId) index.set(`turn:${event.turnId}`, event);
|
|
if (event?.factFamily === "traceEvents" && event?.factId) index.set(`traceEvent:${event.factId}`, event);
|
|
if (event?.sourceEventId) index.set(`sourceEvent:${event.sourceEventId}`, event);
|
|
}
|
|
return index;
|
|
}
|
|
|
|
export function normalizeWorkbenchSessionInputFact(value, requestMeta = {}, now) {
|
|
const input = normalizeJsonObject(value);
|
|
const sessionId = textOr(input.sessionId ?? requestMeta.sessionId, "");
|
|
if (!sessionId) throw requiredWorkbenchFactError("workbench session input fact", ["sessionId"]);
|
|
const timestamp = timestampOr(input.updatedAt ?? input.createdAt, now);
|
|
const turnId = textOr(input.turnId ?? requestMeta.turnId ?? input.traceId ?? requestMeta.traceId, "") || null;
|
|
const traceId = textOr(input.traceId ?? requestMeta.traceId, "") || null;
|
|
const messageId = textOr(input.messageId ?? requestMeta.messageId, "") || null;
|
|
const commandId = textOr(input.commandId ?? input.sourceCommandId, "") || null;
|
|
const delivery = workbenchInputDelivery(input.delivery);
|
|
const status = workbenchInputStatus(input.status);
|
|
const sourceEventId = textOr(input.sourceEventId ?? input.eventId, "") || `${sessionId}:${turnId ?? "turn-unassigned"}:${delivery}:${traceId ?? commandId ?? messageId ?? "input"}`;
|
|
const inputId = textOr(input.inputId ?? input.id, "") || stableWorkbenchFactId("wsi", { sessionId, turnId, traceId, messageId, commandId, delivery, sourceEventId });
|
|
const admittedSeq = nonNegativeInteger(input.admittedSeq ?? input.aggregateSeq ?? input.sourceSeq);
|
|
const promotedSeq = workbenchSessionInputPromotedSeq({ ...input, status }, admittedSeq);
|
|
return pruneUndefined({
|
|
...input,
|
|
id: inputId,
|
|
inputId,
|
|
sessionId,
|
|
turnId,
|
|
traceId,
|
|
messageId,
|
|
commandId,
|
|
delivery,
|
|
admittedSeq,
|
|
promotedSeq,
|
|
sourceSeq: admittedSeq,
|
|
projectedSeq: admittedSeq,
|
|
status,
|
|
errorCode: textOr(input.errorCode ?? input.error?.code, "") || null,
|
|
sourceEventId,
|
|
createdAt: timestampOr(input.createdAt, timestamp),
|
|
updatedAt: timestamp,
|
|
valuesPrinted: false,
|
|
valuesRedacted: true
|
|
});
|
|
}
|
|
|
|
export function memoryWorkbenchSessionInputWithSeq(fact, records) {
|
|
const existingFactSeq = nonNegativeInteger(fact.admittedSeq);
|
|
if (existingFactSeq > 0) return workbenchSessionInputWithSeq(fact, existingFactSeq);
|
|
const existing = records.get(fact.inputId) ?? null;
|
|
const existingSeq = nonNegativeInteger(existing?.admittedSeq);
|
|
if (existingSeq > 0) return workbenchSessionInputWithSeq(fact, existingSeq);
|
|
const nextSeq = [...records.values()]
|
|
.filter((record) => record.sessionId === fact.sessionId)
|
|
.reduce((max, record) => Math.max(max, nonNegativeInteger(record.admittedSeq)), 0) + 1;
|
|
return workbenchSessionInputWithSeq(fact, nextSeq);
|
|
}
|
|
|
|
export function inputFactWithAggregateSeq(fact, eventIndex) {
|
|
const existingFactSeq = nonNegativeInteger(fact.admittedSeq);
|
|
if (existingFactSeq > 0) return workbenchSessionInputWithSeq(fact, existingFactSeq);
|
|
const event = eventIndex.get(`input:${fact.inputId}`) ?? eventIndex.get(`sourceEvent:${fact.sourceEventId}`) ?? null;
|
|
const admittedSeq = nonNegativeInteger(event?.aggregateSeq);
|
|
return admittedSeq > 0 ? workbenchSessionInputWithSeq(fact, admittedSeq) : fact;
|
|
}
|
|
|
|
export function workbenchSessionInputWithSeq(fact, admittedSeq) {
|
|
return {
|
|
...fact,
|
|
admittedSeq,
|
|
promotedSeq: workbenchSessionInputPromotedSeq(fact, admittedSeq),
|
|
sourceSeq: admittedSeq,
|
|
projectedSeq: admittedSeq
|
|
};
|
|
}
|
|
|
|
export function workbenchSessionInputPromotedSeq(fact, admittedSeq) {
|
|
const existing = nonNegativeIntegerOrNull(fact.promotedSeq);
|
|
if (existing !== null && existing > 0) return existing;
|
|
const seq = nonNegativeInteger(admittedSeq);
|
|
return textOr(fact.status, "") === "promoted" && seq > 0 ? seq : existing;
|
|
}
|
|
|
|
export function normalizeWorkbenchSessionFact(value, requestMeta = {}, now) {
|
|
const input = normalizeJsonObject(value);
|
|
const sessionId = textOr(input.sessionId ?? input.id ?? requestMeta.sessionId, "");
|
|
if (!sessionId) throw requiredWorkbenchFactError("workbench session fact", ["sessionId"]);
|
|
const sourceSeq = nonNegativeInteger(input.sourceSeq);
|
|
const projectedSeq = nonNegativeInteger(input.projectedSeq ?? sourceSeq);
|
|
const timestamp = timestampOr(input.updatedAt ?? input.createdAt, now);
|
|
return pruneUndefined({
|
|
...input,
|
|
id: sessionId,
|
|
sessionId,
|
|
ownerUserId: textOr(input.ownerUserId ?? requestMeta.ownerUserId, "") || null,
|
|
projectId: textOr(input.projectId ?? requestMeta.projectId, "") || null,
|
|
conversationId: textOr(input.conversationId ?? requestMeta.conversationId, "") || null,
|
|
threadId: textOr(input.threadId ?? requestMeta.threadId, "") || null,
|
|
status: textOr(input.status, "unknown"),
|
|
lastTraceId: textOr(input.lastTraceId ?? input.traceId ?? requestMeta.traceId, "") || null,
|
|
projectedSeq,
|
|
sourceSeq,
|
|
sourceEventId: textOr(input.sourceEventId ?? input.eventId, "") || null,
|
|
terminal: Boolean(input.terminal),
|
|
sealed: Boolean(input.sealed),
|
|
createdAt: timestampOr(input.createdAt, timestamp),
|
|
updatedAt: timestamp,
|
|
valuesPrinted: false
|
|
});
|
|
}
|
|
|
|
export function normalizeWorkbenchMessageFact(value, requestMeta = {}, now) {
|
|
const input = normalizeJsonObject(value);
|
|
const sessionId = textOr(input.sessionId ?? requestMeta.sessionId, "");
|
|
if (!sessionId) throw requiredWorkbenchFactError("workbench message fact", ["sessionId"]);
|
|
const traceId = textOr(input.traceId ?? requestMeta.traceId, "") || null;
|
|
const turnId = textOr(input.turnId ?? requestMeta.turnId ?? traceId, "") || null;
|
|
const role = textOr(input.role, "agent");
|
|
const sourceSeq = nonNegativeInteger(input.sourceSeq);
|
|
const projectedSeq = nonNegativeInteger(input.projectedSeq ?? sourceSeq);
|
|
const timestamp = timestampOr(input.updatedAt ?? input.createdAt, now);
|
|
const messageId = textOr(input.messageId ?? input.id, "") || stableWorkbenchFactId("msg", { sessionId, turnId, traceId, role, sourceSeq, projectedSeq });
|
|
return pruneUndefined({
|
|
...input,
|
|
id: messageId,
|
|
messageId,
|
|
sessionId,
|
|
turnId,
|
|
traceId,
|
|
role,
|
|
status: textOr(input.status, "unknown"),
|
|
projectedSeq,
|
|
sourceSeq,
|
|
sourceEventId: textOr(input.sourceEventId ?? input.eventId, "") || null,
|
|
terminal: Boolean(input.terminal),
|
|
sealed: Boolean(input.sealed),
|
|
createdAt: timestampOr(input.createdAt, timestamp),
|
|
updatedAt: timestamp,
|
|
valuesPrinted: false
|
|
});
|
|
}
|
|
|
|
export function normalizeWorkbenchPartFact(value, requestMeta = {}, now) {
|
|
const input = normalizeJsonObject(value);
|
|
const sessionId = textOr(input.sessionId ?? requestMeta.sessionId, "");
|
|
const messageId = textOr(input.messageId ?? requestMeta.messageId, "");
|
|
if (!sessionId || !messageId) throw requiredWorkbenchFactError("workbench part fact", ["sessionId", "messageId"]);
|
|
const traceId = textOr(input.traceId ?? requestMeta.traceId, "") || null;
|
|
const turnId = textOr(input.turnId ?? requestMeta.turnId ?? traceId, "") || null;
|
|
const partIndex = nonNegativeInteger(input.partIndex ?? input.index);
|
|
const sourceSeq = nonNegativeInteger(input.sourceSeq);
|
|
const projectedSeq = nonNegativeInteger(input.projectedSeq ?? sourceSeq);
|
|
const timestamp = timestampOr(input.updatedAt ?? input.createdAt, now);
|
|
const partType = textOr(input.partType ?? input.type, "text");
|
|
const partId = textOr(input.partId ?? input.id, "") || stableWorkbenchFactId("wpt", { sessionId, messageId, turnId, traceId, partIndex, partType });
|
|
return pruneUndefined({
|
|
...input,
|
|
id: partId,
|
|
partId,
|
|
messageId,
|
|
sessionId,
|
|
turnId,
|
|
traceId,
|
|
partIndex,
|
|
partType,
|
|
status: textOr(input.status, "unknown"),
|
|
projectedSeq,
|
|
sourceSeq,
|
|
sourceEventId: textOr(input.sourceEventId ?? input.eventId, "") || null,
|
|
terminal: Boolean(input.terminal),
|
|
sealed: Boolean(input.sealed),
|
|
createdAt: timestampOr(input.createdAt, timestamp),
|
|
updatedAt: timestamp,
|
|
valuesPrinted: false
|
|
});
|
|
}
|
|
|
|
export function normalizeWorkbenchTurnFact(value, requestMeta = {}, now) {
|
|
const input = normalizeJsonObject(value);
|
|
const sessionId = textOr(input.sessionId ?? requestMeta.sessionId, "");
|
|
const traceId = textOr(input.traceId ?? requestMeta.traceId, "") || null;
|
|
const turnId = textOr(input.turnId ?? input.id ?? requestMeta.turnId ?? traceId, "");
|
|
if (!sessionId || !turnId) throw requiredWorkbenchFactError("workbench turn fact", ["sessionId", "turnId"]);
|
|
const sourceSeq = nonNegativeInteger(input.sourceSeq);
|
|
const projectedSeq = nonNegativeInteger(input.projectedSeq ?? sourceSeq);
|
|
const timestamp = timestampOr(input.updatedAt ?? input.createdAt, now);
|
|
const status = textOr(input.status, "unknown");
|
|
const terminal = Boolean(input.terminal);
|
|
const sealed = Boolean(input.sealed);
|
|
const finalResponse = input.finalResponse ?? null;
|
|
assertWorkbenchTurnFinalResponseInvariant({ status, terminal, sealed, finalResponse });
|
|
return pruneUndefined({
|
|
...input,
|
|
id: turnId,
|
|
turnId,
|
|
sessionId,
|
|
traceId,
|
|
messageId: textOr(input.messageId ?? requestMeta.messageId, "") || null,
|
|
status,
|
|
projectedSeq,
|
|
sourceSeq,
|
|
sourceEventId: textOr(input.sourceEventId ?? input.eventId, "") || null,
|
|
terminal,
|
|
sealed,
|
|
finalResponse,
|
|
diagnostic: normalizeJsonObject(input.diagnostic ?? input.projection ?? input.blocker),
|
|
createdAt: timestampOr(input.createdAt, timestamp),
|
|
updatedAt: timestamp,
|
|
valuesPrinted: false
|
|
});
|
|
}
|
|
|
|
export function assertWorkbenchTurnFinalResponseInvariant({ status, terminal, sealed, finalResponse } = {}) {
|
|
if (normalizeWorkbenchFactStatus(status) !== "completed") return;
|
|
if (!terminal && !sealed) return;
|
|
if (workbenchFactFinalResponseText(finalResponse)) return;
|
|
const error = new Error("completed Workbench turn facts must include an authoritative final response before sealing");
|
|
error.code = "workbench_terminal_final_response_missing";
|
|
error.layer = "workbench-read-model";
|
|
error.category = "projection-invariant";
|
|
throw error;
|
|
}
|
|
|
|
export function normalizeWorkbenchFactStatus(value) {
|
|
return textOr(value, "").toLowerCase().replace(/_/gu, "-");
|
|
}
|
|
|
|
export function workbenchFactFinalResponseText(value) {
|
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
return workbenchFactFinalResponseText(value.text ?? value.content ?? value.message ?? value.summary ?? value.preview);
|
|
}
|
|
const text = textOr(value, "");
|
|
return text && text !== "[object Object]" ? text : null;
|
|
}
|
|
|
|
export function normalizeWorkbenchTraceEventFact(value, requestMeta = {}, now) {
|
|
const input = normalizeJsonObject(value);
|
|
const traceId = textOr(input.traceId ?? requestMeta.traceId, "");
|
|
if (!traceId) throw requiredWorkbenchFactError("workbench trace event fact", ["traceId"]);
|
|
const sessionId = textOr(input.sessionId ?? requestMeta.sessionId, "") || null;
|
|
const turnId = textOr(input.turnId ?? requestMeta.turnId ?? traceId, "") || null;
|
|
const sourceSeq = nonNegativeInteger(input.sourceSeq);
|
|
const projectedSeq = nonNegativeInteger(input.projectedSeq ?? input.seq);
|
|
const occurredAt = timestampOr(input.occurredAt ?? input.createdAt ?? input.timestamp, now);
|
|
const eventType = textOr(input.eventType ?? input.type ?? input.kind, "event");
|
|
const id = textOr(input.id ?? input.eventId, "") || stableWorkbenchFactId("wte", { traceId, sourceSeq, projectedSeq, eventType, sourceEventId: input.sourceEventId ?? null, occurredAt });
|
|
return pruneUndefined({
|
|
...input,
|
|
id,
|
|
traceId,
|
|
sessionId,
|
|
turnId,
|
|
messageId: textOr(input.messageId ?? requestMeta.messageId, "") || null,
|
|
sourceSeq,
|
|
sourceEventId: textOr(input.sourceEventId ?? input.eventId, "") || null,
|
|
projectedSeq,
|
|
eventType,
|
|
terminal: Boolean(input.terminal),
|
|
sealed: Boolean(input.sealed),
|
|
occurredAt,
|
|
updatedAt: timestampOr(input.updatedAt, occurredAt),
|
|
valuesPrinted: false
|
|
});
|
|
}
|
|
|
|
export function normalizeWorkbenchProjectionCheckpoint(value, requestMeta = {}, now) {
|
|
const input = normalizeJsonObject(value);
|
|
const traceId = textOr(input.traceId ?? requestMeta.traceId, "");
|
|
if (!traceId) throw requiredWorkbenchFactError("workbench projection checkpoint", ["traceId"]);
|
|
const sourceSeq = nonNegativeInteger(input.sourceSeq ?? input.lastSourceSeq ?? input.lastAgentRunSeq);
|
|
const projectedSeq = nonNegativeInteger(input.projectedSeq ?? input.lastProjectedSeq ?? sourceSeq);
|
|
const timestamp = timestampOr(input.updatedAt ?? input.lastProjectedAt, now);
|
|
return pruneUndefined({
|
|
...input,
|
|
id: traceId,
|
|
traceId,
|
|
sessionId: textOr(input.sessionId ?? requestMeta.sessionId, "") || null,
|
|
turnId: textOr(input.turnId ?? requestMeta.turnId ?? traceId, "") || null,
|
|
runId: textOr(input.runId ?? input.sourceRunId, "") || null,
|
|
commandId: textOr(input.commandId ?? input.sourceCommandId, "") || null,
|
|
projectedSeq,
|
|
sourceSeq,
|
|
sourceEventId: textOr(input.sourceEventId ?? input.eventId, "") || null,
|
|
projectionStatus: projectionStatusOr(input.projectionStatus, "projecting"),
|
|
projectionHealth: projectionHealthOr(input.projectionHealth, "healthy"),
|
|
terminal: Boolean(input.terminal),
|
|
sealed: Boolean(input.sealed),
|
|
diagnostic: normalizeJsonObject(input.diagnostic ?? input.blocker),
|
|
createdAt: timestampOr(input.createdAt, timestamp),
|
|
updatedAt: timestamp,
|
|
valuesPrinted: false
|
|
});
|
|
}
|
|
|
|
export function arrayInput(value) {
|
|
if (Array.isArray(value)) return value;
|
|
if (value && typeof value === "object") return [value];
|
|
return [];
|
|
}
|
|
|
|
export function stableWorkbenchFactId(prefix, value) {
|
|
return `${prefix}_${sha256Hex(value).slice(0, 32)}`;
|
|
}
|
|
|
|
export function requiredWorkbenchFactError(label, fields) {
|
|
return new HwlabProtocolError(`${label} requires ${fields.join(", ")}`, {
|
|
code: ERROR_CODES.invalidParams,
|
|
data: { required: fields }
|
|
});
|
|
}
|
|
|
|
export function compareTraceEventRecords(left, right) {
|
|
const leftSeq = positiveIntegerOrNull(left?.event?.seq);
|
|
const rightSeq = positiveIntegerOrNull(right?.event?.seq);
|
|
if (leftSeq && rightSeq && leftSeq !== rightSeq) return leftSeq - rightSeq;
|
|
return String(left?.occurredAt ?? "").localeCompare(String(right?.occurredAt ?? "")) ||
|
|
String(left?.id ?? "").localeCompare(String(right?.id ?? ""));
|
|
}
|
|
|
|
export function compareProjectionStateRecords(left, right) {
|
|
return String(left?.updatedAt ?? "").localeCompare(String(right?.updatedAt ?? "")) ||
|
|
String(left?.traceId ?? "").localeCompare(String(right?.traceId ?? ""));
|
|
}
|
|
|
|
export function compareWorkbenchFactRecords(left, right) {
|
|
return String(left?.updatedAt ?? left?.createdAt ?? "").localeCompare(String(right?.updatedAt ?? right?.createdAt ?? "")) ||
|
|
String(left?.id ?? left?.sessionId ?? left?.messageId ?? left?.turnId ?? left?.traceId ?? "").localeCompare(String(right?.id ?? right?.sessionId ?? right?.messageId ?? right?.turnId ?? right?.traceId ?? ""));
|
|
}
|
|
|
|
export function compareWorkbenchInputFactRecords(left, right) {
|
|
return nonNegativeInteger(left?.admittedSeq) - nonNegativeInteger(right?.admittedSeq) ||
|
|
String(left?.inputId ?? left?.id ?? "").localeCompare(String(right?.inputId ?? right?.id ?? ""));
|
|
}
|
|
|
|
export function compareWorkbenchPartFactRecords(left, right) {
|
|
return String(left?.messageId ?? "").localeCompare(String(right?.messageId ?? "")) ||
|
|
nonNegativeInteger(left?.partIndex) - nonNegativeInteger(right?.partIndex) ||
|
|
compareWorkbenchFactRecords(left, right);
|
|
}
|
|
|
|
export function compareWorkbenchTraceEventFacts(left, right) {
|
|
return nonNegativeInteger(left?.projectedSeq) - nonNegativeInteger(right?.projectedSeq);
|
|
}
|
|
|
|
export function sortWorkbenchFactRows(records, params = {}, family, fallbackCompare) {
|
|
const sorted = [...records];
|
|
if (workbenchFactOrder(params, family) === "updated_desc") {
|
|
return sorted.sort((left, right) => String(right?.updatedAt ?? right?.createdAt ?? "").localeCompare(String(left?.updatedAt ?? left?.createdAt ?? "")) || fallbackCompare(left, right));
|
|
}
|
|
if (family === "inputs") return sorted.sort(compareWorkbenchInputFactRecords);
|
|
return sorted.sort(fallbackCompare);
|
|
}
|
|
|
|
export const WORKBENCH_FACT_FAMILIES = Object.freeze(["inputs", "sessions", "messages", "parts", "turns", "traceEvents", "checkpoints"]);
|
|
|
|
export function workbenchFactFamilySet(params = {}) {
|
|
const raw = params.families ?? params.factFamilies;
|
|
if (raw === undefined || raw === null || raw === "") return new Set(WORKBENCH_FACT_FAMILIES);
|
|
const values = Array.isArray(raw) ? raw : String(raw).split(",");
|
|
const selected = values.map((value) => String(value ?? "").trim()).filter((value) => WORKBENCH_FACT_FAMILIES.includes(value));
|
|
return new Set(selected.length > 0 ? selected : WORKBENCH_FACT_FAMILIES);
|
|
}
|
|
|
|
export function workbenchFactFamilyForTable(table) {
|
|
if (table === "workbench_session_inputs") return "inputs";
|
|
if (table === "workbench_sessions") return "sessions";
|
|
if (table === "workbench_messages") return "messages";
|
|
if (table === "workbench_parts") return "parts";
|
|
if (table === "workbench_turns") return "turns";
|
|
if (table === "workbench_trace_events") return "traceEvents";
|
|
if (table === "workbench_projection_checkpoints") return "checkpoints";
|
|
return "unknown";
|
|
}
|
|
|
|
export function workbenchSessionSummarySelectClause() {
|
|
return [
|
|
"session_id",
|
|
"owner_user_id",
|
|
"project_id",
|
|
"conversation_id",
|
|
"thread_id",
|
|
"status",
|
|
"last_trace_id",
|
|
"projected_seq",
|
|
"source_seq",
|
|
"source_event_id",
|
|
"terminal",
|
|
"sealed",
|
|
"created_at",
|
|
"updated_at",
|
|
"COALESCE(NULLIF(session_json::jsonb ->> 'providerProfile', ''), NULLIF(session_json::jsonb #>> '{sessionJson,providerProfile}', '')) AS provider_profile"
|
|
].join(", ");
|
|
}
|
|
|
|
export function workbenchSessionSummaryFactFromRow(row = {}) {
|
|
const sessionId = textOr(row.session_id, "");
|
|
if (!sessionId) return null;
|
|
const providerProfile = textOr(row.provider_profile, "");
|
|
return {
|
|
id: sessionId,
|
|
sessionId,
|
|
ownerUserId: textOr(row.owner_user_id, null),
|
|
projectId: textOr(row.project_id, null),
|
|
conversationId: textOr(row.conversation_id, null),
|
|
threadId: textOr(row.thread_id, null),
|
|
status: textOr(row.status, "unknown"),
|
|
lastTraceId: textOr(row.last_trace_id, null),
|
|
projectedSeq: nonNegativeInteger(row.projected_seq),
|
|
sourceSeq: nonNegativeInteger(row.source_seq),
|
|
sourceEventId: textOr(row.source_event_id, null),
|
|
terminal: row.terminal === true || row.terminal === "t",
|
|
sealed: row.sealed === true || row.sealed === "t",
|
|
...(providerProfile ? {
|
|
providerProfile,
|
|
sessionJson: {
|
|
providerProfile,
|
|
valuesRedacted: true,
|
|
secretMaterialStored: false
|
|
}
|
|
} : {}),
|
|
createdAt: textOr(row.created_at, null),
|
|
updatedAt: textOr(row.updated_at, row.created_at ?? null),
|
|
valuesPrinted: false,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
export function workbenchFactOrder(params = {}, family = "") {
|
|
const value = params[`${family}Order`] ?? params.order;
|
|
return value === "updated_desc" ? "updated_desc" : "updated_asc";
|
|
}
|
|
|
|
export function traceEventLevel(value) {
|
|
const text = textOr(value, "info").toLowerCase();
|
|
if (["failed", "failure", "error", "timeout"].includes(text)) return "error";
|
|
if (["warn", "warning", "blocked", "canceled", "cancelled"].includes(text)) return "warn";
|
|
if (["debug", "trace"].includes(text)) return "debug";
|
|
return "info";
|
|
}
|
|
|
|
export function positiveIntegerOrNull(value) {
|
|
const parsed = Number.parseInt(value ?? "", 10);
|
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
|
}
|
|
|
|
export function nonNegativeInteger(value) {
|
|
const parsed = Number.parseInt(value ?? "", 10);
|
|
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
|
|
}
|
|
|
|
export function nonNegativeIntegerOrNull(value) {
|
|
const parsed = Number.parseInt(value ?? "", 10);
|
|
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
|
|
}
|
|
|
|
export function projectionStatusOr(value, fallback) {
|
|
const text = textOr(value, fallback).toLowerCase();
|
|
return ["projecting", "caught_up", "terminal", "degraded", "blocked", "stalled"].includes(text) ? text : fallback;
|
|
}
|
|
|
|
export function projectionHealthOr(value, fallback) {
|
|
const text = textOr(value, fallback).toLowerCase();
|
|
return ["healthy", "degraded", "unavailable", "stalled"].includes(text) ? text : fallback;
|
|
}
|
|
|
|
export function resultSyncStateOr(value, fallback) {
|
|
const text = textOr(value, fallback).toLowerCase();
|
|
return ["not_started", "pending", "synced", "failed", "timed_out"].includes(text) ? text : fallback;
|
|
}
|
|
|
|
export function timestampOr(value, fallback) {
|
|
const text = textOr(value, "");
|
|
if (text) {
|
|
const ms = Date.parse(text);
|
|
if (Number.isFinite(ms)) return new Date(ms).toISOString();
|
|
}
|
|
return textOr(fallback, "") || new Date().toISOString();
|
|
}
|
|
|
|
export function textOr(value, fallback) {
|
|
const text = String(value ?? "").trim();
|
|
return text || fallback;
|
|
}
|
|
|
|
export function textList(value) {
|
|
const raw = Array.isArray(value) ? value : [];
|
|
return [...new Set(raw.map((item) => textOr(item, "")).filter(Boolean))];
|
|
}
|
|
|
|
export 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;
|
|
}
|
|
|
|
export 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];
|
|
}
|
|
|
|
export function requireProtocolId(input, field) {
|
|
const value = requireString(input, field);
|
|
return value;
|
|
}
|
|
|
|
export function normalizeJsonObject(value) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
return {};
|
|
}
|
|
return { ...value };
|
|
}
|
|
|
|
export function pruneUndefined(input) {
|
|
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
|
|
}
|
|
|
|
export function makeId(prefix) {
|
|
return `${prefix}_${randomUUID()}`;
|
|
}
|
|
|
|
export function stableJson(value) {
|
|
return JSON.stringify(sortJson(value));
|
|
}
|
|
|
|
export function sha256Hex(value) {
|
|
return createHash("sha256").update(stableJson(value)).digest("hex");
|
|
}
|
|
|
|
export 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;
|
|
}
|
|
|
|
export function matchesQuery(record, params, fields) {
|
|
for (const field of fields) {
|
|
if (params[field] !== undefined && params[field] !== null && record[field] !== params[field]) {
|
|
return false;
|
|
}
|
|
const values = textList(params[`${field}s`]);
|
|
if (values.length > 0 && !values.includes(textOr(record[field], ""))) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export function matchesWorkbenchSessionFactQuery(record, params) {
|
|
if (!matchesQuery(record, params, ["sessionId", "ownerUserId", "projectId", "conversationId", "threadId", "status"])) return false;
|
|
const traceId = textOr(params.traceId ?? params.lastTraceId, "");
|
|
if (traceId && record.lastTraceId !== traceId && record.traceId !== traceId) return false;
|
|
return true;
|
|
}
|
|
|
|
export function limitResults(records, limit) {
|
|
const parsed = Number.parseInt(limit ?? "", 10);
|
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
return records;
|
|
}
|
|
return records.slice(0, parsed);
|
|
}
|
|
|
|
export 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;
|
|
}
|
|
|
|
export function normalizePostgresSslMode(value) {
|
|
const normalized = String(value ?? "").trim().toLowerCase();
|
|
if (["disable", "false", "0", "off", "no"].includes(normalized)) {
|
|
return "disable";
|
|
}
|
|
return "require";
|
|
}
|
|
|
|
export function postgresSslModeFromConnectionString(dbUrl) {
|
|
if (typeof dbUrl !== "string" || dbUrl.trim() === "") return undefined;
|
|
try {
|
|
const url = new URL(dbUrl);
|
|
if (!["postgres:", "postgresql:"].includes(url.protocol)) return undefined;
|
|
return url.searchParams.get("sslmode") ?? url.searchParams.get("ssl") ?? undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
export function postgresSslOption(sslMode) {
|
|
return sslMode === "disable" ? false : { rejectUnauthorized: false };
|
|
}
|
|
|
|
export function normalizePostgresConnectionStringForSslMode(dbUrl, sslMode) {
|
|
if (typeof dbUrl !== "string" || dbUrl.trim() === "") {
|
|
return dbUrl;
|
|
}
|
|
|
|
let url;
|
|
try {
|
|
url = new URL(dbUrl);
|
|
} catch {
|
|
return dbUrl;
|
|
}
|
|
|
|
if (!["postgres:", "postgresql:"].includes(url.protocol)) {
|
|
return dbUrl;
|
|
}
|
|
|
|
for (const key of ["ssl", "sslmode", "sslcert", "sslkey", "sslrootcert"]) {
|
|
url.searchParams.delete(key);
|
|
}
|
|
if (sslMode !== "disable") {
|
|
url.searchParams.set("sslmode", "require");
|
|
url.searchParams.set("uselibpqcompat", "true");
|
|
}
|
|
return url.toString();
|
|
}
|
|
|
|
export 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
|
|
};
|
|
}
|
|
|
|
export function addRuntimeDurabilityContract(summary) {
|
|
return {
|
|
...summary,
|
|
durabilityContract: {
|
|
ready: summary.durable === true && summary.ready === true && summary.liveRuntimeEvidence === true,
|
|
status: summary.durable === true && summary.ready === true && summary.liveRuntimeEvidence === true ? "ready" : "blocked",
|
|
requiredEvidence: RUNTIME_DURABILITY_REQUIRED_EVIDENCE,
|
|
dbLiveEvidenceIsDurabilityEvidence: false,
|
|
liveRuntimeEvidence: Boolean(summary.liveRuntimeEvidence),
|
|
adapterQueryRequired: true,
|
|
blockedLayer: summary.durable === true && summary.ready === true && summary.liveRuntimeEvidence === true
|
|
? null
|
|
: durabilityBlockedLayer(summary),
|
|
blocker: summary.blocker ?? null,
|
|
secretMaterialRead: false
|
|
}
|
|
};
|
|
}
|
|
|
|
export function durabilityBlockedLayer(summary = {}) {
|
|
const blocker = summary.blocker;
|
|
if (
|
|
blocker === RUNTIME_DURABLE_ADAPTER_MISSING ||
|
|
blocker === RUNTIME_DURABLE_ADAPTER_UNCONFIGURED ||
|
|
blocker === RUNTIME_DURABLE_ADAPTER_DRIVER_MISSING
|
|
) {
|
|
return "adapter";
|
|
}
|
|
if (blocker === RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED) {
|
|
return "ssl";
|
|
}
|
|
if (blocker === RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED) {
|
|
return "auth";
|
|
}
|
|
if (blocker === RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED) {
|
|
return "schema";
|
|
}
|
|
if (blocker === RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED) {
|
|
return "migration";
|
|
}
|
|
if (summary.connection?.queryAttempted || blocker === RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED) {
|
|
return "durability_query";
|
|
}
|
|
return "adapter";
|
|
}
|
|
|
|
export function runtimeSafety() {
|
|
return {
|
|
devOnly: true,
|
|
secretsRead: false,
|
|
secretMaterialRead: false,
|
|
valuesRedacted: true,
|
|
endpointRedacted: true
|
|
};
|
|
}
|
|
|
|
export function classifyRuntimeDbError(error) {
|
|
const code = typeof error?.code === "string" ? error.code : "UNKNOWN";
|
|
if (isRuntimeDbConnectTimeout(error)) {
|
|
return {
|
|
blocker: RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED,
|
|
reason: "Postgres runtime adapter connection attempt timed out before completing the runtime query",
|
|
retryable: true,
|
|
transient: true,
|
|
retryAfterMs: 5000,
|
|
connection: {
|
|
queryAttempted: true,
|
|
queryResult: "connect_timeout",
|
|
errorCode: code
|
|
}
|
|
};
|
|
}
|
|
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 (isRuntimeDbSslError(error)) {
|
|
return {
|
|
blocker: RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED,
|
|
reason: "Postgres runtime adapter reached the DB driver but SSL negotiation or SSL mode compatibility blocked the schema query",
|
|
connection: {
|
|
queryAttempted: true,
|
|
queryResult: "ssl_negotiation_blocked",
|
|
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
|
|
}
|
|
};
|
|
}
|
|
if (code === "53300") {
|
|
return {
|
|
blocker: RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED,
|
|
reason: "Postgres runtime adapter reached the database, but the server has exhausted available connection slots",
|
|
retryable: true,
|
|
transient: true,
|
|
retryAfterMs: 2000,
|
|
connection: {
|
|
queryAttempted: true,
|
|
queryResult: "too_many_connections",
|
|
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
|
|
}
|
|
};
|
|
}
|
|
|
|
export function isRuntimeDbConnectTimeout(error) {
|
|
const message = String(error?.message ?? error ?? "").toLowerCase();
|
|
const pgConnectTimeout = message.includes("timeout exceeded when trying to connect");
|
|
const pgConnectionTimeoutTermination = message.includes("connection terminated due to connection timeout");
|
|
if (!pgConnectTimeout && !pgConnectionTimeoutTermination) return false;
|
|
if (pgConnectionTimeoutTermination) return true;
|
|
const stack = String(error?.stack ?? "").toLowerCase();
|
|
return !stack || stack.includes("pg-pool") || stack.includes("node_modules/pg");
|
|
}
|
|
|
|
export function durableRuntimeReadBlockedError(method, readiness = {}) {
|
|
return new HwlabProtocolError("Postgres durable runtime adapter is blocked for runtime evidence queries", {
|
|
code: ERROR_CODES.internalError,
|
|
data: durableRuntimeReadBlockedData(method, readiness),
|
|
context: {
|
|
result: "failed"
|
|
}
|
|
});
|
|
}
|
|
|
|
export function durableRuntimeReadBlockedData(method, readiness = {}) {
|
|
const durability = readiness.durabilityContract ?? {};
|
|
const connection = readiness.connection ?? {};
|
|
const blockedLayer = durability.blockedLayer ?? durabilityBlockedLayer(readiness);
|
|
const queryResult = connection.queryResult ?? (blockedLayer === "durability_query" ? "query_blocked" : "not_ready");
|
|
|
|
return pruneUndefined({
|
|
method,
|
|
adapter: readiness.adapter ?? RUNTIME_STORE_KIND_POSTGRES,
|
|
status: "blocked",
|
|
blocker: readiness.blocker ?? RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED,
|
|
durable: false,
|
|
durableRequested: true,
|
|
liveRuntimeEvidence: false,
|
|
queryResult,
|
|
blockedLayer,
|
|
requiredEvidence: durability.requiredEvidence ?? RUNTIME_DURABILITY_REQUIRED_EVIDENCE,
|
|
dbLiveEvidenceIsDurabilityEvidence: false,
|
|
retryable: readiness.retryable === true || connection.queryResult === "connect_timeout",
|
|
transient: readiness.transient === true || connection.queryResult === "connect_timeout",
|
|
retryAfterMs: Number.isFinite(Number(readiness.retryAfterMs)) ? Math.max(0, Math.trunc(Number(readiness.retryAfterMs))) : undefined,
|
|
secretMaterialRead: false,
|
|
valueRedacted: true,
|
|
endpointRedacted: true,
|
|
errorCode: connection.errorCode ?? undefined,
|
|
redaction: {
|
|
valueRedacted: true,
|
|
valuesRedacted: true,
|
|
endpointRedacted: true,
|
|
secretMaterialRead: false
|
|
}
|
|
});
|
|
}
|
|
|
|
export function isRuntimeDbSslError(error) {
|
|
const code = typeof error?.code === "string" ? error.code : "";
|
|
if (code.startsWith("ERR_SSL") || code.startsWith("ERR_TLS")) {
|
|
return true;
|
|
}
|
|
if ([
|
|
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
|
"SELF_SIGNED_CERT_IN_CHAIN",
|
|
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
|
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
|
|
"CERT_HAS_EXPIRED"
|
|
].includes(code)) {
|
|
return true;
|
|
}
|
|
const message = typeof error?.message === "string" ? error.message.toLowerCase() : "";
|
|
return [
|
|
"does not support ssl",
|
|
"ssl is not enabled",
|
|
"ssl negotiation",
|
|
"ssl off",
|
|
"ssl required",
|
|
"requires ssl",
|
|
"no ssl encryption",
|
|
"no encryption",
|
|
"hostssl",
|
|
"server requires ssl",
|
|
"before secure tls connection",
|
|
"tls handshake"
|
|
].some((pattern) => message.includes(pattern));
|
|
}
|
|
|
|
export function summarizeRuntimeSchema(rows, requiredSchema = requiredPostgresSchema) {
|
|
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(requiredSchema)) {
|
|
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(requiredSchema),
|
|
missingTables,
|
|
missingColumns
|
|
};
|
|
}
|
|
|
|
export function notCheckedRuntimeMigration() {
|
|
return {
|
|
checked: false,
|
|
ready: false,
|
|
table: CLOUD_RUNTIME_DURABLE_MIGRATION_TABLE,
|
|
requiredMigrationId: CLOUD_CORE_MIGRATION_ID,
|
|
requiredSchemaVersion: CLOUD_RUNTIME_DURABLE_ADAPTER_SCHEMA_VERSION,
|
|
appliedMigrationId: null,
|
|
appliedSchemaVersion: null,
|
|
missing: true
|
|
};
|
|
}
|
|
|
|
export function blockedRuntimeMigration({ errorCode = null } = {}) {
|
|
return {
|
|
...notCheckedRuntimeMigration(),
|
|
checked: true,
|
|
errorCode
|
|
};
|
|
}
|
|
|
|
export function runtimeGates({
|
|
ssl = notCheckedGate(),
|
|
auth = notCheckedGate(),
|
|
schema = notCheckedGate(),
|
|
migration = notCheckedGate(),
|
|
durability = notCheckedGate()
|
|
} = {}) {
|
|
return {
|
|
ssl,
|
|
auth,
|
|
schema,
|
|
migration,
|
|
durability
|
|
};
|
|
}
|
|
|
|
export function defaultRuntimeGates({ blocker, connection, schema, migration } = {}) {
|
|
if (blocker === RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED) {
|
|
return {
|
|
ssl: blockedGate({ checked: true, blocker }),
|
|
auth: notCheckedGate()
|
|
};
|
|
}
|
|
if (blocker === RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED) {
|
|
return {
|
|
ssl: readyGate(),
|
|
auth: blockedGate({ checked: true, blocker })
|
|
};
|
|
}
|
|
if (blocker === RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED) {
|
|
return {
|
|
ssl: connection?.queryAttempted ? readyGate() : notCheckedGate(),
|
|
auth: connection?.queryAttempted ? readyGate() : notCheckedGate()
|
|
};
|
|
}
|
|
if (blocker === RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED && schema?.checked && migration?.checked) {
|
|
return {
|
|
ssl: readyGate(),
|
|
auth: readyGate()
|
|
};
|
|
}
|
|
return {
|
|
ssl: notCheckedGate(),
|
|
auth: notCheckedGate()
|
|
};
|
|
}
|
|
|
|
export function defaultSchemaGate({ blocker, schema } = {}) {
|
|
if (schema?.checked) {
|
|
return gateFromReadiness(schema.ready, { blocker: RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED });
|
|
}
|
|
if (blocker === RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED) {
|
|
return blockedGate({ checked: true, blocker });
|
|
}
|
|
return notCheckedGate();
|
|
}
|
|
|
|
export function defaultMigrationGate({ blocker, migration } = {}) {
|
|
if (migration?.checked) {
|
|
return gateFromReadiness(migration.ready, { blocker: RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED });
|
|
}
|
|
if (blocker === RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED) {
|
|
return blockedGate({ checked: true, blocker });
|
|
}
|
|
return notCheckedGate();
|
|
}
|
|
|
|
export function readyGate() {
|
|
return {
|
|
checked: true,
|
|
ready: true,
|
|
status: "ready",
|
|
blocker: null
|
|
};
|
|
}
|
|
|
|
export function blockedGate({ checked = true, blocker = "runtime_durable_adapter_blocked" } = {}) {
|
|
return {
|
|
checked,
|
|
ready: false,
|
|
status: checked ? "blocked" : "not_checked",
|
|
blocker
|
|
};
|
|
}
|
|
|
|
export function notCheckedGate() {
|
|
return {
|
|
checked: false,
|
|
ready: false,
|
|
status: "not_checked",
|
|
blocker: null
|
|
};
|
|
}
|
|
|
|
export function gateFromReadiness(ready, { blocker } = {}) {
|
|
return ready ? readyGate() : blockedGate({ checked: true, blocker });
|
|
}
|
|
|
|
export 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),
|
|
agentTraceEvents: new Map(memory.agentTraceEvents),
|
|
workbenchProjectionStates: new Map(memory.workbenchProjectionStates),
|
|
workbenchSessions: new Map(memory.workbenchSessions),
|
|
workbenchMessages: new Map(memory.workbenchMessages),
|
|
workbenchParts: new Map(memory.workbenchParts),
|
|
workbenchTurns: new Map(memory.workbenchTurns),
|
|
workbenchTraceEvents: new Map(memory.workbenchTraceEvents),
|
|
workbenchProjectionCheckpoints: new Map(memory.workbenchProjectionCheckpoints)
|
|
};
|
|
}
|
|
|
|
export function newRecords(current, before) {
|
|
return [...current.entries()]
|
|
.filter(([id]) => !before.has(id))
|
|
.map(([, record]) => record);
|
|
}
|
|
|
|
export function changedRecords(current, before) {
|
|
return [...current.entries()]
|
|
.filter(([id, record]) => stableJson(before.get(id)) !== stableJson(record))
|
|
.map(([, record]) => record);
|
|
}
|
|
|
|
export function withPersistence(result, persistence) {
|
|
return {
|
|
...result,
|
|
persistence
|
|
};
|
|
}
|
|
|
|
export function parseJsonColumn(value, fallback) {
|
|
if (!value) return fallback;
|
|
if (typeof value === "object") return value;
|
|
try {
|
|
return JSON.parse(String(value));
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
export function postgresPoolStats(pool) {
|
|
if (!pool || typeof pool !== "object") return null;
|
|
return {
|
|
totalCount: Number.isFinite(Number(pool.totalCount)) ? Number(pool.totalCount) : null,
|
|
idleCount: Number.isFinite(Number(pool.idleCount)) ? Number(pool.idleCount) : null,
|
|
waitingCount: Number.isFinite(Number(pool.waitingCount)) ? Number(pool.waitingCount) : null,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
export function toCountKey(table) {
|
|
if (table === "users") return "users";
|
|
if (table === "user_sessions") return "userSessions";
|
|
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";
|
|
if (table === "agent_sessions") return "agentSessions";
|
|
if (table === "account_workspaces") return "accountWorkspaces";
|
|
if (table === "worker_sessions") return "workerSessions";
|
|
if (table === "agent_trace_events") return "agentTraceEvents";
|
|
if (table === "workbench_projection_state") return "workbenchProjectionStates";
|
|
if (table === "workbench_sessions") return "workbenchSessions";
|
|
if (table === "workbench_messages") return "workbenchMessages";
|
|
if (table === "workbench_parts") return "workbenchParts";
|
|
if (table === "workbench_turns") return "workbenchTurns";
|
|
if (table === "workbench_trace_events") return "workbenchTraceEvents";
|
|
if (table === "workbench_projection_checkpoints") return "workbenchProjectionCheckpoints";
|
|
return table;
|
|
}
|
|
|
|
export function normalizeRuntimeTimeoutMs(value) {
|
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
return 1200;
|
|
}
|
|
return Math.min(parsed, 5000);
|
|
}
|
|
|
|
export function normalizeRuntimePoolMax(value) {
|
|
if (value === undefined || value === null || String(value).trim() === "") return null;
|
|
const parsed = Number.parseInt(String(value), 10);
|
|
if (!Number.isInteger(parsed) || parsed <= 0 || String(parsed) !== String(value).trim()) {
|
|
const error = new Error(`${RUNTIME_DB_POOL_MAX_ENV} must be a positive integer`);
|
|
error.code = "HWLAB_CLOUD_DB_POOL_MAX_INVALID";
|
|
throw error;
|
|
}
|
|
return parsed;
|
|
}
|