eb5bada0d1
Tracks pikasTech/HWLAB#773. PR #765 fixed selector confusion but did not touch cloud-api evidence propagation. This change closes the real root cause and adds the read-only evidence selectors that #760 follow-up called for. cloud-api (internal/cloud/access-control.ts): - DEVICE_JOB_INTENTS adds workspace.evidence and debug.evidence. - DEVICE_JOB_READ_ONLY_SUB_ACTIONS = { status, output, wait, cancel, evidence } and DEVICE_JOB_ACTIONABLE_INTENTS = { workspace.build, debug.download } make the mutating-intent / sub-action matrix explicit. - _deviceJobRequiresReason(intent, args, reason) returns false when the caller already provided reason OR when the actionable mutating intent is paired with a read-only sub-action. debug.reset and other non-actionable mutating intents still always require reason. - executorOutputPayload now also surfaces output.summary, nestedOutput.summary, evidence.text, evidence.logTail and evidence.summary as body.output.text, so a dispatcher that includes the host logTail / buildSummary in its result becomes visible to the Code Agent without a separate bootsharp dance. device-pod executor (cmd/hwlab-device-pod/main.ts): - gatewayDispatchText also walks result.evidence.{text,logTail, summary}, dispatch.message (only when dispatchStatus=completed), dispatch.summary, result.summary and dispatch.buildSummary before falling back to JSON.stringify. - deviceHostArgs maps workspace.evidence / debug.evidence to the host device-host-cli evidence subcommands. device-host-cli (skills/device-pod-cli/assets/device-host-cli.mjs): - new readJobEvidence(kindPrefix, requestedId, { tail, full, target }) reads the most recent (or specified) keil-build / keil-download job log file and returns it as keil-build.evidence / keil-download.evidence. tail defaults to 200, --full for entire log. Missing job returns ok=false but does not throw. - workspace build evidence and debug-probe download evidence now wired in main() and help text. device-pod-cli (tools/src/device-pod-cli-lib.ts): - selectorCheatSheet adds the evidence row and clarifies that build/download status/output/wait/cancel/evidence are read-only and do NOT need --reason. build/download start still require --reason. usage examples now include the two evidence selectors. - failed-fast selectors are unchanged; existing 26 tests still pass. SKILL.md (skills/device-pod-cli/SKILL.md): - Selector Cheat Sheet adds workspace.evidence and debug.evidence rows. - #773 follow-up note: --reason is now required only for mutating sub-actions, not for the read-only ones. cloud-api tests (internal/cloud/access-control.test.ts): - workspace.evidence and debug.evidence must be in DEVICE_JOB_INTENTS. - _deviceJobRequiresReason signature and the read-only / actionable branch table. - executorOutputPayload must look at evidence and summary fields in addition to text. 3 new tests; pre-existing 4 workbench failures are unrelated to this change and reproduce on the unchanged v0.2 base. Verification (host-side, G14 /root/hwlab-v02, source=0cf9a8c6): - bun test tools/device-pod-cli.test.ts → 26/26 pass. - bun test internal/cloud/access-control.test.ts → 23 pass, 4 pre-existing workbench failures (unchanged on v0.2 base). - bun test cmd/hwlab-device-pod/main.test.ts → 7/7 pass. - node --check skills/device-pod-cli/assets/device-host-cli.mjs → syntax OK. Hot probe: live v0.2 cloud-api at 74.48.78.17:19667 confirmed job_devicepod_804c5db4... (workspace.build) returned text="", bytes=0, output={} before this change. After the executor text-extraction update and the new evidence selector, a follow-up workspace.build start + build evidence will surface the host logTail / buildSummary in body.output.text without requiring bootsharp + host file fallback. Tracked-by: pikasTech/HWLAB#773
794 lines
28 KiB
JavaScript
794 lines
28 KiB
JavaScript
#!/usr/bin/env node
|
|
import { createServer } from "node:http";
|
|
import { randomUUID } from "node:crypto";
|
|
|
|
import { runtimeIdentityFromEnv } from "../../internal/build-metadata.mjs";
|
|
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 devicePodIds = configuredDevicePodIds(process.env.HWLAB_DEVICE_POD_IDS, devicePodId);
|
|
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,
|
|
devicePodIds,
|
|
observedAt: new Date().toISOString(),
|
|
role: "device-pod-internal-executor",
|
|
authority: "hwlab-cloud-api",
|
|
acceptsUserAuthority: false,
|
|
fake: false,
|
|
gatewayAdapter: gatewayAdapterState(),
|
|
runtime: runtimeIdentityFromEnv(process.env),
|
|
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: devicePodIds.map((id) => executorDevicePodSummary(id))
|
|
};
|
|
}
|
|
|
|
function executorDevicePodSummary(id) {
|
|
return {
|
|
devicePodId: id,
|
|
status: "executor-ready",
|
|
authority: "hwlab-cloud-api",
|
|
fake: false,
|
|
gatewayAdapter: gatewayAdapterState(),
|
|
routes: {
|
|
cloudAuthority: "/v1/device-pods",
|
|
internalJob: `/v1/device-pods/${encodeURIComponent(id)}/jobs`
|
|
}
|
|
};
|
|
}
|
|
|
|
function statusPayload(id) {
|
|
const blocker = gatewayAdapterBlocker("device-host-cli dispatch is owned by hwlab-cloud-api gateway routing");
|
|
const observedAt = new Date().toISOString();
|
|
const output = boundedOutput({ text: blocker.summary, summary: blocker.summary });
|
|
const freshnessPayload = freshness(observedAt, blocker);
|
|
return {
|
|
serviceId: SERVICE_ID,
|
|
contractVersion: CONTRACT_VERSION,
|
|
status: "blocked",
|
|
devicePodId: id,
|
|
targetId: null,
|
|
profileHash: "",
|
|
traceId: `trc_devicepod_${randomUUID()}`,
|
|
operationId: `op_devicepod_${randomUUID()}`,
|
|
observedAt,
|
|
source: sourcePayload(),
|
|
blocker,
|
|
freshness: freshnessPayload,
|
|
output,
|
|
text: output.text,
|
|
bytes: output.bytes,
|
|
truncation: output.truncation,
|
|
summary: {
|
|
devicePodId: id,
|
|
targetId: null,
|
|
profileHash: "",
|
|
status: "blocked",
|
|
freshness: freshnessPayload,
|
|
blocker
|
|
}
|
|
};
|
|
}
|
|
|
|
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 requestId = `req_${job.id}`;
|
|
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": requestId
|
|
},
|
|
body: JSON.stringify({
|
|
id: requestId,
|
|
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,
|
|
runtime: runtimeIdentityFromEnv(process.env)
|
|
};
|
|
}
|
|
|
|
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: String(summary || "gateway/device-host-cli dispatch failed"), 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 expectedId = `req_${job.id}`;
|
|
if (payload && Object.hasOwn(payload, "id") && String(payload.id) !== expectedId) {
|
|
const blocker = gatewayDispatchFailedBlocker(`gateway JSON-RPC id mismatch: expected ${expectedId}, got ${String(payload.id)}`);
|
|
const now = new Date().toISOString();
|
|
return { ...job, status: "failed", updatedAt: now, completedAt: now, output: boundedOutput({ text: "", httpStatus, summary: blocker.summary }), blocker };
|
|
}
|
|
const result = normalizeObject(payload.result);
|
|
const dispatch = normalizeObject(result.dispatch);
|
|
const dispatchText = gatewayDispatchText(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 failureSummary = blocker?.summary || dispatch.message || dispatch.stderr || dispatchText || result.status || "gateway/device-host-cli dispatch failed";
|
|
const now = new Date().toISOString();
|
|
return {
|
|
...job,
|
|
status,
|
|
updatedAt: now,
|
|
completedAt: now,
|
|
output: boundedOutput({
|
|
text: dispatchText,
|
|
httpStatus,
|
|
dispatch: gatewayDispatchSummary(result, dispatch),
|
|
gateway: result.gateway ?? null,
|
|
auditId: result.auditId ?? null,
|
|
evidenceId: result.evidenceId ?? null,
|
|
summary: completed ? "gateway/device-host-cli dispatch completed" : failureSummary
|
|
}),
|
|
blocker: completed ? null : (blocker?.summary ? blocker : gatewayDispatchFailedBlocker(failureSummary))
|
|
};
|
|
}
|
|
|
|
function gatewayDispatchText(result, dispatch) {
|
|
if (typeof result.stdout === "string" && result.stdout) return result.stdout;
|
|
if (typeof result.stderr === "string" && result.stderr) return result.stderr;
|
|
if (typeof dispatch.stdout === "string" && dispatch.stdout) return dispatch.stdout;
|
|
if (typeof dispatch.stderr === "string" && dispatch.stderr) return dispatch.stderr;
|
|
const evidence = normalizeObject(result.evidence ?? dispatch.evidence);
|
|
if (typeof evidence.text === "string" && evidence.text) return evidence.text;
|
|
if (typeof evidence.logTail === "string" && evidence.logTail) return evidence.logTail;
|
|
if (typeof evidence.summary === "string" && evidence.summary) return evidence.summary;
|
|
if (typeof dispatch.message === "string" && dispatch.message && dispatch.dispatchStatus === "completed") return dispatch.message;
|
|
if (typeof dispatch.summary === "string" && dispatch.summary) return dispatch.summary;
|
|
if (typeof result.summary === "string" && result.summary) return result.summary;
|
|
if (typeof dispatch.buildSummary === "string" && dispatch.buildSummary) return dispatch.buildSummary;
|
|
if (typeof result.text === "string") return result.text;
|
|
if (result && Object.keys(result).length > 0) return JSON.stringify(result);
|
|
return "";
|
|
}
|
|
|
|
function gatewayDispatchSummary(result, dispatch) {
|
|
const value = (key) => dispatch[key] ?? result[key];
|
|
return pruneUndefined({
|
|
shellExecuted: value("shellExecuted"),
|
|
dispatchStatus: value("dispatchStatus"),
|
|
status: value("status"),
|
|
exitCode: value("exitCode"),
|
|
signal: value("signal"),
|
|
timedOut: value("timedOut"),
|
|
cwd: value("cwd"),
|
|
command: value("command"),
|
|
stdoutBytes: textBytes(value("stdout")),
|
|
stderrBytes: textBytes(value("stderr")),
|
|
stdoutTruncated: value("stdoutTruncated"),
|
|
stderrTruncated: value("stderrTruncated"),
|
|
durationMs: value("durationMs")
|
|
});
|
|
}
|
|
|
|
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.bootsharp") return ["workspace", "bootsharp", textOr(args.path, "."), ...hostOptionArgs(args, ["depth", "limit", "agentsLimit"])];
|
|
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.put") {
|
|
const path = textOr(args.path, "");
|
|
return path
|
|
? ["workspace", "put", path, ...hostOptionArgs(args, [
|
|
"contentB64",
|
|
"textB64",
|
|
"text",
|
|
"encoding",
|
|
"charset",
|
|
"createOnly",
|
|
"createDirs",
|
|
"updateOnly"
|
|
])]
|
|
: null;
|
|
}
|
|
if (intent === "workspace.rm") {
|
|
const path = textOr(args.path, "");
|
|
return path ? ["workspace", "rm", path, ...hostOptionArgs(args, ["missingOk"])] : null;
|
|
}
|
|
if (intent === "workspace.rmdir") {
|
|
const path = textOr(args.path, "");
|
|
return path ? ["workspace", "rmdir", path] : null;
|
|
}
|
|
if (intent === "workspace.keil") {
|
|
const action = textOr(args.action, "");
|
|
return action
|
|
? [
|
|
"workspace",
|
|
"keil",
|
|
action,
|
|
...optionalValue(args.path),
|
|
...hostOptionArgs(args, ["base", "group", "groupName", "target", "timeoutMs"])
|
|
]
|
|
: 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"),
|
|
...jobIdArgs(args),
|
|
...hostOptionArgs(args, ["target", "timeoutMs", "clean", "dryRun"]),
|
|
];
|
|
}
|
|
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"),
|
|
...jobIdArgs(args),
|
|
...hostOptionArgs(args, [
|
|
"target",
|
|
"timeoutMs",
|
|
"captureUart",
|
|
"captureDurationMs",
|
|
"durationMs",
|
|
"port",
|
|
"baudRate"
|
|
])
|
|
];
|
|
}
|
|
if (intent === "debug.reset") return ["debug-probe", "reset"];
|
|
if (intent === "workspace.evidence") {
|
|
return [
|
|
"workspace",
|
|
"evidence",
|
|
textOr(args.kind, "build"),
|
|
...jobIdArgs(args),
|
|
...hostOptionArgs(args, ["tail", "full", "path", "target"])
|
|
];
|
|
}
|
|
if (intent === "debug.evidence") {
|
|
return [
|
|
"debug-probe",
|
|
"evidence",
|
|
textOr(args.kind, "download"),
|
|
...jobIdArgs(args),
|
|
...hostOptionArgs(args, ["tail", "full", "path", "target"])
|
|
];
|
|
}
|
|
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, uartLaunchFlashOptionKeys)
|
|
];
|
|
}
|
|
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, "")
|
|
];
|
|
}
|
|
if (intent === "io.uart.jsonrpc") {
|
|
return [
|
|
"io-probe",
|
|
textOr(args.uartId, "uart/1"),
|
|
"jsonrpc",
|
|
...optionalValue(args.method),
|
|
...hostOptionArgs(args, uartJsonRpcOptionKeys)
|
|
];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const uartLaunchFlashOptionKeys = [
|
|
"durationMs",
|
|
"port",
|
|
"baudRate",
|
|
"flashBase",
|
|
"skipHardwareReset",
|
|
"connectMode",
|
|
"timeoutMs"
|
|
];
|
|
const uartJsonRpcOptionKeys = [
|
|
"id",
|
|
"request",
|
|
"requestB64",
|
|
"params",
|
|
"paramsB64",
|
|
"responseTimeoutMs",
|
|
"durationMs",
|
|
"retry",
|
|
"retries",
|
|
"retryDelayMs",
|
|
"lineEnding",
|
|
"noNewline",
|
|
"lineDelimited",
|
|
"discardBefore",
|
|
"requireResponse",
|
|
"requireJson",
|
|
"requireJsonrpc",
|
|
"requireJsonrpcResult",
|
|
"allowIdMismatch",
|
|
"expectResultField",
|
|
"port",
|
|
"baudRate"
|
|
];
|
|
|
|
function optionalValue(value) {
|
|
const result = textOr(value, "");
|
|
return result ? [result] : [];
|
|
}
|
|
|
|
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()}`)}`;
|
|
const values = Array.isArray(value) ? value : [value];
|
|
for (const item of values) {
|
|
if (item === undefined || item === null || item === "" || item === false) continue;
|
|
if (item === true) out.push(name);
|
|
else out.push(name, String(item));
|
|
}
|
|
}
|
|
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 configuredDevicePodIds(value, fallbackId) {
|
|
const ids = String(value ?? "")
|
|
.split(",")
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
const unique = [];
|
|
for (const id of [fallbackId, ...ids]) {
|
|
if (!unique.includes(id)) unique.push(id);
|
|
}
|
|
return unique;
|
|
}
|
|
|
|
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 pruneUndefined(value) {
|
|
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
|
|
}
|
|
|
|
function textBytes(value) {
|
|
return typeof value === "string" ? Buffer.byteLength(value, "utf8") : undefined;
|
|
}
|
|
|
|
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}`;
|
|
}
|