Files

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 }); }