Merge remote-tracking branch 'origin/v0.3' into feat/2625-workbench-temporal

# Conflicts:
#	web/hwlab-cloud-web/vite.config.ts
This commit is contained in:
root
2026-07-17 08:07:20 +02:00
23 changed files with 1058 additions and 189 deletions
+4
View File
@@ -27,6 +27,10 @@ core.*
*.out
vendor/
# Python
__pycache__/
*.pyc
# Node package managers, for future web work
node_modules/
node_modules
+3 -6
View File
@@ -1,3 +1,5 @@
import { caseRunAccepted } from "./transport.ts";
export function createHarnessRLHttpApp(options: { service: ReturnType<typeof import("./service.ts")["createHarnessRLService"]> }) {
return {
async fetch(request: Request) {
@@ -13,7 +15,7 @@ export function createHarnessRLHttpApp(options: { service: ReturnType<typeof imp
if (url.pathname === "/v1/caserun/runs" && request.method === "POST") {
const body = await bodyObject(request);
const record = await options.service.submit({ caseId: String(body.caseId ?? ""), runId: body.runId ? String(body.runId) : undefined, runtimeApiUrl: runtimeApiUrl(request) });
return json(202, accepted(record));
return json(202, caseRunAccepted(record));
}
const match = /^\/v1\/caserun\/runs\/([^/]+)(?:\/(events|aggregate|cancel))?$/u.exec(url.pathname);
if (match) {
@@ -36,11 +38,6 @@ export function createHarnessRLHttpApp(options: { service: ReturnType<typeof imp
};
}
function accepted(record: any) {
return { ok: true, contractVersion: "hwlab-caserun-web-v1", runId: record.runId, caseId: record.caseId, status: record.status, stage: record.stage,
statusUrl: `/v1/caserun/runs/${encodeURIComponent(record.runId)}`, eventsUrl: `/v1/caserun/runs/${encodeURIComponent(record.runId)}/events`,
aggregateUrl: `/v1/caserun/runs/${encodeURIComponent(record.runId)}/aggregate`, cancelUrl: `/v1/caserun/runs/${encodeURIComponent(record.runId)}/cancel` };
}
async function bodyObject(request: Request) { const body = await request.json().catch(() => null); if (!body || typeof body !== "object" || Array.isArray(body)) throw Object.assign(new Error("JSON object body is required"), { code: "invalid_json" }); return body as Record<string, unknown>; }
function runtimeApiUrl(request: Request) { const url = new URL(request.url); return `${url.protocol}//${url.host}`; }
function errorBody(code: string, message: string, details?: unknown) { return { ok: false, error: { code, message, details } }; }
+154
View File
@@ -0,0 +1,154 @@
import type { CaseRunRecord } from "./contracts.ts";
export type CaseRunCommand =
| { kind: "start"; caseId: string; runId?: string }
| { kind: "status"; runId: string }
| { kind: "events"; runId: string }
| { kind: "aggregate"; runId: string }
| { kind: "cancel"; runId: string };
export type HarnessRLService = {
submit(input: { caseId: string; runId?: string; runtimeApiUrl: string }): Promise<CaseRunRecord | null>;
getRun(runId: string): Promise<CaseRunRecord>;
events(runId: string): Promise<Record<string, unknown>>;
aggregate(runId: string): Promise<Record<string, unknown>>;
cancel(runId: string): Promise<CaseRunRecord | null>;
};
export type CaseRunTransport = {
mode: "local" | "api";
visibility(command: CaseRunCommand): Record<string, unknown>;
execute(command: CaseRunCommand): Promise<Record<string, unknown>>;
close(): Promise<void>;
};
const DEFAULT_API_TIMEOUT_MS = 30000;
export class CaseRunTransportError extends Error {
code: string;
details?: unknown;
constructor(code: string, message: string, details?: unknown) {
super(message);
this.name = "CaseRunTransportError";
this.code = code;
this.details = details;
}
}
export function createLocalCaseRunTransport(options: { service: HarnessRLService; close?: () => Promise<void> }): CaseRunTransport {
return {
mode: "local",
visibility() {
return { service: "native-harnessrl-application-service", baseUrl: null, route: null, method: null };
},
async execute(command) {
try {
if (command.kind === "start") return caseRunAccepted(await options.service.submit({ caseId: command.caseId, runId: command.runId, runtimeApiUrl: "local://harnessrl" }));
if (command.kind === "status") return options.service.getRun(command.runId);
if (command.kind === "events") return options.service.events(command.runId);
if (command.kind === "aggregate") return options.service.aggregate(command.runId);
return acceptedRecord(await options.service.cancel(command.runId));
} catch (error) {
throw typedError(error, "harnessrl_error");
}
},
close: options.close ?? (async () => {})
};
}
export function createApiCaseRunTransport(options: { baseUrl: string; fetchImpl?: typeof fetch; apiKey?: string; timeoutMs?: number }): CaseRunTransport {
const baseUrl = apiOrigin(options.baseUrl);
const fetchImpl = options.fetchImpl ?? fetch;
const timeoutMs = apiTimeoutMs(options.timeoutMs);
return {
mode: "api",
visibility(command) {
return apiRequestVisibility(baseUrl, command);
},
async execute(command) {
const request = apiRequestVisibility(baseUrl, command);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(`${baseUrl}${request.route}`, {
method: request.method,
signal: controller.signal,
headers: {
...(command.kind === "start" ? { "content-type": "application/json" } : {}),
...(options.apiKey ? { authorization: `Bearer ${options.apiKey}` } : {})
},
body: command.kind === "start" ? JSON.stringify({ caseId: command.caseId, ...(command.runId ? { runId: command.runId } : {}) }) : undefined
});
const body = await response.json().catch((error) => {
if (controller.signal.aborted) throw error;
return null;
}) as any;
if (!response.ok || !body || typeof body !== "object") {
throw new CaseRunTransportError(body?.error?.code ?? "caserun_api_error", body?.error?.message ?? `CaseRun API returned HTTP ${response.status}`, body?.error?.details ?? { status: response.status });
}
return body;
} catch (error) {
if (controller.signal.aborted) {
throw new CaseRunTransportError("caserun_api_timeout", `CaseRun API request timed out after ${timeoutMs}ms`, { timeoutMs, baseUrl, route: request.route, method: request.method });
}
throw typedError(error, "caserun_api_unavailable");
} finally {
clearTimeout(timer);
}
},
close: async () => {}
};
}
export function caseRunAccepted(record: CaseRunRecord | null) {
if (!record) throw new CaseRunTransportError("caserun_start_failed", "CaseRun start did not return a run record");
return {
ok: true,
contractVersion: "hwlab-caserun-web-v1",
runId: record.runId,
caseId: record.caseId,
workflowId: record.workflowId,
workflowRunId: record.workflowRunId,
status: record.status,
stage: record.stage,
statusUrl: `/v1/caserun/runs/${encodeURIComponent(record.runId)}`,
eventsUrl: `/v1/caserun/runs/${encodeURIComponent(record.runId)}/events`,
aggregateUrl: `/v1/caserun/runs/${encodeURIComponent(record.runId)}/aggregate`,
cancelUrl: `/v1/caserun/runs/${encodeURIComponent(record.runId)}/cancel`
};
}
function acceptedRecord(record: CaseRunRecord | null) {
if (!record) throw new CaseRunTransportError("caserun_cancel_failed", "CaseRun cancel did not return a run record");
return record;
}
function apiOrigin(value: string) {
const text = String(value ?? "").trim();
if (!text) throw new CaseRunTransportError("caserun_base_url_required", "--base-url <origin> is required with --over-api");
try {
const url = new URL(text);
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.pathname !== "/" || url.search || url.hash) throw new Error("origin required");
return url.origin;
} catch {
throw new CaseRunTransportError("invalid_caserun_base_url", "--base-url must be an absolute HTTP(S) origin", { baseUrl: text });
}
}
function apiTimeoutMs(value: number | undefined) {
const timeoutMs = value ?? DEFAULT_API_TIMEOUT_MS;
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) throw new CaseRunTransportError("invalid_caserun_timeout", "--timeout-ms must be a positive integer", { timeoutMs });
return timeoutMs;
}
function apiRequestVisibility(baseUrl: string, command: CaseRunCommand) {
const route = command.kind === "start" ? "/v1/caserun/runs" : `/v1/caserun/runs/${encodeURIComponent(command.runId)}${command.kind === "status" ? "" : `/${command.kind}`}`;
const method = command.kind === "start" || command.kind === "cancel" ? "POST" : "GET";
return { baseUrl, route, method };
}
function typedError(error: any, fallbackCode: string) {
if (error instanceof CaseRunTransportError) return error;
return new CaseRunTransportError(error?.code ?? fallbackCode, error?.message ?? String(error), error?.details);
}
+91
View File
@@ -0,0 +1,91 @@
import assert from "node:assert/strict";
import { test } from "bun:test";
import type { CaseRunRecord } from "../../internal/harnessrl/contracts.ts";
import { CaseRunTransportError, createApiCaseRunTransport, createLocalCaseRunTransport } from "../../internal/harnessrl/transport.ts";
import { runHwlabCli } from "../src/hwlab-cli-lib.ts";
const record: CaseRunRecord = {
runId: "run-2617", caseId: "case-2617", workflowId: "harnessrl-caserun-run-2617", workflowRunId: "workflow-run-2617",
status: "queued", stage: "queued", terminal: false, caseRepo: "/cases", runDir: "/state/run-2617", runtimeApiUrl: "local://harnessrl",
result: null, error: null, blocker: null, createdAt: "2026-07-17T00:00:00.000Z", updatedAt: "2026-07-17T00:00:00.000Z"
};
test("L0 local adapter executes the shared CaseRun command contract without HTTP", async () => {
const calls: string[] = [];
const transport = createLocalCaseRunTransport({ service: {
async submit() { calls.push("start"); return record; },
async getRun() { calls.push("status"); return record; },
async events() { calls.push("events"); return { ...record, events: [] }; },
async aggregate() { calls.push("aggregate"); return { ok: true, runId: record.runId, status: record.status }; },
async cancel() { calls.push("cancel"); return { ...record, status: "cancel_requested", stage: "cancel_requested" }; }
}});
const started = await transport.execute({ kind: "start", caseId: record.caseId, runId: record.runId });
assert.equal(started.runId, record.runId);
assert.equal(started.workflowId, record.workflowId);
await transport.execute({ kind: "status", runId: record.runId });
await transport.execute({ kind: "events", runId: record.runId });
await transport.execute({ kind: "aggregate", runId: record.runId });
await transport.execute({ kind: "cancel", runId: record.runId });
assert.deepEqual(calls, ["start", "status", "events", "aggregate", "cancel"]);
});
test("CLI defaults to local and requires explicit over-api for base-url", async () => {
const local = createLocalCaseRunTransport({ service: {
async submit() { return record; }, async getRun() { return record; }, async events() { return { ...record, events: [] }; },
async aggregate() { return { ok: true, runId: record.runId }; }, async cancel() { return record; }
}});
const result = await runHwlabCli(["case", "run", "start", record.caseId, "--run-id", record.runId], { caseRunTransportFactory: () => local });
assert.equal(result.exitCode, 0);
assert.equal(result.payload.transport, "local");
assert.equal(result.payload.service, "native-harnessrl-application-service");
assert.equal(result.payload.baseUrl, null);
assert.equal(result.payload.route, null);
assert.equal(result.payload.method, null);
assert.equal(result.payload.workflowRunId, record.workflowRunId);
const rejected = await runHwlabCli(["case", "run", "status", record.runId, "--base-url", "http://api.test"]);
assert.equal(rejected.exitCode, 1);
assert.equal(rejected.payload.error.code, "caserun_over_api_required");
});
test("API adapter uses only the explicit CaseRun origin and preserves typed errors", async () => {
const requests: Array<{ url: string; method: string; authorization: string | null }> = [];
const transport = createApiCaseRunTransport({ baseUrl: "http://api.test", apiKey: "hwl_live_test", fetchImpl: async (input, init) => {
requests.push({ url: String(input), method: String(init?.method), authorization: new Headers(init?.headers).get("authorization") });
return Response.json({ ok: true, ...record });
}});
const response = await transport.execute({ kind: "events", runId: record.runId });
assert.equal(response.workflowId, record.workflowId);
assert.deepEqual(requests, [{ url: `http://api.test/v1/caserun/runs/${record.runId}/events`, method: "GET", authorization: "Bearer hwl_live_test" }]);
const failed = createApiCaseRunTransport({ baseUrl: "http://api.test", fetchImpl: async () => Response.json({ ok: false, error: { code: "caserun_run_not_found", message: "missing", details: { runId: record.runId } } }, { status: 404 }) });
await assert.rejects(failed.execute({ kind: "status", runId: record.runId }), (error: any) => error instanceof CaseRunTransportError && error.code === "caserun_run_not_found");
});
test("API adapter times out through its real AbortSignal path with typed route details", async () => {
const transport = createApiCaseRunTransport({ baseUrl: "http://api.test", timeoutMs: 5, fetchImpl: async (_input, init) => {
await new Promise((_resolve, reject) => init?.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true }));
throw new Error("unreachable");
}});
await assert.rejects(transport.execute({ kind: "status", runId: record.runId }), (error: any) => {
assert.equal(error instanceof CaseRunTransportError, true);
assert.equal(error.code, "caserun_api_timeout");
assert.deepEqual(error.details, { timeoutMs: 5, baseUrl: "http://api.test", route: `/v1/caserun/runs/${record.runId}`, method: "GET" });
return true;
});
});
test("over-api CLI discloses bounded origin route and method without credentials", async () => {
const result = await runHwlabCli(["case", "run", "events", record.runId, "--over-api", "--base-url", "http://api.test", "--timeout-ms", "25"], {
env: { HWLAB_API_KEY: "hwl_live_secret" },
fetchImpl: async () => Response.json({ ok: true, ...record, events: [] })
});
assert.equal(result.exitCode, 0);
assert.equal(result.payload.transport, "api");
assert.equal(result.payload.baseUrl, "http://api.test");
assert.equal(result.payload.route, `/v1/caserun/runs/${record.runId}/events`);
assert.equal(result.payload.method, "GET");
assert.equal(JSON.stringify(result.payload).includes("hwl_live_secret"), false);
});
-160
View File
@@ -224,79 +224,6 @@ test("case run normalizes v0.3 HWPOD build operation topology and typed wait blo
assert.equal(yamlEmpty.topology.ok, true);
});
test("case run start returns immediately and status/result/logs are short polling commands", async () => {
const root = await mkdtempCaseRoot();
const caseRepo = path.join(root, "hwlab-case-registry");
const cwd = path.join(root, "hwlab");
const caseId = "d601-f103-v2-compile";
try {
await createCaseRepo(caseRepo, caseId);
await mkdir(cwd, { recursive: true });
let workerArgs: string[] = [];
const started = await runHwlabCli([
"case",
"run",
"start",
caseId,
"--case-repo",
caseRepo,
"--run-id",
"run-async",
"--agent-timeout-ms",
"180000",
"--job-timeout-ms",
"70000"
], {
cwd,
now: () => "2026-06-05T00:00:00.000Z",
env: TEST_RUNTIME_ENV,
startProcess: async (input) => {
workerArgs = input.args;
await writeFile(input.stdoutPath, "worker accepted\n", "utf8");
await writeFile(input.stderrPath, "", "utf8");
return { pid: 4242 };
}
});
assert.equal(started.exitCode, 0);
assert.equal(started.payload.action, "case.run.start");
assert.equal(started.payload.status, "submitted");
assert.equal(started.payload.startReturned, true);
assert.equal(started.payload.pid, 4242);
assert.match(started.payload.statusCommand, /case run status run-async/u);
assert.match(started.payload.resultCommand, /case run result run-async/u);
assert.match(started.payload.logsCommand, /case run logs run-async/u);
assert.equal(workerArgs.includes("--agent-timeout-ms"), true);
assert.equal(workerArgs[workerArgs.indexOf("--agent-timeout-ms") + 1], "180000");
assert.equal(workerArgs.includes("--job-timeout-ms"), true);
assert.equal(workerArgs[workerArgs.indexOf("--job-timeout-ms") + 1], "70000");
const status = await runHwlabCli(["case", "run", "status", "run-async"], { cwd, now: () => "2026-06-05T00:00:01.000Z" });
assert.equal(status.exitCode, 0);
assert.equal(status.payload.action, "case.run.status");
assert.equal(status.payload.status, "running");
assert.equal(status.payload.stage, "started");
assert.equal(status.payload.pid, 4242);
assert.equal(status.payload.stdoutBytes, Buffer.byteLength("worker accepted\n"));
assert.equal(status.payload.stderrBytes, 0);
assert.match(status.payload.nextPollCommand, /case run status run-async/u);
const result = await runHwlabCli(["case", "run", "result", "run-async"], { cwd, now: () => "2026-06-05T00:00:02.000Z" });
assert.equal(result.exitCode, 0);
assert.equal(result.payload.action, "case.run.result");
assert.equal(result.payload.ready, false);
assert.equal(result.payload.status, "running");
assert.match(result.payload.nextPollCommand, /case run status run-async/u);
const logs = await runHwlabCli(["case", "run", "logs", "run-async", "--tail", "200"], { cwd, now: () => "2026-06-05T00:00:03.000Z" });
assert.equal(logs.exitCode, 0);
assert.equal(logs.payload.action, "case.run.logs");
assert.equal(logs.payload.stdout, "worker accepted\n");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("case run result finalizes legacy build-completed state and refreshes registry artifacts", async () => {
const root = await mkdtempCaseRoot();
const caseRepo = path.join(root, "hwlab-case-registry");
@@ -738,56 +665,6 @@ test("case run archives rendered trace transcript when finalResponse is null", a
}
});
test("case run status reports latest run stage when async control stage is stale", async () => {
const root = await mkdtempCaseRoot();
const cwd = path.join(root, "hwlab");
const runDir = path.join(cwd, ".state", "hwlab-cli", "caserun", "run-stale-stage");
try {
await mkdir(runDir, { recursive: true });
await writeFile(path.join(runDir, "worker.stdout.log"), "agent finished\n", "utf8");
await writeFile(path.join(runDir, "worker.stderr.log"), "", "utf8");
await writeFile(path.join(runDir, "run.json"), JSON.stringify({
caseId: "d601-f103-v2-compile",
runId: "run-stale-stage",
runDir,
caseRepo: "",
caseDir: "",
caseFile: "",
sourceSpecPath: "",
specPath: path.join(runDir, ".hwlab", "hwpod-spec.yaml"),
apiUrl: "http://api.test",
compileOnly: true,
createdAt: "2026-06-05T00:00:00.000Z",
updatedAt: "2026-06-05T00:00:10.000Z",
status: "running",
stage: "agent-running",
definition: {},
subject: { repoLocalPath: SUBJECT_REPO_LOCAL_PATH, commitId: SUBJECT_COMMIT_ID, subdir: "projects/01_baseline", worktreePath: "F:\\Work\\HWLAB-CASE-F103\\.worktree\\caserun-run-stale-stage", workspacePath: "", setup: {} },
agent: { stageStatus: "completed", baseUrl: "http://web.test", projectId: "prj_hwpod_workbench", providerProfile: "deepseek", conversationId: "cnv_case", sessionId: "ses_case", threadId: "thread-case", traceId: "trc_case", promptPath: path.join(runDir, "agent-prompt.md"), promptSha256: "sha", polls: 3, lastHttpStatus: 200 },
agentDiff: { statusShort: " M projects/01_baseline/main.c\n", diffStat: " projects/01_baseline/main.c | 1 +\n", diffPatchPath: path.join(runDir, "agent-diff.patch"), diffPatchSha256: "sha" },
_control: { status: "running", stage: "prepared", pid: 1234, stdoutPath: path.join(runDir, "worker.stdout.log"), stderrPath: path.join(runDir, "worker.stderr.log"), startedAt: "2026-06-05T00:00:00.000Z" }
}, null, 2), "utf8");
const status = await runHwlabCli(["case", "run", "status", "run-stale-stage"], { cwd, now: () => "2026-06-05T00:00:12.000Z" });
assert.equal(status.exitCode, 0);
assert.equal(status.payload.stage, "agent-diff-collected");
assert.equal(status.payload.staleControlWarning.code, "stale_control_stage");
assert.equal(status.payload.staleControlWarning.controlStage, "prepared");
assert.equal(status.payload.staleControlWarning.selectedStage, "agent-diff-collected");
assert.equal(status.payload.agent.stageStatus, "completed");
assert.match(status.payload.agentDiff.statusShort, /projects\/01_baseline\/main\.c/u);
const result = await runHwlabCli(["case", "run", "result", "run-stale-stage"], { cwd, now: () => "2026-06-05T00:00:13.000Z" });
assert.equal(result.exitCode, 0);
assert.equal(result.payload.ready, false);
assert.equal(result.payload.stage, "agent-diff-collected");
assert.equal(result.payload.traceLookup.commands.trace, "hwlab-cli client agent trace trc_case --render web");
assert.equal(result.payload.staleControlWarning.code, "stale_control_stage");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("case aggregate writes one registry markdown entry without grading or broad registry commits", async () => {
const root = await mkdtempCaseRoot();
const caseRepo = path.join(root, "hwlab-case-registry");
@@ -909,43 +786,6 @@ test("case aggregate writes one registry markdown entry without grading or broad
}
});
test("case run status keeps worker control stage ahead of prepared worktree evidence", async () => {
const root = await mkdtempCaseRoot();
const cwd = path.join(root, "hwlab");
const runDir = path.join(cwd, ".state", "hwlab-cli", "caserun", "run-worker-started");
try {
await mkdir(runDir, { recursive: true });
await writeFile(path.join(runDir, "worker.stdout.log"), "", "utf8");
await writeFile(path.join(runDir, "worker.stderr.log"), "", "utf8");
await writeFile(path.join(runDir, "run.json"), JSON.stringify({
caseId: "d601-f103-v2-compile",
runId: "run-worker-started",
runDir,
caseRepo: "",
caseDir: "",
caseFile: "",
sourceSpecPath: "",
specPath: path.join(runDir, ".hwlab", "hwpod-spec.yaml"),
apiUrl: "http://api.test",
compileOnly: true,
createdAt: "2026-06-05T00:00:00.000Z",
updatedAt: "2026-06-05T00:00:10.000Z",
status: "running",
stage: "queued",
definition: {},
subject: { repoLocalPath: SUBJECT_REPO_LOCAL_PATH, commitId: SUBJECT_COMMIT_ID, subdir: "projects/01_baseline", worktreePath: "F:\\Work\\HWLAB-CASE-F103\\.worktree\\caserun-run-worker-started", workspacePath: "", setup: {} },
_control: { status: "running", stage: "started", pid: 1234, stdoutPath: path.join(runDir, "worker.stdout.log"), stderrPath: path.join(runDir, "worker.stderr.log"), startedAt: "2026-06-05T00:00:00.000Z" }
}, null, 2), "utf8");
const status = await runHwlabCli(["case", "run", "status", "run-worker-started"], { cwd, now: () => "2026-06-05T00:00:12.000Z" });
assert.equal(status.exitCode, 0);
assert.equal(status.payload.stage, "started");
assert.equal(status.payload.staleControlWarning, undefined);
} finally {
await rm(root, { recursive: true, force: true });
}
});
async function mkdtempCaseRoot() {
return await import("node:fs/promises").then(({ mkdtemp }) => mkdtemp(path.join(os.tmpdir(), "hwlab-caserun-")));
}
+47 -7
View File
@@ -13,6 +13,8 @@ import { inspectCaseRegistryRun, refreshCaseRegistryArtifacts, skillUsageCaseReg
import { agentTerminalEvidence, agentToolCallSummaryFromResult, agentWithResultEvidence, applyCaseRunEvidenceRelationships, clean, cliError, clipText, commandVisibility, commandsFromToolCallSummary, compactObject, firstNumberOption, firstTextOption, numberOption, parseJsonMaybe, sha256, summarizeAgentTrace, text, type AgentToolCallSummary } from "./hwlab-caserun-evidence.ts";
import { apiUrlFrom, webUrlFrom } from "./hwlab-caserun-runtime-config.ts";
import { readHwpodSpec } from "./hwpod-harness-lib.ts";
import { harnessRLRuntime } from "../../internal/harnessrl/runtime.ts";
import { createApiCaseRunTransport, createLocalCaseRunTransport, type CaseRunCommand, type CaseRunTransport } from "../../internal/harnessrl/transport.ts";
import { caseValidationPlanFromDefinition, hwpodCommandForValidationStep, normalizeValidationStepResult, validationReplayRelationship } from "./hwlab-caserun-validation.ts";
import { prepareSubjectWorktree, caseRunForSubjectSnapshot, subjectFromDefinition, requestSubjectWorktree, subjectWorktreeStatus, keilSidecarCopyOp, subjectRelativeKeilProject, stripUvprojx, windowsJoin, keilSidecarCopyNodeScript, keilSidecarSummary, objectRecord, isAbsolutePathLike, subjectRelativeWorktreePath, normalizeLocalPath, subjectWorktreePath, looksLikeWindowsPath, rewriteHwpodSpecWorkspacePath, pollKeilJobStatus, requestKeilJobStatus, keilJobStatus, hwpodResultTexts, artifactsFrom, slug } from "./hwlab-caserun-subject.ts";
import { agentTraceIdentityArtifact, agentTraceLookup, agentTraceLookupForRun, renderEvidenceSummary, agentStageSummary, agentStageCommand, agentCommandDetail, markdownTableText, writeCaseAggregateFromRegistry, resolveAggregateRunId, aggregateOutputRel, renderCaseAggregateMarkdown, aggregateTraceBody, aggregateMessagesWithFreshFullTrace, freshRenderedTraceBody, traceEventRowFromAggregate, aggregateBullets, aggregateInlineValue, markdownInline, aggregateDetails, escapeHtml, aggregateFence, aggregateArtifactIndex, registryAggregateFileRecord, archiveCaseRunArtifacts, syncCaseRegistryRun, syncCaseRegistryRel, gitProcess, collectSummaryFromArchive, writeReadableAgentArtifacts, archivedAgentTraceSnapshot, existingAgentTraceSummary, archivedAgentTraceBody, materializeFullAgentTraceWithExistingCli, traceRowsFromBody, traceEventRowFromObject, traceRowTone, applyArchivedAgentTraceSummary, agentTraceBody, agentFinalResponse, finalResponseMissingReason, agentMessageRowForArchive, renderAgentTranscript, renderTranscriptRow, renderFinalResponseArtifact, refreshCompletedRunArchive, caseRunArtifactEntries, copyCaseRunArtifact, registryArtifactFileRecord, caseRunArtifactManifest, aggregateManifestEntry, caseRunArtifactTrace, caseRegistryRunRel, artifactRel, artifactSourceLabel } from "./hwlab-caserun-closeout.ts";
@@ -45,8 +47,7 @@ export async function caseCommand(context: CaseContext) {
async function caseRunCommand(context: CaseContext) {
const mode = context.rest[1] || "";
if (mode === "start") return startCaseRun(context);
if (mode === "status") return statusCaseRun(context);
if (["start", "status", "events", "aggregate", "cancel"].includes(mode)) return harnessRLCaseRunCommand(context, mode as CaseRunCommand["kind"]);
if (mode === "result") return resultCaseRun(context);
if (mode === "logs") return logsCaseRun(context);
if (mode === "inspect") return inspectCaseRegistryRun(context, await resolveCaseRepo(context), "inspect");
@@ -58,7 +59,11 @@ async function caseRunCommand(context: CaseContext) {
export function caseHelp() {
return ok("case.help", {
serviceRuntime: false,
runTransport: {
default: "local HarnessRL application service",
overApi: "explicit --over-api --base-url <origin>",
fallback: false
},
compileOnlyDefault: true,
agentTaskRequiredForRun: false,
autoEvaluation: false,
@@ -78,9 +83,11 @@ export function caseHelp() {
`case refresh CASE_ID --run-id RUN_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--source local|runtime]`,
`case run CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--provider-profile PROFILE]`,
`case run start CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--run-id RUN_ID]`,
`case run status RUN_ID [--state-dir .state/hwlab-cli/caserun]`,
`case run result RUN_ID [--state-dir .state/hwlab-cli/caserun]`,
`case run logs RUN_ID [--tail 8000]`,
`case run start CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} --over-api --base-url <origin> [--timeout-ms 30000] [--run-id RUN_ID]`,
"case run status RUN_ID [--over-api --base-url <origin> --timeout-ms 30000]",
"case run events RUN_ID [--over-api --base-url <origin> --timeout-ms 30000]",
"case run aggregate RUN_ID [--over-api --base-url <origin> --timeout-ms 30000]",
"case run cancel RUN_ID [--over-api --base-url <origin> --timeout-ms 30000]",
`case run inspect RUN_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--case-id CASE_ID]`,
`case run trace-summary RUN_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--case-id CASE_ID]`,
`case run skill-usage RUN_ID --skill SKILL_NAME --case-repo ${STANDARD_CASE_REPO_PATH} [--case-id CASE_ID]`
@@ -92,12 +99,45 @@ export function caseHelp() {
"case aggregate is registry-only and offline by default; it reads existing artifacts without querying runtime.",
"case refresh/backfill defaults to local registry artifacts; only explicit --source runtime may query the existing trace CLI.",
"case run inspect/trace-summary/skill-usage are bounded, service-free registry diagnostics.",
"case run start/status/result/logs is the cli-spec async control surface for long CaseRun flows; start returns immediately and status/result/logs are short polling commands.",
"case run start/status/events/aggregate/cancel share one HarnessRL command/DTO/error contract; start returns immediately and the remaining commands progressively disclose durable state.",
"The default local adapter calls the native HarnessRL application service directly; only explicit --over-api calls HTTP, and neither transport falls back to the other.",
"This version records raw stage evidence only; it does not auto-grade agent output, diff contents, or Keil evidence."
]
});
}
async function harnessRLCaseRunCommand(context: CaseContext, kind: CaseRunCommand["kind"]) {
const command: CaseRunCommand = kind === "start"
? { kind, caseId: requiredText(context.parsed.caseId ?? context.rest[2], "caseId"), ...(text(context.parsed.runId) ? { runId: text(context.parsed.runId) } : {}) }
: { kind, runId: requiredText(context.parsed.runId ?? context.rest[2], "runId") };
const transport = await (context.caseRunTransportFactory?.(context) ?? createHarnessRLTransport(context));
try {
const response = await transport.execute(command);
return ok(`case.run.${kind}`, {
transport: transport.mode,
command: kind,
...transport.visibility(command),
...response,
...(kind === "start" ? { startReturned: true } : {})
}, kind === "start" ? "submitted" : "succeeded");
} finally {
await transport.close();
}
}
function createHarnessRLTransport(context: CaseContext): CaseRunTransport {
if (context.parsed.overApi === true) return createApiCaseRunTransport({ baseUrl: text(context.parsed.baseUrl), fetchImpl: context.fetchImpl, apiKey: text(context.env.HWLAB_API_KEY) || undefined, timeoutMs: firstNumberOption(context.parsed.timeoutMs) });
if (context.parsed.baseUrl) throw cliError("caserun_over_api_required", "--base-url is only valid with explicit --over-api");
const env = {
...context.env,
HWLAB_CASERUN_REPO_ROOT: context.cwd,
...(text(context.parsed.caseRepo ?? context.parsed.caseRepoPath) ? { HWLAB_CASERUN_CASE_REPO: text(context.parsed.caseRepo ?? context.parsed.caseRepoPath) } : {}),
...(text(context.parsed.stateDir) ? { HARNESSRL_STATE_DIR: text(context.parsed.stateDir) } : {})
};
const runtime = harnessRLRuntime(env);
return createLocalCaseRunTransport({ service: runtime.service, close: () => runtime.service.close() });
}
export async function promptCaseRun(context: CaseContext) {
const caseId = requiredText(context.parsed.caseId ?? context.rest[1], "caseId");
const caseRepo = await resolveCaseRepo(context);
+1
View File
@@ -21,6 +21,7 @@ export type CaseContext = {
sleep: (ms: number) => Promise<void>;
runProcess?: RunProcessLike;
startProcess?: StartProcessLike;
caseRunTransportFactory?: (context: CaseContext) => Promise<import("../../internal/harnessrl/transport.ts").CaseRunTransport> | import("../../internal/harnessrl/transport.ts").CaseRunTransport;
argv?: string[];
rest: string[];
};
+2
View File
@@ -36,6 +36,7 @@ type CliOptions = {
sleep?: (ms: number) => Promise<void>;
runProcess?: (command: string[], cwd: string, env: Record<string, string | undefined>) => Promise<{ command: string[]; stdout: string; stderr: string; exitCode: number }>;
startProcess?: (input: { command: string; args: string[]; cwd: string; env: Record<string, string | undefined>; stdoutPath: string; stderrPath: string }) => Promise<{ pid: number }> | { pid: number };
caseRunTransportFactory?: (context: any) => Promise<any> | any;
};
export async function main(argv = process.argv.slice(2), options: CliOptions = {}) {
@@ -60,6 +61,7 @@ export async function runHwlabCli(argv: string[], options: CliOptions = {}) {
sleep: options.sleep ?? wait,
runProcess: options.runProcess,
startProcess: options.startProcess,
caseRunTransportFactory: options.caseRunTransportFactory,
argv
};
const payload = target === "client" ? await clientCommand({ ...context, rest: parsed._.slice(1) }) : target === "case" ? await caseCommand({ ...context, rest: parsed._.slice(1) }) : help();
+8
View File
@@ -6,6 +6,14 @@
"scripts": {
"deps": "node ../../scripts/worktree-deps.mjs --web",
"dev": "bun run deps --quiet && vite",
"dev:native-caserun": "CHOKIDAR_USEPOLLING=1 HWLAB_CASERUN_NATIVE_TEST=1 bun run dev",
"caserun:native": "bun scripts/caserun-native-supervisor.ts",
"caserun:native:start": "bun run scripts/caserun-native-service.ts start",
"caserun:native:stop": "bun run scripts/caserun-native-service.ts stop",
"caserun:native:status": "bun run scripts/caserun-native-service.ts status",
"caserun:native:logs": "bun run scripts/caserun-native-service.ts logs",
"caserun:native:smoke": "bun run scripts/caserun-native-smoke.ts",
"caserun:native:reload-smoke": "bun run scripts/caserun-native-reload-smoke.ts",
"check": "bun run deps --quiet && bun run scripts/tsc-check.ts && bun test scripts",
"check:tsc": "bun run deps --quiet && bun run scripts/tsc-check.ts",
"check:tsc-strict": "bun run deps --quiet && bun run scripts/tsc-check.ts --strict",
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import test from "node:test";
import { listNativeCases, readNativeAggregate, readNativeEvents, readNativeRun, resetNativeFixtures, startNativeRun } from "./caserun-native-fixtures";
test("native CaseRun fixtures expose six deterministic API-owned states", () => {
resetNativeFixtures();
const cases = listNativeCases();
assert.equal(cases.mode, "native-test");
assert.deepEqual(cases.cases.map((item) => item.caseId), ["native-queued", "native-running", "native-completed", "native-failed", "native-blocked", "native-canceled"]);
const started = startNativeRun("native-completed");
assert.equal(started?.status, "queued");
assert.equal(started?.terminal, false);
const running = readNativeRun(started!.runId);
assert.equal(running?.status, "running");
assert.equal(running?.terminal, false);
const completed = readNativeRun(started!.runId);
assert.equal(completed?.status, "completed");
assert.equal(completed?.terminal, true);
assert.match(completed?.references.aggregate.sha256 ?? "", /^[a-f0-9]{64}$/);
const aggregate = readNativeAggregate(started!.runId);
assert.equal(aggregate?.mode, "native-test");
assert.equal(aggregate?.runId, started!.runId);
assert.equal(aggregate?.status, "completed");
assert.equal(aggregate?.sha256, completed?.references.aggregate.sha256);
const events = readNativeEvents(started!.runId);
assert.deepEqual(events?.events.map((event) => event.status), ["queued", "running", "completed"]);
assert.equal(events?.mode, "native-test");
});
test("native blockers and cancellation come from the API fixture", () => {
resetNativeFixtures();
const blocked = startNativeRun("native-blocked")!;
const blockedTerminal = readNativeRun(blocked.runId)!;
assert.equal(blockedTerminal.terminal, true);
assert.equal(blockedTerminal.blocker?.code, "native-capability-blocked");
const canceled = startNativeRun("native-canceled")!;
readNativeRun(canceled.runId);
const canceledTerminal = readNativeRun(canceled.runId)!;
assert.equal(canceledTerminal.status, "canceled");
assert.equal(canceledTerminal.terminal, true);
});
@@ -0,0 +1,161 @@
import { createHash } from "node:crypto";
type NativeStatus = "queued" | "running" | "completed" | "failed" | "blocked" | "canceled";
interface NativeStep {
status: NativeStatus;
stage: string;
terminal: boolean;
blocker?: { code: string; summary: string; layer: string };
}
interface NativeRunState {
runId: string;
caseId: string;
cursor: number;
steps: NativeStep[];
}
const MODE = "native-test";
const CONTRACT_VERSION = "caserun.v1";
const BASE_TIME = Date.parse("2026-07-17T00:00:00.000Z");
const scenarios: Record<string, { title: string; steps: NativeStep[] }> = {
"native-queued": { title: "Native queued", steps: [{ status: "queued", stage: "queued", terminal: false }] },
"native-running": { title: "Native running", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "running", stage: "agent-running", terminal: false }] },
"native-completed": { title: "Native completed", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "running", stage: "agent-running", terminal: false }, { status: "completed", stage: "completed", terminal: true }] },
"native-failed": { title: "Native failed", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "running", stage: "building", terminal: false }, { status: "failed", stage: "failed", terminal: true, blocker: { code: "native-build-failed", summary: "Deterministic native build failure", layer: "build" } }] },
"native-blocked": { title: "Native blocked", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "blocked", stage: "preparing", terminal: true, blocker: { code: "native-capability-blocked", summary: "Deterministic native capability blocker", layer: "hwpod" } }] },
"native-canceled": { title: "Native canceled", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "running", stage: "agent-running", terminal: false }, { status: "canceled", stage: "canceled", terminal: true }] }
};
let runSequence = 0;
const runs = new Map<string, NativeRunState>();
function timestamp(index: number): string {
return new Date(BASE_TIME + index * 1000).toISOString();
}
function aggregateResponse(run: NativeRunState) {
const step = run.steps[run.cursor] ?? run.steps.at(-1)!;
const summary = {
status: step.status,
jobId: `job-${run.runId}`,
artifactCount: step.status === "completed" ? 2 : 0
};
const evidence = {
status: step.status,
blocker: step.blocker ?? null,
artifacts: step.status === "completed" ? [{ name: "firmware.bin" }, { name: "caserun.log" }] : []
};
const archivedRun = {
runId: run.runId,
caseId: run.caseId,
status: step.status,
stage: step.stage,
terminal: step.terminal,
mode: MODE
};
const source = { runId: run.runId, caseId: run.caseId, status: step.status, summary, evidence, run: archivedRun };
return {
ok: true,
mode: MODE,
contractVersion: "caserun.aggregate.v1",
runId: run.runId,
caseId: run.caseId,
status: step.status,
stage: step.stage,
terminal: step.terminal,
sourceAuthority: { mode: MODE, sequence: "request-driven" },
sha256: createHash("sha256").update(JSON.stringify(source)).digest("hex"),
summary,
evidence,
run: archivedRun
};
}
function runResponse(run: NativeRunState) {
const step = run.steps[run.cursor] ?? run.steps.at(-1)!;
return {
ok: true,
mode: MODE,
contractVersion: CONTRACT_VERSION,
runId: run.runId,
caseId: run.caseId,
status: step.status,
stage: step.stage,
terminal: step.terminal,
blocker: step.blocker ?? null,
createdAt: timestamp(0),
updatedAt: timestamp(run.cursor),
facts: {
jobId: `job-${run.runId}`,
artifactCount: step.status === "completed" ? 2 : 0,
evidenceStatus: step.status
},
references: {
status: { href: `/v1/caserun/runs/${run.runId}` },
events: { href: `/v1/caserun/runs/${run.runId}/events`, count: run.cursor + 1 },
aggregate: { href: `/v1/caserun/runs/${run.runId}/aggregate`, sha256: aggregateResponse(run).sha256 },
manifest: step.status === "completed" ? { href: `/v1/caserun/runs/${run.runId}/manifest`, count: 2 } : null,
replay: null,
trace: { traceId: `trace-${run.runId}` },
hwpod: { hwpodId: "native-hwpod", nodeId: "native-node" }
},
sourceAuthority: { mode: MODE, sequence: "request-driven" },
error: null,
statusUrl: `/v1/caserun/runs/${run.runId}`
};
}
export function listNativeCases() {
const cases = Object.entries(scenarios).map(([caseId, scenario]) => ({
caseId,
title: scenario.title,
mode: MODE,
available: true,
hwpodSpec: "native-test",
runtime: { sequence: scenario.steps.map((step) => step.status) }
}));
return { ok: true, mode: MODE, contractVersion: CONTRACT_VERSION, count: cases.length, cases, caseAuthority: { mode: MODE } };
}
export function startNativeRun(caseId: string) {
const scenario = scenarios[caseId];
if (!scenario) return null;
runSequence += 1;
const runId = `${caseId}-${runSequence}`;
const run: NativeRunState = { runId, caseId, cursor: 0, steps: scenario.steps };
runs.set(runId, run);
return runResponse(run);
}
export function readNativeRun(runId: string, advance = true) {
const run = runs.get(runId);
if (!run) return null;
if (advance && run.cursor < run.steps.length - 1) run.cursor += 1;
return runResponse(run);
}
export function readNativeEvents(runId: string) {
const run = runs.get(runId);
if (!run) return null;
const current = runResponse(run);
const events = run.steps.slice(0, run.cursor + 1).map((step, index) => ({
at: timestamp(index),
status: step.status,
stage: step.stage,
payload: { mode: MODE, terminal: step.terminal, blocker: step.blocker ?? null }
}));
return { ok: true, mode: MODE, contractVersion: CONTRACT_VERSION, runId, caseId: run.caseId, status: current.status, stage: current.stage, terminal: current.terminal, sourceAuthority: current.sourceAuthority, events };
}
export function readNativeAggregate(runId: string) {
const run = runs.get(runId);
return run ? aggregateResponse(run) : null;
}
export function resetNativeFixtures(): void {
runSequence = 0;
runs.clear();
}
@@ -0,0 +1,129 @@
import { readFileSync, renameSync, rmSync, statSync, utimesSync } from "node:fs";
import { resolve } from "node:path";
const root = process.cwd();
const watchedPath = resolve(root, "scripts/caserun-native-fixtures.ts");
const displacedPath = `${watchedPath}.reload-smoke`;
const originalStat = statSync(watchedPath);
const port = Number(process.env.HWLAB_CASERUN_RELOAD_SMOKE_PORT || 14316);
const baseUrl = `http://127.0.0.1:${port}`;
const reloadEvents: Array<{ pid?: number; reason?: string }> = [];
let output = "";
let displaced = false;
function requireCondition(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
async function waitFor(description: string, condition: () => boolean | Promise<boolean>, timeoutMs = 5000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await condition()) return;
await Bun.sleep(50);
}
throw new Error(`timeout waiting for ${description}`);
}
async function readOutput(stream: ReadableStream<Uint8Array>): Promise<void> {
const reader = stream.getReader();
const decoder = new TextDecoder();
let pending = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
output += chunk;
pending += chunk;
const lines = pending.split("\n");
pending = lines.pop() || "";
for (const line of lines) {
if (!line.includes('"event":"caserun-native-reload"')) continue;
const event = JSON.parse(line) as { pid?: number; reason?: string };
if (!reloadEvents.some((existing) => existing.pid === event.pid)) reloadEvents.push(event);
}
}
const finalChunk = decoder.decode();
output += finalChunk;
pending += finalChunk;
if (pending.includes('"event":"caserun-native-reload"')) {
const event = JSON.parse(pending) as { pid?: number; reason?: string };
if (!reloadEvents.some((existing) => existing.pid === event.pid)) reloadEvents.push(event);
}
}
async function healthAvailable(): Promise<boolean> {
try {
const response = await fetch(`${baseUrl}/health`);
return response.ok;
} catch {
return false;
}
}
function childPids(supervisorPid: number): number[] {
try {
return readFileSync(`/proc/${supervisorPid}/task/${supervisorPid}/children`, "utf8")
.trim()
.split(/\s+/)
.filter(Boolean)
.map(Number);
} catch {
return [];
}
}
rmSync(displacedPath, { force: true });
const supervisor = Bun.spawn([process.execPath, "scripts/caserun-native-supervisor.ts"], {
cwd: root,
env: { ...process.env, HWLAB_CASERUN_NATIVE_PORT: String(port) },
stdin: "ignore",
stdout: "pipe",
stderr: "pipe"
});
const stdoutTask = readOutput(supervisor.stdout);
const stderrTask = readOutput(supervisor.stderr);
try {
await waitFor("initial native health", healthAvailable);
renameSync(watchedPath, displacedPath);
displaced = true;
await Bun.sleep(300);
requireCondition(supervisor.exitCode === null, `supervisor exited during atomic save window: ${supervisor.exitCode}`);
renameSync(displacedPath, watchedPath);
displaced = false;
const restoredAt = new Date(Date.now() + 1000);
utimesSync(watchedPath, restoredAt, restoredAt);
await waitFor("reload after atomic save", () => reloadEvents.length >= 2);
for (let index = 0; index < 5; index += 1) {
const changedAt = new Date(Date.now() + (index + 1) * 1000);
utimesSync(watchedPath, changedAt, changedAt);
await Bun.sleep(120);
}
await waitFor("final native health", healthAvailable);
await Bun.sleep(300);
const liveChildren = childPids(supervisor.pid);
requireCondition(liveChildren.length === 1, `expected one native server child, found ${liveChildren.join(",") || "none"}`);
requireCondition(reloadEvents.length >= 3, `expected repeated reloads, found ${reloadEvents.length}`);
requireCondition(!output.includes("EADDRINUSE"), "native reload logged EADDRINUSE");
console.log(JSON.stringify({
ok: true,
mode: "native-test",
supervisorPid: supervisor.pid,
serverPid: liveChildren[0],
reloadCount: reloadEvents.length,
reloadPids: reloadEvents.map((event) => event.pid),
atomicSaveMissingFileTolerated: true,
eaddrinuse: false
}));
} finally {
if (displaced) renameSync(displacedPath, watchedPath);
rmSync(displacedPath, { force: true });
utimesSync(watchedPath, originalStat.atime, originalStat.mtime);
if (supervisor.exitCode === null) supervisor.kill("SIGTERM");
await supervisor.exited;
await Promise.all([stdoutTask, stderrTask]);
}
@@ -0,0 +1,67 @@
import { listNativeCases, readNativeAggregate, readNativeEvents, readNativeRun, startNativeRun } from "./caserun-native-fixtures";
const port = Number(process.env.HWLAB_CASERUN_NATIVE_PORT || 4316);
const hostname = process.env.HWLAB_CASERUN_NATIVE_HOST || "127.0.0.1";
function json(data: unknown, status = 200): Response {
return Response.json(data, { status, headers: { "cache-control": "no-store" } });
}
function notFound(kind: string, id?: string): Response {
return json({ ok: false, mode: "native-test", error: { code: `${kind}-not-found`, message: id ? `${kind} ${id} not found` : `${kind} not found` } }, 404);
}
function authSession(): Record<string, unknown> {
return {
ok: true,
authenticated: true,
mode: "native-test",
sessionKind: "native-test",
user: { id: "native-admin", username: "native-admin", displayName: "Native Admin", role: "admin", status: "active" },
capabilities: { admin: "available" },
access: { nav: { profileId: "native-test", allowedIds: ["*"], valuesRedacted: true } },
valuesRedacted: true
};
}
const server = Bun.serve({
hostname,
port,
development: true,
async fetch(request) {
const url = new URL(request.url);
if (request.method === "GET" && url.pathname === "/health") return json({ ok: true, mode: "native-test" });
if (request.method === "POST" && url.pathname === "/auth/login") {
return Response.json(authSession(), { headers: { "cache-control": "no-store", "set-cookie": "hwlab_session=native-test; Path=/; HttpOnly; SameSite=Lax" } });
}
if (request.method === "GET" && url.pathname === "/auth/session") return json(authSession());
if (request.method === "GET" && url.pathname === "/v1/caserun/cases") return json(listNativeCases());
if (request.method === "POST" && url.pathname === "/v1/caserun/runs") {
const body = await request.json().catch(() => null) as { caseId?: string } | null;
if (!body?.caseId) return json({ ok: false, mode: "native-test", error: { code: "case-id-required", message: "caseId is required" } }, 400);
const run = startNativeRun(body.caseId);
return run ? json(run, 202) : notFound("case", body.caseId);
}
const eventsMatch = url.pathname.match(/^\/v1\/caserun\/runs\/([^/]+)\/events$/);
if (request.method === "GET" && eventsMatch) {
const runId = decodeURIComponent(eventsMatch[1]!);
const events = readNativeEvents(runId);
return events ? json(events) : notFound("run", runId);
}
const aggregateMatch = url.pathname.match(/^\/v1\/caserun\/runs\/([^/]+)\/aggregate$/);
if (request.method === "GET" && aggregateMatch) {
const runId = decodeURIComponent(aggregateMatch[1]!);
const aggregate = readNativeAggregate(runId);
return aggregate ? json(aggregate) : notFound("run", runId);
}
const runMatch = url.pathname.match(/^\/v1\/caserun\/runs\/([^/]+)$/);
if (request.method === "GET" && runMatch) {
const runId = decodeURIComponent(runMatch[1]!);
const run = readNativeRun(runId);
return run ? json(run) : notFound("run", runId);
}
return notFound("route", url.pathname);
}
});
console.log(JSON.stringify({ event: "caserun-native-listening", mode: "native-test", hostname: server.hostname, port: server.port }));
@@ -0,0 +1,75 @@
import { mkdirSync, openSync, readFileSync, rmSync } from "node:fs";
import { resolve } from "node:path";
const action = process.argv[2];
const root = process.cwd();
const stateDir = resolve(root, ".state/caserun-native");
const pidPath = resolve(stateDir, "server.pid");
const logPath = resolve(stateDir, "server.log");
function readPid(): number | null {
try {
const pid = Number(readFileSync(pidPath, "utf8").trim());
return Number.isInteger(pid) && pid > 0 ? pid : null;
} catch {
return null;
}
}
function isRunning(pid: number | null): boolean {
if (!pid) return false;
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function waitForExit(pid: number): Promise<boolean> {
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
if (!isRunning(pid)) return true;
await Bun.sleep(25);
}
return !isRunning(pid);
}
mkdirSync(stateDir, { recursive: true });
const pid = readPid();
if (action === "start") {
if (isRunning(pid)) {
console.log(JSON.stringify({ ok: true, action, status: "already-running", pid, logPath }));
process.exit(0);
}
rmSync(pidPath, { force: true });
const logFd = openSync(logPath, "a");
const child = Bun.spawn([process.execPath, "scripts/caserun-native-supervisor.ts"], { cwd: root, stdin: "ignore", stdout: logFd, stderr: logFd });
child.unref();
await Bun.write(pidPath, `${child.pid}\n`);
console.log(JSON.stringify({ ok: true, action, status: "started", pid: child.pid, logPath }));
} else if (action === "stop") {
if (!isRunning(pid)) {
rmSync(pidPath, { force: true });
console.log(JSON.stringify({ ok: true, action, status: "stopped" }));
} else {
process.kill(pid!, "SIGTERM");
if (!await waitForExit(pid!)) {
console.error(JSON.stringify({ ok: false, action, status: "stop-timeout", pid }));
process.exit(1);
}
rmSync(pidPath, { force: true });
console.log(JSON.stringify({ ok: true, action, status: "stopped", pid }));
}
} else if (action === "status") {
const running = isRunning(pid);
console.log(JSON.stringify({ ok: running, action, status: running ? "running" : "stopped", pid: running ? pid : null, logPath }));
process.exit(running ? 0 : 1);
} else if (action === "logs") {
const lines = readFileSync(logPath, "utf8").trimEnd().split("\n").slice(-80);
console.log(lines.join("\n"));
} else {
console.error("usage: bun run scripts/caserun-native-service.ts <start|stop|status|logs>");
process.exit(2);
}
@@ -0,0 +1,47 @@
const baseUrl = process.env.HWLAB_CASERUN_NATIVE_URL || "http://127.0.0.1:4316";
export {};
function requireCondition(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
async function readJson(path: string, init?: RequestInit): Promise<{ response: Response; body: Record<string, unknown> }> {
const response = await fetch(`${baseUrl}${path}`, init);
const body = await response.json();
requireCondition(body && typeof body === "object" && !Array.isArray(body), `invalid JSON object from ${path}`);
return { response, body };
}
const started = await readJson("/v1/caserun/runs", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ caseId: "native-completed" })
});
requireCondition(started.response.status === 202, `start status=${started.response.status}`);
requireCondition(typeof started.body.runId === "string", "start runId missing");
await readJson(`/v1/caserun/runs/${encodeURIComponent(started.body.runId)}`);
const completed = await readJson(`/v1/caserun/runs/${encodeURIComponent(started.body.runId)}`);
requireCondition(completed.body.status === "completed", `run status=${completed.body.status}`);
const aggregate = await readJson(`/v1/caserun/runs/${encodeURIComponent(started.body.runId)}/aggregate`);
requireCondition(aggregate.response.status === 200, `aggregate httpStatus=${aggregate.response.status}`);
requireCondition(aggregate.body.mode === "native-test", `aggregate mode=${aggregate.body.mode}`);
requireCondition(aggregate.body.status === "completed", `aggregate status=${aggregate.body.status}`);
requireCondition(aggregate.body.runId === started.body.runId, `aggregate runId=${aggregate.body.runId}`);
requireCondition(typeof aggregate.body.sha256 === "string" && /^[a-f0-9]{64}$/.test(aggregate.body.sha256), `aggregate sha256=${aggregate.body.sha256}`);
const references = completed.body.references;
requireCondition(references && typeof references === "object" && !Array.isArray(references), "run aggregate reference missing");
const aggregateReference = (references as Record<string, unknown>).aggregate;
requireCondition(aggregateReference && typeof aggregateReference === "object" && !Array.isArray(aggregateReference), "run aggregate reference invalid");
requireCondition(aggregate.body.sha256 === (aggregateReference as Record<string, unknown>).sha256, "aggregate SHA does not match run reference");
console.log(JSON.stringify({
ok: true,
mode: aggregate.body.mode,
path: `/v1/caserun/runs/${started.body.runId}/aggregate`,
status: aggregate.body.status,
runId: aggregate.body.runId,
sha256: aggregate.body.sha256
}));
@@ -0,0 +1,71 @@
import { statSync } from "node:fs";
import { resolve } from "node:path";
const root = process.cwd();
const watchedFiles = [
resolve(root, "scripts/caserun-native-server.ts"),
resolve(root, "scripts/caserun-native-fixtures.ts")
];
let child: ReturnType<typeof Bun.spawn> | null = null;
let signature = sourceSignature();
let stopping = false;
let restarting = false;
function sourceSignature(): string | null {
try {
return watchedFiles.map((path) => {
const source = statSync(path);
return `${path}:${source.mtimeMs}:${source.size}`;
}).join("|");
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") return null;
throw error;
}
}
function startChild(reason: string): void {
const spawned = Bun.spawn([process.execPath, "scripts/caserun-native-server.ts"], { cwd: root, stdin: "ignore", stdout: "inherit", stderr: "inherit" });
child = spawned;
console.log(JSON.stringify({ event: "caserun-native-reload", mode: "native-test", reason, pid: spawned.pid }));
void spawned.exited.then((exitCode) => {
if (!stopping && child === spawned) console.log(JSON.stringify({ event: "caserun-native-exit", exitCode }));
});
}
async function stopChild(): Promise<void> {
const runningChild = child;
if (!runningChild) return;
if (runningChild.exitCode === null) runningChild.kill("SIGTERM");
await runningChild.exited;
if (child === runningChild) child = null;
}
startChild("initial");
async function reloadIfChanged(): Promise<void> {
if (stopping || restarting) return;
const nextSignature = sourceSignature();
if (nextSignature === null || nextSignature === signature) return;
signature = nextSignature;
restarting = true;
try {
await stopChild();
if (!stopping) startChild("source-change");
} finally {
restarting = false;
}
}
const timer = setInterval(() => void reloadIfChanged(), 100);
async function shutdown(): Promise<void> {
if (stopping) return;
stopping = true;
clearInterval(timer);
await stopChild();
process.exit(0);
}
process.on("SIGINT", () => void shutdown());
process.on("SIGTERM", () => void shutdown());
@@ -0,0 +1,36 @@
export default async function caserunNativeSmoke({ page, goto, wait, screenshot, recordStep }) {
await goto("/workbench");
const panel = page.locator("#caserun-panel");
await panel.waitFor({ state: "visible", timeout: 20000 });
await page.locator("#caserun-case-select").selectOption("native-completed");
await page.locator("#caserun-start").click();
await page.locator("#caserun-refresh").click();
await wait(300);
const state = await page.locator("#caserun-status").evaluate((element) => element.textContent || "");
const runId = (await page.locator("#caserun-run-id").textContent())?.trim() || "";
const events = await page.locator("#caserun-event-text").textContent();
const aggregateStatus = (await page.locator("#caserun-aggregate-status").textContent())?.trim() || "";
const aggregateRunId = (await page.locator("#caserun-aggregate-run-id").textContent())?.trim() || "";
const aggregateSha = (await page.locator("#caserun-aggregate-sha").textContent())?.trim() || "";
const conditions = {
mode: state.includes("native-test"),
terminal: state.includes("true"),
aggregateStatus: aggregateStatus === "completed",
aggregateRunId: aggregateRunId === runId && runId.startsWith("native-completed-"),
aggregateSha: /^[a-f0-9]{64}$/.test(aggregateSha),
queuedEvent: events?.includes("queued") === true,
runningEvent: events?.includes("running") === true,
completedEvent: events?.includes("completed") === true
};
const image = await screenshot("caserun-native-completed.png");
recordStep("caserun-native-completed", { ok: Object.values(conditions).every(Boolean), conditions });
return {
ok: Object.values(conditions).every(Boolean),
mode: "native-test",
conditions,
screenshot: image,
finalUrl: page.url(),
valuesRedacted: true
};
}
+3 -2
View File
@@ -1,5 +1,5 @@
import { fetchJson } from "./client";
import type { ApiResult, CaseRunCasesResponse, CaseRunEventsResponse, CaseRunRunResponse } from "@/types";
import type { ApiResult, CaseRunAggregateResponse, CaseRunCasesResponse, CaseRunEventsResponse, CaseRunRunResponse } from "@/types";
export const caserunAPI = {
cases: (): Promise<ApiResult<CaseRunCasesResponse>> => fetchJson("/v1/caserun/cases", { timeoutMs: 12000, timeoutName: "CaseRun cases" }),
@@ -10,5 +10,6 @@ export const caserunAPI = {
timeoutName: "CaseRun start"
}),
run: (runId: string): Promise<ApiResult<CaseRunRunResponse>> => fetchJson(`/v1/caserun/runs/${encodeURIComponent(runId)}`, { timeoutMs: 12000, timeoutName: "CaseRun run" }),
events: (runId: string): Promise<ApiResult<CaseRunEventsResponse>> => fetchJson(`/v1/caserun/runs/${encodeURIComponent(runId)}/events`, { timeoutMs: 12000, timeoutName: "CaseRun events" })
events: (runId: string): Promise<ApiResult<CaseRunEventsResponse>> => fetchJson(`/v1/caserun/runs/${encodeURIComponent(runId)}/events`, { timeoutMs: 12000, timeoutName: "CaseRun events" }),
aggregate: (runId: string): Promise<ApiResult<CaseRunAggregateResponse>> => fetchJson(`/v1/caserun/runs/${encodeURIComponent(runId)}/aggregate`, { timeoutMs: 12000, timeoutName: "CaseRun aggregate" })
};
@@ -18,14 +18,15 @@ const statusTone = computed(() => {
const eventLines = computed(() => caserun.events.map((event) => `[${formatTimestamp(event.at)}] ${event.stage} ${event.status}${event.payload ? ` ${JSON.stringify(event.payload)}` : ""}`));
const artifactCount = computed(() => Number(caserun.currentRun?.facts?.artifactCount ?? 0));
const jobId = computed(() => String(caserun.currentRun?.facts?.jobId ?? ""));
const aggregateHash = computed(() => caserun.currentRun?.references?.aggregate?.sha256 ?? "");
const aggregateHash = computed(() => caserun.aggregate?.sha256 ?? "");
const blockerText = computed(() => caserun.currentRun?.blocker ? `${caserun.currentRun.blocker.code}: ${caserun.currentRun.blocker.summary}` : "");
const fixtureMode = computed(() => String(caserun.aggregate?.mode ?? caserun.currentRun?.mode ?? caserun.cases[0]?.mode ?? "live"));
onMounted(async () => {
await caserun.refreshCases();
selectedCaseId.value = caserun.selectedCaseId;
pollTimer = setInterval(() => {
if (["queued", "running"].includes(runStatus.value)) void caserun.refreshRun();
if (caserun.shouldPoll) void caserun.refreshRun();
}, 5000);
});
@@ -62,8 +63,10 @@ async function startRun(): Promise<void> {
<button id="caserun-refresh" type="button" :disabled="!caserun.currentRun?.runId" @click="caserun.refreshRun()">刷新</button>
</div>
<dl class="caserun-meta" id="caserun-status">
<div><dt>run</dt><dd>{{ caserun.currentRun?.runId || '-' }}</dd></div>
<div><dt>run</dt><dd id="caserun-run-id">{{ caserun.currentRun?.runId || '-' }}</dd></div>
<div><dt>stage</dt><dd>{{ caserun.currentRun?.stage || '-' }}</dd></div>
<div><dt>mode</dt><dd>{{ fixtureMode }}</dd></div>
<div><dt>terminal</dt><dd>{{ caserun.currentRun?.terminal ?? '-' }}</dd></div>
<div><dt>job</dt><dd>{{ jobId || '-' }}</dd></div>
<div><dt>artifacts</dt><dd>{{ artifactCount }}</dd></div>
</dl>
@@ -75,8 +78,9 @@ async function startRun(): Promise<void> {
<pre id="caserun-event-text">{{ eventLines.join('\n') || blockerText || caserun.error || '等待 CaseRun。' }}</pre>
</section>
<section class="caserun-aggregate" id="caserun-aggregate" aria-label="CaseRun aggregate">
<strong>{{ aggregateHash ? aggregateHash.slice(0, 16) : 'aggregate pending' }}</strong>
<span>{{ caserun.currentRun?.status || 'idle' }}</span>
<strong id="caserun-aggregate-sha">{{ aggregateHash || 'aggregate pending' }}</strong>
<span id="caserun-aggregate-status">{{ caserun.aggregate?.status || 'pending' }}</span>
<small id="caserun-aggregate-run-id">{{ caserun.aggregate?.runId || '-' }}</small>
</section>
</aside>
</template>
@@ -0,0 +1,55 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, expect, test, vi } from "vitest";
const { casesRequest, startRequest, runRequest, eventsRequest, aggregateRequest } = vi.hoisted(() => ({
casesRequest: vi.fn(),
startRequest: vi.fn(),
runRequest: vi.fn(),
eventsRequest: vi.fn(),
aggregateRequest: vi.fn()
}));
vi.mock("@/api", () => ({
caserunAPI: { cases: casesRequest, startRun: startRequest, run: runRequest, events: eventsRequest, aggregate: aggregateRequest }
}));
import { useCaseRunStore } from "./caserun";
beforeEach(() => {
setActivePinia(createPinia());
casesRequest.mockReset();
startRequest.mockReset();
runRequest.mockReset();
eventsRequest.mockReset();
aggregateRequest.mockReset();
});
test("polling follows API terminal instead of status names", async () => {
runRequest.mockResolvedValue({ ok: true, data: { runId: "run-1", caseId: "native-completed", status: "custom-progress", stage: "agent-running", terminal: false } });
eventsRequest.mockResolvedValue({ ok: true, data: { runId: "run-1", events: [] } });
aggregateRequest.mockResolvedValue({ ok: true, data: { runId: "run-1", status: "custom-progress", sha256: "progress-sha" } });
const store = useCaseRunStore();
await store.refreshRun("run-1");
expect(store.shouldPoll).toBe(true);
runRequest.mockResolvedValue({ ok: true, data: { runId: "run-1", caseId: "native-completed", status: "running", stage: "completed", terminal: true } });
await store.refreshRun("run-1");
expect(store.shouldPoll).toBe(false);
});
test("start keeps blocker and independent aggregate from API read models", async () => {
startRequest.mockResolvedValue({ ok: true, data: { runId: "run-2", caseId: "native-blocked", status: "queued", stage: "queued", terminal: false } });
runRequest.mockResolvedValue({ ok: true, data: { runId: "run-2", caseId: "native-blocked", status: "blocked", stage: "preparing", terminal: true, blocker: { code: "native-capability-blocked", summary: "blocked" }, references: { aggregate: { sha256: "embedded-sha" } } } });
eventsRequest.mockResolvedValue({ ok: true, data: { runId: "run-2", events: [{ status: "blocked", stage: "preparing" }] } });
aggregateRequest.mockResolvedValue({ ok: true, data: { mode: "native-test", runId: "run-2", status: "blocked", sha256: "api-aggregate-sha" } });
const store = useCaseRunStore();
await store.startRun("native-blocked");
expect(store.currentRun?.blocker?.code).toBe("native-capability-blocked");
expect(store.aggregate?.runId).toBe("run-2");
expect(store.aggregate?.status).toBe("blocked");
expect(store.aggregate?.sha256).toBe("api-aggregate-sha");
expect(store.shouldPoll).toBe(false);
});
+12 -3
View File
@@ -1,16 +1,18 @@
import { computed, ref } from "vue";
import { defineStore } from "pinia";
import { caserunAPI } from "@/api";
import type { CaseRunCaseSummary, CaseRunEvent, CaseRunRunResponse } from "@/types";
import type { CaseRunAggregateResponse, CaseRunCaseSummary, CaseRunEvent, CaseRunRunResponse } from "@/types";
export const useCaseRunStore = defineStore("caserun", () => {
const cases = ref<CaseRunCaseSummary[]>([]);
const currentRun = ref<CaseRunRunResponse | null>(null);
const events = ref<CaseRunEvent[]>([]);
const aggregate = ref<CaseRunAggregateResponse | null>(null);
const loading = ref(false);
const starting = ref(false);
const error = ref<string | null>(null);
const selectedCaseId = computed(() => currentRun.value?.caseId ?? cases.value[0]?.caseId ?? "");
const shouldPoll = computed(() => Boolean(currentRun.value?.runId && currentRun.value.terminal === false));
async function refreshCases(): Promise<void> {
loading.value = true;
@@ -25,6 +27,7 @@ export const useCaseRunStore = defineStore("caserun", () => {
starting.value = true;
error.value = null;
events.value = [];
aggregate.value = null;
const result = await caserunAPI.startRun(caseId);
starting.value = false;
if (!result.ok || !result.data) {
@@ -44,7 +47,7 @@ export const useCaseRunStore = defineStore("caserun", () => {
return;
}
currentRun.value = result.data;
await refreshEvents(runId);
await Promise.all([refreshEvents(runId), refreshAggregate(runId)]);
}
async function refreshEvents(runId = currentRun.value?.runId ?? ""): Promise<void> {
@@ -53,5 +56,11 @@ export const useCaseRunStore = defineStore("caserun", () => {
if (result.ok && result.data?.runId === runId) events.value = result.data.events ?? [];
}
return { cases, currentRun, events, loading, starting, error, selectedCaseId, refreshCases, startRun, refreshRun, refreshEvents };
async function refreshAggregate(runId = currentRun.value?.runId ?? ""): Promise<void> {
if (!runId) return;
const result = await caserunAPI.aggregate(runId);
if (result.ok && result.data?.runId === runId) aggregate.value = result.data;
}
return { cases, currentRun, events, aggregate, loading, starting, error, selectedCaseId, shouldPoll, refreshCases, startRun, refreshRun, refreshEvents, refreshAggregate };
});
+4 -3
View File
@@ -516,12 +516,13 @@ export interface HwlabNodeUpdateMetadata {
observedAt: string;
}
export interface CaseRunCaseSummary { caseId: string; title?: string; mode?: string; available?: boolean; hwpodSpec?: string; subject?: Record<string, unknown> | null; expected?: Record<string, unknown> | null; runtime?: Record<string, unknown> | null; [key: string]: unknown }
export interface CaseRunCasesResponse { ok?: boolean; contractVersion?: string; count?: number; cases?: CaseRunCaseSummary[]; caseAuthority?: Record<string, unknown>; [key: string]: unknown }
export interface CaseRunCasesResponse { ok?: boolean; mode?: string; contractVersion?: string; count?: number; cases?: CaseRunCaseSummary[]; caseAuthority?: Record<string, unknown>; [key: string]: unknown }
export interface CaseRunEvent { at?: string; status?: string; stage?: string; payload?: Record<string, unknown>; [key: string]: unknown }
export interface CaseRunBlocker { code: string; summary: string; layer?: string | null; details?: Record<string, unknown> | null }
export interface CaseRunReference { href?: string; ref?: string | null; sha256?: string | null; count?: number; relationship?: Record<string, unknown> | null; [key: string]: unknown }
export interface CaseRunRunResponse { ok?: boolean; contractVersion?: string; runId?: string; caseId?: string; status?: string; stage?: string; terminal?: boolean; blocker?: CaseRunBlocker | null; createdAt?: string; updatedAt?: string; facts?: { jobId?: string | null; artifactCount?: number; evidenceStatus?: string | null }; references?: { status?: CaseRunReference; events?: CaseRunReference; aggregate?: CaseRunReference; manifest?: CaseRunReference | null; replay?: CaseRunReference | null; trace?: Record<string, unknown> | null; hwpod?: Record<string, unknown> | null }; sourceAuthority?: Record<string, unknown>; error?: { code?: string; message?: string } | null; statusUrl?: string; [key: string]: unknown }
export interface CaseRunEventsResponse { ok?: boolean; contractVersion?: string; runId?: string; caseId?: string; status?: string; stage?: string; terminal?: boolean; sourceAuthority?: Record<string, unknown>; events?: CaseRunEvent[]; [key: string]: unknown }
export interface CaseRunRunResponse { ok?: boolean; mode?: string; contractVersion?: string; runId?: string; caseId?: string; status?: string; stage?: string; terminal?: boolean; blocker?: CaseRunBlocker | null; createdAt?: string; updatedAt?: string; facts?: { jobId?: string | null; artifactCount?: number; evidenceStatus?: string | null }; references?: { status?: CaseRunReference; events?: CaseRunReference; aggregate?: CaseRunReference; manifest?: CaseRunReference | null; replay?: CaseRunReference | null; trace?: Record<string, unknown> | null; hwpod?: Record<string, unknown> | null }; sourceAuthority?: Record<string, unknown>; error?: { code?: string; message?: string } | null; statusUrl?: string; [key: string]: unknown }
export interface CaseRunEventsResponse { ok?: boolean; mode?: string; contractVersion?: string; runId?: string; caseId?: string; status?: string; stage?: string; terminal?: boolean; sourceAuthority?: Record<string, unknown>; events?: CaseRunEvent[]; [key: string]: unknown }
export interface CaseRunAggregateResponse { ok?: boolean; mode?: string; contractVersion?: string; runId?: string; caseId?: string; status?: string; stage?: string; terminal?: boolean; sourceAuthority?: Record<string, unknown>; sha256?: string; summary?: Record<string, unknown> | null; evidence?: Record<string, unknown> | null; run?: Record<string, unknown> | null; [key: string]: unknown }
export interface ProviderProfilesResponse { ok?: boolean; status?: string; profiles?: Array<Record<string, unknown>>; [key: string]: unknown }
export interface ProviderProfileCatalogItem {
profile: string;
+33 -3
View File
@@ -1,3 +1,4 @@
import { Agent } from "node:http";
import { fileURLToPath, URL } from "node:url";
import vue from "@vitejs/plugin-vue";
@@ -5,7 +6,15 @@ import { defineConfig, loadEnv } from "vite";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const nativeApiUrl = String(env.WORKBENCH_NATIVE_API_URL ?? "").trim();
const nativeApiUrl = String(process.env.WORKBENCH_NATIVE_API_URL ?? env.WORKBENCH_NATIVE_API_URL ?? "").trim();
const nativeCaseRunEnabled = (process.env.HWLAB_CASERUN_NATIVE_TEST ?? env.HWLAB_CASERUN_NATIVE_TEST) === "1";
const nativeCaseRunTarget = process.env.HWLAB_CASERUN_NATIVE_URL ?? env.HWLAB_CASERUN_NATIVE_URL ?? "http://127.0.0.1:4316";
const nativeCaseRunAgent = new Agent({ keepAlive: true });
if (nativeApiUrl && nativeCaseRunEnabled) {
throw new Error("Workbench and CaseRun native modes cannot share one Vite server");
}
return {
plugins: [vue()],
resolve: {
@@ -17,8 +26,29 @@ export default defineConfig(({ mode }) => {
host: env.WORKBENCH_WEB_HOST || "127.0.0.1",
port: Number(env.WORKBENCH_WEB_PORT || 5173),
strictPort: true,
watch: env.WORKBENCH_VITE_USE_POLLING === "1" ? { usePolling: true, interval: 500 } : undefined,
proxy: nativeApiUrl ? { "/v1": { target: nativeApiUrl, changeOrigin: false }, "/auth": { target: nativeApiUrl, changeOrigin: false }, "/health": { target: nativeApiUrl, changeOrigin: false } } : undefined
watch: nativeCaseRunEnabled || env.WORKBENCH_VITE_USE_POLLING === "1"
? { usePolling: true, interval: nativeCaseRunEnabled ? 300 : 500 }
: undefined,
proxy: nativeCaseRunEnabled
? {
"/auth": {
target: nativeCaseRunTarget,
changeOrigin: false,
agent: nativeCaseRunAgent
},
"/v1/caserun": {
target: nativeCaseRunTarget,
changeOrigin: false,
agent: nativeCaseRunAgent
}
}
: nativeApiUrl
? {
"/v1": { target: nativeApiUrl, changeOrigin: false },
"/auth": { target: nativeApiUrl, changeOrigin: false },
"/health": { target: nativeApiUrl, changeOrigin: false }
}
: undefined
},
build: {
outDir: "dist",