609 lines
25 KiB
TypeScript
609 lines
25 KiB
TypeScript
/*
|
|
* SPEC: PJ2026-010405 云端控制台;PJ2026-010103 HWPOD 服务。
|
|
* Implementation reference: draft-2026-07-13-p0-cloud-console。
|
|
* Responsibility: 提供 HWLAB Node 更新、HWPOD typed topology、显式有界 readiness 探测与 node-ops 转发。
|
|
*/
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
import { readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
|
|
import { ENVIRONMENT_DEV } from "../protocol/index.mjs";
|
|
import { CLOUD_API_SERVICE_ID } from "../audit/index.mjs";
|
|
import {
|
|
discoverHwpodSpecs,
|
|
hwpodSpecDiscoveryPayload,
|
|
hwpodSpecWorkspaceProbePlan
|
|
} from "./hwpod-spec-discovery.ts";
|
|
import {
|
|
buildHwpodTopologyReadModel,
|
|
HWPOD_NODE_READINESS_DEFINITIONS
|
|
} from "./hwpod-topology-read-model.ts";
|
|
import {
|
|
getHeader,
|
|
parsePositiveInteger,
|
|
readBody,
|
|
safeOpaqueId,
|
|
sendJson,
|
|
truthyFlag
|
|
} from "./server-http-utils.ts";
|
|
import { HWPOD_NODE_OPS, HWPOD_NODE_OPS_CONTRACT_VERSION } from "../../tools/src/hwpod-node-ops-contract.ts";
|
|
|
|
const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024;
|
|
|
|
export async function handleHwpodNodeOpsHttp(request, response, options) {
|
|
if (request.method === "GET") {
|
|
const planId = new URL(request.url, "http://hwlab.local").searchParams.get("planId")?.trim() ?? "";
|
|
if (planId) {
|
|
const operation = await options.hwpodNodeWsRegistry.lookup(planId);
|
|
sendJson(response, operation ? 200 : 404, operation
|
|
? { ok: true, status: operation.result ? "completed" : "running", contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, ...operation }
|
|
: { ok: false, status: "not-found", contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, planId, error: { code: "hwpod_operation_not_found", message: `hwpod operation ${planId} was not found` } });
|
|
return;
|
|
}
|
|
sendJson(response, 200, {
|
|
ok: true,
|
|
status: "ready",
|
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
|
route: "/v1/hwpod-node-ops",
|
|
specAuthority: "code-agent-workspace",
|
|
compiler: "hwpod-compiler-cli",
|
|
apiRole: "node-ops-forwarder",
|
|
nodeRole: "thin-hwpod-node-executor",
|
|
supportedOps: Array.from(HWPOD_NODE_OPS),
|
|
websocket: options.hwpodNodeWsRegistry.describe()
|
|
});
|
|
return;
|
|
}
|
|
if (request.method !== "POST") {
|
|
sendJson(response, 405, { ok: false, error: { code: "method_not_allowed", message: "hwpod-node-ops only supports GET and POST" } });
|
|
return;
|
|
}
|
|
|
|
const body = await readJsonObject(request, options.bodyLimitBytes);
|
|
if (!body.ok) {
|
|
sendJson(response, 400, body.error);
|
|
return;
|
|
}
|
|
const validation = validateHwpodNodeOpsPlan(body.value);
|
|
if (!validation.ok) {
|
|
sendJson(response, 400, {
|
|
ok: false,
|
|
status: "rejected",
|
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
|
error: validation.error
|
|
});
|
|
return;
|
|
}
|
|
|
|
const plan = validation.plan;
|
|
const requestMeta = hwpodNodeOpsRequestMeta(request, options, "hwpod");
|
|
try {
|
|
const handled = await dispatchHwpodNodeOpsPlan(plan, requestMeta, options, { request });
|
|
sendJson(response, handled.httpStatus, handled.payload);
|
|
} catch (error) {
|
|
const summary = error?.message ?? "hwpod-node-ops handler failed";
|
|
recordHwpodNodeOpsBlocked(request, options, plan, requestMeta, "hwpod_node_handler_failed", summary, { dispatchMode: "handler-exception" });
|
|
sendJson(response, 200, hwpodNodeOpsBlockedPayload(plan, requestMeta, summary, { dispatchMode: "handler-exception" }));
|
|
}
|
|
}
|
|
|
|
export function handleHwlabNodeUpdateHttp(request, response, url, options) {
|
|
if (request.method !== "GET") {
|
|
sendJson(response, 405, { ok: false, error: { code: "method_not_allowed", message: "GET required." } });
|
|
return;
|
|
}
|
|
const env = options.env ?? process.env;
|
|
const currentVersion = cleanText(url.searchParams.get("current") || url.searchParams.get("version"));
|
|
const channel = cleanText(url.searchParams.get("channel")) || "stable";
|
|
const platform = cleanText(url.searchParams.get("platform")) || "unknown";
|
|
const bundled = hwlabNodeBundledMetadata(env);
|
|
const latestVersion = cleanText(env.HWLAB_NODE_PY_LATEST_VERSION) || bundled.version || "0.1.0";
|
|
const releaseNotesUrl = cleanText(env.HWLAB_NODE_PY_RELEASE_NOTES_URL) || null;
|
|
const downloadUrl = hwlabNodeDownloadUrl(env);
|
|
const sha256 = cleanText(env.HWLAB_NODE_PY_SHA256) || bundled.sha256 || null;
|
|
const fileName = nodeArtifactFileName(downloadUrl) || "hwlab-node.py";
|
|
const sizeBytes = parsePositiveInteger(env.HWLAB_NODE_PY_FILE_SIZE_BYTES, bundled.sizeBytes);
|
|
const newer = currentVersion ? compareVersionStrings(latestVersion, currentVersion) > 0 : Boolean(latestVersion);
|
|
const updateAvailable = Boolean(downloadUrl && newer);
|
|
sendJson(response, 200, {
|
|
ok: true,
|
|
contractVersion: "hwlab-node-update-v1",
|
|
serviceId: "hwlab-node",
|
|
route: "/v1/hwlab-node/update",
|
|
channel,
|
|
platform,
|
|
currentVersion: currentVersion || null,
|
|
latestVersion,
|
|
updateAvailable,
|
|
downloadUrl: updateAvailable ? downloadUrl : null,
|
|
sha256: updateAvailable ? sha256 : null,
|
|
artifact: {
|
|
version: latestVersion,
|
|
fileName,
|
|
sizeBytes,
|
|
sha256,
|
|
downloadUrl
|
|
},
|
|
releaseNotes: hwlabNodeReleaseNotes(env),
|
|
releaseNotesUrl,
|
|
installConditions: [
|
|
{ id: "windows-python", label: "Windows 交互用户的原生 Python", required: true },
|
|
{ id: "tkinter", label: "Python tkinter 图形能力", required: true },
|
|
{ id: "yaml-managed-config", label: "owning YAML 声明 nodeId、工作区与凭据引用", required: true },
|
|
{ id: "outbound-websocket", label: "可主动出站连接 Cloud API WebSocket", required: true }
|
|
],
|
|
readinessStages: hwlabNodeReadinessStages(),
|
|
manualDefault: true,
|
|
autoApplyDefault: false,
|
|
checkIntervalSeconds: parsePositiveInteger(env.HWLAB_NODE_PY_UPDATE_INTERVAL_SECONDS, 300),
|
|
observedAt: new Date().toISOString()
|
|
});
|
|
}
|
|
|
|
export function handleHwlabNodeDownloadHttp(request, response, options) {
|
|
if (request.method !== "GET") {
|
|
sendJson(response, 405, { ok: false, error: { code: "method_not_allowed", message: "GET required." } });
|
|
return;
|
|
}
|
|
const bundled = hwlabNodeBundledMetadata(options.env ?? process.env);
|
|
if (!bundled.content) {
|
|
sendJson(response, 404, { ok: false, error: { code: "hwlab_node_bundle_missing", message: "bundled hwlab-node.py is not available" } });
|
|
return;
|
|
}
|
|
const bytes = Buffer.from(bundled.content, "utf8");
|
|
response.writeHead(200, {
|
|
"content-type": "text/x-python; charset=utf-8",
|
|
"cache-control": "no-store",
|
|
"x-hwlab-node-version": bundled.version || "unknown",
|
|
"x-hwlab-node-sha256": bundled.sha256 || "",
|
|
"content-length": String(bytes.length)
|
|
});
|
|
response.end(bytes);
|
|
}
|
|
|
|
export async function handleHwpodSpecDiscoveryHttp(request, response, url, options) {
|
|
const probe = truthyFlag(url.searchParams.get("probe"));
|
|
const observedAt = new Date().toISOString();
|
|
let specs = await discoverHwpodSpecs(options);
|
|
if (probe) {
|
|
specs = await probeDiscoveredHwpodSpecs(specs, { request, options, observedAt });
|
|
}
|
|
sendJson(response, 200, hwpodSpecDiscoveryPayload(specs, { observedAt }));
|
|
}
|
|
|
|
export async function handleHwpodTopologyHttp(request, response, url, options) {
|
|
if (request.method !== "GET") {
|
|
sendJson(response, 405, { ok: false, error: { code: "method_not_allowed", message: "GET required." } });
|
|
return;
|
|
}
|
|
const observedAt = new Date().toISOString();
|
|
let specs = await discoverHwpodSpecs(options);
|
|
if (truthyFlag(url.searchParams.get("probe"))) {
|
|
specs = await probeDiscoveredHwpodSpecs(specs, { request, options, observedAt });
|
|
}
|
|
const payload = buildHwpodTopologyReadModel(specs, options.hwpodNodeWsRegistry.describe(), {
|
|
observedAt,
|
|
resource: url.searchParams.get("resource"),
|
|
q: url.searchParams.get("q"),
|
|
status: url.searchParams.get("status"),
|
|
sort: url.searchParams.get("sort"),
|
|
direction: url.searchParams.get("direction"),
|
|
cursor: url.searchParams.get("cursor"),
|
|
limit: parsePositiveInteger(url.searchParams.get("limit"), 50)
|
|
});
|
|
sendJson(response, 200, payload);
|
|
}
|
|
|
|
function runtimeEnvironment(env = process.env) {
|
|
const value = env.HWLAB_GITOPS_PROFILE || env.HWLAB_ENVIRONMENT || ENVIRONMENT_DEV;
|
|
return typeof value === "string" && value.trim() ? value.trim() : ENVIRONMENT_DEV;
|
|
}
|
|
|
|
function hwpodNodeOpsRequestMeta(request, options, label) {
|
|
const httpContext = request?.hwlabHttpRequestContext ?? {};
|
|
return {
|
|
requestId: getHeader(request, "x-request-id") || httpContext.requestId || `req_${label}_${randomUUID()}`,
|
|
traceId: getHeader(request, "x-trace-id") || `trc_${label}_${randomUUID()}`,
|
|
serviceId: getHeader(request, "x-source-service-id") || CLOUD_API_SERVICE_ID,
|
|
environment: runtimeEnvironment(options.env ?? process.env),
|
|
otelTraceId: httpContext.traceId ?? null,
|
|
traceparent: httpContext.traceparent ?? null,
|
|
valuesPrinted: false
|
|
};
|
|
}
|
|
|
|
function hwlabNodeBundledMetadata(env) {
|
|
const bundlePath = cleanText(env.HWLAB_NODE_PY_BUNDLE_PATH) || path.resolve(process.cwd(), "tools/hwlab-node.py");
|
|
try {
|
|
const content = readFileSync(bundlePath, "utf8");
|
|
const version = cleanText(content.match(/APP_VERSION\s*=\s*["']([^"']+)["']/u)?.[1]);
|
|
const sha256 = createHash("sha256").update(content, "utf8").digest("hex");
|
|
return { path: bundlePath, content, version, sha256, sizeBytes: Buffer.byteLength(content, "utf8") };
|
|
} catch {
|
|
return { path: bundlePath, content: "", version: "", sha256: "", sizeBytes: 0 };
|
|
}
|
|
}
|
|
|
|
function nodeArtifactFileName(downloadUrl) {
|
|
try {
|
|
const name = path.posix.basename(new URL(downloadUrl).pathname);
|
|
return cleanText(name);
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function hwlabNodeReleaseNotes(env) {
|
|
const configured = cleanText(env.HWLAB_NODE_PY_RELEASE_NOTES);
|
|
if (configured) return configured.split(/\r?\n|\|/u).map((item) => item.trim()).filter(Boolean).slice(0, 12);
|
|
return ["单文件 Python/tkinter 节点,提供主动出站 WebSocket、有界诊断和 SHA-256 校验自更新。"];
|
|
}
|
|
|
|
function hwlabNodeReadinessStages() {
|
|
return HWPOD_NODE_READINESS_DEFINITIONS.map((stage) => ({ ...stage, nextAction: { ...stage.nextAction } }));
|
|
}
|
|
|
|
function hwlabNodeDownloadUrl(env) {
|
|
const explicit = cleanText(env.HWLAB_NODE_PY_DOWNLOAD_URL);
|
|
if (explicit) return explicit;
|
|
const downloadPath = cleanText(env.HWLAB_NODE_PY_DOWNLOAD_PATH) || "/v1/hwlab-node/download/hwlab-node.py";
|
|
if (/^https?:\/\//iu.test(downloadPath)) return downloadPath;
|
|
const publicEndpoint = cleanText(env.HWLAB_PUBLIC_ENDPOINT);
|
|
if (!publicEndpoint) return downloadPath.startsWith("/") ? downloadPath : `/${downloadPath}`;
|
|
try {
|
|
return new URL(downloadPath, publicEndpoint.endsWith("/") ? publicEndpoint : `${publicEndpoint}/`).toString();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function compareVersionStrings(left, right) {
|
|
const a = versionParts(left);
|
|
const b = versionParts(right);
|
|
for (let index = 0; index < Math.max(a.length, b.length, 3); index += 1) {
|
|
const delta = (a[index] ?? 0) - (b[index] ?? 0);
|
|
if (delta !== 0) return delta > 0 ? 1 : -1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function versionParts(value) {
|
|
return String(value ?? "")
|
|
.trim()
|
|
.replace(/^v/iu, "")
|
|
.split(/[.+-]/u)
|
|
.slice(0, 4)
|
|
.map((part) => parseInt(part, 10))
|
|
.map((part) => (Number.isFinite(part) && part >= 0 ? part : 0));
|
|
}
|
|
|
|
function cleanText(value) {
|
|
const text = String(value ?? "").trim();
|
|
return text || "";
|
|
}
|
|
|
|
async function probeDiscoveredHwpodSpecs(specs, context) {
|
|
const probed = [];
|
|
for (const spec of specs) probed.push(await probeDiscoveredHwpodSpec(spec, context));
|
|
return probed;
|
|
}
|
|
|
|
async function probeDiscoveredHwpodSpec(spec, { request, options, observedAt }) {
|
|
if (spec.ok === false) return spec;
|
|
const plan = hwpodSpecWorkspaceProbePlan(spec);
|
|
const validation = validateHwpodNodeOpsPlan(plan);
|
|
if (!validation.ok) {
|
|
return {
|
|
...spec,
|
|
availability: {
|
|
ok: false,
|
|
status: "invalid_probe_plan",
|
|
checkedAt: observedAt,
|
|
blocker: validation.error
|
|
}
|
|
};
|
|
}
|
|
const requestMeta = { ...hwpodNodeOpsRequestMeta(request, options, "hwpod_spec"), operationKind: "readiness-probe" };
|
|
const handled = await dispatchHwpodNodeOpsPlan(validation.plan, requestMeta, options, { request });
|
|
const payload = handled.payload;
|
|
return {
|
|
...spec,
|
|
availability: {
|
|
ok: payload.ok === true,
|
|
status: payload.ok === true ? "available" : "blocked",
|
|
checkedAt: observedAt,
|
|
probePlanId: payload.planId,
|
|
nodeOpsRoute: "/v1/hwpod-node-ops",
|
|
results: payload.results ?? [],
|
|
blocker: payload.blocker ?? null,
|
|
httpStatus: handled.httpStatus
|
|
}
|
|
};
|
|
}
|
|
|
|
async function dispatchHwpodNodeOpsPlan(plan, requestMeta, options, context = {}) {
|
|
const hwpodNodeOpsUrl = normalizedHwpodNodeOpsUrl(options.env ?? process.env);
|
|
const hwpodNodeWsRegistry = options.hwpodNodeWsRegistry;
|
|
const hasWsNode = hwpodNodeWsRegistry?.hasNode?.(plan.nodeId);
|
|
if (typeof options.hwpodNodeOpsHandler !== "function" && !hasWsNode && !hwpodNodeOpsUrl) {
|
|
const summary = "no outbound WebSocket hwpod-node is connected and HWLAB_HWPOD_NODE_OPS_URL is not configured; cloud-api is only validating the hwpod-node-ops contract";
|
|
const details = {
|
|
dispatchMode: "none",
|
|
websocketRegistryConfigured: Boolean(hwpodNodeWsRegistry),
|
|
websocketConnected: false,
|
|
directUrlConfigured: false
|
|
};
|
|
recordHwpodNodeOpsBlocked(context.request, options, plan, requestMeta, "hwpod_node_unavailable", summary, details);
|
|
return {
|
|
httpStatus: 200,
|
|
payload: hwpodNodeOpsBlockedPayload(plan, requestMeta, summary, details)
|
|
};
|
|
}
|
|
const handled = typeof options.hwpodNodeOpsHandler === "function"
|
|
? await options.hwpodNodeOpsHandler(plan, { request: context.request, requestMeta, env: options.env ?? process.env })
|
|
: hasWsNode
|
|
? await hwpodNodeWsRegistry.dispatch(plan, requestMeta, { timeoutMs: parsePositiveInteger(options.env?.HWLAB_HWPOD_NODE_OPS_TIMEOUT_MS, 30000) })
|
|
: await forwardHwpodNodeOpsPlan(hwpodNodeOpsUrl, plan, requestMeta, options);
|
|
const results = Array.isArray(handled?.results) ? handled.results : [];
|
|
const failed = results.some((item) => item?.ok === false);
|
|
const payload = attachHwpodNodeOpsDiagnostics({
|
|
ok: handled?.ok ?? !failed,
|
|
status: handled?.status ?? (failed ? "failed" : "completed"),
|
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
|
planId: plan.planId,
|
|
hwpodId: plan.hwpodId,
|
|
nodeId: plan.nodeId,
|
|
acceptedOps: plan.ops.length,
|
|
results,
|
|
blocker: handled?.blocker ?? null,
|
|
requestMeta
|
|
}, requestMeta, { dispatchMode: hasWsNode ? "websocket" : hwpodNodeOpsUrl ? "direct-url" : "handler" });
|
|
if (payload.ok === false && payload.status === "blocked" && payload.blocker?.code) {
|
|
recordHwpodNodeOpsBlocked(context.request, options, plan, requestMeta, payload.blocker.code, payload.blocker.summary ?? "hwpod-node-ops blocked", payload.blocker.details ?? {});
|
|
}
|
|
return { httpStatus: handled?.httpStatus ?? (failed ? 409 : 200), payload };
|
|
}
|
|
|
|
async function forwardHwpodNodeOpsPlan(targetUrl, plan, requestMeta, options) {
|
|
const target = await describeDirectHwpodNode(targetUrl, options);
|
|
if (!target.ok || !target.nodeId) {
|
|
return directHwpodNodeBlocked(plan, "hwpod_node_identity_unverified", "configured hwpod-node URL did not expose a verifiable nodeId", {
|
|
requestedNodeId: plan.nodeId,
|
|
targetUrl: redactNodeOpsUrl(targetUrl),
|
|
dispatchMode: "direct-url",
|
|
targetStatus: target.status ?? null,
|
|
error: target.error ?? null
|
|
}, requestMeta);
|
|
}
|
|
if (target.ok && target.nodeId && target.nodeId !== plan.nodeId) {
|
|
return directHwpodNodeBlocked(plan, "hwpod_node_id_mismatch", `HWLAB_HWPOD_NODE_OPS_URL points to ${target.nodeId}, not requested node ${plan.nodeId}`, {
|
|
requestedNodeId: plan.nodeId,
|
|
targetNodeId: target.nodeId,
|
|
targetUrl: redactNodeOpsUrl(targetUrl),
|
|
dispatchMode: "direct-url"
|
|
}, requestMeta);
|
|
}
|
|
const response = await fetch(targetUrl, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-request-id": requestMeta.requestId,
|
|
"x-trace-id": requestMeta.traceId,
|
|
"x-source-service-id": CLOUD_API_SERVICE_ID,
|
|
...(requestMeta.otelTraceId ? { "x-hwlab-otel-trace-id": requestMeta.otelTraceId } : {}),
|
|
...(requestMeta.traceparent ? { traceparent: requestMeta.traceparent } : {})
|
|
},
|
|
body: JSON.stringify(plan),
|
|
signal: AbortSignal.timeout(parsePositiveInteger(options.env?.HWLAB_HWPOD_NODE_OPS_TIMEOUT_MS, 30000))
|
|
});
|
|
const body = await response.json().catch(() => null);
|
|
if (!body || typeof body !== "object") {
|
|
return directHwpodNodeBlocked(plan, "hwpod_node_response_invalid", `hwpod-node returned HTTP ${response.status} without a JSON object payload`, {
|
|
targetUrl: redactNodeOpsUrl(targetUrl),
|
|
dispatchMode: "direct-url",
|
|
targetStatus: response.status
|
|
}, requestMeta);
|
|
}
|
|
return {
|
|
ok: body.ok,
|
|
status: body.status,
|
|
httpStatus: response.status,
|
|
results: body.results,
|
|
blocker: body.blocker ?? null
|
|
};
|
|
}
|
|
|
|
function directHwpodNodeBlocked(plan, code, summary, details, requestMeta = {}) {
|
|
const blocker = hwpodNodeOpsBlocker({ code, layer: "hwpod-node", retryable: true, summary, details }, requestMeta, details);
|
|
return {
|
|
ok: false,
|
|
status: "blocked",
|
|
httpStatus: 200,
|
|
results: plan.ops.map((op) => ({ opId: op.opId, op: op.op, ok: false, status: "blocked", blocker })),
|
|
blocker,
|
|
otelTraceId: blocker.otelTraceId ?? null,
|
|
diagnostic: blocker.diagnostic ?? null
|
|
};
|
|
}
|
|
|
|
async function describeDirectHwpodNode(targetUrl, options) {
|
|
try {
|
|
const response = await fetch(targetUrl, {
|
|
method: "GET",
|
|
signal: AbortSignal.timeout(parsePositiveInteger(options.env?.HWLAB_HWPOD_NODE_OPS_TIMEOUT_MS, 30000))
|
|
});
|
|
const body = await response.json().catch(() => null);
|
|
return { ok: response.ok && body && typeof body === "object", status: response.status, nodeId: safeOpaqueId(body?.nodeId), body };
|
|
} catch (error) {
|
|
return { ok: false, error: error instanceof Error ? error.message : String(error), nodeId: "" };
|
|
}
|
|
}
|
|
|
|
function redactNodeOpsUrl(value) {
|
|
try {
|
|
const parsed = new URL(value);
|
|
parsed.username = "";
|
|
parsed.password = "";
|
|
parsed.search = parsed.search ? "?redacted=1" : "";
|
|
return parsed.toString();
|
|
} catch {
|
|
return "<invalid-url>";
|
|
}
|
|
}
|
|
|
|
function normalizedHwpodNodeOpsUrl(env = process.env) {
|
|
const direct = String(env.HWLAB_HWPOD_NODE_OPS_URL ?? "").trim();
|
|
if (direct) return direct;
|
|
const base = String(env.HWLAB_HWPOD_NODE_URL ?? "").trim().replace(/\/+$/u, "");
|
|
return base ? `${base}/v1/hwpod-node-ops` : "";
|
|
}
|
|
|
|
function validateHwpodNodeOpsPlan(value) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
return { ok: false, error: { code: "invalid_hwpod_node_ops_plan", message: "hwpod-node-ops body must be a JSON object" } };
|
|
}
|
|
if (value.contractVersion !== HWPOD_NODE_OPS_CONTRACT_VERSION) {
|
|
return { ok: false, error: { code: "invalid_hwpod_node_ops_contract", message: `contractVersion must be ${HWPOD_NODE_OPS_CONTRACT_VERSION}`, actual: value.contractVersion ?? null } };
|
|
}
|
|
const planId = safeOpaqueId(value.planId) || `hwpod_plan_${randomUUID()}`;
|
|
const nodeId = safeOpaqueId(value.nodeId);
|
|
const hwpodId = safeOpaqueId(value.hwpodId);
|
|
if (!nodeId) return { ok: false, error: { code: "invalid_hwpod_node_id", message: "nodeId is required" } };
|
|
if (!hwpodId) return { ok: false, error: { code: "invalid_hwpod_id", message: "hwpodId is required" } };
|
|
if (!Array.isArray(value.ops) || value.ops.length === 0) {
|
|
return { ok: false, error: { code: "invalid_hwpod_node_ops", message: "ops must be a non-empty array" } };
|
|
}
|
|
const ops = [];
|
|
for (const [index, item] of value.ops.entries()) {
|
|
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
return { ok: false, error: { code: "invalid_hwpod_node_op", message: `ops[${index}] must be a JSON object` } };
|
|
}
|
|
const op = typeof item.op === "string" ? item.op.trim() : "";
|
|
if (!HWPOD_NODE_OPS.has(op)) {
|
|
return { ok: false, error: { code: "unsupported_hwpod_node_op", message: `unsupported hwpod-node op: ${op || "<empty>"}`, supportedOps: Array.from(HWPOD_NODE_OPS) } };
|
|
}
|
|
ops.push({
|
|
opId: safeOpaqueId(item.opId) || `op_${index + 1}`,
|
|
op,
|
|
args: item.args && typeof item.args === "object" && !Array.isArray(item.args) ? item.args : {}
|
|
});
|
|
}
|
|
return {
|
|
ok: true,
|
|
plan: {
|
|
...value,
|
|
planId,
|
|
hwpodId,
|
|
nodeId,
|
|
ops
|
|
}
|
|
};
|
|
}
|
|
|
|
function hwpodNodeOpsBlockedPayload(plan, requestMeta, summary, details = {}) {
|
|
const blocker = hwpodNodeOpsBlocker({
|
|
code: "hwpod_node_unavailable",
|
|
layer: "hwpod-node",
|
|
retryable: true,
|
|
summary,
|
|
details,
|
|
userMessage: "hwpod-node 尚未接入执行面;cloud-api 已完成 hwpod-node-ops 合同校验。"
|
|
}, requestMeta, details);
|
|
return {
|
|
ok: false,
|
|
status: "blocked",
|
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
|
planId: plan.planId,
|
|
hwpodId: plan.hwpodId,
|
|
nodeId: plan.nodeId,
|
|
acceptedOps: plan.ops.length,
|
|
results: plan.ops.map((op) => ({
|
|
opId: op.opId,
|
|
op: op.op,
|
|
ok: false,
|
|
status: "blocked",
|
|
blocker
|
|
})),
|
|
blocker,
|
|
otelTraceId: blocker.otelTraceId ?? null,
|
|
diagnostic: blocker.diagnostic ?? null,
|
|
requestMeta
|
|
};
|
|
}
|
|
|
|
function hwpodNodeOpsBlocker(blocker, requestMeta = {}, details = {}) {
|
|
const code = cleanText(blocker?.code) || "hwpod_node_ops_blocked";
|
|
const summary = cleanText(blocker?.summary ?? blocker?.message) || "hwpod-node-ops blocked";
|
|
const otelTraceId = cleanText(blocker?.otelTraceId ?? requestMeta?.otelTraceId) || null;
|
|
const diagnostic = {
|
|
code,
|
|
rootCauseCode: code,
|
|
summary,
|
|
otelTraceId,
|
|
traceLine: otelTraceId ? `OTel traceId: ${otelTraceId}` : null,
|
|
details: details && typeof details === "object" ? details : {},
|
|
valuesPrinted: false
|
|
};
|
|
const userMessage = cleanText(blocker?.userMessage);
|
|
return {
|
|
...blocker,
|
|
code,
|
|
layer: blocker?.layer ?? "hwpod-node",
|
|
retryable: blocker?.retryable ?? true,
|
|
summary,
|
|
...(otelTraceId ? { otelTraceId } : {}),
|
|
diagnostic,
|
|
...(userMessage ? { userMessage: `${userMessage}\n${diagnostic.traceLine ?? "OTel traceId: unavailable"}` } : {})
|
|
};
|
|
}
|
|
|
|
function attachHwpodNodeOpsDiagnostics(payload, requestMeta, details = {}) {
|
|
if (!payload || typeof payload !== "object" || payload.ok !== false) return payload;
|
|
const topBlocker = payload.blocker ? hwpodNodeOpsBlocker(payload.blocker, requestMeta, payload.blocker.details ?? details) : null;
|
|
const results = Array.isArray(payload.results)
|
|
? payload.results.map((result) => result?.blocker ? { ...result, blocker: hwpodNodeOpsBlocker(result.blocker, requestMeta, result.blocker.details ?? details) } : result)
|
|
: payload.results;
|
|
const diagnostic = topBlocker?.diagnostic ?? results?.find?.((result) => result?.blocker?.diagnostic)?.blocker?.diagnostic ?? null;
|
|
return {
|
|
...payload,
|
|
results,
|
|
blocker: topBlocker,
|
|
...(diagnostic?.otelTraceId ? { otelTraceId: diagnostic.otelTraceId } : {}),
|
|
...(diagnostic ? { diagnostic } : {})
|
|
};
|
|
}
|
|
|
|
function recordHwpodNodeOpsBlocked(request, options, plan, requestMeta, code, summary, details = {}) {
|
|
const httpContext = request?.hwlabHttpRequestContext;
|
|
const attributes = {
|
|
"hwlab.hwpod.plan_id": cleanText(plan?.planId) || null,
|
|
"hwlab.hwpod.hwpod_id": cleanText(plan?.hwpodId) || null,
|
|
"hwlab.hwpod.node_id": cleanText(plan?.nodeId) || null,
|
|
"hwlab.hwpod.accepted_ops": Array.isArray(plan?.ops) ? plan.ops.length : 0,
|
|
"hwlab.hwpod.ops": Array.isArray(plan?.ops) ? plan.ops.map((op) => cleanText(op?.op)).filter(Boolean).join(",").slice(0, 240) : null,
|
|
"hwlab.hwpod.blocker.code": code,
|
|
"hwlab.hwpod.blocker.summary": String(summary ?? "").slice(0, 500),
|
|
"hwlab.hwpod.dispatch_mode": cleanText(details?.dispatchMode) || "unknown",
|
|
"hwlab.hwpod.websocket_connected": Boolean(details?.websocketConnected),
|
|
"hwlab.hwpod.direct_url_configured": Boolean(details?.directUrlConfigured),
|
|
"hwlab.hwpod.otel_trace_id": cleanText(requestMeta?.otelTraceId) || null,
|
|
traceId: cleanText(requestMeta?.traceId) || null
|
|
};
|
|
if (httpContext) httpContext.otelAttributes = { ...(httpContext.otelAttributes ?? {}), ...attributes };
|
|
const error = Object.assign(new Error(summary), { code });
|
|
options.emitHttpRoutePhaseSpan?.(request, options, "hwpod-node-ops.blocked", Date.now(), Date.now(), "error", error, attributes);
|
|
}
|
|
|
|
async function readJsonObject(request, limitBytes) {
|
|
const body = await readBody(request, limitBytes ?? DEFAULT_BODY_LIMIT_BYTES);
|
|
try {
|
|
const value = body ? JSON.parse(body) : {};
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
return { ok: false, error: { accepted: false, error: { code: "invalid_params", message: "body must be a JSON object" } } };
|
|
}
|
|
return { ok: true, value };
|
|
} catch (error) {
|
|
return { ok: false, error: { accepted: false, error: { code: "parse_error", message: "Invalid JSON body", reason: error.message } } };
|
|
}
|
|
}
|