fix: add durable cloud api runtime adapter

This commit is contained in:
Code Queue Review
2026-05-22 18:27:12 +00:00
parent dcb496bf95
commit b775aba196
20 changed files with 1543 additions and 43 deletions
+4
View File
@@ -31,6 +31,10 @@ for future compatibility, but PROD deployment is not part of MVP acceptance.
`HWLAB_CODE_AGENT_OPENAI_BASE_URL` through the DEV egress/proxy path. Source
checks verify the Secret ref and base-url contract only; they do not prove
provider connectivity and must not print API key material.
- `hwlab-cloud-api` also declares `HWLAB_CLOUD_RUNTIME_ADAPTER=postgres` and
`HWLAB_CLOUD_RUNTIME_DURABLE=true` so DEV can use the Postgres-backed runtime
adapter once the schema is applied. `/health/live` must still remain degraded
if DB auth, DB connectivity, or runtime schema readiness is blocked.
- `deploy/k8s/prod` is a disabled placeholder gate only.
- `deploy/frp` describes the D601-to-master reverse link without secrets.
- `deploy/master-edge` describes public edge ownership and health boundaries.
+2
View File
@@ -200,6 +200,8 @@
"HWLAB_CLOUD_DB_SERVICE_NAMESPACE": "hwlab-dev",
"HWLAB_CLOUD_DB_HOST": "cloud-api-db.hwlab-dev.svc.cluster.local",
"HWLAB_CLOUD_DB_PORT": "5432",
"HWLAB_CLOUD_RUNTIME_ADAPTER": "postgres",
"HWLAB_CLOUD_RUNTIME_DURABLE": "true",
"HWLAB_CODE_AGENT_PROVIDER": "openai",
"HWLAB_CODE_AGENT_MODEL": "gpt-5.5",
"HWLAB_CODE_AGENT_OPENAI_BASE_URL": "http://172.26.26.227:17680/v1/responses",
+8
View File
@@ -387,6 +387,14 @@
"type": "string",
"const": "5432"
},
"HWLAB_CLOUD_RUNTIME_ADAPTER": {
"type": "string",
"const": "postgres"
},
"HWLAB_CLOUD_RUNTIME_DURABLE": {
"type": "string",
"const": "true"
},
"HWLAB_CODE_AGENT_PROVIDER": {
"type": "string",
"const": "openai"
+8
View File
@@ -97,6 +97,14 @@
"name": "HWLAB_CLOUD_DB_PORT",
"value": "5432"
},
{
"name": "HWLAB_CLOUD_RUNTIME_ADAPTER",
"value": "postgres"
},
{
"name": "HWLAB_CLOUD_RUNTIME_DURABLE",
"value": "true"
},
{
"name": "HWLAB_CODE_AGENT_PROVIDER",
"value": "openai"
+24 -4
View File
@@ -1,7 +1,9 @@
# Cloud API Runtime
The L1 `hwlab-cloud-api` runtime is a minimal local HTTP/JSON-RPC service. It
does not deploy to DEV or PROD and does not claim live database persistence.
does not deploy to DEV or PROD from source tests and does not claim live
database persistence unless both DB readiness and the durable runtime adapter
readiness are live.
## Local Smoke
@@ -16,6 +18,9 @@ and verifies:
- `GET /health` reports `status: "degraded"` when live DB persistence is not
connected.
- `GET /health` reports DB readiness and runtime durable adapter readiness as
separate components. A memory adapter remains non-durable even when DB env
fields are present.
- The DB contract reports required env names, redacted Secret refs, env
injection, connection attempt status, connection result, `liveConnected`,
`liveDbEvidence`, DNS/TCP/auth/schema readiness layers, and redaction/safety
@@ -29,14 +34,29 @@ and verifies:
## Persistence Boundary
The current runtime store is process-local memory:
The default local runtime store is process-local memory:
- `runtime.adapter: "memory"`
- `runtime.durable: false`
- `runtime.status: "degraded"`
This is intentional for L1. If `HWLAB_CLOUD_DB_URL` and
`HWLAB_CLOUD_DB_SSL_MODE` are absent, health reports `db.status: "blocked"`.
This remains the safe local default. DEV can opt into the durable adapter with
non-secret env flags:
- `HWLAB_CLOUD_RUNTIME_ADAPTER=postgres`
- `HWLAB_CLOUD_RUNTIME_DURABLE=true`
The Postgres adapter uses `HWLAB_CLOUD_DB_URL` from
`hwlab-cloud-api-dev-db/database-url`; source records only the Secret reference
and non-secret DNS contract. The adapter reports `runtime.ready: true` only
after a live schema query proves the required runtime tables and columns exist.
If DB auth or schema is missing, `/health/live` stays degraded and reports a
runtime blocker such as `runtime_durable_adapter_auth_blocked` or
`runtime_durable_adapter_schema_blocked`. Fixture data and env presence are
never promoted to `runtime.liveRuntimeEvidence`.
If `HWLAB_CLOUD_DB_URL` and `HWLAB_CLOUD_DB_SSL_MODE` are absent, health
reports `db.status: "blocked"`.
If both env vars are present, cloud-api attempts a short read-only TCP
connection to the DB endpoint from the injected runtime env. The health payload
keeps all endpoint details redacted and reports only:
+6 -1
View File
@@ -13,8 +13,13 @@ secrets, enable PROD, or push to GHCR/Docker Hub/other third-party registries.
`HWLAB_DEV_BASE_IMAGE`, a local `node:20-*` Docker image, or a local HWLAB
base tag such as
`127.0.0.1:5000/hwlab/hwlab-dev-base:node20-bookworm-slim`. There is no
network pull or third-party fallback during publish; the build runs with
base-image pull or third-party fallback during publish; the build runs with
`--pull=false`.
- Node runtime dependencies are installed from the source `package.json` and
`package-lock.json` inside the DEV image build with `npm ci --omit=dev
--ignore-scripts` when the lockfile is present. This includes the Postgres
`pg` driver required by the
durable cloud-api runtime adapter.
- Image tag: first seven characters of the Git commit used as build source.
- Required image labels:
- `hwlab.pikastech.local/repo`
+118
View File
@@ -0,0 +1,118 @@
import { RUNTIME_DURABLE_ADAPTER_MISSING } from "../db/runtime-store.mjs";
export const CLOUD_API_HEALTH_CONTRACT_VERSION = "v2";
export function buildCloudApiReadiness({ db, codeAgent, runtime } = {}) {
const dbReady = isDbReady(db);
const codeAgentReady = isCodeAgentReady(codeAgent);
const runtimeReady = isRuntimeReady(runtime);
const blockers = [];
if (!dbReady) {
blockers.push(dbBlocker(db));
}
if (!codeAgentReady) {
blockers.push(codeAgentBlocker(codeAgent));
}
if (!runtimeReady) {
blockers.push(runtimeBlocker(runtime));
}
return {
contractVersion: CLOUD_API_HEALTH_CONTRACT_VERSION,
ready: blockers.length === 0,
status: blockers.length === 0 ? "ok" : "degraded",
blockerCodes: blockers.map((blocker) => blocker.code),
blockers,
components: {
db: dbReady ? "ready" : "blocked",
codeAgent: codeAgentReady ? "ready" : "blocked",
runtime: runtimeReady ? "ready" : dbReady ? "blocked" : "waiting_for_db"
}
};
}
function isDbReady(db) {
return Boolean(db?.ready && db?.connected && db?.liveConnected !== false && db?.liveDbEvidence);
}
function isCodeAgentReady(codeAgent) {
return Boolean(codeAgent?.ready && codeAgent?.status !== "blocked");
}
function isRuntimeReady(runtime) {
if (runtime?.durable !== true) {
return false;
}
return !["blocked", "degraded", "failed"].includes(runtime.status);
}
function dbBlocker(db) {
return {
code: db?.configReady ? "db_live_connection_blocked" : "db_runtime_config_blocked",
type: "runtime_blocker",
scope: "cloud-api-db",
status: "open",
sourceIssue: "pikasTech/HWLAB#49",
summary: db?.blocker ?? "cloud-api DB live readiness is not established",
evidence: {
configReady: Boolean(db?.configReady),
connected: Boolean(db?.connected),
liveConnected: Boolean(db?.liveConnected),
liveDbEvidence: Boolean(db?.liveDbEvidence),
connectionAttempted: Boolean(db?.connectionAttempted ?? db?.connectionChecked),
connectionResult: db?.connectionResult ?? db?.connection?.result ?? "unknown"
}
};
}
function codeAgentBlocker(codeAgent) {
return {
code: codeAgent?.reason ?? "code_agent_provider_unavailable",
type: "agent_blocker",
scope: "code-agent-provider",
status: "open",
sourceIssue: "pikasTech/HWLAB#143",
summary: codeAgent?.summary ?? "Code Agent provider readiness is not established",
evidence: {
provider: codeAgent?.provider ?? "unknown",
backend: codeAgent?.backend ?? "unknown",
missingEnv: Array.isArray(codeAgent?.missingEnv) ? [...codeAgent.missingEnv] : [],
secretsRedacted: true
}
};
}
function runtimeBlocker(runtime) {
const durable = runtime?.durable === true;
const unhealthy = durable && ["blocked", "degraded", "failed"].includes(runtime?.status);
const blocker = runtime?.blocker;
return {
code: durable && unhealthy ? blocker ?? "runtime_durable_adapter_unhealthy" : RUNTIME_DURABLE_ADAPTER_MISSING,
type: "runtime_blocker",
scope: "runtime-durable-adapter",
status: "open",
sourceIssue: "pikasTech/HWLAB#164",
summary: durable && unhealthy
? runtime?.reason ?? "cloud-api durable runtime adapter is configured but not ready"
: "cloud-api DB is live, but L1 runtime writes still use process-local memory because no DB-backed durable adapter is configured",
evidence: {
adapter: runtime?.adapter ?? "unknown",
durable: Boolean(runtime?.durable),
runtimeStatus: runtime?.status ?? "unknown",
runtimeReady: Boolean(runtime?.ready),
liveRuntimeEvidence: Boolean(runtime?.liveRuntimeEvidence),
schemaReady: Boolean(runtime?.schema?.ready),
schemaChecked: Boolean(runtime?.schema?.checked),
queryAttempted: Boolean(runtime?.connection?.queryAttempted),
queryResult: runtime?.connection?.queryResult ?? "unknown",
dbBackedAdapterRequired: true,
secretMaterialRead: false
},
nextTask: durable
? "Apply/repair the runtime DB schema or DB auth, then rerun /health/live without printing DB secret material."
: "Set HWLAB_CLOUD_RUNTIME_ADAPTER=postgres after the runtime DB schema is available; keep HWLAB_CLOUD_DB_URL injected from Secret."
};
}
+42 -16
View File
@@ -15,6 +15,8 @@ import {
deriveActorFromMeta
} from "../audit/index.mjs";
import { buildDbRuntimeReadiness } from "./db-contract.mjs";
import { describeCodeAgentAvailability } from "./code-agent-chat.mjs";
import { buildCloudApiReadiness } from "./health-contract.mjs";
import { createCloudRuntimeStore } from "../db/runtime-store.mjs";
export const SUPPORTED_RPC_METHODS = Object.freeze([
@@ -34,15 +36,15 @@ export const SUPPORTED_RPC_METHODS = Object.freeze([
const rpcHandlers = new Map([
["system.health", handleSystemHealth],
["cloud.adapter.describe", handleAdapterDescribe],
["gateway.session.register", (params, envelope, context) => getRuntimeStore(context).registerGatewaySession(params, envelope.meta)],
["box.resource.register", (params, envelope, context) => getRuntimeStore(context).registerBoxResource(params, envelope.meta)],
["box.capability.report", (params, envelope, context) => getRuntimeStore(context).reportBoxCapabilities(params, envelope.meta)],
["hardware.operation.request", (params, envelope, context) => getRuntimeStore(context).requestHardwareOperation(params, envelope.meta)],
["hardware.invoke.shell", (params, envelope, context) => getRuntimeStore(context).invokeHardwareShell(params, envelope.meta)],
["audit.event.write", (params, envelope, context) => getRuntimeStore(context).writeAuditEvent(params, envelope.meta)],
["audit.event.query", (params, envelope, context) => getRuntimeStore(context).queryAuditEvents(params)],
["evidence.record.write", (params, envelope, context) => getRuntimeStore(context).writeEvidenceRecord(params, envelope.meta)],
["evidence.record.query", (params, envelope, context) => getRuntimeStore(context).queryEvidenceRecords(params)]
["gateway.session.register", async (params, envelope, context) => getRuntimeStore(context).registerGatewaySession(params, envelope.meta)],
["box.resource.register", async (params, envelope, context) => getRuntimeStore(context).registerBoxResource(params, envelope.meta)],
["box.capability.report", async (params, envelope, context) => getRuntimeStore(context).reportBoxCapabilities(params, envelope.meta)],
["hardware.operation.request", async (params, envelope, context) => getRuntimeStore(context).requestHardwareOperation(params, envelope.meta)],
["hardware.invoke.shell", async (params, envelope, context) => getRuntimeStore(context).invokeHardwareShell(params, envelope.meta)],
["audit.event.write", async (params, envelope, context) => getRuntimeStore(context).writeAuditEvent(params, envelope.meta)],
["audit.event.query", async (params, envelope, context) => getRuntimeStore(context).queryAuditEvents(params)],
["evidence.record.write", async (params, envelope, context) => getRuntimeStore(context).writeEvidenceRecord(params, envelope.meta)],
["evidence.record.query", async (params, envelope, context) => getRuntimeStore(context).queryEvidenceRecords(params)]
]);
const defaultRuntimeStore = createCloudRuntimeStore();
@@ -218,24 +220,45 @@ export function createResponseMeta(requestMeta = {}) {
}
async function handleSystemHealth(params, envelope, context) {
const env = context.env ?? process.env;
const db = await buildDbRuntimeReadiness(env, context.dbProbe);
const codeAgent = describeCodeAgentAvailability(env, context);
const runtime = await runtimeReadiness(getRuntimeStore(context));
const readiness = buildCloudApiReadiness({ db, codeAgent, runtime });
return {
status: healthStatus(),
status: readiness.status,
ready: readiness.ready,
serviceId: CLOUD_API_SERVICE_ID,
environment: ENVIRONMENT_DEV,
db: await buildDbRuntimeReadiness(process.env, context.dbProbe),
runtime: getRuntimeStore(context).summary()
db,
codeAgent,
runtime,
readiness,
blockers: readiness.blockers,
blockerCodes: readiness.blockerCodes
};
}
async function handleAdapterDescribe(params, envelope, context) {
const env = context.env ?? process.env;
const db = await buildDbRuntimeReadiness(env, context.dbProbe);
const codeAgent = describeCodeAgentAvailability(env, context);
const runtime = await runtimeReadiness(getRuntimeStore(context));
const readiness = buildCloudApiReadiness({ db, codeAgent, runtime });
return {
serviceId: CLOUD_API_SERVICE_ID,
status: readiness.status,
ready: readiness.ready,
rpcEndpoint: "POST /rpc",
restEndpoint: "POST /v1/rpc/{method}",
methods: SUPPORTED_RPC_METHODS,
auditFields: AUDIT_FIELD_NAMES,
db: await buildDbRuntimeReadiness(process.env, context.dbProbe),
runtime: getRuntimeStore(context).summary()
db,
codeAgent,
runtime,
readiness,
blockers: readiness.blockers,
blockerCodes: readiness.blockerCodes
};
}
@@ -243,8 +266,11 @@ function getRuntimeStore(context = {}) {
return context.runtimeStore || defaultRuntimeStore;
}
function healthStatus() {
return "degraded";
async function runtimeReadiness(runtimeStore) {
if (typeof runtimeStore?.readiness === "function") {
return runtimeStore.readiness();
}
return runtimeStore.summary();
}
function extractMeta(envelope) {
+41 -13
View File
@@ -14,15 +14,17 @@ import {
} from "./json-rpc.mjs";
import { describeCodeAgentAvailability, handleCodeAgentChat } from "./code-agent-chat.mjs";
import { buildDbRuntimeReadiness } from "./db-contract.mjs";
import { createCloudRuntimeStore } from "../db/runtime-store.mjs";
import { buildCloudApiReadiness } from "./health-contract.mjs";
import { createConfiguredCloudRuntimeStore } from "../db/runtime-store.mjs";
const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024;
export function createCloudApiServer(options = {}) {
const runtimeStore = options.runtimeStore || createCloudRuntimeStore();
const env = options.env ?? process.env;
const runtimeStore = options.runtimeStore || createConfiguredCloudRuntimeStore({ ...options, env });
return createServer(async (request, response) => {
try {
await routeRequest(request, response, { ...options, runtimeStore });
await routeRequest(request, response, { ...options, env, runtimeStore });
} catch (error) {
sendJson(response, 500, {
error: {
@@ -37,16 +39,23 @@ export function createCloudApiServer(options = {}) {
export async function buildHealthPayload(options = {}) {
const serviceId = CLOUD_API_SERVICE_ID;
const env = options.env ?? process.env;
const commitId = process.env.HWLAB_COMMIT_ID || process.env.HWLAB_GIT_SHA || "unknown";
const imageReference = process.env.HWLAB_IMAGE || "unknown";
const imageTag = process.env.HWLAB_IMAGE_TAG || commitId.slice(0, 7) || "unknown";
const db = await buildDbRuntimeReadiness(process.env, options.dbProbe);
const codeAgent = describeCodeAgentAvailability(options.env ?? process.env, options);
const db = await buildDbRuntimeReadiness(env, options.dbProbe);
const codeAgent = describeCodeAgentAvailability(env, options);
const runtime = await runtimeReadiness(options.runtimeStore ?? createConfiguredCloudRuntimeStore({ ...options, env }));
const readiness = buildCloudApiReadiness({ db, codeAgent, runtime });
return {
serviceId,
environment: ENVIRONMENT_DEV,
status: db.status === "blocked" ? "degraded" : "degraded",
status: readiness.status,
ready: readiness.ready,
readiness,
blockers: readiness.blockers,
blockerCodes: readiness.blockerCodes,
service: {
id: serviceId,
role: "cloud-api",
@@ -66,7 +75,7 @@ export async function buildHealthPayload(options = {}) {
observedAt: new Date().toISOString(),
db,
codeAgent,
runtime: options.runtimeStore?.summary?.() ?? createCloudRuntimeStore().summary()
runtime
};
}
@@ -137,23 +146,33 @@ async function handleRpcHttpRequest(request, response, options) {
const rpcResponse = await handleJsonRpcRequest(envelope, {
adapter: "json-rpc",
runtimeStore: options.runtimeStore
runtimeStore: options.runtimeStore,
env: options.env,
dbProbe: options.dbProbe
});
sendJson(response, statusForRpcResponse(rpcResponse), rpcResponse);
}
async function handleRestAdapter(request, response, url, options) {
if (request.method === "GET" && url.pathname === "/v1") {
const db = await buildDbRuntimeReadiness(options.env ?? process.env, options.dbProbe);
const codeAgent = describeCodeAgentAvailability(options.env ?? process.env, options);
const runtime = await runtimeReadiness(options.runtimeStore);
const readiness = buildCloudApiReadiness({ db, codeAgent, runtime });
sendJson(response, 200, {
serviceId: CLOUD_API_SERVICE_ID,
adapter: "rest",
status: "degraded",
status: readiness.status,
ready: readiness.ready,
rpcBridge: "POST /v1/rpc/{method}",
codeAgent: describeCodeAgentAvailability(options.env ?? process.env, options),
codeAgent,
methods: SUPPORTED_RPC_METHODS,
auditFields: AUDIT_FIELD_NAMES,
db: await buildDbRuntimeReadiness(process.env, options.dbProbe),
runtime: options.runtimeStore.summary()
db,
runtime,
readiness,
blockers: readiness.blockers,
blockerCodes: readiness.blockerCodes
});
return;
}
@@ -220,7 +239,9 @@ async function handleRestAdapter(request, response, url, options) {
},
{
adapter: "rest",
runtimeStore: options.runtimeStore
runtimeStore: options.runtimeStore,
env: options.env,
dbProbe: options.dbProbe
}
);
@@ -369,3 +390,10 @@ function getHeader(request, name) {
}
return value;
}
async function runtimeReadiness(runtimeStore) {
if (typeof runtimeStore?.readiness === "function") {
return runtimeStore.readiness();
}
return runtimeStore.summary();
}
+77 -1
View File
@@ -3,6 +3,7 @@ import { createServer as createTcpServer } from "node:net";
import test from "node:test";
import { createCloudApiServer } from "./server.mjs";
import { RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED } from "../db/runtime-store.mjs";
test("cloud api exposes /health, /health/live, and /live probes", async () => {
const originalUrl = process.env.HWLAB_CLOUD_DB_URL;
@@ -96,7 +97,7 @@ test("cloud api health reports DB env presence and live connection classificatio
const fakeDb = createTcpServer((socket) => socket.end());
await new Promise((resolve) => fakeDb.listen(0, "127.0.0.1", resolve));
const dbPort = fakeDb.address().port;
process.env.HWLAB_CLOUD_DB_URL = `postgres://user:password@127.0.0.1:${dbPort}/hwlab`;
process.env.HWLAB_CLOUD_DB_URL = `postgres://hwlab_test@127.0.0.1:${dbPort}/hwlab`;
process.env.HWLAB_CLOUD_DB_SSL_MODE = "require";
const server = createCloudApiServer();
@@ -152,6 +153,81 @@ test("cloud api health reports DB env presence and live connection classificatio
}
});
test("cloud api health separates DB connected from durable runtime schema readiness", async () => {
const originalUrl = process.env.HWLAB_CLOUD_DB_URL;
const originalSslMode = process.env.HWLAB_CLOUD_DB_SSL_MODE;
const fakeDb = createTcpServer((socket) => socket.end());
await new Promise((resolve) => fakeDb.listen(0, "127.0.0.1", resolve));
const dbPort = fakeDb.address().port;
process.env.HWLAB_CLOUD_DB_URL = `postgres://hwlab_test@127.0.0.1:${dbPort}/hwlab`;
process.env.HWLAB_CLOUD_DB_SSL_MODE = "require";
const server = createCloudApiServer({
runtimeStore: {
async readiness() {
return {
adapter: "postgres",
durable: true,
ready: false,
status: "blocked",
blocker: RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED,
reason: "test runtime schema is missing",
liveRuntimeEvidence: false,
fixtureEvidence: false,
connection: {
queryAttempted: true,
queryResult: "schema_blocked",
endpointRedacted: true,
valueRedacted: true
},
schema: {
checked: true,
ready: false,
missingTables: ["gateway_sessions"],
missingColumns: ["gateway_sessions.gateway_session_json"]
},
counts: {}
};
}
}
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
const response = await fetch(`http://127.0.0.1:${port}/health/live`);
const payload = await response.json();
assert.equal(response.status, 200);
assert.equal(payload.status, "degraded");
assert.equal(payload.db.connected, true);
assert.equal(payload.db.liveDbEvidence, true);
assert.equal(payload.runtime.durable, true);
assert.equal(payload.runtime.ready, false);
assert.equal(payload.runtime.blocker, RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED);
assert.equal(payload.readiness.components.db, "ready");
assert.equal(payload.readiness.components.runtime, "blocked");
assert.ok(payload.blockerCodes.includes(RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED));
assert.equal(JSON.stringify(payload).includes("password"), false);
} finally {
if (originalUrl === undefined) {
delete process.env.HWLAB_CLOUD_DB_URL;
} else {
process.env.HWLAB_CLOUD_DB_URL = originalUrl;
}
if (originalSslMode === undefined) {
delete process.env.HWLAB_CLOUD_DB_SSL_MODE;
} else {
process.env.HWLAB_CLOUD_DB_SSL_MODE = originalSslMode;
}
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
await new Promise((resolve, reject) => {
fakeDb.close((error) => (error ? reject(error) : resolve()));
});
}
});
test("cloud api health does not treat DB env presence-only as live readiness", async () => {
const originalUrl = process.env.HWLAB_CLOUD_DB_URL;
const originalSslMode = process.env.HWLAB_CLOUD_DB_SSL_MODE;
@@ -1,6 +1,6 @@
-- HWLAB L1 Cloud Core schema skeleton.
-- This file is a contract placeholder only; it does not connect to or write a
-- live database.
-- This file is source schema only; applying it to DEV/PROD is a separate
-- operator action and is not performed by source tests or smokes.
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
@@ -16,7 +16,8 @@ CREATE TABLE IF NOT EXISTS gateway_sessions (
gateway_service_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
started_at TEXT,
ended_at TEXT
ended_at TEXT,
gateway_session_json TEXT NOT NULL DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS box_resources (
@@ -25,6 +26,7 @@ CREATE TABLE IF NOT EXISTS box_resources (
gateway_session_id TEXT,
resource_state TEXT NOT NULL DEFAULT 'available',
labels_json TEXT NOT NULL DEFAULT '{}',
resource_json TEXT NOT NULL DEFAULT '{}',
updated_at TEXT NOT NULL
);
+726 -1
View File
@@ -13,11 +13,108 @@ import {
} from "../audit/index.mjs";
export const RUNTIME_STORE_KIND = "memory";
export const RUNTIME_STORE_KIND_POSTGRES = "postgres";
export const RUNTIME_DURABLE_ADAPTER_MISSING = "runtime_durable_adapter_missing";
export const RUNTIME_DURABLE_ADAPTER_UNCONFIGURED = "runtime_durable_adapter_unconfigured";
export const RUNTIME_DURABLE_ADAPTER_DRIVER_MISSING = "runtime_durable_adapter_driver_missing";
export const RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED = "runtime_durable_adapter_auth_blocked";
export const RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED = "runtime_durable_adapter_schema_blocked";
export const RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED = "runtime_durable_adapter_query_blocked";
export const RUNTIME_ADAPTER_ENV = "HWLAB_CLOUD_RUNTIME_ADAPTER";
export const RUNTIME_DURABLE_ENV = "HWLAB_CLOUD_RUNTIME_DURABLE";
const requiredPostgresSchema = Object.freeze({
gateway_sessions: Object.freeze([
"id",
"project_id",
"gateway_service_id",
"status",
"started_at",
"ended_at",
"gateway_session_json"
]),
box_resources: Object.freeze([
"id",
"project_id",
"gateway_session_id",
"resource_state",
"labels_json",
"resource_json",
"updated_at"
]),
box_capabilities: Object.freeze([
"id",
"box_resource_id",
"capability_type",
"capability_json",
"updated_at"
]),
hardware_operations: Object.freeze([
"id",
"project_id",
"requested_by",
"operation_type",
"operation_json",
"status",
"requested_at",
"updated_at"
]),
audit_events: Object.freeze([
"id",
"request_id",
"actor",
"source",
"operation",
"target",
"result",
"timestamp",
"event_json"
]),
evidence_records: Object.freeze([
"id",
"project_id",
"operation_id",
"evidence_type",
"uri",
"metadata_json",
"created_at"
])
});
const postgresCountTables = Object.freeze([
"gateway_sessions",
"box_resources",
"box_capabilities",
"hardware_operations",
"audit_events",
"evidence_records"
]);
export function createCloudRuntimeStore(options = {}) {
return new CloudRuntimeStore(options);
}
export function createConfiguredCloudRuntimeStore(options = {}) {
const env = options.env ?? process.env;
const adapter = normalizeRuntimeAdapter(options.adapter ?? env?.[RUNTIME_ADAPTER_ENV]);
const durableRequested =
options.durable === true ||
env?.[RUNTIME_DURABLE_ENV] === "1" ||
env?.[RUNTIME_DURABLE_ENV] === "true";
if (adapter === RUNTIME_STORE_KIND_POSTGRES || durableRequested) {
return new PostgresCloudRuntimeStore({
...options,
env,
dbUrl: options.dbUrl ?? env?.HWLAB_CLOUD_DB_URL,
sslMode: options.sslMode ?? env?.HWLAB_CLOUD_DB_SSL_MODE
});
}
return createCloudRuntimeStore(options);
}
export class CloudRuntimeStore {
constructor({ now = () => new Date().toISOString() } = {}) {
this.now = now;
@@ -34,7 +131,13 @@ export class CloudRuntimeStore {
adapter: RUNTIME_STORE_KIND,
durable: false,
status: "degraded",
reason: "live DB persistence is not connected; L1 runtime writes are process-local only",
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,
@@ -510,6 +613,455 @@ export class CloudRuntimeStore {
}
}
export class PostgresCloudRuntimeStore {
constructor({
env = process.env,
dbUrl,
sslMode = "require",
now = () => new Date().toISOString(),
queryClient,
pgModuleLoader
} = {}) {
this.env = env;
this.dbUrl = dbUrl;
this.sslMode = sslMode;
this.now = now;
this.queryClient = queryClient;
this.pgModuleLoader = pgModuleLoader;
this.pool = null;
this.memory = new CloudRuntimeStore({ now });
this.lastReadiness = this.blockedSummary({
blocker: dbUrl || queryClient ? "runtime durable adapter has not completed schema readiness" : RUNTIME_DURABLE_ADAPTER_UNCONFIGURED,
reason: dbUrl || queryClient
? "Postgres runtime adapter is configured but has not completed a live schema query"
: "Postgres runtime adapter requires HWLAB_CLOUD_DB_URL or an injected query client"
});
}
summary() {
return {
...this.lastReadiness,
counts: this.lastReadiness.counts ?? this.memory.summary().counts
};
}
async readiness() {
if (!this.dbUrl && !this.queryClient) {
this.lastReadiness = this.blockedSummary({
blocker: RUNTIME_DURABLE_ADAPTER_UNCONFIGURED,
reason: "Postgres runtime adapter is selected but HWLAB_CLOUD_DB_URL is not injected"
});
return this.summary();
}
let rows;
try {
const result = await this.query(
"SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ANY($1::text[])",
[Object.keys(requiredPostgresSchema)]
);
rows = Array.isArray(result?.rows) ? result.rows : [];
} catch (error) {
this.lastReadiness = this.blockedSummary(classifyRuntimeDbError(error));
return this.summary();
}
const schema = summarizeRuntimeSchema(rows);
if (!schema.ready) {
this.lastReadiness = this.blockedSummary({
blocker: RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED,
reason: "Postgres runtime adapter connected, but required runtime tables or columns are missing",
schema
});
return this.summary();
}
const counts = await this.readCounts().catch(() => null);
this.lastReadiness = {
adapter: RUNTIME_STORE_KIND_POSTGRES,
durable: true,
ready: true,
status: "ready",
blocker: null,
reason: "Postgres durable runtime adapter completed a live schema readiness query",
liveRuntimeEvidence: true,
fixtureEvidence: false,
connection: {
queryAttempted: true,
queryResult: "schema_ready",
endpointRedacted: true,
valueRedacted: true
},
schema,
safety: runtimeSafety(),
adapterContract: postgresAdapterContract(),
counts: counts ?? this.memory.summary().counts
};
return this.summary();
}
async registerGatewaySession(params = {}, requestMeta = {}) {
await this.assertReadyForWrites();
const before = snapshotMemory(this.memory);
const result = this.memory.registerGatewaySession(params, requestMeta);
await this.persistChanges(before);
return withPersistence(result, this.summary());
}
async registerBoxResource(params = {}, requestMeta = {}) {
await this.assertReadyForWrites();
const input = asObject(params.resource ?? params.boxResource ?? params, "boxResource");
await this.hydrateGatewaySession(input.gatewaySessionId);
const before = snapshotMemory(this.memory);
const result = this.memory.registerBoxResource(params, requestMeta);
await this.persistChanges(before);
return withPersistence(result, this.summary());
}
async reportBoxCapabilities(params = {}, requestMeta = {}) {
await this.assertReadyForWrites();
const inputs = normalizeCapabilityInputs(params);
for (const input of inputs) {
await this.hydrateBoxResource(input.resourceId);
}
const before = snapshotMemory(this.memory);
const result = this.memory.reportBoxCapabilities(params, requestMeta);
await this.persistChanges(before);
return withPersistence(result, this.summary());
}
async requestHardwareOperation(params = {}, requestMeta = {}) {
await this.assertReadyForWrites();
await this.hydrateOperationRefs(params);
const before = snapshotMemory(this.memory);
const result = this.memory.requestHardwareOperation(params, requestMeta);
await this.persistChanges(before);
return withPersistence(result, this.summary());
}
async invokeHardwareShell(params = {}, requestMeta = {}) {
await this.assertReadyForWrites();
await this.hydrateOperationRefs(params);
const before = snapshotMemory(this.memory);
const result = this.memory.invokeHardwareShell(params, requestMeta);
await this.persistChanges(before);
return withPersistence(result, this.summary());
}
async writeAuditEvent(params = {}, requestMeta = {}) {
await this.assertReadyForWrites();
const before = snapshotMemory(this.memory);
const result = this.memory.writeAuditEvent(params, requestMeta);
await this.persistChanges(before);
return withPersistence(result, this.summary());
}
async queryAuditEvents(params = {}) {
const result = await this.query(
"SELECT event_json FROM audit_events ORDER BY timestamp ASC",
[]
);
const events = result.rows
.map((row) => parseJsonColumn(row.event_json, null))
.filter(Boolean)
.filter((event) => matchesQuery(event, params, [
"auditId",
"traceId",
"projectId",
"gatewaySessionId",
"operationId",
"action",
"targetId"
]));
return {
events: limitResults(events, params.limit),
count: events.length,
persistence: this.summary()
};
}
async writeEvidenceRecord(params = {}, requestMeta = {}) {
await this.assertReadyForWrites();
const before = snapshotMemory(this.memory);
const result = this.memory.writeEvidenceRecord(params, requestMeta);
await this.persistChanges(before);
return withPersistence(result, this.summary());
}
async queryEvidenceRecords(params = {}) {
const result = await this.query(
"SELECT metadata_json FROM evidence_records ORDER BY created_at ASC",
[]
);
const records = result.rows
.map((row) => parseJsonColumn(row.metadata_json, null))
.filter(Boolean)
.filter((record) => matchesQuery(record, params, [
"evidenceId",
"projectId",
"operationId",
"agentSessionId",
"workerSessionId",
"kind",
"serviceId"
]));
return {
records: limitResults(records, params.limit),
count: records.length,
persistence: this.summary()
};
}
async hydrateOperationRefs(params = {}) {
await this.hydrateGatewaySession(params.gatewaySessionId);
await this.hydrateBoxResource(params.resourceId);
await this.hydrateBoxCapability(params.capabilityId);
}
async hydrateGatewaySession(gatewaySessionId) {
if (!gatewaySessionId || this.memory.gatewaySessions.has(gatewaySessionId)) return;
const result = await this.query("SELECT gateway_session_json FROM gateway_sessions WHERE id = $1 LIMIT 1", [
gatewaySessionId
]);
const record = parseJsonColumn(result.rows?.[0]?.gateway_session_json, null);
if (record) this.memory.gatewaySessions.set(record.gatewaySessionId, record);
}
async hydrateBoxResource(resourceId) {
if (!resourceId || this.memory.boxResources.has(resourceId)) return;
const result = await this.query("SELECT resource_json FROM box_resources WHERE id = $1 LIMIT 1", [
resourceId
]);
const record = parseJsonColumn(result.rows?.[0]?.resource_json, null);
if (record) {
await this.hydrateGatewaySession(record.gatewaySessionId);
this.memory.boxResources.set(record.resourceId, record);
}
}
async hydrateBoxCapability(capabilityId) {
if (!capabilityId || this.memory.boxCapabilities.has(capabilityId)) return;
const result = await this.query("SELECT capability_json FROM box_capabilities WHERE id = $1 LIMIT 1", [
capabilityId
]);
const record = parseJsonColumn(result.rows?.[0]?.capability_json, null);
if (record) {
await this.hydrateBoxResource(record.resourceId);
this.memory.boxCapabilities.set(record.capabilityId, record);
}
}
async persistChanges(before) {
for (const record of newRecords(this.memory.gatewaySessions, before.gatewaySessions)) {
await this.persistGatewaySession(record);
}
for (const record of newRecords(this.memory.boxResources, before.boxResources)) {
await this.persistBoxResource(record);
}
for (const record of newRecords(this.memory.boxCapabilities, before.boxCapabilities)) {
await this.persistBoxCapability(record);
}
for (const record of changedRecords(this.memory.hardwareOperations, before.hardwareOperations)) {
await this.persistHardwareOperation(record);
}
for (const record of newRecords(this.memory.auditEvents, before.auditEvents)) {
await this.persistAuditEvent(record);
}
for (const record of newRecords(this.memory.evidenceRecords, before.evidenceRecords)) {
await this.persistEvidenceRecord(record);
}
}
async assertReadyForWrites() {
const readiness = await this.readiness();
if (readiness.ready !== true) {
throw new HwlabProtocolError("Postgres durable runtime adapter is not ready for writes", {
code: ERROR_CODES.internalError,
data: {
adapter: RUNTIME_STORE_KIND_POSTGRES,
blocker: readiness.blocker,
status: readiness.status,
schemaReady: Boolean(readiness.schema?.ready)
},
context: {
result: "failed"
}
});
}
}
async persistGatewaySession(record) {
await this.query(
"INSERT INTO gateway_sessions (id, project_id, gateway_service_id, status, started_at, ended_at, gateway_session_json) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (id) DO UPDATE SET project_id = EXCLUDED.project_id, gateway_service_id = EXCLUDED.gateway_service_id, status = EXCLUDED.status, started_at = EXCLUDED.started_at, ended_at = EXCLUDED.ended_at, gateway_session_json = EXCLUDED.gateway_session_json",
[
record.gatewaySessionId,
record.projectId,
record.serviceId,
record.status,
record.startedAt,
record.stoppedAt ?? null,
stableJson(record)
]
);
}
async persistBoxResource(record) {
await this.query(
"INSERT INTO box_resources (id, project_id, gateway_session_id, resource_state, labels_json, resource_json, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (id) DO UPDATE SET project_id = EXCLUDED.project_id, gateway_session_id = EXCLUDED.gateway_session_id, resource_state = EXCLUDED.resource_state, labels_json = EXCLUDED.labels_json, resource_json = EXCLUDED.resource_json, updated_at = EXCLUDED.updated_at",
[
record.resourceId,
record.projectId,
record.gatewaySessionId,
record.state,
stableJson(record.metadata ?? {}),
stableJson(record),
record.updatedAt
]
);
}
async persistBoxCapability(record) {
await this.query(
"INSERT INTO box_capabilities (id, box_resource_id, capability_type, capability_json, updated_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (id) DO UPDATE SET box_resource_id = EXCLUDED.box_resource_id, capability_type = EXCLUDED.capability_type, capability_json = EXCLUDED.capability_json, updated_at = EXCLUDED.updated_at",
[
record.capabilityId,
record.resourceId,
record.name,
stableJson(record),
record.updatedAt
]
);
}
async persistHardwareOperation(record) {
await this.query(
"INSERT INTO hardware_operations (id, project_id, requested_by, operation_type, operation_json, status, requested_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (id) DO UPDATE SET project_id = EXCLUDED.project_id, requested_by = EXCLUDED.requested_by, operation_type = EXCLUDED.operation_type, operation_json = EXCLUDED.operation_json, status = EXCLUDED.status, requested_at = EXCLUDED.requested_at, updated_at = EXCLUDED.updated_at",
[
record.operationId,
record.projectId,
record.requestedBy,
record.input?.shell ? "hardware.invoke.shell" : "hardware.operation.request",
stableJson(record),
record.status,
record.requestedAt,
record.updatedAt
]
);
}
async persistAuditEvent(record) {
await this.query(
"INSERT INTO audit_events (id, request_id, actor, source, operation, target, result, timestamp, event_json) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (id) DO UPDATE SET request_id = EXCLUDED.request_id, actor = EXCLUDED.actor, source = EXCLUDED.source, operation = EXCLUDED.operation, target = EXCLUDED.target, result = EXCLUDED.result, timestamp = EXCLUDED.timestamp, event_json = EXCLUDED.event_json",
[
record.auditId,
record.traceId,
stableJson({ type: record.actorType, id: record.actorId }),
stableJson({ serviceId: record.serviceId, environment: record.environment }),
record.action,
stableJson({ type: record.targetType, id: record.targetId }),
record.outcome ?? "accepted",
record.occurredAt,
stableJson(record)
]
);
}
async persistEvidenceRecord(record) {
await this.query(
"INSERT INTO evidence_records (id, project_id, operation_id, evidence_type, uri, metadata_json, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (id) DO UPDATE SET project_id = EXCLUDED.project_id, operation_id = EXCLUDED.operation_id, evidence_type = EXCLUDED.evidence_type, uri = EXCLUDED.uri, metadata_json = EXCLUDED.metadata_json, created_at = EXCLUDED.created_at",
[
record.evidenceId,
record.projectId,
record.operationId,
record.kind,
record.uri,
stableJson(record),
record.createdAt
]
);
}
async readCounts() {
const counts = {};
for (const table of postgresCountTables) {
const result = await this.query(`SELECT COUNT(*)::int AS count FROM ${table}`, []);
counts[toCountKey(table)] = Number(result.rows?.[0]?.count ?? 0);
}
return counts;
}
async query(sql, params = []) {
const client = await this.getQueryClient();
return client.query(sql, params);
}
async getQueryClient() {
if (this.queryClient) {
return this.queryClient;
}
if (this.pool) {
return this.pool;
}
let pg;
try {
pg = await (this.pgModuleLoader ? this.pgModuleLoader() : import("pg"));
} catch (error) {
if (error?.code === "ERR_MODULE_NOT_FOUND") {
const driverError = new Error("Postgres runtime adapter requires the pg package");
driverError.code = "HWLAB_PG_DRIVER_MISSING";
throw driverError;
}
throw error;
}
const Pool = pg.Pool ?? pg.default?.Pool;
if (typeof Pool !== "function") {
const driverError = new Error("Postgres runtime adapter could not load pg.Pool");
driverError.code = "HWLAB_PG_DRIVER_MISSING";
throw driverError;
}
this.pool = new Pool({
connectionString: this.dbUrl,
ssl: this.sslMode === "require" ? { rejectUnauthorized: false } : false,
connectionTimeoutMillis: normalizeRuntimeTimeoutMs(this.env?.HWLAB_CLOUD_DB_PROBE_TIMEOUT_MS)
});
return this.pool;
}
blockedSummary({ blocker, reason, schema, connection }) {
return {
adapter: RUNTIME_STORE_KIND_POSTGRES,
durable: true,
ready: false,
status: "blocked",
blocker,
reason,
liveRuntimeEvidence: false,
fixtureEvidence: false,
connection: {
queryAttempted: Boolean(connection?.queryAttempted),
queryResult: connection?.queryResult ?? "not_ready",
endpointRedacted: true,
valueRedacted: true,
errorCode: connection?.errorCode ?? null
},
schema: schema ?? {
ready: false,
checked: false,
missingTables: [],
missingColumns: []
},
safety: runtimeSafety(),
adapterContract: postgresAdapterContract()
};
}
}
function normalizeCapabilityInputs(params) {
const value = params.capabilities ?? params.capability ?? params;
if (Array.isArray(value)) {
@@ -621,3 +1173,176 @@ function limitResults(records, limit) {
}
return records.slice(0, parsed);
}
function normalizeRuntimeAdapter(value) {
if (typeof value !== "string" || value.trim() === "") {
return RUNTIME_STORE_KIND;
}
const normalized = value.trim().toLowerCase();
if (["postgres", "postgresql", "pg", "db"].includes(normalized)) {
return RUNTIME_STORE_KIND_POSTGRES;
}
return RUNTIME_STORE_KIND;
}
function postgresAdapterContract() {
return {
required: "Postgres-backed durable runtime adapter using HWLAB_CLOUD_DB_URL",
adapterEnv: RUNTIME_ADAPTER_ENV,
adapterEnvValue: RUNTIME_STORE_KIND_POSTGRES,
sourceIssue: "pikasTech/HWLAB#164",
secretMaterialRequiredInSource: false,
fixtureEvidenceAllowed: false
};
}
function runtimeSafety() {
return {
devOnly: true,
secretsRead: false,
secretMaterialRead: false,
valuesRedacted: true,
endpointRedacted: true
};
}
function classifyRuntimeDbError(error) {
const code = typeof error?.code === "string" ? error.code : "UNKNOWN";
if (code === "HWLAB_PG_DRIVER_MISSING") {
return {
blocker: RUNTIME_DURABLE_ADAPTER_DRIVER_MISSING,
reason: "Postgres runtime adapter is selected, but the pg package is not installed in the runtime image",
connection: {
queryAttempted: false,
queryResult: "driver_missing",
errorCode: code
}
};
}
if (["28P01", "28000", "42501"].includes(code)) {
return {
blocker: RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED,
reason: "Postgres runtime adapter reached the DB driver but authentication or authorization blocked the schema query",
connection: {
queryAttempted: true,
queryResult: "auth_blocked",
errorCode: code
}
};
}
if (["42P01", "42703", "3F000"].includes(code)) {
return {
blocker: RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED,
reason: "Postgres runtime adapter reached the database, but the runtime schema is missing or incompatible",
connection: {
queryAttempted: true,
queryResult: "schema_blocked",
errorCode: code
}
};
}
return {
blocker: RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED,
reason: "Postgres runtime adapter could not complete a live runtime schema query",
connection: {
queryAttempted: true,
queryResult: "query_blocked",
errorCode: code
}
};
}
function summarizeRuntimeSchema(rows) {
const columnsByTable = new Map();
for (const row of rows) {
const table = row.table_name;
const column = row.column_name;
if (!table || !column) continue;
if (!columnsByTable.has(table)) {
columnsByTable.set(table, new Set());
}
columnsByTable.get(table).add(column);
}
const missingTables = [];
const missingColumns = [];
for (const [table, columns] of Object.entries(requiredPostgresSchema)) {
const observed = columnsByTable.get(table);
if (!observed) {
missingTables.push(table);
missingColumns.push(...columns.map((column) => `${table}.${column}`));
continue;
}
for (const column of columns) {
if (!observed.has(column)) {
missingColumns.push(`${table}.${column}`);
}
}
}
return {
ready: missingTables.length === 0 && missingColumns.length === 0,
checked: true,
requiredTables: Object.keys(requiredPostgresSchema),
missingTables,
missingColumns
};
}
function snapshotMemory(memory) {
return {
gatewaySessions: new Map(memory.gatewaySessions),
boxResources: new Map(memory.boxResources),
boxCapabilities: new Map(memory.boxCapabilities),
hardwareOperations: new Map(memory.hardwareOperations),
auditEvents: new Map(memory.auditEvents),
evidenceRecords: new Map(memory.evidenceRecords)
};
}
function newRecords(current, before) {
return [...current.entries()]
.filter(([id]) => !before.has(id))
.map(([, record]) => record);
}
function changedRecords(current, before) {
return [...current.entries()]
.filter(([id, record]) => stableJson(before.get(id)) !== stableJson(record))
.map(([, record]) => record);
}
function withPersistence(result, persistence) {
return {
...result,
persistence
};
}
function parseJsonColumn(value, fallback) {
if (!value) return fallback;
if (typeof value === "object") return value;
try {
return JSON.parse(String(value));
} catch {
return fallback;
}
}
function toCountKey(table) {
if (table === "gateway_sessions") return "gatewaySessions";
if (table === "box_resources") return "boxResources";
if (table === "box_capabilities") return "boxCapabilities";
if (table === "hardware_operations") return "hardwareOperations";
if (table === "audit_events") return "auditEvents";
if (table === "evidence_records") return "evidenceRecords";
return table;
}
function normalizeRuntimeTimeoutMs(value) {
const parsed = Number.parseInt(String(value ?? ""), 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
return 1200;
}
return Math.min(parsed, 5000);
}
+224
View File
@@ -0,0 +1,224 @@
import assert from "node:assert/strict";
import test from "node:test";
import { handleJsonRpcRequest } from "../cloud/json-rpc.mjs";
import { validateResponse } from "../protocol/index.mjs";
import {
RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED,
RUNTIME_STORE_KIND_POSTGRES,
createCloudRuntimeStore,
createConfiguredCloudRuntimeStore
} from "./runtime-store.mjs";
import { CLOUD_RUNTIME_DURABLE_TABLE_COLUMNS } from "./schema.mjs";
test("memory runtime store is never marked durable", () => {
const store = createCloudRuntimeStore();
const summary = store.summary();
assert.equal(summary.adapter, "memory");
assert.equal(summary.durable, false);
assert.equal(summary.status, "degraded");
assert.equal(summary.ready, undefined);
assert.equal(summary.adapterContract.secretMaterialRequiredInSource, false);
});
test("configured postgres runtime reports schema blocker without green readiness", async () => {
const store = createConfiguredCloudRuntimeStore({
env: {
HWLAB_CLOUD_RUNTIME_ADAPTER: "postgres",
HWLAB_CLOUD_RUNTIME_DURABLE: "true"
},
dbUrl: "postgres://hwlab_redacted@db.example.invalid:5432/hwlab",
queryClient: {
async query(sql) {
assert.match(sql, /information_schema\.columns/u);
return { rows: [] };
}
}
});
const readiness = await store.readiness();
assert.equal(readiness.adapter, RUNTIME_STORE_KIND_POSTGRES);
assert.equal(readiness.durable, true);
assert.equal(readiness.ready, false);
assert.equal(readiness.status, "blocked");
assert.equal(readiness.blocker, RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED);
assert.equal(readiness.liveRuntimeEvidence, false);
assert.equal(readiness.fixtureEvidence, false);
assert.equal(readiness.schema.checked, true);
assert.ok(readiness.schema.missingTables.includes("gateway_sessions"));
assert.equal(JSON.stringify(readiness).includes("redacted@db.example"), false);
});
test("configured postgres runtime persists and queries records through query client", async () => {
const queryClient = createFakePostgresClient();
const store = createConfiguredCloudRuntimeStore({
env: {
HWLAB_CLOUD_RUNTIME_ADAPTER: "postgres",
HWLAB_CLOUD_RUNTIME_DURABLE: "true"
},
dbUrl: "postgres://hwlab_redacted@db.example.invalid:5432/hwlab",
queryClient,
now: () => "2026-05-22T00:00:00.000Z"
});
const context = { runtimeStore: store, dbProbe: { probe: false } };
const meta = {
traceId: "trc_01J00000000000000000001000",
actorId: "usr_01J00000000000000000001000",
serviceId: "hwlab-cloud-web",
environment: "dev"
};
async function rpc(id, method, params = {}) {
const response = await handleJsonRpcRequest(
{
jsonrpc: "2.0",
id,
method,
params,
meta
},
context
);
validateResponse(response);
assert.equal(Object.hasOwn(response, "error"), false, response.error?.message);
return response.result;
}
const health = await rpc("req_01J00000000000000000001000", "system.health");
assert.equal(health.runtime.adapter, "postgres");
assert.equal(health.runtime.durable, true);
assert.equal(health.runtime.ready, true);
assert.equal(health.runtime.liveRuntimeEvidence, true);
assert.equal(health.runtime.fixtureEvidence, false);
assert.equal(health.readiness.components.runtime, "ready");
await rpc("req_01J00000000000000000001001", "gateway.session.register", {
projectId: "prj_01J00000000000000000001000",
gatewaySessionId: "gws_01J00000000000000000001000",
gatewayId: "gtw_01J00000000000000000001000",
serviceId: "hwlab-gateway-simu",
endpoint: "http://127.0.0.1:7101"
});
await rpc("req_01J00000000000000000001002", "box.resource.register", {
projectId: "prj_01J00000000000000000001000",
gatewaySessionId: "gws_01J00000000000000000001000",
resourceId: "res_01J00000000000000000001000",
boxId: "box_01J00000000000000000001000",
resourceType: "board",
state: "available"
});
await rpc("req_01J00000000000000000001003", "box.capability.report", {
capabilityId: "cap_01J00000000000000000001000",
resourceId: "res_01J00000000000000000001000",
projectId: "prj_01J00000000000000000001000",
name: "shell.exec",
direction: "bidirectional",
valueType: "object",
mutatesState: true
});
const invoke = await rpc("req_01J00000000000000000001004", "hardware.invoke.shell", {
projectId: "prj_01J00000000000000000001000",
gatewaySessionId: "gws_01J00000000000000000001000",
resourceId: "res_01J00000000000000000001000",
capabilityId: "cap_01J00000000000000000001000",
input: {
command: "echo durable"
}
});
const audit = await rpc("req_01J00000000000000000001005", "audit.event.query", {
projectId: "prj_01J00000000000000000001000"
});
const evidence = await rpc("req_01J00000000000000000001006", "evidence.record.query", {
projectId: "prj_01J00000000000000000001000"
});
assert.ok(queryClient.calls.some((call) => call.sql.startsWith("INSERT INTO gateway_sessions")));
assert.ok(queryClient.calls.some((call) => call.sql.startsWith("INSERT INTO evidence_records")));
assert.ok(audit.events.some((event) => event.action === "hardware.invoke.shell"));
assert.equal(evidence.count, 1);
assert.equal(evidence.records[0].operationId, invoke.operationId);
});
function createFakePostgresClient() {
const state = {
gateway_sessions: new Map(),
box_resources: new Map(),
box_capabilities: new Map(),
hardware_operations: new Map(),
audit_events: new Map(),
evidence_records: new Map()
};
const calls = [];
return {
calls,
async query(sql, params = []) {
calls.push({ sql, params });
if (sql.includes("information_schema.columns")) {
return { rows: schemaRows() };
}
if (sql.startsWith("SELECT COUNT(*)::int AS count FROM ")) {
const table = sql.match(/FROM ([a-z_]+)/u)?.[1];
return { rows: [{ count: state[table]?.size ?? 0 }] };
}
if (sql.startsWith("SELECT gateway_session_json FROM gateway_sessions")) {
return jsonSelect(state.gateway_sessions, params[0], "gateway_session_json");
}
if (sql.startsWith("SELECT resource_json FROM box_resources")) {
return jsonSelect(state.box_resources, params[0], "resource_json");
}
if (sql.startsWith("SELECT capability_json FROM box_capabilities")) {
return jsonSelect(state.box_capabilities, params[0], "capability_json");
}
if (sql.startsWith("SELECT event_json FROM audit_events")) {
return { rows: [...state.audit_events.values()].map((record) => ({ event_json: record.event_json })) };
}
if (sql.startsWith("SELECT metadata_json FROM evidence_records")) {
return { rows: [...state.evidence_records.values()].map((record) => ({ metadata_json: record.metadata_json })) };
}
if (sql.startsWith("INSERT INTO gateway_sessions")) {
state.gateway_sessions.set(params[0], { gateway_session_json: params[6] });
return { rows: [] };
}
if (sql.startsWith("INSERT INTO box_resources")) {
state.box_resources.set(params[0], { resource_json: params[5] });
return { rows: [] };
}
if (sql.startsWith("INSERT INTO box_capabilities")) {
state.box_capabilities.set(params[0], { capability_json: params[3] });
return { rows: [] };
}
if (sql.startsWith("INSERT INTO hardware_operations")) {
state.hardware_operations.set(params[0], { operation_json: params[4] });
return { rows: [] };
}
if (sql.startsWith("INSERT INTO audit_events")) {
state.audit_events.set(params[0], { event_json: params[8] });
return { rows: [] };
}
if (sql.startsWith("INSERT INTO evidence_records")) {
state.evidence_records.set(params[0], { metadata_json: params[5] });
return { rows: [] };
}
throw new Error(`unexpected sql: ${sql}`);
}
};
}
function schemaRows() {
return Object.entries(CLOUD_RUNTIME_DURABLE_TABLE_COLUMNS).flatMap(([table, columns]) =>
columns.map((column) => ({
table_name: table,
column_name: column
}))
);
}
function jsonSelect(map, id, column) {
const row = map.get(id);
return {
rows: row ? [{ [column]: row[column] }] : []
};
}
+67 -1
View File
@@ -1,15 +1,75 @@
import { TABLES } from "../protocol/index.mjs";
export const CLOUD_CORE_MIGRATION_ID = "0001_cloud_core_skeleton";
export const CLOUD_RUNTIME_DURABLE_ADAPTER_SCHEMA_VERSION = "runtime-durable-postgres-v1";
export const FROZEN_CLOUD_CORE_TABLES = Object.freeze([...TABLES]);
export const CLOUD_RUNTIME_DURABLE_TABLE_COLUMNS = Object.freeze({
gateway_sessions: Object.freeze([
"id",
"project_id",
"gateway_service_id",
"status",
"started_at",
"ended_at",
"gateway_session_json"
]),
box_resources: Object.freeze([
"id",
"project_id",
"gateway_session_id",
"resource_state",
"labels_json",
"resource_json",
"updated_at"
]),
box_capabilities: Object.freeze([
"id",
"box_resource_id",
"capability_type",
"capability_json",
"updated_at"
]),
hardware_operations: Object.freeze([
"id",
"project_id",
"requested_by",
"operation_type",
"operation_json",
"status",
"requested_at",
"updated_at"
]),
audit_events: Object.freeze([
"id",
"request_id",
"actor",
"source",
"operation",
"target",
"result",
"timestamp",
"event_json"
]),
evidence_records: Object.freeze([
"id",
"project_id",
"operation_id",
"evidence_type",
"uri",
"metadata_json",
"created_at"
])
});
export const CLOUD_CORE_MIGRATIONS = Object.freeze([
{
id: CLOUD_CORE_MIGRATION_ID,
path: "internal/db/migrations/0001_cloud_core_skeleton.sql",
tables: FROZEN_CLOUD_CORE_TABLES,
connectsToDatabase: false
connectsToDatabase: false,
runtimeDurableAdapterSchemaVersion: CLOUD_RUNTIME_DURABLE_ADAPTER_SCHEMA_VERSION
}
]);
@@ -19,3 +79,9 @@ export function assertFrozenCloudCoreTables(tables = FROZEN_CLOUD_CORE_TABLES) {
throw new Error(`cloud core schema is missing frozen tables: ${missing.join(", ")}`);
}
}
export function requiredRuntimeDurableColumns() {
return Object.entries(CLOUD_RUNTIME_DURABLE_TABLE_COLUMNS).flatMap(([table, columns]) =>
columns.map((column) => `${table}.${column}`)
);
}
+16 -1
View File
@@ -7,8 +7,10 @@ import { fileURLToPath } from "node:url";
import { TABLES, assertProtocolRecord, assertProtocolRecords } from "../protocol/index.mjs";
import {
CLOUD_CORE_MIGRATIONS,
CLOUD_RUNTIME_DURABLE_TABLE_COLUMNS,
FROZEN_CLOUD_CORE_TABLES,
assertFrozenCloudCoreTables
assertFrozenCloudCoreTables,
requiredRuntimeDurableColumns
} from "./schema.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
@@ -35,6 +37,19 @@ test("initial migration skeleton declares every frozen table", async () => {
assert.match(sql, /\btimestamp\b/);
});
test("initial migration exposes columns required by the durable runtime adapter", async () => {
const sql = await readFile(path.join(repoRoot, "internal/db/migrations/0001_cloud_core_skeleton.sql"), "utf8");
assert.ok(requiredRuntimeDurableColumns().length > 0);
for (const [table, columns] of Object.entries(CLOUD_RUNTIME_DURABLE_TABLE_COLUMNS)) {
const tableMatch = sql.match(new RegExp(`CREATE TABLE IF NOT EXISTS ${table}\\s*\\(([\\s\\S]*?)\\);`, "u"));
assert.ok(tableMatch, `missing ${table}`);
for (const column of columns) {
assert.match(tableMatch[1], new RegExp(`\\b${column}\\b`), `missing ${table}.${column}`);
}
}
});
test("protocol record guards catch schema drift before runtime writes", () => {
assertProtocolRecord("gatewaySession", {
gatewaySessionId: "gws_01J00000000000000000000000",
+161
View File
@@ -0,0 +1,161 @@
{
"name": "hwlab",
"version": "0.0.0-l0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hwlab",
"version": "0.0.0-l0",
"dependencies": {
"pg": "^8.21.0"
}
},
"node_modules/pg": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
"integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.13.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.14.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz",
"integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz",
"integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
}
}
}
+4 -1
View File
@@ -3,9 +3,12 @@
"version": "0.0.0-l0",
"private": true,
"type": "module",
"dependencies": {
"pg": "^8.21.0"
},
"scripts": {
"validate": "node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check && node scripts/deploy-desired-state-plan.mjs --check",
"check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-deploy-apply.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh",
"check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-deploy-apply.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh",
"dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs",
"cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs",
"m1:smoke": "node scripts/m1-contract-smoke.mjs",
+4
View File
@@ -96,6 +96,10 @@ async function run() {
assert.equal(health.body.runtime.durable, false);
assert.equal(health.body.runtime.status, "degraded");
assert.match(health.body.runtime.reason, /process-local/u);
assert.equal(health.body.readiness.components.db, "blocked");
assert.equal(health.body.readiness.components.runtime, "waiting_for_db");
assert.ok(health.body.blockerCodes.includes("db_runtime_config_blocked"));
assert.ok(health.body.blockerCodes.includes("runtime_durable_adapter_missing"));
logOk("health degraded DB contract");
const v1 = await requestJson(`${baseUrl}/v1`);
+2 -1
View File
@@ -545,6 +545,8 @@ function dockerfile(baseImage, port) {
"ENV PORT=$PORT",
"ENV HWLAB_PORT=$HWLAB_PORT",
"COPY package.json ./package.json",
"COPY package-lock.json* ./",
"RUN if [ -f package-lock.json ]; then npm ci --omit=dev --ignore-scripts; else npm install --omit=dev --ignore-scripts; fi",
"COPY internal ./internal",
"COPY cmd ./cmd",
"COPY web ./web",
@@ -766,7 +768,6 @@ async function buildService({ args, repo, commitId, shortCommit, service }) {
const argsList = [
"build",
"--pull=false",
"--network=none",
"--quiet",
"--build-arg",
`BASE_IMAGE=${args.baseImage}`
+4
View File
@@ -176,10 +176,14 @@ assert.equal(cloudApi.env.HWLAB_CLOUD_DB_SERVICE_NAME, DEV_DB_ENV_CONTRACT.dns.s
assert.equal(cloudApi.env.HWLAB_CLOUD_DB_SERVICE_NAMESPACE, DEV_DB_ENV_CONTRACT.dns.namespace, "cloud-api DB service namespace");
assert.equal(cloudApi.env.HWLAB_CLOUD_DB_HOST, DEV_DB_ENV_CONTRACT.dns.host, "cloud-api DB stable host");
assert.equal(cloudApi.env.HWLAB_CLOUD_DB_PORT, String(DEV_DB_ENV_CONTRACT.dns.port), "cloud-api DB stable port");
assert.equal(cloudApi.env.HWLAB_CLOUD_RUNTIME_ADAPTER, "postgres", "cloud-api runtime durable adapter");
assert.equal(cloudApi.env.HWLAB_CLOUD_RUNTIME_DURABLE, "true", "cloud-api runtime durable flag");
assert.equal(cloudApiWorkloadEnv.HWLAB_CLOUD_DB_SERVICE_NAME?.value, DEV_DB_ENV_CONTRACT.dns.serviceName, "cloud-api workload DB service name");
assert.equal(cloudApiWorkloadEnv.HWLAB_CLOUD_DB_SERVICE_NAMESPACE?.value, DEV_DB_ENV_CONTRACT.dns.namespace, "cloud-api workload DB service namespace");
assert.equal(cloudApiWorkloadEnv.HWLAB_CLOUD_DB_HOST?.value, DEV_DB_ENV_CONTRACT.dns.host, "cloud-api workload DB host");
assert.equal(cloudApiWorkloadEnv.HWLAB_CLOUD_DB_PORT?.value, String(DEV_DB_ENV_CONTRACT.dns.port), "cloud-api workload DB port");
assert.equal(cloudApiWorkloadEnv.HWLAB_CLOUD_RUNTIME_ADAPTER?.value, "postgres", "cloud-api workload runtime durable adapter");
assert.equal(cloudApiWorkloadEnv.HWLAB_CLOUD_RUNTIME_DURABLE?.value, "true", "cloud-api workload runtime durable flag");
assertCloudApiDbDnsContract(k8sServices, cloudApi.env);
assert.equal(cloudApi.env.HWLAB_CODE_AGENT_PROVIDER, "openai", "cloud-api Code Agent provider");
assert.equal(cloudApi.env.HWLAB_CODE_AGENT_MODEL, DEV_CODE_AGENT_PROVIDER_CONTRACT.model, "cloud-api Code Agent model");