168 lines
8.3 KiB
TypeScript
168 lines
8.3 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { Buffer } from "node:buffer";
|
|
import { mkdir, readFile, readdir, stat } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
import type { CaseRunListItem, CaseRunRecord, CaseRunStatus, HarnessRLRegistry, HarnessRLTemporalGateway } from "./contracts.ts";
|
|
|
|
const RUN_ID_PATTERN = /^[A-Za-z0-9_.:-]{3,180}$/u;
|
|
const CASE_RUN_STATUSES = new Set<CaseRunStatus>(["queued", "running", "completed", "failed", "blocked", "cancel_requested", "canceled"]);
|
|
const DEFAULT_RUN_LIST_LIMIT = 20;
|
|
const MAX_RUN_LIST_LIMIT = 100;
|
|
|
|
export function createHarnessRLService(options: { registry: HarnessRLRegistry; temporal: HarnessRLTemporalGateway; cwd: string; caseRepo: string; stateRoot: string }) {
|
|
const { registry, temporal } = options;
|
|
return {
|
|
async health() { return { ok: true, registry: await registry.health() }; },
|
|
async listCases() {
|
|
const root = path.join(options.caseRepo, "cases");
|
|
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
|
|
const cases = [];
|
|
for (const entry of entries) if (entry.isDirectory()) {
|
|
const item = await loadCase(options.caseRepo, entry.name).catch(() => null);
|
|
if (item) cases.push(item);
|
|
}
|
|
return cases.sort((left, right) => left.caseId.localeCompare(right.caseId));
|
|
},
|
|
async listRuns(input: { status?: string; limit?: string; cursor?: string } = {}) {
|
|
const status = parseStatus(input.status);
|
|
const limit = parseLimit(input.limit);
|
|
const records = await registry.listRuns({ status, limit: limit + 1, before: decodeCursor(input.cursor) });
|
|
const hasMore = records.length > limit;
|
|
const page = hasMore ? records.slice(0, limit) : records;
|
|
const last = page.at(-1);
|
|
return {
|
|
ok: true,
|
|
contractVersion: "hwlab-caserun-runs-v1",
|
|
count: page.length,
|
|
runs: page.map(runListItem),
|
|
nextCursor: hasMore && last ? encodeCursor(last) : null
|
|
};
|
|
},
|
|
async submit(input: { caseId: string; runId?: string; runtimeApiUrl: string }) {
|
|
const caseId = opaqueId(input.caseId, "invalid_case_id");
|
|
const definition = await loadCase(options.caseRepo, caseId).catch(() => null);
|
|
if (!definition) throw codedError("case_not_found", "CaseRun case was not found", { caseId });
|
|
const runId = input.runId ? opaqueId(input.runId, "invalid_run_id") : `${caseId}-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
const workflowId = `harnessrl-caserun-${runId}`;
|
|
const runDir = path.join(options.stateRoot, "runs", runId);
|
|
await mkdir(runDir, { recursive: true });
|
|
const record = await registry.createRun({ runId, caseId, workflowId, caseRepo: options.caseRepo, runDir, runtimeApiUrl: input.runtimeApiUrl });
|
|
if (!record.workflowRunId) {
|
|
await registry.appendEvent(runId, "queued", "queued", { caseId });
|
|
const started = await temporal.start({ workflowId, runId });
|
|
await registry.updateRun(runId, { workflowRunId: started.workflowRunId });
|
|
if (started.reused) await registry.appendEvent(runId, "queued", "workflow-reused", { workflowId, workflowRunId: started.workflowRunId });
|
|
}
|
|
return await registry.getRun(runId);
|
|
},
|
|
async getRun(runId: string) {
|
|
const record = await registry.getRun(opaqueId(runId, "invalid_run_id"));
|
|
if (!record) throw codedError("caserun_run_not_found", "CaseRun run was not found", { runId });
|
|
return record;
|
|
},
|
|
async events(runId: string) {
|
|
const record = await this.getRun(runId);
|
|
return { ...record, events: await registry.listEvents(record.runId) };
|
|
},
|
|
async aggregate(runId: string) {
|
|
const record = await this.getRun(runId);
|
|
return { ok: true, contractVersion: "hwlab-caserun-aggregate-v1", runId: record.runId, caseId: record.caseId,
|
|
status: record.status, stage: record.stage, terminal: record.terminal, result: record.result,
|
|
sha256: record.result?.artifactManifestSha256 ?? null };
|
|
},
|
|
async cancel(runId: string) {
|
|
const record = await this.getRun(runId);
|
|
if (record.terminal) return record;
|
|
await registry.updateRun(runId, { status: "cancel_requested", stage: "cancel_requested" });
|
|
await registry.appendEvent(runId, "cancel_requested", "cancel_requested", {});
|
|
await temporal.requestCancel(record.workflowId);
|
|
return await registry.getRun(runId);
|
|
},
|
|
async close() { await temporal.close(); await registry.close(); }
|
|
};
|
|
}
|
|
|
|
function runListItem(record: CaseRunRecord): CaseRunListItem {
|
|
const summary = objectValue(record.result?.summary);
|
|
const evidence = objectValue(record.result?.evidence);
|
|
const artifacts = Array.isArray(summary?.artifacts) ? summary.artifacts : Array.isArray(evidence?.artifacts) ? evidence.artifacts : [];
|
|
return {
|
|
runId: record.runId,
|
|
caseId: record.caseId,
|
|
workflowId: record.workflowId,
|
|
workflowRunId: record.workflowRunId,
|
|
status: record.status,
|
|
stage: record.stage,
|
|
terminal: record.terminal,
|
|
createdAt: record.createdAt,
|
|
updatedAt: record.updatedAt,
|
|
artifactCount: finiteNumber(summary?.artifactCount) ?? artifacts.length,
|
|
evidenceStatus: stringValue(evidence?.status),
|
|
artifactManifestSha256: stringValue(record.result?.artifactManifestSha256)
|
|
};
|
|
}
|
|
|
|
function parseStatus(value?: string): CaseRunStatus | undefined {
|
|
if (value === undefined || value === "") return undefined;
|
|
if (!CASE_RUN_STATUSES.has(value as CaseRunStatus)) throw codedError("invalid_status", "status is invalid", { status: value });
|
|
return value as CaseRunStatus;
|
|
}
|
|
|
|
function parseLimit(value?: string): number {
|
|
if (value === undefined || value === "") return DEFAULT_RUN_LIST_LIMIT;
|
|
const limit = Number(value);
|
|
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_RUN_LIST_LIMIT) {
|
|
throw codedError("invalid_limit", `limit must be an integer between 1 and ${MAX_RUN_LIST_LIMIT}`);
|
|
}
|
|
return limit;
|
|
}
|
|
|
|
function encodeCursor(record: Pick<CaseRunRecord, "createdAt" | "runId">): string {
|
|
return Buffer.from(JSON.stringify({ createdAt: record.createdAt, runId: record.runId }), "utf8").toString("base64url");
|
|
}
|
|
|
|
function decodeCursor(value?: string): { createdAt: string; runId: string } | undefined {
|
|
if (value === undefined || value === "") return undefined;
|
|
try {
|
|
const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
|
|
const createdAt = String(parsed?.createdAt ?? "");
|
|
const runId = opaqueId(parsed?.runId, "invalid_cursor");
|
|
if (!createdAt || Number.isNaN(Date.parse(createdAt))) throw new Error("createdAt is invalid");
|
|
return { createdAt: new Date(createdAt).toISOString(), runId };
|
|
} catch {
|
|
throw codedError("invalid_cursor", "cursor is invalid");
|
|
}
|
|
}
|
|
|
|
function objectValue(value: unknown): Record<string, any> | null {
|
|
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, any> : null;
|
|
}
|
|
|
|
function stringValue(value: unknown): string | null {
|
|
return typeof value === "string" && value ? value : null;
|
|
}
|
|
|
|
function finiteNumber(value: unknown): number | null {
|
|
const number = Number(value);
|
|
return Number.isFinite(number) && number >= 0 ? number : null;
|
|
}
|
|
|
|
async function loadCase(caseRepo: string, caseId: string) {
|
|
const caseDir = path.join(caseRepo, "cases", caseId);
|
|
const definition = JSON.parse(await readFile(path.join(caseDir, "case.json"), "utf8"));
|
|
const mode = String(definition.mode ?? "compile-only").trim() || "compile-only";
|
|
const hardwareRequired = mode !== "software-smoke";
|
|
const hwpodSpec = hardwareRequired ? String(definition.hwpodSpec ?? definition.specPath ?? "hwpod-spec.yaml").trim() || "hwpod-spec.yaml" : null;
|
|
const available = hwpodSpec ? Boolean((await stat(path.resolve(caseDir, hwpodSpec)).catch(() => null))?.isFile()) : true;
|
|
return { caseId: String(definition.caseId ?? caseId), title: String(definition.title ?? caseId), mode, hwpodSpec, hardwareRequired, available,
|
|
subject: definition.subject ?? null, expected: definition.expected ?? null, runtime: definition.runtime ?? null };
|
|
}
|
|
|
|
function opaqueId(value: unknown, code: string) {
|
|
const text = String(value ?? "").trim();
|
|
if (!RUN_ID_PATTERN.test(text)) throw codedError(code, `${code === "invalid_case_id" ? "caseId" : "runId"} is invalid`);
|
|
return text;
|
|
}
|
|
function codedError(code: string, message: string, details?: unknown) { return Object.assign(new Error(message), { code, details }); }
|