263 lines
12 KiB
JavaScript
263 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
import assert from "node:assert/strict";
|
|
import { readdir, readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
DEV_ENDPOINT,
|
|
ENVIRONMENT_DEV,
|
|
ERROR_CODES,
|
|
SERVICE_IDS,
|
|
validateRequest,
|
|
validateResponse
|
|
} from "../internal/protocol/index.mjs";
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const examplesDir = "protocol/examples/m0-contract";
|
|
const auditDocPath = "docs/reference/spec-v02-documentation-governance.md";
|
|
|
|
const timestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
const idPattern = /^[a-z][a-z0-9]*_[A-Za-z0-9._:-]+$/;
|
|
const actionPattern = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/;
|
|
|
|
async function readJSON(relativePath) {
|
|
const raw = await readFile(path.join(repoRoot, relativePath), "utf8");
|
|
return JSON.parse(raw);
|
|
}
|
|
|
|
async function loadExamples() {
|
|
const entries = await readdir(path.join(repoRoot, examplesDir), { withFileTypes: true });
|
|
const examples = new Map();
|
|
for (const entry of entries) {
|
|
if (!entry.isFile() || !entry.name.endsWith(".json")) {
|
|
continue;
|
|
}
|
|
const relativePath = path.join(examplesDir, entry.name);
|
|
examples.set(entry.name, await readJSON(relativePath));
|
|
}
|
|
return examples;
|
|
}
|
|
|
|
function assertUnique(name, values) {
|
|
assert.equal(new Set(values).size, values.length, `${name} must be unique`);
|
|
}
|
|
|
|
function assertServiceId(serviceId, context) {
|
|
assert.ok(SERVICE_IDS.includes(serviceId), `${context} uses unknown serviceId ${serviceId}`);
|
|
}
|
|
|
|
function assertDevEnvironment(value, context) {
|
|
assert.equal(value, ENVIRONMENT_DEV, `${context} must be dev`);
|
|
}
|
|
|
|
function assertId(value, context) {
|
|
assert.equal(typeof value, "string", `${context} must be a string id`);
|
|
assert.match(value, idPattern, `${context} must match HWLAB id pattern`);
|
|
}
|
|
|
|
function assertTimestamp(value, context) {
|
|
assert.equal(typeof value, "string", `${context} must be a timestamp`);
|
|
assert.match(value, timestampPattern, `${context} must be an RFC 3339 UTC timestamp`);
|
|
}
|
|
|
|
function assertServiceIdsExample(doc) {
|
|
assert.equal(doc.contract, "m0-service-ids");
|
|
assertDevEnvironment(doc.environment, "service-ids environment");
|
|
assert.deepEqual(doc.serviceIds, SERVICE_IDS, "example service ids must match frozen runtime service ids");
|
|
assertUnique("example service ids", doc.serviceIds);
|
|
}
|
|
|
|
function assertJsonRpcExamples(request, response, errorEnvelope) {
|
|
validateRequest(request);
|
|
validateResponse(response);
|
|
validateResponse(errorEnvelope);
|
|
|
|
assert.equal(response.id, request.id, "JSON-RPC response must echo request id");
|
|
assert.equal(errorEnvelope.id, request.id, "JSON-RPC error must echo request id");
|
|
assert.equal(response.meta.traceId, request.meta.traceId, "response must keep request traceId");
|
|
assert.equal(errorEnvelope.meta.traceId, request.meta.traceId, "error must keep request traceId");
|
|
assertServiceId(request.meta.serviceId, "request meta");
|
|
assertServiceId(response.meta.serviceId, "response meta");
|
|
assertServiceId(errorEnvelope.meta.serviceId, "error meta");
|
|
assert.ok(
|
|
Object.values(ERROR_CODES).includes(errorEnvelope.error.code),
|
|
"error example must use a reserved JSON-RPC or HWLAB error code"
|
|
);
|
|
for (const key of ["projectId", "gatewaySessionId", "resourceId", "capabilityId", "reason"]) {
|
|
assert.ok(Object.hasOwn(errorEnvelope.error.data, key), `error.data must include ${key}`);
|
|
}
|
|
}
|
|
|
|
function assertAuditEvent(doc) {
|
|
for (const key of [
|
|
"auditId",
|
|
"traceId",
|
|
"actorType",
|
|
"actorId",
|
|
"action",
|
|
"targetType",
|
|
"targetId",
|
|
"serviceId",
|
|
"environment",
|
|
"occurredAt"
|
|
]) {
|
|
assert.ok(Object.hasOwn(doc, key), `audit event missing ${key}`);
|
|
}
|
|
for (const key of ["auditId", "traceId", "actorId", "targetId", "projectId", "gatewaySessionId", "workerSessionId", "operationId"]) {
|
|
assertId(doc[key], `audit event ${key}`);
|
|
}
|
|
assert.match(doc.action, actionPattern, "audit action must be dotted lower-case");
|
|
assert.equal(doc.targetId, doc.operationId, "operation audit targetId must equal operationId");
|
|
assertServiceId(doc.serviceId, "audit event");
|
|
assertDevEnvironment(doc.environment, "audit event environment");
|
|
assertTimestamp(doc.occurredAt, "audit occurredAt");
|
|
}
|
|
|
|
function assertCapabilityTopology(doc) {
|
|
assert.equal(doc.contract, "m0-capability-topology");
|
|
assertDevEnvironment(doc.project.environment, "project environment");
|
|
assertId(doc.project.projectId, "projectId");
|
|
assert.equal(doc.gatewaySession.projectId, doc.project.projectId, "gateway session projectId must match project");
|
|
assert.ok(["hwlab-gateway", "hwlab-gateway-simu"].includes(doc.gatewaySession.serviceId), "gateway service must be gateway");
|
|
assertDevEnvironment(doc.gatewaySession.environment, "gateway session environment");
|
|
|
|
const resourceIds = new Set();
|
|
for (const resource of doc.resources) {
|
|
assertId(resource.resourceId, "resourceId");
|
|
assert.equal(resource.projectId, doc.project.projectId, "resource projectId must match project");
|
|
assert.equal(resource.gatewaySessionId, doc.gatewaySession.gatewaySessionId, "resource gatewaySessionId must match gateway");
|
|
assertDevEnvironment(resource.environment, `resource ${resource.resourceId} environment`);
|
|
resourceIds.add(resource.resourceId);
|
|
}
|
|
assert.ok(resourceIds.size >= 2, "topology example must include at least two resources");
|
|
|
|
const capabilityIds = new Set();
|
|
for (const capability of doc.capabilities) {
|
|
assertId(capability.capabilityId, "capabilityId");
|
|
assert.ok(resourceIds.has(capability.resourceId), `capability ${capability.capabilityId} must reference a known resource`);
|
|
assert.equal(capability.projectId, doc.project.projectId, "capability projectId must match project");
|
|
assert.match(capability.name, actionPattern, "capability name must be dotted lower-case");
|
|
assert.equal(typeof capability.mutatesState, "boolean", "capability mutatesState must be boolean");
|
|
capabilityIds.add(capability.capabilityId);
|
|
}
|
|
assert.ok(capabilityIds.size >= 2, "topology example must include multiple capabilities");
|
|
|
|
const wiring = doc.wiringConfig;
|
|
assert.equal(wiring.projectId, doc.project.projectId, "wiring projectId must match project");
|
|
assert.equal(wiring.gatewaySessionId, doc.gatewaySession.gatewaySessionId, "wiring gatewaySessionId must match gateway");
|
|
assert.equal(wiring.status, "active", "wiring example should show an active config");
|
|
for (const connection of wiring.connections) {
|
|
assert.ok(resourceIds.has(connection.from.resourceId), "wiring from.resourceId must exist");
|
|
assert.ok(resourceIds.has(connection.to.resourceId), "wiring to.resourceId must exist");
|
|
if (connection.from.capabilityId) {
|
|
assert.ok(capabilityIds.has(connection.from.capabilityId), "wiring from.capabilityId must exist");
|
|
}
|
|
if (connection.to.capabilityId) {
|
|
assert.ok(capabilityIds.has(connection.to.capabilityId), "wiring to.capabilityId must exist");
|
|
}
|
|
}
|
|
|
|
const patch = doc.patchPanelStatus;
|
|
assert.equal(patch.serviceId, "hwlab-patch-panel");
|
|
assert.equal(patch.projectId, doc.project.projectId, "patch panel projectId must match project");
|
|
assert.equal(patch.gatewaySessionId, doc.gatewaySession.gatewaySessionId, "patch panel gatewaySessionId must match gateway");
|
|
assert.equal(patch.wiringConfigId, wiring.wiringConfigId, "patch panel wiringConfigId must match wiring");
|
|
assertDevEnvironment(patch.environment, "patch panel environment");
|
|
for (const active of patch.activeConnections) {
|
|
assert.ok(resourceIds.has(active.fromResourceId), "active fromResourceId must exist");
|
|
assert.ok(resourceIds.has(active.toResourceId), "active toResourceId must exist");
|
|
}
|
|
|
|
assert.equal(doc.agentSession.projectId, doc.project.projectId, "agent session projectId must match project");
|
|
assert.equal(doc.agentSession.serviceId, "hwlab-agent-mgr");
|
|
assertDevEnvironment(doc.agentSession.environment, "agent session environment");
|
|
|
|
assert.equal(doc.workerSession.projectId, doc.project.projectId, "worker session projectId must match project");
|
|
assert.equal(doc.workerSession.agentSessionId, doc.agentSession.agentSessionId, "worker must reference agent session");
|
|
assert.equal(doc.workerSession.gatewaySessionId, doc.gatewaySession.gatewaySessionId, "worker must reference gateway session");
|
|
assert.equal(doc.workerSession.serviceId, "hwlab-agent-worker");
|
|
assertDevEnvironment(doc.workerSession.environment, "worker session environment");
|
|
|
|
const operation = doc.hardwareOperation;
|
|
assert.equal(operation.projectId, doc.project.projectId, "operation projectId must match project");
|
|
assert.equal(operation.gatewaySessionId, doc.gatewaySession.gatewaySessionId, "operation gatewaySessionId must match gateway");
|
|
assert.equal(operation.agentSessionId, doc.agentSession.agentSessionId, "operation must reference agent session");
|
|
assert.equal(operation.workerSessionId, doc.workerSession.workerSessionId, "operation must reference worker session");
|
|
assert.ok(resourceIds.has(operation.resourceId), "operation resourceId must exist");
|
|
assert.ok(capabilityIds.has(operation.capabilityId), "operation capabilityId must exist");
|
|
assertDevEnvironment(operation.environment, "operation environment");
|
|
|
|
for (const traceEvent of doc.traceEvents) {
|
|
assert.equal(traceEvent.projectId, doc.project.projectId, "trace projectId must match project");
|
|
assert.equal(traceEvent.operationId, operation.operationId, "trace operationId must match operation");
|
|
assertServiceId(traceEvent.serviceId, "trace event");
|
|
assertDevEnvironment(traceEvent.environment, "trace environment");
|
|
}
|
|
|
|
const evidence = doc.evidenceRecord;
|
|
assert.equal(evidence.projectId, doc.project.projectId, "evidence projectId must match project");
|
|
assert.equal(evidence.operationId, operation.operationId, "evidence operationId must match operation");
|
|
assert.equal(evidence.agentSessionId, doc.agentSession.agentSessionId, "evidence must reference agent session");
|
|
assert.equal(evidence.workerSessionId, doc.workerSession.workerSessionId, "evidence must reference worker session");
|
|
assert.match(evidence.sha256, /^[a-f0-9]{64}$/, "evidence sha256 must be lowercase hex");
|
|
assertServiceId(evidence.serviceId, "evidence");
|
|
assertDevEnvironment(evidence.environment, "evidence environment");
|
|
}
|
|
|
|
function assertDeployManifest(doc) {
|
|
assert.equal(doc.manifestVersion, "v1");
|
|
assertDevEnvironment(doc.environment, "deploy manifest environment");
|
|
assert.equal(doc.endpoint, DEV_ENDPOINT, "deploy manifest endpoint must be the frozen DEV endpoint");
|
|
assert.ok(doc.profiles.dev.enabled, "deploy manifest dev profile must be enabled");
|
|
assert.equal(doc.profiles.dev.endpoint, DEV_ENDPOINT, "deploy manifest dev profile endpoint must be frozen");
|
|
assert.ok(!doc.profiles.prod, "M0 example must not include a PROD profile");
|
|
|
|
const serviceIds = doc.services.map((service) => service.serviceId);
|
|
assert.deepEqual(serviceIds, SERVICE_IDS, "deploy manifest example must cover every frozen service id in order");
|
|
assertUnique("deploy service ids", serviceIds);
|
|
for (const service of doc.services) {
|
|
assertServiceId(service.serviceId, "deploy service");
|
|
assert.equal(service.profile, "dev", `${service.serviceId} must use dev profile in M0 example`);
|
|
assert.equal(service.namespace, doc.namespace, `${service.serviceId} namespace must match manifest namespace`);
|
|
assert.ok(service.healthPath.startsWith("/"), `${service.serviceId} healthPath must be absolute`);
|
|
}
|
|
}
|
|
|
|
async function assertAuditMarkdown() {
|
|
const doc = await readFile(path.join(repoRoot, auditDocPath), "utf8");
|
|
for (const needle of [
|
|
"# v0.2 文档治理规格",
|
|
"docs/m0-*",
|
|
"旧里程碑",
|
|
"#532"
|
|
]) {
|
|
assert.ok(doc.includes(needle), `${auditDocPath} missing ${needle}`);
|
|
}
|
|
}
|
|
|
|
const examples = await loadExamples();
|
|
for (const required of [
|
|
"service-ids.json",
|
|
"json-rpc-request.json",
|
|
"json-rpc-response.json",
|
|
"json-rpc-error.json",
|
|
"audit-event.json",
|
|
"capability-topology.json",
|
|
"deploy-manifest.dev.json"
|
|
]) {
|
|
assert.ok(examples.has(required), `${examplesDir} missing ${required}`);
|
|
}
|
|
|
|
assertServiceIdsExample(examples.get("service-ids.json"));
|
|
assertJsonRpcExamples(
|
|
examples.get("json-rpc-request.json"),
|
|
examples.get("json-rpc-response.json"),
|
|
examples.get("json-rpc-error.json")
|
|
);
|
|
assertAuditEvent(examples.get("audit-event.json"));
|
|
assertCapabilityTopology(examples.get("capability-topology.json"));
|
|
assertDeployManifest(examples.get("deploy-manifest.dev.json"));
|
|
await assertAuditMarkdown();
|
|
|
|
console.log(`validated M0 contract examples: ${examples.size} JSON files, ${SERVICE_IDS.length} service ids`);
|