520 lines
23 KiB
TypeScript
520 lines
23 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
import { buildCaseRun, collectCaseRun, prepareCaseRun } from "../../tools/src/hwlab-caserun-lib.ts";
|
|
|
|
const CONTRACT_VERSION = "hwlab-caserun-web-v1";
|
|
const CASES_CONTRACT_VERSION = "hwlab-caserun-cases-v1";
|
|
const AGGREGATE_CONTRACT_VERSION = "hwlab-caserun-aggregate-v1";
|
|
const SOFTWARE_SMOKE_CONTRACT_VERSION = "hwlab-caserun-software-smoke-v1";
|
|
const SOFTWARE_SMOKE_MODE = "software-smoke";
|
|
const DEFAULT_BODY_LIMIT_BYTES = 64 * 1024;
|
|
const RUN_ID_PATTERN = /^[A-Za-z0-9_.:-]{3,180}$/u;
|
|
|
|
const memoryRuns = new Map<string, any>();
|
|
|
|
export async function handleCaseRunHttp(request: IncomingMessage, response: ServerResponse, url: URL, options: any = {}) {
|
|
const env = options.env ?? process.env;
|
|
const cwd = resolveCwd(env);
|
|
const stateRoot = resolveStateRoot(cwd, env);
|
|
const caseRepo = resolveCaseRepo(cwd, env);
|
|
|
|
if (request.method === "GET" && url.pathname === "/v1/caserun") {
|
|
sendJson(response, 200, {
|
|
ok: true,
|
|
contractVersion: CONTRACT_VERSION,
|
|
casesRoute: "/v1/caserun/cases",
|
|
runsRoute: "/v1/caserun/runs",
|
|
caseAuthority: describeCaseAuthority(caseRepo, cwd),
|
|
compileOnly: true
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (request.method === "GET" && url.pathname === "/v1/caserun/cases") {
|
|
const cases = await listCases(caseRepo);
|
|
sendJson(response, 200, {
|
|
ok: true,
|
|
contractVersion: CASES_CONTRACT_VERSION,
|
|
caseAuthority: describeCaseAuthority(caseRepo, cwd),
|
|
count: cases.length,
|
|
cases
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/caserun/runs") {
|
|
if (request.method !== "POST") return sendError(response, 405, "method_not_allowed", "POST required.");
|
|
const parsed = await readJsonBody(request, DEFAULT_BODY_LIMIT_BYTES);
|
|
if (!parsed.ok) return sendJson(response, 400, parsed.error);
|
|
const body = parsed.body && typeof parsed.body === "object" && !Array.isArray(parsed.body) ? parsed.body as Record<string, unknown> : {};
|
|
const caseId = safeOpaqueId(body.caseId);
|
|
if (!caseId) return sendError(response, 400, "invalid_case_id", "caseId is required.");
|
|
const caseDefinition = await loadCase(caseRepo, caseId).catch(() => null);
|
|
if (!caseDefinition) return sendError(response, 404, "case_not_found", "CaseRun case was not found.", { caseId });
|
|
const requestedRunId = safeOpaqueId(body.runId);
|
|
const runId = requestedRunId || nextRunId(caseId);
|
|
const runDir = path.join(stateRoot, "runs", runId);
|
|
const record = {
|
|
ok: true,
|
|
contractVersion: CONTRACT_VERSION,
|
|
runId,
|
|
caseId,
|
|
status: "queued",
|
|
stage: "queued",
|
|
runDir,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
caseAuthority: describeCaseAuthority(caseRepo, cwd),
|
|
runtimeApiUrl: runtimeApiUrlFromRequest(request, env),
|
|
events: [] as any[],
|
|
result: null as any
|
|
};
|
|
await saveRecord(stateRoot, record);
|
|
remember(record);
|
|
appendEvent(record, "queued", "queued", { caseId });
|
|
startBackgroundCaseRun(record, { ...options, env, cwd, stateRoot, caseRepo, caseDefinition });
|
|
sendJson(response, 202, acceptedRecord(record));
|
|
return;
|
|
}
|
|
|
|
const runMatch = url.pathname.match(/^\/v1\/caserun\/runs\/([^/]+)(?:\/(events|aggregate|cancel))?$/u);
|
|
if (runMatch) {
|
|
const runId = decodeURIComponent(runMatch[1] ?? "");
|
|
if (!RUN_ID_PATTERN.test(runId)) return sendError(response, 400, "invalid_run_id", "runId is invalid.");
|
|
const suffix = runMatch[2] ?? "";
|
|
const record = await findRecord(stateRoot, runId);
|
|
if (!record) return sendError(response, 404, "caserun_run_not_found", "CaseRun run was not found.", { runId });
|
|
if (!suffix && request.method === "GET") return sendJson(response, 200, await projectRunReadModel(record));
|
|
if (suffix === "events" && request.method === "GET") return sendJson(response, 200, {
|
|
ok: true,
|
|
contractVersion: CONTRACT_VERSION,
|
|
runId,
|
|
caseId: record.caseId,
|
|
status: record.status,
|
|
stage: record.stage,
|
|
terminal: terminalStatus(record.status),
|
|
sourceAuthority: sourceAuthorityForRecord(record),
|
|
events: record.events ?? []
|
|
});
|
|
if (suffix === "aggregate" && request.method === "GET") return sendJson(response, 200, await aggregateForRecord(record));
|
|
if (suffix === "cancel" && request.method === "POST") return sendError(response, 409, "caserun_cancel_not_supported", "This compile-only CaseRun worker cannot be cancelled after dispatch.", { runId, status: record.status });
|
|
return sendError(response, 405, "method_not_allowed", suffix === "cancel" ? "POST required." : "GET required.");
|
|
}
|
|
|
|
sendError(response, 404, "caserun_route_not_found", "CaseRun REST route is not implemented.", { route: url.pathname });
|
|
}
|
|
|
|
function startBackgroundCaseRun(record: any, input: any) {
|
|
void runBackgroundCaseRun(record, input).catch(async (error) => {
|
|
appendEvent(record, "failed", "failed", { code: error?.code ?? error?.name ?? "caserun_failed", message: String(error?.message ?? error).slice(0, 500), details: error?.details ?? null });
|
|
record.status = "failed";
|
|
record.stage = "failed";
|
|
record.error = { code: error?.code ?? error?.name ?? "caserun_failed", message: String(error?.message ?? error), details: error?.details ?? null };
|
|
record.blocker = typedBlocker(error?.blocker ?? error?.details?.blocker ?? error?.details ?? null);
|
|
record.updatedAt = new Date().toISOString();
|
|
await saveRecord(input.stateRoot, record).catch(() => undefined);
|
|
});
|
|
}
|
|
|
|
async function runBackgroundCaseRun(record: any, input: any) {
|
|
appendEvent(record, "running", "prepare", { caseId: record.caseId });
|
|
record.status = "running";
|
|
record.stage = "prepare";
|
|
record.updatedAt = new Date().toISOString();
|
|
await saveRecord(input.stateRoot, record);
|
|
const context = caseContextForRecord(record, input);
|
|
const emit = async (stage: string, payload: Record<string, unknown> = {}) => {
|
|
appendEvent(record, "running", stage, payload);
|
|
record.stage = stage;
|
|
record.updatedAt = new Date().toISOString();
|
|
await saveRecord(input.stateRoot, record);
|
|
};
|
|
const executor = typeof input.caseRunExecutor === "function" ? input.caseRunExecutor : defaultCaseRunExecutor;
|
|
const result = await executor(context, emit, { record, caseDefinition: input.caseDefinition });
|
|
record.status = "completed";
|
|
record.stage = "completed";
|
|
record.result = compactResult(result);
|
|
record.updatedAt = new Date().toISOString();
|
|
appendEvent(record, "completed", "completed", { jobId: record.result?.summary?.jobId ?? record.result?.evidence?.keilJob?.jobId ?? null });
|
|
await saveRecord(input.stateRoot, record);
|
|
}
|
|
|
|
async function defaultCaseRunExecutor(context: any, emit: (stage: string, payload?: Record<string, unknown>) => Promise<void>, input: any = {}) {
|
|
if (input.caseDefinition?.mode === SOFTWARE_SMOKE_MODE) return runSoftwareSmokeCase(context, emit, input.caseDefinition);
|
|
const prepared = await prepareCaseRun(context, "web");
|
|
await emit("prepared", { runDir: prepared.runDir, specPath: prepared.specPath });
|
|
const run = prepared.run;
|
|
const build = await buildCaseRun(context, run);
|
|
await emit("build-completed", { jobId: build.summary?.jobId ?? build.evidence?.keilJob?.jobId ?? null, hwpodExitCode: build.summary?.hwpodExitCode ?? null });
|
|
const collected = await collectCaseRun(context, run, build.evidence);
|
|
await emit("collect-completed", { status: collected.status, artifactCount: Array.isArray(build.evidence?.artifacts) ? build.evidence.artifacts.length : 0 });
|
|
return { prepared, build, collected, evidence: build.evidence, summary: build.summary, run: collected.run ?? build.run ?? run };
|
|
}
|
|
|
|
async function runSoftwareSmokeCase(context: any, emit: (stage: string, payload?: Record<string, unknown>) => Promise<void>, caseDefinition: any) {
|
|
const runDir = path.resolve(context.parsed.runDir);
|
|
await mkdir(runDir, { recursive: true });
|
|
await emit("software-smoke-running", { mode: SOFTWARE_SMOKE_MODE, hardwareRequired: false });
|
|
const artifact = {
|
|
contractVersion: SOFTWARE_SMOKE_CONTRACT_VERSION,
|
|
caseId: context.parsed.caseId,
|
|
runId: context.parsed.runId,
|
|
mode: SOFTWARE_SMOKE_MODE,
|
|
status: "completed",
|
|
hardwareRequired: false,
|
|
checks: {
|
|
caseDefinitionLoaded: caseDefinition.available === true,
|
|
runDirectoryWritable: true,
|
|
backgroundWorkerRunning: true
|
|
}
|
|
};
|
|
const artifactPath = path.join(runDir, "software-smoke.json");
|
|
const artifactText = `${JSON.stringify(artifact, null, 2)}\n`;
|
|
await writeFile(artifactPath, artifactText, "utf8");
|
|
const artifacts = [{ path: "software-smoke.json", bytes: Buffer.byteLength(artifactText), sha256: createHash("sha256").update(artifactText).digest("hex") }];
|
|
const evidence = { status: "recorded", mode: SOFTWARE_SMOKE_MODE, hardwareRequired: false, checks: artifact.checks, artifacts };
|
|
const summary = { status: "completed", mode: SOFTWARE_SMOKE_MODE, hardwareRequired: false, artifactCount: artifacts.length, artifacts };
|
|
const run = { runId: context.parsed.runId, caseId: context.parsed.caseId, mode: SOFTWARE_SMOKE_MODE, hardwareRequired: false };
|
|
await emit("software-smoke-completed", { artifactCount: artifacts.length, artifactSha256: artifacts[0].sha256 });
|
|
return { summary, evidence, run };
|
|
}
|
|
|
|
function caseContextForRecord(record: any, input: any) {
|
|
const internalApiUrl = internalRuntimeApiUrlForCaseRun(record, input.env ?? {});
|
|
const parsed = {
|
|
_: [],
|
|
caseId: record.caseId,
|
|
runId: record.runId,
|
|
runDir: record.runDir,
|
|
caseRepo: input.caseRepo,
|
|
apiUrl: internalApiUrl,
|
|
noCaseRepoRecord: true,
|
|
stateDir: path.join(input.stateRoot, "cli")
|
|
};
|
|
return {
|
|
parsed,
|
|
env: {
|
|
...process.env,
|
|
...input.env,
|
|
HWLAB_CASE_REPO: input.caseRepo,
|
|
HWLAB_RUNTIME_API_URL: internalApiUrl,
|
|
HWLAB_CASERUN_PUBLIC_RUNTIME_API_URL: record.runtimeApiUrl,
|
|
HWLAB_RUNTIME_ENDPOINT_LOCKED: "1"
|
|
},
|
|
fetchImpl: input.fetchImpl ?? globalThis.fetch,
|
|
cwd: input.cwd,
|
|
now: () => new Date().toISOString(),
|
|
sleep: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
runProcess: input.caseRunProcess,
|
|
startProcess: input.caseRunStartProcess,
|
|
argv: [],
|
|
rest: ["web", record.caseId]
|
|
};
|
|
}
|
|
|
|
function resolveCwd(env: Record<string, string | undefined>) {
|
|
return path.resolve(String(env.HWLAB_CASERUN_REPO_ROOT ?? process.cwd()));
|
|
}
|
|
|
|
function resolveStateRoot(cwd: string, env: Record<string, string | undefined>) {
|
|
return path.resolve(cwd, String(env.HWLAB_CASERUN_STATE_DIR ?? path.join(".state", "hwlab-cloud-api", "caserun")));
|
|
}
|
|
|
|
function resolveCaseRepo(cwd: string, env: Record<string, string | undefined>) {
|
|
return path.resolve(cwd, String(env.HWLAB_CASERUN_CASE_REPO ?? env.HWLAB_CASE_REPO ?? "."));
|
|
}
|
|
|
|
function describeCaseAuthority(caseRepo: string, cwd: string) {
|
|
const rel = relativeOrAbsolute(cwd, caseRepo);
|
|
return {
|
|
kind: rel === "." ? "builtin-hwlab-v03" : "configured-case-repo",
|
|
repoPath: rel,
|
|
casesPath: path.join(rel, "cases")
|
|
};
|
|
}
|
|
|
|
async function listCases(caseRepo: string) {
|
|
const casesRoot = path.join(caseRepo, "cases");
|
|
const entries = await readdir(casesRoot, { withFileTypes: true }).catch(() => []);
|
|
const cases = [] as any[];
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
const loaded = await loadCase(caseRepo, entry.name).catch(() => null);
|
|
if (loaded) cases.push(loaded);
|
|
}
|
|
cases.sort((left, right) => String(left.caseId).localeCompare(String(right.caseId)));
|
|
return cases;
|
|
}
|
|
|
|
async function loadCase(caseRepo: string, caseId: string) {
|
|
if (!safeOpaqueId(caseId)) throw new Error("invalid case id");
|
|
const caseDir = path.join(caseRepo, "cases", caseId);
|
|
const caseFile = path.join(caseDir, "case.json");
|
|
const definition = JSON.parse(await readFile(caseFile, "utf8"));
|
|
const mode = String(definition.mode ?? "compile-only").trim() || "compile-only";
|
|
const hardwareRequired = mode !== SOFTWARE_SMOKE_MODE;
|
|
const hwpodSpec = hardwareRequired ? String(definition.hwpodSpec ?? definition.specPath ?? "hwpod-spec.yaml").trim() || "hwpod-spec.yaml" : null;
|
|
const specPath = hwpodSpec ? path.resolve(caseDir, hwpodSpec) : null;
|
|
const specInfo = specPath ? await stat(specPath).catch(() => null) : null;
|
|
return {
|
|
caseId: String(definition.caseId ?? caseId).trim() || caseId,
|
|
title: String(definition.title ?? caseId).trim() || caseId,
|
|
mode,
|
|
hwpodSpec,
|
|
hardwareRequired,
|
|
available: hardwareRequired ? Boolean(specInfo?.isFile()) : true,
|
|
subject: definition.subject ?? null,
|
|
expected: definition.expected ?? null,
|
|
runtime: definition.runtime ?? null
|
|
};
|
|
}
|
|
|
|
async function findRecord(stateRoot: string, runId: string) {
|
|
const memory = memoryRuns.get(runId);
|
|
if (memory) return memory;
|
|
const text = await readFile(recordPath(stateRoot, runId), "utf8").catch(() => "");
|
|
if (!text) return null;
|
|
const record = JSON.parse(text);
|
|
remember(record);
|
|
return record;
|
|
}
|
|
|
|
async function saveRecord(stateRoot: string, record: any) {
|
|
await mkdir(path.dirname(recordPath(stateRoot, record.runId)), { recursive: true });
|
|
await mkdir(record.runDir, { recursive: true });
|
|
await writeFile(recordPath(stateRoot, record.runId), `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
function recordPath(stateRoot: string, runId: string) {
|
|
return path.join(stateRoot, "records", `${runId}.json`);
|
|
}
|
|
|
|
function remember(record: any) {
|
|
memoryRuns.set(record.runId, record);
|
|
}
|
|
|
|
function appendEvent(record: any, status: string, stage: string, payload: Record<string, unknown> = {}) {
|
|
const event = { at: new Date().toISOString(), status, stage, payload };
|
|
record.events = Array.isArray(record.events) ? [...record.events, event] : [event];
|
|
record.updatedAt = event.at;
|
|
return event;
|
|
}
|
|
|
|
async function aggregateForRecord(record: any) {
|
|
const evidence = record.result?.evidence ?? await readJson(path.join(record.runDir, "evidence.json")).catch(() => null);
|
|
const run = record.result?.run ?? await readJson(path.join(record.runDir, "run.json")).catch(() => null);
|
|
const summary = record.result?.summary ?? summarizeEvidence(evidence);
|
|
const source = { runId: record.runId, caseId: record.caseId, status: record.status, summary, evidence, run };
|
|
return {
|
|
ok: true,
|
|
contractVersion: AGGREGATE_CONTRACT_VERSION,
|
|
runId: record.runId,
|
|
caseId: record.caseId,
|
|
status: record.status,
|
|
stage: record.stage,
|
|
terminal: terminalStatus(record.status),
|
|
sourceAuthority: sourceAuthorityForRecord(record),
|
|
sha256: sha256Json(source),
|
|
summary,
|
|
evidence,
|
|
run
|
|
};
|
|
}
|
|
|
|
function compactResult(result: any) {
|
|
return {
|
|
summary: result?.summary ?? null,
|
|
evidence: result?.evidence ?? null,
|
|
run: result?.run ?? result?.collected?.run ?? result?.build?.run ?? result?.prepared?.run ?? null,
|
|
collected: result?.collected ? { status: result.collected.status, summary: result.collected.summary ?? null } : null
|
|
};
|
|
}
|
|
|
|
function summarizeEvidence(evidence: any) {
|
|
if (!evidence || typeof evidence !== "object") return null;
|
|
return {
|
|
status: evidence.status ?? null,
|
|
jobId: evidence.keilJob?.jobId ?? null,
|
|
keilStatus: evidence.keilJob?.status ?? null,
|
|
hwpodExitCode: evidence.hwpod?.exitCode ?? null,
|
|
artifactCount: Array.isArray(evidence.artifacts) ? evidence.artifacts.length : 0,
|
|
artifacts: evidence.artifacts ?? []
|
|
};
|
|
}
|
|
|
|
async function projectRunReadModel(record: any) {
|
|
const aggregate = await aggregateForRecord(record);
|
|
const summary = aggregate.summary && typeof aggregate.summary === "object" ? aggregate.summary : {};
|
|
const evidence = aggregate.evidence && typeof aggregate.evidence === "object" ? aggregate.evidence : {};
|
|
const archivedRun = aggregate.run && typeof aggregate.run === "object" ? aggregate.run : {};
|
|
const blocker = typedBlocker(record.blocker ?? archivedRun.blocker ?? evidence.validation?.blocker ?? evidence.hwpod?.buildWaitBlocker ?? evidence.hwpod?.operation?.blocker ?? record.error?.details?.blocker ?? null);
|
|
const manifestPath = firstText(summary.artifactManifestPath, evidence.artifactManifest?.path, archivedRun.artifactManifestPath);
|
|
const manifestSha256 = firstSha256(summary.artifactManifestSha256, evidence.artifactManifest?.sha256, archivedRun.artifactManifestSha256);
|
|
const replay = evidence.replay && typeof evidence.replay === "object" ? evidence.replay : null;
|
|
const replayPath = firstText(evidence.validation?.archivePath, archivedRun.replayPath);
|
|
const trace = firstObject(summary.trace, summary.traceLookup, evidence.agentTrace, evidence.traceLookup, archivedRun.agentTrace);
|
|
const hwpod = firstObject(evidence.hwpod?.operation?.rawArtifactRef, evidence.hwpod?.operation, evidence.keilJob, archivedRun.hwpod);
|
|
return {
|
|
ok: true,
|
|
contractVersion: CONTRACT_VERSION,
|
|
runId: record.runId,
|
|
caseId: record.caseId,
|
|
status: record.status,
|
|
stage: record.stage,
|
|
terminal: terminalStatus(record.status),
|
|
blocker,
|
|
createdAt: record.createdAt,
|
|
updatedAt: record.updatedAt,
|
|
facts: {
|
|
jobId: firstText(summary.jobId, evidence.keilJob?.jobId, evidence.hwpod?.operation?.jobId) || null,
|
|
artifactCount: Number(summary.artifactCount ?? (Array.isArray(evidence.artifacts) ? evidence.artifacts.length : 0)),
|
|
evidenceStatus: firstText(evidence.status, summary.status) || null
|
|
},
|
|
references: {
|
|
status: { href: `/v1/caserun/runs/${encodeURIComponent(record.runId)}` },
|
|
events: { href: `/v1/caserun/runs/${encodeURIComponent(record.runId)}/events`, count: Array.isArray(record.events) ? record.events.length : 0 },
|
|
aggregate: { href: `/v1/caserun/runs/${encodeURIComponent(record.runId)}/aggregate`, sha256: aggregate.sha256 },
|
|
manifest: manifestPath ? { ref: manifestPath, sha256: manifestSha256 } : null,
|
|
replay: replay || replayPath ? { ref: replayPath || null, relationship: replay } : null,
|
|
trace,
|
|
hwpod
|
|
},
|
|
sourceAuthority: sourceAuthorityForRecord(record),
|
|
error: record.error ? { code: record.error.code ?? "caserun_failed", message: record.error.message ?? "CaseRun failed." } : null
|
|
};
|
|
}
|
|
|
|
function acceptedRecord(record: any) {
|
|
return {
|
|
ok: true,
|
|
contractVersion: CONTRACT_VERSION,
|
|
runId: record.runId,
|
|
caseId: record.caseId,
|
|
status: record.status,
|
|
statusUrl: `/v1/caserun/runs/${encodeURIComponent(record.runId)}`
|
|
};
|
|
}
|
|
|
|
function sourceAuthorityForRecord(record: any) {
|
|
return {
|
|
projector: "hwlab-cloud-api",
|
|
authority: "caserun-archived-facts",
|
|
runRecord: "cloud-api-caserun-record",
|
|
archive: "caserun-run-directory",
|
|
execution: "hwpod-agentrun-caserun",
|
|
case: record.caseAuthority ?? null
|
|
};
|
|
}
|
|
|
|
function terminalStatus(status: unknown) {
|
|
return ["completed", "failed", "cancelled", "canceled", "timed_out", "timeout"].includes(String(status ?? "").toLowerCase());
|
|
}
|
|
|
|
function typedBlocker(value: any) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
const code = firstText(value.code);
|
|
if (!code) return null;
|
|
return {
|
|
code,
|
|
summary: firstText(value.summary, value.message) || code,
|
|
layer: firstText(value.layer) || null,
|
|
details: value.details && typeof value.details === "object" ? value.details : null
|
|
};
|
|
}
|
|
|
|
function firstObject(...values: any[]) {
|
|
return values.find((value) => value && typeof value === "object" && !Array.isArray(value)) ?? null;
|
|
}
|
|
|
|
function firstText(...values: unknown[]) {
|
|
return values.map((value) => String(value ?? "").trim()).find(Boolean) ?? "";
|
|
}
|
|
|
|
function firstSha256(...values: unknown[]) {
|
|
return values.map((value) => String(value ?? "").trim().toLowerCase()).find((value) => /^[a-f0-9]{64}$/u.test(value)) ?? null;
|
|
}
|
|
|
|
function runtimeApiUrlFromRequest(request: IncomingMessage, env: Record<string, string | undefined>) {
|
|
const explicit = String(env.HWLAB_CASERUN_RUNTIME_API_URL ?? "").trim();
|
|
if (explicit) return explicit.replace(/\/+$/u, "");
|
|
const host = String(request.headers["x-forwarded-host"] ?? request.headers.host ?? "127.0.0.1:8080").split(",")[0]?.trim() || "127.0.0.1:8080";
|
|
const proto = String(request.headers["x-forwarded-proto"] ?? "http").split(",")[0]?.trim() || "http";
|
|
return `${proto}://${host}`.replace(/\/+$/u, "");
|
|
}
|
|
|
|
function internalRuntimeApiUrlForCaseRun(record: any, env: Record<string, string | undefined>) {
|
|
const explicit = String(env.HWLAB_CASERUN_INTERNAL_API_URL ?? env.HWLAB_RUNTIME_INTERNAL_API_URL ?? "").trim();
|
|
if (explicit) return explicit.replace(/\/+$/u, "");
|
|
const namespace = String(env.HWLAB_NAMESPACE ?? env.POD_NAMESPACE ?? env.KUBERNETES_NAMESPACE ?? namespaceFromEnvironment(env.HWLAB_ENVIRONMENT) ?? "").trim();
|
|
const port = String(env.HWLAB_CLOUD_API_PORT ?? env.HWLAB_PORT ?? env.PORT ?? "6667").trim() || "6667";
|
|
if (namespace) return `http://hwlab-cloud-api.${namespace}.svc.cluster.local:${port}`;
|
|
return String(record.runtimeApiUrl ?? "").replace(/\/+$/u, "");
|
|
}
|
|
|
|
function namespaceFromEnvironment(value: string | undefined) {
|
|
const text = String(value ?? "").trim();
|
|
if (!text) return "";
|
|
if (/^hwlab-[a-z0-9-]+$/u.test(text)) return text;
|
|
if (/^[a-z0-9-]+$/u.test(text)) return `hwlab-${text}`;
|
|
return "";
|
|
}
|
|
|
|
async function readJsonBody(request: IncomingMessage, limitBytes: number) {
|
|
let size = 0;
|
|
const chunks = [] as Buffer[];
|
|
for await (const chunk of request) {
|
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
size += buffer.length;
|
|
if (size > limitBytes) return { ok: false, error: { ok: false, error: { code: "body_too_large", message: `body exceeds ${limitBytes} bytes` } } };
|
|
chunks.push(buffer);
|
|
}
|
|
const body = Buffer.concat(chunks).toString("utf8").trim();
|
|
if (!body) return { ok: true, body: {} };
|
|
try {
|
|
return { ok: true, body: JSON.parse(body) };
|
|
} catch (error) {
|
|
return { ok: false, error: { ok: false, error: { code: "invalid_json", message: error instanceof Error ? error.message : String(error) } } };
|
|
}
|
|
}
|
|
|
|
async function readJson(file: string) {
|
|
return JSON.parse(await readFile(file, "utf8"));
|
|
}
|
|
|
|
function sendError(response: ServerResponse, statusCode: number, code: string, message: string, details: Record<string, unknown> = {}) {
|
|
return sendJson(response, statusCode, { ok: false, error: { code, message, details } });
|
|
}
|
|
|
|
function sendJson(response: ServerResponse, statusCode: number, body: unknown) {
|
|
if (response.headersSent || response.writableEnded) return false;
|
|
response.writeHead(statusCode, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
response.end(`${JSON.stringify(body)}\n`);
|
|
return true;
|
|
}
|
|
|
|
function safeOpaqueId(value: unknown) {
|
|
const text = String(value ?? "").trim();
|
|
return RUN_ID_PATTERN.test(text) ? text : "";
|
|
}
|
|
|
|
function nextRunId(caseId: string) {
|
|
const stamp = new Date().toISOString().replace(/[^0-9]/gu, "").slice(0, 14);
|
|
return `web-${slug(caseId)}-${stamp}-${randomUUID().slice(0, 8)}`;
|
|
}
|
|
|
|
function slug(value: string) {
|
|
return String(value).trim().toLowerCase().replace(/[^a-z0-9_.:-]+/gu, "-").replace(/^-+|-+$/gu, "") || "caserun";
|
|
}
|
|
|
|
function sha256Json(value: unknown) {
|
|
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
}
|
|
|
|
function relativeOrAbsolute(cwd: string, target: string) {
|
|
const relative = path.relative(cwd, target) || ".";
|
|
return relative.startsWith("..") ? target : relative;
|
|
}
|