Files
pikasTech-HWLAB/cmd/hwlab-device-pod/main.ts
T

537 lines
22 KiB
JavaScript

#!/usr/bin/env node
import { createServer } from "node:http";
import { randomUUID } from "node:crypto";
import { jsonResponse, listen, parsePort, readJson } from "../../internal/sim/http.mjs";
const SERVICE_ID = "hwlab-device-pod";
const CONTRACT_VERSION = "device-pod-executor-v1";
const DEFAULT_DEVICE_POD_ID = "device-pod-71-freq";
const port = parsePort(process.env.HWLAB_DEVICE_POD_PORT, parsePort(process.env.PORT, 7601));
const devicePodId = process.env.HWLAB_DEVICE_POD_ID || DEFAULT_DEVICE_POD_ID;
const environment = process.env.HWLAB_ENVIRONMENT || process.env.HWLAB_GITOPS_PROFILE || "dev";
const cloudApiInternalUrl = normalizeBaseUrl(process.env.HWLAB_CLOUD_API_INTERNAL_URL || process.env.HWLAB_CLOUD_API_URL);
const internalToken = textOr(process.env.HWLAB_DEVICE_POD_INTERNAL_TOKEN, "");
const dispatchTimeoutMs = numberOr(process.env.HWLAB_DEVICE_POD_GATEWAY_DISPATCH_TIMEOUT_MS, 120000);
const DEVICE_JOB_OUTPUT_MAX_BYTES = 12000;
const jobs = new Map();
listen(createServer(async (request, response) => {
try {
const url = new URL(request.url ?? "/", "http://localhost");
if (request.method === "GET" && (url.pathname === "/health" || url.pathname === "/health/live")) {
jsonResponse(response, 200, healthPayload());
return;
}
if (url.pathname === "/health" || url.pathname === "/health/live") {
jsonResponse(response, 405, { error: "method_not_allowed", method: request.method ?? "UNKNOWN", path: url.pathname });
return;
}
if (request.method === "GET" && url.pathname === "/v1/device-pods") {
jsonResponse(response, 200, listPayload());
return;
}
const route = parseDevicePodPath(url.pathname);
if (!route) {
jsonResponse(response, 404, { error: "not_found", path: url.pathname });
return;
}
if (request.method === "GET" && route.subpath === "status") {
jsonResponse(response, 200, statusPayload(route.devicePodId));
return;
}
if (request.method === "GET" && route.subpath === "events") {
jsonResponse(response, 200, eventsPayload(route.devicePodId, url.searchParams));
return;
}
if (request.method === "POST" && route.subpath === "jobs") {
await handleInternalJob(request, response, route.devicePodId);
return;
}
if (request.method === "GET" && route.subpath.startsWith("jobs/")) {
handleGetInternalJob(request, response, route.devicePodId, route.subpath);
return;
}
if (request.method === "POST" && route.subpath.startsWith("jobs/") && route.subpath.endsWith("/cancel")) {
handleCancelInternalJob(request, response, route.devicePodId, route.subpath);
return;
}
jsonResponse(response, 405, { error: "method_not_allowed", method: request.method ?? "UNKNOWN", path: url.pathname });
} catch (error) {
jsonResponse(response, 500, { error: "internal_error", message: error?.message ?? String(error), valuesRedacted: true });
}
}), port);
function healthPayload() {
return {
serviceId: SERVICE_ID,
environment,
status: "live",
ready: true,
contractVersion: CONTRACT_VERSION,
devicePodId,
observedAt: new Date().toISOString(),
role: "device-pod-internal-executor",
authority: "hwlab-cloud-api",
acceptsUserAuthority: false,
fake: false,
gatewayAdapter: gatewayAdapterState(),
source: sourcePayload(),
note: "Profile, grant, lease, and user-facing job authority live in hwlab-cloud-api; this service only exposes the internal executor boundary."
};
}
function listPayload() {
return {
serviceId: SERVICE_ID,
contractVersion: CONTRACT_VERSION,
status: "ok",
source: sourcePayload(),
selectedDevicePodId: devicePodId,
devicePods: [{
devicePodId,
status: "executor-ready",
authority: "hwlab-cloud-api",
fake: false,
gatewayAdapter: gatewayAdapterState(),
routes: {
cloudAuthority: "/v1/device-pods",
internalJob: `/v1/device-pods/${encodeURIComponent(devicePodId)}/jobs`
}
}]
};
}
function statusPayload(id) {
return {
serviceId: SERVICE_ID,
contractVersion: CONTRACT_VERSION,
status: "blocked",
devicePodId: id,
observedAt: new Date().toISOString(),
source: sourcePayload(),
summary: {
devicePodId: id,
status: "blocked",
blocker: gatewayAdapterBlocker("device-host-cli dispatch is owned by hwlab-cloud-api gateway routing")
}
};
}
function eventsPayload(id, searchParams) {
const limit = Math.min(Math.max(Number.parseInt(searchParams.get("limit") ?? "80", 10) || 80, 1), 1000);
const event = {
eventId: `evt_${id}_executor_boundary`,
devicePodId: id,
ts: new Date().toISOString(),
level: "warn",
scope: "executor",
status: "blocked",
summary: "No standalone fake device events are emitted by hwlab-device-pod; use hwlab-cloud-api job authority.",
blocker: gatewayAdapterBlocker("standalone device-pod executor has no gateway session")
};
return {
serviceId: SERVICE_ID,
contractVersion: CONTRACT_VERSION,
status: "ok",
devicePodId: id,
source: sourcePayload(),
events: limit > 0 ? [event] : [],
truncation: { limit, returned: limit > 0 ? 1 : 0, truncated: false }
};
}
async function handleInternalJob(request, response, id) {
if (!isInternalCaller(request)) {
jsonResponse(response, 403, {
accepted: false,
status: "blocked",
error: {
code: "cloud_api_authority_required",
message: "Device Pod jobs must be authorized by hwlab-cloud-api before reaching the internal executor."
},
source: sourcePayload()
});
return;
}
const body = await readJson(request);
const traceId = typeof body.traceId === "string" && body.traceId.startsWith("trc_") ? body.traceId : `trc_devicepod_${randomUUID()}`;
const operationId = typeof body.operationId === "string" ? body.operationId : `op_devicepod_${randomUUID()}`;
const now = new Date().toISOString();
const profile = normalizeObject(body.profile);
const route = normalizeObject(profile.route);
const jobId = typeof body.jobId === "string" && body.jobId.startsWith("job_") ? body.jobId : `job_devicepod_${randomUUID()}`;
const command = deviceHostCommand(profile, {
id: jobId,
devicePodId: id,
intent: typeof body.intent === "string" ? body.intent : "unknown",
args: normalizeObject(body.args)
});
const dispatchBlocker = gatewayDispatchPreflightBlocker(route, command);
const job = {
id: jobId,
devicePodId: id,
status: dispatchBlocker ? "blocked" : "running",
intent: typeof body.intent === "string" ? body.intent : "unknown",
reason: typeof body.reason === "string" ? body.reason : "",
traceId,
operationId,
createdAt: now,
updatedAt: now,
completedAt: dispatchBlocker ? now : null,
targetId: typeof body.targetId === "string" ? body.targetId : targetIdFromProfile(profile),
profileHash: typeof body.profileHash === "string" ? body.profileHash : "",
ownerUserId: typeof body.ownerUserId === "string" ? body.ownerUserId : "",
command,
output: dispatchBlocker ? boundedOutput({ text: "", summary: dispatchBlocker.summary }) : boundedOutput({ text: "", summary: "gateway/device-host-cli dispatch queued" }),
blocker: dispatchBlocker,
source: sourcePayload()
};
jobs.set(jobKey(id, job.id), job);
if (!dispatchBlocker) dispatchGatewayJob({ job, route });
jsonResponse(response, dispatchBlocker ? 409 : 202, jobPayload(job, { accepted: !dispatchBlocker }));
}
function handleGetInternalJob(request, response, id, subpath) {
if (!isInternalCaller(request)) return rejectExternalJobRoute(response);
const parts = subpath.split("/");
const job = jobs.get(jobKey(id, decodeURIComponent(parts[1] ?? "")));
if (!job) {
jsonResponse(response, 404, { error: { code: "device_job_not_found", message: "Device Pod executor job was not found" }, source: sourcePayload() });
return;
}
if (parts[2] === "output") {
jsonResponse(response, 200, jobOutputPayload(job));
return;
}
jsonResponse(response, 200, jobPayload(job));
}
function handleCancelInternalJob(request, response, id, subpath) {
if (!isInternalCaller(request)) return rejectExternalJobRoute(response);
const jobId = decodeURIComponent(subpath.split("/")[1] ?? "");
const job = jobs.get(jobKey(id, jobId));
if (!job) {
jsonResponse(response, 404, { error: { code: "device_job_not_found", message: "Device Pod executor job was not found" }, source: sourcePayload() });
return;
}
if (terminalJobStatus(job.status)) {
jsonResponse(response, 200, jobPayload(job));
return;
}
const now = new Date().toISOString();
const canceled = { ...job, status: "canceled", updatedAt: now, completedAt: now, blocker: { code: "device_job_canceled", layer: "device-pod", retryable: false, summary: "Device Pod executor job was canceled by cloud-api" } };
jobs.set(jobKey(id, jobId), canceled);
jsonResponse(response, 200, jobPayload(canceled));
}
async function dispatchGatewayJob({ job, route }) {
const target = `${cloudApiInternalUrl}/v1/internal/device-pod/gateway-dispatch`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), dispatchTimeoutMs + 10000);
try {
const response = await fetch(target, {
method: "POST",
signal: controller.signal,
headers: {
accept: "application/json",
"content-type": "application/json",
"x-hwlab-internal-service": SERVICE_ID,
"x-hwlab-internal-token": internalToken,
"x-trace-id": job.traceId,
"x-request-id": `req_${job.id}`
},
body: JSON.stringify({
id: `req_${job.id}`,
actorId: job.ownerUserId || "svc_hwlab-device-pod",
params: {
projectId: "prj_v02_device_pod",
gatewaySessionId: route.gatewaySessionId,
resourceId: route.resourceId ?? "res_device_pod",
capabilityId: route.capabilityId ?? "cap_device_host_cli",
operationId: job.operationId,
traceId: job.traceId,
input: {
command: job.command,
cwd: route.hostWorkspaceRoot,
timeoutMs: dispatchTimeoutMs
}
}
})
});
const payload = await response.json().catch(() => ({}));
const current = jobs.get(jobKey(job.devicePodId, job.id));
if (current && terminalJobStatus(current.status)) return;
const next = jobFromGatewayDispatch(job, payload, response.status);
jobs.set(jobKey(job.devicePodId, job.id), next);
} catch (error) {
const current = jobs.get(jobKey(job.devicePodId, job.id));
if (current && terminalJobStatus(current.status)) return;
const now = new Date().toISOString();
jobs.set(jobKey(job.devicePodId, job.id), {
...job,
status: "failed",
updatedAt: now,
completedAt: now,
output: boundedOutput({ text: "", error: error?.message ?? "gateway dispatch failed" }),
blocker: gatewayDispatchFailedBlocker(error?.message ?? "gateway dispatch failed")
});
} finally {
clearTimeout(timer);
}
}
function jobPayload(job, { accepted = !["blocked", "failed"].includes(job.status) } = {}) {
return {
accepted,
status: job.status,
contractVersion: CONTRACT_VERSION,
devicePodId: job.devicePodId,
targetId: job.targetId ?? null,
profileHash: job.profileHash ?? "",
traceId: job.traceId,
operationId: job.operationId,
job: publicJob(job),
blocker: job.blocker,
freshness: freshness(job.updatedAt, job.blocker),
outputUrl: `/v1/device-pods/${encodeURIComponent(job.devicePodId)}/jobs/${encodeURIComponent(job.id)}/output`,
cancelUrl: `/v1/device-pods/${encodeURIComponent(job.devicePodId)}/jobs/${encodeURIComponent(job.id)}/cancel`,
source: job.source ?? sourcePayload()
};
}
function rejectExternalJobRoute(response) {
jsonResponse(response, 403, {
accepted: false,
status: "blocked",
error: {
code: "cloud_api_authority_required",
message: "Device Pod jobs must be authorized by hwlab-cloud-api before reaching the internal executor."
},
source: sourcePayload()
});
}
function parseDevicePodPath(pathname) {
const prefix = "/v1/device-pods/";
if (!pathname.startsWith(prefix)) return null;
const [id, ...rest] = pathname.slice(prefix.length).split("/").filter(Boolean).map((part) => decodeURIComponent(part));
return id ? { devicePodId: id, subpath: rest.join("/") } : null;
}
function isInternalCaller(request) {
if (!internalToken) return false;
const serviceHeader = request.headers["x-hwlab-internal-service"];
const tokenHeader = request.headers["x-hwlab-internal-token"];
return String(Array.isArray(serviceHeader) ? serviceHeader[0] : serviceHeader ?? "").trim() === "hwlab-cloud-api"
&& String(Array.isArray(tokenHeader) ? tokenHeader[0] : tokenHeader ?? "").trim() === internalToken;
}
function sourcePayload() {
return { kind: "INTERNAL_EXECUTOR", serviceId: SERVICE_ID, authority: "hwlab-cloud-api", fake: false, devLiveEvidence: false };
}
function gatewayAdapterBlocker(summary) {
return { code: "gateway_dispatch_unavailable", layer: "device-pod", retryable: true, summary, userMessage: "Device Pod executor boundary is present, but gateway/device-host-cli dispatch is not connected here." };
}
function gatewayDispatchFailedBlocker(summary) {
return { code: "gateway_dispatch_failed", layer: "device-pod", retryable: true, summary, userMessage: "Device Pod gateway/device-host-cli dispatch failed." };
}
function gatewayDispatchPreflightBlocker(route, command) {
if (!cloudApiInternalUrl) return gatewayAdapterBlocker("HWLAB_CLOUD_API_INTERNAL_URL is not configured for gateway dispatch");
if (!route.gatewaySessionId) return gatewayAdapterBlocker("profile route.gatewaySessionId is missing");
if (!command) return gatewayAdapterBlocker("device-host-cli command mapping is not available for this intent");
return null;
}
function gatewayAdapterState() {
return { mode: cloudApiInternalUrl ? "cloud-api-internal-dispatch" : "blocked", cloudApiInternalUrlConfigured: Boolean(cloudApiInternalUrl) };
}
function jobFromGatewayDispatch(job, payload, httpStatus) {
const result = normalizeObject(payload.result);
const dispatch = normalizeObject(result.dispatch);
const blocker = payload.blocker ?? result.blocker ?? (payload.error ? gatewayDispatchFailedBlocker(payload.error.message ?? "gateway dispatch failed") : null);
const completed = !blocker && httpStatus < 400 && (result.status === "completed" || result.status === "succeeded" || dispatch.dispatchStatus === "succeeded" || dispatch.dispatchStatus === "completed");
const status = completed ? "completed" : blocker?.code === "gateway_dispatch_unavailable" ? "blocked" : "failed";
const now = new Date().toISOString();
return {
...job,
status,
updatedAt: now,
completedAt: now,
output: boundedOutput({
text: gatewayDispatchText(result, dispatch),
httpStatus,
dispatch,
gateway: result.gateway ?? null,
auditId: result.auditId ?? null,
evidenceId: result.evidenceId ?? null,
summary: completed ? "gateway/device-host-cli dispatch completed" : blocker?.summary ?? "gateway/device-host-cli dispatch failed"
}),
blocker: completed ? null : blocker ?? gatewayDispatchFailedBlocker(dispatch.message ?? dispatch.stderr ?? result.status ?? "gateway dispatch failed")
};
}
function gatewayDispatchText(result, dispatch) {
if (typeof dispatch.stdout === "string" && dispatch.stdout) return dispatch.stdout;
if (typeof dispatch.stderr === "string" && dispatch.stderr) return dispatch.stderr;
if (typeof result.text === "string") return result.text;
return JSON.stringify(result);
}
function deviceHostCommand(profile, job) {
const args = deviceHostArgs(job.intent, job.args);
if (!args) return null;
const payload = {
devicePodId: profile.devicePodId || job.devicePodId,
profile,
hostCli: profile.route?.hostCli || "node tools/device-host-cli.mjs",
args
};
const profileJsonB64 = Buffer.from(JSON.stringify(payload.profile), "utf8").toString("base64");
return shellJoin([...splitCommand(payload.hostCli), "--profile-json-b64", profileJsonB64, "--pod-id", payload.devicePodId, ...payload.args]);
}
function splitCommand(value) {
const out = [];
let quote = null;
let buffer = "";
for (const char of String(value || "")) {
if (quote) {
if (char === quote) quote = null;
else buffer += char;
} else if (char === "'" || char === '"') {
quote = char;
} else if (/\s/u.test(char)) {
if (buffer) {
out.push(buffer);
buffer = "";
}
} else {
buffer += char;
}
}
if (buffer) out.push(buffer);
return out.length ? out : ["node", "tools/device-host-cli.mjs"];
}
function shellJoin(parts) {
return parts.map(shellQuote).join(" ");
}
function shellQuote(value) {
const text = String(value ?? "");
if (/^[A-Za-z0-9_./:=@\\-]+$/u.test(text)) return text;
return `"${text.replace(/(["\\])/gu, "\\$1")}"`;
}
function deviceHostArgs(intent, args = {}) {
if (intent === "workspace.ls") return ["workspace", "ls", textOr(args.path, ".")];
if (intent === "workspace.cat") {
const path = textOr(args.path, "");
return path ? ["workspace", "cat", path, "--limit", String(numberOr(args.limit ?? args.maxBytes, 40000))] : null;
}
if (intent === "workspace.rg") {
const pattern = textOr(args.pattern ?? args.query, "");
return pattern ? ["workspace", "rg", pattern, textOr(args.path, "."), ...hostOptionArgs(args, ["glob", "g", "filesWithMatches", "l", "ignoreCase", "i", "maxCount"])] : null;
}
if (intent === "workspace.apply-patch") return ["workspace", "apply-patch", textOr(args.base, "."), "--patch-b64", patchBase64(args)];
if (intent === "workspace.build") return ["workspace", "build", textOr(args.action, "start"), ...hostOptionArgs(args, ["target", "timeoutMs"]), ...jobIdArgs(args)];
if (intent === "debug.status") return ["debug-probe", "status"];
if (intent === "debug.chip-id") return ["debug-probe", "chip-id"];
if (intent === "debug.download") return ["debug-probe", "download", textOr(args.action, "start"), ...hostOptionArgs(args, ["target", "timeoutMs", "captureUart", "captureDurationMs", "durationMs", "port", "baudRate"]), ...jobIdArgs(args)];
if (intent === "debug.reset") return ["debug-probe", "reset"];
if (intent === "io.ports") return ["io-probe", textOr(args.uartId, "uart/1"), "ports"];
if (intent === "io.uart.read") return ["io-probe", textOr(args.uartId, "uart/1"), "read", "--duration-ms", String(numberOr(args.durationMs ?? args["duration-ms"], 1000)), ...hostOptionArgs(args, ["port", "baudRate"])] ;
if (intent === "io.uart.read-after-launch-flash") return ["io-probe", textOr(args.uartId, "uart/1"), "read-after-launch-flash", ...hostOptionArgs(args, ["durationMs", "port", "baudRate", "flashBase", "skipHardwareReset", "connectMode", "timeoutMs"])] ;
if (intent === "io.uart.write") return ["io-probe", textOr(args.uartId, "uart/1"), "write", ...(args.hex ? ["--hex"] : []), ...hostOptionArgs(args, ["port", "baudRate"]), textOr(args.message ?? args.text ?? args.data, "")];
return null;
}
function jobIdArgs(args = {}) {
const jobId = textOr(args.jobId, "");
return jobId ? [jobId] : [];
}
function hostOptionArgs(args = {}, keys = []) {
const out = [];
for (const key of keys) {
const value = args[key];
if (value === undefined || value === null || value === "" || value === false) continue;
const name = `--${key.replace(/[A-Z]/gu, (match) => `-${match.toLowerCase()}`)}`;
if (value === true) out.push(name);
else out.push(name, String(value));
}
return out;
}
function patchBase64(args = {}) {
if (typeof args.patchB64 === "string") return args.patchB64;
if (typeof args["patch-b64"] === "string") return args["patch-b64"];
return Buffer.from(String(args.patch ?? ""), "utf8").toString("base64");
}
function targetIdFromProfile(profile = {}) {
return profile.target?.id ?? profile.targetId ?? null;
}
function normalizeObject(value) {
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
}
function textOr(value, fallback) {
return typeof value === "string" && value.trim() ? value : fallback;
}
function numberOr(value, fallback) {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function normalizeBaseUrl(value) {
const text = String(value ?? "").trim();
return text ? text.replace(/\/+$/u, "") : "";
}
function jobOutputPayload(job) {
return {
...jobPayload(job),
output: job.output,
text: job.output.text,
bytes: job.output.bytes,
truncation: job.output.truncation
};
}
function publicJob(job) {
return { id: job.id, devicePodId: job.devicePodId, status: job.status, intent: job.intent, reason: job.reason, traceId: job.traceId, operationId: job.operationId, createdAt: job.createdAt, updatedAt: job.updatedAt, completedAt: job.completedAt };
}
function boundedOutput(output, maxBytes = DEVICE_JOB_OUTPUT_MAX_BYTES) {
const text = String(output?.text ?? "");
const buffer = Buffer.from(text, "utf8");
const clipped = buffer.length > maxBytes;
const boundedText = clipped ? buffer.subarray(0, maxBytes).toString("utf8") : text;
const bounded = { ...output, text: boundedText };
if (clipped) {
delete bounded.dispatch;
bounded.omitted = { reason: "device_job_output_truncated", originalBytes: buffer.length };
}
return { ...bounded, bytes: Math.min(buffer.length, maxBytes), truncation: { maxBytes, truncated: clipped, originalBytes: buffer.length } };
}
function freshness(observedAt, blocker) {
return { observedAt, ageMs: 0, stale: Boolean(blocker), source: blocker ? "blocked" : "device-pod-executor" };
}
function terminalJobStatus(status) {
return ["completed", "failed", "blocked", "canceled"].includes(status);
}
function jobKey(id, jobId) {
return `${id}\u0000${jobId}`;
}